Skip to main content

ghl/cmds/
list.rs

1//! Interactive pull request listing and operations.
2
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::ops::Deref;
6use std::str::FromStr;
7
8use owo_colors::OwoColorize;
9use rootcause::prelude::ResultExt;
10use strum::EnumIter;
11use ytil_gh::RepoViewField;
12use ytil_gh::pr::IntoEnumIterator;
13use ytil_gh::pr::PullRequest;
14use ytil_gh::pr::PullRequestMergeState;
15use ytil_sys::pico_args::Arguments;
16
17/// List and optionally batch‑merge GitHub pull requests interactively.
18///
19/// # Errors
20/// - Flag parsing fails (unknown flag, missing value, invalid [`PullRequestMergeState`]).
21/// - GitHub CLI invocation fails (listing PRs via [`ytil_gh::pr::get`], approving via [`ytil_gh::pr::approve`], merging
22///   via [`ytil_gh::pr::merge`], commenting via [`ytil_gh::pr::dependabot_rebase`]).
23/// - TUI interaction fails (PR selection or operation selection).
24pub fn run(mut pargs: Arguments) -> rootcause::Result<()> {
25    let search_filter: Option<String> = match pargs.opt_value_from_str("--search") {
26        Ok(search_filter) => search_filter,
27        Err(error) => {
28            eprintln!("{}", crate::cmds::Help::List.text());
29            return Err(error.into());
30        }
31    };
32    let merge_state = pargs
33        .opt_value_from_fn("--merge-state", PullRequestMergeState::from_str)
34        .attach_with(|| {
35            format!(
36                "accepted values are {:#?}",
37                PullRequestMergeState::iter().collect::<Vec<_>>()
38            )
39        });
40    let merge_state = match merge_state {
41        Ok(merge_state) => merge_state,
42        Err(error) => {
43            eprintln!("{}", crate::cmds::Help::List.text());
44            return Err(error.into());
45        }
46    };
47
48    ytil_gh::log_into_github()?;
49    let repo_name_with_owner = ytil_gh::get_repo_view_field(&RepoViewField::NameWithOwner)?;
50
51    let params = format!(
52        "search_filter={search_filter:?}{}",
53        merge_state
54            .map(|ms| format!("\nmerge_state={ms:?}"))
55            .unwrap_or_default()
56    );
57    println!("\n{}\n{}\n", "Search PRs by".cyan().bold(), params.white().bold());
58
59    let pull_requests = ytil_gh::pr::get(&repo_name_with_owner, search_filter.as_deref(), &|pr: &PullRequest| {
60        if let Some(merge_state) = merge_state {
61            return pr.merge_state == merge_state;
62        }
63        true
64    })?;
65
66    let renderable_prs: Vec<_> = pull_requests.into_iter().map(RenderablePullRequest).collect();
67    if renderable_prs.is_empty() {
68        println!("{}\n{}", "No matching PRs found".yellow().bold(), params.white().bold());
69        return Ok(());
70    }
71
72    let Some(selected_prs) = ytil_tui::minimal_multi_select(renderable_prs, ToString::to_string, ToString::to_string)?
73    else {
74        println!("No PRs selected");
75        return Ok(());
76    };
77
78    let Some(selected_op) = ytil_tui::minimal_select::<SelectableOp>(SelectableOp::iter().collect())? else {
79        println!("No operation selected");
80        return Ok(());
81    };
82
83    println!(); // Cosmetic spacing.
84
85    let selected_op_run = selected_op.run();
86    for pr in selected_prs.iter().map(Deref::deref) {
87        selected_op_run(pr);
88    }
89
90    Ok(())
91}
92
93/// Newtype wrapper implementing colored [`Display`] for a [`PullRequest`].
94///
95/// Renders: `<number> <author.login> <colored-merge-state> <title>`.
96/// Merge state receives a color to aid quick scanning.
97pub struct RenderablePullRequest(pub PullRequest);
98
99impl Deref for RenderablePullRequest {
100    type Target = PullRequest;
101
102    fn deref(&self) -> &Self::Target {
103        &self.0
104    }
105}
106
107impl Display for RenderablePullRequest {
108    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
109        // Write directly to the formatter, avoiding intermediate String allocations from .to_string()
110        write!(
111            f,
112            "{} {} ",
113            self.author.login.blue().bold(),
114            self.updated_at.strftime("%d-%m-%Y %H:%M UTC")
115        )?;
116        match self.merge_state {
117            PullRequestMergeState::Behind => write!(f, "{} ", "Behind".yellow().bold())?,
118            PullRequestMergeState::Blocked => write!(f, "{} ", "Blocked".red())?,
119            PullRequestMergeState::Clean => write!(f, "{} ", "Clean".green())?,
120            PullRequestMergeState::Dirty => write!(f, "{} ", "Dirty".red().bold())?,
121            PullRequestMergeState::Draft => write!(f, "{} ", "Draft".blue().bold())?,
122            PullRequestMergeState::HasHooks => write!(f, "{} ", "HasHooks".magenta())?,
123            PullRequestMergeState::Unknown => write!(f, "Unknown ")?,
124            PullRequestMergeState::Unmergeable => write!(f, "{} ", "Unmergeable".red().bold())?,
125            PullRequestMergeState::Unstable => write!(f, "{} ", "Unstable".magenta().bold())?,
126        }
127        write!(f, "{}", self.title)
128    }
129}
130
131/// User-selectable high-level operations to apply to chosen PRs.
132///
133/// Encapsulates composite actions presented in the TUI. Separate from [`Op`]
134/// which models the underlying atomic steps and reporting. Expanding this enum
135/// only affects menu construction / selection logic.
136#[derive(EnumIter)]
137enum SelectableOp {
138    Approve,
139    ApproveAndMerge,
140    DependabotRebase,
141    EnableAutoMerge,
142}
143
144impl Display for SelectableOp {
145    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
146        match self {
147            Self::Approve => write!(f, "{}", "Approve".green().bold()),
148            Self::ApproveAndMerge => write!(f, "{}", "Approve & Merge".green().bold()),
149            Self::DependabotRebase => write!(f, "{}", "Dependabot Rebase".blue().bold()),
150            Self::EnableAutoMerge => write!(f, "{}", "Enable auto-merge".magenta().bold()),
151        }
152    }
153}
154
155impl SelectableOp {
156    pub fn run(&self) -> Box<dyn Fn(&PullRequest)> {
157        match self {
158            Self::Approve => Box::new(|pr| {
159                drop(Op::Approve.report(pr, ytil_gh::pr::approve(pr.number)));
160            }),
161            Self::ApproveAndMerge => Box::new(|pr| {
162                drop(
163                    Op::Approve
164                        .report(pr, ytil_gh::pr::approve(pr.number))
165                        .and_then(|()| Op::Merge.report(pr, ytil_gh::pr::merge(pr.number))),
166                );
167            }),
168            Self::DependabotRebase => Box::new(|pr| {
169                drop(Op::DependabotRebase.report(pr, ytil_gh::pr::dependabot_rebase(pr.number)));
170            }),
171            Self::EnableAutoMerge => Box::new(|pr| {
172                drop(Op::EnableAutoMerge.report(pr, ytil_gh::pr::enable_auto_merge(pr.number)));
173            }),
174        }
175    }
176}
177
178/// Atomic pull request operations executed by `ghl`.
179///
180/// Represents each discrete action the tool can perform against a selected
181/// pull request. Higher‑level composite choices in the TUI (see [`SelectableOp`])
182/// sequence these as needed. Centralizing variants here keeps reporting logic
183/// (`report`, `report_ok`, `report_error`) uniform and extensible.
184///
185/// # Variants
186/// - `Approve` Submit an approving review via [`ytil_gh::pr::approve`] (`gh pr review --approve`).
187/// - `Merge` Perform the administrative squash merge via [`ytil_gh::pr::merge`] (`gh pr merge --admin --squash`).
188/// - `DependabotRebase` Post the `@dependabot rebase` comment via [`ytil_gh::pr::dependabot_rebase`] to request an
189///   updated rebase for a Dependabot PR.
190/// - `EnableAutoMerge` Schedule automatic merge via [`ytil_gh::pr::enable_auto_merge`] (rebase) once requirements
191///   satisfied.
192enum Op {
193    Approve,
194    Merge,
195    DependabotRebase,
196    EnableAutoMerge,
197}
198
199impl Op {
200    /// Report the result of executing an operation on a pull request.
201    ///
202    /// Delegates to success / error helpers that emit colorized, structured
203    /// terminal output. Keeps call‑site chaining terse while centralizing the
204    /// formatting logic.
205    ///
206    /// # Errors
207    /// Returns the same error contained in `res` (no transformation) so callers
208    /// can continue combinators (`and_then`, etc.) if desired.
209    pub fn report(&self, pr: &PullRequest, res: rootcause::Result<()>) -> rootcause::Result<()> {
210        res.inspect(|()| self.report_ok(pr)).inspect_err(|err| {
211            self.report_error(pr, err);
212        })
213    }
214
215    /// Emit a success line for the completed operation.
216    fn report_ok(&self, pr: &PullRequest) {
217        let msg = match self {
218            Self::Approve => "Approved",
219            Self::Merge => "Merged",
220            Self::DependabotRebase => "Dependabot rebased",
221            Self::EnableAutoMerge => "Auto-merge enabled",
222        };
223        println!("{} {}", format!("{msg} PR").green().bold(), format_pr(pr));
224    }
225
226    /// Emit a structured error report for a failed operation.
227    fn report_error(&self, pr: &PullRequest, error: &rootcause::Report) {
228        let msg = match self {
229            Self::Approve => "approving",
230            Self::Merge => "merging",
231            Self::DependabotRebase => "triggering dependabot rebase",
232            Self::EnableAutoMerge => "enabling auto-merge",
233        };
234        eprintln!(
235            "{} {} error=\n{}",
236            format!("Error {msg} PR").red(),
237            format_pr(pr),
238            format!("{error:#?}").red()
239        );
240    }
241}
242
243/// Format concise identifying PR fields for log / status lines.
244fn format_pr(pr: &PullRequest) -> String {
245    format!(
246        "{}{:?} {}{:?} {}{:?}",
247        "number=".white().bold(),
248        pr.number,
249        "title=".white().bold(),
250        pr.title,
251        "author=",
252        pr.author,
253    )
254}