Skip to main content

ghl/
main.rs

1//! List and batch-merge GitHub pull requests interactively, or create issues with branches.
2//!
3//! # Errors
4//! - Flag parsing, GitHub CLI invocation, or TUI interaction fails.
5
6use 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/// List and optionally batch‑merge GitHub pull requests interactively or create issues with associated branches.
24///
25/// # Errors
26/// - Flag parsing fails (unknown flag, missing value, invalid [`PullRequestMergeState`]).
27/// - GitHub CLI invocation fails (listing PRs via [`ytil_gh::pr::get`], approving via [`ytil_gh::pr::approve`], merging
28///   via [`ytil_gh::pr::merge`], commenting via [`ytil_gh::pr::dependabot_rebase`], creating issue via
29///   [`ytil_gh::issue::create`]).
30/// - TUI interaction fails (selection UI errors via [`ytil_tui::minimal_multi_select`] and
31///   [`ytil_tui::minimal_select`], issue title prompt via [`ytil_tui::text_prompt`], branch checkout prompt via
32///   [`ytil_tui::yes_no_select`]).
33/// - GitHub CLI invocation fails (issue and branch creation via [`ytil_gh::issue::create`] and
34///   [`ytil_gh::issue::develop`]).
35#[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!(); // Cosmetic spacing.
105
106    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
114/// Newtype wrapper implementing colored [`Display`] for a [`PullRequest`].
115///
116/// Renders: `<number> <author.login> <colored-merge-state> <title>`.
117/// Merge state receives a color to aid quick scanning.
118pub 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 directly to the formatter, avoiding intermediate String allocations from .to_string()
131        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            // The spacing before the title is required to align it with the first line.
167            "{} {} {}",
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/// User-selectable high-level operations to apply to chosen PRs.
176///
177/// Encapsulates composite actions presented in the TUI. Separate from [`Op`]
178/// which models the underlying atomic steps and reporting. Expanding this enum
179/// only affects menu construction / selection logic.
180#[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
222/// Atomic pull request operations executed by `ghl`.
223///
224/// Represents each discrete action the tool can perform against a selected
225/// pull request. Higher‑level composite choices in the TUI (see [`SelectableOp`])
226/// sequence these as needed. Centralizing variants here keeps reporting logic
227/// (`report`, `report_ok`, `report_error`) uniform and extensible.
228///
229/// # Variants
230/// - `Approve` Submit an approving review via [`ytil_gh::pr::approve`] (`gh pr review --approve`).
231/// - `Merge` Perform the administrative squash merge via [`ytil_gh::pr::merge`] (`gh pr merge --admin --squash`).
232/// - `DependabotRebase` Post the `@dependabot rebase` comment via [`ytil_gh::pr::dependabot_rebase`] to request an
233///   updated rebase for a Dependabot PR.
234/// - `EnableAutoMerge` Schedule automatic merge via [`ytil_gh::pr::enable_auto_merge`] (rebase) once requirements
235///   satisfied.
236enum Op {
237    Approve,
238    Merge,
239    DependabotRebase,
240    EnableAutoMerge,
241}
242
243impl Op {
244    /// Report the result of executing an operation on a pull request.
245    ///
246    /// Delegates to success / error helpers that emit colorized, structured
247    /// terminal output. Keeps call‑site chaining terse while centralizing the
248    /// formatting logic.
249    ///
250    /// # Errors
251    /// Returns the same error contained in `res` (no transformation) so callers
252    /// can continue combinators (`and_then`, etc.) if desired.
253    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    /// Emit a success line for the completed operation.
260    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    /// Emit a structured error report for a failed operation.
271    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
287/// Format concise identifying PR fields for log / status lines.
288fn 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
300/// Create a GitHub issue and develop it with an associated branch.
301///
302/// # Errors
303/// - User interaction or GitHub CLI operations fail.
304fn 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
330/// Prompts the selection of a branch and creates a pull request for the selected one.
331///
332/// # Errors
333/// - If [`ytil_tui::git_branch::select`] fails.
334/// - If [`pr_title_from_branch_name`] fails.
335/// - If [`ytil_gh::pr::create`] fails.
336fn 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
348/// Interactively creates a GitHub branch from a selected issue.
349///
350/// # Errors
351/// - Issue listing, user selection, or branch development fails.
352fn 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
373/// Parses a branch name to generate a pull request title.
374///
375/// # Errors
376/// - Branch name has no parts separated by `-`.
377/// - The first part is not a valid usize for issue number.
378/// - The title parts result in an empty title.
379fn 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}