1use processkit::Result;
5use serde::Deserialize;
6
7use crate::BINARY;
8
9pub(crate) fn parse_gh_version(raw: &str) -> Option<vcs_diff::Version> {
15 vcs_diff::parse_dotted_version(raw)
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21#[non_exhaustive]
22pub struct PullRequest {
23 pub number: u64,
25 pub title: String,
27 pub state: String,
29 #[serde(rename = "isDraft", default)]
31 pub is_draft: bool,
32 #[serde(
34 rename = "headRefName",
35 default,
36 deserialize_with = "vcs_cli_support::json::null_to_empty"
37 )]
38 pub head_ref_name: String,
39 #[serde(
41 rename = "baseRefName",
42 default,
43 deserialize_with = "vcs_cli_support::json::null_to_empty"
44 )]
45 pub base_ref_name: String,
46 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
48 pub url: String,
49 #[serde(default, deserialize_with = "labels_to_names")]
52 pub labels: Vec<String>,
53 #[serde(default, deserialize_with = "assignees_to_logins")]
56 pub assignees: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[non_exhaustive]
63pub struct Issue {
64 pub number: u64,
66 pub title: String,
68 pub state: String,
70 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
72 pub body: String,
73 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
75 pub url: String,
76 #[serde(default, deserialize_with = "labels_to_names")]
79 pub labels: Vec<String>,
80 #[serde(default, deserialize_with = "assignees_to_logins")]
83 pub assignees: Vec<String>,
84}
85
86#[derive(Deserialize)]
92struct LabelJson {
93 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
94 name: String,
95}
96
97#[derive(Deserialize)]
98struct AssigneeJson {
99 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
100 login: String,
101}
102
103fn labels_to_names<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
104where
105 D: serde::Deserializer<'de>,
106{
107 let raw = Option::<Vec<LabelJson>>::deserialize(deserializer)?.unwrap_or_default();
108 Ok(raw.into_iter().map(|l| l.name).collect())
109}
110
111fn assignees_to_logins<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
112where
113 D: serde::Deserializer<'de>,
114{
115 let raw = Option::<Vec<AssigneeJson>>::deserialize(deserializer)?.unwrap_or_default();
116 Ok(raw.into_iter().map(|a| a.login).collect())
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
121#[non_exhaustive]
122pub struct WorkflowRun {
123 #[serde(rename = "databaseId")]
125 pub database_id: u64,
126 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
128 pub name: String,
129 #[serde(
131 rename = "displayTitle",
132 default,
133 deserialize_with = "vcs_cli_support::json::null_to_empty"
134 )]
135 pub display_title: String,
136 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
138 pub status: String,
139 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
142 pub conclusion: String,
143 #[serde(
145 rename = "workflowName",
146 default,
147 deserialize_with = "vcs_cli_support::json::null_to_empty"
148 )]
149 pub workflow_name: String,
150 #[serde(
152 rename = "headBranch",
153 default,
154 deserialize_with = "vcs_cli_support::json::null_to_empty"
155 )]
156 pub head_branch: String,
157 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
159 pub event: String,
160 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
162 pub url: String,
163 #[serde(
165 rename = "createdAt",
166 default,
167 deserialize_with = "vcs_cli_support::json::null_to_empty"
168 )]
169 pub created_at: String,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
181#[serde(rename_all = "lowercase")]
182#[non_exhaustive]
183pub enum CheckBucket {
184 Pass,
186 Fail,
188 Pending,
190 Skipping,
192 Cancel,
194 #[default]
196 #[serde(other)]
197 Unknown,
198}
199
200impl CheckBucket {
201 pub fn is_failing(self) -> bool {
204 matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
205 }
206
207 pub fn is_pending(self) -> bool {
209 matches!(self, CheckBucket::Pending)
210 }
211
212 pub fn is_passing(self) -> bool {
214 matches!(self, CheckBucket::Pass)
215 }
216
217 pub fn is_unknown(self) -> bool {
223 matches!(self, CheckBucket::Unknown)
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
229#[non_exhaustive]
230pub struct CheckRun {
231 pub name: String,
233 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
235 pub state: String,
236 #[serde(default)]
238 pub bucket: CheckBucket,
239 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
241 pub workflow: String,
242 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
244 pub link: String,
245 #[serde(
247 rename = "startedAt",
248 default,
249 deserialize_with = "vcs_cli_support::json::null_to_empty"
250 )]
251 pub started_at: String,
252 #[serde(
254 rename = "completedAt",
255 default,
256 deserialize_with = "vcs_cli_support::json::null_to_empty"
257 )]
258 pub completed_at: String,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
263#[non_exhaustive]
264pub struct Release {
265 #[serde(rename = "tagName")]
267 pub tag_name: String,
268 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
270 pub name: String,
271 #[serde(default)]
276 pub body: Option<String>,
277 #[serde(default)]
281 pub url: Option<String>,
282 #[serde(
284 rename = "publishedAt",
285 default,
286 deserialize_with = "vcs_cli_support::json::null_to_empty"
287 )]
288 pub published_at: String,
289 #[serde(rename = "isDraft", default)]
291 pub is_draft: bool,
292 #[serde(rename = "isPrerelease", default)]
294 pub is_prerelease: bool,
295 #[serde(rename = "isLatest", default)]
298 pub is_latest: bool,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
303#[non_exhaustive]
304pub struct Review {
305 pub author: String,
307 pub state: String,
310 pub body: String,
312 pub submitted_at: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct Comment {
320 pub author: String,
322 pub body: String,
324 pub url: String,
326 pub created_at: String,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332#[non_exhaustive]
333pub struct PrFeedback {
334 pub reviews: Vec<Review>,
336 pub comments: Vec<Comment>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
342#[non_exhaustive]
343pub struct RepoView {
344 pub name: String,
346 pub owner: String,
348 pub description: Option<String>,
350 pub url: String,
352 pub is_private: bool,
354 pub default_branch: String,
356}
357
358#[derive(Deserialize)]
361struct RepoJson {
362 name: String,
363 owner: OwnerJson,
364 #[serde(default)]
365 description: Option<String>,
366 url: String,
367 #[serde(rename = "isPrivate")]
368 is_private: bool,
369 #[serde(rename = "defaultBranchRef", default)]
370 default_branch_ref: Option<BranchRefJson>,
371}
372
373#[derive(Deserialize)]
374struct OwnerJson {
375 login: String,
376}
377
378#[derive(Deserialize)]
379struct BranchRefJson {
380 name: String,
381}
382
383pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
385 let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
386 Ok(RepoView {
387 name: raw.name,
388 owner: raw.owner.login,
389 description: raw.description,
390 url: raw.url,
391 is_private: raw.is_private,
392 default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
393 })
394}
395
396#[derive(Deserialize)]
399struct FeedbackJson {
400 #[serde(default)]
401 reviews: Vec<ReviewJson>,
402 #[serde(default)]
403 comments: Vec<CommentJson>,
404}
405
406#[derive(Deserialize)]
410struct ReviewJson {
411 #[serde(default)]
412 author: Option<AuthorJson>,
413 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
414 state: String,
415 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
416 body: String,
417 #[serde(
418 rename = "submittedAt",
419 default,
420 deserialize_with = "vcs_cli_support::json::null_to_empty"
421 )]
422 submitted_at: String,
423}
424
425#[derive(Deserialize)]
426struct CommentJson {
427 #[serde(default)]
428 author: Option<AuthorJson>,
429 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
430 body: String,
431 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
432 url: String,
433 #[serde(
434 rename = "createdAt",
435 default,
436 deserialize_with = "vcs_cli_support::json::null_to_empty"
437 )]
438 created_at: String,
439}
440
441#[derive(Deserialize)]
442struct AuthorJson {
443 #[serde(default)]
444 login: String,
445}
446
447pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
450 let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
451 Ok(PrFeedback {
452 reviews: raw
453 .reviews
454 .into_iter()
455 .map(|r| Review {
456 author: r.author.map(|a| a.login).unwrap_or_default(),
457 state: r.state,
458 body: r.body,
459 submitted_at: r.submitted_at,
460 })
461 .collect(),
462 comments: raw
463 .comments
464 .into_iter()
465 .map(|c| Comment {
466 author: c.author.map(|a| a.login).unwrap_or_default(),
467 body: c.body,
468 url: c.url,
469 created_at: c.created_at,
470 })
471 .collect(),
472 })
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use processkit::Error;
479
480 #[test]
481 fn parses_pr_list() {
482 let json = r#"[
483 {"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": true,
484 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
485 ]"#;
486 let prs: Vec<PullRequest> =
487 vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
488 assert_eq!(prs.len(), 1);
489 assert_eq!(
490 prs[0],
491 PullRequest {
492 number: 12,
493 title: "Add feature".into(),
494 state: "OPEN".into(),
495 is_draft: true,
496 head_ref_name: "feat/x".into(),
497 base_ref_name: "main".into(),
498 url: "https://gh/pr/12".into(),
499 labels: Vec::new(),
500 assignees: Vec::new(),
501 }
502 );
503 }
504
505 #[test]
508 fn pr_parses_labels_and_assignees() {
509 let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
510 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
511 "labels": [{"name": "bug", "color": "f00"}, {"name": "priority-1"}],
512 "assignees": [{"login": "octocat", "id": 1}, {"login": "hubot"}]}"#;
513 let pr: PullRequest =
514 vcs_cli_support::json::from_json(BINARY, json).expect("parse pr with labels/assignees");
515 assert_eq!(pr.labels, vec!["bug".to_string(), "priority-1".to_string()]);
516 assert_eq!(
517 pr.assignees,
518 vec!["octocat".to_string(), "hubot".to_string()]
519 );
520 }
521
522 #[test]
527 fn pr_without_labels_or_assignees_parses_to_empty_vecs() {
528 let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
529 "headRefName": "h", "baseRefName": "main", "url": "u",
530 "labels": [], "assignees": []}"#;
531 let pr: PullRequest =
532 vcs_cli_support::json::from_json(BINARY, json).expect("PR with empty labels/assignees");
533 assert!(pr.labels.is_empty());
534 assert!(pr.assignees.is_empty());
535
536 let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
537 BINARY,
538 r#"{"number": 14, "title": "t", "state": "OPEN",
539 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
540 )
541 .expect("PR without labels/assignees keys");
542 assert!(pr_no_keys.labels.is_empty());
543 assert!(pr_no_keys.assignees.is_empty());
544 }
545
546 #[test]
551 fn pr_without_is_draft_defaults_false() {
552 let pr: PullRequest = vcs_cli_support::json::from_json(
553 BINARY,
554 r#"{"number": 4, "title": "t", "state": "OPEN",
555 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
556 )
557 .expect("PR without isDraft");
558 assert!(!pr.is_draft);
559 }
560
561 #[test]
562 fn parses_issue_list() {
563 let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
564 let issues: Vec<Issue> =
565 vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
566 assert_eq!(issues[0].number, 3);
567 }
568
569 #[test]
574 fn null_optional_fields_parse_to_empty() {
575 let pr: PullRequest = vcs_cli_support::json::from_json(
576 BINARY,
577 r#"{"number": 1, "title": "t", "state": "CLOSED",
578 "headRefName": null, "baseRefName": null, "url": null}"#,
579 )
580 .expect("PR with null head/base/url (deleted-branch PR)");
581 assert_eq!(pr.head_ref_name, "");
582 assert_eq!(pr.base_ref_name, "");
583 assert_eq!(pr.url, "");
584
585 let issue: Issue = vcs_cli_support::json::from_json(
586 BINARY,
587 r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
588 )
589 .expect("issue with null body/url");
590 assert_eq!(issue.body, "");
591 assert_eq!(issue.url, "");
592
593 let release: Release = vcs_cli_support::json::from_json(
594 BINARY,
595 r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
596 )
597 .expect("release with null name/body/url/publishedAt");
598 assert_eq!(release.name, "");
599 assert_eq!(release.body, None);
601 assert_eq!(release.url, None);
602 }
603
604 #[test]
605 fn parses_repo_flattening_nested_objects() {
606 let json = r#"{
607 "name": "vcs-toolkit-rs",
608 "owner": {"login": "ZelAnton"},
609 "description": null,
610 "url": "https://gh/repo",
611 "isPrivate": false,
612 "defaultBranchRef": {"name": "main"}
613 }"#;
614 let repo = parse_repo(json).expect("parse repo");
615 assert_eq!(repo.name, "vcs-toolkit-rs");
616 assert_eq!(repo.owner, "ZelAnton");
617 assert_eq!(repo.description, None);
618 assert_eq!(repo.default_branch, "main");
619 assert!(!repo.is_private);
620 }
621
622 #[test]
623 fn empty_repo_has_blank_default_branch() {
624 let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
625 let repo = parse_repo(json).expect("parse repo");
626 assert_eq!(repo.default_branch, "");
627 assert!(repo.is_private);
628 }
629
630 #[test]
631 fn malformed_json_is_a_parse_error() {
632 match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json").unwrap_err() {
633 Error::Parse { .. } => {}
634 other => panic!("expected Parse, got {other:?}"),
635 }
636 }
637
638 #[test]
641 fn parses_run_list_with_blank_in_progress_conclusion() {
642 let json = r#"[
643 {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
644 "status": "in_progress", "conclusion": "", "workflowName": "CI",
645 "headBranch": "main", "event": "push",
646 "url": "https://gh/runs/27023111945",
647 "createdAt": "2026-06-05T10:00:00Z"}
648 ]"#;
649 let runs: Vec<WorkflowRun> =
650 vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
651 assert_eq!(runs[0].database_id, 27023111945);
652 assert_eq!(runs[0].status, "in_progress");
653 assert_eq!(runs[0].conclusion, "");
654 assert_eq!(runs[0].workflow_name, "CI");
655 }
656
657 #[test]
658 fn parses_check_runs_across_buckets() {
659 let json = r#"[
660 {"name": "build", "state": "SUCCESS", "bucket": "pass",
661 "workflow": "CI", "link": "https://gh/c/1",
662 "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
663 {"name": "lint", "state": "FAILURE", "bucket": "fail",
664 "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
665 {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
666 "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
667 {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
668 "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
669 {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
670 "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
671 ]"#;
672 let checks: Vec<CheckRun> =
673 vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
674 let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
675 assert_eq!(
676 buckets,
677 [
678 CheckBucket::Pass,
679 CheckBucket::Fail,
680 CheckBucket::Pending,
681 CheckBucket::Skipping,
682 CheckBucket::Cancel,
683 ]
684 );
685 let exotic: CheckRun =
687 serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
688 assert_eq!(exotic.bucket, CheckBucket::Unknown);
689 assert_eq!(checks[0].name, "build");
690 }
691
692 #[test]
695 fn parses_release_list_and_view_shapes() {
696 let list = r#"[
697 {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
698 "isLatest": true, "isDraft": false, "isPrerelease": false,
699 "publishedAt": "2026-06-04T12:00:00Z"}
700 ]"#;
701 let releases: Vec<Release> =
702 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
703 assert!(releases[0].is_latest);
704 assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
705 assert_eq!(
706 releases[0].body, None,
707 "list doesn't request the body → None"
708 );
709 assert_eq!(releases[0].url, None, "list doesn't request the url → None");
710
711 let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
712 "body": "Added\n- stuff", "url": "https://gh/releases/1",
713 "publishedAt": "2026-06-04T12:00:00Z",
714 "isDraft": false, "isPrerelease": false}"#;
715 let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
716 assert!(!release.is_latest, "view has no isLatest → default false");
717 assert_eq!(release.body.as_deref(), Some("Added\n- stuff"));
718 assert_eq!(release.url.as_deref(), Some("https://gh/releases/1"));
719 }
720
721 #[test]
722 fn parses_feedback_flattening_nested_authors() {
723 let json = r#"{
724 "reviews": [
725 {"author": {"login": "steiza"}, "state": "APPROVED",
726 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
727 {"author": null, "state": "COMMENTED", "body": "ghost",
728 "submittedAt": ""}
729 ],
730 "comments": [
731 {"author": {"login": "andyfeller"}, "body": "nice",
732 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
733 ]
734 }"#;
735 let feedback = parse_feedback(json).expect("parse feedback");
736 assert_eq!(feedback.reviews.len(), 2);
737 assert_eq!(feedback.reviews[0].author, "steiza");
738 assert_eq!(feedback.reviews[0].state, "APPROVED");
739 assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
740 assert_eq!(feedback.comments[0].author, "andyfeller");
741 assert_eq!(feedback.comments[0].url, "https://gh/c/9");
742 }
743
744 #[test]
747 fn issue_parses_with_and_without_view_fields() {
748 let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
749 let issues: Vec<Issue> =
750 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
751 assert_eq!(issues[0].body, "");
752 assert_eq!(issues[0].url, "");
753
754 let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
755 "body": "Write them.", "url": "https://gh/issues/3"}"#;
756 let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
757 assert_eq!(issue.body, "Write them.");
758 assert_eq!(issue.url, "https://gh/issues/3");
759 assert!(issue.labels.is_empty());
760 assert!(issue.assignees.is_empty());
761 }
762
763 #[test]
765 fn issue_parses_labels_and_assignees() {
766 let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
767 "body": "b", "url": "https://gh/issues/3",
768 "labels": [{"name": "docs"}, {"name": "good-first-issue"}],
769 "assignees": [{"login": "andyfeller"}]}"#;
770 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
771 .expect("parse issue with labels/assignees");
772 assert_eq!(
773 issue.labels,
774 vec!["docs".to_string(), "good-first-issue".to_string()]
775 );
776 assert_eq!(issue.assignees, vec!["andyfeller".to_string()]);
777 }
778
779 #[test]
781 fn issue_without_labels_or_assignees_parses_to_empty_vecs() {
782 let json = r#"{"number": 4, "title": "t", "state": "CLOSED",
783 "labels": [], "assignees": []}"#;
784 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
785 .expect("issue with empty labels/assignees");
786 assert!(issue.labels.is_empty());
787 assert!(issue.assignees.is_empty());
788 }
789}