1use processkit::Result;
5use serde::Deserialize;
6
7use crate::BINARY;
8
9#[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 = "isDraft", default)]
22 pub is_draft: bool,
23 #[serde(
25 rename = "headRefName",
26 default,
27 deserialize_with = "vcs_cli_support::json::null_to_empty"
28 )]
29 pub head_ref_name: String,
30 #[serde(
32 rename = "baseRefName",
33 default,
34 deserialize_with = "vcs_cli_support::json::null_to_empty"
35 )]
36 pub base_ref_name: String,
37 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
39 pub url: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45#[non_exhaustive]
46pub struct Issue {
47 pub number: u64,
49 pub title: String,
51 pub state: String,
53 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
55 pub body: String,
56 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
58 pub url: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63#[non_exhaustive]
64pub struct WorkflowRun {
65 #[serde(rename = "databaseId")]
67 pub database_id: u64,
68 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
70 pub name: String,
71 #[serde(
73 rename = "displayTitle",
74 default,
75 deserialize_with = "vcs_cli_support::json::null_to_empty"
76 )]
77 pub display_title: String,
78 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
80 pub status: String,
81 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
84 pub conclusion: String,
85 #[serde(
87 rename = "workflowName",
88 default,
89 deserialize_with = "vcs_cli_support::json::null_to_empty"
90 )]
91 pub workflow_name: String,
92 #[serde(
94 rename = "headBranch",
95 default,
96 deserialize_with = "vcs_cli_support::json::null_to_empty"
97 )]
98 pub head_branch: String,
99 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
101 pub event: String,
102 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
104 pub url: String,
105 #[serde(
107 rename = "createdAt",
108 default,
109 deserialize_with = "vcs_cli_support::json::null_to_empty"
110 )]
111 pub created_at: String,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
123#[serde(rename_all = "lowercase")]
124#[non_exhaustive]
125pub enum CheckBucket {
126 Pass,
128 Fail,
130 Pending,
132 Skipping,
134 Cancel,
136 #[default]
138 #[serde(other)]
139 Unknown,
140}
141
142impl CheckBucket {
143 pub fn is_failing(self) -> bool {
146 matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
147 }
148
149 pub fn is_pending(self) -> bool {
151 matches!(self, CheckBucket::Pending)
152 }
153
154 pub fn is_passing(self) -> bool {
156 matches!(self, CheckBucket::Pass)
157 }
158
159 pub fn is_unknown(self) -> bool {
165 matches!(self, CheckBucket::Unknown)
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
171#[non_exhaustive]
172pub struct CheckRun {
173 pub name: String,
175 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
177 pub state: String,
178 #[serde(default)]
180 pub bucket: CheckBucket,
181 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
183 pub workflow: String,
184 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
186 pub link: String,
187 #[serde(
189 rename = "startedAt",
190 default,
191 deserialize_with = "vcs_cli_support::json::null_to_empty"
192 )]
193 pub started_at: String,
194 #[serde(
196 rename = "completedAt",
197 default,
198 deserialize_with = "vcs_cli_support::json::null_to_empty"
199 )]
200 pub completed_at: String,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
205#[non_exhaustive]
206pub struct Release {
207 #[serde(rename = "tagName")]
209 pub tag_name: String,
210 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
212 pub name: String,
213 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
216 pub body: String,
217 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
219 pub url: String,
220 #[serde(
222 rename = "publishedAt",
223 default,
224 deserialize_with = "vcs_cli_support::json::null_to_empty"
225 )]
226 pub published_at: String,
227 #[serde(rename = "isDraft", default)]
229 pub is_draft: bool,
230 #[serde(rename = "isPrerelease", default)]
232 pub is_prerelease: bool,
233 #[serde(rename = "isLatest", default)]
236 pub is_latest: bool,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241#[non_exhaustive]
242pub struct Review {
243 pub author: String,
245 pub state: String,
248 pub body: String,
250 pub submitted_at: String,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct Comment {
258 pub author: String,
260 pub body: String,
262 pub url: String,
264 pub created_at: String,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270#[non_exhaustive]
271pub struct PrFeedback {
272 pub reviews: Vec<Review>,
274 pub comments: Vec<Comment>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
280#[non_exhaustive]
281pub struct RepoView {
282 pub name: String,
284 pub owner: String,
286 pub description: Option<String>,
288 pub url: String,
290 pub is_private: bool,
292 pub default_branch: String,
294}
295
296#[derive(Deserialize)]
299struct RepoJson {
300 name: String,
301 owner: OwnerJson,
302 #[serde(default)]
303 description: Option<String>,
304 url: String,
305 #[serde(rename = "isPrivate")]
306 is_private: bool,
307 #[serde(rename = "defaultBranchRef", default)]
308 default_branch_ref: Option<BranchRefJson>,
309}
310
311#[derive(Deserialize)]
312struct OwnerJson {
313 login: String,
314}
315
316#[derive(Deserialize)]
317struct BranchRefJson {
318 name: String,
319}
320
321pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
323 let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
324 Ok(RepoView {
325 name: raw.name,
326 owner: raw.owner.login,
327 description: raw.description,
328 url: raw.url,
329 is_private: raw.is_private,
330 default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
331 })
332}
333
334#[derive(Deserialize)]
337struct FeedbackJson {
338 #[serde(default)]
339 reviews: Vec<ReviewJson>,
340 #[serde(default)]
341 comments: Vec<CommentJson>,
342}
343
344#[derive(Deserialize)]
348struct ReviewJson {
349 #[serde(default)]
350 author: Option<AuthorJson>,
351 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
352 state: String,
353 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
354 body: String,
355 #[serde(
356 rename = "submittedAt",
357 default,
358 deserialize_with = "vcs_cli_support::json::null_to_empty"
359 )]
360 submitted_at: String,
361}
362
363#[derive(Deserialize)]
364struct CommentJson {
365 #[serde(default)]
366 author: Option<AuthorJson>,
367 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
368 body: String,
369 #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
370 url: String,
371 #[serde(
372 rename = "createdAt",
373 default,
374 deserialize_with = "vcs_cli_support::json::null_to_empty"
375 )]
376 created_at: String,
377}
378
379#[derive(Deserialize)]
380struct AuthorJson {
381 #[serde(default)]
382 login: String,
383}
384
385pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
388 let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
389 Ok(PrFeedback {
390 reviews: raw
391 .reviews
392 .into_iter()
393 .map(|r| Review {
394 author: r.author.map(|a| a.login).unwrap_or_default(),
395 state: r.state,
396 body: r.body,
397 submitted_at: r.submitted_at,
398 })
399 .collect(),
400 comments: raw
401 .comments
402 .into_iter()
403 .map(|c| Comment {
404 author: c.author.map(|a| a.login).unwrap_or_default(),
405 body: c.body,
406 url: c.url,
407 created_at: c.created_at,
408 })
409 .collect(),
410 })
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use processkit::Error;
417
418 #[test]
419 fn parses_pr_list() {
420 let json = r#"[
421 {"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": true,
422 "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
423 ]"#;
424 let prs: Vec<PullRequest> =
425 vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
426 assert_eq!(prs.len(), 1);
427 assert_eq!(
428 prs[0],
429 PullRequest {
430 number: 12,
431 title: "Add feature".into(),
432 state: "OPEN".into(),
433 is_draft: true,
434 head_ref_name: "feat/x".into(),
435 base_ref_name: "main".into(),
436 url: "https://gh/pr/12".into(),
437 }
438 );
439 }
440
441 #[test]
446 fn pr_without_is_draft_defaults_false() {
447 let pr: PullRequest = vcs_cli_support::json::from_json(
448 BINARY,
449 r#"{"number": 4, "title": "t", "state": "OPEN",
450 "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
451 )
452 .expect("PR without isDraft");
453 assert!(!pr.is_draft);
454 }
455
456 #[test]
457 fn parses_issue_list() {
458 let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
459 let issues: Vec<Issue> =
460 vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
461 assert_eq!(issues[0].number, 3);
462 }
463
464 #[test]
469 fn null_optional_fields_parse_to_empty() {
470 let pr: PullRequest = vcs_cli_support::json::from_json(
471 BINARY,
472 r#"{"number": 1, "title": "t", "state": "CLOSED",
473 "headRefName": null, "baseRefName": null, "url": null}"#,
474 )
475 .expect("PR with null head/base/url (deleted-branch PR)");
476 assert_eq!(pr.head_ref_name, "");
477 assert_eq!(pr.base_ref_name, "");
478 assert_eq!(pr.url, "");
479
480 let issue: Issue = vcs_cli_support::json::from_json(
481 BINARY,
482 r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
483 )
484 .expect("issue with null body/url");
485 assert_eq!(issue.body, "");
486 assert_eq!(issue.url, "");
487
488 let release: Release = vcs_cli_support::json::from_json(
489 BINARY,
490 r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
491 )
492 .expect("release with null name/body/url/publishedAt");
493 assert_eq!(release.name, "");
494 assert_eq!(release.body, "");
495 }
496
497 #[test]
498 fn parses_repo_flattening_nested_objects() {
499 let json = r#"{
500 "name": "vcs-toolkit-rs",
501 "owner": {"login": "ZelAnton"},
502 "description": null,
503 "url": "https://gh/repo",
504 "isPrivate": false,
505 "defaultBranchRef": {"name": "main"}
506 }"#;
507 let repo = parse_repo(json).expect("parse repo");
508 assert_eq!(repo.name, "vcs-toolkit-rs");
509 assert_eq!(repo.owner, "ZelAnton");
510 assert_eq!(repo.description, None);
511 assert_eq!(repo.default_branch, "main");
512 assert!(!repo.is_private);
513 }
514
515 #[test]
516 fn empty_repo_has_blank_default_branch() {
517 let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
518 let repo = parse_repo(json).expect("parse repo");
519 assert_eq!(repo.default_branch, "");
520 assert!(repo.is_private);
521 }
522
523 #[test]
524 fn malformed_json_is_a_parse_error() {
525 match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json").unwrap_err() {
526 Error::Parse { .. } => {}
527 other => panic!("expected Parse, got {other:?}"),
528 }
529 }
530
531 #[test]
534 fn parses_run_list_with_blank_in_progress_conclusion() {
535 let json = r#"[
536 {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
537 "status": "in_progress", "conclusion": "", "workflowName": "CI",
538 "headBranch": "main", "event": "push",
539 "url": "https://gh/runs/27023111945",
540 "createdAt": "2026-06-05T10:00:00Z"}
541 ]"#;
542 let runs: Vec<WorkflowRun> =
543 vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
544 assert_eq!(runs[0].database_id, 27023111945);
545 assert_eq!(runs[0].status, "in_progress");
546 assert_eq!(runs[0].conclusion, "");
547 assert_eq!(runs[0].workflow_name, "CI");
548 }
549
550 #[test]
551 fn parses_check_runs_across_buckets() {
552 let json = r#"[
553 {"name": "build", "state": "SUCCESS", "bucket": "pass",
554 "workflow": "CI", "link": "https://gh/c/1",
555 "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
556 {"name": "lint", "state": "FAILURE", "bucket": "fail",
557 "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
558 {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
559 "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
560 {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
561 "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
562 {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
563 "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
564 ]"#;
565 let checks: Vec<CheckRun> =
566 vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
567 let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
568 assert_eq!(
569 buckets,
570 [
571 CheckBucket::Pass,
572 CheckBucket::Fail,
573 CheckBucket::Pending,
574 CheckBucket::Skipping,
575 CheckBucket::Cancel,
576 ]
577 );
578 let exotic: CheckRun =
580 serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
581 assert_eq!(exotic.bucket, CheckBucket::Unknown);
582 assert_eq!(checks[0].name, "build");
583 }
584
585 #[test]
588 fn parses_release_list_and_view_shapes() {
589 let list = r#"[
590 {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
591 "isLatest": true, "isDraft": false, "isPrerelease": false,
592 "publishedAt": "2026-06-04T12:00:00Z"}
593 ]"#;
594 let releases: Vec<Release> =
595 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
596 assert!(releases[0].is_latest);
597 assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
598 assert_eq!(releases[0].body, "", "list doesn't fetch the body");
599
600 let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
601 "body": "Added\n- stuff", "url": "https://gh/releases/1",
602 "publishedAt": "2026-06-04T12:00:00Z",
603 "isDraft": false, "isPrerelease": false}"#;
604 let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
605 assert!(!release.is_latest, "view has no isLatest → default false");
606 assert_eq!(release.body, "Added\n- stuff");
607 assert_eq!(release.url, "https://gh/releases/1");
608 }
609
610 #[test]
611 fn parses_feedback_flattening_nested_authors() {
612 let json = r#"{
613 "reviews": [
614 {"author": {"login": "steiza"}, "state": "APPROVED",
615 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
616 {"author": null, "state": "COMMENTED", "body": "ghost",
617 "submittedAt": ""}
618 ],
619 "comments": [
620 {"author": {"login": "andyfeller"}, "body": "nice",
621 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
622 ]
623 }"#;
624 let feedback = parse_feedback(json).expect("parse feedback");
625 assert_eq!(feedback.reviews.len(), 2);
626 assert_eq!(feedback.reviews[0].author, "steiza");
627 assert_eq!(feedback.reviews[0].state, "APPROVED");
628 assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
629 assert_eq!(feedback.comments[0].author, "andyfeller");
630 assert_eq!(feedback.comments[0].url, "https://gh/c/9");
631 }
632
633 #[test]
636 fn issue_parses_with_and_without_view_fields() {
637 let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
638 let issues: Vec<Issue> =
639 vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
640 assert_eq!(issues[0].body, "");
641 assert_eq!(issues[0].url, "");
642
643 let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
644 "body": "Write them.", "url": "https://gh/issues/3"}"#;
645 let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
646 assert_eq!(issue.body, "Write them.");
647 assert_eq!(issue.url, "https://gh/issues/3");
648 }
649}