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#[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 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 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
59pub 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
79pub 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
102pub 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
133pub 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}