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, Serialize};
4
5use crate::model::PrState;
6
7/// A GitHub issue label.
8#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
9pub struct IssueLabel {
10    /// Label name.
11    #[serde(default)]
12    pub name: String,
13}
14
15/// A GitHub issue type (the organization-defined "type" field, distinct from
16/// labels).
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
18pub struct IssueType {
19    /// Issue type name.
20    #[serde(default)]
21    pub name: String,
22}
23
24/// A GitHub issue milestone.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
26pub struct IssueMilestone {
27    /// Milestone title.
28    #[serde(default)]
29    pub title: String,
30}
31
32/// An open issue as returned by `gh issue list --json ...`.
33#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
34pub struct IssueSummary {
35    /// Issue number.
36    pub number: u64,
37    /// Issue title.
38    pub title: String,
39    /// Issue state (`OPEN`/`CLOSED`).
40    pub state: String,
41    /// Labels attached to the issue.
42    #[serde(default)]
43    pub labels: Vec<IssueLabel>,
44    /// Optional organization-defined issue type.
45    #[serde(rename = "issueType", default)]
46    pub issue_type: Option<IssueType>,
47    /// Optional milestone.
48    #[serde(default)]
49    pub milestone: Option<IssueMilestone>,
50    /// ISO-8601 creation time.
51    #[serde(rename = "createdAt", default)]
52    pub created_at: String,
53    /// Issue web URL.
54    #[serde(default)]
55    pub url: String,
56}
57
58/// A full issue as returned by `gh issue view <target> --json ...`.
59///
60/// This carries only the token-efficient issue context the generation step
61/// needs; comments, assignees, reactions and project bookkeeping are
62/// deliberately not requested.
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
64pub struct IssueView {
65    /// Issue number.
66    pub number: u64,
67    /// Issue title.
68    pub title: String,
69    /// Issue body.
70    #[serde(default)]
71    pub body: String,
72    /// Issue state (`OPEN`/`CLOSED`).
73    pub state: String,
74    /// Labels attached to the issue.
75    #[serde(default)]
76    pub labels: Vec<IssueLabel>,
77    /// Optional organization-defined issue type.
78    #[serde(rename = "issueType", default)]
79    pub issue_type: Option<IssueType>,
80    /// Optional milestone.
81    #[serde(default)]
82    pub milestone: Option<IssueMilestone>,
83    /// ISO-8601 creation time.
84    #[serde(rename = "createdAt", default)]
85    pub created_at: String,
86    /// ISO-8601 update time.
87    #[serde(rename = "updatedAt", default)]
88    pub updated_at: String,
89    /// Issue web URL.
90    #[serde(default)]
91    pub url: String,
92}
93
94/// A PR author (`{ "login": ... }`).
95#[derive(Debug, Clone, Default, Deserialize)]
96pub struct Author {
97    /// The author's login.
98    #[serde(default)]
99    pub login: String,
100}
101
102/// A PR as returned by `gh pr list --json ...`.
103#[derive(Debug, Clone, Deserialize)]
104pub struct PrSummary {
105    /// PR number.
106    pub number: u64,
107    /// PR title.
108    pub title: String,
109    /// PR author.
110    #[serde(default)]
111    pub author: Author,
112    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
113    pub state: String,
114    /// Whether the PR is a draft.
115    #[serde(rename = "isDraft", default)]
116    pub is_draft: bool,
117    /// The PR's head branch name.
118    #[serde(rename = "headRefName", default)]
119    pub head_ref_name: String,
120    /// ISO-8601 creation time.
121    #[serde(rename = "createdAt", default)]
122    pub created_at: String,
123}
124
125impl PrSummary {
126    /// The mapped [`PrState`].
127    pub fn pr_state(&self) -> PrState {
128        pr_state(&self.state, self.is_draft)
129    }
130}
131
132/// A PR as returned by `gh pr view <target> --json ...`.
133#[derive(Debug, Clone, Deserialize)]
134pub struct PrView {
135    /// PR number.
136    pub number: u64,
137    /// PR title.
138    pub title: String,
139    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
140    pub state: String,
141    /// Whether the PR is a draft.
142    #[serde(rename = "isDraft", default)]
143    pub is_draft: bool,
144    /// The PR's head branch name (the local branch the worktree checks out).
145    #[serde(rename = "headRefName")]
146    pub head_ref_name: String,
147    /// The PR's base branch name (recorded as the worktree's base ref).
148    #[serde(rename = "baseRefName")]
149    pub base_ref_name: String,
150    /// The PR's web URL (shown in the TUI detail pane).
151    #[serde(default)]
152    pub url: String,
153}
154
155impl PrView {
156    /// The mapped [`PrState`].
157    pub fn pr_state(&self) -> PrState {
158        pr_state(&self.state, self.is_draft)
159    }
160}
161
162/// An open PR found for a branch, as returned by
163/// `gh pr list --head <branch> --json number,url,state,isDraft`.
164///
165/// This is `wt`'s local mirror of `sendit::ExistingPr`; it is converted to the
166/// `sendit` type when assembling a `PrContext` for the compose/submit flow.
167#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
168pub struct OpenPr {
169    /// PR number.
170    pub number: u64,
171    /// PR web URL.
172    #[serde(default)]
173    pub url: String,
174    /// PR state (`OPEN`/`CLOSED`/`MERGED`).
175    pub state: String,
176    /// Whether the PR is a draft.
177    #[serde(rename = "isDraft", default)]
178    pub is_draft: bool,
179}
180
181/// Extract the default branch name from `gh repo view --json defaultBranchRef`
182/// output, or `None` if it is absent or unparseable (kept non-fatal so trunk
183/// detection can fall back to local git state).
184pub(crate) fn parse_default_branch(json: &str) -> Option<String> {
185    #[derive(Deserialize)]
186    struct Ref {
187        name: String,
188    }
189    #[derive(Deserialize)]
190    struct View {
191        #[serde(rename = "defaultBranchRef")]
192        default_branch_ref: Option<Ref>,
193    }
194    let view: View = serde_json::from_str(json).ok()?;
195    view.default_branch_ref.map(|r| r.name)
196}
197
198/// Maps a `gh` state string + draft flag to a [`PrState`].
199pub fn pr_state(state: &str, is_draft: bool) -> PrState {
200    if is_draft && state.eq_ignore_ascii_case("open") {
201        return PrState::Draft;
202    }
203    match state.to_ascii_lowercase().as_str() {
204        "closed" => PrState::Closed,
205        "merged" => PrState::Merged,
206        _ => PrState::Open,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn parses_issue_list_json() {
216        let json = r#"[
217            {"number": 12, "title": "Broken login", "state": "OPEN",
218             "labels": [{"name": "bug"}], "issueType": {"name": "Bug"},
219             "milestone": {"title": "v2"}, "createdAt": "2024-01-15T10:30:00Z",
220             "url": "https://github.com/o/r/issues/12"},
221            {"number": 13, "title": "Bare", "state": "OPEN"}
222        ]"#;
223        let issues: Vec<IssueSummary> = serde_json::from_str(json).unwrap();
224        assert_eq!(issues.len(), 2);
225        assert_eq!(issues[0].number, 12);
226        assert_eq!(issues[0].labels[0].name, "bug");
227        assert_eq!(issues[0].issue_type.as_ref().unwrap().name, "Bug");
228        assert_eq!(issues[0].milestone.as_ref().unwrap().title, "v2");
229        // Every optional field defaults, so a sparse `gh` response still parses.
230        assert!(issues[1].labels.is_empty());
231        assert_eq!(issues[1].issue_type, None);
232        assert_eq!(issues[1].milestone, None);
233        assert_eq!(issues[1].url, "");
234    }
235
236    #[test]
237    fn parses_issue_view_json() {
238        let json = r#"{"number": 7, "title": "Add login", "body": "Please add it.",
239            "state": "OPEN", "labels": [{"name": "enhancement"}],
240            "issueType": null, "milestone": null,
241            "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-02-01T09:00:00Z",
242            "url": "https://github.com/o/r/issues/7"}"#;
243        let issue: IssueView = serde_json::from_str(json).unwrap();
244        assert_eq!(issue.number, 7);
245        assert_eq!(issue.body, "Please add it.");
246        assert_eq!(issue.labels[0].name, "enhancement");
247        assert_eq!(issue.issue_type, None);
248        assert_eq!(issue.url, "https://github.com/o/r/issues/7");
249    }
250
251    #[test]
252    fn issue_view_tolerates_a_body_only_response() {
253        // `gh` omits fields the repository does not use; only number/title/state
254        // are required, so the rest must not make parsing fail.
255        let issue: IssueView =
256            serde_json::from_str(r#"{"number": 1, "title": "T", "state": "OPEN"}"#).unwrap();
257        assert_eq!(issue.body, "");
258        assert_eq!(issue.updated_at, "");
259    }
260
261    #[test]
262    fn parses_pr_list_json() {
263        let json = r#"[
264            {"number": 42, "title": "Add login", "author": {"login": "alice"},
265             "state": "OPEN", "isDraft": false, "headRefName": "feature/login",
266             "createdAt": "2024-01-15T10:30:00Z"},
267            {"number": 7, "title": "WIP", "author": {"login": "bob"},
268             "state": "OPEN", "isDraft": true, "headRefName": "wip"}
269        ]"#;
270        let prs: Vec<PrSummary> = serde_json::from_str(json).unwrap();
271        assert_eq!(prs.len(), 2);
272        assert_eq!(prs[0].number, 42);
273        assert_eq!(prs[0].author.login, "alice");
274        assert_eq!(prs[0].pr_state(), PrState::Open);
275        assert_eq!(prs[1].pr_state(), PrState::Draft); // open + draft
276    }
277
278    #[test]
279    fn parses_pr_view_json() {
280        let json = r#"{"number": 5, "title": "Fix", "state": "MERGED", "isDraft": false,
281            "headRefName": "fork-branch", "baseRefName": "main"}"#;
282        let view: PrView = serde_json::from_str(json).unwrap();
283        assert_eq!(view.number, 5);
284        assert_eq!(view.head_ref_name, "fork-branch");
285        assert_eq!(view.base_ref_name, "main");
286        assert_eq!(view.pr_state(), PrState::Merged);
287    }
288
289    #[test]
290    fn state_mapping() {
291        assert_eq!(pr_state("OPEN", false), PrState::Open);
292        assert_eq!(pr_state("OPEN", true), PrState::Draft);
293        assert_eq!(pr_state("CLOSED", false), PrState::Closed);
294        assert_eq!(pr_state("MERGED", false), PrState::Merged);
295        assert_eq!(pr_state("CLOSED", true), PrState::Closed); // draft only matters for open
296    }
297
298    #[test]
299    fn parses_open_pr_list() {
300        let json = r#"[{"number": 77, "url": "https://github.com/o/r/pull/77",
301            "state": "OPEN", "isDraft": true}]"#;
302        let prs: Vec<OpenPr> = serde_json::from_str(json).unwrap();
303        assert_eq!(prs.len(), 1);
304        assert_eq!(prs[0].number, 77);
305        assert_eq!(prs[0].url, "https://github.com/o/r/pull/77");
306        assert!(prs[0].is_draft);
307    }
308
309    #[test]
310    fn parses_default_branch() {
311        assert_eq!(
312            parse_default_branch(r#"{"defaultBranchRef": {"name": "main"}}"#),
313            Some("main".to_string())
314        );
315        // Null ref (e.g. empty repo) and garbage both yield None.
316        assert_eq!(parse_default_branch(r#"{"defaultBranchRef": null}"#), None);
317        assert_eq!(parse_default_branch("not json"), None);
318    }
319}