205 lines
7.2 KiB
Rust
205 lines
7.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 forgejo_api::structs::{
|
|
CommitStatusState, MergePullRequestOption, MergePullRequestOptionDo, PullRequest, StateType,
|
|
};
|
|
|
|
use crate::client::{Client, index, split_repo};
|
|
|
|
/// 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 (used for the
|
|
/// confirmation message).
|
|
fn forgejo_do(self) -> &'static str {
|
|
match self {
|
|
Method::Merge => "merge",
|
|
Method::Rebase => "rebase",
|
|
}
|
|
}
|
|
|
|
/// The typed `Do` enum variant for the merge request body.
|
|
fn as_option_do(self) -> MergePullRequestOptionDo {
|
|
match self {
|
|
Method::Merge => MergePullRequestOptionDo::Merge,
|
|
Method::Rebase => MergePullRequestOptionDo::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 (owner, name) = client.owner_repo()?;
|
|
let idx = index(args.number)?;
|
|
let pull = client
|
|
.api()
|
|
.repo_get_pull_request(owner, name, idx)
|
|
.send()?;
|
|
|
|
if pull.merged.unwrap_or(false) {
|
|
bail!("pr-merge: PR #{} is already merged", args.number);
|
|
}
|
|
if pull.state == Some(StateType::Closed) {
|
|
bail!("pr-merge: PR #{} is closed", args.number);
|
|
}
|
|
|
|
if !args.force {
|
|
check_ready(client, repo, args.number, &pull)?;
|
|
}
|
|
|
|
let payload = MergePullRequestOption {
|
|
r#do: args.method.as_option_do(),
|
|
merge_commit_id: None,
|
|
merge_message_field: None,
|
|
merge_title_field: None,
|
|
delete_branch_after_merge: Some(!args.keep_branch),
|
|
force_merge: Some(args.force),
|
|
head_commit_id: None,
|
|
merge_when_checks_succeed: None,
|
|
};
|
|
client
|
|
.api()
|
|
.repo_merge_pull_request(owner, name, idx, payload)
|
|
.send()?;
|
|
|
|
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: &PullRequest) -> Result<()> {
|
|
match pull.mergeable {
|
|
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.head.as_ref().and_then(|h| h.sha.as_deref()) {
|
|
let (owner, name) = split_repo(repo)?;
|
|
let (_, combined) = client
|
|
.api()
|
|
.repo_get_combined_status_by_ref(owner, name, sha)
|
|
.send()?;
|
|
let state = combined.state;
|
|
let has_statuses = combined.statuses.as_ref().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 != Some(CommitStatusState::Success) {
|
|
let state = super::pr_status::status_state_str(state);
|
|
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.
|
|
// Pin the wire shape of the typed body — `Do` casing included.
|
|
let payload = MergePullRequestOption {
|
|
r#do: Method::Merge.as_option_do(),
|
|
merge_commit_id: None,
|
|
merge_message_field: None,
|
|
merge_title_field: None,
|
|
delete_branch_after_merge: Some(true),
|
|
force_merge: Some(false),
|
|
head_commit_id: None,
|
|
merge_when_checks_succeed: None,
|
|
};
|
|
let wire = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(wire["Do"], "merge");
|
|
assert_eq!(wire["delete_branch_after_merge"], true);
|
|
assert_eq!(wire["force_merge"], false);
|
|
}
|
|
}
|