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#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
17pub struct CreatedIssue {
18 pub title: String,
20 pub repo: String,
22 pub issue_nr: String,
24}
25
26impl CreatedIssue {
27 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 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
71pub struct DevelopOutput {
75 pub branch_ref: String,
77 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
95pub 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
121pub 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
154pub 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}