hyperhive/hive-c0re/src/forge/mod.rs
atlas d2747c7b77 refs: repoint seven comments that name files which have moved
Comments cite nix modules, scripts and crate source files constantly,
and nothing evaluates a comment — so when a file moves, the reference
rots silently and `nix flake check` stays green. A reader following one
finds nothing and cannot tell whether the file was renamed, deleted, or
never existed.

Seven such references, each repointed at the file that actually holds
the thing the sentence is about rather than at the directory the old
name became:

  hive-c0re/src/agent_config/limits.rs   hive-agent/src/mcp.rs
                                       -> hive-agent-mcp/src/mcp/mod.rs
  hive-agent-mcp/src/mcp/mod.rs          hive-c0re/src/limits.rs
                                       -> hive-c0re/src/agent_config/limits.rs
                                         (and the module path in the doc
                                          comment above it, which was stale
                                          in the same way)
  hive-c0re/src/forge/mod.rs             hive-c0re/src/knowledge.rs
                                       -> hive-c0re/src/workers/knowledge.rs
  nix/host-modules/hive-c0re/options.nix hive-c0re/src/hive_stats.rs
                                       -> hive-c0re/src/stats/hive_stats.rs
  nix/packages/default.nix               nix/host-modules/hive-c0re.nix
                                       -> .../hive-c0re/options.nix
  nix/agent-modules/network.nix          nix/host-modules/hive-gateway.nix
                                       -> .../hive-gateway/dnsmasq.nix
  frontend/README.md                     nix/modules/frontend.nix
                                       -> nix/packages/frontend.nix

The two `limits.rs` comments are a matched pair: each names the other's
old path, so the "keep in sync" instruction they exist to carry pointed
both ways at nothing.

Where a flat module became a directory the target is the file that
declares the named thing, not `default.nix` by reflex — the
`preBuildAgentTemplates` option is declared in `options.nix`, and the
DHCP pool that sentence is about lives in `dnsmasq.nix`.

Comments only; no behaviour change. Refs #3923, which is about whether a
gate should cover this class at all — that question is unanswered and
this does not close it.
2026-09-02 08:58:31 +02:00

638 lines
28 KiB
Rust

//! Optional Forgejo wiring — per-agent user + token provisioning,
//! config-repo mirroring, meta read-access grants. Also seeds
//! `internal/docs` — a private repo every agent gets read-only
//! collaborator access to for operator-curated shared content.
//! 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 reconcile;
mod repos;
mod users;
pub use pr_merge::{
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment,
pr_head_sha, pr_is_open,
};
pub use reconcile::{reconcile_config_apply, reconcile_config_status};
pub use repos::{
clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo,
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fetch_config_main_into_applied,
meta_read_access, push_config, push_meta, shared_docs_access,
};
pub use users::{core_token, ensure_user_for, provision_user_token};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use forgejo_api::{Auth, Forgejo};
use url::Url;
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
use users::{
ensure_config_org_avatar, ensure_core_avatar, ensure_core_user_and_token,
ensure_repo_creation_disabled, ensure_user_email,
};
const FORGE_CONTAINER: &str = "hive-forge";
/// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL`
/// (set unconditionally by the hyperhive NixOS module on the `hive-c0re`
/// unit, to `http://<forge.domain>`).
///
/// # Panics
///
/// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only
/// runs under the NixOS module, which always sets it, so an unset var
/// means the deployment is broken. There is no loopback fallback,
/// because a guess is wrong in exactly the cases that matter — the
/// forge may live on a different host from the daemon, and a fallback
/// turns "misconfigured" into "silently talking to the wrong machine"
/// or, worse, "connection refused" surfacing far from its cause.
pub(crate) fn forge_http_base() -> &'static str {
static BASE: OnceLock<String> = OnceLock::new();
BASE.get_or_init(|| {
std::env::var("HIVE_FORGE_URL").expect(
"HIVE_FORGE_URL is unset — the hyperhive NixOS module sets it \
unconditionally on the hive-c0re unit, so this process was \
started outside that unit",
)
})
}
/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**.
///
/// The token does not go here. A URL is a process argument, and `argv` is
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
/// lives, so a credentialed remote publishes the core admin token to every
/// local user on the host. Credentials travel in the environment instead, via
/// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] —
/// `/proc/<pid>/environ` is owner-only.
pub(crate) fn forge_git_url(repo: &str) -> String {
git_url_with_base(forge_http_base(), repo)
}
/// The pure half of [`forge_git_url`], split out so it can be tested without a
/// process-wide env var (which would race every other test in this binary).
fn git_url_with_base(base: &str, repo: &str) -> String {
format!("{base}/{repo}.git")
}
/// The `http.extraHeader` value authenticating as the forge core user.
///
/// Basic auth over a header rather than userinfo in the URL, so the secret
/// reaches git through the environment (see [`forge_git_url`]). Pair with
/// [`crate::lifecycle::git_command_authed`], which is the only thing that
/// should ever hold the result.
pub(crate) fn core_auth_header(token: &str) -> String {
use base64::Engine as _;
let basic = base64::engine::general_purpose::STANDARD.encode(format!("core:{token}"));
format!("Authorization: Basic {basic}")
}
/// 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.
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
/// common reference without the operator having to bake content into
/// the system prompt or rely on `/shared`. Only the manager + operator
/// (i.e. `core` user) can push.
const SHARED_ORG: &str = "internal";
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
/// at `{forge_http_base()}/internal/docs.git`.
const SHARED_DOCS_REPO: &str = "docs";
/// The hive-wide knowledge repo inside `SHARED_ORG`. Public — agents
/// can fork it and open PRs without explicit collaborator grants.
/// Bind-mounted read-only into every container at `/knowledge`.
/// See `hive-c0re/src/workers/knowledge.rs`.
const KNOWLEDGE_REPO: &str = crate::knowledge::REPO;
/// Forgejo org that owns agent-created repos. Agents can't create
/// repos with their own token (`max_repo_creation = 0`); instead hive-c0re
/// creates them here and adds the requesting agent as a **write** member
/// (not owner/admin). Because the org — not the agent — owns the repo,
/// perms stay c0re-managed and branch protection (referencing
/// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This
/// is the "agents namespace" repos land in by default.
const AGENTS_ORG: &str = "agents";
/// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by
/// hive-c0re (so perms can be set before anyone joins); the operator adds
/// herself via the forge UI / hivectl. Branch protection on agents-org repos
/// references this team by name for the merge/approval whitelist, so the
/// rule never hardcodes a specific reviewer agent (which may not exist).
const OPERATORS_TEAM: &str = "operators";
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
/// `core/meta` (the `core` user's own namespace — no org needed).
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG, AGENTS_ORG];
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
/// set of per-org boot warnings in [`ensure_all`] (one per seeded org, at
/// most a handful per process). [`crate::warnings::set_boot_warning`]
/// requires a `'static` kind so distinct orgs/repos don't clobber each
/// other's banner entry; leaking a few short strings once per boot is
/// cheap and bounded, unlike a per-request or per-loop-iteration leak.
fn static_kind(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc. Routed
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
/// unprivileged (privsep).
pub async fn is_present() -> bool {
let Ok(stdout) = crate::priv_client::list_containers().await else {
return false;
};
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
}
/// Name a `forgejo admin` invocation for an error message without
/// reproducing its arguments: keep the leading verb path, stop at the
/// first flag. `["user", "create", "--username", "iris", "--password",
/// "…"]` becomes `forgejo admin user create`.
///
/// The verbs are an **allowlist**, and that is the whole point. Some
/// callers pass a live secret as an argument value (`--password`), so a
/// message built from the raw vector puts it in the log. Listing the
/// flags to *hide* instead would repeat the bug this guards against: a
/// newly added secret-bearing flag would leak until someone remembered
/// to extend the list. Verbs are a closed set this crate chooses
/// itself; argument values never are.
fn describe_forge_admin(args: &[&str]) -> String {
let verbs: Vec<&str> = args
.iter()
.take_while(|a| !a.starts_with('-'))
.copied()
.collect();
if verbs.is_empty() {
"forgejo admin".to_owned()
} else {
format!("forgejo admin {}", verbs.join(" "))
}
}
/// Run `forgejo admin <args>` inside the hive-forge container as the
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
// Route through hive-priv (root helper) because `nixos-container run`
// uses nsenter to enter the container's namespaces, which requires root.
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
// nsenter directly — doing so produces:
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
.await
.with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?;
Ok(stdout)
}
/// Typed Forgejo API client for the local forge ([`forge_http_base()`]),
/// authenticated as `token`. All Forgejo API calls that don't shell
/// out to `forgejo admin` go through clients built here — one place
/// for the base URL and auth. Tokens differ per call site (core admin
/// token vs per-agent tokens), so the token is passed per call; the
/// base URL is parsed once. Failures surface as
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
/// and the API's error message (e.g. the validation reason on a 422).
pub(crate) fn api(token: &str) -> Result<Forgejo> {
static URL: OnceLock<Url> = OnceLock::new();
let url = URL
.get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL"))
.clone();
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
}
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
/// a mirrored config repo, read access to `core/meta`, and the `meta`
/// remote in its proposed repo. All operations are idempotent; failures
/// are logged as warnings but don't abort the caller.
///
/// `core_token` is `core_token()` — passed in so callers that already
/// fetched it don't re-read the file. Pass `None` to skip the
/// `meta_read_access` step (safe: the access grant is best-effort).
///
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
/// (per-rebuild) so the two paths stay equivalent.
/// Returns `true` if all steps succeeded, `false` if any step failed. The
/// caller can use the return value to aggregate per-agent failures into a
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
let mut ok = true;
if let Err(e) = ensure_user_for(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
ok = false;
}
// Align email to match the git user.email set by meta::render_flake
// so commits link to the agent's Forgejo profile. Best-effort;
// also patches up agents created before this fix (old @hive.local).
ensure_user_email(name).await;
// Block direct agent-initiated repo creation: agents create
// repos through hive-c0re, never with their own token. Idempotent +
// marker-guarded; also covers agents provisioned before this landed.
ensure_repo_creation_disabled(name).await;
// Mirror the agent's applied config repo into agent-configs.
// ensure_config_repo is idempotent; push_config catches any
// drift since the last run — e.g. the startup migration just
// relocated `deployed/0`, or a deploy landed while the forge
// was down.
if let Err(e) = ensure_config_repo(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
ok = false;
}
if let Err(e) = push_config(name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed");
ok = false;
}
// Grant read-only access to core/meta and wire the `meta` remote
// into the proposed repo so agents can fetch their deployment context.
if let Some(token) = core_token
&& let Err(e) = meta_read_access(name, token).await
{
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
ok = false;
}
if let Err(e) = ensure_meta_remote(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
ok = false;
}
// Grant read-only access to internal/docs so the agent can clone
// the operator-curated shared skills/runbook repo. Best-effort.
if let Some(token) = core_token
&& let Err(e) = shared_docs_access(name, token).await
{
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
ok = false;
}
// internal/knowledge is public — no per-agent collaborator grant needed.
ok
}
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta
/// repo, shared/knowledge repos, avatars, and CI runner registration — every
/// step that needs an authenticated forge client. Split out purely to keep
/// `ensure_all` under clippy's function-length limit; not meant to be called
/// from anywhere else.
async fn ensure_all_orgs_and_repos(token: &str) {
for org in SEEDED_ORGS {
if let Err(e) = ensure_org(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_org_{org}")),
"crit",
format!("forge: org {org} provisioning failed: {e}"),
);
}
}
// Seed the operator-declared pull-mirrors (nix `forge.mirrors` +
// the CI-auto `actions/checkout`, forwarded via the
// `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so
// this is independent of the SEEDED_ORGS loop above.
ensure_mirrors(token).await;
// Provision the operator merge-gate team (empty) inside BOTH the
// agents org and the agent-configs org so branch protection in each
// can reference it before anyone joins. Gitea teams are org-scoped —
// missing the agent-configs copy 422'd every config-repo protection
// apply, leaving those repos unprotected and letting operator-merged
// config PRs bypass the deploy pipeline. The operator adds herself as
// a member out-of-band.
for org in [AGENTS_ORG, CONFIG_ORG] {
if let Err(e) = ensure_operators_team(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_operators_team_{org}")),
"crit",
format!("forge: operators team in {org} provisioning failed: {e}"),
);
}
}
// Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists
// before the first push hits a 404.
if let Err(e) = ensure_repo("meta", token).await {
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
crate::warnings::set_boot_warning(
"forge_ensure_meta_repo",
"crit",
format!("forge: core/meta repo provisioning failed: {e}"),
);
}
// Seed the shared docs repo. internal is already in
// SEEDED_ORGS above so the org exists; ensure the repo itself.
if let Err(e) = ensure_shared_docs_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_shared_docs_repo",
"warn",
format!("forge: shared docs repo provisioning failed: {e}"),
);
}
// Seed the hive-wide knowledge repo.
if let Err(e) = ensure_knowledge_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_knowledge_repo",
"crit",
format!("forge: knowledge repo provisioning failed: {e}"),
);
}
// Clone knowledge repo locally so it can be bind-mounted into agents.
if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
crate::warnings::set_boot_warning(
"forge_knowledge_local_clone",
"crit",
format!("knowledge: local clone failed: {e}"),
);
}
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_core_avatar",
"warn",
format!("forge: core avatar upload failed: {e}"),
);
}
if let Err(e) = ensure_config_org_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_config_org_avatar",
"warn",
format!("forge: agent-configs org avatar upload failed: {e}"),
);
}
// Register the hive-ci Actions runner (off the container's boot path;
// no-op when CI is disabled or the runner already holds valid creds).
ci_runner::ensure_ci_runner_registered(token).await;
}
/// Poll the local Forgejo API until it answers, with a timeout of 1 minute.
/// The whole boot provisioning below hits the API, and [`is_present`] only
/// confirms the container exists — not that Forgejo is *listening*. A
/// `nixos-rebuild` that restarts hive-forge and hive-c0re together races:
/// without this gate every `ensure_*` fires at a refused socket and leaves a
/// stale "provisioning failed" banner that never clears (the pass is one-shot).
/// Returns whether the endpoint became ready before the deadline; on timeout
/// the caller proceeds anyway so a genuinely-down forge still surfaces its real
/// errors. Uses the unauthenticated `get_version` endpoint (no token yet).
async fn wait_until_ready() -> bool {
const READY_TIMEOUT: Duration = Duration::from_mins(1);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
let Ok(url) = Url::parse(forge_http_base()) else {
tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait");
return false;
};
let Ok(client) = Forgejo::new(Auth::None, url) else {
tracing::warn!("forge: could not build readiness client; skipping readiness wait");
return false;
};
let deadline = Instant::now() + READY_TIMEOUT;
let mut waited = false;
loop {
if client.get_version().await.is_ok() {
if waited {
tracing::info!("forge: endpoint ready, proceeding with provisioning");
}
return true;
}
if Instant::now() >= deadline {
tracing::warn!(
"forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway"
);
return false;
}
waited = true;
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token, plus an `agent-configs/<name>`
/// repo mirroring its applied config. Also seeds the `core` admin
/// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API), the `agent-configs` org, and the `core/meta` repo.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
pub async fn ensure_all() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return;
}
// The container exists, but its HTTP may not be up yet (a rebuild restarts
// hive-forge + hive-c0re together). Wait for the API to answer before the
// provisioning pass so a boot race doesn't leave stale failure banners.
wait_until_ready().await;
let core_token = match ensure_core_user_and_token().await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
crate::warnings::set_boot_warning(
"forge_ensure_core_user",
"crit",
format!("forge: core user/token provisioning failed: {e}"),
);
None
}
};
if let Some(token) = core_token.as_deref() {
ensure_all_orgs_and_repos(token).await;
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
crate::warnings::set_boot_warning(
"forge_container_list",
"crit",
"forge: nixos-container list failed; per-agent forge sync skipped this boot",
);
return;
};
let mut sync_failed: Vec<String> = Vec::new();
for c in containers {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
if !sync_agent(name, core_token.as_deref()).await {
sync_failed.push(name.to_owned());
}
}
if !sync_failed.is_empty() {
// Use static_kind to mint a `&'static str` key from the failed-agent
// list (bounded leak: one per hive-c0re boot, not per request).
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
crate::warnings::set_boot_warning(
key,
"warn",
format!(
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
sync_failed.join(", ")
),
);
}
}
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` exists and
/// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists
/// existing hooks first and skips creation when one is already targeting the
/// correct URL.
///
/// `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 beside `knowledge::remove_webhook`, its opposite: that
/// 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.
/// - The Forgejo `org_create_hook` API call 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));
match listed {
Ok(hooks) => {
let already_exists = hooks.iter().any(|h| {
h.config
.as_ref()
.and_then(|c| c.get("url"))
.map(String::as_str)
== Some(target_url.as_str())
});
if already_exists {
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
return Ok(());
}
// Delete stale hooks that point at our path but a different base
// (e.g. old loopback hooks from before the SSRF-bypass migration).
for h in &hooks {
let hook_url = h
.config
.as_ref()
.and_then(|c| c.get("url"))
.map_or("", String::as_str);
if hook_url.ends_with("/webhook/config-pr")
&& hook_url != target_url
&& let Some(id) = h.id
{
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}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::describe_forge_admin;
#[test]
fn describe_keeps_the_verb_path_and_drops_every_value() {
assert_eq!(
describe_forge_admin(&[
"user",
"create",
"--username",
"iris",
"--email",
"iris@hyperhive.local",
"--random-password",
]),
"forgejo admin user create"
);
}
#[test]
fn describe_does_not_reproduce_a_password_argument() {
let described = describe_forge_admin(&[
"user",
"change-password",
"--username",
"iris",
"--password",
"correct-horse-battery-staple",
]);
assert_eq!(described, "forgejo admin user change-password");
assert!(!described.contains("correct-horse-battery-staple"));
}
#[test]
fn describe_survives_a_leading_flag_and_an_empty_vector() {
assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin");
assert_eq!(describe_forge_admin(&[]), "forgejo admin");
}
}