ghl/main.rs
1//! List and optionally batch‑merge GitHub pull requests interactively, or create issues with associated branches.
2//!
3//! Provides a colorized TUI to select multiple PRs then apply a composite
4//! operation (approve & merge, Dependabot rebase, enable auto-merge). Alternatively, create a GitHub issue
5//! and an associated branch from the default branch. Mirrors the `run()` pattern
6//! used by `gch` so the binary `main` stays trivial.
7//!
8//! # Flow
9//! - Parse flags (`--search`, `--merge-state`, `issue`).
10//! - If `issue` is present:
11//! - Prompt for issue title via [`ytil_tui::text_prompt`].
12//! - Prompt for whether to checkout the branch via [`ytil_tui::yes_no_select`].
13//! - Create issue via [`ytil_gh::issue::create`].
14//! - Develop the issue via [`ytil_gh::issue::develop`] (creates branch and optionally checks it out).
15//! - Otherwise:
16//! - Detect current repository via [`ytil_gh::get_repo_view_field`].
17//! - Fetch PR list via [`ytil_gh::pr::get`] (GitHub CLI `gh pr list`) forwarding the search filter.
18//! - Apply optional in‑process merge state filter.
19//! - Present multi‑select TUI via [`ytil_tui::minimal_multi_select`].
20//! - Execute chosen high‑level operation over selected PRs, reporting per‑PR result.
21//!
22//! # Flags
23//! - `--search <FILTER>` or `--search=<FILTER>`: forwarded to `gh pr list --search`. Optional.
24//! - `--merge-state <STATE>` or `--merge-state=<STATE>`: client‑side filter over fetched PRs. Accepted
25//! (case‑insensitive) values for [`PullRequestMergeState`]:
26//! `Behind|Blocked|Clean|Dirty|Draft|HasHooks|Unknown|Unmergeable|Unstable`.
27//! - `issue`: switch to issue creation mode (prompts for title, creates issue and branch).
28//!
29//! Use `--` to terminate flag parsing (subsequent arguments ignored by this tool).
30//!
31//! # Usage
32//! ```bash
33//! ghl # list all open PRs interactively
34//! ghl --search "fix ci" # filter by search terms
35//! ghl --merge-state Clean # filter by merge state only
36//! ghl --search="lint" --merge-state Dirty # combine search + state (supports = or space)
37//! ghl issue # create issue and branch interactively
38//! ```
39//!
40//! # Errors
41//! - Flag parsing fails (unknown flag, missing value, invalid [`PullRequestMergeState`]).
42//! - GitHub CLI invocation fails (listing PRs via [`ytil_gh::pr::get`], approving via [`ytil_gh::pr::approve`], merging
43//! via [`ytil_gh::pr::merge`], commenting via [`ytil_gh::pr::dependabot_rebase`], creating issue via
44//! [`ytil_gh::issue::create`]).
45//! - TUI interaction fails (selection UI errors via [`ytil_tui::minimal_multi_select`] and
46//! [`ytil_tui::minimal_select`], issue title prompt via [`ytil_tui::text_prompt`], branch checkout prompt via
47//! [`ytil_tui::yes_no_select`]).
48//! - GitHub CLI invocation fails (issue and branch creation via [`ytil_gh::issue::create`] and
49//! [`ytil_gh::issue::develop`]).
50//!
51//! # Future Work
52//! - Add dry‑run mode printing planned operations without executing.
53//! - Provide additional bulk actions (labeling, commenting).
54//! - Introduce structured logging (JSON) for automated auditing.
55#![feature(exit_status_error)]
56
57use core::fmt::Display;
58use std::ops::Deref;
59use std::str::FromStr;
60
61use color_eyre::Section;
62use color_eyre::eyre::Context as _;
63use color_eyre::eyre::bail;
64use color_eyre::eyre::eyre;
65use color_eyre::owo_colors::OwoColorize;
66use strum::EnumIter;
67use ytil_gh::RepoViewField;
68use ytil_gh::issue::ListedIssue;
69use ytil_gh::pr::IntoEnumIterator;
70use ytil_gh::pr::PullRequest;
71use ytil_gh::pr::PullRequestMergeState;
72use ytil_sys::cli::Args as _;
73use ytil_sys::pico_args::Arguments;
74
75/// Newtype wrapper implementing colored [`Display`] for a [`PullRequest`].
76///
77/// Renders: `<number> <author.login> <colored-merge-state> <title>`.
78/// Merge state receives a color to aid quick scanning.
79pub struct RenderablePullRequest(pub PullRequest);
80
81impl Deref for RenderablePullRequest {
82 type Target = PullRequest;
83
84 fn deref(&self) -> &Self::Target {
85 &self.0
86 }
87}
88
89impl Display for RenderablePullRequest {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 let state = match self.merge_state {
92 PullRequestMergeState::Behind => "Behind".yellow().bold().to_string(),
93 PullRequestMergeState::Blocked => "Blocked".red().to_string(),
94 PullRequestMergeState::Clean => "Clean".green().to_string(),
95 PullRequestMergeState::Dirty => "Dirty".red().bold().to_string(),
96 PullRequestMergeState::Draft => "Draft".blue().bold().to_string(),
97 PullRequestMergeState::HasHooks => "HasHooks".magenta().to_string(),
98 PullRequestMergeState::Unknown => "Unknown".to_string(),
99 PullRequestMergeState::Unmergeable => "Unmergeable".red().bold().to_string(),
100 PullRequestMergeState::Unstable => "Unstable".magenta().bold().to_string(),
101 };
102 write!(
103 f,
104 // The spacing before the title is required to align it with the first line.
105 "{} {} {state}\n {}",
106 self.author.login.blue().bold(),
107 self.updated_at.format("%d-%m-%Y %H:%M UTC"),
108 self.title
109 )
110 }
111}
112
113struct RenderableListedIssue(pub ListedIssue);
114
115impl Deref for RenderableListedIssue {
116 type Target = ListedIssue;
117
118 fn deref(&self) -> &Self::Target {
119 &self.0
120 }
121}
122
123impl Display for RenderableListedIssue {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 write!(
126 f,
127 // The spacing before the title is required to align it with the first line.
128 "{} {} \n {}",
129 self.author.login.blue().bold(),
130 self.updated_at.format("%d-%m-%Y %H:%M UTC"),
131 self.title
132 )
133 }
134}
135
136/// User-selectable high-level operations to apply to chosen PRs.
137///
138/// Encapsulates composite actions presented in the TUI. Separate from [`Op`]
139/// which models the underlying atomic steps and reporting. Expanding this enum
140/// only affects menu construction / selection logic.
141///
142/// # Variants
143/// - `Approve` Perform [`Op::Approve`] review.
144/// - `ApproveAndMerge` Perform [`Op::Approve`] review then [`Op::Merge`] if approval succeeds.
145/// - `DependabotRebase` Post the `@dependabot rebase` comment via [`Op::DependabotRebase`] to a Dependabot PR.
146/// - `EnableAutoMerge` Enable [`Op::EnableAutoMerge`] (rebase strategy + delete branch) for the PR.
147///
148/// # Future Work
149/// - Add bulk label operations (e.g. `Label` / `RemoveLabel`).
150/// - Introduce `Comment` with arbitrary body once use-cases emerge.
151/// - Provide dry-run variants for auditing actions.
152#[derive(EnumIter)]
153enum SelectableOp {
154 Approve,
155 ApproveAndMerge,
156 DependabotRebase,
157 EnableAutoMerge,
158}
159
160impl Display for SelectableOp {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 let repr = match self {
163 Self::Approve => "Approve".green().bold().to_string(),
164 Self::ApproveAndMerge => "Approve & Merge".green().bold().to_string(),
165 Self::DependabotRebase => "Dependabot Rebase".blue().bold().to_string(),
166 Self::EnableAutoMerge => "Enable auto-merge".magenta().bold().to_string(),
167 };
168 write!(f, "{repr}")
169 }
170}
171
172impl SelectableOp {
173 pub fn run(&self) -> Box<dyn Fn(&PullRequest)> {
174 match self {
175 Self::Approve => Box::new(|pr| {
176 let _ = Op::Approve.report(pr, ytil_gh::pr::approve(pr.number));
177 }),
178 Self::ApproveAndMerge => Box::new(|pr| {
179 let _ = Op::Approve
180 .report(pr, ytil_gh::pr::approve(pr.number))
181 .and_then(|()| Op::Merge.report(pr, ytil_gh::pr::merge(pr.number)));
182 }),
183 Self::DependabotRebase => Box::new(|pr| {
184 let _ = Op::DependabotRebase.report(pr, ytil_gh::pr::dependabot_rebase(pr.number));
185 }),
186 Self::EnableAutoMerge => Box::new(|pr| {
187 let _ = Op::EnableAutoMerge.report(pr, ytil_gh::pr::enable_auto_merge(pr.number));
188 }),
189 }
190 }
191}
192
193/// Atomic pull request operations executed by `ghl`.
194///
195/// Represents each discrete action the tool can perform against a selected
196/// pull request. Higher‑level composite choices in the TUI (see [`SelectableOp`])
197/// sequence these as needed. Centralizing variants here keeps reporting logic
198/// (`report`, `report_ok`, `report_error`) uniform and extensible.
199///
200/// # Variants
201/// - `Approve` Submit an approving review via [`ytil_gh::pr::approve`] (`gh pr review --approve`).
202/// - `Merge` Perform the administrative squash merge via [`ytil_gh::pr::merge`] (`gh pr merge --admin --squash`).
203/// - `DependabotRebase` Post the `@dependabot rebase` comment via [`ytil_gh::pr::dependabot_rebase`] to request an
204/// updated rebase for a Dependabot PR.
205/// - `EnableAutoMerge` Schedule automatic merge via [`ytil_gh::pr::enable_auto_merge`] (rebase) once requirements
206/// satisfied.
207enum Op {
208 Approve,
209 Merge,
210 DependabotRebase,
211 EnableAutoMerge,
212}
213
214impl Op {
215 /// Report the result of executing an operation on a pull request.
216 ///
217 /// Delegates to success / error helpers that emit colorized, structured
218 /// terminal output. Keeps call‑site chaining terse while centralizing the
219 /// formatting logic.
220 ///
221 /// # Errors
222 /// Returns the same error contained in `res` (no transformation) so callers
223 /// can continue combinators (`and_then`, etc.) if desired.
224 pub fn report(&self, pr: &PullRequest, res: color_eyre::Result<()>) -> color_eyre::Result<()> {
225 res.inspect(|()| self.report_ok(pr)).inspect_err(|err| {
226 self.report_error(pr, err);
227 })
228 }
229
230 /// Emit a success line for the completed operation.
231 fn report_ok(&self, pr: &PullRequest) {
232 let msg = match self {
233 Self::Approve => "Approved",
234 Self::Merge => "Merged",
235 Self::DependabotRebase => "Dependabot rebased",
236 Self::EnableAutoMerge => "Auto-merge enabled",
237 };
238 println!("{} {}", format!("{msg} PR").green().bold(), format_pr(pr));
239 }
240
241 /// Emit a structured error report for a failed operation.
242 ///
243 /// # Rationale
244 /// Keeps multi‑line error payload visually grouped with the PR metadata.
245 fn report_error(&self, pr: &PullRequest, error: &color_eyre::Report) {
246 let msg = match self {
247 Self::Approve => "approving",
248 Self::Merge => "merging",
249 Self::DependabotRebase => "triggering dependabot rebase",
250 Self::EnableAutoMerge => "enabling auto-merge",
251 };
252 eprintln!(
253 "{} {} error=\n{}",
254 format!("Error {msg} PR").red(),
255 format_pr(pr),
256 format!("{error:#?}").red()
257 );
258 }
259}
260
261/// Format concise identifying PR fields for log / status lines.
262///
263/// Builds a single colorized string containing number, quoted title, and
264/// debug formatting of the author object.
265///
266/// # Rationale
267/// Central helper avoids duplicating formatting order and styling decisions.
268fn format_pr(pr: &PullRequest) -> String {
269 format!(
270 "{}{:?} {}{:?} {}{:?}",
271 "number=".white().bold(),
272 pr.number,
273 "title=".white().bold(),
274 pr.title,
275 "author=",
276 pr.author,
277 )
278}
279
280/// Create a GitHub issue and develop it with an associated branch.
281///
282/// Prompts the user for an issue title, creates the issue via GitHub CLI,
283/// then develops it by creating an associated branch from the default branch.
284/// Optionally checks out the newly created branch based on user preference.
285///
286/// # Errors
287/// - If [`ytil_tui::text_prompt`] fails when prompting for issue title.
288/// - If [`ytil_tui::yes_no_select`] fails when prompting for branch checkout preference.
289/// - If [`ytil_gh::issue::create`] fails when creating the GitHub issue.
290/// - If [`ytil_gh::issue::develop`] fails when creating the associated branch.
291///
292/// # Rationale
293/// Separates issue creation flow from PR listing flow, allowing users to quickly
294/// bootstrap new work items without leaving the terminal interface.
295fn create_issue_and_branch_from_default_branch() -> Result<(), color_eyre::eyre::Error> {
296 let Some(issue_title) = ytil_tui::text_prompt("Issue title:")?.map(|x| x.trim().to_string()) else {
297 return Ok(());
298 };
299
300 let Some(checkout_branch) = ytil_tui::yes_no_select("Checkout branch?")? else {
301 return Ok(());
302 };
303
304 let created_issue = ytil_gh::issue::create(&issue_title)?;
305 println!(
306 "\n{} number={} title={issue_title:?}",
307 "Issue created".green().bold(),
308 created_issue.issue_nr
309 );
310
311 let develop_output = ytil_gh::issue::develop(&created_issue.issue_nr, checkout_branch)?;
312 println!(
313 "{} with name={:?}",
314 "Branch created".green().bold(),
315 develop_output.branch_name
316 );
317
318 Ok(())
319}
320
321/// Prompts the selection of a branch and creates a pull request for the selected one.
322///
323/// # Errors
324/// - If [`ytil_tui::git_branch::select`] fails.
325/// - If [`pr_title_from_branch_name`] fails.
326/// - If [`ytil_gh::pr::create`] fails.
327fn create_pr() -> Result<(), color_eyre::eyre::Error> {
328 let Some(branch) = ytil_tui::git_branch::select()? else {
329 return Ok(());
330 };
331
332 let title = pr_title_from_branch_name(branch.name_no_origin())?;
333 let pr_url = ytil_gh::pr::create(&title)?;
334 println!("{} title={title:?} pr_url={pr_url:?}", "PR created".green().bold());
335
336 Ok(())
337}
338
339/// Interactively creates a GitHub branch from a selected issue.
340///
341/// # Errors
342/// Propagates errors from issue listing, user selection, or branch development.
343///
344/// # Assumptions
345/// Assumes a terminal UI is available for user interaction and the GitHub CLI is configured.
346///
347/// # Rationale
348/// Provides an interactive workflow for developers to quickly create feature branches tied to specific GitHub issues.
349///
350/// # Performance
351/// Involves user interaction and subprocess calls, suitable for interactive use.
352///
353/// # Future Work
354/// Consider adding branch naming customization or integration with project management tools.
355fn create_branch_from_issue() -> Result<(), color_eyre::eyre::Error> {
356 let issues = ytil_gh::issue::list()?;
357
358 let Some(issue) = ytil_tui::minimal_select(issues.into_iter().map(RenderableListedIssue).collect())? else {
359 return Ok(());
360 };
361
362 let Some(checkout_branch) = ytil_tui::yes_no_select("Checkout branch?")? else {
363 return Ok(());
364 };
365
366 let develop_output = ytil_gh::issue::develop(&issue.number.to_string(), checkout_branch)?;
367 println!(
368 "{} with name={:?}",
369 "Branch created".green().bold(),
370 develop_output.branch_name
371 );
372
373 Ok(())
374}
375
376/// Parses a branch name to generate a pull request title.
377///
378/// # Errors
379/// - Branch name has no parts separated by `-`.
380/// - The first part is not a valid usize for issue number.
381/// - The title parts result in an empty title.
382fn pr_title_from_branch_name(branch_name: &str) -> color_eyre::Result<String> {
383 let mut parts = branch_name.split('-');
384
385 let issue_number: usize = parts
386 .next()
387 .ok_or_else(|| eyre!("error malformed branch_name | branch_name={branch_name:?}"))
388 .and_then(|x| {
389 x.parse().wrap_err_with(|| {
390 format!("error parsing issue number | branch_name={branch_name:?} issue_number={x:?}")
391 })
392 })?;
393
394 let title = parts
395 .enumerate()
396 .map(|(i, word)| {
397 if i == 0 {
398 let mut chars = word.chars();
399 let Some(first) = chars.next() else {
400 return String::new();
401 };
402 return first.to_uppercase().chain(chars.as_str().chars()).collect();
403 }
404 word.to_string()
405 })
406 .collect::<Vec<_>>()
407 .join(" ");
408
409 if title.is_empty() {
410 bail!("error empty title | branch_name={branch_name:?}");
411 }
412
413 Ok(format!("[{issue_number}]: {title}"))
414}
415
416/// List and optionally batch‑merge GitHub pull requests interactively or create issues with associated branches.
417///
418/// # Errors
419/// - Flag parsing fails (unknown flag, missing value, invalid [`PullRequestMergeState`]).
420/// - GitHub CLI invocation fails (listing PRs via [`ytil_gh::pr::get`], approving via [`ytil_gh::pr::approve`], merging
421/// via [`ytil_gh::pr::merge`], commenting via [`ytil_gh::pr::dependabot_rebase`], creating issue via
422/// [`ytil_gh::issue::create`]).
423/// - TUI interaction fails (selection UI errors via [`ytil_tui::minimal_multi_select`] and
424/// [`ytil_tui::minimal_select`], issue title prompt via [`ytil_tui::text_prompt`], branch checkout prompt via
425/// [`ytil_tui::yes_no_select`]).
426/// - GitHub CLI invocation fails (issue and branch creation via [`ytil_gh::issue::create`] and
427/// [`ytil_gh::issue::develop`]).
428fn main() -> color_eyre::Result<()> {
429 color_eyre::install()?;
430
431 let mut pargs = Arguments::from_env();
432 if pargs.has_help() {
433 println!("{}", include_str!("../help.txt"));
434 return Ok(());
435 }
436
437 ytil_gh::log_into_github()?;
438
439 if pargs.contains("issue") {
440 create_issue_and_branch_from_default_branch()?;
441 return Ok(());
442 }
443
444 if pargs.contains("pr") {
445 create_pr()?;
446 return Ok(());
447 }
448
449 if pargs.contains("branch") {
450 create_branch_from_issue()?;
451 return Ok(());
452 }
453
454 let repo_name_with_owner = ytil_gh::get_repo_view_field(&RepoViewField::NameWithOwner)?;
455
456 let search_filter: Option<String> = pargs.opt_value_from_str("--search")?;
457 let merge_state = pargs
458 .opt_value_from_fn("--merge-state", PullRequestMergeState::from_str)
459 .with_section(|| {
460 format!(
461 "accepted values are {:#?}",
462 PullRequestMergeState::iter().collect::<Vec<_>>()
463 )
464 .red()
465 .bold()
466 .to_string()
467 })?;
468
469 let params = format!(
470 "search_filter={search_filter:?}{}",
471 merge_state
472 .map(|ms| format!("\nmerge_state={ms:?}"))
473 .unwrap_or_default()
474 );
475 println!("\n{}\n{}\n", "Search PRs by".cyan().bold(), params.white().bold());
476
477 let pull_requests = ytil_gh::pr::get(&repo_name_with_owner, search_filter.as_deref(), &|pr: &PullRequest| {
478 if let Some(merge_state) = merge_state {
479 return pr.merge_state == merge_state;
480 }
481 true
482 })?;
483
484 let renderable_prs: Vec<_> = pull_requests.into_iter().map(RenderablePullRequest).collect();
485 if renderable_prs.is_empty() {
486 println!("{}\n{}", "No matching PRs found".yellow().bold(), params.white().bold());
487 return Ok(());
488 }
489
490 let Some(selected_prs) = ytil_tui::minimal_multi_select::<RenderablePullRequest>(renderable_prs)? else {
491 println!("No PRs selected");
492 return Ok(());
493 };
494
495 let Some(selected_op) = ytil_tui::minimal_select::<SelectableOp>(SelectableOp::iter().collect())? else {
496 println!("No operation selected");
497 return Ok(());
498 };
499
500 println!(); // Cosmetic spacing.
501
502 let selected_op_run = selected_op.run();
503 for pr in selected_prs.iter().map(Deref::deref) {
504 selected_op_run(pr);
505 }
506
507 Ok(())
508}
509
510#[cfg(test)]
511mod tests {
512 use rstest::rstest;
513
514 use super::*;
515
516 #[rstest]
517 #[case("43-foo-bar-baz", "[43]: Foo bar baz")]
518 #[case("1-hello", "[1]: Hello")]
519 #[case("123-long-branch-name-here", "[123]: Long branch name here")]
520 fn pr_title_from_branch_name_when_valid_input_formats_correctly(#[case] input: &str, #[case] expected: &str) {
521 pretty_assertions::assert_eq!(pr_title_from_branch_name(input).unwrap(), expected);
522 }
523
524 #[rstest]
525 #[case(
526 "abc-foo",
527 r#"error parsing issue number | branch_name="abc-foo" issue_number="abc""#
528 )]
529 #[case("42", r#"error empty title | branch_name="42""#)]
530 #[case("", r#"error parsing issue number | branch_name="" issue_number="""#)]
531 fn pr_title_from_branch_name_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_error: &str) {
532 assert2::let_assert!(Err(err) = pr_title_from_branch_name(input));
533 pretty_assertions::assert_eq!(err.to_string(), expected_error);
534 }
535}