189 lines
7.3 KiB
Rust
189 lines
7.3 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|