Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge.rs

2882 lines
119 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 and puts it there (`hive-forge/default.nix`'s
//! `forgejo-swarm-controller-account`).
//!
//! [`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. Once filled, the
//! client reads the token again when the forge answers 401 and sends the
//! request once more with it (`forge/api.rs`), so a replaced token needs no restart
//! either.
use anyhow::{Context, Result};
use backon::{ExponentialBuilder, Retryable as _};
use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
ChangeFileOperationOperation, ChangeFilesOptions, CommitStatusState,
CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType,
CreateRepoOption, CreateStatusOption, CreateUserOption, EditUserOption, GetTreeQuery, GitEntry,
IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
RepoDownloadPullDiffOrPatchQuery, RepoGetContentsQuery, RepoGetPullRequestCommitsQuery,
RepoGetPullRequestFilesQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
RepoSearchQuery, StateType,
};
use forgejo_api::{ApiErrorKind, 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_secret_client::stored_secret::StoredSecret;
use swarm_wire::review_classifier::Commit;
pub mod agent_token;
mod api;
pub mod builder_account;
pub mod ci_metrics_account;
pub mod classifier_account;
pub mod docs_grant;
pub mod hive_token;
pub mod legacy_tokens;
pub mod objects;
pub mod operator_profiles;
pub mod pull_diff;
pub mod runner_token;
pub mod site_admin;
/// The longest commit status description [`Client::create_commit_status`]
/// sends, in characters. The forge keeps more; its PR view shows one line.
pub const STATUS_DESCRIPTION_MAX: usize = 200;
/// One commit status, as [`Client::create_commit_status`] writes it.
pub struct CommitStatus<'a> {
pub context: &'a str,
pub state: CommitStatusState,
/// Cut to [`STATUS_DESCRIPTION_MAX`] characters.
pub description: &'a str,
/// Where the status links to, if anywhere.
pub target_url: Option<&'a str>,
}
/// `description` cut to [`STATUS_DESCRIPTION_MAX`] characters, an ellipsis
/// marking a cut.
fn status_description(description: &str) -> String {
if description.chars().count() <= STATUS_DESCRIPTION_MAX {
return description.to_owned();
}
let mut cut: String = description
.chars()
.take(STATUS_DESCRIPTION_MAX - 1)
.collect();
cut.push('…');
cut
}
/// 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>,
}
/// An open PR as the review try-out's PR dropdown lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
pub struct OpenPull {
pub number: u64,
pub title: String,
/// The head commit the PR is at now. Absent only if Forgejo omitted it.
pub head_sha: Option<String>,
}
/// `pulls` as the dropdown's rows, newest (highest number) first. A PR
/// without a usable number cannot be named, so it is left out.
/// The merge base of a PR's head and base branch, read off its commits (the
/// forge lists them from the current head and base): the one parent that is
/// not itself a PR commit. `None` when the PR has no commits or its commits
/// reach more than one such parent (a merge of the base branch into the head).
///
/// The PR's own `merge_base` field is not used: the forge stores it and
/// refreshes it asynchronously, so right after a rebase or force-push it
/// still names the old base.
fn merge_base_of(commits: &[forgejo_api::structs::Commit]) -> Option<String> {
let own: HashSet<&str> = commits.iter().filter_map(|c| c.sha.as_deref()).collect();
let outside: HashSet<&str> = commits
.iter()
.flat_map(|c| c.parents.iter().flatten())
.filter_map(|p| p.sha.as_deref())
.filter(|sha| !own.contains(sha))
.collect();
let mut outside = outside.into_iter();
match (outside.next(), outside.next()) {
(Some(base), None) => Some(base.to_owned()),
_ => None,
}
}
fn open_pulls(pulls: Vec<forgejo_api::structs::PullRequest>) -> Vec<OpenPull> {
let mut rows: Vec<OpenPull> = pulls
.into_iter()
.filter_map(|pr| {
Some(OpenPull {
number: u64::try_from(pr.number?).ok()?,
title: pr.title.unwrap_or_default(),
head_sha: pr.head.and_then(|h| h.sha),
})
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.number));
rows
}
/// 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,
/// Why `diff` is the forge's `.diff`, with 3 lines of context, rather
/// than [`pull_diff::wide`]'s. `None` when it is the wide one.
pub narrowed: Option<String>,
}
/// Files on a repo's default branch, as [`Client::default_branch_files`] reads
/// them.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DefaultBranchFiles {
pub branch: String,
/// The commit the files were read at.
pub commit: String,
/// The repo's page on the forge, as a browser reaches it.
pub web_url: Option<url::Url>,
/// Each asked-for path that exists there, in the order asked.
pub files: Vec<BranchFile>,
}
/// One file of [`DefaultBranchFiles`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BranchFile {
pub path: String,
/// The blob's sha, which the forge asks for to replace the file.
pub sha: String,
pub bytes: Vec<u8>,
}
/// 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.
pub(crate) 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}: forgejo-swarm-controller-account, in the forge's container, has \
not published this daemon's forge token (if the store lost it, delete /var/lib/forgejo/\
swarm-controller-token-name in that container and restart the unit)"
),
}
}
/// Typed forgejo client, built once at startup from env.
pub struct Client {
api: api::Api,
/// 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)
}
/// How long the background re-read waits before its first attempt. The caller
/// has just made one, so an immediate retry would only repeat it.
const BACKGROUND_FIRST: std::time::Duration = std::time::Duration::from_secs(30);
/// The longest the background re-read waits between attempts.
const BACKGROUND_CAP: std::time::Duration = std::time::Duration::from_mins(5);
/// Fill `slot` with the first value `attempt` yields: after
/// [`BACKGROUND_FIRST`], then at 60s, doubling, capped at [`BACKGROUND_CAP`].
/// Never gives up. A failure is logged at `info` once the next wait is the cap,
/// so a controller that never gets its token says so once per
/// [`BACKGROUND_CAP`] without a line per early retry, and at `debug` before
/// that. The caller logs the first failure and the recovery.
async fn fill_when_ready<T, F, Fut>(slot: &OnceLock<T>, attempt: F) -> &T
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
tokio::time::sleep(BACKGROUND_FIRST).await;
let value = attempt
.retry(
ExponentialBuilder::default()
.with_min_delay(BACKGROUND_FIRST * 2)
.with_max_delay(BACKGROUND_CAP)
.without_max_times(),
)
.notify(|e, wait| {
if wait >= BACKGROUND_CAP {
tracing::info!(
error = %format!("{e:#}"),
"still no forge access: the controller's forge token is unavailable"
);
} else {
tracing::debug!(
error = %format!("{e:#}"),
"the controller's forge token is still unavailable"
);
}
})
.await
.expect("a retry with no cap on its tries returns only once it succeeds");
slot.get_or_init(|| value)
}
/// 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.
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, which reads it again when the forge refuses 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 = StoredSecret::new("swarm-controller-forge", fetch_token().await?, || async {
fetch_token().await.map_err(Into::into)
});
let api = api::Api::new(url.clone(), token).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
.call(|api| {
api.create_org_repo(CONFIG_ORG, Self::repo_option(name, true))
.into_future()
})
.await
{
Ok(_) => {
tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}");
Ok(())
}
Err(e) => {
let existing = is_ambiguous_validation_failure(&e)
&& self
.api
.call(|api| api.repo_get(CONFIG_ORG, name).into_future())
.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
.call(|api| {
api.repo_add_collaborator(
owner,
repo,
user,
AddCollaboratorOption {
permission: Some(permission),
},
)
.into_future()
})
.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
.call(|api| {
api.repo_create_branch_protection(CONFIG_ORG, repo, rule.clone())
.into_future()
})
.await
else {
tracing::info!(%repo, "swarm forge: applied operator branch protection");
return Ok(());
};
match self
.api
.call(|api| {
api.repo_get_branch_protection(CONFIG_ORG, repo, "main")
.into_future()
})
.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
.call(|api| api.user_get(agent).into_future())
.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
.call(|api| {
api.admin_edit_user(agent, repo_creation_lockdown(agent))
.into_future()
})
.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
.call(|api| {
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.clone()),
restricted: None,
send_notify: None,
source_id: None,
username: login.to_owned(),
visibility: None,
})
.into_future()
})
.await;
match res {
Ok(_) => {
tracing::info!(%login, "swarm forge: created forge user");
Ok(())
}
Err(e) => {
let existing = is_ambiguous_validation_failure(&e)
&& self
.api
.call(|api| api.user_get(login).into_future())
.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
.call(|api| {
api.repo_change_files(
CONFIG_ORG,
repo,
ChangeFilesOptions {
author: None,
branch: None,
committer: None,
dates: None,
files: files.clone(),
force_overwrite_new_branch: None,
message: Some("swarm-controller: seed agent config".to_owned()),
new_branch: None,
signoff: None,
},
)
.into_future()
})
.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
.call(|api| api.repo_get(CONFIG_ORG, repo).into_future())
.await
.with_context(|| format!("get repo {CONFIG_ORG}/{repo}"))?
.empty
.unwrap_or(false);
if is_empty {
return Ok(false);
}
match self
.api
.call(|api| {
api.repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default())
.into_future()
})
.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
.call(|api| Box::pin(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
.call(|api| {
Box::pin(
api.repo_list_pull_requests(CONFIG_ORG, agent, query.clone())
.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>> {
Ok(Self::full_names_where(self.search_repos().await?, |r| {
r.open_issues_count.unwrap_or(0) > 0
}))
}
/// Repos with at least one open pull request, as `owner/name` full
/// names — the data source for the review try-out's repo dropdown. Same
/// one-request-per-page shape as [`Self::list_repos_with_open_issues`],
/// filtered on `Repository::open_pr_counter`.
pub async fn list_repos_with_open_pulls(&self) -> Result<Vec<String>> {
Ok(Self::full_names_where(self.search_repos().await?, |r| {
r.open_pr_counter.unwrap_or(0) > 0
}))
}
/// The `owner/name` of each repo in `repos` that `keep` accepts.
fn full_names_where(
repos: Vec<forgejo_api::structs::Repository>,
keep: impl Fn(&forgejo_api::structs::Repository) -> bool,
) -> Vec<String> {
repos
.into_iter()
.filter(|r| keep(r))
.filter_map(|r| r.full_name)
.collect()
}
/// Every repo the forge hosts, from the search endpoint.
async fn search_repos(&self) -> Result<Vec<forgejo_api::structs::Repository>> {
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
.call(|api| {
api.repo_search(RepoSearchQuery::default())
.page(page)
.page_size(PAGE_SIZE)
.into_future()
})
.await
.context("search repos")?
.data)
})
.await?;
Ok(repos)
}
/// The open pull requests of `owner/repo`, newest first — the data
/// source for the review try-out's PR dropdown. A Forgejo 404 stays in
/// the error chain for [`is_repo_not_found`] to find.
pub async fn list_open_pulls(&self, owner: &str, repo: &str) -> Result<Vec<OpenPull>> {
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
sort: None,
milestone: None,
labels: None,
poster: None,
base: None,
head: None,
};
let pulls = self
.api
.call(|api| {
Box::pin(
api.repo_list_pull_requests(owner, repo, query.clone())
.all(),
)
})
.await
.with_context(|| format!("list open pull requests in {owner}/{repo}"))?;
Ok(open_pulls(pulls))
}
/// 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
.call(|api| {
Box::pin(
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
.call(|api| {
api.issue_list_issue_dependencies(owner, repo, number)
.into_future()
})
.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())
}
/// Set `status` on `sha` of `owner/repo`.
///
/// # Errors
/// The forge refused the status.
pub async fn create_commit_status(
&self,
owner: &str,
repo: &str,
sha: &str,
status: CommitStatus<'_>,
) -> Result<()> {
let context = status.context;
let status = CreateStatusOption {
context: Some(context.to_owned()),
description: Some(status_description(status.description)),
state: Some(status.state),
target_url: status.target_url.map(str::to_owned),
};
self.api
.call(|api| {
api.repo_create_status(owner, repo, sha, status.clone())
.into_future()
})
.await
.with_context(|| format!("set the {context} status on {sha} of {owner}/{repo}"))?;
Ok(())
}
/// 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 from the merge base ([`merge_base_of`]): [`pull_diff::wide`]'s, or the
/// forge's `.diff` when the wide one is not read, cannot be read, or is
/// too large.
///
/// # 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
.call(|api| api.repo_get_pull_request(owner, repo, number).into_future())
.await
.with_context(|| format!("read PR {number} of {owner}/{repo}"))?;
let changed_files = pull.changed_files;
let head_sha = pull
.head
.and_then(|h| h.sha)
.with_context(|| format!("PR {number} of {owner}/{repo} names no head commit"))?;
let listed_commits = self
.api
.call(|api| {
Box::pin(
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}"))?;
let merge_base = merge_base_of(&listed_commits);
let commits = listed_commits
.into_iter()
.map(|c| Commit {
sha: c.sha.unwrap_or_default(),
message: c.commit.and_then(|m| m.message).unwrap_or_default(),
})
.collect();
let count = changed_files.map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
let wide = match (pull_diff::too_many_files(count), merge_base) {
(Some(reason), _) => Err(reason),
(None, None) => Err("the PR's commits do not name a single merge base".to_owned()),
(None, Some(base)) => {
match self
.changed_files(owner, repo, number, &base, &head_sha)
.await
{
Ok(files) => tokio::task::spawn_blocking(move || pull_diff::wide(&files))
.await
.unwrap_or_else(|e| Err(format!("building the wide diff failed: {e}"))),
Err(e) => Err(format!("reading the files failed: {e:#}")),
}
}
};
let (diff, narrowed) = match wide {
Ok(diff) => (diff, None),
Err(reason) => {
tracing::info!(
%owner, %repo, pr = number, %reason,
"the review classifier reads the forge's 3-line diff"
);
(self.forge_diff(owner, repo, number).await?, Some(reason))
}
};
Ok(PullForReview {
head_sha,
commits,
diff,
narrowed,
})
}
/// The forge's own `.diff` of PR `number`, with 3 lines of context.
async fn forge_diff(&self, owner: &str, repo: &str, number: i64) -> Result<String> {
self.api
.call(|api| {
api.repo_download_pull_diff_or_patch(
owner,
repo,
number,
"diff",
RepoDownloadPullDiffOrPatchQuery::default(),
)
.into_future()
})
.await
.with_context(|| format!("read the diff of PR {number} of {owner}/{repo}"))
}
/// Each file PR `number` changes, with its bytes at `base` and at `head`.
/// A renamed file is read at its old path on the `base` side.
async fn changed_files(
&self,
owner: &str,
repo: &str,
number: i64,
base: &str,
head: &str,
) -> Result<Vec<pull_diff::FileChange>> {
let listed = self
.api
.call(|api| {
Box::pin(
api.repo_get_pull_request_files(
owner,
repo,
number,
RepoGetPullRequestFilesQuery::default(),
)
.all(),
)
})
.await
.with_context(|| format!("list the files of PR {number} of {owner}/{repo}"))?;
let mut trees = HashMap::new();
let mut files = Vec::with_capacity(listed.len());
for file in listed {
let new_path = file
.filename
.with_context(|| format!("a file of PR {number} of {owner}/{repo} has no name"))?;
let old_path = file.previous_filename.unwrap_or_else(|| new_path.clone());
let old = self
.file_at(owner, repo, base, &old_path, &mut trees)
.await?;
let new = self
.file_at(owner, repo, head, &new_path, &mut trees)
.await?;
files.push(pull_diff::FileChange {
old_path,
new_path,
old: old.map(|(_, bytes)| bytes),
new: new.map(|(_, bytes)| bytes),
});
}
Ok(files)
}
/// The `paths` of `owner/repo` that exist on its default branch, all
/// read at the branch's head commit. `None` when the default branch has
/// no commit: an empty repo, or a default branch that doesn't exist.
///
/// The files come through the git tree and blob API, one path segment
/// per request: forgejo-api escapes the `/` of a nested path in its
/// file endpoints.
///
/// # Errors
/// A forge failure, or a tree the forge truncated. A [`ForgejoError`] 404
/// underneath comes only from the first call, and means the repo does
/// not exist ([`is_repo_not_found`]).
pub async fn default_branch_files(
&self,
owner: &str,
repo: &str,
paths: &[&str],
) -> Result<Option<DefaultBranchFiles>> {
let info = self
.api
.call(|api| api.repo_get(owner, repo).into_future())
.await
.with_context(|| format!("get repo {owner}/{repo}"))?;
if info.empty.unwrap_or(false) {
return Ok(None);
}
let branch = info
.default_branch
.with_context(|| format!("{owner}/{repo} names no default branch"))?;
let head = match self
.api
.call(|api| api.repo_get_branch(owner, repo, &branch).into_future())
.await
{
Ok(head) => head,
Err(e) if is_repo_not_found(&e) => return Ok(None),
Err(e) => {
return Err(e).with_context(|| format!("read branch {branch} of {owner}/{repo}"));
}
};
let commit = head
.commit
.and_then(|c| c.id)
.with_context(|| format!("branch {branch} of {owner}/{repo} names no commit"))?;
let mut trees = HashMap::new();
let mut files = Vec::new();
for path in paths {
if let Some((sha, bytes)) = self.file_at(owner, repo, &commit, path, &mut trees).await?
{
files.push(BranchFile {
path: (*path).to_owned(),
sha,
bytes,
});
}
}
Ok(Some(DefaultBranchFiles {
branch,
commit,
web_url: info.html_url,
files,
}))
}
/// The blob sha and bytes of the file at `path` in `owner/repo` at
/// `commit`, or `None` when no blob is there. `trees` holds the tree
/// listings already read, by sha.
async fn file_at(
&self,
owner: &str,
repo: &str,
commit: &str,
path: &str,
trees: &mut HashMap<String, Vec<GitEntry>>,
) -> Result<Option<(String, Vec<u8>)>> {
let mut tree = commit.to_owned();
let mut segments = path.split('/').peekable();
while let Some(name) = segments.next() {
let kind = if segments.peek().is_some() {
"tree"
} else {
"blob"
};
let entries = self.tree(owner, repo, &tree, trees).await?;
let Some(sha) = entries
.iter()
.find(|e| e.path.as_deref() == Some(name) && e.r#type.as_deref() == Some(kind))
.and_then(|e| e.sha.clone())
else {
return Ok(None);
};
if kind == "blob" {
return self
.blob(owner, repo, &sha)
.await
.map(|bytes| Some((sha, bytes)));
}
tree = sha;
}
Ok(None)
}
/// The entries of tree `sha` (or of a commit's root tree), read once
/// into `trees`.
async fn tree<'t>(
&self,
owner: &str,
repo: &str,
sha: &str,
trees: &'t mut HashMap<String, Vec<GitEntry>>,
) -> Result<&'t [GitEntry]> {
if !trees.contains_key(sha) {
let listing = self
.api
.call(|api| {
api.get_tree(owner, repo, sha, GetTreeQuery::default())
.into_future()
})
.await
.map_err(|e| anyhow::anyhow!("read tree {sha} of {owner}/{repo}: {e}"))?;
if listing.truncated.unwrap_or(false) {
anyhow::bail!(
"the forge truncated tree {sha} of {owner}/{repo}, so a missing entry can't \
be told from one past the cut"
);
}
trees.insert(sha.to_owned(), listing.tree.unwrap_or_default());
}
Ok(&trees[sha])
}
/// The bytes of blob `sha`.
async fn blob(&self, owner: &str, repo: &str, sha: &str) -> Result<Vec<u8>> {
use base64::Engine as _;
let blob = self
.api
.call(|api| api.get_blob(owner, repo, sha).into_future())
.await
.map_err(|e| anyhow::anyhow!("read blob {sha} of {owner}/{repo}: {e}"))?;
anyhow::ensure!(
blob.encoding.as_deref() == Some("base64"),
"blob {sha} of {owner}/{repo} is not base64 but {:?}",
blob.encoding
);
let content: String = blob
.content
.unwrap_or_default()
.split_ascii_whitespace()
.collect();
base64::engine::general_purpose::STANDARD
.decode(content)
.with_context(|| format!("decode blob {sha} of {owner}/{repo}"))
}
/// 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. A forge that does not answer within
/// [`FORGE_DEADLINE`] is a failure too.
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
within_forge_deadline(
"register swarm webhooks",
self.ensure_all_swarm_hooks(public_base, secret),
)
.await
.unwrap_or_else(|| {
anyhow::bail!(
"the forge did not answer within {}s",
FORGE_DEADLINE.as_secs()
)
})
}
async fn ensure_all_swarm_hooks(&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),
// Instance-wide too: any repo whose admin gave the
// classifier's account access can request its review.
DeliveryKind::ReviewRequest => ("pull_request_review_request", 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: &api::Api) -> Result<Vec<String>, ForgejoError> {
let hooks = match self {
Self::Repo { org, repo } => {
api.call(|api| Box::pin(api.repo_list_hooks(org, repo).all()))
.await?
}
Self::Org { org } => {
api.call(|api| api.org_list_hooks(org).into_future())
.await?
}
Self::Instance => api.call(|api| api.admin_list_hooks().into_future()).await?,
};
Ok(hooks
.iter()
.filter_map(|h| h.config.as_ref()?.get("url").cloned())
.collect())
}
async fn create_hook(
&self,
api: &api::Api,
hook: CreateHookOption,
) -> Result<(), ForgejoError> {
match self {
Self::Repo { org, repo } => api
.call(|api| api.repo_create_hook(org, repo, hook.clone()).into_future())
.await
.map(drop),
Self::Org { org } => api
.call(|api| api.org_create_hook(org, hook.clone()).into_future())
.await
.map(drop),
Self::Instance => api
.call(|api| api.admin_create_hook(hook.clone()).into_future())
.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 = crate::forge::api::tests::fixed(url.clone(), "stub-token");
Self { api, url }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_pulls_are_listed_newest_first_and_skip_one_with_no_number() {
// The generated type insists on `requested_reviewers` being present.
let pull = |mut pr: serde_json::Value| {
pr["requested_reviewers"] = serde_json::json!([]);
serde_json::from_value(pr).expect("a pull request")
};
let pulls = vec![
pull(serde_json::json!({"number": 7, "title": "older", "head": {"sha": "aaa"}})),
pull(serde_json::json!({"title": "no number", "head": {"sha": "bbb"}})),
pull(serde_json::json!({"number": 12, "title": "newer", "head": {"sha": "ccc"}})),
pull(serde_json::json!({"number": 9, "head": {}})),
];
let want = |number, title: &str, head_sha: Option<&str>| OpenPull {
number,
title: title.to_owned(),
head_sha: head_sha.map(str::to_owned),
};
assert_eq!(
open_pulls(pulls),
vec![
want(12, "newer", Some("ccc")),
want(9, "", None),
want(7, "older", Some("aaa")),
]
);
}
// 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);
}
}
// 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_token_that_stays_absent_is_retried_every_five_minutes() {
let slot = OnceLock::new();
let start = tokio::time::Instant::now();
let mut calls = 0;
fill_when_ready(&slot, || {
calls += 1;
let n = calls;
async move {
if n < 7 {
anyhow::bail!("nothing at the controller's forge token path")
}
Ok("client")
}
})
.await;
assert_eq!(calls, 7);
assert_eq!(
start.elapsed().as_secs(),
30 + 60 + 120 + 240 + 300 + 300 + 300
);
}
#[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));
}
/// A forge that accepts the connection and never answers fails the
/// registration at the deadline, so the caller's retry advances. The
/// paused clock jumps to the deadline once nothing else can run.
#[tokio::test(start_paused = true)]
async fn swarm_webhook_registration_fails_at_the_deadline_when_the_forge_never_answers() {
// Bound but never accepted: the handshake completes, no response comes.
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();
let client = Client::for_test_forge(url);
let start = tokio::time::Instant::now();
// The outer bound turns a missing deadline into a failure, not a hang.
let err = tokio::time::timeout(
FORGE_DEADLINE * 2,
client.ensure_swarm_webhooks("https://swarm.example", "s3cret"),
)
.await
.expect("registration returns by itself")
.expect_err("no answer is a failure");
assert_eq!(start.elapsed(), FORGE_DEADLINE);
assert!(format!("{err:#}").contains("did not answer"), "{err:#}");
}
#[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 = crate::forge::api::tests::fixed(url.clone(), "stub-token");
(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"]);
}
const RULES_YML: &str = ".hyperhive/review-classifier.yml";
const RULES_JSON: &str = ".hyperhive/review-classifier.json";
/// A stand-in forge holding `o/r` with default branch `main` at
/// `def456`. The branch lookup answers `branch`, and the commit's root
/// tree answers `root` (a 404 for `None`). The `.hyperhive` tree holds
/// only `review-classifier.yml`. Records every request's path.
async fn rules_forge(
branch: u16,
root: Option<&'static str>,
) -> (Client, Arc<std::sync::Mutex<Vec<String>>>) {
use axum::http::{StatusCode, Uri};
use base64::Engine as _;
let blob = format!(
r#"{{"sha":"b1","encoding":"base64","content":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode("classifications: []\n")
);
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded = Arc::clone(&seen);
let app = axum::Router::new().fallback(move |uri: Uri| {
let (recorded, blob) = (Arc::clone(&recorded), blob.clone());
async move {
recorded.lock().unwrap().push(uri.path().to_owned());
let (status, body) = match uri.path() {
"/api/v1/repos/o/r" => {
(200, r#"{"default_branch":"main","empty":false}"#.to_owned())
}
"/api/v1/repos/o/r/branches/main" if branch == 200 => (
200,
r#"{"name":"main","commit":{"id":"def456"}}"#.to_owned(),
),
"/api/v1/repos/o/r/branches/main" => (branch, r#"{"message":"no"}"#.to_owned()),
"/api/v1/repos/o/r/git/trees/def456" if root.is_some() => {
(200, root.unwrap_or_default().to_owned())
}
"/api/v1/repos/o/r/git/trees/t1" => (
200,
r#"{"sha":"t1","truncated":false,"tree":[
{"path":"review-classifier.yml","type":"blob","sha":"b1"}]}"#
.to_owned(),
),
"/api/v1/repos/o/r/git/blobs/b1" => (200, blob),
_ => (404, r#"{"message":"not found"}"#.to_owned()),
};
(StatusCode::from_u16(status).unwrap(), 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 = crate::forge::api::tests::fixed(url.clone(), "stub-token");
(Client { api, url }, seen)
}
/// One PR commit whose parent is `mb`.
const ON_MB: &str = r#"[{"sha":"c1","parents":[{"sha":"mb"}]}]"#;
/// A stand-in forge holding PR 7 of `o/r`, head `h1`, which changes
/// `m.nix` from `old` at merge base `mb` to `new`. `pull` is the PR's own
/// JSON, `commits` its commit list. Records every request's path.
async fn pull_forge(
pull: &'static str,
commits: &'static str,
old: &'static str,
new: &'static str,
) -> (Client, Arc<std::sync::Mutex<Vec<String>>>) {
use axum::http::Uri;
use base64::Engine as _;
let blob = |content: &str| {
format!(
r#"{{"encoding":"base64","content":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(content)
)
};
let tree = |sha: &str, blob: &str| {
format!(
r#"{{"sha":"{sha}","truncated":false,"tree":[
{{"path":"m.nix","type":"blob","sha":"{blob}"}}]}}"#
)
};
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded = Arc::clone(&seen);
let app = axum::Router::new().fallback(move |uri: Uri| {
let recorded = Arc::clone(&recorded);
let body = match uri.path() {
"/api/v1/repos/o/r/pulls/7" => Some(pull.to_owned()),
"/api/v1/repos/o/r/pulls/7/commits" => Some(commits.to_owned()),
"/api/v1/repos/o/r/pulls/7/files" => {
Some(r#"[{"filename":"m.nix","status":"modified"}]"#.to_owned())
}
"/api/v1/repos/o/r/pulls/7.diff" => Some("forge diff\n".to_owned()),
"/api/v1/repos/o/r/git/trees/mb" => Some(tree("mb", "bo")),
"/api/v1/repos/o/r/git/trees/h1" => Some(tree("h1", "bn")),
"/api/v1/repos/o/r/git/blobs/bo" => Some(blob(old)),
"/api/v1/repos/o/r/git/blobs/bn" => Some(blob(new)),
_ => None,
};
async move {
recorded.lock().unwrap().push(uri.path().to_owned());
let builder = axum::http::Response::builder();
let (builder, body) = match body {
Some(body) => {
let count = if body == "[]" { "0" } else { "1" };
(builder.status(200).header("x-total-count", count), body)
}
None => (builder.status(404), r#"{"message":"not found"}"#.to_owned()),
};
builder.body(axum::body::Body::from(body)).unwrap()
}
});
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 = crate::forge::api::tests::fixed(url.clone(), "stub-token");
(Client { api, url }, seen)
}
fn commit(sha: &str, parents: &[&str]) -> forgejo_api::structs::Commit {
let parents = parents
.iter()
.map(|p| format!(r#"{{"sha":"{p}"}}"#))
.collect::<Vec<_>>()
.join(",");
serde_json::from_str(&format!(r#"{{"sha":"{sha}","parents":[{parents}]}}"#)).unwrap()
}
#[test]
fn the_merge_base_is_the_one_parent_outside_the_pr() {
let linear = [commit("c2", &["c1"]), commit("c1", &["mb"])];
assert_eq!(merge_base_of(&linear).as_deref(), Some("mb"));
// A merge of the base branch into the head reaches two outside parents.
let merged = [commit("c2", &["c1", "main-tip"]), commit("c1", &["mb"])];
assert_eq!(merge_base_of(&merged), None);
assert_eq!(merge_base_of(&[]), None);
}
#[tokio::test]
async fn a_review_reads_the_wide_diff_from_the_merge_base_or_says_why_not() {
let old = "{\n config = lib.mkIf gate {\n a = 1;\n b = 2;\n c = 3;\n d = 4;\n e = 5;\n };\n}\n";
let new = "{\n config = lib.mkIf gate {\n a = 1;\n b = 2;\n c = 3;\n d = 4;\n };\n}\n";
let wide = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h1"},"merge_base":"mb","changed_files":1}"#;
let (client, _) = pull_forge(wide, ON_MB, old, new).await;
let read = client.pull_for_review("o", "r", 7).await.unwrap();
assert_eq!(read.narrowed, None);
assert!(
read.diff
.starts_with("diff --git a/m.nix b/m.nix\n--- a/m.nix\n+++ b/m.nix\n"),
"{}",
read.diff
);
assert!(
read.diff.contains("\n config = lib.mkIf gate {\n"),
"{}",
read.diff
);
assert!(read.diff.contains("\n- e = 5;\n"), "{}", read.diff);
let many = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h1"},"merge_base":"mb","changed_files":101}"#;
let (client, seen) = pull_forge(many, ON_MB, old, new).await;
let read = client.pull_for_review("o", "r", 7).await.unwrap();
assert_eq!(read.diff, "forge diff\n");
assert_eq!(
read.narrowed.as_deref(),
Some("the PR changes 101 files, more than the 100 a wide diff is read for")
);
assert!(
!seen.lock().unwrap().iter().any(|p| p.ends_with("/files")),
"no file is read for a PR past the limit"
);
// The PR record still names the base it was opened on; the commits
// name the current one.
let stale = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h1"},"merge_base":"old-base","changed_files":1}"#;
let (client, _) = pull_forge(stale, ON_MB, old, new).await;
let read = client.pull_for_review("o", "r", 7).await.unwrap();
assert_eq!(read.narrowed, None, "the diff is read from `mb`");
assert!(read.diff.contains("\n- e = 5;\n"), "{}", read.diff);
let no_commits = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h1"},"merge_base":"mb","changed_files":1}"#;
let (client, _) = pull_forge(no_commits, "[]", old, new).await;
let read = client.pull_for_review("o", "r", 7).await.unwrap();
assert_eq!(read.diff, "forge diff\n");
assert_eq!(
read.narrowed.as_deref(),
Some("the PR's commits do not name a single merge base")
);
let unreadable = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h2"},"merge_base":"mb","changed_files":1}"#;
let (client, _) = pull_forge(unreadable, ON_MB, old, new).await;
let read = client.pull_for_review("o", "r", 7).await.unwrap();
assert_eq!(read.diff, "forge diff\n");
let reason = read.narrowed.expect("a failed file read falls back");
assert!(
reason.starts_with("reading the files failed: read tree h2 of o/r"),
"{reason}"
);
}
/// A root tree with the `.hyperhive` tree and a blob beside it.
const ROOT: &str = r#"{"sha":"def456","truncated":false,"tree":[
{"path":"README.md","type":"blob","sha":"b0"},
{"path":".hyperhive","type":"tree","sha":"t1"}]}"#;
#[tokio::test]
async fn default_branch_files_reads_each_path_through_the_trees_at_the_branch_head() {
let (client, seen) = rules_forge(200, Some(ROOT)).await;
let read = client
.default_branch_files("o", "r", &[RULES_YML, RULES_JSON])
.await
.unwrap()
.expect("the branch has a commit");
assert_eq!(read.branch, "main");
assert_eq!(read.commit, "def456");
assert_eq!(
read.files,
[BranchFile {
path: RULES_YML.to_owned(),
sha: "b1".to_owned(),
bytes: b"classifications: []\n".to_vec(),
}]
);
// Each tree is read once for both paths.
let seen = seen.lock().unwrap();
let reads = |p: &str| seen.iter().filter(|s| *s == p).count();
assert_eq!(reads("/api/v1/repos/o/r/git/trees/def456"), 1, "{seen:?}");
assert_eq!(reads("/api/v1/repos/o/r/git/trees/t1"), 1, "{seen:?}");
}
#[tokio::test]
async fn a_repo_without_the_directory_has_no_files() {
let root = r#"{"sha":"def456","truncated":false,"tree":[
{"path":"README.md","type":"blob","sha":"b0"},
{"path":".hyperhive","type":"blob","sha":"b2"}]}"#;
let (client, _) = rules_forge(200, Some(root)).await;
let read = client
.default_branch_files("o", "r", &[RULES_YML])
.await
.unwrap()
.expect("the branch has a commit");
assert_eq!(read.files, []);
}
#[tokio::test]
async fn a_truncated_tree_is_an_error_not_a_missing_file() {
let root = r#"{"sha":"def456","truncated":true,"tree":[]}"#;
let (client, _) = rules_forge(200, Some(root)).await;
let err = client
.default_branch_files("o", "r", &[RULES_YML])
.await
.unwrap_err();
assert!(format!("{err:#}").contains("truncated"), "{err:#}");
}
#[tokio::test]
async fn a_missing_default_branch_is_no_commit_not_no_repo() {
let (client, _) = rules_forge(404, Some(ROOT)).await;
let read = client
.default_branch_files("o", "r", &[RULES_YML])
.await
.unwrap();
assert_eq!(read, None);
}
/// Only the repo lookup's 404 reads as "no such repo"; a failed branch
/// read or a tree the forge doesn't have is some other failure.
#[tokio::test]
async fn only_an_unknown_repo_is_a_forge_404() {
let no_repo = |e: &anyhow::Error| {
e.downcast_ref::<ForgejoError>()
.is_some_and(is_repo_not_found)
};
let (client, _) = rules_forge(500, Some(ROOT)).await;
let unknown = client
.default_branch_files("o", "elsewhere", &[])
.await
.unwrap_err();
assert!(no_repo(&unknown), "{unknown:#}");
let branch = client
.default_branch_files("o", "r", &[])
.await
.unwrap_err();
assert!(!no_repo(&branch), "{branch:#}");
let (client, _) = rules_forge(200, None).await;
let tree = client
.default_branch_files("o", "r", &[RULES_YML])
.await
.unwrap_err();
assert!(!no_repo(&tree), "{tree:#}");
}
#[test]
fn a_long_status_description_is_cut_with_an_ellipsis() {
assert_eq!(status_description("built"), "built");
let long = "é".repeat(STATUS_DESCRIPTION_MAX + 5);
let cut = status_description(&long);
assert_eq!(cut.chars().count(), STATUS_DESCRIPTION_MAX);
assert!(cut.ends_with('…'));
}
#[tokio::test]
async fn a_commit_status_is_posted_on_the_commit() {
let (client, seen) = stub_forge(
(201, r#"{"id":1}"#),
(404, r#"{"message":"not stubbed"}"#),
(404, r#"{"message":"not stubbed"}"#),
)
.await;
client
.create_commit_status(
CONFIG_ORG,
"atlas",
"abc",
CommitStatus {
context: "swarm-builder",
state: CommitStatusState::Failure,
description: "error: boom",
target_url: Some("https://swarm.example/api/builder/runs/r1"),
},
)
.await
.expect("the stub accepts the status");
let seen = seen.lock().unwrap();
let (method, path, body) = seen.first().expect("one request");
assert_eq!(method, "POST");
assert_eq!(path, "/api/v1/repos/agent-configs/atlas/statuses/abc");
assert_eq!(body["context"], "swarm-builder");
assert_eq!(body["state"], "failure");
assert_eq!(body["description"], "error: boom");
assert_eq!(
body["target_url"],
"https://swarm.example/api/builder/runs/r1"
);
}
}