//! `pr-merge [--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 = 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); } }