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 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
22/// Newtype wrapper implementing colored [`Display`] for a [`PullRequest`].
23///
24/// Renders: `<number> <author.login> <colored-merge-state> <title>`.
25/// Merge state receives a color to aid quick scanning.
26pub 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 directly to the formatter, avoiding intermediate String allocations from .to_string()
39        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            // The spacing before the title is required to align it with the first line.
75            "{} {} {}",
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/// User-selectable high-level operations to apply to chosen PRs.
84///
85/// Encapsulates composite actions presented in the TUI. Separate from [`Op`]
86/// which models the underlying atomic steps and reporting. Expanding this enum
87/// only affects menu construction / selection logic.
88#[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
128/// Atomic pull request operations executed by `ghl`.
129///
130/// Represents each discrete action the tool can perform against a selected
131/// pull request. Higher‑level composite choices in the TUI (see [`SelectableOp`])
132/// sequence these as needed. Centralizing variants here keeps reporting logic
133/// (`report`, `report_ok`, `report_error`) uniform and extensible.
134///
135/// # Variants
136/// - `Approve` Submit an approving review via [`ytil_gh::pr::approve`] (`gh pr review --approve`).
137/// - `Merge` Perform the administrative squash merge via [`ytil_gh::pr::merge`] (`gh pr merge --admin --squash`).
138/// - `DependabotRebase` Post the `@dependabot rebase` comment via [`ytil_gh::pr::dependabot_rebase`] to request an
139///   updated rebase for a Dependabot PR.
140/// - `EnableAutoMerge` Schedule automatic merge via [`ytil_gh::pr::enable_auto_merge`] (rebase) once requirements
141///   satisfied.
142enum Op {
143    Approve,
144    Merge,
145    DependabotRebase,
146    EnableAutoMerge,
147}
148
149impl Op {
150    /// Report the result of executing an operation on a pull request.
151    ///
152    /// Delegates to success / error helpers that emit colorized, structured
153    /// terminal output. Keeps call‑site chaining terse while centralizing the
154    /// formatting logic.
155    ///
156    /// # Errors
157    /// Returns the same error contained in `res` (no transformation) so callers
158    /// can continue combinators (`and_then`, etc.) if desired.
159    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    /// Emit a success line for the completed operation.
166    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    /// Emit a structured error report for a failed operation.
177    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
193/// Format concise identifying PR fields for log / status lines.
194fn 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
206/// Create a GitHub issue and develop it with an associated branch.
207///
208/// # Errors
209/// - User interaction or GitHub CLI operations fail.
210fn 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
236/// Prompts the selection of a branch and creates a pull request for the selected one.
237///
238/// # Errors
239/// - If [`ytil_tui::git_branch::select`] fails.
240/// - If [`pr_title_from_branch_name`] fails.
241/// - If [`ytil_gh::pr::create`] fails.
242fn 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
254/// Interactively creates a GitHub branch from a selected issue.
255///
256/// # Errors
257/// - Issue listing, user selection, or branch development fails.
258fn 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
279/// Parses a branch name to generate a pull request title.
280///
281/// # Errors
282/// - Branch name has no parts separated by `-`.
283/// - The first part is not a valid usize for issue number.
284/// - The title parts result in an empty title.
285fn 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/// List and optionally batch‑merge GitHub pull requests interactively or create issues with associated branches.
323///
324/// # Errors
325/// - Flag parsing fails (unknown flag, missing value, invalid [`PullRequestMergeState`]).
326/// - GitHub CLI invocation fails (listing PRs via [`ytil_gh::pr::get`], approving via [`ytil_gh::pr::approve`], merging
327///   via [`ytil_gh::pr::merge`], commenting via [`ytil_gh::pr::dependabot_rebase`], creating issue via
328///   [`ytil_gh::issue::create`]).
329/// - TUI interaction fails (selection UI errors via [`ytil_tui::minimal_multi_select`] and
330///   [`ytil_tui::minimal_select`], issue title prompt via [`ytil_tui::text_prompt`], branch checkout prompt via
331///   [`ytil_tui::yes_no_select`]).
332/// - GitHub CLI invocation fails (issue and branch creation via [`ytil_gh::issue::create`] and
333///   [`ytil_gh::issue::develop`]).
334#[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!(); // Cosmetic spacing.
403
404    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}