From 376de8f161a3d42945d6ad9a988293323d9800f6 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 22 Jun 2026 20:48:28 +0200 Subject: [PATCH] feat(#1838): forge.rs merge primitives for the PR-based config flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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//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, 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. --- hive-c0re/src/forge.rs | 244 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index ef519352..cee081e7 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -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/`). +// --------------------------------------------------------------------------- + +/// 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 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 refs/pull//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 { + 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 :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//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" + ); + } }