A failure reading the changed files (a forge error on a tree or blob, a truncated tree, a non-base64 blob) or joining the diff task made the PR read fail, so the review posted no verdict. It now falls back to the forge's 3-line .diff with "reading the files failed: …" as the note. render wrote each file's whole diff before wide compared it with MAX_BYTES. It now writes through a writer that refuses once past the cap, which stops similar's formatter mid-file, so one huge file costs about MAX_BYTES of output, not its full diff.
2769 lines
114 KiB
Rust
2769 lines
114 KiB
Rust
//! Swarm-controller's own forgejo client. Self-contained — deliberately
|
|
//! not sharing code with `hive-c0re::forge` across the crate boundary
|
|
//! (forcing that split now, over a handful of idempotent CRUD-ish
|
|
//! calls, is premature plumbing; factor out a shared crate later if the
|
|
//! duplication actually starts to hurt).
|
|
//!
|
|
//! The forge's URL comes from `swarm-controller.nix`'s `forgeEnv`
|
|
//! (`SWARM_CONTROLLER_FORGE_URL`). The token comes from the swarm's secret
|
|
//! store, at `swarm_secret_client::forge::controller_token_path`, read under
|
|
//! this daemon's own certificate and held only in memory. The forge mints it
|
|
//! inside its container (`hive-forge/default.nix`'s
|
|
//! `forgejo-swarm-controller-account`) and `swarm-bao-forge-publisher`, on the
|
|
//! forge's host, puts it there.
|
|
//!
|
|
//! [`enable`] fills the daemon's one client slot: at start when the token is
|
|
//! there, later from a background re-read when it is not. Every caller reads
|
|
//! the slot and treats an empty one as "no forge access here" — the same
|
|
//! graceful-absence shape the queue coordinates use — so the daemon serves
|
|
//! from the start and gains forge access without a restart. 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 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_queue_client::review_classifier::Commit;
|
|
use swarm_queue_client::stored_secret::StoredSecret;
|
|
|
|
pub mod agent_token;
|
|
mod api;
|
|
pub mod builder_account;
|
|
pub mod classifier_account;
|
|
pub mod docs_grant;
|
|
pub mod hive_token;
|
|
pub mod legacy_tokens;
|
|
pub mod objects;
|
|
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;
|
|
|
|
/// `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.
|
|
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}: swarm-bao-forge-publisher, on the host that runs the forge, has \
|
|
not published this daemon's forge token"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Typed forgejo client, built once at startup from env.
|
|
pub struct Client {
|
|
api: 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)
|
|
}
|
|
|
|
/// The longest the background re-read waits between attempts.
|
|
const BACKGROUND_CAP: std::time::Duration = std::time::Duration::from_mins(5);
|
|
|
|
/// How long the background re-read waits before its `n`-th attempt: 30s,
|
|
/// doubling, capped at [`BACKGROUND_CAP`].
|
|
fn background_delay(n: u32) -> std::time::Duration {
|
|
const FIRST_S: u64 = 30;
|
|
std::time::Duration::from_secs(
|
|
FIRST_S
|
|
.saturating_mul(1u64.checked_shl(n).unwrap_or(u64::MAX))
|
|
.min(BACKGROUND_CAP.as_secs()),
|
|
)
|
|
}
|
|
|
|
/// Whether the `n`-th failed attempt is logged at `info` rather than `debug`:
|
|
/// once the backoff is capped, so a controller that never gets its token says
|
|
/// so once per [`BACKGROUND_CAP`] without a line per early retry.
|
|
fn failure_logs_at_info(n: u32) -> bool {
|
|
background_delay(n) >= BACKGROUND_CAP
|
|
}
|
|
|
|
/// Fill `slot` with the first value `attempt` yields, sleeping
|
|
/// [`background_delay`] before each attempt. Never gives up. A failure is logged
|
|
/// as [`failure_logs_at_info`] says; the caller logs the first one and the
|
|
/// recovery.
|
|
async fn fill_when_ready<T, F, Fut>(slot: &OnceLock<T>, mut attempt: F) -> &T
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
let mut n = 0;
|
|
loop {
|
|
tokio::time::sleep(background_delay(n)).await;
|
|
match attempt().await {
|
|
Ok(value) => return slot.get_or_init(|| value),
|
|
Err(e) if failure_logs_at_info(n) => tracing::info!(
|
|
error = %format!("{e:#}"),
|
|
"still no forge access: the controller's forge token is unavailable"
|
|
),
|
|
Err(e) => tracing::debug!(
|
|
error = %format!("{e:#}"),
|
|
"the controller's forge token is still unavailable"
|
|
),
|
|
}
|
|
n = n.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
/// The longest one unit of forge work, a periodic pass or a job node, may run
|
|
/// before it is abandoned. forgejo-api's client has no request timeout, so
|
|
/// without this a forge that accepts a connection and never answers holds the
|
|
/// pass's loop, or the node, forever. Under every pass's 5-minute interval,
|
|
/// and far over what the longest node (`MintHiveForgeToken`, about fifteen
|
|
/// sequential forge and store requests) takes while it is making progress.
|
|
pub(crate) const FORGE_DEADLINE: std::time::Duration = std::time::Duration::from_mins(2);
|
|
|
|
/// Run `fut`, the forge work named `work` in the log, under
|
|
/// [`FORGE_DEADLINE`]. `None` means it was abandoned at the deadline; that is
|
|
/// logged here.
|
|
pub(crate) async fn within_forge_deadline<T>(
|
|
work: &str,
|
|
fut: impl std::future::Future<Output = T>,
|
|
) -> Option<T> {
|
|
let result = tokio::time::timeout(FORGE_DEADLINE, fut).await.ok();
|
|
if result.is_none() {
|
|
tracing::warn!(
|
|
work,
|
|
deadline_s = FORGE_DEADLINE.as_secs(),
|
|
"forge work: no answer within the deadline; abandoned"
|
|
);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Put a client for `url` into `slot` and hand it to `on_ready`: at once when
|
|
/// the store has the token, otherwise from a background task that re-reads it
|
|
/// until it does. Either way the daemon starts, and every forge-backed path
|
|
/// reads `slot`, so it answers "no forge" until the token lands and works
|
|
/// from then on.
|
|
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 the commit status named `context` on `sha` of `owner/repo` to
|
|
/// `state`, with `description` cut to [`STATUS_DESCRIPTION_MAX`]
|
|
/// characters.
|
|
///
|
|
/// # Errors
|
|
/// The forge refused the status.
|
|
pub async fn create_commit_status(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
sha: &str,
|
|
context: &str,
|
|
state: CommitStatusState,
|
|
description: &str,
|
|
) -> Result<()> {
|
|
let status = CreateStatusOption {
|
|
context: Some(context.to_owned()),
|
|
description: Some(status_description(description)),
|
|
state: Some(state),
|
|
target_url: None,
|
|
};
|
|
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: [`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 (merge_base, changed_files) = (pull.merge_base, 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 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}"))?
|
|
.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 forge names no 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.
|
|
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
|
for kind in DeliveryKind::ALL {
|
|
let target_url = kind.target_url(public_base);
|
|
let (event, scope) = match kind {
|
|
DeliveryKind::Knowledge => (
|
|
"push",
|
|
HookScope::Repo {
|
|
org: KNOWLEDGE_ORG,
|
|
repo: KNOWLEDGE_REPO,
|
|
},
|
|
),
|
|
DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }),
|
|
// Instance-wide: commit/push activity is not scoped to one
|
|
// org (unlike the two above) — it needs observing across
|
|
// every repo the forge hosts, per the "no metric exists for
|
|
// commits or pushes" tracker issue. Forgejo's
|
|
// "global (system) webhook" (`admin_create_hook`) is exactly
|
|
// this — the only scope in this API that fires for a repo
|
|
// in an org created after this hook was registered, with no
|
|
// per-org registration to keep in sync as orgs come and go.
|
|
DeliveryKind::VcsActivity => ("push", HookScope::Instance),
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_re_read_logs_at_info_only_once_the_backoff_is_capped() {
|
|
assert!((0..4).all(|n| !failure_logs_at_info(n)));
|
|
assert!(failure_logs_at_info(4));
|
|
assert!(failure_logs_at_info(40));
|
|
}
|
|
|
|
#[test]
|
|
fn the_background_re_read_backs_off_from_30s_to_a_5_minute_cap() {
|
|
let s = |n| background_delay(n).as_secs();
|
|
assert_eq!([s(0), s(1), s(2), s(3), s(4)], [30, 60, 120, 240, 300]);
|
|
assert_eq!(s(40), 300);
|
|
assert_eq!(s(u32::MAX), 300);
|
|
}
|
|
|
|
// Paused time: each sleep completes at once and advances the clock, so the
|
|
// elapsed time is the backoff schedule itself.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_token_absent_at_start_fills_the_slot_once_it_arrives() {
|
|
let slot = OnceLock::new();
|
|
let start = tokio::time::Instant::now();
|
|
let mut calls = 0;
|
|
let got = fill_when_ready(&slot, || {
|
|
calls += 1;
|
|
let n = calls;
|
|
async move {
|
|
if n < 3 {
|
|
anyhow::bail!("nothing at the controller's forge token path")
|
|
}
|
|
Ok("client")
|
|
}
|
|
})
|
|
.await;
|
|
assert_eq!(*got, "client");
|
|
assert_eq!(slot.get(), Some(&"client"));
|
|
assert_eq!(calls, 3);
|
|
assert_eq!(start.elapsed().as_secs(), 30 + 60 + 120);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_pass_that_never_answers_is_abandoned_at_the_deadline() {
|
|
let start = tokio::time::Instant::now();
|
|
let got = within_forge_deadline("test", std::future::pending::<()>()).await;
|
|
assert_eq!(got, None);
|
|
assert_eq!(start.elapsed(), FORGE_DEADLINE);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_pass_that_answers_returns_its_result() {
|
|
assert_eq!(within_forge_deadline("test", async { 7 }).await, Some(7));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_search_results_pages_past_a_full_first_page() {
|
|
#[derive(Clone)]
|
|
struct FakeRepo {
|
|
full_name: String,
|
|
open_issues: u32,
|
|
}
|
|
|
|
// Page 1 is exactly PAGE_SIZE long, which must not be mistaken for
|
|
// the last page — a repo living on page 2 still has to turn up.
|
|
const PAGE_SIZE: u32 = 2;
|
|
let pages = vec![
|
|
vec![
|
|
FakeRepo {
|
|
full_name: "org/a".to_owned(),
|
|
open_issues: 0,
|
|
},
|
|
FakeRepo {
|
|
full_name: "org/b".to_owned(),
|
|
open_issues: 0,
|
|
},
|
|
],
|
|
vec![FakeRepo {
|
|
full_name: "org/c".to_owned(),
|
|
open_issues: 3,
|
|
}],
|
|
];
|
|
let repos = Client::page_search_results(PAGE_SIZE, 10, |page| {
|
|
let pages = pages.clone();
|
|
async move { Ok(pages.get(page as usize - 1).cloned()) }
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let with_open_issues: Vec<_> = repos
|
|
.into_iter()
|
|
.filter(|r| r.open_issues > 0)
|
|
.map(|r| r.full_name)
|
|
.collect();
|
|
assert_eq!(with_open_issues, vec!["org/c".to_owned()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_search_results_errors_instead_of_looping_forever() {
|
|
// A server that never returns a short page (misbehaving, or an
|
|
// instance that genuinely outgrew `max_pages`) must fail loudly
|
|
// rather than paging forever or handing back a partial result.
|
|
const PAGE_SIZE: u32 = 2;
|
|
let result =
|
|
Client::page_search_results(
|
|
PAGE_SIZE,
|
|
3,
|
|
|_page| async move { Ok(Some(vec![1u32, 2])) },
|
|
)
|
|
.await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_walks_a_chain_not_just_the_direct_edge() {
|
|
// 3 depends on 2 depends on 1: reverse_adj is "who depends on me",
|
|
// so 1 -> [2], 2 -> [3]. Fixing 1 eventually frees both 2 and 3.
|
|
let adj = HashMap::from([(1, vec![2]), (2, vec![3])]);
|
|
assert_eq!(transitive_reach(1, &adj), 2);
|
|
assert_eq!(transitive_reach(2, &adj), 1);
|
|
assert_eq!(transitive_reach(3, &adj), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_counts_a_diamond_descendant_once() {
|
|
// 1 -> [2, 3], both 2 and 3 -> [4]: 4 is reachable via two paths
|
|
// but must only be counted once.
|
|
let adj = HashMap::from([(1, vec![2, 3]), (2, vec![4]), (3, vec![4])]);
|
|
assert_eq!(transitive_reach(1, &adj), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn transitive_reach_terminates_on_a_cycle() {
|
|
// 1 -> [2] -> [1]: nothing forge-side prevents a dependency cycle,
|
|
// so the walk has to survive one instead of looping forever.
|
|
let adj = HashMap::from([(1, vec![2]), (2, vec![1])]);
|
|
assert_eq!(transitive_reach(1, &adj), 1);
|
|
}
|
|
|
|
// The three cases from the `forge-422-fold` issue: a create call's
|
|
// 422 with the object confirmed absent must fail (case 1), the same
|
|
// 422 with the object confirmed present must fold to success (case
|
|
// 2), and a 409 must fold to success without needing a follow-up
|
|
// read at all (case 3).
|
|
#[test]
|
|
fn folds_into_success_case1_422_absent_fails() {
|
|
assert!(!folds_into_success(&validation_failed_error(), false));
|
|
}
|
|
|
|
#[test]
|
|
fn folds_into_success_case2_422_present_is_done() {
|
|
assert!(folds_into_success(&validation_failed_error(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn folds_into_success_case3_conflict_is_done() {
|
|
// `existing` is never consulted for a 409 — pass `false` to prove
|
|
// that, not just `true`.
|
|
assert!(folds_into_success(&conflict_error(), false));
|
|
}
|
|
|
|
// Invert-proof: the old `is_already_exists` folded every 422 into
|
|
// success regardless of whether the object existed — exactly the
|
|
// defect `folds_into_success` fixes. Demonstrate the old logic
|
|
// getting case 1 wrong before trusting the new one.
|
|
#[test]
|
|
fn old_is_already_exists_wrongly_returns_true_for_an_absent_object() {
|
|
let e = validation_failed_error();
|
|
assert!(is_already_exists(&e), "old logic: false positive on 422");
|
|
assert!(
|
|
!folds_into_success(&e, false),
|
|
"new logic: correctly refuses to fold a 422 with no confirmed existence"
|
|
);
|
|
}
|
|
|
|
/// What [`stub_forge`] saw: method, path and JSON body of each request.
|
|
pub(super) type Seen =
|
|
std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
|
|
|
|
/// A stand-in forge on a loopback port — never the real one — answering
|
|
/// the admin user create (`POST`) with `create`, the pre-lockdown
|
|
/// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with
|
|
/// `edit` (status, JSON body each), and recording every request.
|
|
pub(super) async fn stub_forge(
|
|
create: (u16, &'static str),
|
|
get: (u16, &'static str),
|
|
edit: (u16, &'static str),
|
|
) -> (Client, Seen) {
|
|
use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE};
|
|
|
|
let seen = Seen::default();
|
|
let recorded = seen.clone();
|
|
let app = axum::Router::new().fallback(
|
|
move |method: Method, uri: Uri, body: axum::body::Bytes| {
|
|
let recorded = recorded.clone();
|
|
async move {
|
|
let json = serde_json::from_slice(&body).unwrap_or_default();
|
|
recorded.lock().unwrap().push((
|
|
method.to_string(),
|
|
uri.path().to_owned(),
|
|
json,
|
|
));
|
|
let (status, body) = match method {
|
|
Method::POST => create,
|
|
Method::GET => get,
|
|
Method::PATCH => edit,
|
|
_ => (404, r#"{"message":"not stubbed"}"#),
|
|
};
|
|
let status = StatusCode::from_u16(status).unwrap();
|
|
(status, [(CONTENT_TYPE, "application/json")], body)
|
|
}
|
|
},
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
|
let api = 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)
|
|
}
|
|
|
|
/// 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. Records every request's path.
|
|
async fn pull_forge(
|
|
pull: &'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("[]".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)
|
|
}
|
|
|
|
#[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, 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, 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"
|
|
);
|
|
|
|
let no_base =
|
|
r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h1"},"changed_files":1}"#;
|
|
let (client, _) = pull_forge(no_base, 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 forge names no merge base")
|
|
);
|
|
|
|
let unreadable = r#"{"number":7,"requested_reviewers":[],"head":{"sha":"h2"},"merge_base":"mb","changed_files":1}"#;
|
|
let (client, _) = pull_forge(unreadable, 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",
|
|
"swarm-builder",
|
|
CommitStatusState::Failure,
|
|
"error: boom",
|
|
)
|
|
.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");
|
|
}
|
|
}
|