//! 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) -> 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 = 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/). 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, open_prs: &std::collections::HashSet<(String, u64)>, scanned_agents: &std::collections::HashSet, ) { 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::() 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" ); } } } }