swarm-controller: post the review classifier's verdict on a review request
Requesting the forge account `review-classifier` as a reviewer now runs the repo's rules against the PR and posts the result as that account's review, on the head commit the run read. A push starts no run; only a fresh review request does. - An instance-wide `pull_request_review_request` system hook delivers to `/webhook/forge/review-request`. A request for any other reviewer, or a removed request, does nothing. - The controller reads the PR, loads the rules with the same reader as `GET /api/review-classifier/rules`, sends one request on the try-out subject, and posts APPROVED or REQUEST_CHANGES with the table as the body. - With no clean verdict (PR or rules don't load, the run fails, or any classification has no answer) it posts a COMMENT saying why, never an approval. The event is one function, `no_clean_verdict_event`. - Table cells are flattened, markup-escaped, and every `@` gets a zero-width space, so a model's reason can't break the table or mention anyone. - At most two requested reviews run at once; the rest wait in memory. - The account is minted the way hive accounts are: a five-minute pass queues a `MintClassifierForgeToken` node when the token at `swarm/services/review-classifier/forge/token` is missing or stale. The token is `write:repository`. The controller's bao policy gains that leaf (create/read/update), pinned in module-eval bao-grants. The classifier never sees the token. The account gets no repo access automatically; a repo admin adds it by hand. A re-request retires the account's earlier review: Forgejo clears `official` on a reviewer's earlier reviews when it is requested again (models/issues/review.go AddReviewRequest, v16.0.5), and approvals and rejection blocks count official reviews only. Refs #5137
This commit is contained in:
parent
ad47664e01
commit
a669cd96b6
17 changed files with 1156 additions and 50 deletions
328
swarm-controller/src/forge/classifier_account.rs
Normal file
328
swarm-controller/src/forge/classifier_account.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! The review classifier's forge account, [`LOGIN`]: created here with the
|
||||
//! admin API and given one token, stored at
|
||||
//! `swarm_secret_client::review_classifier::forge_token_path`. Requesting
|
||||
//! this account as a reviewer starts a classifier run, and this daemon posts
|
||||
//! the result as the account's review (`crate::review_classifier::review`).
|
||||
//! The classifier itself never holds the token.
|
||||
//!
|
||||
//! The account gets no repo access here. A repo admin adds it to a repo by
|
||||
//! hand: read to request it as a reviewer, write (or a place on the branch
|
||||
//! protection's approvals whitelist) for its approval to count.
|
||||
//!
|
||||
//! The token decision is [`super::agent_token`]'s, under this module's name
|
||||
//! and scopes, so a stored token the forge still lists with the same last
|
||||
//! eight characters and scopes is kept, and a pass whose reads fail queues
|
||||
//! nothing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use forgejo_api::structs::{CreatePullReviewOptions, RepoSearchQuery};
|
||||
use forgejo_api::{Auth, Forgejo};
|
||||
use swarm_secret_client::forge;
|
||||
use swarm_secret_client::review_classifier::forge_token_path;
|
||||
|
||||
use super::Client;
|
||||
use super::agent_token::{Decision, Observed, classify_named};
|
||||
|
||||
/// The classifier's forge login: the reviewer someone requests.
|
||||
pub const LOGIN: &str = "review-classifier";
|
||||
|
||||
/// The forge's name for the account's one token.
|
||||
pub const TOKEN_NAME: &str = "swarm-review-classifier";
|
||||
|
||||
/// What the token may do: post reviews, which the forge files under the
|
||||
/// repository scope. Which repos is the account's access, granted by hand.
|
||||
pub const TOKEN_SCOPES: &str = "write:repository";
|
||||
|
||||
/// How often [`spawn`] re-checks the account and its token.
|
||||
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||
|
||||
/// The email this module creates the account with, and the one it requires
|
||||
/// before acting on an account named [`LOGIN`]. Under its own domain, so it
|
||||
/// equals no agent's or hive's account email.
|
||||
const EMAIL: &str = "review-classifier@services.hyperhive.local";
|
||||
|
||||
/// Whether a pass that found `seen` queues a `MintClassifierForgeToken` job.
|
||||
/// [`Observed::Unknown`] never does, so a forge or store outage can't rotate
|
||||
/// the token, and neither does a [`Decision::Conflict`], which the job would
|
||||
/// refuse.
|
||||
pub fn needs_job(seen: Observed) -> bool {
|
||||
matches!(
|
||||
seen,
|
||||
Observed::NoForgeUser | Observed::Decided(Decision::Mint(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// One review as the classifier's account posts it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Review {
|
||||
/// `APPROVED`, `REQUEST_CHANGES` or `COMMENT`.
|
||||
pub event: &'static str,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Make sure [`LOGIN`] exists and holds a live [`TOKEN_NAME`] token in
|
||||
/// the store, minting one when it does not. The whole job of the
|
||||
/// `MintClassifierForgeToken` node.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store or the forge refuses a step, when an account named
|
||||
/// [`LOGIN`] exists with another email, or when the read-back does not
|
||||
/// show the new token.
|
||||
pub async fn ensure_classifier_forge_token(&self) -> Result<()> {
|
||||
let path = forge_token_path()?;
|
||||
self.ensure_classifier_user().await?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let (stored, listed) = self.read_classifier_token(&store, &path).await?;
|
||||
let reason = match classify_named(TOKEN_NAME, TOKEN_SCOPES, stored.as_ref(), &listed) {
|
||||
Decision::Keep => {
|
||||
tracing::debug!("review classifier forge token is current; left as it is");
|
||||
return Ok(());
|
||||
}
|
||||
Decision::Conflict => bail!(
|
||||
"the forge lists more than one {TOKEN_NAME:?} token for {LOGIN:?}; delete them \
|
||||
by id and re-run"
|
||||
),
|
||||
Decision::Mint(reason) => reason,
|
||||
};
|
||||
let credential = self
|
||||
.replace_token(&store, LOGIN, TOKEN_NAME, TOKEN_SCOPES, &listed, &path)
|
||||
.await?;
|
||||
|
||||
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
||||
.context("building a forge client with the new token")?
|
||||
.repo_search(RepoSearchQuery::default())
|
||||
.await
|
||||
.with_context(|| format!("using {LOGIN}'s new forge token"))?;
|
||||
let (stored, listed) = self.read_classifier_token(&store, &path).await?;
|
||||
let now = classify_named(TOKEN_NAME, TOKEN_SCOPES, stored.as_ref(), &listed);
|
||||
if now != Decision::Keep {
|
||||
bail!("{LOGIN}'s new forge token does not read back as current: {now:?}");
|
||||
}
|
||||
tracing::info!(?reason, %path, "review classifier forge token minted and stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Post `review` on PR `number` of `owner/repo` as [`LOGIN`], on
|
||||
/// `commit` when one is given. Reads the token from the store on every
|
||||
/// call.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store holds no token yet, or the forge refuses the review.
|
||||
pub async fn post_classifier_review(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
commit: Option<&str>,
|
||||
review: Review,
|
||||
) -> Result<()> {
|
||||
let path = forge_token_path()?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let Some(credential) = store
|
||||
.read_optional::<forge::Credential>(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?
|
||||
else {
|
||||
bail!("nothing at {path}: the classifier's forge token is not minted yet");
|
||||
};
|
||||
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
||||
.context("building a forge client with the classifier's token")?
|
||||
.repo_create_pull_review(
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
CreatePullReviewOptions {
|
||||
body: Some(review.body),
|
||||
comments: None,
|
||||
commit_id: commit.map(str::to_owned),
|
||||
event: Some(review.event.to_owned()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("posting {LOGIN}'s review on PR {number} of {owner}/{repo}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The stored token at `path` and every token the forge lists for
|
||||
/// [`LOGIN`], which must exist.
|
||||
async fn read_classifier_token(
|
||||
&self,
|
||||
store: &swarm_secret_client::SecretStore,
|
||||
path: &str,
|
||||
) -> Result<(
|
||||
Option<forge::Credential>,
|
||||
Vec<forgejo_api::structs::AccessToken>,
|
||||
)> {
|
||||
let stored = store
|
||||
.read_optional(path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let Some(listed) = self.list_agent_tokens(LOGIN).await? else {
|
||||
bail!("the forge has no user {LOGIN:?} after this job created it");
|
||||
};
|
||||
Ok((stored, listed))
|
||||
}
|
||||
|
||||
/// Create [`LOGIN`] if there is none, refuse one with another email, and
|
||||
/// lock it out of creating repos.
|
||||
async fn ensure_classifier_user(&self) -> Result<()> {
|
||||
self.create_user(LOGIN, EMAIL).await?;
|
||||
let user = self
|
||||
.api
|
||||
.user_get(LOGIN)
|
||||
.await
|
||||
.with_context(|| format!("read forge user {LOGIN}"))?;
|
||||
if user.email.as_deref() != Some(EMAIL) {
|
||||
bail!(
|
||||
"forge account `{LOGIN}` exists but is not the review classifier's (email {:?}, \
|
||||
expected `{EMAIL}`); refusing to mint a token for it",
|
||||
user.email
|
||||
);
|
||||
}
|
||||
self.api
|
||||
.admin_edit_user(LOGIN, super::repo_creation_lockdown(LOGIN))
|
||||
.await
|
||||
.with_context(|| format!("disable repo creation for forge user {LOGIN}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the store and the forge say about the account's token.
|
||||
async fn observe_classifier(&self) -> Result<Observed> {
|
||||
let path = forge_token_path()?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let stored = match store.read_optional::<forge::Credential>(&path).await {
|
||||
Ok(stored) => stored,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "review classifier forge token: store read failed");
|
||||
return Ok(Observed::Unknown);
|
||||
}
|
||||
};
|
||||
Ok(match self.list_agent_tokens(LOGIN).await {
|
||||
Ok(Some(listed)) => Observed::Decided(classify_named(
|
||||
TOKEN_NAME,
|
||||
TOKEN_SCOPES,
|
||||
stored.as_ref(),
|
||||
&listed,
|
||||
)),
|
||||
Ok(None) => Observed::NoForgeUser,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "review classifier forge token: forge read failed");
|
||||
Observed::Unknown
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the account now and every [`RECONCILE_INTERVAL`] after, and call
|
||||
/// `enqueue`, which inserts a `MintClassifierForgeToken` node, when it needs
|
||||
/// a job. A pass that fails is logged and retried on the next tick.
|
||||
pub fn spawn(client: Arc<Client>, enqueue: impl Fn() + Send + 'static) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Some(outcome) = super::within_forge_deadline(
|
||||
"review classifier forge token",
|
||||
client.observe_classifier(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match outcome {
|
||||
Ok(seen) if needs_job(seen) => {
|
||||
tracing::info!(?seen, "review classifier forge token: queueing a job");
|
||||
enqueue();
|
||||
}
|
||||
Ok(seen) => tracing::debug!(?seen, "review classifier forge token: no job"),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||
"review classifier forge token: pass failed; retrying next tick"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::agent_token::MintReason;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_token_writes_repositories_and_nothing_else() {
|
||||
assert_eq!(TOKEN_SCOPES, "write:repository");
|
||||
assert_ne!(TOKEN_NAME, super::super::agent_token::AGENT_TOKEN_NAME);
|
||||
assert_ne!(TOKEN_NAME, super::super::hive_token::HIVE_TOKEN_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mint_or_a_missing_account_queues_and_nothing_else_does() {
|
||||
assert!(needs_job(Observed::NoForgeUser));
|
||||
assert!(needs_job(Observed::Decided(Decision::Mint(
|
||||
MintReason::NotStored
|
||||
))));
|
||||
assert!(!needs_job(Observed::Decided(Decision::Keep)));
|
||||
assert!(!needs_job(Observed::Decided(Decision::Conflict)));
|
||||
assert!(!needs_job(Observed::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_accounts_email_is_no_agents() {
|
||||
assert_ne!(EMAIL, super::super::agent_email(LOGIN));
|
||||
assert_ne!(EMAIL, super::super::legacy_agent_email(LOGIN));
|
||||
}
|
||||
|
||||
/// An existing account named like the classifier with an agent's email
|
||||
/// is refused before the lockdown, so no token is minted for it.
|
||||
#[tokio::test]
|
||||
async fn the_job_refuses_an_account_that_is_not_the_classifiers() {
|
||||
use super::super::tests::{lockdown_patch, stub_forge};
|
||||
|
||||
let (client, seen) = stub_forge(
|
||||
(409, r#"{"message":"user already exists"}"#),
|
||||
(200, r#"{"email":"review-classifier@hyperhive.local"}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
let err = client
|
||||
.ensure_classifier_user()
|
||||
.await
|
||||
.expect_err("an agent's account is not the classifier's");
|
||||
assert!(format!("{err:#}").contains("not the review classifier's"));
|
||||
assert!(lockdown_patch(&seen, LOGIN).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_job_locks_down_the_classifiers_own_account() {
|
||||
use super::super::tests::{lockdown_patch, stub_forge};
|
||||
|
||||
let (client, seen) = stub_forge(
|
||||
(409, r#"{"message":"user already exists"}"#),
|
||||
(
|
||||
200,
|
||||
r#"{"email":"review-classifier@services.hyperhive.local"}"#,
|
||||
),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
client
|
||||
.ensure_classifier_user()
|
||||
.await
|
||||
.expect("the classifier's own account");
|
||||
let body = lockdown_patch(&seen, LOGIN).expect("no lockdown PATCH sent");
|
||||
assert_eq!(body["max_repo_creation"], 0);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue