Skip to main content

vcs_github/
parse.rs

1//! Typed results from `gh … --json` and the deserialization helpers. Parsing is
2//! pure, so these tests are hermetic and run on CI.
3
4use processkit::{Error, Result};
5use serde::Deserialize;
6use serde::de::DeserializeOwned;
7
8use crate::BINARY;
9
10/// Deserialize a `String` field that `gh` may send as JSON `null` for an empty
11/// optional value (e.g. `headRefName`/`baseRefName` on a PR whose head branch was
12/// deleted): `null` → empty string, the same result as an absent key.
13/// `#[serde(default)]` alone only covers an **absent** key; a present `null` would
14/// otherwise fail the *whole* object parse with "invalid type: null, expected a
15/// string".
16fn null_to_empty<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
17where
18    D: serde::Deserializer<'de>,
19{
20    Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
21}
22
23/// A pull request (`gh pr list/view --json number,title,state,headRefName,baseRefName,url`).
24#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
25#[non_exhaustive]
26pub struct PullRequest {
27    /// PR number.
28    pub number: u64,
29    /// PR title.
30    pub title: String,
31    /// State, e.g. `"OPEN"`, `"MERGED"`, `"CLOSED"`.
32    pub state: String,
33    /// Source (head) branch name.
34    #[serde(rename = "headRefName", default, deserialize_with = "null_to_empty")]
35    pub head_ref_name: String,
36    /// Target (base) branch name.
37    #[serde(rename = "baseRefName", default, deserialize_with = "null_to_empty")]
38    pub base_ref_name: String,
39    /// Web URL.
40    #[serde(default, deserialize_with = "null_to_empty")]
41    pub url: String,
42}
43
44/// An issue (`gh issue list --json number,title,state`;
45/// `gh issue view` additionally fills `body`/`url`).
46#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
47#[non_exhaustive]
48pub struct Issue {
49    /// Issue number.
50    pub number: u64,
51    /// Issue title.
52    pub title: String,
53    /// State, e.g. `"OPEN"`, `"CLOSED"`.
54    pub state: String,
55    /// Issue body (markdown). Fetched by both `issue_list` and `issue_view`.
56    #[serde(default, deserialize_with = "null_to_empty")]
57    pub body: String,
58    /// Web URL. Fetched by both `issue_list` and `issue_view`.
59    #[serde(default, deserialize_with = "null_to_empty")]
60    pub url: String,
61}
62
63/// A GitHub Actions workflow run (`gh run list/view --json …`).
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[non_exhaustive]
66pub struct WorkflowRun {
67    /// The run id (`databaseId`) — the `<run-id>` other `gh run` commands take.
68    #[serde(rename = "databaseId")]
69    pub database_id: u64,
70    /// Workflow name as shown in the runs list.
71    #[serde(default, deserialize_with = "null_to_empty")]
72    pub name: String,
73    /// The run's display title (usually the commit subject).
74    #[serde(rename = "displayTitle", default, deserialize_with = "null_to_empty")]
75    pub display_title: String,
76    /// Lifecycle status, e.g. `"queued"`, `"in_progress"`, `"completed"`.
77    #[serde(default, deserialize_with = "null_to_empty")]
78    pub status: String,
79    /// Outcome, e.g. `"success"`, `"failure"`, `"cancelled"`, `"skipped"` —
80    /// gh reports an **empty string** until the run completes (not `null`).
81    #[serde(default, deserialize_with = "null_to_empty")]
82    pub conclusion: String,
83    /// Name of the workflow that produced the run.
84    #[serde(rename = "workflowName", default, deserialize_with = "null_to_empty")]
85    pub workflow_name: String,
86    /// Branch the run was triggered for.
87    #[serde(rename = "headBranch", default, deserialize_with = "null_to_empty")]
88    pub head_branch: String,
89    /// Triggering event, e.g. `"push"`, `"workflow_dispatch"`.
90    #[serde(default, deserialize_with = "null_to_empty")]
91    pub event: String,
92    /// Web URL.
93    #[serde(default, deserialize_with = "null_to_empty")]
94    pub url: String,
95    /// Creation timestamp (ISO 8601).
96    #[serde(rename = "createdAt", default, deserialize_with = "null_to_empty")]
97    pub created_at: String,
98}
99
100/// gh's coarse categorisation of a [`CheckRun`]'s state — the field to branch on
101/// when deciding whether CI passed. `gh` derives it from the raw `state`; this is
102/// the typed form of its `pass`/`fail`/`pending`/`skipping`/`cancel` strings.
103///
104/// `#[non_exhaustive]` with an [`Unknown`](CheckBucket::Unknown) catch-all: a
105/// bucket name a future `gh` introduces (or a missing field) deserialises to
106/// `Unknown` rather than failing the parse, so the wrapper never breaks on an
107/// unmodelled value.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
109#[serde(rename_all = "lowercase")]
110#[non_exhaustive]
111pub enum CheckBucket {
112    /// The check succeeded.
113    Pass,
114    /// The check failed.
115    Fail,
116    /// The check is queued or still running.
117    Pending,
118    /// The check was skipped (e.g. a conditional job that didn't run).
119    Skipping,
120    /// The check was cancelled.
121    Cancel,
122    /// A bucket `gh` reported that this version doesn't model, or an absent field.
123    #[default]
124    #[serde(other)]
125    Unknown,
126}
127
128impl CheckBucket {
129    /// Whether this bucket means the check failed or was cancelled — the states
130    /// that should fail an aggregate CI verdict.
131    pub fn is_failing(self) -> bool {
132        matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
133    }
134
135    /// Whether this bucket means the check is still in flight (queued/running).
136    pub fn is_pending(self) -> bool {
137        matches!(self, CheckBucket::Pending)
138    }
139
140    /// Whether this bucket means the check completed successfully.
141    pub fn is_passing(self) -> bool {
142        matches!(self, CheckBucket::Pass)
143    }
144
145    /// Whether this is the [`Unknown`](CheckBucket::Unknown) catch-all — a bucket a
146    /// future `gh` introduced (or a missing field) that this version doesn't model.
147    /// Distinct from [`Skipping`](CheckBucket::Skipping): a skip is a deliberate,
148    /// terminal no-op, whereas an unknown bucket is *unclassified* and should be
149    /// treated conservatively (as "not known to be done") by an aggregator.
150    pub fn is_unknown(self) -> bool {
151        matches!(self, CheckBucket::Unknown)
152    }
153}
154
155/// One check on a PR (`gh pr checks --json …`).
156#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
157#[non_exhaustive]
158pub struct CheckRun {
159    /// Check name.
160    pub name: String,
161    /// Raw state, e.g. `"SUCCESS"`, `"FAILURE"`, `"IN_PROGRESS"`.
162    #[serde(default, deserialize_with = "null_to_empty")]
163    pub state: String,
164    /// gh's categorisation of `state` — the field to branch on. See [`CheckBucket`].
165    #[serde(default)]
166    pub bucket: CheckBucket,
167    /// Workflow the check belongs to (empty for non-Actions checks).
168    #[serde(default, deserialize_with = "null_to_empty")]
169    pub workflow: String,
170    /// Web link to the check's details.
171    #[serde(default, deserialize_with = "null_to_empty")]
172    pub link: String,
173    /// Start timestamp (ISO 8601), empty until started.
174    #[serde(rename = "startedAt", default, deserialize_with = "null_to_empty")]
175    pub started_at: String,
176    /// Completion timestamp (ISO 8601), empty until completed.
177    #[serde(rename = "completedAt", default, deserialize_with = "null_to_empty")]
178    pub completed_at: String,
179}
180
181/// A release (`gh release list/view --json …`).
182#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
183#[non_exhaustive]
184pub struct Release {
185    /// The release's tag.
186    #[serde(rename = "tagName")]
187    pub tag_name: String,
188    /// Release title (may be empty/null).
189    #[serde(default, deserialize_with = "null_to_empty")]
190    pub name: String,
191    /// Release notes (markdown); empty from `release_list`, which doesn't
192    /// fetch it.
193    #[serde(default, deserialize_with = "null_to_empty")]
194    pub body: String,
195    /// Web URL; empty from `release_list`, which doesn't fetch it.
196    #[serde(default, deserialize_with = "null_to_empty")]
197    pub url: String,
198    /// Publication timestamp (ISO 8601); empty/null for a draft.
199    #[serde(rename = "publishedAt", default, deserialize_with = "null_to_empty")]
200    pub published_at: String,
201    /// `true` for an unpublished draft.
202    #[serde(rename = "isDraft", default)]
203    pub is_draft: bool,
204    /// `true` for a prerelease.
205    #[serde(rename = "isPrerelease", default)]
206    pub is_prerelease: bool,
207    /// `true` for the latest release. Only `release_list` reports this field;
208    /// from `release_view` it defaults to `false`.
209    #[serde(rename = "isLatest", default)]
210    pub is_latest: bool,
211}
212
213/// A submitted PR review (from `gh pr view --json reviews`).
214#[derive(Debug, Clone, PartialEq, Eq)]
215#[non_exhaustive]
216pub struct Review {
217    /// Reviewer login.
218    pub author: String,
219    /// Review state: `"APPROVED"`, `"CHANGES_REQUESTED"`, `"COMMENTED"`,
220    /// `"DISMISSED"` or `"PENDING"`.
221    pub state: String,
222    /// Review body (may be empty).
223    pub body: String,
224    /// Submission timestamp (ISO 8601).
225    pub submitted_at: String,
226}
227
228/// A PR conversation comment (from `gh pr view --json comments`).
229#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub struct Comment {
232    /// Commenter login.
233    pub author: String,
234    /// Comment body.
235    pub body: String,
236    /// Web URL of the comment.
237    pub url: String,
238    /// Creation timestamp (ISO 8601).
239    pub created_at: String,
240}
241
242/// The review/comment feedback on a PR (`gh pr view --json reviews,comments`).
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub struct PrFeedback {
246    /// Submitted reviews, oldest first (gh's order).
247    pub reviews: Vec<Review>,
248    /// Conversation comments, oldest first (gh's order).
249    pub comments: Vec<Comment>,
250}
251
252/// A repository (`gh repo view --json name,owner,description,url,isPrivate,defaultBranchRef`).
253#[derive(Debug, Clone, PartialEq, Eq)]
254#[non_exhaustive]
255pub struct Repo {
256    /// Repository name.
257    pub name: String,
258    /// Owner login.
259    pub owner: String,
260    /// Description, `None` when GitHub returns `null`.
261    pub description: Option<String>,
262    /// Web URL.
263    pub url: String,
264    /// `true` for a private repository.
265    pub is_private: bool,
266    /// Default branch name (empty for an empty repository).
267    pub default_branch: String,
268}
269
270// gh nests `owner` and `defaultBranchRef` as objects; deserialize into this and
271// flatten into the public `Repo`.
272#[derive(Deserialize)]
273struct RepoJson {
274    name: String,
275    owner: OwnerJson,
276    #[serde(default)]
277    description: Option<String>,
278    url: String,
279    #[serde(rename = "isPrivate")]
280    is_private: bool,
281    #[serde(rename = "defaultBranchRef", default)]
282    default_branch_ref: Option<BranchRefJson>,
283}
284
285#[derive(Deserialize)]
286struct OwnerJson {
287    login: String,
288}
289
290#[derive(Deserialize)]
291struct BranchRefJson {
292    name: String,
293}
294
295/// Deserialize `gh --json` output into `T`, mapping parse errors to
296/// [`Error::Parse`].
297pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
298    serde_json::from_str(json).map_err(|e| Error::Parse {
299        program: BINARY.to_string(),
300        message: e.to_string(),
301    })
302}
303
304/// Parse `gh repo view --json …` output, flattening the nested objects.
305pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
306    let raw: RepoJson = from_json(json)?;
307    Ok(Repo {
308        name: raw.name,
309        owner: raw.owner.login,
310        description: raw.description,
311        url: raw.url,
312        is_private: raw.is_private,
313        default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
314    })
315}
316
317// gh nests the author as `{"login": …}` (and reports `null` for a deleted
318// account); deserialize into these and flatten into the public types.
319#[derive(Deserialize)]
320struct FeedbackJson {
321    #[serde(default)]
322    reviews: Vec<ReviewJson>,
323    #[serde(default)]
324    comments: Vec<CommentJson>,
325}
326
327#[derive(Deserialize)]
328struct ReviewJson {
329    #[serde(default)]
330    author: Option<AuthorJson>,
331    #[serde(default)]
332    state: String,
333    #[serde(default)]
334    body: String,
335    #[serde(rename = "submittedAt", default)]
336    submitted_at: String,
337}
338
339#[derive(Deserialize)]
340struct CommentJson {
341    #[serde(default)]
342    author: Option<AuthorJson>,
343    #[serde(default)]
344    body: String,
345    #[serde(default)]
346    url: String,
347    #[serde(rename = "createdAt", default)]
348    created_at: String,
349}
350
351#[derive(Deserialize)]
352struct AuthorJson {
353    #[serde(default)]
354    login: String,
355}
356
357/// Parse `gh pr view --json reviews,comments` output, flattening the nested
358/// author objects (a deleted account's `null` author becomes an empty login).
359pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
360    let raw: FeedbackJson = from_json(json)?;
361    Ok(PrFeedback {
362        reviews: raw
363            .reviews
364            .into_iter()
365            .map(|r| Review {
366                author: r.author.map(|a| a.login).unwrap_or_default(),
367                state: r.state,
368                body: r.body,
369                submitted_at: r.submitted_at,
370            })
371            .collect(),
372        comments: raw
373            .comments
374            .into_iter()
375            .map(|c| Comment {
376                author: c.author.map(|a| a.login).unwrap_or_default(),
377                body: c.body,
378                url: c.url,
379                created_at: c.created_at,
380            })
381            .collect(),
382    })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn parses_pr_list() {
391        let json = r#"[
392            {"number": 12, "title": "Add feature", "state": "OPEN",
393             "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
394        ]"#;
395        let prs: Vec<PullRequest> = from_json(json).expect("parse prs");
396        assert_eq!(prs.len(), 1);
397        assert_eq!(
398            prs[0],
399            PullRequest {
400                number: 12,
401                title: "Add feature".into(),
402                state: "OPEN".into(),
403                head_ref_name: "feat/x".into(),
404                base_ref_name: "main".into(),
405                url: "https://gh/pr/12".into(),
406            }
407        );
408    }
409
410    #[test]
411    fn parses_issue_list() {
412        let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
413        let issues: Vec<Issue> = from_json(json).expect("parse issues");
414        assert_eq!(issues[0].number, 3);
415    }
416
417    // gh emits a *present* `null` (not an absent key) for some optional strings —
418    // notably `headRefName`/`baseRefName` on a PR whose head branch was deleted, and
419    // a null `body`. `#[serde(default)]` alone rejects a present null; `null_to_empty`
420    // must turn it into an empty string rather than failing the whole parse.
421    #[test]
422    fn null_optional_fields_parse_to_empty() {
423        let pr: PullRequest = from_json(
424            r#"{"number": 1, "title": "t", "state": "CLOSED",
425                "headRefName": null, "baseRefName": null, "url": null}"#,
426        )
427        .expect("PR with null head/base/url (deleted-branch PR)");
428        assert_eq!(pr.head_ref_name, "");
429        assert_eq!(pr.base_ref_name, "");
430        assert_eq!(pr.url, "");
431
432        let issue: Issue =
433            from_json(r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#)
434                .expect("issue with null body/url");
435        assert_eq!(issue.body, "");
436        assert_eq!(issue.url, "");
437
438        let release: Release = from_json(
439            r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
440        )
441        .expect("release with null name/body/url/publishedAt");
442        assert_eq!(release.name, "");
443        assert_eq!(release.body, "");
444    }
445
446    #[test]
447    fn parses_repo_flattening_nested_objects() {
448        let json = r#"{
449            "name": "vcs-toolkit-rs",
450            "owner": {"login": "ZelAnton"},
451            "description": null,
452            "url": "https://gh/repo",
453            "isPrivate": false,
454            "defaultBranchRef": {"name": "main"}
455        }"#;
456        let repo = parse_repo(json).expect("parse repo");
457        assert_eq!(repo.name, "vcs-toolkit-rs");
458        assert_eq!(repo.owner, "ZelAnton");
459        assert_eq!(repo.description, None);
460        assert_eq!(repo.default_branch, "main");
461        assert!(!repo.is_private);
462    }
463
464    #[test]
465    fn empty_repo_has_blank_default_branch() {
466        let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
467        let repo = parse_repo(json).expect("parse repo");
468        assert_eq!(repo.default_branch, "");
469        assert!(repo.is_private);
470    }
471
472    #[test]
473    fn malformed_json_is_a_parse_error() {
474        match from_json::<Vec<Issue>>("not json").unwrap_err() {
475            Error::Parse { .. } => {}
476            other => panic!("expected Parse, got {other:?}"),
477        }
478    }
479
480    // gh reports `"conclusion": ""` (an empty string, NOT null) while a run is
481    // in progress — the DTO must accept that shape, not demand an Option.
482    #[test]
483    fn parses_run_list_with_blank_in_progress_conclusion() {
484        let json = r#"[
485            {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
486             "status": "in_progress", "conclusion": "", "workflowName": "CI",
487             "headBranch": "main", "event": "push",
488             "url": "https://gh/runs/27023111945",
489             "createdAt": "2026-06-05T10:00:00Z"}
490        ]"#;
491        let runs: Vec<WorkflowRun> = from_json(json).expect("parse runs");
492        assert_eq!(runs[0].database_id, 27023111945);
493        assert_eq!(runs[0].status, "in_progress");
494        assert_eq!(runs[0].conclusion, "");
495        assert_eq!(runs[0].workflow_name, "CI");
496    }
497
498    #[test]
499    fn parses_check_runs_across_buckets() {
500        let json = r#"[
501            {"name": "build", "state": "SUCCESS", "bucket": "pass",
502             "workflow": "CI", "link": "https://gh/c/1",
503             "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
504            {"name": "lint", "state": "FAILURE", "bucket": "fail",
505             "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
506            {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
507             "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
508            {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
509             "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
510            {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
511             "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
512        ]"#;
513        let checks: Vec<CheckRun> = from_json(json).expect("parse checks");
514        let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
515        assert_eq!(
516            buckets,
517            [
518                CheckBucket::Pass,
519                CheckBucket::Fail,
520                CheckBucket::Pending,
521                CheckBucket::Skipping,
522                CheckBucket::Cancel,
523            ]
524        );
525        // An unrecognised bucket deserialises to the forward-compatible catch-all.
526        let exotic: CheckRun =
527            serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
528        assert_eq!(exotic.bucket, CheckBucket::Unknown);
529        assert_eq!(checks[0].name, "build");
530    }
531
532    // `release list` carries isLatest; `release view` does NOT have that field
533    // (it must default to false) but fills body/url.
534    #[test]
535    fn parses_release_list_and_view_shapes() {
536        let list = r#"[
537            {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
538             "isLatest": true, "isDraft": false, "isPrerelease": false,
539             "publishedAt": "2026-06-04T12:00:00Z"}
540        ]"#;
541        let releases: Vec<Release> = from_json(list).expect("parse list");
542        assert!(releases[0].is_latest);
543        assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
544        assert_eq!(releases[0].body, "", "list doesn't fetch the body");
545
546        let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
547            "body": "Added\n- stuff", "url": "https://gh/releases/1",
548            "publishedAt": "2026-06-04T12:00:00Z",
549            "isDraft": false, "isPrerelease": false}"#;
550        let release: Release = from_json(view).expect("parse view");
551        assert!(!release.is_latest, "view has no isLatest → default false");
552        assert_eq!(release.body, "Added\n- stuff");
553        assert_eq!(release.url, "https://gh/releases/1");
554    }
555
556    #[test]
557    fn parses_feedback_flattening_nested_authors() {
558        let json = r#"{
559            "reviews": [
560                {"author": {"login": "steiza"}, "state": "APPROVED",
561                 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
562                {"author": null, "state": "COMMENTED", "body": "ghost",
563                 "submittedAt": ""}
564            ],
565            "comments": [
566                {"author": {"login": "andyfeller"}, "body": "nice",
567                 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
568            ]
569        }"#;
570        let feedback = parse_feedback(json).expect("parse feedback");
571        assert_eq!(feedback.reviews.len(), 2);
572        assert_eq!(feedback.reviews[0].author, "steiza");
573        assert_eq!(feedback.reviews[0].state, "APPROVED");
574        assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
575        assert_eq!(feedback.comments[0].author, "andyfeller");
576        assert_eq!(feedback.comments[0].url, "https://gh/c/9");
577    }
578
579    // The Issue extension must stay backward-compatible with `issue list`
580    // JSON (no body/url requested) while `issue view` fills both.
581    #[test]
582    fn issue_parses_with_and_without_view_fields() {
583        let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
584        let issues: Vec<Issue> = from_json(list).expect("parse list");
585        assert_eq!(issues[0].body, "");
586        assert_eq!(issues[0].url, "");
587
588        let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
589            "body": "Write them.", "url": "https://gh/issues/3"}"#;
590        let issue: Issue = from_json(view).expect("parse view");
591        assert_eq!(issue.body, "Write them.");
592        assert_eq!(issue.url, "https://gh/issues/3");
593    }
594}