hyperhive/hive-forge/src/verbs/pr_merge.rs
atlas f633abbdc4 refactor(hive-forge): share latest-per-reviewer logic between pr-status and pr-merge
pr-status and pr-merge both computed 'latest non-comment review verdict per
reviewer' independently (identical oldest-first, COMMENT/PENDING-skipping,
supersede-by-later loop). Extract it to a shared verbs::latest_reviews helper
so the verdict semantics live in one place and can't drift between the
health view and the pre-merge changes-requested gate. Pure dedup, no
behaviour change.
2026-06-15 11:44:19 +02:00

179 lines
6.2 KiB
Rust

//! `pr-merge <number> [--method merge|rebase] [--keep-branch] [--force]`
//! — merge a pull request.
//!
//! Wraps `POST /api/v1/repos/{owner}/{repo}/pulls/{n}/merge` so agents on a
//! peer-review-and-merge workflow (e.g. the paper repo, where agents merge
//! each other's PRs without an operator approval) have a CLI path instead of
//! reaching for the raw API. Pairs with `pr-status` (the merge-readiness
//! verdict this verb pre-checks) and `pr-create`.
//!
//! Safe by default: refuses unless the PR is mergeable, CI is not red, and no
//! review requests changes — pass `--force` to override (which also sets
//! Forgejo's own `force_merge`). The head branch is deleted after a successful
//! merge unless `--keep-branch` is given. Squash is intentionally not offered.
use anyhow::{Result, bail};
use clap::{Args as ClapArgs, ValueEnum};
use serde_json::{Value, json};
use crate::client::Client;
/// Merge strategy. Squash is deliberately omitted (hive convention: keep the
/// per-commit history, so a squash option isn't exposed).
#[derive(Clone, Copy, ValueEnum)]
pub enum Method {
/// Create a merge commit (Forgejo `Do: merge`).
Merge,
/// Rebase the head branch onto the base then fast-forward (Forgejo `Do: rebase`).
Rebase,
}
impl Method {
/// The Forgejo `Do` field value for this strategy.
fn forgejo_do(self) -> &'static str {
match self {
Method::Merge => "merge",
Method::Rebase => "rebase",
}
}
}
#[derive(ClapArgs)]
pub struct Args {
/// PR number to merge.
number: u64,
/// Merge strategy (default: a merge commit). Squash is not offered.
#[arg(long, value_enum, default_value = "merge")]
method: Method,
/// Keep the head branch after merging. By default the head branch is
/// deleted once the merge succeeds.
#[arg(long = "keep-branch")]
keep_branch: bool,
/// Merge even if the PR is not mergeable, CI is not green, or a review
/// requests changes. Also sets Forgejo's `force_merge` so the server does
/// not refuse on its own status checks.
#[arg(long)]
force: bool,
}
/// # Errors
///
/// Returns an error if the PR lookup or any readiness GET fails, if the PR is
/// already merged or closed, if a pre-merge readiness check fails without
/// `--force` (not mergeable / CI not green / changes requested), or if the
/// merge POST itself returns a non-2xx (e.g. Forgejo `405` when the PR cannot
/// be merged).
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let pull = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?;
if pull.get("merged").and_then(Value::as_bool).unwrap_or(false) {
bail!("pr-merge: PR #{} is already merged", args.number);
}
if pull.get("state").and_then(Value::as_str) == Some("closed") {
bail!("pr-merge: PR #{} is closed", args.number);
}
if !args.force {
check_ready(client, repo, args.number, &pull)?;
}
let payload = json!({
"Do": args.method.forgejo_do(),
"delete_branch_after_merge": !args.keep_branch,
"force_merge": args.force,
});
client.post_no_content(
&format!("/repos/{repo}/pulls/{}/merge", args.number),
&payload,
)?;
let deleted = if args.keep_branch {
""
} else {
" (head branch deleted)"
};
println!(
"merged PR #{} via {}{deleted}",
args.number,
args.method.forgejo_do()
);
Ok(())
}
/// Pre-merge readiness gate, mirroring `pr-status`'s verdict: the PR must be
/// mergeable, CI must not be red/pending, and no review may request changes.
/// Bails with an actionable message (pointing at `--force`) on the first
/// failure.
fn check_ready(client: &Client, repo: &str, number: u64, pull: &Value) -> Result<()> {
match pull.get("mergeable").and_then(Value::as_bool) {
Some(true) => {}
Some(false) => bail!(
"pr-merge: PR #{number} is not mergeable (conflicts). Rebase it, or pass --force."
),
None => bail!(
"pr-merge: PR #{number} mergeability is still being computed. Retry shortly, or pass --force."
),
}
if let Some(sha) = pull
.get("head")
.and_then(|h| h.get("sha"))
.and_then(Value::as_str)
{
let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?;
let state = combined.get("state").and_then(Value::as_str).unwrap_or("");
let has_statuses = combined
.get("statuses")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty());
// An empty status set means no CI is configured — not a blocker.
// Anything other than success once CI exists blocks the merge.
if has_statuses && state != "success" {
bail!(
"pr-merge: PR #{number} CI is not green (state: {state}). Wait for green, or pass --force."
);
}
}
// Block only on reviewers whose *current* verdict requests changes
// (latest-per-reviewer, so a later APPROVED clears an earlier
// REQUEST_CHANGES). Shares the verdict logic with `pr-status`.
let blockers: Vec<String> = super::latest_reviews(client, repo, number)?
.into_iter()
.filter(|(_, st)| st == "REQUEST_CHANGES")
.map(|(login, _)| login)
.collect();
if !blockers.is_empty() {
bail!(
"pr-merge: PR #{number} has changes requested by {}. Resolve the review, or pass --force.",
blockers.join(", ")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn method_maps_to_forgejo_do() {
assert_eq!(Method::Merge.forgejo_do(), "merge");
assert_eq!(Method::Rebase.forgejo_do(), "rebase");
}
#[test]
fn merge_payload_shape() {
// delete-by-default: keep_branch=false → delete_branch_after_merge=true.
let payload = json!({
"Do": Method::Merge.forgejo_do(),
"delete_branch_after_merge": true,
"force_merge": false,
});
assert_eq!(payload["Do"], "merge");
assert_eq!(payload["delete_branch_after_merge"], true);
assert_eq!(payload["force_merge"], false);
}
}