feat(#2377): forge-webhook-triggered config-PR merge flow

Replace the request_merge_config_pr MCP tool with a Forgejo
pull_request webhook on the agent-configs org. Agents now open a
config PR normally; hive-c0re auto-queues the MergeConfigPr approval
from the webhook event — no extra tool call needed.

Changes:
- dashboard/webhook.rs: add POST /webhook/config-pr handler
  - parses Forgejo pull_request payload (opened/synchronize)
  - strips agent-configs/<agent> prefix to extract agent name
  - calls submit_merge_config_pr → queues dashboard approval card
  - always 200 to prevent Forgejo retries; errors logged at warn
- dashboard/mod.rs: wire /webhook/config-pr route
- forge/mod.rs: add ensure_config_pr_webhook() — idempotent org-level
  hook registration on agent-configs at startup; CONFIG_ORG now
  pub(crate) for webhook handler
- main.rs: call ensure_config_pr_webhook alongside knowledge webhook
- socket_server/config_approvals.rs: drop handle_request_merge_config_pr;
  make submit_merge_config_pr pub(crate) for webhook handler
- socket_server/mod.rs: re-export submit_merge_config_pr; drop dispatch arm
- hive-sh4re/src/lib.rs: remove AgentRequest::RequestMergeConfigPr
  wire type; drop from ToolGroup::Approvals tool list
- hive-ag3nt/src/mcp/: drop request_merge_config_pr tool + args struct
- docs: update approvals.md (webhook trigger), conventions.md (tool
  group), agent-hierarchy.md, tools/lifecycle.md

Hardening from #2375-merge-config-pr-hardening branch preserved:
- pr_is_open check at queue time (rejects closed/merged PRs)
- atomic fetched_sha INSERT via submit_kind(fetched_sha: Some(&sha))

Approve-handler machinery unchanged (run_merge_config_pr,
ff_push_to_main, fetch_pr_head_into_applied, mark_pr_merged).
This commit is contained in:
atlas 2026-07-11 10:50:24 +02:00 committed by mara
commit d5a81f9195
14 changed files with 255 additions and 163 deletions

View file

@ -69,7 +69,7 @@ pub(crate) fn forge_git_url(token: &str, repo: &str) -> String {
/// 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.
const CONFIG_ORG: &str = "agent-configs";
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
@ -294,3 +294,70 @@ pub async fn ensure_all() {
sync_agent(name, core_token.as_deref()).await;
}
}
/// 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. `dashboard_port` is the TCP port hive-c0re's dashboard listens
/// on (default 7000); the webhook URL is
/// `http://127.0.0.1:<port>/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 alongside `knowledge::ensure_webhook`. No-op when the
/// core token is absent (forge not yet provisioned).
pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> 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!("http://127.0.0.1:{dashboard_port}/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).all())
.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(());
}
}
Err(e) => {
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
}
}
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: BTreeMap::new(),
},
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(())
}