ytil_gh/
pr.rs

1use std::process::Command;
2
3use chrono::DateTime;
4use chrono::Utc;
5use color_eyre::eyre::bail;
6use serde::Deserialize;
7use strum::EnumIter;
8use strum::EnumString;
9pub use strum::IntoEnumIterator;
10use ytil_cmd::CmdExt;
11
12/// Pull request summary fetched via the `gh pr list` command.
13///
14/// Captures only the fields needed for listing, filtering, display, and merge
15/// decisions. Additional fields can be appended later without breaking callers.
16///
17/// - `number` Numeric PR number (unique per repository).
18/// - `title` Current PR title.
19/// - `author` Author login + bot flag (see [`PullRequestAuthor`]).
20/// - `merge_state` High‑level mergeability classification returned by GitHub (see [`PullRequestMergeState`]).
21/// - `updated_at` Last update timestamp in UTC (GitHub `updatedAt`).
22///
23/// # Future Work
24/// - Add labels and draft status if/when used for filtering.
25/// - Include head / base branch names for richer displays.
26#[derive(Debug, Deserialize)]
27pub struct PullRequest {
28    pub number: usize,
29    pub title: String,
30    pub author: PullRequestAuthor,
31    #[serde(rename = "mergeStateStatus")]
32    pub merge_state: PullRequestMergeState,
33    #[serde(rename = "updatedAt")]
34    pub updated_at: DateTime<Utc>,
35}
36
37/// Author metadata for a pull request.
38///
39/// Minimal surface: login + bot flag; extended profile fields are intentionally
40/// omitted to keep JSON payloads small.
41#[derive(Debug, Deserialize)]
42pub struct PullRequestAuthor {
43    pub login: String,
44    pub is_bot: bool,
45}
46
47/// Merge state classification returned by GitHub's GraphQL / REST surfaces.
48///
49/// (Sourced via `mergeStateStatus` field.) Used to colorize and optionally
50/// filter PRs prior to attempting a merge.
51///
52/// Variants map 1:1 to upstream values (`SCREAMING_SNAKE_CASE`) to simplify
53/// deserialization and future additions.
54#[derive(Clone, Copy, Debug, Deserialize, EnumIter, EnumString, Eq, PartialEq)]
55#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
56pub enum PullRequestMergeState {
57    Behind,
58    Blocked,
59    Clean,
60    Dirty,
61    Draft,
62    HasHooks,
63    Unknown,
64    Unmergeable,
65    Unstable,
66}
67
68/// Fetch pull requests for a repository using `gh pr list`.
69///
70/// Requests the JSON fields: `number,title,author,mergeStateStatus,updatedAt`.
71/// The `updated_at` timestamp (UTC) enables client‑side freshness sorting, stale PR
72/// detection, and activity‑based filtering without an additional API round‑trip.
73///
74/// # Errors
75/// - Spawning or executing `gh pr list` fails.
76/// - Command exits non‑zero (handled inside [`ytil_cmd::CmdExt`]).
77/// - Output JSON cannot be deserialized.
78///
79/// # Rationale
80/// Accepting `Option<&str>` for search cleanly distinguishes absence vs empty and avoids
81/// forcing callers to include flag/quoting. Using a trait object for the predicate avoids
82/// generic inference issues when passing `None`.
83///
84/// # Future Work
85/// - Expose pagination (currently relies on `gh` default limit).
86pub fn get(
87    repo: &str,
88    search: Option<&str>,
89    retain_fn: &dyn Fn(&PullRequest) -> bool,
90) -> color_eyre::Result<Vec<PullRequest>> {
91    let mut args = vec![
92        "pr",
93        "list",
94        "--repo",
95        repo,
96        "--json",
97        "number,title,author,mergeStateStatus,updatedAt",
98    ];
99    if let Some(s) = search.filter(|s| !s.is_empty()) {
100        args.extend(["--search", s]);
101    }
102
103    let output = Command::new("gh").args(args).exec()?.stdout;
104
105    if output.is_empty() {
106        return Ok(Vec::new());
107    }
108
109    let mut prs: Vec<PullRequest> = serde_json::from_slice(&output)?;
110    prs.retain(|pr| retain_fn(pr));
111    prs.sort_unstable_by(|a, b| b.updated_at.cmp(&a.updated_at));
112
113    Ok(prs)
114}
115
116/// Merge a pull request using administrative squash semantics.
117///
118/// Invokes: `gh pr merge --admin --squash --delete-branch <PR_NUMBER>`.
119///
120/// # Errors
121/// - Spawning or executing the `gh pr merge` command fails.
122/// - Command exits with non‑zero status (propagated by [`ytil_cmd::CmdExt`]).
123///
124/// # Rationale
125/// Squash + delete keeps history linear and prunes merged topic branches automatically.
126pub fn merge(pr_number: usize) -> color_eyre::Result<()> {
127    Command::new("gh")
128        .args([
129            "pr",
130            "merge",
131            "--admin",
132            "--squash",
133            "--delete-branch",
134            &format!("{pr_number}"),
135        ])
136        .exec()?;
137    Ok(())
138}
139
140/// Approve a pull request via `gh pr review --approve`.
141///
142/// Issues an approval review for the specified pull request using the GitHub
143/// CLI. Mirrors the minimalist style of [`merge`] for consistency and keeps
144/// policy / flag decisions localized here.
145///
146/// # Errors
147/// - Spawning or executing `gh pr review` fails.
148/// - Command exits with non‑zero status (propagated by [`ytil_cmd::CmdExt`]).
149pub fn approve(pr_number: usize) -> color_eyre::Result<()> {
150    Command::new("gh")
151        .args(["pr", "review", &format!("{pr_number}"), "--approve"])
152        .exec()?;
153    Ok(())
154}
155
156/// Trigger Dependabot to rebase a pull request.
157///
158/// Sends the special `@dependabot rebase` comment recognized by Dependabot to
159/// request an up‑to‑date rebase of its generated pull request. Useful when the
160/// PR is out-of-date with the base branch or conflicting after merges.
161///
162/// # Errors
163/// - Spawning or executing `gh pr comment` fails.
164/// - Command exits with non‑zero status (propagated by [`ytil_cmd::CmdExt`]).
165pub fn dependabot_rebase(pr_number: usize) -> color_eyre::Result<()> {
166    Command::new("gh")
167        .args(["pr", "comment", &format!("{pr_number}"), "--body", "@dependabot rebase"])
168        .exec()?;
169    Ok(())
170}
171
172/// Enable GitHub auto-merge for a pull request (squash strategy).
173///
174/// Invokes: `gh pr merge <PR_NUMBER> --auto --squash --delete-branch`.
175/// Schedules a squash merge to occur automatically once required status checks
176/// and reviews pass. If all requirements are already satisfied, merge occurs immediately.
177///
178/// # Errors
179/// - Spawning or executing `gh pr merge` fails.
180/// - Command exits non-zero (propagated by [`ytil_cmd::CmdExt`]).
181pub fn enable_auto_merge(pr_number: usize) -> color_eyre::Result<()> {
182    Command::new("gh")
183        .args([
184            "pr",
185            "merge",
186            &format!("{pr_number}"),
187            "--auto",
188            "--squash",
189            "--delete-branch",
190        ])
191        .exec()?;
192    Ok(())
193}
194
195/// Creates a GitHub pull request with the specified title.
196///
197/// # Errors
198/// - The title is empty.
199/// - Spawning or executing `gh pr create` fails.
200/// - Command exits non-zero (propagated by [`ytil_cmd::CmdExt`]).
201pub fn create(title: &str) -> color_eyre::Result<String> {
202    if title.is_empty() {
203        bail!("error cannot create GitHub PR with empty title");
204    }
205    let output = Command::new("gh")
206        .args(["pr", "create", "--title", title, "--body", ""])
207        .exec()?;
208    ytil_cmd::extract_success_output(&output)
209}