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