Per mara's review call on this PR: "the view should be filled by a single backend call." AgentsPage.tsx was doing three fetches (/api/agents, /api/config-prs, /api/agents/status) and joining them client-side by name. Moves the config-PR join server-side instead: AgentStatusRow gains a config_pr field, populated by get_agents_status's handler from AppState::config_prs after agent_status::AgentStatusReader::view() returns - not inside that module, which has no forge client and stays that way (see the field's doc comment for why the handler is the right layer for this merge, not the reader). AgentsPage.tsx now does exactly one fetch and no client-side joining at all - the wire row is the table row. Dropped the separate AgentStatusRow TS interface (folded into AgentRow, which now mirrors the backend type field-for-field) and the /api/agents + /api/config-prs fetches entirely; neither is needed once /api/agents/status already returns every roster agent with its config PR attached. ConfigPrStatus gained Deserialize (previously Serialize-only) since AgentStatusRow derives both and a struct's derive requires every field to support it.
1113 lines
51 KiB
Rust
1113 lines
51 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).
|
|
//!
|
|
//! Reads its identity from the credentials `swarm-controller.nix`'s
|
|
//! `forgeEnv` wires in (`SWARM_CONTROLLER_FORGE_URL` +
|
|
//! `SWARM_CONTROLLER_FORGE_TOKEN_FILE`, the latter pointing at a
|
|
//! systemd `LoadCredential`-delivered file) — see that module and
|
|
//! `hive-forge/default.nix`'s `forgejo-swarm-controller-account` /
|
|
//! `hive-forge-swarm-controller-token` units for how the token itself
|
|
//! gets minted and delivered. Absent env (forge not configured on this
|
|
//! host) means [`Client::from_env`] returns `None` — every caller
|
|
//! treats that as "no forge access here" and continues, the same
|
|
//! graceful-absence shape the queue coordinates already use.
|
|
|
|
use anyhow::{Context, Result};
|
|
use forgejo_api::structs::{
|
|
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
|
|
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
|
|
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
|
|
CreateUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
|
RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
|
RepoSearchQuery, StateType,
|
|
};
|
|
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
|
use futures_util::{StreamExt as _, TryStreamExt as _};
|
|
use reqwest::StatusCode;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::webhook::DeliveryKind;
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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 `hive-c0re::forge::repos`
|
|
/// already (`ensure_operators_team`), not re-provisioned here. If that
|
|
/// assumption ever breaks (this becomes reachable before any hive's
|
|
/// `hive-c0re` has run its startup sweep), branch-protection creation
|
|
/// below will fail loudly rather than silently no-op — see
|
|
/// [`Client::create_repo`]'s doc comment.
|
|
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: `hive-c0re` provisions the
|
|
/// `operators` team in **both** orgs precisely so branch protection can be
|
|
/// applied in either.
|
|
const CONFIG_ORG: &str = "agent-configs";
|
|
|
|
/// The hive-wide knowledge repo, where the `push` hook lives. Same values as
|
|
/// `hive-c0re::workers::knowledge::{ORG, REPO}`.
|
|
const KNOWLEDGE_ORG: &str = "internal";
|
|
const KNOWLEDGE_REPO: &str = "knowledge";
|
|
|
|
/// This daemon's own forge account — same constant as
|
|
/// `swarm-controller.nix`'s `swarmControllerForgeUser` (`hive-forge/default.nix`).
|
|
/// Needed in the branch-protection push whitelist (see
|
|
/// [`Client::apply_operator_branch_protection`]): being a site admin does
|
|
/// not exempt a caller from a protected branch's push check on the
|
|
/// content-edit API path (`repo_change_files`, what [`Client::seed_agent_config`]
|
|
/// uses) the way it does for a real `git push` — confirmed live against a
|
|
/// re-test where the account was already promoted to admin and the seed
|
|
/// commit still failed `ErrUserCannotCommit`. Whitelisting the account by
|
|
/// name is the same shape a git-push-based seed gets for free from the
|
|
/// admin bypass, without adding a `git` shell-out to this daemon.
|
|
const SWARM_CONTROLLER_FORGE_USER: &str = "swarm-controller";
|
|
|
|
/// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here
|
|
/// once so [`Client::from_env`] and the nix module can't drift silently
|
|
/// — a rename on one side without the other fails loudly (env var
|
|
/// absent) rather than silently losing forge access.
|
|
const URL_ENV: &str = "SWARM_CONTROLLER_FORGE_URL";
|
|
const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE";
|
|
|
|
/// Typed forgejo client, built once at startup from env.
|
|
pub struct Client {
|
|
api: Forgejo,
|
|
}
|
|
|
|
impl Client {
|
|
/// Build from env. `Ok(None)` (not an error) when neither env var is
|
|
/// set — forge isn't configured on this host, which is a normal,
|
|
/// expected deployment shape (see the module doc comment), not a
|
|
/// failure. `Err` only for a genuine misconfiguration: one of the
|
|
/// two env vars present without the other, the token file unreadable,
|
|
/// or the URL/client failing to build — those ARE failures, because
|
|
/// they mean forge was *meant* to be configured here and isn't
|
|
/// usable, which should be loud rather than silently downgrading to
|
|
/// "no forge access."
|
|
pub fn from_env() -> Result<Option<Self>> {
|
|
let url = std::env::var(URL_ENV).ok();
|
|
let token_file = std::env::var(TOKEN_FILE_ENV).ok();
|
|
let (url, token_file) = match (url, token_file) {
|
|
(None, None) => return Ok(None),
|
|
(Some(url), Some(token_file)) => (url, token_file),
|
|
(Some(_), None) => {
|
|
anyhow::bail!("{URL_ENV} is set but {TOKEN_FILE_ENV} is not — partial forge config")
|
|
}
|
|
(None, Some(_)) => {
|
|
anyhow::bail!("{TOKEN_FILE_ENV} is set but {URL_ENV} is not — partial forge config")
|
|
}
|
|
};
|
|
let token = std::fs::read_to_string(&token_file)
|
|
.with_context(|| format!("read forge token from {token_file}"))?;
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
anyhow::bail!("forge token file {token_file} is empty");
|
|
}
|
|
let parsed_url =
|
|
url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?;
|
|
let api = Forgejo::new(Auth::Token(token), parsed_url).context("build forgejo client")?;
|
|
Ok(Some(Self { api }))
|
|
}
|
|
|
|
/// 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/422) is folded into success.
|
|
async fn ensure_org_repo(&self, name: &str) -> Result<()> {
|
|
match self
|
|
.api
|
|
.create_org_repo(CONFIG_ORG, Self::repo_option(name, true))
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}");
|
|
Ok(())
|
|
}
|
|
Err(e) if is_already_exists(&e) => {
|
|
tracing::debug!(%name, "swarm forge: repo already exists");
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e).with_context(|| format!("create repo {CONFIG_ORG}/{name}")),
|
|
}
|
|
}
|
|
|
|
/// Add `user` as a collaborator on `CONFIG_ORG/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(
|
|
&self,
|
|
repo: &str,
|
|
user: &str,
|
|
permission: AddCollaboratorOptionPermission,
|
|
) -> Result<()> {
|
|
let res = self
|
|
.api
|
|
.repo_add_collaborator(
|
|
CONFIG_ORG,
|
|
repo,
|
|
user,
|
|
AddCollaboratorOption {
|
|
permission: Some(permission),
|
|
},
|
|
)
|
|
.await;
|
|
match res {
|
|
Ok(()) => {}
|
|
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
|
|
Err(e) => {
|
|
return Err(e)
|
|
.with_context(|| format!("add collaborator {user} to {CONFIG_ORG}/{repo}"));
|
|
}
|
|
}
|
|
tracing::debug!(%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. Mirrors `hive-c0re::forge::repos::apply_operator_branch_protection`
|
|
/// exactly (same policy, same verify-don't-trust shape): 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.
|
|
///
|
|
/// Assumes [`OPERATORS_TEAM`] already exists in [`CONFIG_ORG`]
|
|
/// (provisioned by `hive-c0re::forge::repos::ensure_operators_team`
|
|
/// on its own startup sweep, not re-provisioned here) — if it
|
|
/// doesn't yet, this fails loudly rather than silently leaving the
|
|
/// repo unprotected, which is the correct failure mode for a
|
|
/// prerequisite that's supposed to already be there.
|
|
async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> {
|
|
let rule = CreateBranchProtectionOption {
|
|
apply_to_admins: None,
|
|
approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
|
approvals_whitelist_username: None,
|
|
block_on_official_review_requests: Some(true),
|
|
block_on_outdated_branch: None,
|
|
block_on_rejected_reviews: None,
|
|
branch_name: Some("main".to_owned()),
|
|
dismiss_stale_approvals: None,
|
|
enable_approvals_whitelist: Some(true),
|
|
enable_merge_whitelist: Some(true),
|
|
enable_push: Some(true),
|
|
enable_push_whitelist: Some(true),
|
|
enable_status_check: None,
|
|
ignore_stale_approvals: None,
|
|
merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
|
merge_whitelist_usernames: None,
|
|
protected_file_patterns: None,
|
|
push_whitelist_deploy_keys: None,
|
|
push_whitelist_teams: None,
|
|
push_whitelist_usernames: Some(vec![SWARM_CONTROLLER_FORGE_USER.to_owned()]),
|
|
require_signed_commits: None,
|
|
required_approvals: Some(1),
|
|
rule_name: None,
|
|
status_check_contexts: None,
|
|
unprotected_file_patterns: None,
|
|
};
|
|
let Err(create_err) = self
|
|
.api
|
|
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
|
|
.await
|
|
else {
|
|
tracing::info!(%repo, "swarm forge: applied operator branch protection");
|
|
return Ok(());
|
|
};
|
|
match self
|
|
.api
|
|
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
tracing::debug!(
|
|
%repo, create_error = %create_err,
|
|
"swarm forge: operator branch protection already present"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(check_err) => anyhow::bail!(
|
|
"branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
|
|
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Create `repo` in [`CONFIG_ORG`] and apply the operator merge gate to
|
|
/// its default branch — the whole job of the `CreateRepo` node.
|
|
/// Branch protection is folded in here rather than a separate node: it
|
|
/// has no independent retry value apart from the repo existing (there
|
|
/// is nothing useful to retry "branch protection on a repo that
|
|
/// doesn't exist yet"), unlike collaborator-add and config-seed, which
|
|
/// are genuinely separate operations against an already-existing repo.
|
|
/// Idempotent — safe to call again.
|
|
pub async fn create_repo(&self, repo: &str) -> Result<String> {
|
|
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 — 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 itself is a throwaway: 32 random bytes, generated once,
|
|
/// never persisted anywhere, and never needed again (unlike
|
|
/// `hive-c0re`'s CLI path, which can ask forgejo to `--random-password`
|
|
/// on its own, the HTTP admin API requires a real value up front — see
|
|
/// [`crate::webhook::generate_hex_secret`], reused here rather than
|
|
/// duplicated for the same reason a webhook secret and this password
|
|
/// are both "32 random bytes nothing reads back").
|
|
///
|
|
/// Idempotent: an existing user (409/422) is folded into success, same
|
|
/// as [`Self::ensure_org_repo`]. Deliberately does not attempt to align
|
|
/// the account's email or disable its own repo-creation rights the way
|
|
/// `hive-c0re`'s per-hive provisioning does (`ensure_user_email`,
|
|
/// `ensure_repo_creation_disabled`) — this account only exists so
|
|
/// `AddRepoMember` has something to add, and the agent's owning hive
|
|
/// still runs its own full provisioning pass once the agent actually
|
|
/// spawns there, which self-heals both of those.
|
|
pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> {
|
|
let password = crate::webhook::generate_hex_secret()
|
|
.context("generating a throwaway password for the agent's forge account")?;
|
|
let res = self
|
|
.api
|
|
.admin_create_user(CreateUserOption {
|
|
created_at: None,
|
|
email: format!("{agent}@hyperhive.local"),
|
|
full_name: None,
|
|
login_name: None,
|
|
must_change_password: Some(false),
|
|
password: Some(password),
|
|
restricted: None,
|
|
send_notify: None,
|
|
source_id: None,
|
|
username: agent.to_owned(),
|
|
visibility: None,
|
|
})
|
|
.await;
|
|
match res {
|
|
Ok(_) => {
|
|
tracing::info!(%agent, "swarm forge: created agent forge user");
|
|
Ok(())
|
|
}
|
|
Err(e) if is_already_exists(&e) => {
|
|
tracing::debug!(%agent, "swarm forge: agent forge user already exists");
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e).with_context(|| format!("create forge user {agent}")),
|
|
}
|
|
}
|
|
|
|
/// Seed `repo` with the two files every agent config repo needs:
|
|
/// `agent.nix` (the agent's own module) and `flake.nix` (the
|
|
/// boilerplate that lets the meta flake import this repo as a flake
|
|
/// input) — committed here in one atomic `repo_change_files` call
|
|
/// instead of a local `git commit` (this process has no working tree to
|
|
/// commit from — it only ever talks to the forge over HTTP). The whole
|
|
/// job of the `InitAgentConfigRepo` node.
|
|
///
|
|
/// **This is the agent's config, not a copy of it.** A hive told to
|
|
/// deploy the agent clones this repo
|
|
/// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing
|
|
/// the same files again locally; the byte-identical template in
|
|
/// `hive-c0re::lifecycle::setup::seed_template` is only reached when no
|
|
/// repo exists here to clone.
|
|
///
|
|
/// Idempotent by construction rather than by catching a conflict: an
|
|
/// unconditional `repo_change_files` against an already-seeded repo
|
|
/// would overwrite `agent.nix` with the seed content again, clobbering
|
|
/// any edits merged since — so this checks for `agent.nix`'s presence
|
|
/// first and treats it as "already seeded, nothing to do" rather than
|
|
/// re-committing over it.
|
|
pub async fn seed_agent_config(&self, repo: &str, agent: &str) -> Result<()> {
|
|
if self.file_exists(repo, "agent.nix").await? {
|
|
tracing::debug!(%repo, "swarm forge: config already seeded");
|
|
return Ok(());
|
|
}
|
|
let files = vec![
|
|
ChangeFileOperation {
|
|
content: Some(base64_encode(&initial_agent_nix(agent))),
|
|
from_path: None,
|
|
operation: ChangeFileOperationOperation::Create,
|
|
path: "agent.nix".to_owned(),
|
|
sha: None,
|
|
},
|
|
ChangeFileOperation {
|
|
content: Some(base64_encode(initial_flake_nix())),
|
|
from_path: None,
|
|
operation: ChangeFileOperationOperation::Create,
|
|
path: "flake.nix".to_owned(),
|
|
sha: None,
|
|
},
|
|
];
|
|
self.api
|
|
.repo_change_files(
|
|
CONFIG_ORG,
|
|
repo,
|
|
ChangeFilesOptions {
|
|
author: None,
|
|
branch: None,
|
|
committer: None,
|
|
dates: None,
|
|
files,
|
|
force_overwrite_new_branch: None,
|
|
message: Some("swarm-controller: seed agent config".to_owned()),
|
|
new_branch: None,
|
|
signoff: None,
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?;
|
|
tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix");
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether `path` exists on `repo`'s default branch — the idempotency
|
|
/// check [`Self::seed_agent_config`] uses instead of catching a
|
|
/// create-conflict, since `repo_change_files` has no
|
|
/// already-exists-is-fine status the way `create_org_repo` does.
|
|
///
|
|
/// `create_repo` makes `repo` with `auto_init: false` (see
|
|
/// [`Self::repo_option`]) — no commits, no `main` ref, nothing a
|
|
/// contents lookup can resolve. Forgejo doesn't 404 that the way it
|
|
/// does a missing path on a real ref: it returns `200` with a bare
|
|
/// JSON `[]`, which fails to deserialize into the single-object
|
|
/// `ContentsResponse` the crate expects for a file path ("invalid
|
|
/// length 0, expected struct `ContentsResponse` with 16 elements" —
|
|
/// serde reading the 0-element array as a positional struct). So a
|
|
/// freshly created, not-yet-seeded repo trips this as an opaque
|
|
/// deserialize error, not the `NOT_FOUND` branch below — confirmed
|
|
/// against a live re-test of the swarm agent-create flow tracked on
|
|
/// the forge. Short-circuit on `empty` first: an empty repo
|
|
/// obviously has no `agent.nix`, and skipping
|
|
/// the contents call for it sidesteps the malformed-response shape
|
|
/// entirely rather than trying to special-case parsing it.
|
|
async fn file_exists(&self, repo: &str, path: &str) -> Result<bool> {
|
|
let is_empty = self
|
|
.api
|
|
.repo_get(CONFIG_ORG, repo)
|
|
.await
|
|
.with_context(|| format!("get repo {CONFIG_ORG}/{repo}"))?
|
|
.empty
|
|
.unwrap_or(false);
|
|
if is_empty {
|
|
return Ok(false);
|
|
}
|
|
match self
|
|
.api
|
|
.repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default())
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false),
|
|
Err(e) => Err(e).with_context(|| format!("check for {path} in {CONFIG_ORG}/{repo}")),
|
|
}
|
|
}
|
|
|
|
/// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent
|
|
/// name. Mirrors `hive-c0re::forge::config_pr_poll::poll_open_config_prs`'s
|
|
/// scan shape (list repos in the org, list open PRs per repo) but returns
|
|
/// data instead of side-effecting an approval queue — this daemon has no
|
|
/// approval system of its own; it exists so `GET
|
|
/// /api/agents/{name}/config-pr` has something to answer from,
|
|
/// independent of any one hive being up.
|
|
///
|
|
/// A single repo's list failing does not fail the whole scan — logged and
|
|
/// skipped, so one flaky repo can't blank out every other agent's status.
|
|
pub async fn list_open_config_prs(
|
|
&self,
|
|
) -> Result<std::collections::HashMap<String, ConfigPrStatus>> {
|
|
let repos = self
|
|
.api
|
|
.org_list_repos(CONFIG_ORG)
|
|
.all()
|
|
.await
|
|
.with_context(|| format!("list repos in {CONFIG_ORG}"))?;
|
|
|
|
let mut out = std::collections::HashMap::new();
|
|
for repo in repos {
|
|
let Some(agent) = repo.name.as_deref() else {
|
|
continue;
|
|
};
|
|
let query = RepoListPullRequestsQuery {
|
|
state: Some(RepoListPullRequestsQueryState::Open),
|
|
sort: None,
|
|
milestone: None,
|
|
labels: None,
|
|
poster: None,
|
|
base: None,
|
|
head: None,
|
|
};
|
|
let prs = match self
|
|
.api
|
|
.repo_list_pull_requests(CONFIG_ORG, agent, query)
|
|
.all()
|
|
.await
|
|
{
|
|
Ok(prs) => prs,
|
|
Err(e) => {
|
|
tracing::debug!(%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 (the
|
|
// same assumption `hive-c0re`'s poller and the `MergeConfigPr`
|
|
// approval flow both make).
|
|
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)
|
|
}
|
|
|
|
/// 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 regardless
|
|
/// of how many repos exist.
|
|
///
|
|
/// One search page (forgejo's own default page size) — this binding's
|
|
/// `RepoSearchQuery` has no `page`/`limit` field to page through, unlike
|
|
/// the `(Headers, Vec<T>)`-shaped list endpoints elsewhere in this file
|
|
/// that `.all()` fully drains. Fine for admin-facing tooling at today's
|
|
/// repo count; revisit if an instance's repo count ever outgrows one
|
|
/// page.
|
|
pub async fn list_repos_with_open_issues(&self) -> Result<Vec<String>> {
|
|
let results = self
|
|
.api
|
|
.repo_search(RepoSearchQuery::default())
|
|
.await
|
|
.context("search repos")?;
|
|
Ok(results
|
|
.data
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|r| r.open_issues_count.unwrap_or(0) > 0)
|
|
.filter_map(|r| r.full_name)
|
|
.collect())
|
|
}
|
|
|
|
/// Every open issue in `owner/repo`, each with its dependency-derived
|
|
/// `blocked` / `depended_on_by_count` pre-resolved server-side — the
|
|
/// whole job behind `GET /api/repos/{org}/{repo}/issue-report`. Doing
|
|
/// this resolution client-side (one dependency lookup per issue, on
|
|
/// every page load) doesn't scale, which is the reason this endpoint
|
|
/// exists at all rather than swarm-ui resolving it itself per row.
|
|
///
|
|
/// Forgejo has no bulk or reverse dependency query — the same
|
|
/// limitation `hive-forge issue dependency` hits — so this is still one
|
|
/// `issue_list_issue_dependencies` call per open issue, just run with
|
|
/// bounded concurrency (`CONCURRENCY`) rather than one-at-a-time or all
|
|
/// at once, so a repo with hundreds of open issues doesn't serialize on
|
|
/// round-trips or fire them all in one burst.
|
|
///
|
|
/// `depended_on_by_count` is a reverse index built off that SAME
|
|
/// resolve pass, not a second query: every open issue's own dependency
|
|
/// list is already in hand to compute `blocked`, so counting how often
|
|
/// each open issue's number appears across all of them is pure
|
|
/// in-memory aggregation. A dependency on a closed issue, or one
|
|
/// outside this open-issue set, can never accrue a count here — it
|
|
/// isn't a row in the report either, so "how many rows depend on this
|
|
/// row" would have nothing to point at.
|
|
///
|
|
/// `transitively_blocks_count` walks the same `reverse_adj` (blocker
|
|
/// number → issues naming it as a dependency) from each issue — see
|
|
/// [`transitive_reach`] for how the walk itself is made
|
|
/// diamond/cycle-safe. `depended_on_by_count` is `reverse_adj[n].len()`;
|
|
/// this is everything reachable from `n`, not just the direct edge.
|
|
pub async fn issue_report(&self, owner: &str, repo: &str) -> Result<Vec<IssueReportRow>> {
|
|
const CONCURRENCY: usize = 8;
|
|
|
|
let issues = self
|
|
.api
|
|
.issue_list_issues(
|
|
owner,
|
|
repo,
|
|
IssueListIssuesQuery {
|
|
state: Some(IssueListIssuesQueryState::Open),
|
|
r#type: Some(IssueListIssuesQueryType::Issues),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.all()
|
|
.await
|
|
.with_context(|| format!("list open issues in {owner}/{repo}"))?;
|
|
|
|
// Numbers collected into an owned `Vec` first, rather than handing
|
|
// `stream::iter` a lazy `filter_map` over borrowed `issues`
|
|
// directly — the latter shape trips a "closure implementation of
|
|
// `FnOnce` is not general enough" HRTB inference error where this
|
|
// function is used as an axum handler (the `routes!` macro checks
|
|
// `Handler` for arbitrary request lifetimes), even though the
|
|
// closure itself is unremarkable.
|
|
let numbers: Vec<i64> = issues.iter().filter_map(|i| i.number).collect();
|
|
let deps: HashMap<i64, Vec<forgejo_api::structs::Issue>> =
|
|
futures_util::stream::iter(numbers)
|
|
.map(|number| async move {
|
|
let blockers = self
|
|
.api
|
|
.issue_list_issue_dependencies(owner, repo, number)
|
|
.await
|
|
.with_context(|| format!("list dependencies of {owner}/{repo}#{number}"))?;
|
|
Ok::<_, anyhow::Error>((number, blockers))
|
|
})
|
|
.buffer_unordered(CONCURRENCY)
|
|
.try_collect()
|
|
.await?;
|
|
|
|
let open_numbers: HashSet<i64> = issues.iter().filter_map(|i| i.number).collect();
|
|
let mut reverse_adj: HashMap<i64, Vec<i64>> = HashMap::new();
|
|
for (&number, blockers) in &deps {
|
|
for blocker in blockers {
|
|
if let Some(n) = blocker.number
|
|
&& open_numbers.contains(&n)
|
|
{
|
|
reverse_adj.entry(n).or_default().push(number);
|
|
}
|
|
}
|
|
}
|
|
|
|
let repo_full_name = format!("{owner}/{repo}");
|
|
Ok(issues
|
|
.into_iter()
|
|
.filter_map(|issue| {
|
|
let number = issue.number?;
|
|
let blocked = deps
|
|
.get(&number)
|
|
.is_some_and(|b| b.iter().any(|d| d.state == Some(StateType::Open)));
|
|
Some(IssueReportRow {
|
|
repo: repo_full_name.clone(),
|
|
number,
|
|
title: issue.title.unwrap_or_default(),
|
|
labels: issue
|
|
.labels
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|l| l.name)
|
|
.collect(),
|
|
assignees: issue
|
|
.assignees
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|a| a.login)
|
|
.collect(),
|
|
html_url: issue.html_url.map(|u| u.to_string()),
|
|
blocked,
|
|
depended_on_by_count: u32::try_from(
|
|
reverse_adj.get(&number).map_or(0, Vec::len),
|
|
)
|
|
.unwrap_or(u32::MAX),
|
|
transitively_blocks_count: u32::try_from(transitive_reach(
|
|
number,
|
|
&reverse_adj,
|
|
))
|
|
.unwrap_or(u32::MAX),
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// [`Self::issue_report`], fanned out over every repo
|
|
/// [`Self::list_repos_with_open_issues`] finds — the default,
|
|
/// no-repo-filter view. Each repo's report is resolved independently and
|
|
/// concurrently (same `CONCURRENCY` bound as the per-issue dependency
|
|
/// resolution inside `issue_report`, since this is the same
|
|
/// "N independent forge round-trips" shape one level up), then
|
|
/// flattened into one row set — `IssueReportRow::repo` is what lets a
|
|
/// caller tell which repo a given row came from.
|
|
///
|
|
/// One repo's report failing does not fail the whole scan — logged and
|
|
/// skipped, same best-effort shape [`Self::list_open_config_prs`] uses:
|
|
/// a flaky repo should not blank out every other repo's rows.
|
|
pub async fn issue_report_all(&self) -> Result<Vec<IssueReportRow>> {
|
|
const CONCURRENCY: usize = 4;
|
|
|
|
let repos = self.list_repos_with_open_issues().await?;
|
|
let rows: Vec<Vec<IssueReportRow>> = futures_util::stream::iter(repos)
|
|
.map(|full_name| async move {
|
|
let Some((owner, repo)) = full_name.split_once('/') else {
|
|
tracing::warn!(%full_name, "issue_report_all: repo full_name has no '/'");
|
|
return Vec::new();
|
|
};
|
|
match self.issue_report(owner, repo).await {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %format!("{e:#}"),
|
|
%full_name,
|
|
"issue_report_all: one repo's report failed, skipping it"
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
})
|
|
.buffer_unordered(CONCURRENCY)
|
|
.collect()
|
|
.await;
|
|
Ok(rows.into_iter().flatten().collect())
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **These are registered ALONGSIDE the per-hive hooks, not instead of
|
|
/// them.** Every hive keeps receiving and acting on its own deliveries
|
|
/// exactly as today; the controller receives a copy and (for now) logs
|
|
/// it. Taking the hive-side registration away is a later step, and it
|
|
/// has to be later: fan-out swarm→hive does not exist yet, so a hook
|
|
/// moved now would point at a receiver that forwards nowhere — silent on
|
|
/// both sides, indistinguishable from no activity.
|
|
///
|
|
/// ⛔ **No stale-hook deletion arm, unlike the two per-hive registrars
|
|
/// this otherwise mirrors.** Their arm deletes hooks matching their own
|
|
/// path with a foreign base; copying it here would delete the hives'
|
|
/// live hooks, which are not stale — they are the path still in
|
|
/// production. The controller only ever adds its own.
|
|
///
|
|
/// 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, the same best-effort shape `hive-c0re`
|
|
/// uses.
|
|
pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> {
|
|
for kind in DeliveryKind::ALL {
|
|
let target_url = kind.target_url(public_base);
|
|
let (event, scope) = match kind {
|
|
DeliveryKind::Knowledge => (
|
|
"push",
|
|
HookScope::Repo {
|
|
org: KNOWLEDGE_ORG,
|
|
repo: KNOWLEDGE_REPO,
|
|
},
|
|
),
|
|
DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }),
|
|
// Instance-wide: commit/push activity is not scoped to one
|
|
// org (unlike the two above) — it needs observing across
|
|
// every repo the forge hosts, per the "no metric exists for
|
|
// commits or pushes" tracker issue. Forgejo's
|
|
// "global (system) webhook" (`admin_create_hook`) is exactly
|
|
// this — the only scope in this API that fires for a repo
|
|
// in an org created after this hook was registered, with no
|
|
// per-org registration to keep in sync as orgs come and go.
|
|
DeliveryKind::VcsActivity => ("push", HookScope::Instance),
|
|
};
|
|
self.ensure_hook(&scope, &target_url, event, secret)
|
|
.await
|
|
.with_context(|| format!("register swarm webhook {target_url}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`]
|
|
/// so the org-scoped and repo-scoped calls do not each grow their own
|
|
/// copy of the list-then-create logic.
|
|
async fn ensure_hook(
|
|
&self,
|
|
scope: &HookScope<'_>,
|
|
target_url: &str,
|
|
event: &str,
|
|
secret: &str,
|
|
) -> Result<()> {
|
|
// ⚠️ Both non-matching arms log at a level the journal keeps, because
|
|
// this idempotency check fails *silently*: when the list step does
|
|
// not recognise a hook that is already there, the create below runs
|
|
// again, and that stays harmless only while the forge rejects the
|
|
// duplicate. A forge that accepts it leaves one extra hook per
|
|
// process start, each delivering on every event — and the create
|
|
// arm's line reads exactly like a first, correct registration.
|
|
//
|
|
// The two ways to reach a create have different causes, so they are
|
|
// separated: `listed=0` means the create is not landing in the set
|
|
// the list reads (a bucket or permission problem — see
|
|
// [`HookScope::extra_create_config`], which is where the instance
|
|
// scope's own version of that went wrong), a non-zero count with no
|
|
// match means the recorded url is not the one compared.
|
|
match scope.list_hook_urls(&self.api).await {
|
|
Ok(urls) => {
|
|
if urls.iter().any(|u| u == target_url) {
|
|
tracing::debug!(%target_url, "swarm forge: webhook already registered");
|
|
return Ok(());
|
|
}
|
|
tracing::info!(
|
|
%target_url,
|
|
listed = urls.len(),
|
|
"swarm forge: no listed hook matches; registering"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Best-effort, same as the per-hive registrars: 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, mirroring exactly where the per-hive registrars put
|
|
/// theirs — a hook on the wrong scope would never fire, and forgejo would
|
|
/// report that as a perfectly healthy hook with no deliveries.
|
|
enum HookScope<'a> {
|
|
Repo {
|
|
org: &'a str,
|
|
repo: &'a str,
|
|
},
|
|
Org {
|
|
org: &'a str,
|
|
},
|
|
/// Forgejo's "global (system) webhook" — fires for every repo in the
|
|
/// instance, in every org, present or future. The `admin_*` API
|
|
/// namespace; needs the same `write:admin` scope
|
|
/// `Client::ensure_agent_user` already requires on this token, **and**
|
|
/// the `is_system_webhook` config key from
|
|
/// [`Self::extra_create_config`] — that endpoint's default is the
|
|
/// *other* kind of admin hook.
|
|
Instance,
|
|
}
|
|
|
|
impl HookScope<'_> {
|
|
/// Config-map keys the create call needs beyond the shared ones.
|
|
///
|
|
/// Instance scope carries `is_system_webhook`, and it is load-bearing
|
|
/// twice over. `POST /admin/hooks` reads that key **out of the config
|
|
/// map** and defaults it to `false`, which makes a forgejo *default*
|
|
/// webhook — a template copied into repos created later, not a live
|
|
/// hook — while `GET /admin/hooks` returns only webhooks with the flag
|
|
/// set. Omitting it therefore breaks both halves at once: the hook is
|
|
/// not the instance-wide one this scope exists for, and
|
|
/// [`Self::list_hook_urls`] can never see it, so every process start
|
|
/// creates another one — twelve in a day, `listed=0` each time,
|
|
/// before this key was sent.
|
|
fn extra_create_config(&self) -> &'static [(&'static str, &'static str)] {
|
|
match self {
|
|
Self::Repo { .. } | Self::Org { .. } => &[],
|
|
Self::Instance => &[("is_system_webhook", "true")],
|
|
}
|
|
}
|
|
|
|
/// The `url` config value of every hook currently on this scope.
|
|
async fn list_hook_urls(&self, api: &Forgejo) -> Result<Vec<String>, ForgejoError> {
|
|
let hooks = match self {
|
|
Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?,
|
|
Self::Org { org } => api.org_list_hooks(org).send().await?,
|
|
Self::Instance => api.admin_list_hooks().await?,
|
|
};
|
|
Ok(hooks
|
|
.iter()
|
|
.filter_map(|h| h.config.as_ref()?.get("url").cloned())
|
|
.collect())
|
|
}
|
|
|
|
async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> {
|
|
match self {
|
|
Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop),
|
|
Self::Org { org } => api.org_create_hook(org, hook).await.map(drop),
|
|
Self::Instance => api.admin_create_hook(hook).await.map(drop),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Base64-encode `content` for a forgejo content-API call — every file
|
|
/// write on this API takes base64, never raw bytes.
|
|
fn base64_encode(content: &str) -> String {
|
|
use base64::Engine as _;
|
|
base64::engine::general_purpose::STANDARD.encode(content)
|
|
}
|
|
|
|
/// Per-agent NixOS module seed — same content
|
|
/// `hive-c0re::lifecycle::setup::initial_agent_nix` writes at the per-hive
|
|
/// level (this process has no access to that function across the crate
|
|
/// boundary, and it's three lines — not worth a shared crate for).
|
|
///
|
|
/// Deliberately does **not** record which hive the agent belongs to, even
|
|
/// though `POST /api/agents` now takes one: an agent's config should carry
|
|
/// no reference to the hive it runs on. The hive is an address the swarm
|
|
/// routes on, not a property of the agent — a
|
|
/// config that named its own hive would be a second place stating where
|
|
/// the agent lives, free to disagree with the queue that actually
|
|
/// delivers to it.
|
|
fn initial_agent_nix(name: &str) -> String {
|
|
format!(
|
|
"{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n",
|
|
)
|
|
}
|
|
|
|
/// Module-only flake seed, byte-identical to
|
|
/// `hive-c0re::lifecycle::setup::initial_flake_nix` — see that function's
|
|
/// doc comment for why the shape looks the way it does (`flakeInputs`
|
|
/// forwarding, meta-flake consumption). Not shared across the crate
|
|
/// boundary for the same reason [`initial_agent_nix`] isn't.
|
|
fn initial_flake_nix() -> &'static str {
|
|
"{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n"
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Both scenarios live in ONE test, not two, deliberately: `cargo
|
|
// test` runs tests in parallel by default, and two tests mutating
|
|
// the same process-global env vars 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 from_env_reads_both_vars_or_neither() {
|
|
// SAFETY: test-only mutation of env vars this module itself
|
|
// owns (`URL_ENV`/`TOKEN_FILE_ENV`), scoped to this one test
|
|
// function so there's no cross-test race (see comment above).
|
|
unsafe {
|
|
std::env::remove_var(URL_ENV);
|
|
std::env::remove_var(TOKEN_FILE_ENV);
|
|
}
|
|
assert!(Client::from_env().unwrap().is_none());
|
|
|
|
unsafe {
|
|
std::env::set_var(URL_ENV, "https://forge.example.invalid");
|
|
}
|
|
// Not `.unwrap_err()`: that needs `Client: Debug` for its panic
|
|
// message, and the wrapped `forgejo_api::Forgejo` doesn't derive
|
|
// it. Match directly instead of adding a `Debug` impl nothing
|
|
// else needs.
|
|
match Client::from_env() {
|
|
Err(e) => assert!(e.to_string().contains(TOKEN_FILE_ENV)),
|
|
Ok(_) => panic!("expected an error for a partial forge config"),
|
|
}
|
|
|
|
unsafe {
|
|
std::env::remove_var(URL_ENV);
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|