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#[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 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 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
60pub 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
80pub 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
103pub 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
134pub 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}