1use processkit::{Error, Result};
5use serde::Deserialize;
6use serde::de::DeserializeOwned;
7
8use crate::BINARY;
9
10#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[non_exhaustive]
13pub struct PullRequest {
14 pub number: u64,
16 pub title: String,
18 pub state: String,
20 #[serde(rename = "headRefName", default)]
22 pub head_ref_name: String,
23 #[serde(rename = "baseRefName", default)]
25 pub base_ref_name: String,
26 #[serde(default)]
28 pub url: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
34#[non_exhaustive]
35pub struct Issue {
36 pub number: u64,
38 pub title: String,
40 pub state: String,
42 #[serde(default)]
44 pub body: String,
45 #[serde(default)]
47 pub url: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
52#[non_exhaustive]
53pub struct WorkflowRun {
54 #[serde(rename = "databaseId")]
56 pub database_id: u64,
57 #[serde(default)]
59 pub name: String,
60 #[serde(rename = "displayTitle", default)]
62 pub display_title: String,
63 #[serde(default)]
65 pub status: String,
66 #[serde(default)]
69 pub conclusion: String,
70 #[serde(rename = "workflowName", default)]
72 pub workflow_name: String,
73 #[serde(rename = "headBranch", default)]
75 pub head_branch: String,
76 #[serde(default)]
78 pub event: String,
79 #[serde(default)]
81 pub url: String,
82 #[serde(rename = "createdAt", default)]
84 pub created_at: String,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
89#[non_exhaustive]
90pub struct CheckRun {
91 pub name: String,
93 #[serde(default)]
95 pub state: String,
96 #[serde(default)]
99 pub bucket: String,
100 #[serde(default)]
102 pub workflow: String,
103 #[serde(default)]
105 pub link: String,
106 #[serde(rename = "startedAt", default)]
108 pub started_at: String,
109 #[serde(rename = "completedAt", default)]
111 pub completed_at: String,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
116#[non_exhaustive]
117pub struct Release {
118 #[serde(rename = "tagName")]
120 pub tag_name: String,
121 #[serde(default)]
123 pub name: String,
124 #[serde(default)]
127 pub body: String,
128 #[serde(default)]
130 pub url: String,
131 #[serde(rename = "publishedAt", default)]
133 pub published_at: String,
134 #[serde(rename = "isDraft", default)]
136 pub is_draft: bool,
137 #[serde(rename = "isPrerelease", default)]
139 pub is_prerelease: bool,
140 #[serde(rename = "isLatest", default)]
143 pub is_latest: bool,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct Review {
150 pub author: String,
152 pub state: String,
155 pub body: String,
157 pub submitted_at: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub struct Comment {
165 pub author: String,
167 pub body: String,
169 pub url: String,
171 pub created_at: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177#[non_exhaustive]
178pub struct PrFeedback {
179 pub reviews: Vec<Review>,
181 pub comments: Vec<Comment>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct Repo {
189 pub name: String,
191 pub owner: String,
193 pub description: Option<String>,
195 pub url: String,
197 pub is_private: bool,
199 pub default_branch: String,
201}
202
203#[derive(Deserialize)]
206struct RepoJson {
207 name: String,
208 owner: OwnerJson,
209 #[serde(default)]
210 description: Option<String>,
211 url: String,
212 #[serde(rename = "isPrivate")]
213 is_private: bool,
214 #[serde(rename = "defaultBranchRef", default)]
215 default_branch_ref: Option<BranchRefJson>,
216}
217
218#[derive(Deserialize)]
219struct OwnerJson {
220 login: String,
221}
222
223#[derive(Deserialize)]
224struct BranchRefJson {
225 name: String,
226}
227
228pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
231 serde_json::from_str(json).map_err(|e| Error::Parse {
232 program: BINARY.to_string(),
233 message: e.to_string(),
234 })
235}
236
237pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
239 let raw: RepoJson = from_json(json)?;
240 Ok(Repo {
241 name: raw.name,
242 owner: raw.owner.login,
243 description: raw.description,
244 url: raw.url,
245 is_private: raw.is_private,
246 default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
247 })
248}
249
250#[derive(Deserialize)]
253struct FeedbackJson {
254 #[serde(default)]
255 reviews: Vec<ReviewJson>,
256 #[serde(default)]
257 comments: Vec<CommentJson>,
258}
259
260#[derive(Deserialize)]
261struct ReviewJson {
262 #[serde(default)]
263 author: Option<AuthorJson>,
264 #[serde(default)]
265 state: String,
266 #[serde(default)]
267 body: String,
268 #[serde(rename = "submittedAt", default)]
269 submitted_at: String,
270}
271
272#[derive(Deserialize)]
273struct CommentJson {
274 #[serde(default)]
275 author: Option<AuthorJson>,
276 #[serde(default)]
277 body: String,
278 #[serde(default)]
279 url: String,
280 #[serde(rename = "createdAt", default)]
281 created_at: String,
282}
283
284#[derive(Deserialize)]
285struct AuthorJson {
286 #[serde(default)]
287 login: String,
288}
289
290pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
293 let raw: FeedbackJson = from_json(json)?;
294 Ok(PrFeedback {
295 reviews: raw
296 .reviews
297 .into_iter()
298 .map(|r| Review {
299 author: r.author.map(|a| a.login).unwrap_or_default(),
300 state: r.state,
301 body: r.body,
302 submitted_at: r.submitted_at,
303 })
304 .collect(),
305 comments: raw
306 .comments
307 .into_iter()
308 .map(|c| Comment {
309 author: c.author.map(|a| a.login).unwrap_or_default(),
310 body: c.body,
311 url: c.url,
312 created_at: c.created_at,
313 })
314 .collect(),
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn parses_pr_list() {
324 let json = r#"[
325 {"number": 12, "title": "Add feature", "state": "OPEN",
326 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
327 ]"#;
328 let prs: Vec<PullRequest> = from_json(json).expect("parse prs");
329 assert_eq!(prs.len(), 1);
330 assert_eq!(
331 prs[0],
332 PullRequest {
333 number: 12,
334 title: "Add feature".into(),
335 state: "OPEN".into(),
336 head_ref_name: "feat/x".into(),
337 base_ref_name: "main".into(),
338 url: "https://gh/pr/12".into(),
339 }
340 );
341 }
342
343 #[test]
344 fn parses_issue_list() {
345 let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
346 let issues: Vec<Issue> = from_json(json).expect("parse issues");
347 assert_eq!(issues[0].number, 3);
348 }
349
350 #[test]
351 fn parses_repo_flattening_nested_objects() {
352 let json = r#"{
353 "name": "vcs-toolkit-rs",
354 "owner": {"login": "ZelAnton"},
355 "description": null,
356 "url": "https://gh/repo",
357 "isPrivate": false,
358 "defaultBranchRef": {"name": "main"}
359 }"#;
360 let repo = parse_repo(json).expect("parse repo");
361 assert_eq!(repo.name, "vcs-toolkit-rs");
362 assert_eq!(repo.owner, "ZelAnton");
363 assert_eq!(repo.description, None);
364 assert_eq!(repo.default_branch, "main");
365 assert!(!repo.is_private);
366 }
367
368 #[test]
369 fn empty_repo_has_blank_default_branch() {
370 let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
371 let repo = parse_repo(json).expect("parse repo");
372 assert_eq!(repo.default_branch, "");
373 assert!(repo.is_private);
374 }
375
376 #[test]
377 fn malformed_json_is_a_parse_error() {
378 match from_json::<Vec<Issue>>("not json").unwrap_err() {
379 Error::Parse { .. } => {}
380 other => panic!("expected Parse, got {other:?}"),
381 }
382 }
383
384 #[test]
387 fn parses_run_list_with_blank_in_progress_conclusion() {
388 let json = r#"[
389 {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
390 "status": "in_progress", "conclusion": "", "workflowName": "CI",
391 "headBranch": "main", "event": "push",
392 "url": "https://gh/runs/27023111945",
393 "createdAt": "2026-06-05T10:00:00Z"}
394 ]"#;
395 let runs: Vec<WorkflowRun> = from_json(json).expect("parse runs");
396 assert_eq!(runs[0].database_id, 27023111945);
397 assert_eq!(runs[0].status, "in_progress");
398 assert_eq!(runs[0].conclusion, "");
399 assert_eq!(runs[0].workflow_name, "CI");
400 }
401
402 #[test]
403 fn parses_check_runs_across_buckets() {
404 let json = r#"[
405 {"name": "build", "state": "SUCCESS", "bucket": "pass",
406 "workflow": "CI", "link": "https://gh/c/1",
407 "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
408 {"name": "lint", "state": "FAILURE", "bucket": "fail",
409 "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
410 {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
411 "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
412 {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
413 "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
414 {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
415 "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
416 ]"#;
417 let checks: Vec<CheckRun> = from_json(json).expect("parse checks");
418 let buckets: Vec<&str> = checks.iter().map(|c| c.bucket.as_str()).collect();
419 assert_eq!(buckets, ["pass", "fail", "pending", "skipping", "cancel"]);
420 assert_eq!(checks[0].name, "build");
421 }
422
423 #[test]
426 fn parses_release_list_and_view_shapes() {
427 let list = r#"[
428 {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
429 "isLatest": true, "isDraft": false, "isPrerelease": false,
430 "publishedAt": "2026-06-04T12:00:00Z"}
431 ]"#;
432 let releases: Vec<Release> = from_json(list).expect("parse list");
433 assert!(releases[0].is_latest);
434 assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
435 assert_eq!(releases[0].body, "", "list doesn't fetch the body");
436
437 let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
438 "body": "Added\n- stuff", "url": "https://gh/releases/1",
439 "publishedAt": "2026-06-04T12:00:00Z",
440 "isDraft": false, "isPrerelease": false}"#;
441 let release: Release = from_json(view).expect("parse view");
442 assert!(!release.is_latest, "view has no isLatest → default false");
443 assert_eq!(release.body, "Added\n- stuff");
444 assert_eq!(release.url, "https://gh/releases/1");
445 }
446
447 #[test]
448 fn parses_feedback_flattening_nested_authors() {
449 let json = r#"{
450 "reviews": [
451 {"author": {"login": "steiza"}, "state": "APPROVED",
452 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
453 {"author": null, "state": "COMMENTED", "body": "ghost",
454 "submittedAt": ""}
455 ],
456 "comments": [
457 {"author": {"login": "andyfeller"}, "body": "nice",
458 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
459 ]
460 }"#;
461 let feedback = parse_feedback(json).expect("parse feedback");
462 assert_eq!(feedback.reviews.len(), 2);
463 assert_eq!(feedback.reviews[0].author, "steiza");
464 assert_eq!(feedback.reviews[0].state, "APPROVED");
465 assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
466 assert_eq!(feedback.comments[0].author, "andyfeller");
467 assert_eq!(feedback.comments[0].url, "https://gh/c/9");
468 }
469
470 #[test]
473 fn issue_parses_with_and_without_view_fields() {
474 let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
475 let issues: Vec<Issue> = from_json(list).expect("parse list");
476 assert_eq!(issues[0].body, "");
477 assert_eq!(issues[0].url, "");
478
479 let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
480 "body": "Write them.", "url": "https://gh/issues/3"}"#;
481 let issue: Issue = from_json(view).expect("parse view");
482 assert_eq!(issue.body, "Write them.");
483 assert_eq!(issue.url, "https://gh/issues/3");
484 }
485}