Two sites, same class, different blast radius: `forge_http_base()` fell back to `http://localhost:3000` when `HIVE_FORGE_URL` was unset. The NixOS module sets that variable unconditionally, so the fallback could only ever fire for a process started outside the module — where guessing produces a confusing "connection refused" far from its cause. It now panics saying exactly that. `forge_git_url()` had a second, nastier fallback: a base URL with no `://` produced `http://core:<token>@localhost:3000/...`, sending a *credentialed* git push at whatever happened to answer on the local port. Split the credential-insertion half out as `git_url_with_base`, which panics on a malformed base. That split also lets the tests cover the shape without setting a process-wide env var, which would race every other test in the binary. Adds a case pinning that the scheme is carried through rather than assumed — the old hardcoded `http://` would have silently downgraded a TLS-fronted forge. Refs #2860
286 lines
12 KiB
Rust
286 lines
12 KiB
Rust
//! PR-based config-flow merge primitives — the forge-side mechanics
|
|
//! hive-c0re's deploy apply node (`actions::run_deploy_apply`) orchestrates to
|
|
//! land an operator-approved config PR. Part of the operator trust
|
|
//! boundary; moved verbatim from the `forge` module root.
|
|
|
|
use anyhow::Context;
|
|
use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo, StateType};
|
|
|
|
use super::{CONFIG_ORG, api, core_token, forge_git_url};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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: 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 deploy apply node (`run_deploy_apply`)
|
|
// orchestrates; the orchestration fetches the verified sha into the agent's
|
|
// 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 (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`) 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 },
|
|
/// 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),
|
|
}
|
|
|
|
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::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)
|
|
}
|
|
|
|
/// 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
|
|
/// `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.
|
|
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 = forge_git_url(&token, repo);
|
|
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())
|
|
}
|
|
|
|
/// Check whether PR `pr` on `repo` is still open. Returns `Ok(true)` if
|
|
/// open, `Ok(false)` if closed or merged, or an error on transport failure.
|
|
///
|
|
/// Called at submission time to give an early, actionable error rather than
|
|
/// queuing an approval card that will fail later in the approve handler.
|
|
///
|
|
/// # Errors
|
|
/// `Other` on transport failure or a missing/malformed PR response.
|
|
pub async fn pr_is_open(repo: &str, pr: u64) -> Result<bool, 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(|| {
|
|
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
})?;
|
|
let index = i64::try_from(pr)
|
|
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
let pull = client
|
|
.repo_get_pull_request(owner, name, index)
|
|
.await
|
|
.map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request")))?;
|
|
Ok(pull.state == Some(StateType::Open))
|
|
}
|
|
|
|
/// Full `owner/name` path of an agent's config repo on the forge — the
|
|
/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards.
|
|
pub fn config_repo(agent: &str) -> String {
|
|
format!("{CONFIG_ORG}/{agent}")
|
|
}
|
|
|
|
/// 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 `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.
|
|
pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> 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));
|
|
let refspec = format!("refs/pull/{pr}/head");
|
|
let out = crate::lifecycle::git_command()
|
|
.current_dir(&applied)
|
|
.args(["fetch", "--no-tags", &url, &refspec])
|
|
.output()
|
|
.await
|
|
.context("git fetch PR head into applied")?;
|
|
if !out.status.success() {
|
|
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
|
"git fetch {repo} {refspec} into applied failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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
|
|
/// `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(|| {
|
|
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
})?;
|
|
let index = i64::try_from(pr)
|
|
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
let body = MergePullRequestOption {
|
|
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: Some(sha.to_owned()),
|
|
merge_when_checks_succeed: None,
|
|
};
|
|
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
match client
|
|
.repo_merge_pull_request(owner, name, index, body)
|
|
.await
|
|
{
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
// 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!("ff-merge PR #{pr} in {repo} to {sha}")),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Post a comment to PR (= issue) `pr` on `repo` as the core forge user.
|
|
/// PRs are issues in Forgejo, so the PR number is the issue index. Used to
|
|
/// surface a failed config-approval deploy's build log back onto the PR so
|
|
/// the manager sees why it was rejected without leaving the forge. `repo`
|
|
/// is `owner/name`.
|
|
///
|
|
/// # Errors
|
|
/// `Other` on absent core token, malformed repo, or transport/API failure.
|
|
pub async fn post_pr_comment(repo: &str, pr: u64, body: &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(|| {
|
|
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
|
})?;
|
|
let index = i64::try_from(pr)
|
|
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
client
|
|
.issue_create_comment(
|
|
owner,
|
|
name,
|
|
index,
|
|
forgejo_api::structs::CreateIssueCommentOption {
|
|
body: body.to_owned(),
|
|
updated_at: None,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("post PR comment")))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::repo_agent_name;
|
|
use crate::forge::git_url_with_base;
|
|
|
|
#[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 forge_git_url_shape() {
|
|
// Tests the pure half: credentials go between scheme and
|
|
// authority. Deliberately not via `forge_git_url`, which reads
|
|
// HIVE_FORGE_URL — setting that here would race every other
|
|
// test in this binary, and there is no fallback to lean on any
|
|
// more (a guessed base is the bug this issue removes).
|
|
let url = git_url_with_base("http://forge.example.test", "tok", "a/iris");
|
|
assert_eq!(url, "http://core:tok@forge.example.test/a/iris.git");
|
|
}
|
|
|
|
#[test]
|
|
fn forge_git_url_preserves_https() {
|
|
// The scheme is carried through rather than assumed: a swarm
|
|
// whose forge is behind TLS must not be downgraded to http.
|
|
let url = git_url_with_base("https://forge.example.test", "tok", "a/iris");
|
|
assert!(
|
|
url.starts_with("https://core:tok@"),
|
|
"https must survive: {url}"
|
|
);
|
|
}
|
|
}
|