feat(#1838): forge.rs merge primitives for the PR-based config flow

Three forge-side fns + a typed error the c0re approve-handler
(run_merge_config_pr, a follow-up) orchestrates to land an operator-approved
config PR:

- pr_head_sha(repo, pr): git ls-remote refs/pull/<pr>/head — the handler's
  primary drift gate (compare against the approved sha).
- ff_push_to_main(repo, sha): the merge — ls-remote main, strict-ancestor
  pre-check (git merge-base --is-ancestor in the agent's applied repo where
  the orchestration has fetched the sha), then a non-force push sha:main. Two
  independent guards so a raced main surfaces as NotFastForward rather than
  clobbering reviewed history.
- mark_pr_merged(repo, pr, sha): POST pulls/<pr>/merge Do=manually-merged
  MergeCommitID=sha; best-effort HeadDrift detection on non-2xx via a PR-head
  re-read (the handler's pre-merge re-read is the primary gate).

ForgeMergeError {HeadDrift, NotFastForward, Other} (hand-rolled Display/Error/
From<anyhow::Error>, no new dep) so the handler can match recoverable drift
(refresh + re-verify) vs hard-fail. Core token sourced internally.

Uncalled until the handler lands (pub lib API, no dead_code). clippy -D,
unit tests, and treefmt clean.
This commit is contained in:
atlas 2026-06-22 20:48:28 +02:00 committed by mara
commit 376de8f161

View file

@ -1115,9 +1115,236 @@ pub async fn ensure_all() {
}
}
// ---------------------------------------------------------------------------
// PR-based config-flow merge primitives (#1838).
//
// 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
// 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>`).
// ---------------------------------------------------------------------------
/// 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.
#[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.
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.
Other(anyhow::Error),
}
impl std::fmt::Display for ForgeMergeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
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}"),
}
}
}
impl std::error::Error for ForgeMergeError {}
impl From<anyhow::Error> for ForgeMergeError {
fn from(e: anyhow::Error) -> Self {
Self::Other(e)
}
}
/// Agent name from an `owner/name` forge repo string (the trailing segment).
fn repo_agent_name(repo: &str) -> &str {
repo.rsplit('/').next().unwrap_or(repo)
}
/// Token-in-URL clone/push URL for a forge repo (`owner/name`). Mirrors
/// `push_config`'s pattern; the token is passed straight to git and never
/// stored as a named remote.
fn tokenised_repo_url(repo: &str, token: &str) -> String {
format!("http://core:{token}@localhost:3000/{repo}.git")
}
/// 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.
///
/// # Errors
/// `Other` on transport failure or an empty/missing ref.
pub async fn pr_head_sha(repo: &str, pr: u64) -> Result<String, ForgeMergeError> {
let token = core_token()
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
let url = tokenised_repo_url(repo, &token);
let refspec = format!("refs/pull/{pr}/head");
let out = crate::lifecycle::git_command()
.args(["ls-remote", &url, &refspec])
.output()
.await
.context("git ls-remote PR head")?;
if !out.status.success() {
return Err(ForgeMergeError::Other(anyhow::anyhow!(
"git ls-remote {repo} {refspec} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let sha = stdout
.split_whitespace()
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ForgeMergeError::Other(anyhow::anyhow!(
"no head ref for PR #{pr} in {repo} (ls-remote empty)"
))
})?;
Ok(sha.to_string())
}
/// 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.
///
/// # 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 = tokenised_repo_url(repo, &token);
let applied = Coordinator::agent_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
/// `POST …/pulls/{pr}/merge` 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 a non-2xx, 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.
///
/// # 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> {
let token = core_token()
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge");
let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#);
let status = forge_http(reqwest::Method::POST, &url, &token, &body)
.await
.context("POST pulls/<pr>/merge (manually-merged)")?;
if status.is_success() {
return Ok(());
}
// Best-effort drift detection: if the live head no longer matches `sha`,
// that's a head-drift race; otherwise surface as a hard failure.
match pr_head_sha(repo, pr).await {
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
expected: sha.to_string(),
actual,
}),
_ => Err(ForgeMergeError::Other(anyhow::anyhow!(
"mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}"
))),
}
}
#[cfg(test)]
mod tests {
use super::{CoreTokenCheck, classify_core_token_status};
use super::{CoreTokenCheck, classify_core_token_status, repo_agent_name, tokenised_repo_url};
use reqwest::StatusCode;
#[test]
@ -1164,4 +1391,19 @@ mod tests {
);
}
}
#[test]
fn repo_agent_name_takes_trailing_segment() {
assert_eq!(repo_agent_name("agent-configs/atlas"), "atlas");
assert_eq!(repo_agent_name("atlas"), "atlas");
assert_eq!(repo_agent_name("a/b/c"), "c");
}
#[test]
fn tokenised_repo_url_shape() {
assert_eq!(
tokenised_repo_url("agent-configs/iris", "tok"),
"http://core:tok@localhost:3000/agent-configs/iris.git"
);
}
}