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