Skip to main content

ytil_gh/
issue.rs

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