Skip to main content

ytil_gh/
issue.rs

1use std::process::Command;
2
3use convert_case::Case;
4use convert_case::Casing;
5use jiff::Timestamp;
6use rootcause::bail;
7use rootcause::prelude::ResultExt;
8use rootcause::report;
9use serde::Deserialize;
10use ytil_cmd::CmdExt;
11
12/// Represents a newly created GitHub issue.
13#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
14pub struct CreatedIssue {
15    pub title: String,
16    pub repo: String,
17    pub issue_nr: String,
18}
19
20impl CreatedIssue {
21    /// Generates a branch name from the issue number and title.
22    pub fn branch_name(&self) -> String {
23        format!(
24            "{}-{}",
25            self.issue_nr.trim_matches('-'),
26            self.title.to_case(Case::Kebab).trim_matches('-')
27        )
28    }
29
30    /// Creates a [`CreatedIssue`] from the `gh issue create` command output.
31    ///
32    /// # Errors
33    /// - Output parsing fails.
34    fn new(title: &str, output: &str) -> rootcause::Result<Self> {
35        let get_not_empty_field = |maybe_value: Option<&str>, field: &str| -> rootcause::Result<String> {
36            maybe_value
37                .ok_or_else(|| report!("error building CreateIssueOutput"))
38                .attach_with(|| format!("missing={field:?} output={output:?}"))
39                .and_then(|s| {
40                    if s.is_empty() {
41                        Err(report!("error building CreateIssueOutput")
42                            .attach(format!("empty={field:?} output={output:?}")))
43                    } else {
44                        Ok(s.trim_matches('/').to_string())
45                    }
46                })
47        };
48
49        let mut split = output.split("issues");
50
51        Ok(Self {
52            title: title.to_string(),
53            repo: get_not_empty_field(split.next(), "repo")?,
54            issue_nr: get_not_empty_field(split.next(), "issue_nr")?,
55        })
56    }
57}
58
59/// Output of [`develop`] a GitHub issue.
60pub struct DevelopOutput {
61    pub branch_ref: String,
62    pub branch_name: String,
63}
64
65#[derive(Debug, Deserialize)]
66pub struct ListedIssue {
67    pub author: Author,
68    pub title: String,
69    pub number: usize,
70    #[serde(rename = "updatedAt")]
71    pub updated_at: Timestamp,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct Author {
76    pub login: String,
77}
78
79/// Creates a new GitHub issue with the specified title.
80///
81/// # Errors
82/// - Title is empty or `gh issue create` fails.
83pub fn create(title: &str) -> rootcause::Result<CreatedIssue> {
84    if title.is_empty() {
85        bail!("cannot create GitHub issue with empty title")
86    }
87
88    let output = Command::new("gh")
89        .args(["issue", "create", "--title", title, "--body", ""])
90        .output()
91        .context("error creating GitHub issue")
92        .attach_with(|| format!("title={title:?}"))?;
93
94    let created_issue = ytil_cmd::extract_success_output(&output)
95        .and_then(|output| CreatedIssue::new(title, &output))
96        .context("error parsing created issue output")
97        .attach_with(|| format!("title={title:?}"))?;
98
99    Ok(created_issue)
100}
101
102/// Creates a branch for the supplied GitHub issue number.
103///
104/// # Errors
105/// - `gh issue develop` fails or output parsing fails.
106pub fn develop(issue_number: &str, checkout: bool) -> rootcause::Result<DevelopOutput> {
107    let mut args = vec!["issue", "develop", issue_number];
108
109    if checkout {
110        args.push("-c");
111    }
112
113    let output = Command::new("gh")
114        .args(args)
115        .exec()
116        .context("error develop GitHub issue")
117        .attach_with(|| format!("issue_number={issue_number}"))?;
118
119    let branch_ref = str::from_utf8(&output.stdout)?.trim().to_string();
120    let branch_name = branch_ref
121        .rsplit('/')
122        .next()
123        .ok_or_else(|| report!("error extracting branch name from develop output"))
124        .attach_with(|| format!("output={branch_ref:?}"))?
125        .to_string();
126
127    Ok(DevelopOutput {
128        branch_ref,
129        branch_name,
130    })
131}
132
133/// Lists all GitHub issues for the current repository.
134///
135/// # Errors
136/// - `gh issue list` fails or JSON deserialization fails.
137pub fn list() -> rootcause::Result<Vec<ListedIssue>> {
138    let output = Command::new("gh")
139        .args(["issue", "list", "--json", "number,title,author,updatedAt"])
140        .exec()
141        .context("error listing GitHub issues")?;
142
143    let list_output = str::from_utf8(&output.stdout)?.trim().to_string();
144
145    Ok(serde_json::from_str(&list_output)?)
146}
147
148#[cfg(test)]
149mod tests {
150    use rstest::rstest;
151    use test_that::prelude::*;
152
153    use super::*;
154
155    #[test]
156    fn test_created_issue_new_parses_valid_output() {
157        assert_that!(
158            CreatedIssue::new("Test Issue", "https://github.com/owner/repo/issues/123"),
159            ok(eq(CreatedIssue {
160                title: "Test Issue".to_string(),
161                repo: "https://github.com/owner/repo".to_string(),
162                issue_nr: "123".to_string(),
163            }))
164        );
165    }
166
167    #[rstest]
168    #[case("")]
169    #[case("issues")]
170    #[case("https://github.com/owner/repo/123")]
171    #[case("repo/issues")]
172    fn test_created_issue_new_errors_on_invalid_output(#[case] output: &str) {
173        assert_that!(
174            (CreatedIssue::new("title", output)).map(|_| ()),
175            err(result_of!(
176                |err: &rootcause::Report| err.format_current_context().to_string(),
177                eq("error building CreateIssueOutput")
178            ))
179        );
180    }
181
182    #[rstest]
183    #[case("Fix bug", "42", "42-fix-bug")]
184    #[case("-Fix bug", "-42-", "42-fix-bug")]
185    fn test_created_issue_branch_name_formats_correctly(
186        #[case] title: &str,
187        #[case] issue_nr: &str,
188        #[case] expected: &str,
189    ) {
190        let issue = CreatedIssue {
191            title: title.to_string(),
192            issue_nr: issue_nr.to_string(),
193            repo: "https://github.com/owner/repo/".to_string(),
194        };
195        assert_that!(issue.branch_name(), eq(expected));
196    }
197}