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::Result;
5use serde::Deserialize;
6
7use crate::BINARY;
8
9/// Parse `gh --version` output (`gh version 2.40.1 (2024-01-05)`) into the shared
10/// [`vcs_diff::Version`]: the first dotted-numeric token wins, so gh's `(date)` and
11/// the release-URL trailer on the next line are ignored. `None` when the banner
12/// carries no version token. Reuses the same tolerant parser `vcs-git`/`vcs-jj`
13/// gate on, so the three CLIs share one version-parsing contract.
14pub(crate) fn parse_gh_version(raw: &str) -> Option<vcs_diff::Version> {
15    vcs_diff::parse_dotted_version(raw)
16}
17
18/// A pull request
19/// (`gh pr list/view --json number,title,state,isDraft,headRefName,baseRefName,url`).
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21#[non_exhaustive]
22pub struct PullRequest {
23    /// PR number.
24    pub number: u64,
25    /// PR title.
26    pub title: String,
27    /// State, e.g. `"OPEN"`, `"MERGED"`, `"CLOSED"`.
28    pub state: String,
29    /// Whether the PR is a draft (`gh --json isDraft`).
30    #[serde(rename = "isDraft", default)]
31    pub is_draft: bool,
32    /// Source (head) branch name.
33    #[serde(
34        rename = "headRefName",
35        default,
36        deserialize_with = "vcs_cli_support::json::null_to_empty"
37    )]
38    pub head_ref_name: String,
39    /// Target (base) branch name.
40    #[serde(
41        rename = "baseRefName",
42        default,
43        deserialize_with = "vcs_cli_support::json::null_to_empty"
44    )]
45    pub base_ref_name: String,
46    /// Web URL.
47    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
48    pub url: String,
49    /// Labels attached to the PR (gh `--json labels`, flattened from
50    /// `[{"name": "bug", ...}]` to plain names).
51    #[serde(default, deserialize_with = "labels_to_names")]
52    pub labels: Vec<String>,
53    /// Logins of assigned users (gh `--json assignees`, flattened from
54    /// `[{"login": "octocat", ...}]` to plain logins).
55    #[serde(default, deserialize_with = "assignees_to_logins")]
56    pub assignees: Vec<String>,
57}
58
59/// An issue (`gh issue list --json number,title,state`;
60/// `gh issue view` additionally fills `body`/`url`).
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[non_exhaustive]
63pub struct Issue {
64    /// Issue number.
65    pub number: u64,
66    /// Issue title.
67    pub title: String,
68    /// State, e.g. `"OPEN"`, `"CLOSED"`.
69    pub state: String,
70    /// Issue body (markdown). Fetched by both `issue_list` and `issue_view`.
71    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
72    pub body: String,
73    /// Web URL. Fetched by both `issue_list` and `issue_view`.
74    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
75    pub url: String,
76    /// Labels attached to the issue (gh `--json labels`, flattened from
77    /// `[{"name": "bug", ...}]` to plain names).
78    #[serde(default, deserialize_with = "labels_to_names")]
79    pub labels: Vec<String>,
80    /// Logins of assigned users (gh `--json assignees`, flattened from
81    /// `[{"login": "octocat", ...}]` to plain logins).
82    #[serde(default, deserialize_with = "assignees_to_logins")]
83    pub assignees: Vec<String>,
84}
85
86// gh emits both `labels` and `assignees` as arrays of objects (`[{"name": …}]`,
87// `[{"login": …}]`), not plain strings — flatten each into a `Vec<String>`.
88// `Option<Vec<_>>` (not a bare `Vec<_>`) so a present JSON `null` — like the
89// other optional fields in this file — degrades to an empty list rather than
90// failing the whole parse.
91#[derive(Deserialize)]
92struct LabelJson {
93    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
94    name: String,
95}
96
97#[derive(Deserialize)]
98struct AssigneeJson {
99    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
100    login: String,
101}
102
103fn labels_to_names<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
104where
105    D: serde::Deserializer<'de>,
106{
107    let raw = Option::<Vec<LabelJson>>::deserialize(deserializer)?.unwrap_or_default();
108    Ok(raw.into_iter().map(|l| l.name).collect())
109}
110
111fn assignees_to_logins<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
112where
113    D: serde::Deserializer<'de>,
114{
115    let raw = Option::<Vec<AssigneeJson>>::deserialize(deserializer)?.unwrap_or_default();
116    Ok(raw.into_iter().map(|a| a.login).collect())
117}
118
119/// A GitHub Actions workflow run (`gh run list/view --json …`).
120#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
121#[non_exhaustive]
122pub struct WorkflowRun {
123    /// The run id (`databaseId`) — the `<run-id>` other `gh run` commands take.
124    #[serde(rename = "databaseId")]
125    pub database_id: u64,
126    /// Workflow name as shown in the runs list.
127    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
128    pub name: String,
129    /// The run's display title (usually the commit subject).
130    #[serde(
131        rename = "displayTitle",
132        default,
133        deserialize_with = "vcs_cli_support::json::null_to_empty"
134    )]
135    pub display_title: String,
136    /// Lifecycle status, e.g. `"queued"`, `"in_progress"`, `"completed"`.
137    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
138    pub status: String,
139    /// Outcome, e.g. `"success"`, `"failure"`, `"cancelled"`, `"skipped"` —
140    /// gh reports an **empty string** until the run completes (not `null`).
141    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
142    pub conclusion: String,
143    /// Name of the workflow that produced the run.
144    #[serde(
145        rename = "workflowName",
146        default,
147        deserialize_with = "vcs_cli_support::json::null_to_empty"
148    )]
149    pub workflow_name: String,
150    /// Branch the run was triggered for.
151    #[serde(
152        rename = "headBranch",
153        default,
154        deserialize_with = "vcs_cli_support::json::null_to_empty"
155    )]
156    pub head_branch: String,
157    /// Triggering event, e.g. `"push"`, `"workflow_dispatch"`.
158    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
159    pub event: String,
160    /// Web URL.
161    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
162    pub url: String,
163    /// Creation timestamp (ISO 8601).
164    #[serde(
165        rename = "createdAt",
166        default,
167        deserialize_with = "vcs_cli_support::json::null_to_empty"
168    )]
169    pub created_at: String,
170}
171
172/// gh's coarse categorisation of a [`CheckRun`]'s state — the field to branch on
173/// when deciding whether CI passed. `gh` derives it from the raw `state`; this is
174/// the typed form of its `pass`/`fail`/`pending`/`skipping`/`cancel` strings.
175///
176/// `#[non_exhaustive]` with an [`Unknown`](CheckBucket::Unknown) catch-all: a
177/// bucket name a future `gh` introduces (or a missing field) deserialises to
178/// `Unknown` rather than failing the parse, so the wrapper never breaks on an
179/// unmodelled value.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
181#[serde(rename_all = "lowercase")]
182#[non_exhaustive]
183pub enum CheckBucket {
184    /// The check succeeded.
185    Pass,
186    /// The check failed.
187    Fail,
188    /// The check is queued or still running.
189    Pending,
190    /// The check was skipped (e.g. a conditional job that didn't run).
191    Skipping,
192    /// The check was cancelled.
193    Cancel,
194    /// A bucket `gh` reported that this version doesn't model, or an absent field.
195    #[default]
196    #[serde(other)]
197    Unknown,
198}
199
200impl CheckBucket {
201    /// Whether this bucket means the check failed or was cancelled — the states
202    /// that should fail an aggregate CI verdict.
203    pub fn is_failing(self) -> bool {
204        matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
205    }
206
207    /// Whether this bucket means the check is still in flight (queued/running).
208    pub fn is_pending(self) -> bool {
209        matches!(self, CheckBucket::Pending)
210    }
211
212    /// Whether this bucket means the check completed successfully.
213    pub fn is_passing(self) -> bool {
214        matches!(self, CheckBucket::Pass)
215    }
216
217    /// Whether this is the [`Unknown`](CheckBucket::Unknown) catch-all — a bucket a
218    /// future `gh` introduced (or a missing field) that this version doesn't model.
219    /// Distinct from [`Skipping`](CheckBucket::Skipping): a skip is a deliberate,
220    /// terminal no-op, whereas an unknown bucket is *unclassified* and should be
221    /// treated conservatively (as "not known to be done") by an aggregator.
222    pub fn is_unknown(self) -> bool {
223        matches!(self, CheckBucket::Unknown)
224    }
225}
226
227/// One check on a PR (`gh pr checks --json …`).
228#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
229#[non_exhaustive]
230pub struct CheckRun {
231    /// Check name.
232    pub name: String,
233    /// Raw state, e.g. `"SUCCESS"`, `"FAILURE"`, `"IN_PROGRESS"`.
234    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
235    pub state: String,
236    /// gh's categorisation of `state` — the field to branch on. See [`CheckBucket`].
237    #[serde(default)]
238    pub bucket: CheckBucket,
239    /// Workflow the check belongs to (empty for non-Actions checks).
240    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
241    pub workflow: String,
242    /// Web link to the check's details.
243    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
244    pub link: String,
245    /// Start timestamp (ISO 8601), empty until started.
246    #[serde(
247        rename = "startedAt",
248        default,
249        deserialize_with = "vcs_cli_support::json::null_to_empty"
250    )]
251    pub started_at: String,
252    /// Completion timestamp (ISO 8601), empty until completed.
253    #[serde(
254        rename = "completedAt",
255        default,
256        deserialize_with = "vcs_cli_support::json::null_to_empty"
257    )]
258    pub completed_at: String,
259}
260
261/// A release (`gh release list/view --json …`).
262#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
263#[non_exhaustive]
264pub struct Release {
265    /// The release's tag.
266    #[serde(rename = "tagName")]
267    pub tag_name: String,
268    /// Release title (may be empty/null).
269    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
270    pub name: String,
271    /// Release notes (markdown). `None` from `release_list`, which doesn't request
272    /// the field (only `release_view` does) — so an absent value reads as the
273    /// honest "not fetched", not a false empty string. A present JSON `null` (a
274    /// release genuinely without notes) likewise reads as `None`.
275    #[serde(default)]
276    pub body: Option<String>,
277    /// Web URL. `None` from `release_list`, which doesn't request the field (only
278    /// `release_view` does) — so an absent value reads as the honest "not fetched",
279    /// not a false empty string. A present JSON `null` likewise reads as `None`.
280    #[serde(default)]
281    pub url: Option<String>,
282    /// Publication timestamp (ISO 8601); empty/null for a draft.
283    #[serde(
284        rename = "publishedAt",
285        default,
286        deserialize_with = "vcs_cli_support::json::null_to_empty"
287    )]
288    pub published_at: String,
289    /// `true` for an unpublished draft.
290    #[serde(rename = "isDraft", default)]
291    pub is_draft: bool,
292    /// `true` for a prerelease.
293    #[serde(rename = "isPrerelease", default)]
294    pub is_prerelease: bool,
295    /// `true` for the latest release. Only `release_list` reports this field;
296    /// from `release_view` it defaults to `false`.
297    #[serde(rename = "isLatest", default)]
298    pub is_latest: bool,
299}
300
301/// A submitted PR review (from `gh pr view --json reviews`).
302#[derive(Debug, Clone, PartialEq, Eq)]
303#[non_exhaustive]
304pub struct Review {
305    /// Reviewer login.
306    pub author: String,
307    /// Review state: `"APPROVED"`, `"CHANGES_REQUESTED"`, `"COMMENTED"`,
308    /// `"DISMISSED"` or `"PENDING"`.
309    pub state: String,
310    /// Review body (may be empty).
311    pub body: String,
312    /// Submission timestamp (ISO 8601).
313    pub submitted_at: String,
314}
315
316/// A PR conversation comment (from `gh pr view --json comments`).
317#[derive(Debug, Clone, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct Comment {
320    /// Commenter login.
321    pub author: String,
322    /// Comment body.
323    pub body: String,
324    /// Web URL of the comment.
325    pub url: String,
326    /// Creation timestamp (ISO 8601).
327    pub created_at: String,
328}
329
330/// The review/comment feedback on a PR (`gh pr view --json reviews,comments`).
331#[derive(Debug, Clone, PartialEq, Eq)]
332#[non_exhaustive]
333pub struct PrFeedback {
334    /// Submitted reviews, oldest first (gh's order).
335    pub reviews: Vec<Review>,
336    /// Conversation comments, oldest first (gh's order).
337    pub comments: Vec<Comment>,
338}
339
340/// A repository (`gh repo view --json name,owner,description,url,isPrivate,defaultBranchRef`).
341#[derive(Debug, Clone, PartialEq, Eq)]
342#[non_exhaustive]
343pub struct RepoView {
344    /// Repository name.
345    pub name: String,
346    /// Owner login.
347    pub owner: String,
348    /// Description, `None` when GitHub returns `null`.
349    pub description: Option<String>,
350    /// Web URL.
351    pub url: String,
352    /// `true` for a private repository.
353    pub is_private: bool,
354    /// Default branch name (empty for an empty repository).
355    pub default_branch: String,
356}
357
358// gh nests `owner` and `defaultBranchRef` as objects; deserialize into this and
359// flatten into the public `RepoView`.
360#[derive(Deserialize)]
361struct RepoJson {
362    name: String,
363    owner: OwnerJson,
364    #[serde(default)]
365    description: Option<String>,
366    url: String,
367    #[serde(rename = "isPrivate")]
368    is_private: bool,
369    #[serde(rename = "defaultBranchRef", default)]
370    default_branch_ref: Option<BranchRefJson>,
371}
372
373#[derive(Deserialize)]
374struct OwnerJson {
375    login: String,
376}
377
378#[derive(Deserialize)]
379struct BranchRefJson {
380    name: String,
381}
382
383/// Parse `gh repo view --json …` output, flattening the nested objects.
384pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
385    let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
386    Ok(RepoView {
387        name: raw.name,
388        owner: raw.owner.login,
389        description: raw.description,
390        url: raw.url,
391        is_private: raw.is_private,
392        default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
393    })
394}
395
396// gh nests the author as `{"login": …}` (and reports `null` for a deleted
397// account); deserialize into these and flatten into the public types.
398#[derive(Deserialize)]
399struct FeedbackJson {
400    #[serde(default)]
401    reviews: Vec<ReviewJson>,
402    #[serde(default)]
403    comments: Vec<CommentJson>,
404}
405
406// Optional string fields use `null_to_empty` (not bare `default`) so a present
407// JSON `null` maps to "" like an absent key — uniform with the rest of this
408// crate's `gh --json` DTOs, robust to whatever `gh` emits for an empty value.
409#[derive(Deserialize)]
410struct ReviewJson {
411    #[serde(default)]
412    author: Option<AuthorJson>,
413    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
414    state: String,
415    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
416    body: String,
417    #[serde(
418        rename = "submittedAt",
419        default,
420        deserialize_with = "vcs_cli_support::json::null_to_empty"
421    )]
422    submitted_at: String,
423}
424
425#[derive(Deserialize)]
426struct CommentJson {
427    #[serde(default)]
428    author: Option<AuthorJson>,
429    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
430    body: String,
431    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
432    url: String,
433    #[serde(
434        rename = "createdAt",
435        default,
436        deserialize_with = "vcs_cli_support::json::null_to_empty"
437    )]
438    created_at: String,
439}
440
441#[derive(Deserialize)]
442struct AuthorJson {
443    #[serde(default)]
444    login: String,
445}
446
447/// Parse `gh pr view --json reviews,comments` output, flattening the nested
448/// author objects (a deleted account's `null` author becomes an empty login).
449pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
450    let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
451    Ok(PrFeedback {
452        reviews: raw
453            .reviews
454            .into_iter()
455            .map(|r| Review {
456                author: r.author.map(|a| a.login).unwrap_or_default(),
457                state: r.state,
458                body: r.body,
459                submitted_at: r.submitted_at,
460            })
461            .collect(),
462        comments: raw
463            .comments
464            .into_iter()
465            .map(|c| Comment {
466                author: c.author.map(|a| a.login).unwrap_or_default(),
467                body: c.body,
468                url: c.url,
469                created_at: c.created_at,
470            })
471            .collect(),
472    })
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use processkit::Error;
479
480    #[test]
481    fn parses_pr_list() {
482        let json = r#"[
483            {"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": true,
484             "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
485        ]"#;
486        let prs: Vec<PullRequest> =
487            vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
488        assert_eq!(prs.len(), 1);
489        assert_eq!(
490            prs[0],
491            PullRequest {
492                number: 12,
493                title: "Add feature".into(),
494                state: "OPEN".into(),
495                is_draft: true,
496                head_ref_name: "feat/x".into(),
497                base_ref_name: "main".into(),
498                url: "https://gh/pr/12".into(),
499                labels: Vec::new(),
500                assignees: Vec::new(),
501            }
502        );
503    }
504
505    // Positive case: gh's `--json labels,assignees` shape (`[{"name": …}]`,
506    // `[{"login": …}]`) flattens to plain `Vec<String>`.
507    #[test]
508    fn pr_parses_labels_and_assignees() {
509        let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
510            "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
511            "labels": [{"name": "bug", "color": "f00"}, {"name": "priority-1"}],
512            "assignees": [{"login": "octocat", "id": 1}, {"login": "hubot"}]}"#;
513        let pr: PullRequest =
514            vcs_cli_support::json::from_json(BINARY, json).expect("parse pr with labels/assignees");
515        assert_eq!(pr.labels, vec!["bug".to_string(), "priority-1".to_string()]);
516        assert_eq!(
517            pr.assignees,
518            vec!["octocat".to_string(), "hubot".to_string()]
519        );
520    }
521
522    // Negative case: an empty `labels`/`assignees` array parses to an empty
523    // `Vec`, not a panic or parse error. And when the keys are absent entirely
524    // (e.g. an older canned fixture), `#[serde(default)]` fills the same empty
525    // `Vec`.
526    #[test]
527    fn pr_without_labels_or_assignees_parses_to_empty_vecs() {
528        let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
529            "headRefName": "h", "baseRefName": "main", "url": "u",
530            "labels": [], "assignees": []}"#;
531        let pr: PullRequest =
532            vcs_cli_support::json::from_json(BINARY, json).expect("PR with empty labels/assignees");
533        assert!(pr.labels.is_empty());
534        assert!(pr.assignees.is_empty());
535
536        let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
537            BINARY,
538            r#"{"number": 14, "title": "t", "state": "OPEN",
539                "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
540        )
541        .expect("PR without labels/assignees keys");
542        assert!(pr_no_keys.labels.is_empty());
543        assert!(pr_no_keys.assignees.is_empty());
544    }
545
546    // `#[serde(default)]` robustness: a payload that omits `isDraft` deserializes
547    // to `false` rather than failing the whole parse. (When we request `--json
548    // isDraft`, gh emits the key or hard-errors on an unknown field — it never
549    // silently omits it — so this guards our own tolerance, not a real gh quirk.)
550    #[test]
551    fn pr_without_is_draft_defaults_false() {
552        let pr: PullRequest = vcs_cli_support::json::from_json(
553            BINARY,
554            r#"{"number": 4, "title": "t", "state": "OPEN",
555                "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
556        )
557        .expect("PR without isDraft");
558        assert!(!pr.is_draft);
559    }
560
561    #[test]
562    fn parses_issue_list() {
563        let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
564        let issues: Vec<Issue> =
565            vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
566        assert_eq!(issues[0].number, 3);
567    }
568
569    // gh emits a *present* `null` (not an absent key) for some optional strings —
570    // notably `headRefName`/`baseRefName` on a PR whose head branch was deleted, and
571    // a null `body`. `#[serde(default)]` alone rejects a present null; `null_to_empty`
572    // must turn it into an empty string rather than failing the whole parse.
573    #[test]
574    fn null_optional_fields_parse_to_empty() {
575        let pr: PullRequest = vcs_cli_support::json::from_json(
576            BINARY,
577            r#"{"number": 1, "title": "t", "state": "CLOSED",
578                "headRefName": null, "baseRefName": null, "url": null}"#,
579        )
580        .expect("PR with null head/base/url (deleted-branch PR)");
581        assert_eq!(pr.head_ref_name, "");
582        assert_eq!(pr.base_ref_name, "");
583        assert_eq!(pr.url, "");
584
585        let issue: Issue = vcs_cli_support::json::from_json(
586            BINARY,
587            r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
588        )
589        .expect("issue with null body/url");
590        assert_eq!(issue.body, "");
591        assert_eq!(issue.url, "");
592
593        let release: Release = vcs_cli_support::json::from_json(
594            BINARY,
595            r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null}"#,
596        )
597        .expect("release with null name/body/url/publishedAt");
598        assert_eq!(release.name, "");
599        // `body`/`url` are `Option`: a present `null` reads as `None`, not "".
600        assert_eq!(release.body, None);
601        assert_eq!(release.url, None);
602    }
603
604    #[test]
605    fn parses_repo_flattening_nested_objects() {
606        let json = r#"{
607            "name": "vcs-toolkit-rs",
608            "owner": {"login": "ZelAnton"},
609            "description": null,
610            "url": "https://gh/repo",
611            "isPrivate": false,
612            "defaultBranchRef": {"name": "main"}
613        }"#;
614        let repo = parse_repo(json).expect("parse repo");
615        assert_eq!(repo.name, "vcs-toolkit-rs");
616        assert_eq!(repo.owner, "ZelAnton");
617        assert_eq!(repo.description, None);
618        assert_eq!(repo.default_branch, "main");
619        assert!(!repo.is_private);
620    }
621
622    #[test]
623    fn empty_repo_has_blank_default_branch() {
624        let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
625        let repo = parse_repo(json).expect("parse repo");
626        assert_eq!(repo.default_branch, "");
627        assert!(repo.is_private);
628    }
629
630    #[test]
631    fn malformed_json_is_a_parse_error() {
632        match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json").unwrap_err() {
633            Error::Parse { .. } => {}
634            other => panic!("expected Parse, got {other:?}"),
635        }
636    }
637
638    // gh reports `"conclusion": ""` (an empty string, NOT null) while a run is
639    // in progress — the DTO must accept that shape, not demand an Option.
640    #[test]
641    fn parses_run_list_with_blank_in_progress_conclusion() {
642        let json = r#"[
643            {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
644             "status": "in_progress", "conclusion": "", "workflowName": "CI",
645             "headBranch": "main", "event": "push",
646             "url": "https://gh/runs/27023111945",
647             "createdAt": "2026-06-05T10:00:00Z"}
648        ]"#;
649        let runs: Vec<WorkflowRun> =
650            vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
651        assert_eq!(runs[0].database_id, 27023111945);
652        assert_eq!(runs[0].status, "in_progress");
653        assert_eq!(runs[0].conclusion, "");
654        assert_eq!(runs[0].workflow_name, "CI");
655    }
656
657    #[test]
658    fn parses_check_runs_across_buckets() {
659        let json = r#"[
660            {"name": "build", "state": "SUCCESS", "bucket": "pass",
661             "workflow": "CI", "link": "https://gh/c/1",
662             "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
663            {"name": "lint", "state": "FAILURE", "bucket": "fail",
664             "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
665            {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
666             "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
667            {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
668             "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
669            {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
670             "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
671        ]"#;
672        let checks: Vec<CheckRun> =
673            vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
674        let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
675        assert_eq!(
676            buckets,
677            [
678                CheckBucket::Pass,
679                CheckBucket::Fail,
680                CheckBucket::Pending,
681                CheckBucket::Skipping,
682                CheckBucket::Cancel,
683            ]
684        );
685        // An unrecognised bucket deserialises to the forward-compatible catch-all.
686        let exotic: CheckRun =
687            serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
688        assert_eq!(exotic.bucket, CheckBucket::Unknown);
689        assert_eq!(checks[0].name, "build");
690    }
691
692    // `release list` carries isLatest; `release view` does NOT have that field
693    // (it must default to false) but fills body/url.
694    #[test]
695    fn parses_release_list_and_view_shapes() {
696        let list = r#"[
697            {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
698             "isLatest": true, "isDraft": false, "isPrerelease": false,
699             "publishedAt": "2026-06-04T12:00:00Z"}
700        ]"#;
701        let releases: Vec<Release> =
702            vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
703        assert!(releases[0].is_latest);
704        assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
705        assert_eq!(
706            releases[0].body, None,
707            "list doesn't request the body → None"
708        );
709        assert_eq!(releases[0].url, None, "list doesn't request the url → None");
710
711        let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
712            "body": "Added\n- stuff", "url": "https://gh/releases/1",
713            "publishedAt": "2026-06-04T12:00:00Z",
714            "isDraft": false, "isPrerelease": false}"#;
715        let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
716        assert!(!release.is_latest, "view has no isLatest → default false");
717        assert_eq!(release.body.as_deref(), Some("Added\n- stuff"));
718        assert_eq!(release.url.as_deref(), Some("https://gh/releases/1"));
719    }
720
721    #[test]
722    fn parses_feedback_flattening_nested_authors() {
723        let json = r#"{
724            "reviews": [
725                {"author": {"login": "steiza"}, "state": "APPROVED",
726                 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
727                {"author": null, "state": "COMMENTED", "body": "ghost",
728                 "submittedAt": ""}
729            ],
730            "comments": [
731                {"author": {"login": "andyfeller"}, "body": "nice",
732                 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
733            ]
734        }"#;
735        let feedback = parse_feedback(json).expect("parse feedback");
736        assert_eq!(feedback.reviews.len(), 2);
737        assert_eq!(feedback.reviews[0].author, "steiza");
738        assert_eq!(feedback.reviews[0].state, "APPROVED");
739        assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
740        assert_eq!(feedback.comments[0].author, "andyfeller");
741        assert_eq!(feedback.comments[0].url, "https://gh/c/9");
742    }
743
744    // The Issue extension must stay backward-compatible with `issue list`
745    // JSON (no body/url requested) while `issue view` fills both.
746    #[test]
747    fn issue_parses_with_and_without_view_fields() {
748        let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
749        let issues: Vec<Issue> =
750            vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
751        assert_eq!(issues[0].body, "");
752        assert_eq!(issues[0].url, "");
753
754        let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
755            "body": "Write them.", "url": "https://gh/issues/3"}"#;
756        let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
757        assert_eq!(issue.body, "Write them.");
758        assert_eq!(issue.url, "https://gh/issues/3");
759        assert!(issue.labels.is_empty());
760        assert!(issue.assignees.is_empty());
761    }
762
763    // Positive case for issues, mirroring `pr_parses_labels_and_assignees`.
764    #[test]
765    fn issue_parses_labels_and_assignees() {
766        let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
767            "body": "b", "url": "https://gh/issues/3",
768            "labels": [{"name": "docs"}, {"name": "good-first-issue"}],
769            "assignees": [{"login": "andyfeller"}]}"#;
770        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
771            .expect("parse issue with labels/assignees");
772        assert_eq!(
773            issue.labels,
774            vec!["docs".to_string(), "good-first-issue".to_string()]
775        );
776        assert_eq!(issue.assignees, vec!["andyfeller".to_string()]);
777    }
778
779    // Negative case for issues: empty arrays parse to empty `Vec`s, not an error.
780    #[test]
781    fn issue_without_labels_or_assignees_parses_to_empty_vecs() {
782        let json = r#"{"number": 4, "title": "t", "state": "CLOSED",
783            "labels": [], "assignees": []}"#;
784        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
785            .expect("issue with empty labels/assignees");
786        assert!(issue.labels.is_empty());
787        assert!(issue.assignees.is_empty());
788    }
789}