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/// A pull request (`gh pr list/view --json number,title,state,headRefName,baseRefName,url`).
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[non_exhaustive]
13pub struct PullRequest {
14    /// PR number.
15    pub number: u64,
16    /// PR title.
17    pub title: String,
18    /// State, e.g. `"OPEN"`, `"MERGED"`, `"CLOSED"`.
19    pub state: String,
20    /// Source (head) branch name.
21    #[serde(rename = "headRefName", default)]
22    pub head_ref_name: String,
23    /// Target (base) branch name.
24    #[serde(rename = "baseRefName", default)]
25    pub base_ref_name: String,
26    /// Web URL.
27    #[serde(default)]
28    pub url: String,
29}
30
31/// An issue (`gh issue list --json number,title,state`;
32/// `gh issue view` additionally fills `body`/`url`).
33#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
34#[non_exhaustive]
35pub struct Issue {
36    /// Issue number.
37    pub number: u64,
38    /// Issue title.
39    pub title: String,
40    /// State, e.g. `"OPEN"`, `"CLOSED"`.
41    pub state: String,
42    /// Issue body (markdown); empty from `issue_list`, which doesn't fetch it.
43    #[serde(default)]
44    pub body: String,
45    /// Web URL; empty from `issue_list`, which doesn't fetch it.
46    #[serde(default)]
47    pub url: String,
48}
49
50/// A GitHub Actions workflow run (`gh run list/view --json …`).
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
52#[non_exhaustive]
53pub struct WorkflowRun {
54    /// The run id (`databaseId`) — the `<run-id>` other `gh run` commands take.
55    #[serde(rename = "databaseId")]
56    pub database_id: u64,
57    /// Workflow name as shown in the runs list.
58    #[serde(default)]
59    pub name: String,
60    /// The run's display title (usually the commit subject).
61    #[serde(rename = "displayTitle", default)]
62    pub display_title: String,
63    /// Lifecycle status, e.g. `"queued"`, `"in_progress"`, `"completed"`.
64    #[serde(default)]
65    pub status: String,
66    /// Outcome, e.g. `"success"`, `"failure"`, `"cancelled"`, `"skipped"` —
67    /// gh reports an **empty string** until the run completes (not `null`).
68    #[serde(default)]
69    pub conclusion: String,
70    /// Name of the workflow that produced the run.
71    #[serde(rename = "workflowName", default)]
72    pub workflow_name: String,
73    /// Branch the run was triggered for.
74    #[serde(rename = "headBranch", default)]
75    pub head_branch: String,
76    /// Triggering event, e.g. `"push"`, `"workflow_dispatch"`.
77    #[serde(default)]
78    pub event: String,
79    /// Web URL.
80    #[serde(default)]
81    pub url: String,
82    /// Creation timestamp (ISO 8601).
83    #[serde(rename = "createdAt", default)]
84    pub created_at: String,
85}
86
87/// One check on a PR (`gh pr checks --json …`).
88#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
89#[non_exhaustive]
90pub struct CheckRun {
91    /// Check name.
92    pub name: String,
93    /// Raw state, e.g. `"SUCCESS"`, `"FAILURE"`, `"IN_PROGRESS"`.
94    #[serde(default)]
95    pub state: String,
96    /// gh's categorisation of `state`: one of `"pass"`, `"fail"`, `"pending"`,
97    /// `"skipping"`, `"cancel"` — the field to branch on.
98    #[serde(default)]
99    pub bucket: String,
100    /// Workflow the check belongs to (empty for non-Actions checks).
101    #[serde(default)]
102    pub workflow: String,
103    /// Web link to the check's details.
104    #[serde(default)]
105    pub link: String,
106    /// Start timestamp (ISO 8601), empty until started.
107    #[serde(rename = "startedAt", default)]
108    pub started_at: String,
109    /// Completion timestamp (ISO 8601), empty until completed.
110    #[serde(rename = "completedAt", default)]
111    pub completed_at: String,
112}
113
114/// A release (`gh release list/view --json …`).
115#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
116#[non_exhaustive]
117pub struct Release {
118    /// The release's tag.
119    #[serde(rename = "tagName")]
120    pub tag_name: String,
121    /// Release title (may be empty).
122    #[serde(default)]
123    pub name: String,
124    /// Release notes (markdown); empty from `release_list`, which doesn't
125    /// fetch it.
126    #[serde(default)]
127    pub body: String,
128    /// Web URL; empty from `release_list`, which doesn't fetch it.
129    #[serde(default)]
130    pub url: String,
131    /// Publication timestamp (ISO 8601); empty for a draft.
132    #[serde(rename = "publishedAt", default)]
133    pub published_at: String,
134    /// `true` for an unpublished draft.
135    #[serde(rename = "isDraft", default)]
136    pub is_draft: bool,
137    /// `true` for a prerelease.
138    #[serde(rename = "isPrerelease", default)]
139    pub is_prerelease: bool,
140    /// `true` for the latest release. Only `release_list` reports this field;
141    /// from `release_view` it defaults to `false`.
142    #[serde(rename = "isLatest", default)]
143    pub is_latest: bool,
144}
145
146/// A submitted PR review (from `gh pr view --json reviews`).
147#[derive(Debug, Clone, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct Review {
150    /// Reviewer login.
151    pub author: String,
152    /// Review state: `"APPROVED"`, `"CHANGES_REQUESTED"`, `"COMMENTED"`,
153    /// `"DISMISSED"` or `"PENDING"`.
154    pub state: String,
155    /// Review body (may be empty).
156    pub body: String,
157    /// Submission timestamp (ISO 8601).
158    pub submitted_at: String,
159}
160
161/// A PR conversation comment (from `gh pr view --json comments`).
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub struct Comment {
165    /// Commenter login.
166    pub author: String,
167    /// Comment body.
168    pub body: String,
169    /// Web URL of the comment.
170    pub url: String,
171    /// Creation timestamp (ISO 8601).
172    pub created_at: String,
173}
174
175/// The review/comment feedback on a PR (`gh pr view --json reviews,comments`).
176#[derive(Debug, Clone, PartialEq, Eq)]
177#[non_exhaustive]
178pub struct PrFeedback {
179    /// Submitted reviews, oldest first (gh's order).
180    pub reviews: Vec<Review>,
181    /// Conversation comments, oldest first (gh's order).
182    pub comments: Vec<Comment>,
183}
184
185/// A repository (`gh repo view --json name,owner,description,url,isPrivate,defaultBranchRef`).
186#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct Repo {
189    /// Repository name.
190    pub name: String,
191    /// Owner login.
192    pub owner: String,
193    /// Description, `None` when GitHub returns `null`.
194    pub description: Option<String>,
195    /// Web URL.
196    pub url: String,
197    /// `true` for a private repository.
198    pub is_private: bool,
199    /// Default branch name (empty for an empty repository).
200    pub default_branch: String,
201}
202
203// gh nests `owner` and `defaultBranchRef` as objects; deserialize into this and
204// flatten into the public `Repo`.
205#[derive(Deserialize)]
206struct RepoJson {
207    name: String,
208    owner: OwnerJson,
209    #[serde(default)]
210    description: Option<String>,
211    url: String,
212    #[serde(rename = "isPrivate")]
213    is_private: bool,
214    #[serde(rename = "defaultBranchRef", default)]
215    default_branch_ref: Option<BranchRefJson>,
216}
217
218#[derive(Deserialize)]
219struct OwnerJson {
220    login: String,
221}
222
223#[derive(Deserialize)]
224struct BranchRefJson {
225    name: String,
226}
227
228/// Deserialize `gh --json` output into `T`, mapping parse errors to
229/// [`Error::Parse`].
230pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
231    serde_json::from_str(json).map_err(|e| Error::Parse {
232        program: BINARY.to_string(),
233        message: e.to_string(),
234    })
235}
236
237/// Parse `gh repo view --json …` output, flattening the nested objects.
238pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
239    let raw: RepoJson = from_json(json)?;
240    Ok(Repo {
241        name: raw.name,
242        owner: raw.owner.login,
243        description: raw.description,
244        url: raw.url,
245        is_private: raw.is_private,
246        default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
247    })
248}
249
250// gh nests the author as `{"login": …}` (and reports `null` for a deleted
251// account); deserialize into these and flatten into the public types.
252#[derive(Deserialize)]
253struct FeedbackJson {
254    #[serde(default)]
255    reviews: Vec<ReviewJson>,
256    #[serde(default)]
257    comments: Vec<CommentJson>,
258}
259
260#[derive(Deserialize)]
261struct ReviewJson {
262    #[serde(default)]
263    author: Option<AuthorJson>,
264    #[serde(default)]
265    state: String,
266    #[serde(default)]
267    body: String,
268    #[serde(rename = "submittedAt", default)]
269    submitted_at: String,
270}
271
272#[derive(Deserialize)]
273struct CommentJson {
274    #[serde(default)]
275    author: Option<AuthorJson>,
276    #[serde(default)]
277    body: String,
278    #[serde(default)]
279    url: String,
280    #[serde(rename = "createdAt", default)]
281    created_at: String,
282}
283
284#[derive(Deserialize)]
285struct AuthorJson {
286    #[serde(default)]
287    login: String,
288}
289
290/// Parse `gh pr view --json reviews,comments` output, flattening the nested
291/// author objects (a deleted account's `null` author becomes an empty login).
292pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
293    let raw: FeedbackJson = from_json(json)?;
294    Ok(PrFeedback {
295        reviews: raw
296            .reviews
297            .into_iter()
298            .map(|r| Review {
299                author: r.author.map(|a| a.login).unwrap_or_default(),
300                state: r.state,
301                body: r.body,
302                submitted_at: r.submitted_at,
303            })
304            .collect(),
305        comments: raw
306            .comments
307            .into_iter()
308            .map(|c| Comment {
309                author: c.author.map(|a| a.login).unwrap_or_default(),
310                body: c.body,
311                url: c.url,
312                created_at: c.created_at,
313            })
314            .collect(),
315    })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn parses_pr_list() {
324        let json = r#"[
325            {"number": 12, "title": "Add feature", "state": "OPEN",
326             "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
327        ]"#;
328        let prs: Vec<PullRequest> = from_json(json).expect("parse prs");
329        assert_eq!(prs.len(), 1);
330        assert_eq!(
331            prs[0],
332            PullRequest {
333                number: 12,
334                title: "Add feature".into(),
335                state: "OPEN".into(),
336                head_ref_name: "feat/x".into(),
337                base_ref_name: "main".into(),
338                url: "https://gh/pr/12".into(),
339            }
340        );
341    }
342
343    #[test]
344    fn parses_issue_list() {
345        let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
346        let issues: Vec<Issue> = from_json(json).expect("parse issues");
347        assert_eq!(issues[0].number, 3);
348    }
349
350    #[test]
351    fn parses_repo_flattening_nested_objects() {
352        let json = r#"{
353            "name": "vcs-toolkit-rs",
354            "owner": {"login": "ZelAnton"},
355            "description": null,
356            "url": "https://gh/repo",
357            "isPrivate": false,
358            "defaultBranchRef": {"name": "main"}
359        }"#;
360        let repo = parse_repo(json).expect("parse repo");
361        assert_eq!(repo.name, "vcs-toolkit-rs");
362        assert_eq!(repo.owner, "ZelAnton");
363        assert_eq!(repo.description, None);
364        assert_eq!(repo.default_branch, "main");
365        assert!(!repo.is_private);
366    }
367
368    #[test]
369    fn empty_repo_has_blank_default_branch() {
370        let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
371        let repo = parse_repo(json).expect("parse repo");
372        assert_eq!(repo.default_branch, "");
373        assert!(repo.is_private);
374    }
375
376    #[test]
377    fn malformed_json_is_a_parse_error() {
378        match from_json::<Vec<Issue>>("not json").unwrap_err() {
379            Error::Parse { .. } => {}
380            other => panic!("expected Parse, got {other:?}"),
381        }
382    }
383
384    // gh reports `"conclusion": ""` (an empty string, NOT null) while a run is
385    // in progress — the DTO must accept that shape, not demand an Option.
386    #[test]
387    fn parses_run_list_with_blank_in_progress_conclusion() {
388        let json = r#"[
389            {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
390             "status": "in_progress", "conclusion": "", "workflowName": "CI",
391             "headBranch": "main", "event": "push",
392             "url": "https://gh/runs/27023111945",
393             "createdAt": "2026-06-05T10:00:00Z"}
394        ]"#;
395        let runs: Vec<WorkflowRun> = from_json(json).expect("parse runs");
396        assert_eq!(runs[0].database_id, 27023111945);
397        assert_eq!(runs[0].status, "in_progress");
398        assert_eq!(runs[0].conclusion, "");
399        assert_eq!(runs[0].workflow_name, "CI");
400    }
401
402    #[test]
403    fn parses_check_runs_across_buckets() {
404        let json = r#"[
405            {"name": "build", "state": "SUCCESS", "bucket": "pass",
406             "workflow": "CI", "link": "https://gh/c/1",
407             "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
408            {"name": "lint", "state": "FAILURE", "bucket": "fail",
409             "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
410            {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
411             "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
412            {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
413             "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
414            {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
415             "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
416        ]"#;
417        let checks: Vec<CheckRun> = from_json(json).expect("parse checks");
418        let buckets: Vec<&str> = checks.iter().map(|c| c.bucket.as_str()).collect();
419        assert_eq!(buckets, ["pass", "fail", "pending", "skipping", "cancel"]);
420        assert_eq!(checks[0].name, "build");
421    }
422
423    // `release list` carries isLatest; `release view` does NOT have that field
424    // (it must default to false) but fills body/url.
425    #[test]
426    fn parses_release_list_and_view_shapes() {
427        let list = r#"[
428            {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
429             "isLatest": true, "isDraft": false, "isPrerelease": false,
430             "publishedAt": "2026-06-04T12:00:00Z"}
431        ]"#;
432        let releases: Vec<Release> = from_json(list).expect("parse list");
433        assert!(releases[0].is_latest);
434        assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
435        assert_eq!(releases[0].body, "", "list doesn't fetch the body");
436
437        let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
438            "body": "Added\n- stuff", "url": "https://gh/releases/1",
439            "publishedAt": "2026-06-04T12:00:00Z",
440            "isDraft": false, "isPrerelease": false}"#;
441        let release: Release = from_json(view).expect("parse view");
442        assert!(!release.is_latest, "view has no isLatest → default false");
443        assert_eq!(release.body, "Added\n- stuff");
444        assert_eq!(release.url, "https://gh/releases/1");
445    }
446
447    #[test]
448    fn parses_feedback_flattening_nested_authors() {
449        let json = r#"{
450            "reviews": [
451                {"author": {"login": "steiza"}, "state": "APPROVED",
452                 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
453                {"author": null, "state": "COMMENTED", "body": "ghost",
454                 "submittedAt": ""}
455            ],
456            "comments": [
457                {"author": {"login": "andyfeller"}, "body": "nice",
458                 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
459            ]
460        }"#;
461        let feedback = parse_feedback(json).expect("parse feedback");
462        assert_eq!(feedback.reviews.len(), 2);
463        assert_eq!(feedback.reviews[0].author, "steiza");
464        assert_eq!(feedback.reviews[0].state, "APPROVED");
465        assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
466        assert_eq!(feedback.comments[0].author, "andyfeller");
467        assert_eq!(feedback.comments[0].url, "https://gh/c/9");
468    }
469
470    // The Issue extension must stay backward-compatible with `issue list`
471    // JSON (no body/url requested) while `issue view` fills both.
472    #[test]
473    fn issue_parses_with_and_without_view_fields() {
474        let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
475        let issues: Vec<Issue> = from_json(list).expect("parse list");
476        assert_eq!(issues[0].body, "");
477        assert_eq!(issues[0].url, "");
478
479        let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
480            "body": "Write them.", "url": "https://gh/issues/3"}"#;
481        let issue: Issue = from_json(view).expect("parse view");
482        assert_eq!(issue.body, "Write them.");
483        assert_eq!(issue.url, "https://gh/issues/3");
484    }
485}