//! 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`). //! This daemon also pushes the try-out page's branches as the account //! (`crate::review_classifier::open_pr`). 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, and write to //! push a branch. //! //! 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::{ ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreatePullReviewOptions, Identity, 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, is_not_found}; /// 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 and push branches, both of 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, } /// Who a commit names as its author. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitAuthor { pub name: String, pub email: String, } /// One file written as one commit on a new branch. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BranchCommit { /// The branch the new one starts from, at its head. pub base: String, /// Must not exist yet: the forge refuses the commit when it does. pub branch: String, pub path: String, /// The blob sha of the file on `base` it replaces; `None` creates it. /// The forge refuses the commit when `base` no longer holds that blob. pub sha: Option, pub content: Vec, pub message: String, pub author: CommitAuthor, } 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. The posted review's URL, when the forge names one. /// /// # 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> { self.as_classifier() .await? .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}")) .map(|posted| posted.html_url.map(String::from)) } /// Make `commit` as [`LOGIN`]: one commit on a new branch, from the head /// of its base. Reads the token from the store on every call. /// /// # Errors /// When the store holds no token yet, or the forge refuses the commit; /// a [`forgejo_api::ForgejoError`] in the chain carries the forge's /// status. pub async fn push_classifier_branch( &self, owner: &str, repo: &str, commit: BranchCommit, ) -> Result<()> { push_branch(&self.as_classifier().await?, owner, repo, commit).await } /// The forge account `login` as a commit author: its full name, or its /// login when it has none, and its [`noreply_address`], so the commit /// links to the account without showing its email. `None` when the /// forge has no such account. /// /// # Errors /// When the forge refuses the read, or this daemon's forge URL has no /// host. pub async fn commit_author(&self, login: &str) -> Result> { let user = match self.api.call(|api| api.user_get(login).into_future()).await { Ok(user) => user, Err(e) if is_not_found(&e) => return Ok(None), Err(e) => return Err(e).with_context(|| format!("read forge user {login}")), }; let login = user.login.as_deref().unwrap_or(login); let email = noreply_address(&self.url, login)?; let name = user .full_name .map(|n| n.trim().to_owned()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| login.to_owned()); Ok(Some(CommitAuthor { name, email })) } /// A forge client acting as [`LOGIN`], with the token the store holds. async fn as_classifier(&self) -> 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::(&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") } /// 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, Vec, )> { 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 .call(|api| api.user_get(LOGIN).into_future()) .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 .call(|api| { api.admin_edit_user(LOGIN, super::repo_creation_lockdown(LOGIN)) .into_future() }) .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 { 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::(&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 } }) } } /// The address the forge itself puts on a web commit by `login` when the /// account keeps its email private: the lowercased login at /// `NO_REPLY_ADDRESS`. The forge module leaves that at its default, /// `noreply.`, and sets `DOMAIN` from /// `services.hyperhive.swarm.forge.domain`, the option this daemon's forge /// URL `forge` is built from, so the domain is that URL's host. fn noreply_address(forge: &url::Url, login: &str) -> Result { let domain = forge .host_str() .with_context(|| format!("the forge URL {forge} names no host"))?; Ok(format!("{}@noreply.{domain}", login.to_lowercase())) } /// Make `commit` with `forge`'s identity as committer, through the contents /// API. async fn push_branch(forge: &Forgejo, owner: &str, repo: &str, commit: BranchCommit) -> Result<()> { use base64::Engine as _; let branch = commit.branch.clone(); forge .repo_change_files( owner, repo, ChangeFilesOptions { author: Some(Identity { name: Some(commit.author.name), email: Some(commit.author.email), }), branch: Some(commit.base), committer: None, dates: None, files: vec![ChangeFileOperation { content: Some( base64::engine::general_purpose::STANDARD.encode(&commit.content), ), from_path: None, operation: if commit.sha.is_some() { ChangeFileOperationOperation::Update } else { ChangeFileOperationOperation::Create }, path: commit.path, sha: commit.sha, }], force_overwrite_new_branch: Some(false), message: Some(commit.message), new_branch: Some(commit.branch), signoff: None, }, ) .await .with_context(|| format!("pushing {LOGIN}'s branch {branch} to {owner}/{repo}"))?; Ok(()) } /// 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, 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()); } /// The one request `push_branch` sends for `sha`, as a stand-in forge /// saw it: method, path and JSON body. async fn pushed(sha: Option<&str>) -> (String, String, serde_json::Value) { use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE}; let seen = Arc::new(std::sync::Mutex::new(Vec::new())); let recorded = Arc::clone(&seen); let app = axum::Router::new().fallback( move |method: Method, uri: Uri, body: axum::body::Bytes| { let recorded = Arc::clone(&recorded); async move { recorded.lock().unwrap().push(( method.to_string(), uri.path().to_owned(), serde_json::from_slice(&body).unwrap_or_default(), )); ( StatusCode::CREATED, [(CONTENT_TYPE, "application/json")], r#"{"commit":{"sha":"c1"}}"#, ) } }, ); 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 forge = Forgejo::new(Auth::Token("stub-token"), url).unwrap(); let commit = BranchCommit { base: "main".to_owned(), branch: "review-classifier/alice-0a1b2c3d".to_owned(), path: ".hyperhive/review-classifier.yml".to_owned(), sha: sha.map(str::to_owned), content: b"classifications: []\n".to_vec(), message: "review classifier: add no-ads".to_owned(), author: CommitAuthor { name: "Alice".to_owned(), email: "alice@example.test".to_owned(), }, }; push_branch(&forge, "o", "r", commit).await.expect("pushed"); let mut seen = seen.lock().unwrap(); assert_eq!(seen.len(), 1, "{seen:?}"); seen.remove(0) } #[tokio::test] async fn a_push_is_one_commit_on_a_new_branch_authored_by_the_operator() { let (method, path, body) = pushed(Some("b1")).await; assert_eq!(method, "POST"); assert_eq!(path, "/api/v1/repos/o/r/contents"); assert_eq!(body["branch"], "main"); assert_eq!(body["new_branch"], "review-classifier/alice-0a1b2c3d"); assert_eq!(body["force_overwrite_new_branch"], false); assert_eq!(body["message"], "review classifier: add no-ads"); assert_eq!( body["author"], serde_json::json!({ "name": "Alice", "email": "alice@example.test" }) ); assert_eq!(body["committer"], serde_json::Value::Null); assert_eq!( body["files"], serde_json::json!([{ "operation": "update", "path": ".hyperhive/review-classifier.yml", "sha": "b1", // base64 of "classifications: []\n" "content": "Y2xhc3NpZmljYXRpb25zOiBbXQo=", "from_path": null, }]) ); } #[tokio::test] async fn a_push_without_a_blob_sha_creates_the_file() { let (_, _, body) = pushed(None).await; assert_eq!(body["files"][0]["operation"], "create"); assert_eq!(body["files"][0]["sha"], serde_json::Value::Null); } #[tokio::test] async fn a_commit_author_is_the_accounts_name_at_its_noreply_address_or_none() { use super::super::tests::stub_forge; let (client, _) = stub_forge( (404, "{}"), ( 200, r#"{"login":"Alice","full_name":" Alice A ","email":"private@x.test"}"#, ), (404, "{}"), ) .await; let domain = client.url.host_str().expect("a host").to_owned(); assert_eq!( client.commit_author("alice").await.expect("read"), Some(CommitAuthor { name: "Alice A".to_owned(), email: format!("alice@noreply.{domain}"), }) ); let (client, _) = stub_forge( (404, "{}"), (200, r#"{"login":"bob","full_name":"","email":""}"#), (404, "{}"), ) .await; let bob = client.commit_author("bob").await.expect("read"); assert_eq!(bob.map(|a| a.name).as_deref(), Some("bob")); let (client, _) = stub_forge( (404, "{}"), (404, r#"{"message":"user does not exist"}"#), (404, "{}"), ) .await; assert_eq!(client.commit_author("nobody").await.expect("read"), None); } #[test] fn the_noreply_address_is_the_lowercased_login_at_noreply_dot_the_forge_host() { let forge = url::Url::parse("https://Forge.Example:8443/").expect("a URL"); assert_eq!( noreply_address(&forge, "Alice-B").expect("a host"), "alice-b@noreply.forge.example" ); } #[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); } }