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}