1use std::fmt::Display;
7use std::fmt::Formatter;
8use std::ops::Deref;
9use std::str::FromStr;
10
11use owo_colors::OwoColorize;
12use rootcause::prelude::ResultExt;
13use rootcause::report;
14use strum::EnumIter;
15use ytil_gh::RepoViewField;
16use ytil_gh::issue::ListedIssue;
17use ytil_gh::pr::IntoEnumIterator;
18use ytil_gh::pr::PullRequest;
19use ytil_gh::pr::PullRequestMergeState;
20use ytil_sys::cli::Args;
21use ytil_sys::pico_args::Arguments;
22
23#[ytil_sys::main]
36fn main() -> rootcause::Result<()> {
37 let mut pargs = Arguments::from_env();
38 if pargs.has_help() {
39 println!("{}", include_str!("../help.txt"));
40 return Ok(());
41 }
42
43 ytil_gh::log_into_github()?;
44
45 if pargs.contains("issue") {
46 create_issue_and_branch_from_default_branch()?;
47 return Ok(());
48 }
49
50 if pargs.contains("pr") {
51 create_pr()?;
52 return Ok(());
53 }
54
55 if pargs.contains("branch") {
56 create_branch_from_issue()?;
57 return Ok(());
58 }
59
60 let repo_name_with_owner = ytil_gh::get_repo_view_field(&RepoViewField::NameWithOwner)?;
61
62 let search_filter: Option<String> = pargs.opt_value_from_str("--search")?;
63 let merge_state = pargs
64 .opt_value_from_fn("--merge-state", PullRequestMergeState::from_str)
65 .attach_with(|| {
66 format!(
67 "accepted values are {:#?}",
68 PullRequestMergeState::iter().collect::<Vec<_>>()
69 )
70 })?;
71
72 let params = format!(
73 "search_filter={search_filter:?}{}",
74 merge_state
75 .map(|ms| format!("\nmerge_state={ms:?}"))
76 .unwrap_or_default()
77 );
78 println!("\n{}\n{}\n", "Search PRs by".cyan().bold(), params.white().bold());
79
80 let pull_requests = ytil_gh::pr::get(&repo_name_with_owner, search_filter.as_deref(), &|pr: &PullRequest| {
81 if let Some(merge_state) = merge_state {
82 return pr.merge_state == merge_state;
83 }
84 true
85 })?;
86
87 let renderable_prs: Vec<_> = pull_requests.into_iter().map(RenderablePullRequest).collect();
88 if renderable_prs.is_empty() {
89 println!("{}\n{}", "No matching PRs found".yellow().bold(), params.white().bold());
90 return Ok(());
91 }
92
93 let Some(selected_prs) = ytil_tui::minimal_multi_select(renderable_prs, ToString::to_string, ToString::to_string)?
94 else {
95 println!("No PRs selected");
96 return Ok(());
97 };
98
99 let Some(selected_op) = ytil_tui::minimal_select::<SelectableOp>(SelectableOp::iter().collect())? else {
100 println!("No operation selected");
101 return Ok(());
102 };
103
104 println!(); let selected_op_run = selected_op.run();
107 for pr in selected_prs.iter().map(Deref::deref) {
108 selected_op_run(pr);
109 }
110
111 Ok(())
112}
113
114pub struct RenderablePullRequest(pub PullRequest);
119
120impl Deref for RenderablePullRequest {
121 type Target = PullRequest;
122
123 fn deref(&self) -> &Self::Target {
124 &self.0
125 }
126}
127
128impl Display for RenderablePullRequest {
129 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
130 write!(
132 f,
133 "{} {} ",
134 self.author.login.blue().bold(),
135 self.updated_at.format("%d-%m-%Y %H:%M UTC")
136 )?;
137 match self.merge_state {
138 PullRequestMergeState::Behind => write!(f, "{} ", "Behind".yellow().bold())?,
139 PullRequestMergeState::Blocked => write!(f, "{} ", "Blocked".red())?,
140 PullRequestMergeState::Clean => write!(f, "{} ", "Clean".green())?,
141 PullRequestMergeState::Dirty => write!(f, "{} ", "Dirty".red().bold())?,
142 PullRequestMergeState::Draft => write!(f, "{} ", "Draft".blue().bold())?,
143 PullRequestMergeState::HasHooks => write!(f, "{} ", "HasHooks".magenta())?,
144 PullRequestMergeState::Unknown => write!(f, "Unknown ")?,
145 PullRequestMergeState::Unmergeable => write!(f, "{} ", "Unmergeable".red().bold())?,
146 PullRequestMergeState::Unstable => write!(f, "{} ", "Unstable".magenta().bold())?,
147 }
148 write!(f, "{}", self.title)
149 }
150}
151
152struct RenderableListedIssue(pub ListedIssue);
153
154impl Deref for RenderableListedIssue {
155 type Target = ListedIssue;
156
157 fn deref(&self) -> &Self::Target {
158 &self.0
159 }
160}
161
162impl Display for RenderableListedIssue {
163 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
164 write!(
165 f,
166 "{} {} {}",
168 self.author.login.blue().bold(),
169 self.updated_at.format("%d-%m-%Y %H:%M UTC"),
170 self.title
171 )
172 }
173}
174
175#[derive(EnumIter)]
181enum SelectableOp {
182 Approve,
183 ApproveAndMerge,
184 DependabotRebase,
185 EnableAutoMerge,
186}
187
188impl Display for SelectableOp {
189 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
190 match self {
191 Self::Approve => write!(f, "{}", "Approve".green().bold()),
192 Self::ApproveAndMerge => write!(f, "{}", "Approve & Merge".green().bold()),
193 Self::DependabotRebase => write!(f, "{}", "Dependabot Rebase".blue().bold()),
194 Self::EnableAutoMerge => write!(f, "{}", "Enable auto-merge".magenta().bold()),
195 }
196 }
197}
198
199impl SelectableOp {
200 pub fn run(&self) -> Box<dyn Fn(&PullRequest)> {
201 match self {
202 Self::Approve => Box::new(|pr| {
203 drop(Op::Approve.report(pr, ytil_gh::pr::approve(pr.number)));
204 }),
205 Self::ApproveAndMerge => Box::new(|pr| {
206 drop(
207 Op::Approve
208 .report(pr, ytil_gh::pr::approve(pr.number))
209 .and_then(|()| Op::Merge.report(pr, ytil_gh::pr::merge(pr.number))),
210 );
211 }),
212 Self::DependabotRebase => Box::new(|pr| {
213 drop(Op::DependabotRebase.report(pr, ytil_gh::pr::dependabot_rebase(pr.number)));
214 }),
215 Self::EnableAutoMerge => Box::new(|pr| {
216 drop(Op::EnableAutoMerge.report(pr, ytil_gh::pr::enable_auto_merge(pr.number)));
217 }),
218 }
219 }
220}
221
222enum Op {
237 Approve,
238 Merge,
239 DependabotRebase,
240 EnableAutoMerge,
241}
242
243impl Op {
244 pub fn report(&self, pr: &PullRequest, res: rootcause::Result<()>) -> rootcause::Result<()> {
254 res.inspect(|()| self.report_ok(pr)).inspect_err(|err| {
255 self.report_error(pr, err);
256 })
257 }
258
259 fn report_ok(&self, pr: &PullRequest) {
261 let msg = match self {
262 Self::Approve => "Approved",
263 Self::Merge => "Merged",
264 Self::DependabotRebase => "Dependabot rebased",
265 Self::EnableAutoMerge => "Auto-merge enabled",
266 };
267 println!("{} {}", format!("{msg} PR").green().bold(), format_pr(pr));
268 }
269
270 fn report_error(&self, pr: &PullRequest, error: &rootcause::Report) {
272 let msg = match self {
273 Self::Approve => "approving",
274 Self::Merge => "merging",
275 Self::DependabotRebase => "triggering dependabot rebase",
276 Self::EnableAutoMerge => "enabling auto-merge",
277 };
278 eprintln!(
279 "{} {} error=\n{}",
280 format!("Error {msg} PR").red(),
281 format_pr(pr),
282 format!("{error:#?}").red()
283 );
284 }
285}
286
287fn format_pr(pr: &PullRequest) -> String {
289 format!(
290 "{}{:?} {}{:?} {}{:?}",
291 "number=".white().bold(),
292 pr.number,
293 "title=".white().bold(),
294 pr.title,
295 "author=",
296 pr.author,
297 )
298}
299
300fn create_issue_and_branch_from_default_branch() -> Result<(), rootcause::Report> {
305 let Some(issue_title) = ytil_tui::text_prompt("Issue title:")?.map(|x| x.trim().to_string()) else {
306 return Ok(());
307 };
308
309 let Some(checkout_branch) = ytil_tui::yes_no_select("Checkout branch?")? else {
310 return Ok(());
311 };
312
313 let created_issue = ytil_gh::issue::create(&issue_title)?;
314 println!(
315 "\n{} number={} title={issue_title:?}",
316 "Issue created".green().bold(),
317 created_issue.issue_nr
318 );
319
320 let develop_output = ytil_gh::issue::develop(&created_issue.issue_nr, checkout_branch)?;
321 println!(
322 "{} with name={:?}",
323 "Branch created".green().bold(),
324 develop_output.branch_name
325 );
326
327 Ok(())
328}
329
330fn create_pr() -> Result<(), rootcause::Report> {
337 let Some(branch) = ytil_tui::git_branch::select()? else {
338 return Ok(());
339 };
340
341 let title = pr_title_from_branch_name(branch.name_no_origin())?;
342 let pr_url = ytil_gh::pr::create(&title)?;
343 println!("{} title={title:?} pr_url={pr_url:?}", "PR created".green().bold());
344
345 Ok(())
346}
347
348fn create_branch_from_issue() -> Result<(), rootcause::Report> {
353 let issues = ytil_gh::issue::list()?;
354
355 let Some(issue) = ytil_tui::minimal_select(issues.into_iter().map(RenderableListedIssue).collect())? else {
356 return Ok(());
357 };
358
359 let Some(checkout_branch) = ytil_tui::yes_no_select("Checkout branch?")? else {
360 return Ok(());
361 };
362
363 let develop_output = ytil_gh::issue::develop(&issue.number.to_string(), checkout_branch)?;
364 println!(
365 "{} with name={:?}",
366 "Branch created".green().bold(),
367 develop_output.branch_name
368 );
369
370 Ok(())
371}
372
373fn pr_title_from_branch_name(branch_name: &str) -> rootcause::Result<String> {
380 let mut parts = branch_name.split('-');
381
382 let x = parts
383 .next()
384 .ok_or_else(|| report!("error malformed branch_name"))
385 .attach_with(|| format!("branch_name={branch_name:?}"))?;
386 let issue_number: usize = x
387 .parse()
388 .context("error parsing issue number")
389 .attach_with(|| format!("branch_name={branch_name:?} issue_number={x:?}"))?;
390
391 let mut title = String::with_capacity(branch_name.len());
392 for (i, word) in parts.enumerate() {
393 if i > 0 {
394 title.push(' ');
395 }
396 if i == 0 {
397 let mut chars = word.chars();
398 if let Some(first) = chars.next() {
399 for c in first.to_uppercase() {
400 title.push(c);
401 }
402 title.push_str(chars.as_str());
403 }
404 } else {
405 title.push_str(word);
406 }
407 }
408
409 if title.is_empty() {
410 Err(report!("error empty title")).attach_with(|| format!("branch_name={branch_name:?}"))?;
411 }
412
413 Ok(format!("[{issue_number}]: {title}"))
414}
415
416#[cfg(test)]
417mod tests {
418 use rstest::rstest;
419
420 use super::*;
421
422 #[rstest]
423 #[case("43-foo-bar-baz", "[43]: Foo bar baz")]
424 #[case("1-hello", "[1]: Hello")]
425 #[case("123-long-branch-name-here", "[123]: Long branch name here")]
426 fn test_pr_title_from_branch_name_when_valid_input_formats_correctly(#[case] input: &str, #[case] expected: &str) {
427 pretty_assertions::assert_eq!(pr_title_from_branch_name(input).unwrap(), expected);
428 }
429
430 #[rstest]
431 #[case("abc-foo", "error parsing issue number")]
432 #[case("42", "error empty title")]
433 #[case("", "error parsing issue number")]
434 fn test_pr_title_from_branch_name_when_invalid_input_returns_error(
435 #[case] input: &str,
436 #[case] expected_ctx: &str,
437 ) {
438 assert2::assert!(let Err(err) = pr_title_from_branch_name(input));
439 assert_eq!(err.format_current_context().to_string(), expected_ctx);
440 }
441}