fix(#2393): ff-merge config prs via forge api instead of pushing main

This commit is contained in:
damocles 2026-07-13 11:18:43 +02:00
commit 6e0f6893cf
4 changed files with 135 additions and 197 deletions

View file

@ -4,7 +4,6 @@
//! boundary; moved verbatim from the `forge` module root.
use anyhow::Context;
use forgejo_api::ForgejoError;
use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo, StateType};
use super::{CONFIG_ORG, api, core_token, forge_git_url};
@ -14,33 +13,30 @@ use super::{CONFIG_ORG, api, core_token, forge_git_url};
// dashboard-approve-driven config-change flow).
//
// The dashboard-approve-driven flow has hive-c0re verify an operator-approved
// config PR, then land it: ff-push the verified sha to the protected default
// branch (= the merge) and mark the PR merged manually. These three fns are
// config PR, then land it: fast-forward-merge the verified sha into the
// protected default branch via the forge merge API (= the merge). These fns are
// the forge-side mechanics the c0re approve-handler (`run_merge_config_pr`)
// orchestrates; the orchestration fetches the verified sha into the agent's
// applied repo before calling `ff_push_to_main`. The core token is sourced
// internally (`core_token`), never passed in. `repo` is the agent's editable
// forge config repo in `owner/name` form (e.g. `agent-configs/<agent>`).
// applied repo (for the eval-verify) before calling `merge_config_pr_ff`. The
// core token is sourced internally (`core_token`), never passed in. `repo` is
// the agent's editable forge config repo in `owner/name` form (e.g.
// `agent-configs/<agent>`).
// ---------------------------------------------------------------------------
/// Typed failure for the merge primitives so the c0re approve-handler can
/// `match` recoverable drift (refresh the request sha + re-verify) against a
/// hard failure (fail the approval). The two drift variants carry the observed
/// sha so the handler can re-pin to it; `Other` is everything else (transport,
/// API, unexpected) and is not auto-retried.
/// `match` recoverable drift (surface a re-review message) against a hard
/// failure (fail the approval). `HeadDrift` carries the observed sha; `Other`
/// is everything else (a raced non-ff `main`, transport, API, unexpected) and
/// is not auto-retried.
#[derive(Debug)]
pub enum ForgeMergeError {
/// The PR head moved off `expected` (now at `actual`), seen at
/// mark-merged time. The handler's pre-merge `pr_head_sha` re-read is the
/// primary drift gate; this is the belt-and-suspenders race catch.
/// The PR head moved off `expected` (now at `actual`) since the handler's
/// pre-merge `pr_head_sha` re-read — caught by the `head_commit_id` pin on
/// the merge call, which refuses to merge a head that isn't the reviewed sha.
HeadDrift { expected: String, actual: String },
/// `main` (`actual_head`) is not a descendant of `expected_ancestor`, so
/// pushing the verified sha would not be a fast-forward — `main` raced.
NotFastForward {
expected_ancestor: String,
actual_head: String,
},
/// Transport / API / unexpected failure — hard-fail, no auto-retry.
/// Transport / API / unexpected failure — hard-fail, no auto-retry. Covers
/// a raced non-fast-forwardable `main` (the `fast-forward-only` merge is
/// refused by the forge) as well.
Other(anyhow::Error),
}
@ -50,13 +46,6 @@ impl std::fmt::Display for ForgeMergeError {
Self::HeadDrift { expected, actual } => {
write!(f, "PR head drifted: expected {expected}, found {actual}")
}
Self::NotFastForward {
expected_ancestor,
actual_head,
} => write!(
f,
"not a fast-forward: main {actual_head} is not a descendant of {expected_ancestor}"
),
Self::Other(e) => write!(f, "{e}"),
}
}
@ -77,8 +66,8 @@ fn repo_agent_name(repo: &str) -> &str {
/// Resolve a PR's head sha via `git ls-remote <repo> refs/pull/<pr>/head`
/// (Forgejo exposes PR heads there). Pure read, no mutation — the handler's
/// primary drift gate (compare against the approved sha), and `mark_pr_merged`
/// uses it to detect head-drift on failure.
/// primary drift gate (compare against the approved sha), and
/// `merge_config_pr_ff` uses it to classify a merge-API rejection as head-drift.
///
/// # Errors
/// `Other` on transport failure or an empty/missing ref.
@ -145,9 +134,8 @@ pub fn config_repo(agent: &str) -> String {
/// Fetch PR #`pr`'s head into the agent's applied repo via
/// `refs/pull/<pr>/head` (which Forgejo always serves — a bare-sha fetch can
/// be refused by uploadpack policy). This makes the reviewed head an object
/// in the applied repo so the ancestor check in [`ff_push_to_main`], the
/// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all
/// resolve it locally before the irreversible push.
/// in the applied repo so the `git_update_ref(main, …)` in the deploy tail and
/// the eval-verify both resolve it locally before the irreversible merge.
///
/// # Errors
/// `Other` on transport failure or a non-zero git exit.
@ -173,103 +161,20 @@ pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), Forge
Ok(())
}
/// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow.
/// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it
/// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the
/// agent's applied repo where the orchestration has already fetched `sha`),
/// then does a **non-force** `git push <sha>:refs/heads/main`. The ancestor
/// pre-check and the non-force push are two independent guards: either catches
/// a raced `main` (→ `NotFastForward`) rather than clobbering reviewed history.
/// Fast-forward-merge PR `pr` on `repo` to `sha` via the Forgejo merge API —
/// THE merge in the config-PR flow. Uses `Do=fast-forward-only` so `main` only
/// ever advances by fast-forward (never a merge commit; a raced non-ff `main`
/// is refused by the forge → `Other`), and pins `head_commit_id = sha` so the
/// forge atomically refuses to merge a head that isn't the reviewed sha —
/// closing the check-then-merge race without ever pushing the protected branch.
/// `core` needs only the repo's merge whitelist, never push access to `main`.
/// The single call fast-forwards `main` to exactly `sha` and marks the PR
/// merged.
///
/// # Errors
/// `NotFastForward` if `main` raced ahead of `sha`; `Other` on transport/other.
pub async fn ff_push_to_main(repo: &str, sha: &str) -> Result<(), ForgeMergeError> {
let token = core_token()
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
let url = forge_git_url(&token, repo);
let applied = crate::paths::applied_dir(repo_agent_name(repo));
// Current `main` on the forge repo.
let ls = crate::lifecycle::git_command()
.args(["ls-remote", &url, "refs/heads/main"])
.output()
.await
.context("git ls-remote main")?;
if !ls.status.success() {
return Err(ForgeMergeError::Other(anyhow::anyhow!(
"git ls-remote {repo} refs/heads/main failed ({}): {}",
ls.status,
String::from_utf8_lossy(&ls.stderr).trim()
)));
}
let ls_out = String::from_utf8_lossy(&ls.stdout);
let main_sha = ls_out.split_whitespace().next().unwrap_or("").to_string();
// Strict-ancestor check: `main` must be an ancestor of `sha` for a true
// fast-forward. Skip when `main` is unborn (empty) — the push creates it.
if !main_sha.is_empty() {
let anc = crate::lifecycle::git_command()
.current_dir(&applied)
.args(["merge-base", "--is-ancestor", &main_sha, sha])
.output()
.await
.context("git merge-base --is-ancestor")?;
match anc.status.code() {
Some(0) => {} // ancestor → fast-forward safe
Some(1) => {
return Err(ForgeMergeError::NotFastForward {
expected_ancestor: main_sha,
actual_head: sha.to_string(),
});
}
_ => {
return Err(ForgeMergeError::Other(anyhow::anyhow!(
"git merge-base --is-ancestor errored ({}): {}",
anc.status,
String::from_utf8_lossy(&anc.stderr).trim()
)));
}
}
}
// Non-force push `sha` → `main`. Without `--force`, git rejects a
// non-fast-forward (a race between the check above and now), surfaced as
// `NotFastForward` rather than clobbering the remote.
let push = crate::lifecycle::git_command()
.current_dir(&applied)
.args(["push", &url, &format!("{sha}:refs/heads/main")])
.output()
.await
.context("git push sha:main")?;
if !push.status.success() {
let stderr = String::from_utf8_lossy(&push.stderr);
if stderr.contains("non-fast-forward") || stderr.contains("fetch first") {
return Err(ForgeMergeError::NotFastForward {
expected_ancestor: main_sha,
actual_head: sha.to_string(),
});
}
return Err(ForgeMergeError::Other(anyhow::anyhow!(
"git push {repo} {sha}:main failed ({}): {}",
push.status,
stderr.trim()
)));
}
Ok(())
}
/// Mark PR `pr` as **manually merged** at `sha` (Forgejo
/// `repo_merge_pull_request` with `Do=manually-merged`, `MergeCommitID=sha`).
/// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires
/// the branch already be at the merge commit). On an API rejection, re-reads
/// the PR head to distinguish drift (`HeadDrift`) from a generic failure
/// (`Other`) — best-effort, since the handler's pre-merge head re-read is
/// the real gate. A transport failure skips the drift re-read (it couldn't
/// reach the forge either) and surfaces as `Other` directly.
///
/// # Errors
/// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise.
pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
/// `HeadDrift` if the live PR head no longer matches `sha` (the pin rejected
/// it); `Other` for a raced non-ff `main`, transport, or API failure.
pub async fn merge_config_pr_ff(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
let token = core_token()
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
let (owner, name) = repo.split_once('/').ok_or_else(|| {
@ -278,13 +183,13 @@ pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeM
let index = i64::try_from(pr)
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
let body = MergePullRequestOption {
r#do: MergePullRequestOptionDo::ManuallyMerged,
merge_commit_id: Some(sha.to_owned()),
r#do: MergePullRequestOptionDo::FastForwardOnly,
merge_commit_id: None,
merge_message_field: None,
merge_title_field: None,
delete_branch_after_merge: None,
force_merge: None,
head_commit_id: None,
head_commit_id: Some(sha.to_owned()),
merge_when_checks_succeed: None,
};
let client = api(&token).map_err(ForgeMergeError::Other)?;
@ -293,21 +198,21 @@ pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeM
.await
{
Ok(()) => Ok(()),
Err(ForgejoError::ReqwestError(e)) => Err(ForgeMergeError::Other(
anyhow::Error::from(e).context("POST pulls/<pr>/merge (manually-merged)"),
)),
Err(e) => {
// Best-effort drift detection: if the live head no longer matches
// `sha`, that's a head-drift race; otherwise surface as a hard
// failure.
// Classify: a live head that no longer matches `sha` is drift (the
// `head_commit_id` pin rejected it); everything else — a raced
// non-ff `main`, transport, an API error — is a hard failure.
// Best-effort, since the handler's pre-merge re-read is the primary
// gate; a transport failure that also breaks this read falls through
// to `Other`.
match pr_head_sha(repo, pr).await {
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
expected: sha.to_string(),
actual,
}),
_ => Err(ForgeMergeError::Other(anyhow::Error::from(e).context(
format!("mark PR #{pr} in {repo} manually-merged at {sha}"),
))),
_ => Err(ForgeMergeError::Other(
anyhow::Error::from(e).context(format!("ff-merge PR #{pr} in {repo} to {sha}")),
)),
}
}
}