//! 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))?; 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)) => 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; }; // Skip if a pending approval already exists for this PR. match coord .approvals .has_pending_merge_config_pr(agent, pr_number) { Ok(true) => { tracing::debug!( %agent, %pr_number, "config-pr poll: approval already pending, skipping" ); continue; } Ok(false) => {} Err(e) => { tracing::warn!( %agent, %pr_number, error = ?e, "config-pr poll: DB check failed, skipping" ); continue; } } tracing::info!( %agent, %pr_number, "config-pr poll: queuing missed MergeConfigPr approval" ); 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 queue MergeConfigPr approval" ); } } } Ok(()) }