ytil_gh/
issue.rs

1use std::process::Command;
2
3use chrono::DateTime;
4use chrono::Utc;
5use color_eyre::eyre::Context as _;
6use color_eyre::eyre::bail;
7use color_eyre::eyre::eyre;
8use convert_case::Case;
9use convert_case::Casing as _;
10use serde::Deserialize;
11use ytil_cmd::CmdExt;
12
13/// Represents a newly created GitHub issue.
14///
15/// Contains the parsed details from the `gh issue create` command output.
16#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
17pub struct CreatedIssue {
18    /// The title of the created issue.
19    pub title: String,
20    /// The repository URL prefix (e.g., `https://github.com/owner/repo/`).
21    pub repo: String,
22    /// The issue number (e.g., "123").
23    pub issue_nr: String,
24}
25
26impl CreatedIssue {
27    /// Creates a [`CreatedIssue`] from the `gh issue create` command output.
28    ///
29    /// Parses the output URL to extract repository and issue number.
30    ///
31    /// # Errors
32    /// - Output does not contain "issues".
33    /// - Repository or issue number parts are empty.
34    fn new(title: &str, output: &str) -> color_eyre::Result<Self> {
35        let get_not_empty_field = |maybe_value: Option<&str>, field: &str| -> color_eyre::Result<String> {
36            maybe_value
37                .ok_or_else(|| eyre!("error building CreateIssueOutput | missing={field:?} output={output:?}"))
38                .and_then(|s| {
39                    if s.is_empty() {
40                        Err(eyre!(
41                            "error building CreateIssueOutput | empty={field:?} output={output:?}"
42                        ))
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    /// Generates a branch title from the issue number and title.
59    ///
60    /// Formats as `{issue_nr}-{title}` where `title` is converted to kebab-case and leading/trailing dashes are
61    /// trimmed.
62    pub fn branch_name(&self) -> String {
63        format!(
64            "{}-{}",
65            self.issue_nr.trim_matches('-'),
66            self.title.to_case(Case::Kebab).trim_matches('-')
67        )
68    }
69}
70
71/// Output of [`develop`] a GitHub issue.
72///
73/// Contains the branch reference and name created by the `gh issue develop` command.
74pub struct DevelopOutput {
75    /// The full branch reference (e.g., "refs/heads/issue-123-feature").
76    pub branch_ref: String,
77    /// The branch name (e.g., "issue-123-feature").
78    pub branch_name: String,
79}
80
81#[derive(Debug, Deserialize)]
82pub struct ListedIssue {
83    pub author: Author,
84    pub title: String,
85    pub number: usize,
86    #[serde(rename = "updatedAt")]
87    pub updated_at: DateTime<Utc>,
88}
89
90#[derive(Debug, Deserialize)]
91pub struct Author {
92    pub login: String,
93}
94
95/// Creates a new GitHub issue with the specified title.
96///
97/// This function invokes `gh issue create --title <title> --body ""` to create the issue.
98///
99/// # Errors
100/// - If `title` is empty.
101/// - Spawning or executing the `gh issue create` command fails.
102/// - Command exits with non-zero status.
103/// - Output cannot be parsed as a valid issue URL.
104pub fn create(title: &str) -> color_eyre::Result<CreatedIssue> {
105    if title.is_empty() {
106        bail!("cannot create GitHub issue with empty title")
107    }
108
109    let output = Command::new("gh")
110        .args(["issue", "create", "--title", title, "--body", ""])
111        .output()
112        .wrap_err_with(|| eyre!("error creating GitHub issue | title={title:?}"))?;
113
114    let created_issue = ytil_cmd::extract_success_output(&output)
115        .and_then(|output| CreatedIssue::new(title, &output))
116        .wrap_err_with(|| eyre!("error parsing created issue output | title={title:?}"))?;
117
118    Ok(created_issue)
119}
120
121/// Creates a branch for the supplied GitHub issue number.
122///
123/// Uses the `gh issue develop` command to create a development branch for the specified issue.
124///
125/// # Errors
126/// - If the `gh` command execution fails.
127/// - If the command output cannot be parsed as UTF-8.
128/// - If the branch name cannot be extracted from the output.
129pub fn develop(issue_number: &str, checkout: bool) -> color_eyre::Result<DevelopOutput> {
130    let mut args = vec!["issue", "develop", issue_number];
131
132    if checkout {
133        args.push("-c");
134    }
135
136    let output = Command::new("gh")
137        .args(args)
138        .exec()
139        .wrap_err_with(|| eyre!("error develop GitHub issue | issue_number={issue_number}"))?;
140
141    let branch_ref = str::from_utf8(&output.stdout)?.trim().to_string();
142    let branch_name = branch_ref
143        .rsplit('/')
144        .next()
145        .ok_or_else(|| eyre!("error extracting branch name from develop output | output={branch_ref:?}"))?
146        .to_string();
147
148    Ok(DevelopOutput {
149        branch_ref,
150        branch_name,
151    })
152}
153
154/// Lists all GitHub issues for the current repository.
155///
156/// # Errors
157/// Returns an error if the `gh` command fails to execute, if the output is not valid UTF-8, or if the JSON cannot be
158/// deserialized into [`Vec<ListedIssue>`].
159///
160/// # Assumptions
161/// Assumes the `gh` CLI tool is installed and properly authenticated with GitHub.
162///
163/// # Rationale
164/// Uses the GitHub CLI (`gh`) for simplicity and to leverage existing authentication setup, rather than implementing
165/// direct API calls.
166///
167/// # Performance
168/// Involves a subprocess call to `gh`, which may be slower than direct HTTP requests but avoids dependency on GitHub
169/// API tokens in code.
170///
171/// # Future Work
172/// Consider migrating to direct GitHub API calls using a library like `octocrab` for better performance and control.
173pub fn list() -> color_eyre::Result<Vec<ListedIssue>> {
174    let output = Command::new("gh")
175        .args(["issue", "list", "--json", "number,title,author,updatedAt"])
176        .exec()
177        .wrap_err_with(|| eyre!("error listing GitHub issues"))?;
178
179    let list_output = str::from_utf8(&output.stdout)?.trim().to_string();
180
181    Ok(serde_json::from_str(&list_output)?)
182}
183
184#[cfg(test)]
185mod tests {
186    use rstest::rstest;
187
188    use super::*;
189
190    #[test]
191    fn created_issue_new_parses_valid_output() {
192        assert2::let_assert!(Ok(actual) = CreatedIssue::new("Test Issue", "https://github.com/owner/repo/issues/123"));
193        pretty_assertions::assert_eq!(
194            actual,
195            CreatedIssue {
196                title: "Test Issue".to_string(),
197                repo: "https://github.com/owner/repo".to_string(),
198                issue_nr: "123".to_string(),
199            }
200        );
201    }
202
203    #[rstest]
204    #[case("", "error building CreateIssueOutput | empty=\"repo\" output=\"\"")]
205    #[case("issues", "error building CreateIssueOutput | empty=\"repo\" output=\"issues\"")]
206    #[case(
207        "https://github.com/owner/repo/123",
208        "error building CreateIssueOutput | missing=\"issue_nr\" output=\"https://github.com/owner/repo/123\""
209    )]
210    #[case(
211        "repo/issues",
212        "error building CreateIssueOutput | empty=\"issue_nr\" output=\"repo/issues\""
213    )]
214    fn created_issue_new_errors_on_invalid_output(#[case] output: &str, #[case] expected_error: &str) {
215        assert2::let_assert!(Err(err) = CreatedIssue::new("title", output));
216        pretty_assertions::assert_eq!(err.to_string(), expected_error);
217    }
218
219    #[rstest]
220    #[case("Fix bug", "42", "42-fix-bug")]
221    #[case("-Fix bug", "-42-", "42-fix-bug")]
222    fn created_issue_branch_name_formats_correctly(
223        #[case] title: &str,
224        #[case] issue_nr: &str,
225        #[case] expected: &str,
226    ) {
227        let issue = CreatedIssue {
228            title: title.to_string(),
229            issue_nr: issue_nr.to_string(),
230            repo: "https://github.com/owner/repo/".to_string(),
231        };
232        pretty_assertions::assert_eq!(issue.branch_name(), expected);
233    }
234}