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,
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    };
84
85    if resource.kind == GithubResourceKind::PullRequest {
86        document.files = files_from(root.get("files"));
87        document.reviews = reviews_from(root.get("reviews"));
88        document.review_comment_sections = review_comment_sections_from(
89            root.get("reviewCommentSections")
90                .or_else(|| root.get("review_comment_sections")),
91        );
92        // Shims that retain review comments on each review do not need a second
93        // top-level section field. Normalize that representation here too.
94        if document.review_comment_sections.is_empty() {
95            document.review_comment_sections = document
96                .reviews
97                .iter()
98                .filter(|review| {
99                    !review.comments.is_empty() || review.comments_total_count.is_some()
100                })
101                .map(|review| GithubReviewCommentSection {
102                    author: review.author.clone(),
103                    submitted_at: review.submitted_at.clone(),
104                    comments: review.comments.clone(),
105                    comments_total_count: review.comments_total_count,
106                    minimized_comments_count: None,
107                })
108                .collect();
109        }
110    }
111
112    Ok(document)
113}
114
115fn document_value(json: &Value) -> Option<&Value> {
116    json.pointer("/data/repository/issueOrPullRequest")
117        .or_else(|| json.pointer("/data/repository/pullRequest"))
118        .or_else(|| json.pointer("/data/repository/issue"))
119        .or_else(|| json.pointer("/data/resource"))
120        .or_else(|| json.get("resource"))
121        .or_else(|| json.as_object().map(|_| json))
122}
123
124fn resolved_repository(json: &Value, root: &Value) -> Option<String> {
125    repository_from_value(root.get("repository"))
126        .or_else(|| repository_from_value(json.pointer("/data/repository")))
127        .or_else(|| repository_from_url(root.get("url").and_then(Value::as_str)))
128        .or_else(|| repository_from_url(json.get("url").and_then(Value::as_str)))
129}
130
131fn repository_from_value(value: Option<&Value>) -> Option<String> {
132    let value = value?;
133    if let Some(value) = value.as_str() {
134        return normalized_repository(value);
135    }
136    for name in ["nameWithOwner", "name_with_owner", "fullName", "full_name"] {
137        if let Some(repository) = value.get(name).and_then(Value::as_str) {
138            return normalized_repository(repository);
139        }
140    }
141    let owner = actor_login(value.get("owner"))?;
142    let name = value.get("name").and_then(Value::as_str)?;
143    normalized_repository(&format!("{owner}/{name}"))
144}
145
146fn repository_from_url(value: Option<&str>) -> Option<String> {
147    let url = Url::parse(value?).ok()?;
148    if url.scheme() != "https" || url.host_str()? != "github.com" {
149        return None;
150    }
151    let mut segments = url.path_segments()?;
152    let owner = segments.next()?;
153    let repository = segments.next()?;
154    normalized_repository(&format!("{owner}/{repository}"))
155}
156
157fn normalized_repository(value: &str) -> Option<String> {
158    let mut parts = value.split('/');
159    let owner = parts.next()?.trim();
160    let repository = parts.next()?.trim();
161    if parts.next().is_some() || !valid_component(owner) || !valid_component(repository) {
162        return None;
163    }
164    Some(format!("{owner}/{repository}"))
165}
166
167fn valid_component(value: &str) -> bool {
168    !value.is_empty()
169        && value
170            .bytes()
171            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
172}
173
174fn collection_nodes(value: Option<&Value>) -> Vec<&Value> {
175    match value {
176        Some(Value::Array(values)) => values.iter().collect(),
177        Some(Value::Object(values)) => values
178            .get("nodes")
179            .or_else(|| values.get("items"))
180            .or_else(|| values.get("edges"))
181            .and_then(Value::as_array)
182            .map(|items| {
183                items
184                    .iter()
185                    .filter_map(|item| item.get("node").or(Some(item)))
186                    .collect()
187            })
188            .unwrap_or_default(),
189        _ => Vec::new(),
190    }
191}
192
193fn total_count(value: Option<&Value>) -> Option<usize> {
194    value.and_then(|value| {
195        value
196            .get("totalCount")
197            .or_else(|| value.get("total_count"))
198            .and_then(Value::as_u64)
199            .and_then(|count| usize::try_from(count).ok())
200    })
201}
202
203fn comments_from(value: Option<&Value>) -> Vec<GithubComment> {
204    collection_nodes(value)
205        .into_iter()
206        .map(comment_from)
207        .collect()
208}
209
210fn comment_from(value: &Value) -> GithubComment {
211    GithubComment {
212        author: actor_login(value.get("author")).or_else(|| value_string(value, "author")),
213        body: value_string(value, "body").unwrap_or_default(),
214        created_at: value_string(value, "createdAt").or_else(|| value_string(value, "created_at")),
215        updated_at: value_string(value, "updatedAt").or_else(|| value_string(value, "updated_at")),
216        minimized: value
217            .get("isMinimized")
218            .or_else(|| value.get("minimized"))
219            .and_then(Value::as_bool)
220            .unwrap_or(false),
221    }
222}
223
224fn labels_from(value: Option<&Value>) -> Vec<String> {
225    collection_nodes(value)
226        .into_iter()
227        .filter_map(|label| {
228            value_string(label, "name").or_else(|| label.as_str().map(str::to_owned))
229        })
230        .collect()
231}
232
233fn actors_from(value: Option<&Value>) -> Vec<String> {
234    collection_nodes(value)
235        .into_iter()
236        .filter_map(|actor| actor_login(Some(actor)).or_else(|| actor.as_str().map(str::to_owned)))
237        .collect()
238}
239
240fn milestone_from(value: Option<&Value>) -> Option<String> {
241    let value = value?;
242    value_string(value, "title").or_else(|| value.as_str().map(str::to_owned))
243}
244
245fn reactions_from(value: Option<&Value>) -> Vec<GithubReaction> {
246    collection_nodes(value)
247        .into_iter()
248        .filter_map(|reaction| {
249            let count = reaction
250                .get("users")
251                .and_then(|users| users.as_u64().or_else(|| value_u64(users, "totalCount")))
252                .or_else(|| value_u64(reaction, "count"))
253                .or_else(|| value_u64(reaction, "totalCount"))
254                .unwrap_or(0);
255            let content =
256                value_string(reaction, "content").or_else(|| value_string(reaction, "name"))?;
257            (count > 0).then_some(GithubReaction { content, count })
258        })
259        .collect()
260}
261
262fn files_from(value: Option<&Value>) -> Vec<GithubPullRequestFile> {
263    collection_nodes(value)
264        .into_iter()
265        .filter_map(|file| {
266            let path = value_string(file, "path").or_else(|| value_string(file, "name"))?;
267            Some(GithubPullRequestFile {
268                path,
269                additions: value_u64(file, "additions"),
270                deletions: value_u64(file, "deletions"),
271                status: value_string(file, "status"),
272            })
273        })
274        .collect()
275}
276
277fn reviews_from(value: Option<&Value>) -> Vec<GithubReview> {
278    collection_nodes(value)
279        .into_iter()
280        .map(|review| GithubReview {
281            author: actor_login(review.get("author")).or_else(|| value_string(review, "author")),
282            body: value_string(review, "body").unwrap_or_default(),
283            state: value_string(review, "state"),
284            submitted_at: value_string(review, "submittedAt")
285                .or_else(|| value_string(review, "submitted_at")),
286            comments: comments_from(review.get("comments")),
287            comments_total_count: total_count(review.get("comments")),
288        })
289        .collect()
290}
291
292fn review_comment_sections_from(value: Option<&Value>) -> Vec<GithubReviewCommentSection> {
293    collection_nodes(value)
294        .into_iter()
295        .map(|section| GithubReviewCommentSection {
296            author: actor_login(section.get("author")).or_else(|| value_string(section, "author")),
297            submitted_at: value_string(section, "submittedAt")
298                .or_else(|| value_string(section, "submitted_at")),
299            comments: comments_from(section.get("comments")),
300            comments_total_count: total_count(section.get("comments")),
301            minimized_comments_count: value_usize(section, "minimizedCommentsCount")
302                .or_else(|| value_usize(section, "minimized_comments_count")),
303        })
304        .collect()
305}
306
307fn actor_login(value: Option<&Value>) -> Option<String> {
308    let value = value?;
309    value_string(value, "login")
310        .or_else(|| value_string(value, "name"))
311        .or_else(|| value.as_str().map(str::to_owned))
312}
313
314fn value_string(value: &Value, key: &str) -> Option<String> {
315    value.get(key)?.as_str().map(str::to_owned)
316}
317
318fn value_u64(value: &Value, key: &str) -> Option<u64> {
319    value.get(key)?.as_u64()
320}
321
322fn value_usize(value: &Value, key: &str) -> Option<usize> {
323    value_u64(value, key).and_then(|value| usize::try_from(value).ok())
324}
325
326#[cfg(test)]
327mod tests {
328    use serde_json::json;
329
330    use super::*;
331    use crate::github_read::resource::GithubResourceKind;
332
333    #[test]
334    fn normalizes_cli_json_without_human_output_parsing() {
335        let resource = GithubResource {
336            kind: GithubResourceKind::Issue,
337            number: 7,
338            repository: None,
339            comment_selector: None,
340        };
341        let document = normalize_structured_document(
342            &resource,
343            &json!({
344                "url": "https://github.com/CortexKit/aft/issues/7",
345                "number": 7,
346                "title": "Fixture issue",
347                "state": "OPEN",
348                "author": { "login": "octo" },
349                "comments": { "totalCount": 2, "nodes": [
350                    { "author": { "login": "reviewer" }, "body": "one", "createdAt": "2026-01-01T00:00:00Z" }
351                ] },
352                "reactionGroups": [{ "content": "THUMBS_UP", "users": 2 }]
353            }),
354        )
355        .unwrap();
356
357        assert_eq!(document.repository, "CortexKit/aft");
358        assert_eq!(document.comments_total_count, Some(2));
359        assert_eq!(document.reactions[0].count, 2);
360    }
361}