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 #[serde(default, deserialize_with = "author_login")]
61 pub author: String,
62 #[serde(
64 rename = "createdAt",
65 default,
66 deserialize_with = "vcs_cli_support::json::null_to_empty"
67 )]
68 pub created_at: String,
69 #[serde(
71 rename = "updatedAt",
72 default,
73 deserialize_with = "vcs_cli_support::json::null_to_empty"
74 )]
75 pub updated_at: String,
76 #[serde(default, deserialize_with = "milestone_to_title")]
80 pub milestone: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[non_exhaustive]
87pub struct Issue {
88 pub number: u64,
90 pub title: String,
92 pub state: String,
94 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
96 pub body: String,
97 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
99 pub url: String,
100 #[serde(default, deserialize_with = "labels_to_names")]
103 pub labels: Vec<String>,
104 #[serde(default, deserialize_with = "assignees_to_logins")]
107 pub assignees: Vec<String>,
108 #[serde(default, deserialize_with = "author_login")]
112 pub author: String,
113 #[serde(
115 rename = "createdAt",
116 default,
117 deserialize_with = "vcs_cli_support::json::null_to_empty"
118 )]
119 pub created_at: String,
120 #[serde(
122 rename = "updatedAt",
123 default,
124 deserialize_with = "vcs_cli_support::json::null_to_empty"
125 )]
126 pub updated_at: String,
127 #[serde(default, deserialize_with = "milestone_to_title")]
131 pub milestone: Option<String>,
132}
133
134#[derive(Deserialize)]
140struct LabelJson {
141 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
142 name: String,
143}
144
145#[derive(Deserialize)]
146struct AssigneeJson {
147 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
148 login: String,
149}
150
151fn labels_to_names<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
152where
153 D: serde::Deserializer<'de>,
154{
155 let raw = Option::<Vec<LabelJson>>::deserialize(deserializer)?.unwrap_or_default();
156 Ok(raw.into_iter().map(|l| l.name).collect())
157}
158
159fn assignees_to_logins<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
160where
161 D: serde::Deserializer<'de>,
162{
163 let raw = Option::<Vec<AssigneeJson>>::deserialize(deserializer)?.unwrap_or_default();
164 Ok(raw.into_iter().map(|a| a.login).collect())
165}
166
167fn author_login<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
171where
172 D: serde::Deserializer<'de>,
173{
174 let raw = Option::<AuthorJson>::deserialize(deserializer)?;
175 Ok(raw.map(|a| a.login).unwrap_or_default())
176}
177
178fn author_login_opt<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
179where
180 D: serde::Deserializer<'de>,
181{
182 let raw = Option::<AuthorJson>::deserialize(deserializer)?;
183 Ok(raw.map(|a| a.login))
184}
185
186#[derive(Deserialize)]
188struct MilestoneJson {
189 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
190 title: String,
191}
192
193fn milestone_to_title<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
194where
195 D: serde::Deserializer<'de>,
196{
197 let raw = Option::<MilestoneJson>::deserialize(deserializer)?;
198 Ok(raw.map(|m| m.title))
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
203#[non_exhaustive]
204pub struct WorkflowRun {
205 #[serde(rename = "databaseId")]
207 pub database_id: u64,
208 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
210 pub name: String,
211 #[serde(
213 rename = "displayTitle",
214 default,
215 deserialize_with = "vcs_cli_support::json::null_to_empty"
216 )]
217 pub display_title: String,
218 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
220 pub status: String,
221 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
224 pub conclusion: String,
225 #[serde(
227 rename = "workflowName",
228 default,
229 deserialize_with = "vcs_cli_support::json::null_to_empty"
230 )]
231 pub workflow_name: String,
232 #[serde(
234 rename = "headBranch",
235 default,
236 deserialize_with = "vcs_cli_support::json::null_to_empty"
237 )]
238 pub head_branch: String,
239 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
241 pub event: String,
242 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
244 pub url: String,
245 #[serde(
247 rename = "createdAt",
248 default,
249 deserialize_with = "vcs_cli_support::json::null_to_empty"
250 )]
251 pub created_at: String,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
256#[non_exhaustive]
257pub struct Workflow {
258 pub id: u64,
260 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
262 pub name: String,
263 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
265 pub path: String,
266 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
269 pub state: String,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
281#[serde(rename_all = "lowercase")]
282#[non_exhaustive]
283pub enum CheckBucket {
284 Pass,
286 Fail,
288 Pending,
290 Skipping,
292 Cancel,
294 #[default]
296 #[serde(other)]
297 Unknown,
298}
299
300impl CheckBucket {
301 pub fn is_failing(self) -> bool {
304 matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
305 }
306
307 pub fn is_pending(self) -> bool {
309 matches!(self, CheckBucket::Pending)
310 }
311
312 pub fn is_passing(self) -> bool {
314 matches!(self, CheckBucket::Pass)
315 }
316
317 pub fn is_unknown(self) -> bool {
323 matches!(self, CheckBucket::Unknown)
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
329#[non_exhaustive]
330pub struct CheckRun {
331 pub name: String,
333 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
335 pub state: String,
336 #[serde(default)]
338 pub bucket: CheckBucket,
339 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
341 pub workflow: String,
342 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
344 pub link: String,
345 #[serde(
347 rename = "startedAt",
348 default,
349 deserialize_with = "vcs_cli_support::json::null_to_empty"
350 )]
351 pub started_at: String,
352 #[serde(
354 rename = "completedAt",
355 default,
356 deserialize_with = "vcs_cli_support::json::null_to_empty"
357 )]
358 pub completed_at: String,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
363#[non_exhaustive]
364pub struct Release {
365 #[serde(rename = "tagName")]
367 pub tag_name: String,
368 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
370 pub name: String,
371 #[serde(default)]
376 pub body: Option<String>,
377 #[serde(default)]
381 pub url: Option<String>,
382 #[serde(
384 rename = "publishedAt",
385 default,
386 deserialize_with = "vcs_cli_support::json::null_to_empty"
387 )]
388 pub published_at: String,
389 #[serde(rename = "isDraft", default)]
391 pub is_draft: bool,
392 #[serde(rename = "isPrerelease", default)]
394 pub is_prerelease: bool,
395 #[serde(rename = "isLatest", default)]
398 pub is_latest: bool,
399 #[serde(default, deserialize_with = "author_login_opt")]
404 pub author: Option<String>,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409#[non_exhaustive]
410pub struct Review {
411 pub author: String,
413 pub state: String,
416 pub body: String,
418 pub submitted_at: String,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
424#[non_exhaustive]
425pub struct Comment {
426 pub author: String,
428 pub body: String,
430 pub url: String,
432 pub created_at: String,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
438#[non_exhaustive]
439pub struct PrFeedback {
440 pub reviews: Vec<Review>,
442 pub comments: Vec<Comment>,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq)]
448#[non_exhaustive]
449pub struct RepoView {
450 pub name: String,
452 pub owner: String,
454 pub description: Option<String>,
456 pub url: String,
458 pub is_private: bool,
460 pub default_branch: String,
462}
463
464#[derive(Deserialize)]
467struct RepoJson {
468 name: String,
469 owner: OwnerJson,
470 #[serde(default)]
471 description: Option<String>,
472 url: String,
473 #[serde(rename = "isPrivate")]
474 is_private: bool,
475 #[serde(rename = "defaultBranchRef", default)]
476 default_branch_ref: Option<BranchRefJson>,
477}
478
479#[derive(Deserialize)]
480struct OwnerJson {
481 login: String,
482}
483
484#[derive(Deserialize)]
485struct BranchRefJson {
486 name: String,
487}
488
489pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
491 let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
492 Ok(RepoView {
493 name: raw.name,
494 owner: raw.owner.login,
495 description: raw.description,
496 url: raw.url,
497 is_private: raw.is_private,
498 default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
499 })
500}
501
502#[derive(Deserialize)]
505struct FeedbackJson {
506 #[serde(default)]
507 reviews: Vec<ReviewJson>,
508 #[serde(default)]
509 comments: Vec<CommentJson>,
510}
511
512#[derive(Deserialize)]
516struct ReviewJson {
517 #[serde(default)]
518 author: Option<AuthorJson>,
519 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
520 state: String,
521 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
522 body: String,
523 #[serde(
524 rename = "submittedAt",
525 default,
526 deserialize_with = "vcs_cli_support::json::null_to_empty"
527 )]
528 submitted_at: String,
529}
530
531#[derive(Deserialize)]
532struct CommentJson {
533 #[serde(default)]
534 author: Option<AuthorJson>,
535 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
536 body: String,
537 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
538 url: String,
539 #[serde(
540 rename = "createdAt",
541 default,
542 deserialize_with = "vcs_cli_support::json::null_to_empty"
543 )]
544 created_at: String,
545}
546
547#[derive(Deserialize)]
548struct AuthorJson {
549 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
550 login: String,
551}
552
553pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
556 let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
557 Ok(PrFeedback {
558 reviews: raw
559 .reviews
560 .into_iter()
561 .map(|r| Review {
562 author: r.author.map(|a| a.login).unwrap_or_default(),
563 state: r.state,
564 body: r.body,
565 submitted_at: r.submitted_at,
566 })
567 .collect(),
568 comments: raw
569 .comments
570 .into_iter()
571 .map(|c| Comment {
572 author: c.author.map(|a| a.login).unwrap_or_default(),
573 body: c.body,
574 url: c.url,
575 created_at: c.created_at,
576 })
577 .collect(),
578 })
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use processkit::ErrorReason;
585
586 #[test]
587 fn parses_pr_list() {
588 let json = r#"[
589 {"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": true,
590 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
591 ]"#;
592 let prs: Vec<PullRequest> =
593 vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
594 assert_eq!(prs.len(), 1);
595 assert_eq!(
596 prs[0],
597 PullRequest {
598 number: 12,
599 title: "Add feature".into(),
600 state: "OPEN".into(),
601 is_draft: true,
602 head_ref_name: "feat/x".into(),
603 base_ref_name: "main".into(),
604 url: "https://gh/pr/12".into(),
605 labels: Vec::new(),
606 assignees: Vec::new(),
607 author: String::new(),
608 created_at: String::new(),
609 updated_at: String::new(),
610 milestone: None,
611 }
612 );
613 }
614
615 #[test]
618 fn pr_parses_labels_and_assignees() {
619 let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
620 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
621 "labels": [{"name": "bug", "color": "f00"}, {"name": "priority-1"}],
622 "assignees": [{"login": "octocat", "id": 1}, {"login": "hubot"}]}"#;
623 let pr: PullRequest =
624 vcs_cli_support::json::from_json(BINARY, json).expect("parse pr with labels/assignees");
625 assert_eq!(pr.labels, vec!["bug".to_string(), "priority-1".to_string()]);
626 assert_eq!(
627 pr.assignees,
628 vec!["octocat".to_string(), "hubot".to_string()]
629 );
630 }
631
632 #[test]
637 fn pr_without_labels_or_assignees_parses_to_empty_vecs() {
638 let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
639 "headRefName": "h", "baseRefName": "main", "url": "u",
640 "labels": [], "assignees": []}"#;
641 let pr: PullRequest =
642 vcs_cli_support::json::from_json(BINARY, json).expect("PR with empty labels/assignees");
643 assert!(pr.labels.is_empty());
644 assert!(pr.assignees.is_empty());
645
646 let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
647 BINARY,
648 r#"{"number": 14, "title": "t", "state": "OPEN",
649 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
650 )
651 .expect("PR without labels/assignees keys");
652 assert!(pr_no_keys.labels.is_empty());
653 assert!(pr_no_keys.assignees.is_empty());
654 }
655
656 #[test]
659 fn pr_parses_author_timestamps_and_milestone() {
660 let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
661 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
662 "author": {"login": "octocat", "id": 1},
663 "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-02T00:00:00Z",
664 "milestone": {"title": "v1.0"}}"#;
665 let pr: PullRequest = vcs_cli_support::json::from_json(BINARY, json)
666 .expect("parse pr with author/timestamps/milestone");
667 assert_eq!(pr.author, "octocat");
668 assert_eq!(pr.created_at, "2026-07-01T00:00:00Z");
669 assert_eq!(pr.updated_at, "2026-07-02T00:00:00Z");
670 assert_eq!(pr.milestone.as_deref(), Some("v1.0"));
671 }
672
673 #[test]
677 fn pr_null_author_and_milestone_parse_tolerantly() {
678 let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
679 "headRefName": "h", "baseRefName": "main", "url": "u",
680 "author": null, "milestone": null}"#;
681 let pr: PullRequest =
682 vcs_cli_support::json::from_json(BINARY, json).expect("PR with null author/milestone");
683 assert_eq!(pr.author, "", "deleted account → empty login");
684 assert_eq!(pr.milestone, None, "no milestone attached → None");
685
686 let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
688 BINARY,
689 r#"{"number": 14, "title": "t", "state": "OPEN",
690 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
691 )
692 .expect("PR without author/timestamps/milestone keys");
693 assert_eq!(pr_no_keys.author, "");
694 assert_eq!(pr_no_keys.created_at, "");
695 assert_eq!(pr_no_keys.updated_at, "");
696 assert_eq!(pr_no_keys.milestone, None);
697 }
698
699 #[test]
704 fn pr_without_is_draft_defaults_false() {
705 let pr: PullRequest = vcs_cli_support::json::from_json(
706 BINARY,
707 r#"{"number": 4, "title": "t", "state": "OPEN",
708 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
709 )
710 .expect("PR without isDraft");
711 assert!(!pr.is_draft);
712 }
713
714 #[test]
715 fn parses_issue_list() {
716 let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
717 let issues: Vec<Issue> =
718 vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
719 assert_eq!(issues[0].number, 3);
720 }
721
722 #[test]
724 fn issue_parses_author_timestamps_and_milestone() {
725 let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
726 "author": {"login": "andyfeller"},
727 "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-02T00:00:00Z",
728 "milestone": {"title": "v1.0"}}"#;
729 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
730 .expect("parse issue with author/timestamps/milestone");
731 assert_eq!(issue.author, "andyfeller");
732 assert_eq!(issue.created_at, "2026-07-01T00:00:00Z");
733 assert_eq!(issue.updated_at, "2026-07-02T00:00:00Z");
734 assert_eq!(issue.milestone.as_deref(), Some("v1.0"));
735 }
736
737 #[test]
739 fn issue_null_author_and_milestone_parse_tolerantly() {
740 let json = r#"{"number": 4, "title": "t", "state": "OPEN",
741 "author": null, "milestone": null}"#;
742 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
743 .expect("issue with null author/milestone");
744 assert_eq!(issue.author, "");
745 assert_eq!(issue.milestone, None);
746 }
747
748 #[test]
753 fn null_optional_fields_parse_to_empty() {
754 let pr: PullRequest = vcs_cli_support::json::from_json(
755 BINARY,
756 r#"{"number": 1, "title": "t", "state": "CLOSED",
757 "headRefName": null, "baseRefName": null, "url": null}"#,
758 )
759 .expect("PR with null head/base/url (deleted-branch PR)");
760 assert_eq!(pr.head_ref_name, "");
761 assert_eq!(pr.base_ref_name, "");
762 assert_eq!(pr.url, "");
763
764 let issue: Issue = vcs_cli_support::json::from_json(
765 BINARY,
766 r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
767 )
768 .expect("issue with null body/url");
769 assert_eq!(issue.body, "");
770 assert_eq!(issue.url, "");
771
772 let release: Release = vcs_cli_support::json::from_json(
773 BINARY,
774 r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null,
775 "author": {}}"#,
776 )
777 .expect("release with null name/body/url/publishedAt/author");
778 assert_eq!(release.name, "");
779 assert_eq!(release.body, None);
781 assert_eq!(release.url, None);
782 assert_eq!(
783 release.author,
784 Some("".to_string()),
785 "deleted account → empty login"
786 );
787 }
788
789 #[test]
790 fn parses_repo_flattening_nested_objects() {
791 let json = r#"{
792 "name": "vcs-toolkit-rs",
793 "owner": {"login": "ZelAnton"},
794 "description": null,
795 "url": "https://gh/repo",
796 "isPrivate": false,
797 "defaultBranchRef": {"name": "main"}
798 }"#;
799 let repo = parse_repo(json).expect("parse repo");
800 assert_eq!(repo.name, "vcs-toolkit-rs");
801 assert_eq!(repo.owner, "ZelAnton");
802 assert_eq!(repo.description, None);
803 assert_eq!(repo.default_branch, "main");
804 assert!(!repo.is_private);
805 }
806
807 #[test]
808 fn empty_repo_has_blank_default_branch() {
809 let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
810 let repo = parse_repo(json).expect("parse repo");
811 assert_eq!(repo.default_branch, "");
812 assert!(repo.is_private);
813 }
814
815 #[test]
816 fn malformed_json_is_a_parse_error() {
817 match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json")
818 .unwrap_err()
819 .into_reason()
820 {
821 ErrorReason::Parse { .. } => {}
822 other => panic!("expected Parse, got {other:?}"),
823 }
824 }
825
826 #[test]
829 fn parses_run_list_with_blank_in_progress_conclusion() {
830 let json = r#"[
831 {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
832 "status": "in_progress", "conclusion": "", "workflowName": "CI",
833 "headBranch": "main", "event": "push",
834 "url": "https://gh/runs/27023111945",
835 "createdAt": "2026-06-05T10:00:00Z"}
836 ]"#;
837 let runs: Vec<WorkflowRun> =
838 vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
839 assert_eq!(runs[0].database_id, 27023111945);
840 assert_eq!(runs[0].status, "in_progress");
841 assert_eq!(runs[0].conclusion, "");
842 assert_eq!(runs[0].workflow_name, "CI");
843 }
844
845 #[test]
846 fn parses_workflow_inventory() {
847 let json = r#"[
848 {"id": 17, "name": "CI", "path": ".github/workflows/ci.yml",
849 "state": "active"},
850 {"id": 18, "name": null, "path": null, "state": null}
851 ]"#;
852 let workflows: Vec<Workflow> =
853 vcs_cli_support::json::from_json(BINARY, json).expect("parse workflows");
854 assert_eq!(workflows[0].id, 17);
855 assert_eq!(workflows[0].name, "CI");
856 assert_eq!(workflows[0].path, ".github/workflows/ci.yml");
857 assert_eq!(workflows[0].state, "active");
858 assert_eq!(workflows[1].name, "");
859 assert_eq!(workflows[1].path, "");
860 assert_eq!(workflows[1].state, "");
861 }
862
863 #[test]
864 fn parses_check_runs_across_buckets() {
865 let json = r#"[
866 {"name": "build", "state": "SUCCESS", "bucket": "pass",
867 "workflow": "CI", "link": "https://gh/c/1",
868 "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
869 {"name": "lint", "state": "FAILURE", "bucket": "fail",
870 "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
871 {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
872 "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
873 {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
874 "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
875 {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
876 "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
877 ]"#;
878 let checks: Vec<CheckRun> =
879 vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
880 let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
881 assert_eq!(
882 buckets,
883 [
884 CheckBucket::Pass,
885 CheckBucket::Fail,
886 CheckBucket::Pending,
887 CheckBucket::Skipping,
888 CheckBucket::Cancel,
889 ]
890 );
891 let exotic: CheckRun =
893 serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
894 assert_eq!(exotic.bucket, CheckBucket::Unknown);
895 assert_eq!(checks[0].name, "build");
896 }
897
898 #[test]
901 fn parses_release_list_and_view_shapes() {
902 let list = r#"[
903 {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
904 "isLatest": true, "isDraft": false, "isPrerelease": false,
905 "publishedAt": "2026-06-04T12:00:00Z"}
906 ]"#;
907 let releases: Vec<Release> =
908 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
909 assert!(releases[0].is_latest);
910 assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
911 assert_eq!(
912 releases[0].body, None,
913 "list doesn't request the body → None"
914 );
915 assert_eq!(releases[0].url, None, "list doesn't request the url → None");
916 assert_eq!(releases[0].author, None);
917
918 let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
919 "body": "Added\n- stuff", "url": "https://gh/releases/1",
920 "publishedAt": "2026-06-04T12:00:00Z",
921 "isDraft": false, "isPrerelease": false, "author": {"login": "ZelAnton"}}"#;
922 let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
923 assert!(!release.is_latest, "view has no isLatest → default false");
924 assert_eq!(release.body.as_deref(), Some("Added\n- stuff"));
925 assert_eq!(release.url.as_deref(), Some("https://gh/releases/1"));
926 assert_eq!(release.author, Some("ZelAnton".to_string()));
927 }
928
929 #[test]
930 fn parses_feedback_flattening_nested_authors() {
931 let json = r#"{
932 "reviews": [
933 {"author": {"login": "steiza"}, "state": "APPROVED",
934 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
935 {"author": null, "state": "COMMENTED", "body": "ghost",
936 "submittedAt": ""}
937 ],
938 "comments": [
939 {"author": {"login": "andyfeller"}, "body": "nice",
940 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
941 ]
942 }"#;
943 let feedback = parse_feedback(json).expect("parse feedback");
944 assert_eq!(feedback.reviews.len(), 2);
945 assert_eq!(feedback.reviews[0].author, "steiza");
946 assert_eq!(feedback.reviews[0].state, "APPROVED");
947 assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
948 assert_eq!(feedback.comments[0].author, "andyfeller");
949 assert_eq!(feedback.comments[0].url, "https://gh/c/9");
950 }
951
952 #[test]
955 fn issue_parses_with_and_without_view_fields() {
956 let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
957 let issues: Vec<Issue> =
958 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
959 assert_eq!(issues[0].body, "");
960 assert_eq!(issues[0].url, "");
961
962 let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
963 "body": "Write them.", "url": "https://gh/issues/3"}"#;
964 let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
965 assert_eq!(issue.body, "Write them.");
966 assert_eq!(issue.url, "https://gh/issues/3");
967 assert!(issue.labels.is_empty());
968 assert!(issue.assignees.is_empty());
969 }
970
971 #[test]
973 fn issue_parses_labels_and_assignees() {
974 let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
975 "body": "b", "url": "https://gh/issues/3",
976 "labels": [{"name": "docs"}, {"name": "good-first-issue"}],
977 "assignees": [{"login": "andyfeller"}]}"#;
978 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
979 .expect("parse issue with labels/assignees");
980 assert_eq!(
981 issue.labels,
982 vec!["docs".to_string(), "good-first-issue".to_string()]
983 );
984 assert_eq!(issue.assignees, vec!["andyfeller".to_string()]);
985 }
986
987 #[test]
989 fn issue_without_labels_or_assignees_parses_to_empty_vecs() {
990 let json = r#"{"number": 4, "title": "t", "state": "CLOSED",
991 "labels": [], "assignees": []}"#;
992 let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
993 .expect("issue with empty labels/assignees");
994 assert!(issue.labels.is_empty());
995 assert!(issue.assignees.is_empty());
996 }
997}