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:
parent
0a9bfe2b9b
commit
376de8f161
1 changed files with 243 additions and 1 deletions
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue