Watch
0
0
Fork
You've already forked hyperhive
0

review try-out: open a PR with the classifications on the page

The try-out page gets an "open a PR" button. It posts the page's
classifications to the new POST /api/review-classifier/open-pr, which
merges them by name into the repo's rules file on the default branch
and has the review-classifier forge account commit the result on a new
branch, review-classifier/<user>-<8 hex>, through the contents API. The
commit's author is the forge account named like the session's
Remote-User, read with the controller's admin token. The answer links
to the forge's compare page, where the user opens the PR as themselves.

The push uses the account's existing token from the store
(swarm/services/review-classifier/forge/token, scope write:repository),
so the account needs write access to the repo; a refused push answers
403 naming that. In a YAML rules file, the entries the merge leaves
alone keep their text, as long as the result reads back as the merged
list; otherwise the file is written out whole.

DefaultBranchFiles now carries each file's blob sha, which the forge
needs to replace a file, and the repo's web URL for the link.

Fixes #5259
Refs #5096
This commit is contained in:
atlas 2026-10-14 15:33:49 +02:00 • committed by mara
commit 49b15e7775
10 changed files with 1231 additions and 53 deletions

View file

@ -3,11 +3,14 @@
//! `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.
//! 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.
//! 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
@ -17,13 +20,16 @@
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use forgejo_api::structs::{CreatePullReviewOptions, RepoSearchQuery};
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};
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";
@ -31,8 +37,8 @@ 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.
/// 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.
@ -62,6 +68,29 @@ pub struct Review {
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<String>,
pub content: Vec<u8>,
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
@ -121,19 +150,8 @@ impl Client {
commit: Option<&str>,
review: Review,
) -> Result<Option<String>> {
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")?
self.as_classifier()
.await?
.repo_create_pull_review(
owner,
repo,
@ -150,6 +168,101 @@ impl Client {
.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<()> {
use base64::Engine as _;
let branch = commit.branch.clone();
self.as_classifier()
.await?
.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(())
}
/// The forge account `login` as a commit author: its full name, or its
/// login when it has none, and its email. `None` when the forge has no
/// such account. Read with this daemon's site-admin token, which sees an
/// email the account keeps private.
///
/// # Errors
/// When the forge refuses the read, or the account has no email.
pub async fn commit_author(&self, login: &str) -> Result<Option<CommitAuthor>> {
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 email = user
.email
.filter(|e| !e.trim().is_empty())
.with_context(|| format!("forge user {login} has no email"))?;
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<Forgejo> {
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")
}
/// The stored token at `path` and every token the forge lists for
/// [`LOGIN`], which must exist.
async fn read_classifier_token(
@ -306,6 +419,43 @@ mod tests {
assert!(lockdown_patch(&seen, LOGIN).is_none());
}
#[tokio::test]
async fn a_commit_author_is_the_accounts_name_and_email_or_none() {
use super::super::tests::stub_forge;
let (client, _) = stub_forge(
(404, "{}"),
(
200,
r#"{"login":"alice","full_name":" Alice A ","email":"a@x.test"}"#,
),
(404, "{}"),
)
.await;
assert_eq!(
client.commit_author("alice").await.expect("read"),
Some(CommitAuthor {
name: "Alice A".to_owned(),
email: "a@x.test".to_owned(),
})
);
let (client, _) = stub_forge(
(404, "{}"),
(200, r#"{"login":"bob","full_name":"","email":"b@x.test"}"#),
(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);
}
#[tokio::test]
async fn the_job_locks_down_the_classifiers_own_account() {
use super::super::tests::{lockdown_patch, stub_forge};