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