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