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