1use std::fmt;
2
3use serde_json::Value;
4use url::Url;
5
6use super::model::{
7 GithubComment, GithubDocument, GithubDocumentKind, GithubPullRequestFile, GithubReaction,
8 GithubReview, GithubReviewCommentSection, GithubTimelineEvent,
9};
10use super::resource::{GithubResource, GithubResourceKind};
11
12#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct NormalizeError(pub String);
16
17impl fmt::Display for NormalizeError {
18 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
19 formatter.write_str(&self.0)
20 }
21}
22
23impl std::error::Error for NormalizeError {}
24
25pub fn normalize_structured_document(
31 resource: &GithubResource,
32 json: &Value,
33) -> Result<GithubDocument, NormalizeError> {
34 let root = document_value(json).ok_or_else(|| {
35 NormalizeError("GitHub returned structured data without a resource document".to_string())
36 })?;
37 let repository = resolved_repository(json, root).ok_or_else(|| {
38 NormalizeError(
39 "GitHub structured response did not identify the resolved owner/repository".to_string(),
40 )
41 })?;
42 let number = value_u64(root, "number").unwrap_or(resource.number);
43 if number != resource.number {
44 return Err(NormalizeError(format!(
45 "GitHub returned resource #{number} for requested #{}",
46 resource.number
47 )));
48 }
49
50 let kind = match resource.kind {
51 GithubResourceKind::Issue => GithubDocumentKind::Issue,
52 GithubResourceKind::PullRequest => GithubDocumentKind::PullRequest,
53 };
54 let comments = comments_from(root.get("comments"));
55 let comments_total_count = total_count(root.get("comments"));
56 let minimized_comments_count = value_usize(root, "minimizedCommentsCount")
57 .or_else(|| value_usize(root, "minimized_comments_count"));
58
59 let mut document = GithubDocument {
60 repository,
61 kind,
62 number,
63 title: value_string(root, "title").unwrap_or_default(),
64 state: value_string(root, "state").unwrap_or_else(|| "UNKNOWN".to_string()),
65 author: actor_login(root.get("author")).or_else(|| value_string(root, "author")),
66 created_at: value_string(root, "createdAt").or_else(|| value_string(root, "created_at")),
67 updated_at: value_string(root, "updatedAt").or_else(|| value_string(root, "updated_at")),
68 labels: labels_from(root.get("labels")),
69 assignees: actors_from(root.get("assignees")),
70 milestone: milestone_from(root.get("milestone")),
71 body: value_string(root, "body").unwrap_or_default(),
72 reactions: reactions_from(
73 root.get("reactionGroups")
74 .or_else(|| root.get("reaction_groups"))
75 .or_else(|| root.get("reactions")),
76 ),
77 comments,
78 comments_total_count,
79 minimized_comments_count,
80 files: Vec::new(),
81 reviews: Vec::new(),
82 review_comment_sections: Vec::new(),
83 base_ref_name: None,
84 head_ref_name: None,
85 review_decision: None,
86 timeline: Vec::new(),
87 };
88
89 if resource.kind == GithubResourceKind::PullRequest {
90 document.files = files_from(root.get("files"));
91 document.reviews = reviews_from(root.get("reviews"));
92 document.base_ref_name =
93 value_string(root, "baseRefName").or_else(|| value_string(root, "base_ref_name"));
94 document.head_ref_name =
95 value_string(root, "headRefName").or_else(|| value_string(root, "head_ref_name"));
96 document.review_decision =
97 value_string(root, "reviewDecision").or_else(|| value_string(root, "review_decision"));
98 document.review_comment_sections = review_comment_sections_from(
99 root.get("reviewCommentSections")
100 .or_else(|| root.get("review_comment_sections")),
101 );
102 if document.review_comment_sections.is_empty() {
105 document.review_comment_sections = document
106 .reviews
107 .iter()
108 .filter(|review| {
109 !review.comments.is_empty() || review.comments_total_count.is_some()
110 })
111 .map(|review| GithubReviewCommentSection {
112 author: review.author.clone(),
113 submitted_at: review.submitted_at.clone(),
114 comments: review.comments.clone(),
115 comments_total_count: review.comments_total_count,
116 minimized_comments_count: None,
117 })
118 .collect();
119 }
120 }
121
122 Ok(document)
123}
124
125fn document_value(json: &Value) -> Option<&Value> {
126 json.pointer("/data/repository/issueOrPullRequest")
127 .or_else(|| json.pointer("/data/repository/pullRequest"))
128 .or_else(|| json.pointer("/data/repository/issue"))
129 .or_else(|| json.pointer("/data/resource"))
130 .or_else(|| json.get("resource"))
131 .or_else(|| json.as_object().map(|_| json))
132}
133
134fn resolved_repository(json: &Value, root: &Value) -> Option<String> {
135 repository_from_value(root.get("repository"))
136 .or_else(|| repository_from_value(json.pointer("/data/repository")))
137 .or_else(|| repository_from_url(root.get("url").and_then(Value::as_str)))
138 .or_else(|| repository_from_url(json.get("url").and_then(Value::as_str)))
139}
140
141fn repository_from_value(value: Option<&Value>) -> Option<String> {
142 let value = value?;
143 if let Some(value) = value.as_str() {
144 return normalized_repository(value);
145 }
146 for name in ["nameWithOwner", "name_with_owner", "fullName", "full_name"] {
147 if let Some(repository) = value.get(name).and_then(Value::as_str) {
148 return normalized_repository(repository);
149 }
150 }
151 let owner = actor_login(value.get("owner"))?;
152 let name = value.get("name").and_then(Value::as_str)?;
153 normalized_repository(&format!("{owner}/{name}"))
154}
155
156fn repository_from_url(value: Option<&str>) -> Option<String> {
157 let url = Url::parse(value?).ok()?;
158 if url.scheme() != "https" || url.host_str()? != "github.com" {
159 return None;
160 }
161 let mut segments = url.path_segments()?;
162 let owner = segments.next()?;
163 let repository = segments.next()?;
164 normalized_repository(&format!("{owner}/{repository}"))
165}
166
167fn normalized_repository(value: &str) -> Option<String> {
168 let mut parts = value.split('/');
169 let owner = parts.next()?.trim();
170 let repository = parts.next()?.trim();
171 if parts.next().is_some() || !valid_component(owner) || !valid_component(repository) {
172 return None;
173 }
174 Some(format!("{owner}/{repository}"))
175}
176
177fn valid_component(value: &str) -> bool {
178 !value.is_empty()
179 && value
180 .bytes()
181 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
182}
183
184fn collection_nodes(value: Option<&Value>) -> Vec<&Value> {
185 match value {
186 Some(Value::Array(values)) => values.iter().collect(),
187 Some(Value::Object(values)) => values
188 .get("nodes")
189 .or_else(|| values.get("items"))
190 .or_else(|| values.get("edges"))
191 .and_then(Value::as_array)
192 .map(|items| {
193 items
194 .iter()
195 .filter_map(|item| item.get("node").or(Some(item)))
196 .collect()
197 })
198 .unwrap_or_default(),
199 _ => Vec::new(),
200 }
201}
202
203fn total_count(value: Option<&Value>) -> Option<usize> {
204 value.and_then(|value| {
205 value
206 .get("totalCount")
207 .or_else(|| value.get("total_count"))
208 .and_then(Value::as_u64)
209 .and_then(|count| usize::try_from(count).ok())
210 })
211}
212
213fn comments_from(value: Option<&Value>) -> Vec<GithubComment> {
214 collection_nodes(value)
215 .into_iter()
216 .map(comment_from)
217 .collect()
218}
219
220fn comment_from(value: &Value) -> GithubComment {
221 GithubComment {
222 author: actor_login(value.get("author")).or_else(|| value_string(value, "author")),
223 body: value_string(value, "body").unwrap_or_default(),
224 url: value_string(value, "url"),
225 created_at: value_string(value, "createdAt").or_else(|| value_string(value, "created_at")),
226 updated_at: value_string(value, "updatedAt").or_else(|| value_string(value, "updated_at")),
227 minimized: value
228 .get("isMinimized")
229 .or_else(|| value.get("minimized"))
230 .and_then(Value::as_bool)
231 .unwrap_or(false),
232 path: value_string(value, "path"),
233 line: value_u64(value, "line").or_else(|| value_u64(value, "originalLine")),
234 }
235}
236
237fn labels_from(value: Option<&Value>) -> Vec<String> {
238 collection_nodes(value)
239 .into_iter()
240 .filter_map(|label| {
241 value_string(label, "name").or_else(|| label.as_str().map(str::to_owned))
242 })
243 .collect()
244}
245
246fn actors_from(value: Option<&Value>) -> Vec<String> {
247 collection_nodes(value)
248 .into_iter()
249 .filter_map(|actor| actor_login(Some(actor)).or_else(|| actor.as_str().map(str::to_owned)))
250 .collect()
251}
252
253fn milestone_from(value: Option<&Value>) -> Option<String> {
254 let value = value?;
255 value_string(value, "title").or_else(|| value.as_str().map(str::to_owned))
256}
257
258fn reactions_from(value: Option<&Value>) -> Vec<GithubReaction> {
259 collection_nodes(value)
260 .into_iter()
261 .filter_map(|reaction| {
262 let count = reaction
263 .get("users")
264 .and_then(|users| users.as_u64().or_else(|| value_u64(users, "totalCount")))
265 .or_else(|| value_u64(reaction, "count"))
266 .or_else(|| value_u64(reaction, "totalCount"))
267 .unwrap_or(0);
268 let content =
269 value_string(reaction, "content").or_else(|| value_string(reaction, "name"))?;
270 (count > 0).then_some(GithubReaction { content, count })
271 })
272 .collect()
273}
274
275fn files_from(value: Option<&Value>) -> Vec<GithubPullRequestFile> {
276 collection_nodes(value)
277 .into_iter()
278 .filter_map(|file| {
279 let path = value_string(file, "path").or_else(|| value_string(file, "name"))?;
280 Some(GithubPullRequestFile {
281 path,
282 additions: value_u64(file, "additions"),
283 deletions: value_u64(file, "deletions"),
284 status: value_string(file, "status"),
285 })
286 })
287 .collect()
288}
289
290fn reviews_from(value: Option<&Value>) -> Vec<GithubReview> {
291 collection_nodes(value)
292 .into_iter()
293 .map(|review| GithubReview {
294 author: actor_login(review.get("author")).or_else(|| value_string(review, "author")),
295 body: value_string(review, "body").unwrap_or_default(),
296 state: value_string(review, "state"),
297 submitted_at: value_string(review, "submittedAt")
298 .or_else(|| value_string(review, "submitted_at")),
299 comments: comments_from(review.get("comments")),
300 comments_total_count: total_count(review.get("comments")),
301 })
302 .collect()
303}
304
305fn review_comment_sections_from(value: Option<&Value>) -> Vec<GithubReviewCommentSection> {
306 collection_nodes(value)
307 .into_iter()
308 .map(|section| GithubReviewCommentSection {
309 author: actor_login(section.get("author")).or_else(|| value_string(section, "author")),
310 submitted_at: value_string(section, "submittedAt")
311 .or_else(|| value_string(section, "submitted_at")),
312 comments: comments_from(section.get("comments")),
313 comments_total_count: total_count(section.get("comments")),
314 minimized_comments_count: value_usize(section, "minimizedCommentsCount")
315 .or_else(|| value_usize(section, "minimized_comments_count")),
316 })
317 .collect()
318}
319
320pub fn normalize_timeline_events(json: &Value) -> Vec<GithubTimelineEvent> {
323 let mut values = Vec::new();
324 collect_timeline_values(json, &mut values);
325 values.into_iter().filter_map(timeline_event_from).collect()
326}
327
328fn collect_timeline_values<'a>(value: &'a Value, values: &mut Vec<&'a Value>) {
329 match value {
330 Value::Array(items) => {
331 for item in items {
332 collect_timeline_values(item, values);
333 }
334 }
335 Value::Object(object) if object.contains_key("event") => values.push(value),
336 Value::Object(object) => {
337 if let Some(events) = object.get("events") {
338 collect_timeline_values(events, values);
339 }
340 }
341 _ => {}
342 }
343}
344
345fn timeline_event_from(value: &Value) -> Option<GithubTimelineEvent> {
346 let event = value_string(value, "event")?;
347 if !matches!(
348 event.as_str(),
349 "closed"
350 | "reopened"
351 | "merged"
352 | "labeled"
353 | "unlabeled"
354 | "assigned"
355 | "unassigned"
356 | "milestoned"
357 | "demilestoned"
358 | "renamed"
359 | "review_requested"
360 | "ready_for_review"
361 | "converted_to_draft"
362 ) {
363 return None;
364 }
365 let rename = value.get("rename");
366 Some(GithubTimelineEvent {
367 actor: actor_login(value.get("actor")),
368 event,
369 created_at: value_string(value, "created_at").or_else(|| value_string(value, "createdAt")),
370 label: value
371 .get("label")
372 .and_then(|label| value_string(label, "name")),
373 assignee: actor_login(value.get("assignee")),
374 milestone: value
375 .get("milestone")
376 .and_then(|milestone| value_string(milestone, "title")),
377 rename_from: rename.and_then(|rename| value_string(rename, "from")),
378 rename_to: rename.and_then(|rename| value_string(rename, "to")),
379 requested_reviewer: actor_login(value.get("requested_reviewer")),
380 commit_id: value_string(value, "commit_id").or_else(|| value_string(value, "commitId")),
381 })
382}
383
384fn actor_login(value: Option<&Value>) -> Option<String> {
385 let value = value?;
386 value_string(value, "login")
387 .or_else(|| value_string(value, "name"))
388 .or_else(|| value.as_str().map(str::to_owned))
389}
390
391fn value_string(value: &Value, key: &str) -> Option<String> {
392 value.get(key)?.as_str().map(str::to_owned)
393}
394
395fn value_u64(value: &Value, key: &str) -> Option<u64> {
396 value.get(key)?.as_u64()
397}
398
399fn value_usize(value: &Value, key: &str) -> Option<usize> {
400 value_u64(value, key).and_then(|value| usize::try_from(value).ok())
401}
402
403#[cfg(test)]
404mod tests {
405 use serde_json::json;
406
407 use super::*;
408 use crate::github_read::resource::GithubResourceKind;
409
410 #[test]
411 fn normalizes_cli_json_without_human_output_parsing() {
412 let resource = GithubResource {
413 kind: GithubResourceKind::Issue,
414 number: 7,
415 repository: None,
416 comment_selector: None,
417 };
418 let document = normalize_structured_document(
419 &resource,
420 &json!({
421 "url": "https://github.com/CortexKit/aft/issues/7",
422 "number": 7,
423 "title": "Fixture issue",
424 "state": "OPEN",
425 "author": { "login": "octo" },
426 "comments": { "totalCount": 2, "nodes": [
427 { "author": { "login": "reviewer" }, "body": "one", "createdAt": "2026-01-01T00:00:00Z" }
428 ] },
429 "reactionGroups": [{ "content": "THUMBS_UP", "users": 2 }]
430 }),
431 )
432 .unwrap();
433
434 assert_eq!(document.repository, "CortexKit/aft");
435 assert_eq!(document.comments_total_count, Some(2));
436 assert_eq!(document.reactions[0].count, 2);
437 }
438
439 #[test]
440 fn timeline_fixture_keeps_selected_events_and_inline_review_locations() {
441 let resource = GithubResource {
442 kind: GithubResourceKind::PullRequest,
443 number: 999,
444 repository: Some("cortexkit/aft".to_string()),
445 comment_selector: None,
446 };
447 let primary = serde_json::from_str(include_str!("fixtures/pr-999-timeline.json"))
448 .expect("parse timeline PR fixture");
449 let review_comments = serde_json::from_str(include_str!(
450 "fixtures/pr-999-timeline-review-comments.json"
451 ))
452 .expect("parse timeline review comment fixture");
453 let timeline = serde_json::from_str(include_str!("fixtures/pr-999-timeline-events.json"))
454 .expect("parse timeline event fixture");
455
456 let document = normalize_structured_document(&resource, &primary)
457 .expect("normalize timeline PR fixture");
458 let review_document = normalize_structured_document(&resource, &review_comments)
459 .expect("normalize inline review fixture");
460 let events = normalize_timeline_events(&timeline);
461
462 assert_eq!(document.comments.len(), 2);
463 assert_eq!(document.reviews.len(), 2);
464 assert_eq!(
465 review_document.review_comment_sections[0].comments[1]
466 .path
467 .as_deref(),
468 Some("src/timeline.rs")
469 );
470 assert_eq!(
471 review_document.review_comment_sections[0].comments[1].line,
472 Some(24)
473 );
474 assert_eq!(
475 events
476 .iter()
477 .map(|event| event.event.as_str())
478 .collect::<Vec<_>>(),
479 ["labeled", "closed", "reopened"]
480 );
481 assert_eq!(events[1].actor.as_deref(), Some("aft-alfonso[bot]"));
482 }
483}