diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 8a3c12b2..ef519352 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -1115,237 +1115,9 @@ pub async fn ensure_all() { } } -// --------------------------------------------------------------------------- -// PR-based config-flow merge primitives (part of the -// 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 -// 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, repo_agent_name, tokenised_repo_url}; + use super::{CoreTokenCheck, classify_core_token_status}; use reqwest::StatusCode; #[test] @@ -1392,19 +1164,4 @@ 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" - ); - } }