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#[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 };
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 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}