Watch
0
0
Fork
You've already forked hyperhive
0

config PRs: remove the hive's config-PR webhook, poll and core merge

An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:

- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
  boot node and the org-hook registration; the hive vhost's `/webhook/`
  location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
  drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
  DeployTail), with verify_commit, the two-phase meta deploy, rollback
  refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
  `sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
  them

`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.

Closes #4850
This commit is contained in:
atlas 2026-10-02 22:32:17 +02:00
commit efbfec6d01
39 changed files with 286 additions and 3199 deletions

View file

@ -128,8 +128,7 @@ fn runner_address_matches(raw: &str, configured: &str) -> bool {
/// Bound on reaching the forge.
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Bound on one whole forge admin call, body included; the same budget
/// `config_pr_poll` gives this forge.
/// Bound on one whole forge admin call, body included.
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// A client with both forge bounds applied.

View file

@ -1,189 +0,0 @@
//! Polling fallback for the config-PR webhook.
//!
//! The webhook (`/webhook/config-pr`) is the primary path for detecting open
//! PRs on `agent-configs/*` repos and queuing `MergeConfigPr` approvals.
//! But webhooks can be missed — hive-c0re might be down when a PR is opened,
//! or Forgejo might fail a delivery.
//!
//! This module provides [`poll_open_config_prs`], called periodically from
//! `main.rs`, which scans all `agent-configs/*` repos for open PRs that have
//! no pending `MergeConfigPr` approval yet, and queues one. Idempotent: PRs
//! that already have a pending approval are skipped.
use std::sync::Arc;
use anyhow::Result;
use forgejo_api::structs::{RepoListPullRequestsQuery, RepoListPullRequestsQueryState};
use crate::coordinator::Coordinator;
use crate::forge::CONFIG_ORG;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Scan every repo in `agent-configs` for open PRs that have no pending
/// `MergeConfigPr` approval yet, and queue one for each gap found.
///
/// Designed to be called on a periodic timer (e.g. every 5 minutes) as a
/// fault-tolerance backstop for the Forgejo webhook. The webhook fires
/// immediately; this catches anything the webhook missed.
pub async fn poll_open_config_prs(core_token: &str, coord: &Arc<Coordinator>) -> Result<()> {
let client = crate::forge::api(core_token)?;
// List all repos in agent-configs org.
let repos = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_repos(CONFIG_ORG).all())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))?;
// (agent, pr_number) of every OPEN config PR seen this sweep, plus the set
// of agents whose PR list was fetched successfully. The reconcile pass
// below uses these to cancel pending approvals whose PR is no longer open,
// without wrongly cancelling one when a repo's list failed transiently.
let mut open_prs: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
let mut scanned_agents: std::collections::HashSet<String> = std::collections::HashSet::new();
for repo in repos {
let Some(repo_name) = repo.name.as_deref() else {
continue;
};
// The repo name is the agent name (agent-configs/<agent>).
let agent = repo_name;
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
sort: None,
milestone: None,
labels: None,
poster: None,
base: None,
head: None,
};
let prs = match tokio::time::timeout(
HTTP_TIMEOUT,
client
.repo_list_pull_requests(CONFIG_ORG, repo_name, query)
.all(),
)
.await
{
Ok(Ok(prs)) => {
scanned_agents.insert(agent.to_owned());
prs
}
Ok(Err(e)) => {
tracing::debug!(
%agent, error = %e,
"config-pr poll: listing PRs failed, skipping repo"
);
continue;
}
Err(_) => {
tracing::debug!(
%agent,
"config-pr poll: timeout listing PRs, skipping repo"
);
continue;
}
};
for pr in prs {
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
continue;
};
open_prs.insert((agent.to_owned(), pr_number));
// `submit_merge_config_pr` is idempotent + PR-drift aware: it
// no-ops when an approval pinned to this PR's *current* head is
// already pending, and cancels+re-queues when the head has drifted.
// So the poll can call it unconditionally — it backstops both a
// missed `opened` webhook (no approval yet) and a missed
// `synchronize` (stale approval whose head moved).
let description = format!("PR #{pr_number} on {CONFIG_ORG}/{agent} (poll fallback)");
if let Err(e) = crate::socket_server::submit_merge_config_pr(
coord,
agent,
pr_number,
Some(&description),
"poll", // submitter — identifies the polling path in the audit trail
)
.await
{
tracing::warn!(
%agent, %pr_number, error = ?e,
"config-pr poll: failed to reconcile MergeConfigPr approval"
);
}
}
}
// Reconcile: cancel pending approvals whose PR is no longer open.
reconcile_stale_config_pr_approvals(coord, &open_prs, &scanned_agents);
Ok(())
}
/// Cancel pending `MergeConfigPr` approvals whose PR is no longer open — merged
/// (incl. outside the approval flow) or closed. Without this the card lingers on
/// the dashboard forever, since the webhook only signals opened PRs and the
/// add-loop only ever queues.
///
/// `open_prs` is the set of `(agent, pr_number)` seen open this sweep and
/// `scanned_agents` the agents whose PR list was fetched successfully — the
/// reconcile is guarded to those so a transient list failure can't cancel a
/// still-valid approval.
fn reconcile_stale_config_pr_approvals(
coord: &Arc<Coordinator>,
open_prs: &std::collections::HashSet<(String, u64)>,
scanned_agents: &std::collections::HashSet<String>,
) {
let pending = match coord.approvals.pending() {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = ?e, "config-pr poll: pending() failed, skipping reconcile");
return;
}
};
for a in pending {
if a.kind != hive_sh4re::approvals::ApprovalKind::MergeConfigPr
|| !scanned_agents.contains(a.agent.as_str())
{
continue;
}
let Ok(pr_number) = a.commit_ref.parse::<u64>() else {
continue;
};
if open_prs.contains(&(a.agent.to_string(), pr_number)) {
continue;
}
match coord
.approvals
.mark_cancelled(a.id, "config-pr poll (PR no longer open)")
{
Ok(_) => {
tracing::info!(
agent = %a.agent, pr_number, id = a.id,
"config-pr poll: cancelled stale MergeConfigPr approval (PR merged/closed)"
);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id,
agent: a.agent.as_str(),
approval_kind: "merge_config_pr",
sha_short: a
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned()),
status: "cancelled",
note: Some("PR merged/closed outside the approval".to_owned()),
description: a.description.clone(),
});
}
Err(e) => {
tracing::warn!(
agent = %a.agent, id = a.id, error = ?e,
"config-pr poll: failed to cancel stale MergeConfigPr approval"
);
}
}
}
}

View file

@ -5,16 +5,12 @@
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
mod ci_runner;
pub mod config_pr_poll;
mod pr_merge;
mod pr_comment;
mod reconcile;
mod repos;
mod users;
pub use pr_merge::{
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff,
merged_pr_for_commit, post_pr_comment, pr_head_sha, pr_is_open,
};
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{
clone_config_into_proposed, ensure_config_repo, ensure_meta_remote, ensure_repo,
@ -92,15 +88,14 @@ pub(crate) fn core_auth_header(token: &str) -> String {
}
/// Forgejo org grouping every agent's config repo. Core is a site admin
/// and reads + writes every repo here. As of the agent-config-PR flow each
/// agent is a **write collaborator on its own** `agent-configs/<name>` repo —
/// the editable PR surface it pushes config-change branches to — but `main` is
/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
/// handler lands on it (operator approval required; the agent can't push
/// `main` or self-merge). The repos remain private, so an agent still can't
/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re
/// never force-pushes; the `push_config` mirror runs best-effort until the
/// PR-merge flow retires it.
/// and reads + writes every repo here. Each agent is a **write collaborator
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
/// pushes config-change branches to. `main`'s branch protection is
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
/// so the agent can't push `main` or self-merge, and an operator's merge
/// deploys through swarm-controller. The repos remain private, so an agent
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
/// the `push_config` mirror runs best-effort.
pub(crate) const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a
@ -403,183 +398,49 @@ pub async fn ensure_all() {
}
}
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` targets
/// hive-c0re's `/webhook/config-pr` and signs with `webhook_secret`. A hook
/// already at that URL is kept if [`crate::webhook_secret::is_registered`],
/// else deleted and recreated (Forgejo's edit-hook API ignores `secret`).
///
/// `hive_domain` is the public domain name of the hive; the webhook URL is
/// `https://<hive_domain>/webhook/config-pr` (routed through the gateway,
/// avoiding the Forgejo SSRF guard that blocks loopback delivery).
///
/// `webhook_secret` is the HMAC secret Forgejo will attach as
/// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header
/// in the dashboard webhook handler (`post_webhook_config_pr`).
///
/// An org-level hook covers every repo in `agent-configs` automatically,
/// so no per-repo setup is needed as new agents are provisioned.
///
/// Called at startup. The knowledge repo's one hook is the controller's
/// now; this one has not moved yet. No-op when the core token is absent
/// (forge not yet provisioned).
///
/// # Errors
///
/// Returns an error if:
/// - `hive_domain` produces a URL that `url::Url::parse` rejects.
/// - Deleting the hook being replaced, or `org_create_hook`, fails (transport
/// error, auth failure, or the `agent-configs` org does not exist).
/// - The HTTP call times out (10 s limit).
///
/// Listing failures are treated as best-effort: they fall through to the
/// create attempt rather than surfacing an error.
pub async fn ensure_config_pr_webhook(
core_token: &str,
hive_domain: &str,
webhook_secret: &str,
) -> Result<()> {
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
use std::collections::BTreeMap;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let target_url = format!("https://{hive_domain}/webhook/config-pr");
let client = api(core_token)?;
// List existing org hooks — skip creation if ours is already there.
// Best-effort: a listing failure falls through to the create attempt.
let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from));
let listed_ok = listed.is_ok();
match listed {
Ok(hooks) => {
let secret_registered = crate::webhook_secret::is_registered(webhook_secret);
if config_pr_hook_is_current(&hooks, &target_url, secret_registered) {
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
return Ok(());
}
for h in &hooks {
let hook_url = hook_url(h);
let Some(id) = h.id else { continue };
if hook_url == target_url {
// A failed delete must not fall through to create: the
// old hook would stay beside the new one, and a later
// pass sees the URL present and never removes it.
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: replacing config-pr webhook (secret not the registered one)"
);
tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| {
format!("delete config-pr webhook {id} to replace its secret")
})?;
} else if hook_url.ends_with("/webhook/config-pr") {
// Our path on a different base (e.g. old loopback hooks
// from before the SSRF-bypass migration).
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: deleting stale config-pr webhook (wrong base)"
);
let _ = tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await;
}
}
}
Err(e) => {
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
}
}
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), webhook_secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
branch_filter: None,
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: Url::parse(&target_url).context("parse config-pr webhook target url")?,
additional,
},
events: Some(vec!["pull_request".to_owned()]),
r#type: CreateHookOptionType::Forgejo,
};
tokio::time::timeout(HTTP_TIMEOUT, client.org_create_hook(CONFIG_ORG, hook))
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?;
tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}");
// Only a listed pass knows no hook with an older secret survived beside
// this one; an unlisted pass leaves the record stale so the next
// registration lists and replaces again.
if listed_ok && let Err(e) = crate::webhook_secret::record_registered(webhook_secret) {
tracing::warn!(error = ?e, "forge: recording the config-pr webhook secret failed");
}
Ok(())
}
/// The `url` a Forgejo hook delivers to, or `""` when it carries none.
fn hook_url(hook: &forgejo_api::structs::Hook) -> &str {
hook.config
.as_ref()
.and_then(|c| c.get("url"))
.map_or("", String::as_str)
}
/// Whether the config-PR hook needs no work: one targets `target_url`, and
/// the current secret is the one it was registered with.
fn config_pr_hook_is_current(
hooks: &[forgejo_api::structs::Hook],
target_url: &str,
secret_registered: bool,
) -> bool {
secret_registered && hooks.iter().any(|h| hook_url(h) == target_url)
}
#[cfg(test)]
mod tests {
use super::{config_pr_hook_is_current, describe_forge_admin};
use super::{describe_forge_admin, git_url_with_base};
const TARGET: &str = "https://hive.example/webhook/config-pr";
fn hook(url: &str) -> forgejo_api::structs::Hook {
serde_json::from_value(serde_json::json!({ "id": 1, "config": { "url": url } }))
.expect("hook json")
/// The remote carries **no credential**. `argv` is world-readable through
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
/// every local user for the life of the git child.
///
/// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` —
/// setting that here would race every other test in this binary.
#[test]
fn forge_git_url_carries_no_credential() {
let url = git_url_with_base("http://forge.example.test", "a/iris");
assert_eq!(url, "http://forge.example.test/a/iris.git");
assert!(!url.contains('@'), "no userinfo: {url}");
}
#[test]
fn a_hook_at_the_target_with_the_registered_secret_is_kept() {
assert!(config_pr_hook_is_current(&[hook(TARGET)], TARGET, true));
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", "a/iris");
assert!(url.starts_with("https://"), "https must survive: {url}");
}
/// Forgejo can't be asked which secret a hook signs with, so a hook at
/// the right URL is not enough: without the matching record it is
/// replaced, or every delivery fails HMAC.
/// The credential goes in an `Authorization` header instead — decodable
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous.
#[test]
fn a_hook_at_the_target_with_a_changed_secret_is_replaced() {
assert!(!config_pr_hook_is_current(&[hook(TARGET)], TARGET, false));
}
#[test]
fn a_hook_on_another_base_is_not_ours() {
let stale = hook("http://127.0.0.1:7000/webhook/config-pr");
assert!(!config_pr_hook_is_current(&[stale], TARGET, true));
assert!(!config_pr_hook_is_current(&[], TARGET, true));
fn core_auth_header_is_basic_core_token() {
use base64::Engine as _;
let header = super::core_auth_header("s3cret");
let b64 = header
.strip_prefix("Authorization: Basic ")
.expect("basic auth header");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64");
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
assert!(
!header.contains("s3cret"),
"token not in the clear: {header}"
);
}
#[test]

View file

@ -0,0 +1,56 @@
//! Commenting on an agent's config PRs as the core forge user — how a hive
//! reports a merged config commit it refused to deploy back onto the PR that
//! merged it.
use anyhow::{Context, Result};
use super::{CONFIG_ORG, api, core_token};
/// Full `owner/name` path of an agent's config repo on the forge.
pub fn config_repo(agent: &str) -> String {
format!("{CONFIG_ORG}/{agent}")
}
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
///
/// # Errors
/// Absent core token, malformed repo, no such PR, or transport/API failure.
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64> {
let token = core_token().context("forge core token absent")?;
let (owner, name) = repo
.split_once('/')
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
let pull = api(&token)?
.repo_get_commit_pull_request(owner, name, sha)
.await
.context("GET pull request of commit")?;
pull.number
.and_then(|n| u64::try_from(n).ok())
.with_context(|| format!("pull request of {sha} in {repo} carries no number"))
}
/// 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.
///
/// # Errors
/// Absent core token, malformed repo, or transport/API failure.
pub async fn post_pr_comment(repo: &str, pr: u64, body: &str) -> Result<()> {
let token = core_token().context("forge core token absent")?;
let (owner, name) = repo
.split_once('/')
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
let index = i64::try_from(pr).with_context(|| format!("PR index {pr} overflows i64"))?;
api(&token)?
.issue_create_comment(
owner,
name,
index,
forgejo_api::structs::CreateIssueCommentOption {
body: body.to_owned(),
updated_at: None,
},
)
.await
.context("post PR comment")?;
Ok(())
}

View file

@ -1,332 +0,0 @@
//! 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_auth_header, 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(repo);
let refspec = format!("refs/pull/{pr}/head");
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
.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(repo);
let applied = crate::paths::applied_dir(repo_agent_name(repo));
let refspec = format!("refs/pull/{pr}/head");
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
.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}")),
)),
}
}
}
}
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
///
/// # Errors
/// `Other` on absent core token, malformed repo, no such PR, or
/// transport/API failure.
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64, 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 client = api(&token).map_err(ForgeMergeError::Other)?;
let pull = client
.repo_get_commit_pull_request(owner, name, sha)
.await
.map_err(|e| {
ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request of commit"))
})?;
pull.number
.and_then(|n| u64::try_from(n).ok())
.ok_or_else(|| {
ForgeMergeError::Other(anyhow::anyhow!(
"pull request of {sha} in {repo} carries no number"
))
})
}
/// 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");
}
/// The remote carries **no credential**. `argv` is world-readable through
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
/// every local user for the life of the git child.
///
/// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` —
/// setting that here would race every other test in this binary.
#[test]
fn forge_git_url_carries_no_credential() {
let url = git_url_with_base("http://forge.example.test", "a/iris");
assert_eq!(url, "http://forge.example.test/a/iris.git");
assert!(!url.contains('@'), "no userinfo: {url}");
}
#[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", "a/iris");
assert!(url.starts_with("https://"), "https must survive: {url}");
}
/// The credential goes in an `Authorization` header instead — decodable
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous.
#[test]
fn core_auth_header_is_basic_core_token() {
use base64::Engine as _;
let header = crate::forge::core_auth_header("s3cret");
let b64 = header
.strip_prefix("Authorization: Basic ")
.expect("basic auth header");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64");
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
assert!(
!header.contains("s3cret"),
"token not in the clear: {header}"
);
}
}