Skip to main content

aft/github_read/
normalize.rs

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/// A structured-response error. The fetch layer maps this into a typed,
13/// redacted user-facing fetch failure.
14#[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
25/// Normalize `gh --json` or GraphQL JSON into the renderer's stable model.
26///
27/// The function accepts the ordinary GitHub CLI spelling as well as the
28/// `data.repository.issueOrPullRequest` GraphQL envelope used by shims. It
29/// never accepts display-oriented text; malformed structured data is an error.
30pub 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        // Shims that retain review comments on each review do not need a second
103        // top-level section field. Normalize that representation here too.
104        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
320/// Normalize the selected state-changing event records from one or more pages
321/// returned by `gh api --paginate --slurp`.
322pub 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}