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