POST /api/review-classifier/try-out validates the run (owner/name repo,
a PR number, one to ten classifications with distinct names, thresholds
up to 100), reads the PR's head commit, commits and diff with the
controller's forge token, sends one request to the review classifier on
the queue and answers 202 with an id. GET .../try-out/{id} reports the
run as running, done (rows and overall verdict) or failed (the error).
Results live in memory for an hour after the reply; there is no run
store. POST then GET because a run can outlast the gateway's 60-second
proxy read timeout. Both routes are utoipa-annotated, so they show on
/api/docs/.
1973 lines
83 KiB
Rust
1973 lines
83 KiB
Rust
//! Swarm-controller's own forgejo client. Self-contained — deliberately
|
|
//! not sharing code with `hive-c0re::forge` across the crate boundary
|
|
//! (forcing that split now, over a handful of idempotent CRUD-ish
|
|
//! calls, is premature plumbing; factor out a shared crate later if the
|
|
//! duplication actually starts to hurt).
|
|
//!
|
|
//! The forge's URL comes from `swarm-controller.nix`'s `forgeEnv`
|
|
//! (`SWARM_CONTROLLER_FORGE_URL`). The token comes from the swarm's secret
|
|
//! store, at `swarm_secret_client::forge::controller_token_path`, read under
|
|
//! this daemon's own certificate and held only in memory. The forge mints it
|
|
//! inside its container (`hive-forge/default.nix`'s
|
|
//! `forgejo-swarm-controller-account`) and `swarm-bao-forge-publisher`, on the
|
|
//! forge's host, puts it there.
|
|
//!
|
|
//! [`enable`] fills the daemon's one client slot: at start when the token is
|
|
//! there, later from a background re-read when it is not. Every caller reads
|
|
//! the slot and treats an empty one as "no forge access here" — the same
|
|
//! graceful-absence shape the queue coordinates use — so the daemon serves
|
|
//! from the start and gains forge access without a restart.
|
|
|
|
use anyhow::{Context, Result};
|
|
use forgejo_api::structs::{
|
|
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
|
|
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
|
|
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
|
|
CreateUserOption, EditUserOption, IssueListIssuesQuery, IssueListIssuesQueryState,
|
|
IssueListIssuesQueryType, RepoDownloadPullDiffOrPatchQuery, RepoGetContentsQuery,
|
|
RepoGetPullRequestCommitsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
|
RepoSearchQuery, StateType,
|
|
};
|
|
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
|
use futures_util::{StreamExt as _, TryStreamExt as _};
|
|
use reqwest::StatusCode;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::sync::{Arc, OnceLock};
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::webhook::DeliveryKind;
|
|
use swarm_queue_client::review_classifier::Commit;
|
|
|
|
pub mod agent_token;
|
|
pub mod docs_grant;
|
|
pub mod hive_token;
|
|
pub mod legacy_tokens;
|
|
pub mod objects;
|
|
pub mod runner_token;
|
|
pub mod site_admin;
|
|
|
|
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
|
|
/// and `GET /api/agents/{name}/config-pr` serves it.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
|
pub struct ConfigPrStatus {
|
|
pub pr_number: u64,
|
|
/// Absent only if Forgejo itself omitted the field — every real PR has
|
|
/// one; not worth failing the whole scan over.
|
|
pub html_url: Option<String>,
|
|
}
|
|
|
|
/// A PR as [`Client::pull_for_review`] reads it for the review classifier.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PullForReview {
|
|
pub head_sha: String,
|
|
pub commits: Vec<Commit>,
|
|
pub diff: String,
|
|
}
|
|
|
|
/// One row of [`Client::issue_report`] — an open issue plus the two facts
|
|
/// derived from its dependency graph that swarm-ui's issue-report page
|
|
/// sorts/filters on. See that function's doc comment for how both are
|
|
/// computed.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
|
|
pub struct IssueReportRow {
|
|
/// `owner/name` — present on every row (not just the cross-repo
|
|
/// aggregate) so a caller can use the same row type for either
|
|
/// endpoint without a second shape to branch on.
|
|
pub repo: String,
|
|
pub number: i64,
|
|
pub title: String,
|
|
pub labels: Vec<String>,
|
|
/// All assignees, not just one — forgejo's `Issue::assignee` is a
|
|
/// legacy single-value field; `assignee`**s** (plural) is the real
|
|
/// multi-assignee list, and this repo's issues do use more than one.
|
|
pub assignees: Vec<String>,
|
|
pub html_url: Option<String>,
|
|
/// True if any of this issue's own dependencies is still open — same
|
|
/// semantics `hive-forge issue dependency list` already uses (a closed
|
|
/// blocker doesn't count).
|
|
pub blocked: bool,
|
|
/// How many OTHER issues in this same report list this issue as a
|
|
/// dependency — the reverse of `blocked`. Ranks "fix this one to
|
|
/// unblock the most other work" highest.
|
|
pub depended_on_by_count: u32,
|
|
/// How many distinct OTHER issues this issue unblocks, following the
|
|
/// same reverse-dependency edges transitively (a depends on b depends
|
|
/// on c: fixing c eventually frees both a and b, so c's count includes
|
|
/// both). A superset of `depended_on_by_count`, which only counts the
|
|
/// direct edge. See [`Client::issue_report`] for how it's walked.
|
|
pub transitively_blocks_count: u32,
|
|
}
|
|
|
|
/// The `operators` team, whitelisted for the merge gate on every repo
|
|
/// this client protects. Provisioned by this daemon's own periodic pass
|
|
/// ([`objects`]), and checked again by [`Client::create_repo`] before it
|
|
/// applies a rule naming the team.
|
|
const OPERATORS_TEAM: &str = "operators";
|
|
|
|
/// The org owning per-agent config repos — where this daemon creates them,
|
|
/// seeds them, and where the `pull_request` hook lives. Same value as
|
|
/// `hive-c0re::forge::CONFIG_ORG` — one forge, one org.
|
|
///
|
|
/// ⚠️ Duplicated across the crate boundary (this crate deliberately does not
|
|
/// depend on `hive-c0re`), so nothing makes the two fail together. The
|
|
/// failure mode if they drift is quiet: the hook is created on an org that
|
|
/// exists, forgejo reports it healthy, and it simply never fires.
|
|
///
|
|
/// ⚠️ **Not `agents`.** That org exists and is a different thing: it is the
|
|
/// namespace repos an *agent asks for* land in, per
|
|
/// `hive-c0re::forge::AGENTS_ORG`. Agent **config** repos have always lived
|
|
/// here, which is where `hive-c0re` reconciles, merges and mirrors them — a
|
|
/// config repo created in `agents` is invisible to every one of those paths,
|
|
/// and nothing errors, because both orgs exist and both accept a repo.
|
|
///
|
|
/// The merge gate survives the distinction: [`objects`] provisions the
|
|
/// `operators` team in **both** orgs precisely so branch protection can be
|
|
/// applied in either.
|
|
const CONFIG_ORG: &str = "agent-configs";
|
|
|
|
/// The hive-wide knowledge repo, where the `push` hook lives. Same values as
|
|
/// `hive-c0re::workers::knowledge::{ORG, REPO}`.
|
|
const KNOWLEDGE_ORG: &str = "internal";
|
|
const KNOWLEDGE_REPO: &str = "knowledge";
|
|
|
|
/// This daemon's own forge account — same constant as
|
|
/// `swarm-controller.nix`'s `swarmControllerForgeUser` (`hive-forge/default.nix`).
|
|
/// Needed in the branch-protection push whitelist (see
|
|
/// [`Client::apply_operator_branch_protection`]): being a site admin does
|
|
/// not exempt a caller from a protected branch's push check on the
|
|
/// content-edit API path (`repo_change_files`, what [`Client::seed_agent_config`]
|
|
/// uses) the way it does for a real `git push` — confirmed live against a
|
|
/// re-test where the account was already promoted to admin and the seed
|
|
/// commit still failed `ErrUserCannotCommit`. Whitelisting the account by
|
|
/// name is the same shape a git-push-based seed gets for free from the
|
|
/// admin bypass, without adding a `git` shell-out to this daemon.
|
|
const SWARM_CONTROLLER_FORGE_USER: &str = "swarm-controller";
|
|
|
|
/// The env var `swarm-controller.nix`'s `forgeEnv` sets. Named here once so
|
|
/// [`url_from_env`] and the nix module can't drift silently.
|
|
const URL_ENV: &str = "SWARM_CONTROLLER_FORGE_URL";
|
|
|
|
/// One login and one read. An empty value is refused like an absent one: it
|
|
/// would reach the forge as the token and come back as a 401 on the first
|
|
/// call, which names neither the store nor the publisher.
|
|
async fn fetch_token() -> Result<String> {
|
|
let path = swarm_secret_client::forge::controller_token_path()?;
|
|
let store = crate::store::connect()
|
|
.await
|
|
.context("logging in to the swarm secret store")?;
|
|
let stored: Option<swarm_secret_client::forge::ControllerToken> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
match stored {
|
|
Some(t) if !t.value.trim().is_empty() => Ok(t.value.trim().to_owned()),
|
|
_ => anyhow::bail!(
|
|
"nothing at {path}: swarm-bao-forge-publisher, on the host that runs the forge, has \
|
|
not published this daemon's forge token"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Typed forgejo client, built once at startup from env.
|
|
pub struct Client {
|
|
api: Forgejo,
|
|
/// The forge's base URL, kept so a client authenticated as someone
|
|
/// else can be built against the same forge (see
|
|
/// [`agent_token`]'s read-back).
|
|
url: url::Url,
|
|
}
|
|
|
|
/// The forge's URL. `Ok(None)` (not an error) when `SWARM_CONTROLLER_FORGE_URL`
|
|
/// is unset — no forge is configured for this daemon.
|
|
///
|
|
/// # Errors
|
|
/// A URL that does not parse. Retrying cannot fix that, so it is reported once
|
|
/// rather than waited out.
|
|
pub fn url_from_env() -> Result<Option<url::Url>> {
|
|
let Some(url) = std::env::var(URL_ENV).ok() else {
|
|
return Ok(None);
|
|
};
|
|
url::Url::parse(&url)
|
|
.with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))
|
|
.map(Some)
|
|
}
|
|
|
|
/// The longest the background re-read waits between attempts.
|
|
const BACKGROUND_CAP: std::time::Duration = std::time::Duration::from_mins(5);
|
|
|
|
/// How long the background re-read waits before its `n`-th attempt: 30s,
|
|
/// doubling, capped at [`BACKGROUND_CAP`].
|
|
fn background_delay(n: u32) -> std::time::Duration {
|
|
const FIRST_S: u64 = 30;
|
|
std::time::Duration::from_secs(
|
|
FIRST_S
|
|
.saturating_mul(1u64.checked_shl(n).unwrap_or(u64::MAX))
|
|
.min(BACKGROUND_CAP.as_secs()),
|
|
)
|
|
}
|
|
|
|
/// Whether the `n`-th failed attempt is logged at `info` rather than `debug`:
|
|
/// once the backoff is capped, so a controller that never gets its token says
|
|
/// so once per [`BACKGROUND_CAP`] without a line per early retry.
|
|
fn failure_logs_at_info(n: u32) -> bool {
|
|
background_delay(n) >= BACKGROUND_CAP
|
|
}
|
|
|
|
/// Fill `slot` with the first value `attempt` yields, sleeping
|
|
/// [`background_delay`] before each attempt. Never gives up. A failure is logged
|
|
/// as [`failure_logs_at_info`] says; the caller logs the first one and the
|
|
/// recovery.
|
|
async fn fill_when_ready<T, F, Fut>(slot: &OnceLock<T>, mut attempt: F) -> &T
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
let mut n = 0;
|
|
loop {
|
|
tokio::time::sleep(background_delay(n)).await;
|
|
match attempt().await {
|
|
Ok(value) => return slot.get_or_init(|| value),
|
|
Err(e) if failure_logs_at_info(n) => tracing::info!(
|
|
error = %format!("{e:#}"),
|
|
"still no forge access: the controller's forge token is unavailable"
|
|
),
|
|
Err(e) => tracing::debug!(
|
|
error = %format!("{e:#}"),
|
|
"the controller's forge token is still unavailable"
|
|
),
|
|
}
|
|
n = n.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
/// The longest one unit of forge work, a periodic pass or a job node, may run
|
|
/// before it is abandoned. forgejo-api's client has no request timeout, so
|
|
/// without this a forge that accepts a connection and never answers holds the
|
|
/// pass's loop, or the node, forever. Under every pass's 5-minute interval,
|
|
/// and far over what the longest node (`MintHiveForgeToken`, about fifteen
|
|
/// sequential forge and store requests) takes while it is making progress.
|
|
pub(crate) const FORGE_DEADLINE: std::time::Duration = std::time::Duration::from_mins(2);
|
|
|
|
/// Run `fut`, the forge work named `work` in the log, under
|
|
/// [`FORGE_DEADLINE`]. `None` means it was abandoned at the deadline; that is
|
|
/// logged here.
|
|
pub(crate) async fn within_forge_deadline<T>(
|
|
work: &str,
|
|
fut: impl std::future::Future<Output = T>,
|
|
) -> Option<T> {
|
|
let result = tokio::time::timeout(FORGE_DEADLINE, fut).await.ok();
|
|
if result.is_none() {
|
|
tracing::warn!(
|
|
work,
|
|
deadline_s = FORGE_DEADLINE.as_secs(),
|
|
"forge work: no answer within the deadline; abandoned"
|
|
);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Put a client for `url` into `slot` and hand it to `on_ready`: at once when
|
|
/// the store has the token, otherwise from a background task that re-reads it
|
|
/// until it does. Either way the daemon starts, and every forge-backed path
|
|
/// reads `slot`, so it answers "no forge" until the token lands and works
|
|
/// from then on.
|
|
///
|
|
/// A token replaced in the store after the slot is filled reaches this daemon
|
|
/// with its next restart.
|
|
pub async fn enable(
|
|
url: url::Url,
|
|
slot: Arc<OnceLock<Arc<Client>>>,
|
|
on_ready: impl FnOnce(Arc<Client>) + Send + 'static,
|
|
) {
|
|
match Client::connect(&url).await {
|
|
Ok(client) => on_ready(Arc::clone(slot.get_or_init(|| Arc::new(client)))),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %format!("{e:#}"),
|
|
"no forge access yet; re-reading the forge token from the swarm secret store in \
|
|
the background"
|
|
);
|
|
tokio::spawn(async move {
|
|
let client = Arc::clone(
|
|
fill_when_ready(&slot, || async {
|
|
Client::connect(&url).await.map(Arc::new)
|
|
})
|
|
.await,
|
|
);
|
|
tracing::info!("the forge token is in the swarm secret store; forge access is on");
|
|
on_ready(client);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
/// One read of the token from the secret store, and a client for `url`
|
|
/// holding it.
|
|
///
|
|
/// # Errors
|
|
/// The store refusing the login, nothing at the path because the forge's
|
|
/// host has not published it yet, or the client failing to build.
|
|
async fn connect(url: &url::Url) -> Result<Self> {
|
|
let token = fetch_token().await?;
|
|
let api = Forgejo::new(Auth::Token(&token), url.clone()).context("build forgejo client")?;
|
|
Ok(Self {
|
|
api,
|
|
url: url.clone(),
|
|
})
|
|
}
|
|
|
|
/// Creation options for an empty repo defaulting to `main`.
|
|
fn repo_option(name: &str, private: bool) -> CreateRepoOption {
|
|
CreateRepoOption {
|
|
auto_init: Some(false),
|
|
default_branch: Some("main".to_owned()),
|
|
description: None,
|
|
gitignores: None,
|
|
issue_labels: None,
|
|
license: None,
|
|
name: name.to_owned(),
|
|
object_format_name: None,
|
|
private: Some(private),
|
|
readme: None,
|
|
template: None,
|
|
trust_model: None,
|
|
}
|
|
}
|
|
|
|
/// Create `name` inside [`CONFIG_ORG`]. Idempotent: an existing repo
|
|
/// (409, or a 422 confirmed by a follow-up `repo_get`) is folded into
|
|
/// success. A 422 that is *not* an existing repo — forgejo also uses
|
|
/// it for other repo-create validation failures — fails with
|
|
/// forgejo's own message rather than being folded silently (see
|
|
/// [`folds_into_success`]).
|
|
async fn ensure_org_repo(&self, name: &str) -> Result<()> {
|
|
match self
|
|
.api
|
|
.create_org_repo(CONFIG_ORG, Self::repo_option(name, true))
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let existing = is_ambiguous_validation_failure(&e)
|
|
&& self.api.repo_get(CONFIG_ORG, name).await.is_ok();
|
|
if folds_into_success(&e, existing) {
|
|
tracing::debug!(%name, "swarm forge: repo already exists");
|
|
Ok(())
|
|
} else {
|
|
tracing::error!(%name, error = %e, "swarm forge: forge refused to create repo");
|
|
Err(e).with_context(|| format!("create repo {CONFIG_ORG}/{name}"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`.
|
|
async fn add_collaborator(
|
|
&self,
|
|
repo: &str,
|
|
user: &str,
|
|
permission: AddCollaboratorOptionPermission,
|
|
) -> Result<()> {
|
|
self.add_collaborator_to(CONFIG_ORG, repo, user, permission)
|
|
.await
|
|
}
|
|
|
|
/// Add `user` as a collaborator on `owner/repo` at `permission`.
|
|
/// Idempotent: re-adding an existing collaborator just updates its
|
|
/// permission (forgejo answers 204 either way; a 201 from older
|
|
/// server versions is tolerated defensively).
|
|
async fn add_collaborator_to(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
user: &str,
|
|
permission: AddCollaboratorOptionPermission,
|
|
) -> Result<()> {
|
|
let res = self
|
|
.api
|
|
.repo_add_collaborator(
|
|
owner,
|
|
repo,
|
|
user,
|
|
AddCollaboratorOption {
|
|
permission: Some(permission),
|
|
},
|
|
)
|
|
.await;
|
|
match res {
|
|
Ok(()) => {}
|
|
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
|
|
Err(e) => {
|
|
return Err(e)
|
|
.with_context(|| format!("add collaborator {user} to {owner}/{repo}"));
|
|
}
|
|
}
|
|
tracing::debug!(%owner, %repo, %user, ?permission, "swarm forge: collaborator set");
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply the operator merge-gate branch protection to `repo`'s
|
|
/// default branch — only [`OPERATORS_TEAM`] members can merge, and
|
|
/// an approving review from that team is required, so the agent (a
|
|
/// write-level collaborator, not in the team) cannot merge its own
|
|
/// PR. A create failure is ambiguous (already-exists vs. a silent
|
|
/// reject that created no rule), so on any error this GETs the
|
|
/// `main` rule and only treats it as success if the rule is
|
|
/// actually present — a fail-open merge gate is a security bug,
|
|
/// not a shrug.
|
|
///
|
|
/// Also push-whitelists [`SWARM_CONTROLLER_FORGE_USER`] alone —
|
|
/// [`Client::seed_agent_config`]'s initial commit is a direct push to
|
|
/// this same protected `main`, and being a site admin does not
|
|
/// exempt the content-edit API path from a protected branch's push
|
|
/// check the way a real `git push` is exempted (confirmed live: an
|
|
/// already-admin-promoted account still hit `ErrUserCannotCommit`
|
|
/// here). `hive-c0re`'s equivalent seed avoids the whole question by
|
|
/// seeding through an actual `git push`, which the admin bypass does
|
|
/// cover — this daemon has no `git` shell-out, so an explicit
|
|
/// whitelist entry is the narrower fix. The agent (a write
|
|
/// collaborator, not on this whitelist) still cannot push directly,
|
|
/// so this doesn't loosen the "can't merge your own PR" guarantee.
|
|
///
|
|
/// Needs [`OPERATORS_TEAM`] to exist in [`CONFIG_ORG`], which
|
|
/// [`Client::create_repo`] ensures first — if it still doesn't, this
|
|
/// fails loudly rather than silently leaving the repo unprotected.
|
|
async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> {
|
|
let rule = CreateBranchProtectionOption {
|
|
apply_to_admins: None,
|
|
approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
|
approvals_whitelist_username: None,
|
|
block_on_official_review_requests: Some(true),
|
|
block_on_outdated_branch: None,
|
|
block_on_rejected_reviews: None,
|
|
branch_name: Some("main".to_owned()),
|
|
dismiss_stale_approvals: None,
|
|
enable_approvals_whitelist: Some(true),
|
|
enable_merge_whitelist: Some(true),
|
|
enable_push: Some(true),
|
|
enable_push_whitelist: Some(true),
|
|
enable_status_check: None,
|
|
ignore_stale_approvals: None,
|
|
merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
|
merge_whitelist_usernames: None,
|
|
protected_file_patterns: None,
|
|
push_whitelist_deploy_keys: None,
|
|
push_whitelist_teams: None,
|
|
push_whitelist_usernames: Some(vec![SWARM_CONTROLLER_FORGE_USER.to_owned()]),
|
|
require_signed_commits: None,
|
|
required_approvals: Some(1),
|
|
rule_name: None,
|
|
status_check_contexts: None,
|
|
unprotected_file_patterns: None,
|
|
};
|
|
let Err(create_err) = self
|
|
.api
|
|
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
|
|
.await
|
|
else {
|
|
tracing::info!(%repo, "swarm forge: applied operator branch protection");
|
|
return Ok(());
|
|
};
|
|
match self
|
|
.api
|
|
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
tracing::debug!(
|
|
%repo, create_error = %create_err,
|
|
"swarm forge: operator branch protection already present"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(check_err) => anyhow::bail!(
|
|
"branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
|
|
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Create `repo` in [`CONFIG_ORG`] and apply the operator merge gate to
|
|
/// its default branch — the whole job of the `CreateRepo` node.
|
|
/// Branch protection is folded in here rather than a separate node: it
|
|
/// has no independent retry value apart from the repo existing (there
|
|
/// is nothing useful to retry "branch protection on a repo that
|
|
/// doesn't exist yet"), unlike collaborator-add and config-seed, which
|
|
/// are genuinely separate operations against an already-existing repo.
|
|
/// Idempotent — safe to call again.
|
|
///
|
|
/// Ensures the org and its [`OPERATORS_TEAM`] first: the periodic
|
|
/// [`objects`] pass does too, but an agent can be created before that
|
|
/// pass has succeeded once, and the rule below names the team.
|
|
pub async fn create_repo(&self, repo: &str) -> Result<String> {
|
|
self.ensure_merge_gate_prerequisites().await?;
|
|
self.ensure_org_repo(repo).await?;
|
|
self.apply_operator_branch_protection(repo).await?;
|
|
tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate");
|
|
Ok(format!("{CONFIG_ORG}/{repo}"))
|
|
}
|
|
|
|
/// Add `agent` as a **write** collaborator on `repo` (can push + open
|
|
/// PRs, cannot bypass branch protection) — the whole job of the
|
|
/// `AddRepoMember` node. Idempotent.
|
|
pub async fn add_repo_member(&self, repo: &str, agent: &str) -> Result<()> {
|
|
self.add_collaborator(repo, agent, AddCollaboratorOptionPermission::Write)
|
|
.await
|
|
}
|
|
|
|
/// Ensure `agent` exists as a Forgejo user account that cannot create
|
|
/// repos — the whole job of the `CreateForgeUser` node, and the fix for the "user does not
|
|
/// exist" failure `AddRepoMember` hit before this node existed: adding
|
|
/// a nonexistent user as a collaborator is a Forgejo validation error,
|
|
/// not an idempotent no-op, so something has to create the account
|
|
/// first. Mirrors `hive-c0re::forge::users::ensure_user_exists`'s
|
|
/// intent (an agent's Forgejo identity is provisioned once, up front,
|
|
/// authenticates by token thereafter, and its password is never read)
|
|
/// but not its mechanism: that function shells out to the local
|
|
/// `forgejo admin` CLI, which assumes co-location with the forge host.
|
|
/// This daemon has no such assumption — like every other call in this
|
|
/// file, it only ever talks to the forge over HTTP — so this goes through
|
|
/// `admin_create_user` instead. The password is a throwaway: 32 random
|
|
/// bytes, generated once, never persisted or read again (see
|
|
/// [`crate::webhook::generate_hex_secret`], reused here for the same
|
|
/// reason a webhook secret and this password are both "32 random bytes
|
|
/// nothing reads back").
|
|
///
|
|
/// Idempotent, same as [`Self::ensure_org_repo`]: 409 folds to success
|
|
/// unconditionally, 422 only after a follow-up `user_get` confirms the
|
|
/// user (see [`folds_into_success`] — not every 422 means it exists).
|
|
///
|
|
/// Then locks the account out of creating repos
|
|
/// ([`Self::disable_repo_creation`]), whether it was just created or
|
|
/// already existed. Nothing else does that for a swarm-created agent, so
|
|
/// a failure fails the node. Leaves an existing account's email as it
|
|
/// finds it.
|
|
pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> {
|
|
self.create_agent_user(agent).await?;
|
|
self.disable_repo_creation(agent).await
|
|
}
|
|
|
|
/// Set `max_repo_creation = 0` on `agent`'s account. Agents create repos
|
|
/// through the hive, never with their own token — a write-scoped token
|
|
/// can otherwise create and own a repo and self-merge in it, bypassing
|
|
/// the operator-only-merge policy. Existing repos are untouched; push,
|
|
/// PRs and clone keep working.
|
|
///
|
|
/// The PATCH itself is unconditional rather than verify-then-patch:
|
|
/// Forgejo's API never returns `max_repo_creation` (it is a field of
|
|
/// `EditUserOption` only, not of `User`), so there is no current value
|
|
/// to compare against, and setting 0 on an account already at 0 is a
|
|
/// no-op. But `create_agent_user`'s idempotent fold only proves *some*
|
|
/// account named `agent` exists — a 409 folds with no read at all, and
|
|
/// a 422's follow-up `user_get` only confirms the username, not that
|
|
/// swarm-controller created it — so before that PATCH this reads the
|
|
/// account once and checks the one thing that *is* comparable: the
|
|
/// email. A match against either [`agent_email`] (what
|
|
/// `create_agent_user` itself sets) or [`legacy_agent_email`] (what
|
|
/// older hive-created agent accounts carry) counts as this agent's
|
|
/// account. A mismatch against both means the
|
|
/// name collided with an account this node didn't create, so it fails
|
|
/// the node rather than sending the PATCH (or silently skipping) to an
|
|
/// account nobody verified is agent-controlled.
|
|
async fn disable_repo_creation(&self, agent: &str) -> Result<()> {
|
|
let user = self
|
|
.api
|
|
.user_get(agent)
|
|
.await
|
|
.with_context(|| format!("read forge user {agent} before locking it down"))?;
|
|
let expected = agent_email(agent);
|
|
let legacy = legacy_agent_email(agent);
|
|
if !matches!(user.email.as_deref(), Some(e) if e == expected || e == legacy) {
|
|
anyhow::bail!(
|
|
"forge account `{agent}` exists but is not this agent's (email {:?}, expected \
|
|
`{expected}` or the legacy `{legacy}`); refusing to lock it down",
|
|
user.email
|
|
);
|
|
}
|
|
self.api
|
|
.admin_edit_user(agent, repo_creation_lockdown(agent))
|
|
.await
|
|
.with_context(|| {
|
|
format!("disable repo creation for forge user {agent} (max_repo_creation = 0)")
|
|
})?;
|
|
tracing::info!(%agent, "swarm forge: disabled repo creation (max_repo_creation = 0)");
|
|
Ok(())
|
|
}
|
|
|
|
/// Create `agent`'s account. Idempotent: an existing user (409/422) is
|
|
/// folded into success, same as [`Self::ensure_org_repo`].
|
|
async fn create_agent_user(&self, agent: &str) -> Result<()> {
|
|
self.create_user(agent, &agent_email(agent)).await
|
|
}
|
|
|
|
/// Create the account `login` with `email` and a throwaway password.
|
|
/// Idempotent like [`Self::create_agent_user`], and like it proves only
|
|
/// that *some* account named `login` exists: the caller checks the email.
|
|
async fn create_user(&self, login: &str, email: &str) -> Result<()> {
|
|
let password = crate::webhook::generate_hex_secret()
|
|
.context("generating a throwaway password for a forge account")?;
|
|
let res = self
|
|
.api
|
|
.admin_create_user(CreateUserOption {
|
|
created_at: None,
|
|
email: email.to_owned(),
|
|
full_name: None,
|
|
login_name: None,
|
|
must_change_password: Some(false),
|
|
password: Some(password),
|
|
restricted: None,
|
|
send_notify: None,
|
|
source_id: None,
|
|
username: login.to_owned(),
|
|
visibility: None,
|
|
})
|
|
.await;
|
|
match res {
|
|
Ok(_) => {
|
|
tracing::info!(%login, "swarm forge: created forge user");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let existing =
|
|
is_ambiguous_validation_failure(&e) && self.api.user_get(login).await.is_ok();
|
|
if folds_into_success(&e, existing) {
|
|
tracing::debug!(%login, "swarm forge: forge user already exists");
|
|
Ok(())
|
|
} else {
|
|
tracing::error!(%login, error = %e, "swarm forge: forge refused to create user");
|
|
Err(e).with_context(|| format!("create forge user {login}"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Seed `repo` with the two files every agent config repo needs:
|
|
/// `agent.nix` (the agent's own module) and `flake.nix` (the
|
|
/// boilerplate that lets the meta flake import this repo as a flake
|
|
/// input) — committed here in one atomic `repo_change_files` call
|
|
/// instead of a local `git commit` (this process has no working tree to
|
|
/// commit from — it only ever talks to the forge over HTTP). The whole
|
|
/// job of the `InitAgentConfigRepo` node.
|
|
///
|
|
/// **This is the agent's config, not a copy of it.** A hive told to
|
|
/// deploy the agent clones this repo
|
|
/// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing
|
|
/// the same files again locally; the byte-identical template in
|
|
/// `hive-c0re::lifecycle::setup::seed_template` is only reached when no
|
|
/// repo exists here to clone.
|
|
///
|
|
/// Idempotent by construction rather than by catching a conflict: an
|
|
/// unconditional `repo_change_files` against an already-seeded repo
|
|
/// would overwrite `agent.nix` with the seed content again, clobbering
|
|
/// any edits merged since — so this checks for `agent.nix`'s presence
|
|
/// first and treats it as "already seeded, nothing to do" rather than
|
|
/// re-committing over it.
|
|
pub async fn seed_agent_config(&self, repo: &str, agent: &str) -> Result<()> {
|
|
if self.file_exists(repo, "agent.nix").await? {
|
|
tracing::debug!(%repo, "swarm forge: config already seeded");
|
|
return Ok(());
|
|
}
|
|
let files = vec![
|
|
ChangeFileOperation {
|
|
content: Some(base64_encode(&initial_agent_nix(agent))),
|
|
from_path: None,
|
|
operation: ChangeFileOperationOperation::Create,
|
|
path: "agent.nix".to_owned(),
|
|
sha: None,
|
|
},
|
|
ChangeFileOperation {
|
|
content: Some(base64_encode(initial_flake_nix())),
|
|
from_path: None,
|
|
operation: ChangeFileOperationOperation::Create,
|
|
path: "flake.nix".to_owned(),
|
|
sha: None,
|
|
},
|
|
];
|
|
self.api
|
|
.repo_change_files(
|
|
CONFIG_ORG,
|
|
repo,
|
|
ChangeFilesOptions {
|
|
author: None,
|
|
branch: None,
|
|
committer: None,
|
|
dates: None,
|
|
files,
|
|
force_overwrite_new_branch: None,
|
|
message: Some("swarm-controller: seed agent config".to_owned()),
|
|
new_branch: None,
|
|
signoff: None,
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?;
|
|
tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix");
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether `path` exists on `repo`'s default branch — the idempotency
|
|
/// check [`Self::seed_agent_config`] uses instead of catching a
|
|
/// create-conflict, since `repo_change_files` has no
|
|
/// already-exists-is-fine status the way `create_org_repo` does.
|
|
///
|
|
/// `create_repo` makes `repo` with `auto_init: false` (see
|
|
/// [`Self::repo_option`]) — no commits, no `main` ref, nothing a
|
|
/// contents lookup can resolve. Forgejo doesn't 404 that the way it
|
|
/// does a missing path on a real ref: it returns `200` with a bare
|
|
/// JSON `[]`, which fails to deserialize into the single-object
|
|
/// `ContentsResponse` the crate expects for a file path ("invalid
|
|
/// length 0, expected struct `ContentsResponse` with 16 elements" —
|
|
/// serde reading the 0-element array as a positional struct). So a
|
|
/// freshly created, not-yet-seeded repo trips this as an opaque
|
|
/// deserialize error, not the `NOT_FOUND` branch below — confirmed
|
|
/// against a live re-test of the swarm agent-create flow tracked on
|
|
/// the forge. Short-circuit on `empty` first: an empty repo
|
|
/// obviously has no `agent.nix`, and skipping
|
|
/// the contents call for it sidesteps the malformed-response shape
|
|
/// entirely rather than trying to special-case parsing it.
|
|
async fn file_exists(&self, repo: &str, path: &str) -> Result<bool> {
|
|
let is_empty = self
|
|
.api
|
|
.repo_get(CONFIG_ORG, repo)
|
|
.await
|
|
.with_context(|| format!("get repo {CONFIG_ORG}/{repo}"))?
|
|
.empty
|
|
.unwrap_or(false);
|
|
if is_empty {
|
|
return Ok(false);
|
|
}
|
|
match self
|
|
.api
|
|
.repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default())
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false),
|
|
Err(e) => Err(e).with_context(|| format!("check for {path} in {CONFIG_ORG}/{repo}")),
|
|
}
|
|
}
|
|
|
|
/// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent
|
|
/// name: list repos in the org, then open PRs per repo. It exists so `GET
|
|
/// /api/agents/{name}/config-pr` has something to answer from,
|
|
/// independent of any one hive being up.
|
|
///
|
|
/// A single repo's list failing does not fail the whole scan — logged and
|
|
/// skipped, so one flaky repo can't blank out every other agent's status.
|
|
pub async fn list_open_config_prs(
|
|
&self,
|
|
) -> Result<std::collections::HashMap<String, ConfigPrStatus>> {
|
|
let repos = self
|
|
.api
|
|
.org_list_repos(CONFIG_ORG)
|
|
.all()
|
|
.await
|
|
.with_context(|| format!("list repos in {CONFIG_ORG}"))?;
|
|
|
|
let mut out = std::collections::HashMap::new();
|
|
for repo in repos {
|
|
let Some(agent) = repo.name.as_deref() else {
|
|
continue;
|
|
};
|
|
let query = RepoListPullRequestsQuery {
|
|
state: Some(RepoListPullRequestsQueryState::Open),
|
|
sort: None,
|
|
milestone: None,
|
|
labels: None,
|
|
poster: None,
|
|
base: None,
|
|
head: None,
|
|
};
|
|
let prs = match self
|
|
.api
|
|
.repo_list_pull_requests(CONFIG_ORG, agent, query)
|
|
.all()
|
|
.await
|
|
{
|
|
Ok(prs) => prs,
|
|
Err(e) => {
|
|
tracing::warn!(%agent, error = %e, "swarm forge: listing config PRs failed, skipping repo");
|
|
continue;
|
|
}
|
|
};
|
|
// Only the first open PR matters for the panel — a config repo
|
|
// is meant to carry at most one live proposal at a time.
|
|
if let Some(pr) = prs.into_iter().next() {
|
|
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
|
|
continue;
|
|
};
|
|
out.insert(
|
|
agent.to_owned(),
|
|
ConfigPrStatus {
|
|
pr_number,
|
|
html_url: pr.html_url.map(|u| u.to_string()),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Pages a search-shaped result (`data: Option<Vec<T>>`, no total-count
|
|
/// header) to exhaustion via `fetch`, which must issue one request per
|
|
/// `page` (1-based) at `page_size` and return its `data`. `repo_search`'s
|
|
/// `SearchResults` is this shape, not the `(Headers, Vec<T>)` shape the
|
|
/// `.all()` helper on `Request` requires, so it can't use that helper —
|
|
/// this is its manual equivalent. Stops at the first page shorter than
|
|
/// `page_size` (forgejo's own last-page signal); a page exactly
|
|
/// `page_size` long always triggers one more fetch to confirm there
|
|
/// isn't a further page. Errors instead of returning a partial result if
|
|
/// `max_pages` is reached without a short page, so a misbehaving or
|
|
/// unexpectedly huge instance fails loudly rather than looping forever
|
|
/// or silently truncating again.
|
|
async fn page_search_results<T, F, Fut>(
|
|
page_size: u32,
|
|
max_pages: u32,
|
|
mut fetch: F,
|
|
) -> Result<Vec<T>>
|
|
where
|
|
F: FnMut(u32) -> Fut,
|
|
Fut: std::future::Future<Output = Result<Option<Vec<T>>>>,
|
|
{
|
|
let mut out = Vec::new();
|
|
for page in 1..=max_pages {
|
|
let rows = fetch(page).await?.unwrap_or_default();
|
|
let got = rows.len();
|
|
out.extend(rows);
|
|
if got < page_size as usize {
|
|
return Ok(out);
|
|
}
|
|
}
|
|
anyhow::bail!(
|
|
"search paging hit the {max_pages}-page bound (page size {page_size}) without a \
|
|
short page — the forge may hold more results than this scan can safely enumerate"
|
|
)
|
|
}
|
|
|
|
/// Repos with at least one open issue, as `owner/name` full names —
|
|
/// the data source for swarm-ui's repo-filter dropdown, which lists
|
|
/// only repos that currently have open issues rather than every repo
|
|
/// the forge hosts. Filtered on `Repository::open_issues_count` from
|
|
/// the search result itself rather than a follow-up
|
|
/// `issue_list_issues` call per repo — forgejo's own repo summary
|
|
/// already carries that count, so this stays one request per page
|
|
/// regardless of how many repos exist.
|
|
pub async fn list_repos_with_open_issues(&self) -> Result<Vec<String>> {
|
|
const PAGE_SIZE: u32 = 50;
|
|
// 40 pages at 50/page is 2000 repos — comfortably past any repo
|
|
// count this instance has today; see `page_search_results` for why
|
|
// hitting it is an error rather than a truncated result.
|
|
const MAX_PAGES: u32 = 40;
|
|
let repos = Self::page_search_results(PAGE_SIZE, MAX_PAGES, |page| async move {
|
|
Ok(self
|
|
.api
|
|
.repo_search(RepoSearchQuery::default())
|
|
.page(page)
|
|
.page_size(PAGE_SIZE)
|
|
.await
|
|
.context("search repos")?
|
|
.data)
|
|
})
|
|
.await?;
|
|
Ok(repos
|
|
.into_iter()
|
|
.filter(|r| r.open_issues_count.unwrap_or(0) > 0)
|
|
.filter_map(|r| r.full_name)
|
|
.collect())
|
|
}
|
|
|
|
/// Every open issue in `owner/repo`, each with its dependency-derived
|
|
/// `blocked` / `depended_on_by_count` pre-resolved server-side — the
|
|
/// whole job behind `GET /api/repos/{org}/{repo}/issue-report`. Doing
|
|
/// this resolution client-side (one dependency lookup per issue, on
|
|
/// every page load) doesn't scale, which is the reason this endpoint
|
|
/// exists at all rather than swarm-ui resolving it itself per row.
|
|
///
|
|
/// Forgejo has no bulk or reverse dependency query — the same
|
|
/// limitation `hive-forge issue dependency` hits — so this is still one
|
|
/// `issue_list_issue_dependencies` call per open issue, just run with
|
|
/// bounded concurrency (`CONCURRENCY`) rather than one-at-a-time or all
|
|
/// at once, so a repo with hundreds of open issues doesn't serialize on
|
|
/// round-trips or fire them all in one burst.
|
|
///
|
|
/// `depended_on_by_count` is a reverse index built off that SAME
|
|
/// resolve pass, not a second query: every open issue's own dependency
|
|
/// list is already in hand to compute `blocked`, so counting how often
|
|
/// each open issue's number appears across all of them is pure
|
|
/// in-memory aggregation. A dependency on a closed issue, or one
|
|
/// outside this open-issue set, can never accrue a count here — it
|
|
/// isn't a row in the report either, so "how many rows depend on this
|
|
/// row" would have nothing to point at.
|
|
///
|
|
/// `transitively_blocks_count` walks the same `reverse_adj` (blocker
|
|
/// number → issues naming it as a dependency) from each issue — see
|
|
/// [`transitive_reach`] for how the walk itself is made
|
|
/// diamond/cycle-safe. `depended_on_by_count` is `reverse_adj[n].len()`;
|
|
/// this is everything reachable from `n`, not just the direct edge.
|
|
pub async fn issue_report(&self, owner: &str, repo: &str) -> Result<Vec<IssueReportRow>> {
|
|
const CONCURRENCY: usize = 8;
|
|
|
|
let issues = self
|
|
.api
|
|
.issue_list_issues(
|
|
owner,
|
|
repo,
|
|
IssueListIssuesQuery {
|
|
state: Some(IssueListIssuesQueryState::Open),
|
|
r#type: Some(IssueListIssuesQueryType::Issues),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.all()
|
|
.await
|
|
.with_context(|| format!("list open issues in {owner}/{repo}"))?;
|
|
|
|
// Numbers collected into an owned `Vec` first, rather than handing
|
|
// `stream::iter` a lazy `filter_map` over borrowed `issues`
|
|
// directly — the latter shape trips a "closure implementation of
|
|
// `FnOnce` is not general enough" HRTB inference error where this
|
|
// function is used as an axum handler (the `routes!` macro checks
|
|
// `Handler` for arbitrary request lifetimes), even though the
|
|
// closure itself is unremarkable.
|
|
let numbers: Vec<i64> = issues.iter().filter_map(|i| i.number).collect();
|
|
let deps: HashMap<i64, Vec<forgejo_api::structs::Issue>> =
|
|
futures_util::stream::iter(numbers)
|
|
.map(|number| async move {
|
|
let blockers = self
|
|
.api
|
|
.issue_list_issue_dependencies(owner, repo, number)
|
|
.await
|
|
.with_context(|| format!("list dependencies of {owner}/{repo}#{number}"))?;
|
|
Ok::<_, anyhow::Error>((number, blockers))
|
|
})
|
|
.buffer_unordered(CONCURRENCY)
|
|
.try_collect()
|
|
.await?;
|
|
|
|
let open_numbers: HashSet<i64> = issues.iter().filter_map(|i| i.number).collect();
|
|
let mut reverse_adj: HashMap<i64, Vec<i64>> = HashMap::new();
|
|
for (&number, blockers) in &deps {
|
|
for blocker in blockers {
|
|
if let Some(n) = blocker.number
|
|
&& open_numbers.contains(&n)
|
|
{
|
|
reverse_adj.entry(n).or_default().push(number);
|
|
}
|
|
}
|
|
}
|
|
|
|
let repo_full_name = format!("{owner}/{repo}");
|
|
Ok(issues
|
|
.into_iter()
|
|
.filter_map(|issue| {
|
|
let number = issue.number?;
|
|
let blocked = deps
|
|
.get(&number)
|
|
.is_some_and(|b| b.iter().any(|d| d.state == Some(StateType::Open)));
|
|
Some(IssueReportRow {
|
|
repo: repo_full_name.clone(),
|
|
number,
|
|
title: issue.title.unwrap_or_default(),
|
|
labels: issue
|
|
.labels
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|l| l.name)
|
|
.collect(),
|
|
assignees: issue
|
|
.assignees
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|a| a.login)
|
|
.collect(),
|
|
html_url: issue.html_url.map(|u| u.to_string()),
|
|
blocked,
|
|
depended_on_by_count: u32::try_from(
|
|
reverse_adj.get(&number).map_or(0, Vec::len),
|
|
)
|
|
.unwrap_or(u32::MAX),
|
|
transitively_blocks_count: u32::try_from(transitive_reach(
|
|
number,
|
|
&reverse_adj,
|
|
))
|
|
.unwrap_or(u32::MAX),
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// [`Self::issue_report`], fanned out over every repo
|
|
/// [`Self::list_repos_with_open_issues`] finds — the default,
|
|
/// no-repo-filter view. Each repo's report is resolved independently and
|
|
/// concurrently (same `CONCURRENCY` bound as the per-issue dependency
|
|
/// resolution inside `issue_report`, since this is the same
|
|
/// "N independent forge round-trips" shape one level up), then
|
|
/// flattened into one row set — `IssueReportRow::repo` is what lets a
|
|
/// caller tell which repo a given row came from.
|
|
///
|
|
/// One repo's report failing does not fail the whole scan — logged and
|
|
/// skipped, same best-effort shape [`Self::list_open_config_prs`] uses:
|
|
/// a flaky repo should not blank out every other repo's rows.
|
|
pub async fn issue_report_all(&self) -> Result<Vec<IssueReportRow>> {
|
|
const CONCURRENCY: usize = 4;
|
|
|
|
let repos = self.list_repos_with_open_issues().await?;
|
|
let rows: Vec<Vec<IssueReportRow>> = futures_util::stream::iter(repos)
|
|
.map(|full_name| async move {
|
|
let Some((owner, repo)) = full_name.split_once('/') else {
|
|
tracing::warn!(%full_name, "issue_report_all: repo full_name has no '/'");
|
|
return Vec::new();
|
|
};
|
|
match self.issue_report(owner, repo).await {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %format!("{e:#}"),
|
|
%full_name,
|
|
"issue_report_all: one repo's report failed, skipping it"
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
})
|
|
.buffer_unordered(CONCURRENCY)
|
|
.collect()
|
|
.await;
|
|
Ok(rows.into_iter().flatten().collect())
|
|
}
|
|
|
|
/// What the review classifier reads of `owner/repo`'s PR `number`: its
|
|
/// head commit, its commits oldest first with their full messages, and
|
|
/// its unified diff.
|
|
///
|
|
/// # Errors
|
|
/// A forge failure, with the [`ForgejoError`] underneath; a 404 on the
|
|
/// first call means the repo or the PR does not exist
|
|
/// ([`is_repo_not_found`]).
|
|
pub async fn pull_for_review(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
number: i64,
|
|
) -> Result<PullForReview> {
|
|
let pull = self
|
|
.api
|
|
.repo_get_pull_request(owner, repo, number)
|
|
.await
|
|
.with_context(|| format!("read PR {number} of {owner}/{repo}"))?;
|
|
let head_sha = pull
|
|
.head
|
|
.and_then(|h| h.sha)
|
|
.with_context(|| format!("PR {number} of {owner}/{repo} names no head commit"))?;
|
|
let commits = self
|
|
.api
|
|
.repo_get_pull_request_commits(
|
|
owner,
|
|
repo,
|
|
number,
|
|
RepoGetPullRequestCommitsQuery {
|
|
verification: Some(false),
|
|
files: Some(false),
|
|
},
|
|
)
|
|
.all()
|
|
.await
|
|
.with_context(|| format!("list the commits of PR {number} of {owner}/{repo}"))?
|
|
.into_iter()
|
|
.map(|c| Commit {
|
|
sha: c.sha.unwrap_or_default(),
|
|
message: c.commit.and_then(|m| m.message).unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
let diff = self
|
|
.api
|
|
.repo_download_pull_diff_or_patch(
|
|
owner,
|
|
repo,
|
|
number,
|
|
"diff",
|
|
RepoDownloadPullDiffOrPatchQuery::default(),
|
|
)
|
|
.await
|
|
.with_context(|| format!("read the diff of PR {number} of {owner}/{repo}"))?;
|
|
Ok(PullForReview {
|
|
head_sha,
|
|
commits,
|
|
diff,
|
|
})
|
|
}
|
|
|
|
/// Register the swarm-wide hooks against this controller, so a real
|
|
/// forge event reaches [`crate::webhook`] instead of the endpoint only
|
|
/// being reachable by hand.
|
|
///
|
|
/// Adds its own hooks only. A hive's `/webhook/config-pr` hook left on
|
|
/// the `agent-configs` org by an older release stays registered and
|
|
/// delivers to a route no hive serves.
|
|
///
|
|
/// Idempotent: an existing hook with the same `target_url` is left
|
|
/// alone, so this is safe on every boot.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns the first failure. A listing failure is not fatal — it falls
|
|
/// through to the create attempt, which is idempotent server-side by way
|
|
/// of the already-exists fold.
|
|
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
|
for kind in DeliveryKind::ALL {
|
|
let target_url = kind.target_url(public_base);
|
|
let (event, scope) = match kind {
|
|
DeliveryKind::Knowledge => (
|
|
"push",
|
|
HookScope::Repo {
|
|
org: KNOWLEDGE_ORG,
|
|
repo: KNOWLEDGE_REPO,
|
|
},
|
|
),
|
|
DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }),
|
|
// Instance-wide: commit/push activity is not scoped to one
|
|
// org (unlike the two above) — it needs observing across
|
|
// every repo the forge hosts, per the "no metric exists for
|
|
// commits or pushes" tracker issue. Forgejo's
|
|
// "global (system) webhook" (`admin_create_hook`) is exactly
|
|
// this — the only scope in this API that fires for a repo
|
|
// in an org created after this hook was registered, with no
|
|
// per-org registration to keep in sync as orgs come and go.
|
|
DeliveryKind::VcsActivity => ("push", HookScope::Instance),
|
|
};
|
|
self.ensure_hook(&scope, &target_url, event, secret)
|
|
.await
|
|
.with_context(|| format!("register swarm webhook {target_url}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`]
|
|
/// so the org-scoped and repo-scoped calls do not each grow their own
|
|
/// copy of the list-then-create logic.
|
|
async fn ensure_hook(
|
|
&self,
|
|
scope: &HookScope<'_>,
|
|
target_url: &str,
|
|
event: &str,
|
|
secret: &str,
|
|
) -> Result<()> {
|
|
// ⚠️ Both non-matching arms log at a level the journal keeps, because
|
|
// this idempotency check fails *silently*: when the list step does
|
|
// not recognise a hook that is already there, the create below runs
|
|
// again, and that stays harmless only while the forge rejects the
|
|
// duplicate. A forge that accepts it leaves one extra hook per
|
|
// process start, each delivering on every event — and the create
|
|
// arm's line reads exactly like a first, correct registration.
|
|
//
|
|
// The two ways to reach a create have different causes, so they are
|
|
// separated: `listed=0` means the create is not landing in the set
|
|
// the list reads (a bucket or permission problem — see
|
|
// [`HookScope::extra_create_config`], which is where the instance
|
|
// scope's own version of that went wrong), a non-zero count with no
|
|
// match means the recorded url is not the one compared.
|
|
match scope.list_hook_urls(&self.api).await {
|
|
Ok(urls) => {
|
|
if urls.iter().any(|u| u == target_url) {
|
|
tracing::debug!(%target_url, "swarm forge: webhook already registered");
|
|
return Ok(());
|
|
}
|
|
tracing::info!(
|
|
%target_url,
|
|
listed = urls.len(),
|
|
"swarm forge: no listed hook matches; registering"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Best-effort: a forge that
|
|
// cannot be listed may still accept a create, and a
|
|
// duplicate create is folded into success below. `warn!`
|
|
// because that fold is an assumption about the forge, not a
|
|
// guarantee — see the note above.
|
|
tracing::warn!(error = %e, %target_url, "swarm forge: listing hooks failed; registering blind");
|
|
}
|
|
}
|
|
|
|
let mut additional = BTreeMap::new();
|
|
additional.insert("secret".to_owned(), secret.to_owned());
|
|
for (key, value) in scope.extra_create_config() {
|
|
additional.insert((*key).to_owned(), (*value).to_owned());
|
|
}
|
|
let hook = CreateHookOption {
|
|
active: Some(true),
|
|
authorization_header: None,
|
|
branch_filter: None,
|
|
config: CreateHookOptionConfig {
|
|
content_type: "json".to_owned(),
|
|
url: url::Url::parse(target_url)
|
|
.with_context(|| format!("parse webhook target url {target_url}"))?,
|
|
additional,
|
|
},
|
|
events: Some(vec![event.to_owned()]),
|
|
r#type: CreateHookOptionType::Forgejo,
|
|
};
|
|
match scope.create_hook(&self.api, hook).await {
|
|
Ok(()) => {
|
|
tracing::info!(%target_url, %event, "swarm forge: webhook registered");
|
|
Ok(())
|
|
}
|
|
Err(e) if is_already_exists(&e) => {
|
|
tracing::debug!(%target_url, "swarm forge: webhook already present");
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every distinct node reachable from `start` by following `adj` edges
|
|
/// (`adj[n]` = the issues that directly depend on `n`), not counting
|
|
/// `start` itself. Plain DFS with a `visited` set doubling as the
|
|
/// cycle guard — see [`Client::issue_report`]'s doc comment for why a
|
|
/// cycle has to be tolerated rather than assumed impossible.
|
|
fn transitive_reach(start: i64, adj: &HashMap<i64, Vec<i64>>) -> usize {
|
|
// `start` goes into `visited` up front, pre-empting the one case a
|
|
// plain "insert on visit" walk gets wrong: a cycle that loops back to
|
|
// `start` would otherwise re-insert it and count it as its own
|
|
// descendant. Pre-seeding makes that re-visit a no-op instead, so the
|
|
// `- 1` below only ever backs out the seed, never a real node.
|
|
let mut visited: HashSet<i64> = HashSet::from([start]);
|
|
let mut stack: Vec<i64> = adj.get(&start).cloned().unwrap_or_default();
|
|
while let Some(node) = stack.pop() {
|
|
if visited.insert(node)
|
|
&& let Some(next) = adj.get(&node)
|
|
{
|
|
stack.extend(next);
|
|
}
|
|
}
|
|
visited.len() - 1
|
|
}
|
|
|
|
/// Where a hook lives. The knowledge hook is repo-scoped and the config-PR
|
|
/// hook is org-scoped — a hook on the wrong scope would never fire, and forgejo would
|
|
/// report that as a perfectly healthy hook with no deliveries.
|
|
enum HookScope<'a> {
|
|
Repo {
|
|
org: &'a str,
|
|
repo: &'a str,
|
|
},
|
|
Org {
|
|
org: &'a str,
|
|
},
|
|
/// Forgejo's "global (system) webhook" — fires for every repo in the
|
|
/// instance, in every org, present or future. The `admin_*` API
|
|
/// namespace; needs the same `write:admin` scope
|
|
/// `Client::ensure_agent_user` already requires on this token, **and**
|
|
/// the `is_system_webhook` config key from
|
|
/// [`Self::extra_create_config`] — that endpoint's default is the
|
|
/// *other* kind of admin hook.
|
|
Instance,
|
|
}
|
|
|
|
impl HookScope<'_> {
|
|
/// Config-map keys the create call needs beyond the shared ones.
|
|
///
|
|
/// Instance scope carries `is_system_webhook`, and it is load-bearing
|
|
/// twice over. `POST /admin/hooks` reads that key **out of the config
|
|
/// map** and defaults it to `false`, which makes a forgejo *default*
|
|
/// webhook — a template copied into repos created later, not a live
|
|
/// hook — while `GET /admin/hooks` returns only webhooks with the flag
|
|
/// set. Omitting it therefore breaks both halves at once: the hook is
|
|
/// not the instance-wide one this scope exists for, and
|
|
/// [`Self::list_hook_urls`] can never see it, so every process start
|
|
/// creates another one — twelve in a day, `listed=0` each time,
|
|
/// before this key was sent.
|
|
fn extra_create_config(&self) -> &'static [(&'static str, &'static str)] {
|
|
match self {
|
|
Self::Repo { .. } | Self::Org { .. } => &[],
|
|
Self::Instance => &[("is_system_webhook", "true")],
|
|
}
|
|
}
|
|
|
|
/// The `url` config value of every hook currently on this scope.
|
|
async fn list_hook_urls(&self, api: &Forgejo) -> Result<Vec<String>, ForgejoError> {
|
|
let hooks = match self {
|
|
Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?,
|
|
Self::Org { org } => api.org_list_hooks(org).send().await?,
|
|
Self::Instance => api.admin_list_hooks().await?,
|
|
};
|
|
Ok(hooks
|
|
.iter()
|
|
.filter_map(|h| h.config.as_ref()?.get("url").cloned())
|
|
.collect())
|
|
}
|
|
|
|
async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> {
|
|
match self {
|
|
Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop),
|
|
Self::Org { org } => api.org_create_hook(org, hook).await.map(drop),
|
|
Self::Instance => api.admin_create_hook(hook).await.map(drop),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Base64-encode `content` for a forgejo content-API call — every file
|
|
/// write on this API takes base64, never raw bytes.
|
|
fn base64_encode(content: &str) -> String {
|
|
use base64::Engine as _;
|
|
base64::engine::general_purpose::STANDARD.encode(content)
|
|
}
|
|
|
|
/// Per-agent NixOS module seed — same content
|
|
/// `hive-c0re::lifecycle::setup::initial_agent_nix` writes at the per-hive
|
|
/// level (this process has no access to that function across the crate
|
|
/// boundary, and it's three lines — not worth a shared crate for).
|
|
///
|
|
/// Deliberately does **not** record which hive the agent belongs to, even
|
|
/// though `POST /api/agents` now takes one: an agent's config should carry
|
|
/// no reference to the hive it runs on. The hive is an address the swarm
|
|
/// routes on, not a property of the agent — a
|
|
/// config that named its own hive would be a second place stating where
|
|
/// the agent lives, free to disagree with the queue that actually
|
|
/// delivers to it.
|
|
fn initial_agent_nix(name: &str) -> String {
|
|
format!(
|
|
"{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n",
|
|
)
|
|
}
|
|
|
|
/// Module-only flake seed, byte-identical to
|
|
/// `hive-c0re::lifecycle::setup::initial_flake_nix` — see that function's
|
|
/// doc comment for why the shape looks the way it does (`flakeInputs`
|
|
/// forwarding, meta-flake consumption). Not shared across the crate
|
|
/// boundary for the same reason [`initial_agent_nix`] isn't.
|
|
fn initial_flake_nix() -> &'static str {
|
|
"{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n"
|
|
}
|
|
|
|
/// Canonical email for an agent's Forgejo account, set by
|
|
/// [`Client::create_agent_user`] at creation and checked by
|
|
/// [`Client::disable_repo_creation`] before it sends the account's PATCH. Same
|
|
/// format string as `hive-c0re::forge::users::agent_email` — not called
|
|
/// across the crate boundary (see this module's doc comment), but both
|
|
/// creators use this exact value, so it doubles as the
|
|
/// marker that distinguishes an agent's own account from an unrelated one
|
|
/// that happens to share its username.
|
|
fn agent_email(agent: &str) -> String {
|
|
format!("{agent}@hyperhive.local")
|
|
}
|
|
|
|
/// The email older hive-created agent accounts carry, and keep: nothing
|
|
/// rewrites it to [`agent_email`]. Accepted by
|
|
/// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`] so
|
|
/// such an account is still recognised as the agent's own.
|
|
fn legacy_agent_email(agent: &str) -> String {
|
|
format!("{agent}@hive.local")
|
|
}
|
|
|
|
/// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent`
|
|
/// and nothing else. `login_name` + `source_id = 0` (local auth,
|
|
/// which is what [`Client::create_agent_user`] creates) ride along because
|
|
/// Forgejo resets `use_custom_avatar` on an admin edit that omits
|
|
/// `login_name`; every other field is left unset so nothing else is touched.
|
|
fn repo_creation_lockdown(agent: &str) -> EditUserOption {
|
|
EditUserOption {
|
|
active: None,
|
|
admin: None,
|
|
allow_create_organization: None,
|
|
allow_git_hook: None,
|
|
allow_import_local: None,
|
|
description: None,
|
|
email: None,
|
|
full_name: None,
|
|
hide_email: None,
|
|
location: None,
|
|
login_name: Some(agent.to_owned()),
|
|
max_repo_creation: Some(0),
|
|
must_change_password: None,
|
|
password: None,
|
|
prohibit_login: None,
|
|
pronouns: None,
|
|
restricted: None,
|
|
source_id: Some(0),
|
|
visibility: None,
|
|
website: None,
|
|
}
|
|
}
|
|
|
|
/// Whether a create-style call failed because the object already
|
|
/// exists. Forgejo signals this as HTTP 409 (conflict) or 422
|
|
/// (validation) — match both defensively, same as
|
|
/// `hive-c0re::forge::repos::is_already_exists`.
|
|
fn is_already_exists(e: &ForgejoError) -> bool {
|
|
match e {
|
|
ForgejoError::ApiError(api) => match api.error_kind() {
|
|
ApiErrorKind::ValidationFailed => true,
|
|
ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT,
|
|
_ => false,
|
|
},
|
|
ForgejoError::UnexpectedStatusCode(s) => {
|
|
*s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Whether a create-call failure is forgejo's unambiguous "already
|
|
/// exists" signal — HTTP 409. Unlike a 422 (see
|
|
/// [`is_ambiguous_validation_failure`]), nothing else produces a 409 on
|
|
/// these create calls, so folding it into success needs no follow-up
|
|
/// confirmation.
|
|
fn is_confirmed_conflict(e: &ForgejoError) -> bool {
|
|
match e {
|
|
ForgejoError::ApiError(api) => {
|
|
matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::CONFLICT)
|
|
}
|
|
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::CONFLICT,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Whether a create-call failure is forgejo's 422 "validation failed" —
|
|
/// ambiguous on its own. Forgejo folds several distinct causes into this
|
|
/// one status; an admin user create alone answers 422 for
|
|
/// `ErrUserAlreadyExist`, `ErrEmailAlreadyUsed`, `ErrNameReserved`,
|
|
/// `ErrNameCharsNotAllowed`, `ErrEmailInvalid` and
|
|
/// `ErrNamePatternNotAllowed` — only the first of those means the object
|
|
/// exists. A 422 alone is never enough to fold into success; the caller
|
|
/// must confirm with a follow-up read first (see [`folds_into_success`]).
|
|
fn is_ambiguous_validation_failure(e: &ForgejoError) -> bool {
|
|
match e {
|
|
ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::ValidationFailed),
|
|
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::UNPROCESSABLE_ENTITY,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Whether a create-call failure `e` should be folded into success, given
|
|
/// whether a follow-up read found the object already present.
|
|
/// `existing` is only consulted for the ambiguous 422 case — a 409 is
|
|
/// trusted on its own, and any other error always fails.
|
|
fn folds_into_success(e: &ForgejoError, existing: bool) -> bool {
|
|
is_confirmed_conflict(e) || (is_ambiguous_validation_failure(e) && existing)
|
|
}
|
|
|
|
/// Whether a forge call failed because the named repo itself doesn't exist —
|
|
/// forgejo answers a plain HTTP 404 for `owner/repo` not found, which the
|
|
/// generated client surfaces as `ApiErrorKind::NotFound`. Checked
|
|
/// defensively against `ApiErrorKind::Other`/`UnexpectedStatusCode` too,
|
|
/// same dual-check shape [`is_already_exists`] uses for 409/422 — belt and
|
|
/// braces against a response whose body didn't parse as the expected shape.
|
|
/// Used by [`crate::issue_report::get_issue_report`] to tell "no such repo"
|
|
/// (404, retrying won't help) apart from every other forge failure (503,
|
|
/// might clear up) — see that module for why the distinction matters.
|
|
pub(crate) fn is_repo_not_found(e: &ForgejoError) -> bool {
|
|
match e {
|
|
ForgejoError::ApiError(api) => match api.error_kind() {
|
|
ApiErrorKind::NotFound { .. } => true,
|
|
ApiErrorKind::Other(s) => *s == StatusCode::NOT_FOUND,
|
|
_ => false,
|
|
},
|
|
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn validation_failed_error() -> ForgejoError {
|
|
ForgejoError::ApiError(ApiErrorKind::ValidationFailed.into())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn conflict_error() -> ForgejoError {
|
|
ForgejoError::ApiError(ApiErrorKind::Other(StatusCode::CONFLICT).into())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Client {
|
|
/// A client for a test's own forge at `url`, holding a placeholder token.
|
|
pub(crate) fn for_test_forge(url: url::Url) -> Self {
|
|
let api = Forgejo::new(Auth::Token("stub-token"), url.clone()).unwrap();
|
|
Self { api, url }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Both scenarios live in ONE test, not two, deliberately: `cargo
|
|
// test` runs tests in parallel by default, and two tests mutating
|
|
// the same process-global env var would race each other. A single
|
|
// test's body is one thread, so sequential set/clear within it is
|
|
// race-free without needing a cross-test lock.
|
|
#[test]
|
|
fn url_from_env_is_none_without_a_url_and_refuses_a_bad_one() {
|
|
// SAFETY: test-only mutation of the env var this module itself
|
|
// owns (`URL_ENV`), scoped to this one test function so there's no
|
|
// cross-test race (see comment above).
|
|
unsafe {
|
|
std::env::remove_var(URL_ENV);
|
|
}
|
|
assert_eq!(url_from_env().unwrap(), None);
|
|
|
|
unsafe {
|
|
std::env::set_var(URL_ENV, "not a url");
|
|
}
|
|
let err = url_from_env().expect_err("an unparseable forge URL");
|
|
assert!(err.to_string().contains(URL_ENV), "{err:#}");
|
|
|
|
unsafe {
|
|
std::env::remove_var(URL_ENV);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_re_read_logs_at_info_only_once_the_backoff_is_capped() {
|
|
assert!((0..4).all(|n| !failure_logs_at_info(n)));
|
|
assert!(failure_logs_at_info(4));
|
|
assert!(failure_logs_at_info(40));
|
|
}
|
|
|
|
#[test]
|
|
fn the_background_re_read_backs_off_from_30s_to_a_5_minute_cap() {
|
|
let s = |n| background_delay(n).as_secs();
|
|
assert_eq!([s(0), s(1), s(2), s(3), s(4)], [30, 60, 120, 240, 300]);
|
|
assert_eq!(s(40), 300);
|
|
assert_eq!(s(u32::MAX), 300);
|
|
}
|
|
|
|
// Paused time: each sleep completes at once and advances the clock, so the
|
|
// elapsed time is the backoff schedule itself.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_token_absent_at_start_fills_the_slot_once_it_arrives() {
|
|
let slot = OnceLock::new();
|
|
let start = tokio::time::Instant::now();
|
|
let mut calls = 0;
|
|
let got = fill_when_ready(&slot, || {
|
|
calls += 1;
|
|
let n = calls;
|
|
async move {
|
|
if n < 3 {
|
|
anyhow::bail!("nothing at the controller's forge token path")
|
|
}
|
|
Ok("client")
|
|
}
|
|
})
|
|
.await;
|
|
assert_eq!(*got, "client");
|
|
assert_eq!(slot.get(), Some(&"client"));
|
|
assert_eq!(calls, 3);
|
|
assert_eq!(start.elapsed().as_secs(), 30 + 60 + 120);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_pass_that_never_answers_is_abandoned_at_the_deadline() {
|
|
let start = tokio::time::Instant::now();
|
|
let got = within_forge_deadline("test", std::future::pending::<()>()).await;
|
|
assert_eq!(got, None);
|
|
assert_eq!(start.elapsed(), FORGE_DEADLINE);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_pass_that_answers_returns_its_result() {
|
|
assert_eq!(within_forge_deadline("test", async { 7 }).await, Some(7));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_search_results_pages_past_a_full_first_page() {
|
|
#[derive(Clone)]
|
|
struct FakeRepo {
|
|
full_name: String,
|
|
open_issues: u32,
|
|
}
|
|
|
|
// Page 1 is exactly PAGE_SIZE long, which must not be mistaken for
|
|
// the last page — a repo living on page 2 still has to turn up.
|
|
const PAGE_SIZE: u32 = 2;
|
|
let pages = vec![
|
|
vec![
|
|
FakeRepo {
|
|
full_name: "org/a".to_owned(),
|
|
open_issues: 0,
|
|
},
|
|
FakeRepo {
|
|
full_name: "org/b".to_owned(),
|
|
open_issues: 0,
|
|
},
|
|
],
|
|
vec![FakeRepo {
|
|
full_name: "org/c".to_owned(),
|
|
open_issues: 3,
|
|
}],
|
|
];
|
|
let repos = Client::page_search_results(PAGE_SIZE, 10, |page| {
|
|
let pages = pages.clone();
|
|
async move { Ok(pages.get(page as usize - 1).cloned()) }
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let with_open_issues: Vec<_> = repos
|
|
.into_iter()
|
|
.filter(|r| r.open_issues > 0)
|
|
.map(|r| r.full_name)
|
|
.collect();
|
|
assert_eq!(with_open_issues, vec!["org/c".to_owned()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_search_results_errors_instead_of_looping_forever() {
|
|
// A server that never returns a short page (misbehaving, or an
|
|
// instance that genuinely outgrew `max_pages`) must fail loudly
|
|
// rather than paging forever or handing back a partial result.
|
|
const PAGE_SIZE: u32 = 2;
|
|
let result =
|
|
Client::page_search_results(
|
|
PAGE_SIZE,
|
|
3,
|
|
|_page| async move { Ok(Some(vec![1u32, 2])) },
|
|
)
|
|
.await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_walks_a_chain_not_just_the_direct_edge() {
|
|
// 3 depends on 2 depends on 1: reverse_adj is "who depends on me",
|
|
// so 1 -> [2], 2 -> [3]. Fixing 1 eventually frees both 2 and 3.
|
|
let adj = HashMap::from([(1, vec![2]), (2, vec![3])]);
|
|
assert_eq!(transitive_reach(1, &adj), 2);
|
|
assert_eq!(transitive_reach(2, &adj), 1);
|
|
assert_eq!(transitive_reach(3, &adj), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_counts_a_diamond_descendant_once() {
|
|
// 1 -> [2, 3], both 2 and 3 -> [4]: 4 is reachable via two paths
|
|
// but must only be counted once.
|
|
let adj = HashMap::from([(1, vec![2, 3]), (2, vec![4]), (3, vec![4])]);
|
|
assert_eq!(transitive_reach(1, &adj), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_terminates_on_a_cycle() {
|
|
// 1 -> [2] -> [1]: nothing forge-side prevents a dependency cycle,
|
|
// so the walk has to survive one instead of looping forever.
|
|
let adj = HashMap::from([(1, vec![2]), (2, vec![1])]);
|
|
assert_eq!(transitive_reach(1, &adj), 1);
|
|
}
|
|
|
|
// The three cases from the `forge-422-fold` issue: a create call's
|
|
// 422 with the object confirmed absent must fail (case 1), the same
|
|
// 422 with the object confirmed present must fold to success (case
|
|
// 2), and a 409 must fold to success without needing a follow-up
|
|
// read at all (case 3).
|
|
#[test]
|
|
fn folds_into_success_case1_422_absent_fails() {
|
|
assert!(!folds_into_success(&validation_failed_error(), false));
|
|
}
|
|
|
|
#[test]
|
|
fn folds_into_success_case2_422_present_is_done() {
|
|
assert!(folds_into_success(&validation_failed_error(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn folds_into_success_case3_conflict_is_done() {
|
|
// `existing` is never consulted for a 409 — pass `false` to prove
|
|
// that, not just `true`.
|
|
assert!(folds_into_success(&conflict_error(), false));
|
|
}
|
|
|
|
// Invert-proof: the old `is_already_exists` folded every 422 into
|
|
// success regardless of whether the object existed — exactly the
|
|
// defect `folds_into_success` fixes. Demonstrate the old logic
|
|
// getting case 1 wrong before trusting the new one.
|
|
#[test]
|
|
fn old_is_already_exists_wrongly_returns_true_for_an_absent_object() {
|
|
let e = validation_failed_error();
|
|
assert!(is_already_exists(&e), "old logic: false positive on 422");
|
|
assert!(
|
|
!folds_into_success(&e, false),
|
|
"new logic: correctly refuses to fold a 422 with no confirmed existence"
|
|
);
|
|
}
|
|
|
|
/// What [`stub_forge`] saw: method, path and JSON body of each request.
|
|
pub(super) type Seen =
|
|
std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
|
|
|
|
/// A stand-in forge on a loopback port — never the real one — answering
|
|
/// the admin user create (`POST`) with `create`, the pre-lockdown
|
|
/// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with
|
|
/// `edit` (status, JSON body each), and recording every request.
|
|
pub(super) async fn stub_forge(
|
|
create: (u16, &'static str),
|
|
get: (u16, &'static str),
|
|
edit: (u16, &'static str),
|
|
) -> (Client, Seen) {
|
|
use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE};
|
|
|
|
let seen = Seen::default();
|
|
let recorded = seen.clone();
|
|
let app = axum::Router::new().fallback(
|
|
move |method: Method, uri: Uri, body: axum::body::Bytes| {
|
|
let recorded = recorded.clone();
|
|
async move {
|
|
let json = serde_json::from_slice(&body).unwrap_or_default();
|
|
recorded.lock().unwrap().push((
|
|
method.to_string(),
|
|
uri.path().to_owned(),
|
|
json,
|
|
));
|
|
let (status, body) = match method {
|
|
Method::POST => create,
|
|
Method::GET => get,
|
|
Method::PATCH => edit,
|
|
_ => (404, r#"{"message":"not stubbed"}"#),
|
|
};
|
|
let status = StatusCode::from_u16(status).unwrap();
|
|
(status, [(CONTENT_TYPE, "application/json")], body)
|
|
}
|
|
},
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
|
let api = Forgejo::new(Auth::Token("stub-token"), url.clone()).unwrap();
|
|
(Client { api, url }, seen)
|
|
}
|
|
|
|
/// The `PATCH` [`Client::ensure_agent_user`] sent, if any.
|
|
pub(super) fn lockdown_patch(seen: &Seen, agent: &str) -> Option<serde_json::Value> {
|
|
let path = format!("/api/v1/admin/users/{agent}");
|
|
seen.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|(method, p, _)| method == "PATCH" && *p == path)
|
|
.map(|(_, _, body)| body.clone())
|
|
}
|
|
|
|
/// The email [`stub_forge`]'s `user_get` stub should answer with for an
|
|
/// account this agent actually owns — same value
|
|
/// [`Client::create_agent_user`] itself sets.
|
|
fn own_account_get(agent: &str) -> (u16, &'static str) {
|
|
// Leaked rather than borrowed: `stub_forge`'s `get` param needs
|
|
// `&'static str` (it moves into a `'static` axum handler), and this
|
|
// helper builds the body per-agent instead of hardcoding "alice".
|
|
(
|
|
200,
|
|
Box::leak(format!(r#"{{"email":"{agent}@hyperhive.local"}}"#).into_boxed_str()),
|
|
)
|
|
}
|
|
|
|
/// A freshly created agent account is locked out of repo creation by
|
|
/// this node itself — not left for a hive-level pass to catch later —
|
|
/// and the edit sets nothing beyond `max_repo_creation` plus the
|
|
/// `login_name` / `source_id` pair Forgejo needs to leave the rest alone.
|
|
#[tokio::test]
|
|
async fn a_created_agent_user_gets_repo_creation_disabled() {
|
|
let (client, seen) = stub_forge((201, "{}"), own_account_get("alice"), (200, "{}")).await;
|
|
|
|
client.ensure_agent_user("alice").await.unwrap();
|
|
|
|
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
|
|
assert_eq!(body["max_repo_creation"], 0);
|
|
let set: Vec<&str> = body
|
|
.as_object()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|(_, v)| !v.is_null())
|
|
.map(|(k, _)| k.as_str())
|
|
.collect();
|
|
assert_eq!(set, ["login_name", "max_repo_creation", "source_id"]);
|
|
assert_eq!(body["login_name"], "alice");
|
|
assert_eq!(body["source_id"], 0);
|
|
}
|
|
|
|
/// An account that already existed is locked down too: the node is
|
|
/// re-run, and an account created before this lockdown existed must
|
|
/// not keep repo creation just because the create step folded to
|
|
/// success. 409 rather than 422 so the case doesn't hinge on how a 422
|
|
/// is disambiguated.
|
|
#[tokio::test]
|
|
async fn an_existing_agent_user_still_gets_repo_creation_disabled() {
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
own_account_get("alice"),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
client.ensure_agent_user("alice").await.unwrap();
|
|
|
|
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
|
|
assert_eq!(body["max_repo_creation"], 0);
|
|
}
|
|
|
|
/// A refused lockdown fails the node with Forgejo's own message, rather
|
|
/// than warning and reporting the account as provisioned, which would
|
|
/// leave an agent able to create and self-merge with nothing marked
|
|
/// failed.
|
|
#[tokio::test]
|
|
async fn a_refused_lockdown_fails_the_node() {
|
|
let (client, _seen) = stub_forge(
|
|
(201, "{}"),
|
|
own_account_get("alice"),
|
|
(403, r#"{"message":"token does not have required scope"}"#),
|
|
)
|
|
.await;
|
|
|
|
let err = client.ensure_agent_user("alice").await.unwrap_err();
|
|
|
|
let msg = format!("{err:#}");
|
|
assert!(msg.contains("max_repo_creation"), "{msg}");
|
|
assert!(msg.contains("token does not have required scope"), "{msg}");
|
|
}
|
|
|
|
/// The guard this PR adds: a name collision with a pre-existing
|
|
/// non-agent account (or any account whose email isn't the
|
|
/// `{agent}@hyperhive.local` marker) must fail the node rather than
|
|
/// PATCH `max_repo_creation`/`source_id` onto an account nobody
|
|
/// verified this node created. 409 create-fold, same as
|
|
/// `an_existing_agent_user_still_gets_repo_creation_disabled` — the
|
|
/// fold itself is untouched by this PR; the fix lives in the read right
|
|
/// before the PATCH.
|
|
#[tokio::test]
|
|
async fn a_name_collision_with_a_non_agent_account_fails_without_patching() {
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
(200, r#"{"email":"someone@example.com"}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
let err = client.ensure_agent_user("alice").await.unwrap_err();
|
|
|
|
let msg = format!("{err:#}");
|
|
assert!(msg.contains("alice"), "{msg}");
|
|
assert!(
|
|
msg.contains("not this agent's"),
|
|
"must name the collision, got: {msg}"
|
|
);
|
|
assert!(
|
|
lockdown_patch(&seen, "alice").is_none(),
|
|
"a collision must never reach the PATCH"
|
|
);
|
|
}
|
|
|
|
/// A legacy hive-created account can carry the same
|
|
/// `{agent}@hyperhive.local` marker, plus `login_name`/`source_id` from
|
|
/// a hive admin edit — this node must still
|
|
/// lock such an account down, not just ones it created itself.
|
|
#[tokio::test]
|
|
async fn a_legacy_hive_created_agent_account_still_gets_locked_down() {
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
(
|
|
200,
|
|
r#"{"id":42,"login":"alice","login_name":"alice","email":"alice@hyperhive.local","source_id":0}"#,
|
|
),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
client.ensure_agent_user("alice").await.unwrap();
|
|
|
|
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
|
|
assert_eq!(body["max_repo_creation"], 0);
|
|
}
|
|
|
|
/// A real, pre-existing agent account can carry the legacy
|
|
/// `@hive.local` email, and nothing rewrites it. The guard must accept
|
|
/// that value too, or such an agent would fail with "not this agent's".
|
|
#[tokio::test]
|
|
async fn a_pre_alignment_legacy_email_still_gets_locked_down() {
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
(200, r#"{"email":"alice@hive.local"}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
client.ensure_agent_user("alice").await.unwrap();
|
|
|
|
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
|
|
assert_eq!(body["max_repo_creation"], 0);
|
|
}
|
|
|
|
/// Every `PATCH` body the stub saw.
|
|
fn patches(seen: &Seen) -> Vec<serde_json::Value> {
|
|
seen.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|(method, _, _)| method == "PATCH")
|
|
.map(|(_, _, body)| body.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Only `admin` goes out: a `source_id` would reset an SSO-made
|
|
/// account's login type, and the other fields would overwrite the user's
|
|
/// own settings.
|
|
#[tokio::test]
|
|
async fn make_site_admin_sets_admin_and_nothing_else() {
|
|
let (client, seen) = stub_forge(
|
|
(404, "{}"),
|
|
(200, r#"{"login":"mara","is_admin":false}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
let plan = client.make_site_admin("mara").await.unwrap();
|
|
|
|
assert_eq!(plan, site_admin::Plan::Promote);
|
|
let bodies = patches(&seen);
|
|
assert_eq!(bodies.len(), 1, "{bodies:?}");
|
|
let set: Vec<(&String, &serde_json::Value)> = bodies[0]
|
|
.as_object()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|(_, v)| !v.is_null())
|
|
.collect();
|
|
assert_eq!(set, [(&"admin".to_owned(), &serde_json::Value::Bool(true))]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn make_site_admin_leaves_an_admin_alone() {
|
|
let (client, seen) = stub_forge(
|
|
(404, "{}"),
|
|
(200, r#"{"login":"mara","is_admin":true}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
let plan = client.make_site_admin("mara").await.unwrap();
|
|
|
|
assert_eq!(plan, site_admin::Plan::AlreadyAdmin);
|
|
assert!(patches(&seen).is_empty());
|
|
}
|
|
|
|
/// A user who has not logged in yet is reported, not created: nothing is
|
|
/// sent but the read.
|
|
#[tokio::test]
|
|
async fn make_site_admin_does_not_create_a_missing_user() {
|
|
let (client, seen) = stub_forge(
|
|
(201, "{}"),
|
|
(404, r#"{"message":"user does not exist"}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
|
|
let plan = client.make_site_admin("mara").await.unwrap();
|
|
|
|
assert_eq!(plan, site_admin::Plan::NoSuchUser);
|
|
let methods: Vec<String> = seen
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(m, _, _)| m.clone())
|
|
.collect();
|
|
assert_eq!(methods, ["GET"]);
|
|
}
|
|
}
|