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    /// Author's login (gh `--json author`, flattened from `{"login": …}`; a
58    /// deleted account's `null` author becomes an empty string, matching the
59    /// existing PR feedback author flatten).
60    #[serde(default, deserialize_with = "author_login")]
61    pub author: String,
62    /// Creation timestamp (RFC 3339) (gh `--json createdAt`).
63    #[serde(
64        rename = "createdAt",
65        default,
66        deserialize_with = "vcs_cli_support::json::null_to_empty"
67    )]
68    pub created_at: String,
69    /// Last-update timestamp (RFC 3339) (gh `--json updatedAt`).
70    #[serde(
71        rename = "updatedAt",
72        default,
73        deserialize_with = "vcs_cli_support::json::null_to_empty"
74    )]
75    pub updated_at: String,
76    /// Milestone title, or `None` when no milestone is attached (gh `--json
77    /// milestone`, flattened from `{"title": …}`; a `null` milestone becomes
78    /// `None`).
79    #[serde(default, deserialize_with = "milestone_to_title")]
80    pub milestone: Option<String>,
81}
82
83/// An issue (`gh issue list --json number,title,state`;
84/// `gh issue view` additionally fills `body`/`url`).
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[non_exhaustive]
87pub struct Issue {
88    /// Issue number.
89    pub number: u64,
90    /// Issue title.
91    pub title: String,
92    /// State, e.g. `"OPEN"`, `"CLOSED"`.
93    pub state: String,
94    /// Issue body (markdown). Fetched by both `issue_list` and `issue_view`.
95    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
96    pub body: String,
97    /// Web URL. Fetched by both `issue_list` and `issue_view`.
98    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
99    pub url: String,
100    /// Labels attached to the issue (gh `--json labels`, flattened from
101    /// `[{"name": "bug", ...}]` to plain names).
102    #[serde(default, deserialize_with = "labels_to_names")]
103    pub labels: Vec<String>,
104    /// Logins of assigned users (gh `--json assignees`, flattened from
105    /// `[{"login": "octocat", ...}]` to plain logins).
106    #[serde(default, deserialize_with = "assignees_to_logins")]
107    pub assignees: Vec<String>,
108    /// Author's login (gh `--json author`, flattened from `{"login": …}`; a
109    /// deleted account's `null` author becomes an empty string, matching the
110    /// existing PR feedback author flatten).
111    #[serde(default, deserialize_with = "author_login")]
112    pub author: String,
113    /// Creation timestamp (RFC 3339) (gh `--json createdAt`).
114    #[serde(
115        rename = "createdAt",
116        default,
117        deserialize_with = "vcs_cli_support::json::null_to_empty"
118    )]
119    pub created_at: String,
120    /// Last-update timestamp (RFC 3339) (gh `--json updatedAt`).
121    #[serde(
122        rename = "updatedAt",
123        default,
124        deserialize_with = "vcs_cli_support::json::null_to_empty"
125    )]
126    pub updated_at: String,
127    /// Milestone title, or `None` when no milestone is attached (gh `--json
128    /// milestone`, flattened from `{"title": …}`; a `null` milestone becomes
129    /// `None`).
130    #[serde(default, deserialize_with = "milestone_to_title")]
131    pub milestone: Option<String>,
132}
133
134// gh emits both `labels` and `assignees` as arrays of objects (`[{"name": …}]`,
135// `[{"login": …}]`), not plain strings — flatten each into a `Vec<String>`.
136// `Option<Vec<_>>` (not a bare `Vec<_>`) so a present JSON `null` — like the
137// other optional fields in this file — degrades to an empty list rather than
138// failing the whole parse.
139#[derive(Deserialize)]
140struct LabelJson {
141    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
142    name: String,
143}
144
145#[derive(Deserialize)]
146struct AssigneeJson {
147    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
148    login: String,
149}
150
151fn labels_to_names<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
152where
153    D: serde::Deserializer<'de>,
154{
155    let raw = Option::<Vec<LabelJson>>::deserialize(deserializer)?.unwrap_or_default();
156    Ok(raw.into_iter().map(|l| l.name).collect())
157}
158
159fn assignees_to_logins<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
160where
161    D: serde::Deserializer<'de>,
162{
163    let raw = Option::<Vec<AssigneeJson>>::deserialize(deserializer)?.unwrap_or_default();
164    Ok(raw.into_iter().map(|a| a.login).collect())
165}
166
167// gh nests a PR/issue/release `author` as `{"login": …}` (and reports `null` for
168// a deleted account) — the same shape `AuthorJson` (below) flattens for PR
169// feedback; reused here so an author's `null` uniformly becomes an empty login.
170fn author_login<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
171where
172    D: serde::Deserializer<'de>,
173{
174    let raw = Option::<AuthorJson>::deserialize(deserializer)?;
175    Ok(raw.map(|a| a.login).unwrap_or_default())
176}
177
178fn author_login_opt<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
179where
180    D: serde::Deserializer<'de>,
181{
182    let raw = Option::<AuthorJson>::deserialize(deserializer)?;
183    Ok(raw.map(|a| a.login))
184}
185
186// gh nests `milestone` as `{"title": …}`, `null` when none is attached.
187#[derive(Deserialize)]
188struct MilestoneJson {
189    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
190    title: String,
191}
192
193fn milestone_to_title<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
194where
195    D: serde::Deserializer<'de>,
196{
197    let raw = Option::<MilestoneJson>::deserialize(deserializer)?;
198    Ok(raw.map(|m| m.title))
199}
200
201/// A GitHub Actions workflow run (`gh run list/view --json …`).
202#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
203#[non_exhaustive]
204pub struct WorkflowRun {
205    /// The run id (`databaseId`) — the `<run-id>` other `gh run` commands take.
206    #[serde(rename = "databaseId")]
207    pub database_id: u64,
208    /// Workflow name as shown in the runs list.
209    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
210    pub name: String,
211    /// The run's display title (usually the commit subject).
212    #[serde(
213        rename = "displayTitle",
214        default,
215        deserialize_with = "vcs_cli_support::json::null_to_empty"
216    )]
217    pub display_title: String,
218    /// Lifecycle status, e.g. `"queued"`, `"in_progress"`, `"completed"`.
219    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
220    pub status: String,
221    /// Outcome, e.g. `"success"`, `"failure"`, `"cancelled"`, `"skipped"` —
222    /// gh reports an **empty string** until the run completes (not `null`).
223    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
224    pub conclusion: String,
225    /// Name of the workflow that produced the run.
226    #[serde(
227        rename = "workflowName",
228        default,
229        deserialize_with = "vcs_cli_support::json::null_to_empty"
230    )]
231    pub workflow_name: String,
232    /// Branch the run was triggered for.
233    #[serde(
234        rename = "headBranch",
235        default,
236        deserialize_with = "vcs_cli_support::json::null_to_empty"
237    )]
238    pub head_branch: String,
239    /// Triggering event, e.g. `"push"`, `"workflow_dispatch"`.
240    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
241    pub event: String,
242    /// Web URL.
243    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
244    pub url: String,
245    /// Creation timestamp (ISO 8601).
246    #[serde(
247        rename = "createdAt",
248        default,
249        deserialize_with = "vcs_cli_support::json::null_to_empty"
250    )]
251    pub created_at: String,
252}
253
254/// A GitHub Actions workflow definition (`gh workflow list --json …`).
255#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
256#[non_exhaustive]
257pub struct Workflow {
258    /// The workflow's repository-scoped database id.
259    pub id: u64,
260    /// Display name from the workflow file.
261    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
262    pub name: String,
263    /// Repository-relative workflow file path (normally `.github/workflows/*.yml`).
264    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
265    pub path: String,
266    /// GitHub state, e.g. `"active"`, `"disabled_manually"`, or
267    /// `"disabled_inactivity"`.
268    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
269    pub state: String,
270}
271
272/// gh's coarse categorisation of a [`CheckRun`]'s state — the field to branch on
273/// when deciding whether CI passed. `gh` derives it from the raw `state`; this is
274/// the typed form of its `pass`/`fail`/`pending`/`skipping`/`cancel` strings.
275///
276/// `#[non_exhaustive]` with an [`Unknown`](CheckBucket::Unknown) catch-all: a
277/// bucket name a future `gh` introduces (or a missing field) deserialises to
278/// `Unknown` rather than failing the parse, so the wrapper never breaks on an
279/// unmodelled value.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
281#[serde(rename_all = "lowercase")]
282#[non_exhaustive]
283pub enum CheckBucket {
284    /// The check succeeded.
285    Pass,
286    /// The check failed.
287    Fail,
288    /// The check is queued or still running.
289    Pending,
290    /// The check was skipped (e.g. a conditional job that didn't run).
291    Skipping,
292    /// The check was cancelled.
293    Cancel,
294    /// A bucket `gh` reported that this version doesn't model, or an absent field.
295    #[default]
296    #[serde(other)]
297    Unknown,
298}
299
300impl CheckBucket {
301    /// Whether this bucket means the check failed or was cancelled — the states
302    /// that should fail an aggregate CI verdict.
303    pub fn is_failing(self) -> bool {
304        matches!(self, CheckBucket::Fail | CheckBucket::Cancel)
305    }
306
307    /// Whether this bucket means the check is still in flight (queued/running).
308    pub fn is_pending(self) -> bool {
309        matches!(self, CheckBucket::Pending)
310    }
311
312    /// Whether this bucket means the check completed successfully.
313    pub fn is_passing(self) -> bool {
314        matches!(self, CheckBucket::Pass)
315    }
316
317    /// Whether this is the [`Unknown`](CheckBucket::Unknown) catch-all — a bucket a
318    /// future `gh` introduced (or a missing field) that this version doesn't model.
319    /// Distinct from [`Skipping`](CheckBucket::Skipping): a skip is a deliberate,
320    /// terminal no-op, whereas an unknown bucket is *unclassified* and should be
321    /// treated conservatively (as "not known to be done") by an aggregator.
322    pub fn is_unknown(self) -> bool {
323        matches!(self, CheckBucket::Unknown)
324    }
325}
326
327/// One check on a PR (`gh pr checks --json …`).
328#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
329#[non_exhaustive]
330pub struct CheckRun {
331    /// Check name.
332    pub name: String,
333    /// Raw state, e.g. `"SUCCESS"`, `"FAILURE"`, `"IN_PROGRESS"`.
334    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
335    pub state: String,
336    /// gh's categorisation of `state` — the field to branch on. See [`CheckBucket`].
337    #[serde(default)]
338    pub bucket: CheckBucket,
339    /// Workflow the check belongs to (empty for non-Actions checks).
340    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
341    pub workflow: String,
342    /// Web link to the check's details.
343    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
344    pub link: String,
345    /// Start timestamp (ISO 8601), empty until started.
346    #[serde(
347        rename = "startedAt",
348        default,
349        deserialize_with = "vcs_cli_support::json::null_to_empty"
350    )]
351    pub started_at: String,
352    /// Completion timestamp (ISO 8601), empty until completed.
353    #[serde(
354        rename = "completedAt",
355        default,
356        deserialize_with = "vcs_cli_support::json::null_to_empty"
357    )]
358    pub completed_at: String,
359}
360
361/// A release (`gh release list/view --json …`).
362#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
363#[non_exhaustive]
364pub struct Release {
365    /// The release's tag.
366    #[serde(rename = "tagName")]
367    pub tag_name: String,
368    /// Release title (may be empty/null).
369    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
370    pub name: String,
371    /// Release notes (markdown). `None` from `release_list`, which doesn't request
372    /// the field (only `release_view` does) — so an absent value reads as the
373    /// honest "not fetched", not a false empty string. A present JSON `null` (a
374    /// release genuinely without notes) likewise reads as `None`.
375    #[serde(default)]
376    pub body: Option<String>,
377    /// Web URL. `None` from `release_list`, which doesn't request the field (only
378    /// `release_view` does) — so an absent value reads as the honest "not fetched",
379    /// not a false empty string. A present JSON `null` likewise reads as `None`.
380    #[serde(default)]
381    pub url: Option<String>,
382    /// Publication timestamp (ISO 8601); empty/null for a draft.
383    #[serde(
384        rename = "publishedAt",
385        default,
386        deserialize_with = "vcs_cli_support::json::null_to_empty"
387    )]
388    pub published_at: String,
389    /// `true` for an unpublished draft.
390    #[serde(rename = "isDraft", default)]
391    pub is_draft: bool,
392    /// `true` for a prerelease.
393    #[serde(rename = "isPrerelease", default)]
394    pub is_prerelease: bool,
395    /// `true` for the latest release. Only `release_list` reports this field;
396    /// from `release_view` it defaults to `false`.
397    #[serde(rename = "isLatest", default)]
398    pub is_latest: bool,
399    /// Release author's login. `None` from `release_list`, which doesn't request
400    /// the field (only `release_view` does) — so an absent value reads as the
401    /// honest "not fetched", not a false empty string. A present author object
402    /// with no login for a deleted or anonymized account becomes `Some("")`.
403    #[serde(default, deserialize_with = "author_login_opt")]
404    pub author: Option<String>,
405}
406
407/// A submitted PR review (from `gh pr view --json reviews`).
408#[derive(Debug, Clone, PartialEq, Eq)]
409#[non_exhaustive]
410pub struct Review {
411    /// Reviewer login.
412    pub author: String,
413    /// Review state: `"APPROVED"`, `"CHANGES_REQUESTED"`, `"COMMENTED"`,
414    /// `"DISMISSED"` or `"PENDING"`.
415    pub state: String,
416    /// Review body (may be empty).
417    pub body: String,
418    /// Submission timestamp (ISO 8601).
419    pub submitted_at: String,
420}
421
422/// A PR conversation comment (from `gh pr view --json comments`).
423#[derive(Debug, Clone, PartialEq, Eq)]
424#[non_exhaustive]
425pub struct Comment {
426    /// Commenter login.
427    pub author: String,
428    /// Comment body.
429    pub body: String,
430    /// Web URL of the comment.
431    pub url: String,
432    /// Creation timestamp (ISO 8601).
433    pub created_at: String,
434}
435
436/// The review/comment feedback on a PR (`gh pr view --json reviews,comments`).
437#[derive(Debug, Clone, PartialEq, Eq)]
438#[non_exhaustive]
439pub struct PrFeedback {
440    /// Submitted reviews, oldest first (gh's order).
441    pub reviews: Vec<Review>,
442    /// Conversation comments, oldest first (gh's order).
443    pub comments: Vec<Comment>,
444}
445
446/// A repository (`gh repo view --json name,owner,description,url,isPrivate,defaultBranchRef`).
447#[derive(Debug, Clone, PartialEq, Eq)]
448#[non_exhaustive]
449pub struct RepoView {
450    /// Repository name.
451    pub name: String,
452    /// Owner login.
453    pub owner: String,
454    /// Description, `None` when GitHub returns `null`.
455    pub description: Option<String>,
456    /// Web URL.
457    pub url: String,
458    /// `true` for a private repository.
459    pub is_private: bool,
460    /// Default branch name (empty for an empty repository).
461    pub default_branch: String,
462}
463
464// gh nests `owner` and `defaultBranchRef` as objects; deserialize into this and
465// flatten into the public `RepoView`.
466#[derive(Deserialize)]
467struct RepoJson {
468    name: String,
469    owner: OwnerJson,
470    #[serde(default)]
471    description: Option<String>,
472    url: String,
473    #[serde(rename = "isPrivate")]
474    is_private: bool,
475    #[serde(rename = "defaultBranchRef", default)]
476    default_branch_ref: Option<BranchRefJson>,
477}
478
479#[derive(Deserialize)]
480struct OwnerJson {
481    login: String,
482}
483
484#[derive(Deserialize)]
485struct BranchRefJson {
486    name: String,
487}
488
489/// Parse `gh repo view --json …` output, flattening the nested objects.
490pub(crate) fn parse_repo(json: &str) -> Result<RepoView> {
491    let raw: RepoJson = vcs_cli_support::json::from_json(BINARY, json)?;
492    Ok(RepoView {
493        name: raw.name,
494        owner: raw.owner.login,
495        description: raw.description,
496        url: raw.url,
497        is_private: raw.is_private,
498        default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
499    })
500}
501
502// gh nests the author as `{"login": …}` (and reports `null` for a deleted
503// account); deserialize into these and flatten into the public types.
504#[derive(Deserialize)]
505struct FeedbackJson {
506    #[serde(default)]
507    reviews: Vec<ReviewJson>,
508    #[serde(default)]
509    comments: Vec<CommentJson>,
510}
511
512// Optional string fields use `null_to_empty` (not bare `default`) so a present
513// JSON `null` maps to "" like an absent key — uniform with the rest of this
514// crate's `gh --json` DTOs, robust to whatever `gh` emits for an empty value.
515#[derive(Deserialize)]
516struct ReviewJson {
517    #[serde(default)]
518    author: Option<AuthorJson>,
519    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
520    state: String,
521    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
522    body: String,
523    #[serde(
524        rename = "submittedAt",
525        default,
526        deserialize_with = "vcs_cli_support::json::null_to_empty"
527    )]
528    submitted_at: String,
529}
530
531#[derive(Deserialize)]
532struct CommentJson {
533    #[serde(default)]
534    author: Option<AuthorJson>,
535    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
536    body: String,
537    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
538    url: String,
539    #[serde(
540        rename = "createdAt",
541        default,
542        deserialize_with = "vcs_cli_support::json::null_to_empty"
543    )]
544    created_at: String,
545}
546
547#[derive(Deserialize)]
548struct AuthorJson {
549    #[serde(default, deserialize_with = "vcs_cli_support::json::null_to_empty")]
550    login: String,
551}
552
553/// Parse `gh pr view --json reviews,comments` output, flattening the nested
554/// author objects (a deleted account's `null` author becomes an empty login).
555pub(crate) fn parse_feedback(json: &str) -> Result<PrFeedback> {
556    let raw: FeedbackJson = vcs_cli_support::json::from_json(BINARY, json)?;
557    Ok(PrFeedback {
558        reviews: raw
559            .reviews
560            .into_iter()
561            .map(|r| Review {
562                author: r.author.map(|a| a.login).unwrap_or_default(),
563                state: r.state,
564                body: r.body,
565                submitted_at: r.submitted_at,
566            })
567            .collect(),
568        comments: raw
569            .comments
570            .into_iter()
571            .map(|c| Comment {
572                author: c.author.map(|a| a.login).unwrap_or_default(),
573                body: c.body,
574                url: c.url,
575                created_at: c.created_at,
576            })
577            .collect(),
578    })
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use processkit::ErrorReason;
585
586    #[test]
587    fn parses_pr_list() {
588        let json = r#"[
589            {"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": true,
590             "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
591        ]"#;
592        let prs: Vec<PullRequest> =
593            vcs_cli_support::json::from_json(BINARY, json).expect("parse prs");
594        assert_eq!(prs.len(), 1);
595        assert_eq!(
596            prs[0],
597            PullRequest {
598                number: 12,
599                title: "Add feature".into(),
600                state: "OPEN".into(),
601                is_draft: true,
602                head_ref_name: "feat/x".into(),
603                base_ref_name: "main".into(),
604                url: "https://gh/pr/12".into(),
605                labels: Vec::new(),
606                assignees: Vec::new(),
607                author: String::new(),
608                created_at: String::new(),
609                updated_at: String::new(),
610                milestone: None,
611            }
612        );
613    }
614
615    // Positive case: gh's `--json labels,assignees` shape (`[{"name": …}]`,
616    // `[{"login": …}]`) flattens to plain `Vec<String>`.
617    #[test]
618    fn pr_parses_labels_and_assignees() {
619        let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
620            "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
621            "labels": [{"name": "bug", "color": "f00"}, {"name": "priority-1"}],
622            "assignees": [{"login": "octocat", "id": 1}, {"login": "hubot"}]}"#;
623        let pr: PullRequest =
624            vcs_cli_support::json::from_json(BINARY, json).expect("parse pr with labels/assignees");
625        assert_eq!(pr.labels, vec!["bug".to_string(), "priority-1".to_string()]);
626        assert_eq!(
627            pr.assignees,
628            vec!["octocat".to_string(), "hubot".to_string()]
629        );
630    }
631
632    // Negative case: an empty `labels`/`assignees` array parses to an empty
633    // `Vec`, not a panic or parse error. And when the keys are absent entirely
634    // (e.g. an older canned fixture), `#[serde(default)]` fills the same empty
635    // `Vec`.
636    #[test]
637    fn pr_without_labels_or_assignees_parses_to_empty_vecs() {
638        let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
639            "headRefName": "h", "baseRefName": "main", "url": "u",
640            "labels": [], "assignees": []}"#;
641        let pr: PullRequest =
642            vcs_cli_support::json::from_json(BINARY, json).expect("PR with empty labels/assignees");
643        assert!(pr.labels.is_empty());
644        assert!(pr.assignees.is_empty());
645
646        let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
647            BINARY,
648            r#"{"number": 14, "title": "t", "state": "OPEN",
649                "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
650        )
651        .expect("PR without labels/assignees keys");
652        assert!(pr_no_keys.labels.is_empty());
653        assert!(pr_no_keys.assignees.is_empty());
654    }
655
656    // Positive case: gh's `--json author,createdAt,updatedAt,milestone` shape
657    // (`{"login": …}`/`{"title": …}` nested objects) flattens to plain strings.
658    #[test]
659    fn pr_parses_author_timestamps_and_milestone() {
660        let json = r#"{"number": 12, "title": "Add feature", "state": "OPEN", "isDraft": false,
661            "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12",
662            "author": {"login": "octocat", "id": 1},
663            "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-02T00:00:00Z",
664            "milestone": {"title": "v1.0"}}"#;
665        let pr: PullRequest = vcs_cli_support::json::from_json(BINARY, json)
666            .expect("parse pr with author/timestamps/milestone");
667        assert_eq!(pr.author, "octocat");
668        assert_eq!(pr.created_at, "2026-07-01T00:00:00Z");
669        assert_eq!(pr.updated_at, "2026-07-02T00:00:00Z");
670        assert_eq!(pr.milestone.as_deref(), Some("v1.0"));
671    }
672
673    // Negative case: a `null` author (deleted account) flattens to an empty
674    // login, and a `null` milestone (none attached) flattens to `None` — neither
675    // fails the parse.
676    #[test]
677    fn pr_null_author_and_milestone_parse_tolerantly() {
678        let json = r#"{"number": 13, "title": "t", "state": "OPEN", "isDraft": false,
679            "headRefName": "h", "baseRefName": "main", "url": "u",
680            "author": null, "milestone": null}"#;
681        let pr: PullRequest =
682            vcs_cli_support::json::from_json(BINARY, json).expect("PR with null author/milestone");
683        assert_eq!(pr.author, "", "deleted account → empty login");
684        assert_eq!(pr.milestone, None, "no milestone attached → None");
685
686        // Absent keys entirely (an older canned fixture) default the same way.
687        let pr_no_keys: PullRequest = vcs_cli_support::json::from_json(
688            BINARY,
689            r#"{"number": 14, "title": "t", "state": "OPEN",
690                "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
691        )
692        .expect("PR without author/timestamps/milestone keys");
693        assert_eq!(pr_no_keys.author, "");
694        assert_eq!(pr_no_keys.created_at, "");
695        assert_eq!(pr_no_keys.updated_at, "");
696        assert_eq!(pr_no_keys.milestone, None);
697    }
698
699    // `#[serde(default)]` robustness: a payload that omits `isDraft` deserializes
700    // to `false` rather than failing the whole parse. (When we request `--json
701    // isDraft`, gh emits the key or hard-errors on an unknown field — it never
702    // silently omits it — so this guards our own tolerance, not a real gh quirk.)
703    #[test]
704    fn pr_without_is_draft_defaults_false() {
705        let pr: PullRequest = vcs_cli_support::json::from_json(
706            BINARY,
707            r#"{"number": 4, "title": "t", "state": "OPEN",
708                "headRefName": "h", "baseRefName": "main", "url": "u"}"#,
709        )
710        .expect("PR without isDraft");
711        assert!(!pr.is_draft);
712    }
713
714    #[test]
715    fn parses_issue_list() {
716        let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
717        let issues: Vec<Issue> =
718            vcs_cli_support::json::from_json(BINARY, json).expect("parse issues");
719        assert_eq!(issues[0].number, 3);
720    }
721
722    // Positive case for issues, mirroring `pr_parses_author_timestamps_and_milestone`.
723    #[test]
724    fn issue_parses_author_timestamps_and_milestone() {
725        let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
726            "author": {"login": "andyfeller"},
727            "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-02T00:00:00Z",
728            "milestone": {"title": "v1.0"}}"#;
729        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
730            .expect("parse issue with author/timestamps/milestone");
731        assert_eq!(issue.author, "andyfeller");
732        assert_eq!(issue.created_at, "2026-07-01T00:00:00Z");
733        assert_eq!(issue.updated_at, "2026-07-02T00:00:00Z");
734        assert_eq!(issue.milestone.as_deref(), Some("v1.0"));
735    }
736
737    // Negative case for issues: a `null` author/milestone parses tolerantly.
738    #[test]
739    fn issue_null_author_and_milestone_parse_tolerantly() {
740        let json = r#"{"number": 4, "title": "t", "state": "OPEN",
741            "author": null, "milestone": null}"#;
742        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
743            .expect("issue with null author/milestone");
744        assert_eq!(issue.author, "");
745        assert_eq!(issue.milestone, None);
746    }
747
748    // gh emits a *present* `null` (not an absent key) for some optional strings —
749    // notably `headRefName`/`baseRefName` on a PR whose head branch was deleted, and
750    // a null `body`. `#[serde(default)]` alone rejects a present null; `null_to_empty`
751    // must turn it into an empty string rather than failing the whole parse.
752    #[test]
753    fn null_optional_fields_parse_to_empty() {
754        let pr: PullRequest = vcs_cli_support::json::from_json(
755            BINARY,
756            r#"{"number": 1, "title": "t", "state": "CLOSED",
757                "headRefName": null, "baseRefName": null, "url": null}"#,
758        )
759        .expect("PR with null head/base/url (deleted-branch PR)");
760        assert_eq!(pr.head_ref_name, "");
761        assert_eq!(pr.base_ref_name, "");
762        assert_eq!(pr.url, "");
763
764        let issue: Issue = vcs_cli_support::json::from_json(
765            BINARY,
766            r#"{"number": 2, "title": "t", "state": "OPEN", "body": null, "url": null}"#,
767        )
768        .expect("issue with null body/url");
769        assert_eq!(issue.body, "");
770        assert_eq!(issue.url, "");
771
772        let release: Release = vcs_cli_support::json::from_json(
773            BINARY,
774            r#"{"tagName": "v1", "name": null, "body": null, "url": null, "publishedAt": null,
775                "author": {}}"#,
776        )
777        .expect("release with null name/body/url/publishedAt/author");
778        assert_eq!(release.name, "");
779        // `body`/`url` are `Option`: a present `null` reads as `None`, not "".
780        assert_eq!(release.body, None);
781        assert_eq!(release.url, None);
782        assert_eq!(
783            release.author,
784            Some("".to_string()),
785            "deleted account → empty login"
786        );
787    }
788
789    #[test]
790    fn parses_repo_flattening_nested_objects() {
791        let json = r#"{
792            "name": "vcs-toolkit-rs",
793            "owner": {"login": "ZelAnton"},
794            "description": null,
795            "url": "https://gh/repo",
796            "isPrivate": false,
797            "defaultBranchRef": {"name": "main"}
798        }"#;
799        let repo = parse_repo(json).expect("parse repo");
800        assert_eq!(repo.name, "vcs-toolkit-rs");
801        assert_eq!(repo.owner, "ZelAnton");
802        assert_eq!(repo.description, None);
803        assert_eq!(repo.default_branch, "main");
804        assert!(!repo.is_private);
805    }
806
807    #[test]
808    fn empty_repo_has_blank_default_branch() {
809        let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
810        let repo = parse_repo(json).expect("parse repo");
811        assert_eq!(repo.default_branch, "");
812        assert!(repo.is_private);
813    }
814
815    #[test]
816    fn malformed_json_is_a_parse_error() {
817        match vcs_cli_support::json::from_json::<Vec<Issue>>(BINARY, "not json")
818            .unwrap_err()
819            .into_reason()
820        {
821            ErrorReason::Parse { .. } => {}
822            other => panic!("expected Parse, got {other:?}"),
823        }
824    }
825
826    // gh reports `"conclusion": ""` (an empty string, NOT null) while a run is
827    // in progress — the DTO must accept that shape, not demand an Option.
828    #[test]
829    fn parses_run_list_with_blank_in_progress_conclusion() {
830        let json = r#"[
831            {"databaseId": 27023111945, "name": "CI", "displayTitle": "fix: x",
832             "status": "in_progress", "conclusion": "", "workflowName": "CI",
833             "headBranch": "main", "event": "push",
834             "url": "https://gh/runs/27023111945",
835             "createdAt": "2026-06-05T10:00:00Z"}
836        ]"#;
837        let runs: Vec<WorkflowRun> =
838            vcs_cli_support::json::from_json(BINARY, json).expect("parse runs");
839        assert_eq!(runs[0].database_id, 27023111945);
840        assert_eq!(runs[0].status, "in_progress");
841        assert_eq!(runs[0].conclusion, "");
842        assert_eq!(runs[0].workflow_name, "CI");
843    }
844
845    #[test]
846    fn parses_workflow_inventory() {
847        let json = r#"[
848            {"id": 17, "name": "CI", "path": ".github/workflows/ci.yml",
849             "state": "active"},
850            {"id": 18, "name": null, "path": null, "state": null}
851        ]"#;
852        let workflows: Vec<Workflow> =
853            vcs_cli_support::json::from_json(BINARY, json).expect("parse workflows");
854        assert_eq!(workflows[0].id, 17);
855        assert_eq!(workflows[0].name, "CI");
856        assert_eq!(workflows[0].path, ".github/workflows/ci.yml");
857        assert_eq!(workflows[0].state, "active");
858        assert_eq!(workflows[1].name, "");
859        assert_eq!(workflows[1].path, "");
860        assert_eq!(workflows[1].state, "");
861    }
862
863    #[test]
864    fn parses_check_runs_across_buckets() {
865        let json = r#"[
866            {"name": "build", "state": "SUCCESS", "bucket": "pass",
867             "workflow": "CI", "link": "https://gh/c/1",
868             "startedAt": "2026-06-05T10:00:00Z", "completedAt": "2026-06-05T10:05:00Z"},
869            {"name": "lint", "state": "FAILURE", "bucket": "fail",
870             "workflow": "CI", "link": "", "startedAt": "", "completedAt": ""},
871            {"name": "deploy", "state": "IN_PROGRESS", "bucket": "pending",
872             "workflow": "CD", "link": "", "startedAt": "", "completedAt": ""},
873            {"name": "docs", "state": "SKIPPED", "bucket": "skipping",
874             "workflow": "", "link": "", "startedAt": "", "completedAt": ""},
875            {"name": "bench", "state": "CANCELLED", "bucket": "cancel",
876             "workflow": "", "link": "", "startedAt": "", "completedAt": ""}
877        ]"#;
878        let checks: Vec<CheckRun> =
879            vcs_cli_support::json::from_json(BINARY, json).expect("parse checks");
880        let buckets: Vec<CheckBucket> = checks.iter().map(|c| c.bucket).collect();
881        assert_eq!(
882            buckets,
883            [
884                CheckBucket::Pass,
885                CheckBucket::Fail,
886                CheckBucket::Pending,
887                CheckBucket::Skipping,
888                CheckBucket::Cancel,
889            ]
890        );
891        // An unrecognised bucket deserialises to the forward-compatible catch-all.
892        let exotic: CheckRun =
893            serde_json::from_str(r#"{"name":"x","bucket":"teleport"}"#).expect("parse");
894        assert_eq!(exotic.bucket, CheckBucket::Unknown);
895        assert_eq!(checks[0].name, "build");
896    }
897
898    // `release list` carries isLatest; `release view` does NOT have that field
899    // (it must default to false) but fills body/url.
900    #[test]
901    fn parses_release_list_and_view_shapes() {
902        let list = r#"[
903            {"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
904             "isLatest": true, "isDraft": false, "isPrerelease": false,
905             "publishedAt": "2026-06-04T12:00:00Z"}
906        ]"#;
907        let releases: Vec<Release> =
908            vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
909        assert!(releases[0].is_latest);
910        assert_eq!(releases[0].tag_name, "vcs-git-v0.4.0");
911        assert_eq!(
912            releases[0].body, None,
913            "list doesn't request the body → None"
914        );
915        assert_eq!(releases[0].url, None, "list doesn't request the url → None");
916        assert_eq!(releases[0].author, None);
917
918        let view = r#"{"tagName": "vcs-git-v0.4.0", "name": "vcs-git v0.4.0",
919            "body": "Added\n- stuff", "url": "https://gh/releases/1",
920            "publishedAt": "2026-06-04T12:00:00Z",
921            "isDraft": false, "isPrerelease": false, "author": {"login": "ZelAnton"}}"#;
922        let release: Release = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
923        assert!(!release.is_latest, "view has no isLatest → default false");
924        assert_eq!(release.body.as_deref(), Some("Added\n- stuff"));
925        assert_eq!(release.url.as_deref(), Some("https://gh/releases/1"));
926        assert_eq!(release.author, Some("ZelAnton".to_string()));
927    }
928
929    #[test]
930    fn parses_feedback_flattening_nested_authors() {
931        let json = r#"{
932            "reviews": [
933                {"author": {"login": "steiza"}, "state": "APPROVED",
934                 "body": "LGTM", "submittedAt": "2026-06-01T00:00:00Z"},
935                {"author": null, "state": "COMMENTED", "body": "ghost",
936                 "submittedAt": ""}
937            ],
938            "comments": [
939                {"author": {"login": "andyfeller"}, "body": "nice",
940                 "url": "https://gh/c/9", "createdAt": "2026-06-02T00:00:00Z"}
941            ]
942        }"#;
943        let feedback = parse_feedback(json).expect("parse feedback");
944        assert_eq!(feedback.reviews.len(), 2);
945        assert_eq!(feedback.reviews[0].author, "steiza");
946        assert_eq!(feedback.reviews[0].state, "APPROVED");
947        assert_eq!(feedback.reviews[1].author, "", "deleted account → empty");
948        assert_eq!(feedback.comments[0].author, "andyfeller");
949        assert_eq!(feedback.comments[0].url, "https://gh/c/9");
950    }
951
952    // The Issue extension must stay backward-compatible with `issue list`
953    // JSON (no body/url requested) while `issue view` fills both.
954    #[test]
955    fn issue_parses_with_and_without_view_fields() {
956        let list = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
957        let issues: Vec<Issue> =
958            vcs_cli_support::json::from_json(BINARY, list).expect("parse list");
959        assert_eq!(issues[0].body, "");
960        assert_eq!(issues[0].url, "");
961
962        let view = r#"{"number": 3, "title": "Docs", "state": "OPEN",
963            "body": "Write them.", "url": "https://gh/issues/3"}"#;
964        let issue: Issue = vcs_cli_support::json::from_json(BINARY, view).expect("parse view");
965        assert_eq!(issue.body, "Write them.");
966        assert_eq!(issue.url, "https://gh/issues/3");
967        assert!(issue.labels.is_empty());
968        assert!(issue.assignees.is_empty());
969    }
970
971    // Positive case for issues, mirroring `pr_parses_labels_and_assignees`.
972    #[test]
973    fn issue_parses_labels_and_assignees() {
974        let json = r#"{"number": 3, "title": "Docs", "state": "OPEN",
975            "body": "b", "url": "https://gh/issues/3",
976            "labels": [{"name": "docs"}, {"name": "good-first-issue"}],
977            "assignees": [{"login": "andyfeller"}]}"#;
978        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
979            .expect("parse issue with labels/assignees");
980        assert_eq!(
981            issue.labels,
982            vec!["docs".to_string(), "good-first-issue".to_string()]
983        );
984        assert_eq!(issue.assignees, vec!["andyfeller".to_string()]);
985    }
986
987    // Negative case for issues: empty arrays parse to empty `Vec`s, not an error.
988    #[test]
989    fn issue_without_labels_or_assignees_parses_to_empty_vecs() {
990        let json = r#"{"number": 4, "title": "t", "state": "CLOSED",
991            "labels": [], "assignees": []}"#;
992        let issue: Issue = vcs_cli_support::json::from_json(BINARY, json)
993            .expect("issue with empty labels/assignees");
994        assert!(issue.labels.is_empty());
995        assert!(issue.assignees.is_empty());
996    }
997}