Skip to main content

wt/gh/
types.rs

1//! `gh` JSON shapes and their mapping to the domain model (spec ยง4).
2
3use serde::Deserialize;
4
5use crate::model::PrState;
6
7/// A PR author (`{ "login": ... }`).
8#[derive(Debug, Clone, Default, Deserialize)]
9pub struct Author {
10    /// The author's login.
11    #[serde(default)]
12    pub login: String,
13}
14
15/// A PR as returned by `gh pr list --json ...`.
16#[derive(Debug, Clone, Deserialize)]
17pub struct PrSummary {
18    /// PR number.
19    pub number: u64,
20    /// PR title.
21    pub title: String,
22    /// PR author.
23    #[serde(default)]
24    pub author: Author,
25    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
26    pub state: String,
27    /// Whether the PR is a draft.
28    #[serde(rename = "isDraft", default)]
29    pub is_draft: bool,
30    /// The PR's head branch name.
31    #[serde(rename = "headRefName", default)]
32    pub head_ref_name: String,
33    /// ISO-8601 creation time.
34    #[serde(rename = "createdAt", default)]
35    pub created_at: String,
36}
37
38impl PrSummary {
39    /// The mapped [`PrState`].
40    pub fn pr_state(&self) -> PrState {
41        pr_state(&self.state, self.is_draft)
42    }
43}
44
45/// A PR as returned by `gh pr view <target> --json ...`.
46#[derive(Debug, Clone, Deserialize)]
47pub struct PrView {
48    /// PR number.
49    pub number: u64,
50    /// PR title.
51    pub title: String,
52    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
53    pub state: String,
54    /// Whether the PR is a draft.
55    #[serde(rename = "isDraft", default)]
56    pub is_draft: bool,
57    /// The PR's head branch name (the local branch the worktree checks out).
58    #[serde(rename = "headRefName")]
59    pub head_ref_name: String,
60    /// The PR's base branch name (recorded as the worktree's base ref).
61    #[serde(rename = "baseRefName")]
62    pub base_ref_name: String,
63    /// The PR's web URL (shown in the TUI detail pane).
64    #[serde(default)]
65    pub url: String,
66}
67
68impl PrView {
69    /// The mapped [`PrState`].
70    pub fn pr_state(&self) -> PrState {
71        pr_state(&self.state, self.is_draft)
72    }
73}
74
75/// An open PR found for a branch, as returned by
76/// `gh pr list --head <branch> --json number,url,state,isDraft`.
77///
78/// This is `wt`'s local mirror of `sendit::ExistingPr`; it is converted to the
79/// `sendit` type when assembling a `PrContext` for the compose/submit flow.
80#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
81pub struct OpenPr {
82    /// PR number.
83    pub number: u64,
84    /// PR web URL.
85    #[serde(default)]
86    pub url: String,
87    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
88    pub state: String,
89    /// Whether the PR is a draft.
90    #[serde(rename = "isDraft", default)]
91    pub is_draft: bool,
92}
93
94/// Extract the default branch name from `gh repo view --json defaultBranchRef`
95/// output, or `None` if it is absent or unparseable (kept non-fatal so trunk
96/// detection can fall back to local git state).
97pub(crate) fn parse_default_branch(json: &str) -> Option<String> {
98    #[derive(Deserialize)]
99    struct Ref {
100        name: String,
101    }
102    #[derive(Deserialize)]
103    struct View {
104        #[serde(rename = "defaultBranchRef")]
105        default_branch_ref: Option<Ref>,
106    }
107    let view: View = serde_json::from_str(json).ok()?;
108    view.default_branch_ref.map(|r| r.name)
109}
110
111/// Maps a `gh` state string + draft flag to a [`PrState`].
112pub fn pr_state(state: &str, is_draft: bool) -> PrState {
113    if is_draft && state.eq_ignore_ascii_case("open") {
114        return PrState::Draft;
115    }
116    match state.to_ascii_lowercase().as_str() {
117        "closed" => PrState::Closed,
118        "merged" => PrState::Merged,
119        _ => PrState::Open,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn parses_pr_list_json() {
129        let json = r#"[
130            {"number": 42, "title": "Add login", "author": {"login": "alice"},
131             "state": "OPEN", "isDraft": false, "headRefName": "feature/login",
132             "createdAt": "2024-01-15T10:30:00Z"},
133            {"number": 7, "title": "WIP", "author": {"login": "bob"},
134             "state": "OPEN", "isDraft": true, "headRefName": "wip"}
135        ]"#;
136        let prs: Vec<PrSummary> = serde_json::from_str(json).unwrap();
137        assert_eq!(prs.len(), 2);
138        assert_eq!(prs[0].number, 42);
139        assert_eq!(prs[0].author.login, "alice");
140        assert_eq!(prs[0].pr_state(), PrState::Open);
141        assert_eq!(prs[1].pr_state(), PrState::Draft); // open + draft
142    }
143
144    #[test]
145    fn parses_pr_view_json() {
146        let json = r#"{"number": 5, "title": "Fix", "state": "MERGED", "isDraft": false,
147            "headRefName": "fork-branch", "baseRefName": "main"}"#;
148        let view: PrView = serde_json::from_str(json).unwrap();
149        assert_eq!(view.number, 5);
150        assert_eq!(view.head_ref_name, "fork-branch");
151        assert_eq!(view.base_ref_name, "main");
152        assert_eq!(view.pr_state(), PrState::Merged);
153    }
154
155    #[test]
156    fn state_mapping() {
157        assert_eq!(pr_state("OPEN", false), PrState::Open);
158        assert_eq!(pr_state("OPEN", true), PrState::Draft);
159        assert_eq!(pr_state("CLOSED", false), PrState::Closed);
160        assert_eq!(pr_state("MERGED", false), PrState::Merged);
161        assert_eq!(pr_state("CLOSED", true), PrState::Closed); // draft only matters for open
162    }
163
164    #[test]
165    fn parses_open_pr_list() {
166        let json = r#"[{"number": 77, "url": "https://github.com/o/r/pull/77",
167            "state": "OPEN", "isDraft": true}]"#;
168        let prs: Vec<OpenPr> = serde_json::from_str(json).unwrap();
169        assert_eq!(prs.len(), 1);
170        assert_eq!(prs[0].number, 77);
171        assert_eq!(prs[0].url, "https://github.com/o/r/pull/77");
172        assert!(prs[0].is_draft);
173    }
174
175    #[test]
176    fn parses_default_branch() {
177        assert_eq!(
178            parse_default_branch(r#"{"defaultBranchRef": {"name": "main"}}"#),
179            Some("main".to_string())
180        );
181        // Null ref (e.g. empty repo) and garbage both yield None.
182        assert_eq!(parse_default_branch(r#"{"defaultBranchRef": null}"#), None);
183        assert_eq!(parse_default_branch("not json"), None);
184    }
185}