Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
d52e97f376 swarm-controller: read review rules through the git tree and blob API
The rules file is now read with the existing forgejo-api client: the
commit's root tree, then the `.hyperhive` tree, then the
`review-classifier.{yml,json}` blob, base64-decoded. Every request takes
single-segment path parameters, so nothing depends on how a nested path
is escaped, and the controller keeps one HTTP transport to the forge.
The second reqwest client, `Client::with_token` and `raw_file_url` are
gone.

A tree the forge truncated is an error, not a missing file. A 404 on a
tree or blob doesn't come back as a forge 404, so only the repo lookup's
404 reads as "no such repo".

The `is_repo_not_found` doc comment is back on its function.

Part of #5096.
2026-10-12 14:54:59 +02:00
atlas
2b1573c2ba swarm-controller: read review rules at a literal nested path
forgejo-api escapes the whole file path of a raw-file request, so the
rules file went out as `raw/.hyperhive%2Freview-classifier.yml` and
reached the forge only if every proxy and router in between decoded the
`%2F`. The controller now sends that one request itself: `raw_file_url`
escapes each path segment on its own and joins them with `/`, and
`forge::Client` holds a second HTTP client with the same token for it.

A 404 from the default-branch lookup now means "no commit on the
default branch", like an empty repo, instead of falling through to the
route's "no repo" answer. Only the repo lookup's 404 still means the
repo doesn't exist.

Tests cover the URL shape, the reads against a loopback stub forge
that serves only the literal path, the branch 404, 503 with no forge
access, 503 at the forge deadline on a paused clock, and a bad
polarity value.

Part of #5096.
2026-10-12 14:54:59 +02:00
atlas
16c7c249ef swarm-controller: read a repo's review rules from its default branch
`GET /api/review-classifier/rules?repo=owner/name` reads
`.hyperhive/review-classifier.yml` or `.json` from the repo's default
branch with the controller's forge token, on every request, and answers
the classifications in the try-out POST's shape, plus the file, the
branch and the commit it was read at. The swarm UI's try-out page loads
them into its editor (#5134).

The file is one `classifications` list of the try-out's `Classification`.
A missing file, both files, a parse error, any unknown key, an empty list
or more than ten entries is a problem+json error, never a pass. The
checks the POST makes on its classifications move into one function both
paths call.

The doc names the file and its schema, replacing the placeholders, and
documents the route.

Part of #5096.
2026-10-12 14:54:59 +02:00
6 changed files with 899 additions and 60 deletions

1
Cargo.lock generated
View file

@ -4843,6 +4843,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_norway",
"sha2 0.11.0",
"strum",
"swarm-authelia-bridge-sock",

View file

@ -18,23 +18,31 @@ that reviewer.
## Repo configuration
A repo opts in with a config file in a `.hyperhive/` directory at its root,
in YAML or JSON. The classifier always reads it from the repo's **default
branch**, never from the PR, so a PR can't edit the rules it's judged by.
A repo opts in with a rules file at its root, in YAML as
`.hyperhive/review-classifier.yml` or in JSON as
`.hyperhive/review-classifier.json`. It holds one of the two; with both, the
rules don't load. `swarm-controller` always reads the file from the repo's
**default branch**, never from the PR, so a PR can't edit the rules it's
judged by. It reads it with its own forge token on every load, with no cache,
so a rule merged to the default branch counts from the next load on.
Each classification has:
The file holds one key, `classifications`: a list of one to ten entries, each
with exactly these four keys:
- a **name**, short and unique in the repo, shown in the result table
- a **description**: the yes/no question the model answers about the PR
- a **certainty threshold** from 0 to 100
- a **polarity**: _positive_ means a match is the wanted outcome, and
_negative_ means a match is a violation
- `name`: short and unique in the repo, shown in the result table
- `description`: the yes/no question the model answers about the PR
- `threshold`: the certainty, 0 to 100, a verdict needs to count
- `polarity`: `positive` means a match is the wanted outcome, and
`negative` means a match is a violation
An illustrative example. The file name and every field name in it are
placeholders, not a fixed schema:
Any other key, at the top or in an entry, is an error: a misspelt key fails
the load instead of vanishing. An empty list, more than ten entries, an empty
name or description, and two entries with one name are errors too. Nothing
falls back to a pass: a repo whose file doesn't load has no rules.
An example:
```yaml
# illustrative only: file name and field names aren't fixed
classifications:
- name: datetime
description: All datetimes carry time zone information.
@ -241,6 +249,25 @@ and sends it to the classifier as one queue request on
stays open. The controller keeps the result in memory for an hour; there is no
run store.
`GET /api/review-classifier/rules?repo=owner/name` returns the repo's own
rules, for the page to load into its editor; a run posts them like typed
ones. The route answers:
- **200** with `path` (the file read), `branch` (the default branch),
`commit` (the commit the route read the file at) and `classifications`, in
the file's order and in the POST's shape
- **400** for a `repo` that's missing or not `owner/name`
- **404** for a repo the forge doesn't know; a default branch with no commit,
in an empty repo or when the default branch doesn't exist; or a default
branch with neither file
- **422** for both files on the default branch, or a file that doesn't parse
or breaks a rule from [Repo configuration](#repo-configuration); the
`detail` names the file and what's wrong
- **503** when this host has no forge token yet, or the forge failed or
didn't answer within two minutes
Every error is problem+json with the reason in `detail`.
When it's ready, **open a PR** has the service account push a branch that
adds the classification to the repo's config, with you, the swarm UI user,
as commit author. The page then links you to the forge's new-PR page for

View file

@ -22,9 +22,9 @@ axum.workspace = true
# boundary (see #3306's design discussion: forcing that split now, over a
# few idempotent CRUD-ish calls, is premature plumbing).
forgejo-api.workspace = true
# Only for base64-encoding file content for `forge.rs`'s
# `repo_change_files` calls — forgejo's content API takes base64, never
# raw bytes.
# `forge.rs` encodes file content for its `repo_change_files` calls and
# decodes blobs it reads: forgejo's content and blob APIs carry base64,
# never raw bytes.
base64.workspace = true
# `agent_identity.rs` mints the per-agent queue secret, the one value in this
# tree this daemon invents rather than receives. Straight from the kernel's
@ -79,6 +79,7 @@ hive-types.workspace = true
reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true
serde_norway = "0.9.42"
strum.workspace = true
swarm-authelia-bridge-sock.workspace = true
# The queue connect (token mint + auth callback + reconnect) is shared with

View file

@ -23,10 +23,10 @@ use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
CreateUserOption, EditUserOption, IssueListIssuesQuery, IssueListIssuesQueryState,
IssueListIssuesQueryType, RepoDownloadPullDiffOrPatchQuery, RepoGetContentsQuery,
RepoGetPullRequestCommitsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
RepoSearchQuery, StateType,
CreateUserOption, EditUserOption, GetTreeQuery, GitEntry, IssueListIssuesQuery,
IssueListIssuesQueryState, IssueListIssuesQueryType, RepoDownloadPullDiffOrPatchQuery,
RepoGetContentsQuery, RepoGetPullRequestCommitsQuery, RepoListPullRequestsQuery,
RepoListPullRequestsQueryState, RepoSearchQuery, StateType,
};
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
use futures_util::{StreamExt as _, TryStreamExt as _};
@ -65,6 +65,18 @@ pub struct PullForReview {
pub diff: 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,
/// Each asked-for path that exists there, with its bytes, in the order
/// asked.
pub files: Vec<(String, 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
@ -1102,6 +1114,144 @@ impl Client {
})
}
/// 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
.repo_get(owner, repo)
.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.repo_get_branch(owner, repo, &branch).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(bytes) = self.file_at(owner, repo, &commit, path, &mut trees).await? {
files.push(((*path).to_owned(), bytes));
}
}
Ok(Some(DefaultBranchFiles {
branch,
commit,
files,
}))
}
/// 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<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(Some);
}
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
.get_tree(owner, repo, sha, GetTreeQuery::default())
.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
.get_blob(owner, repo, sha)
.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.
@ -1970,4 +2120,147 @@ mod tests {
.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 = Forgejo::new(Auth::Token("stub-token"), url.clone()).unwrap();
(Client { api, url }, seen)
}
/// 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,
[(RULES_YML.to_owned(), 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:#}");
}
}

View file

@ -3281,6 +3281,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(issue_report::get_issue_report))
.routes(routes!(review_classifier::post_try_out))
.routes(routes!(review_classifier::get_try_out))
.routes(routes!(review_classifier::get_rules))
.routes(routes!(webhook::post_webhook_forge))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from

View file

@ -1,6 +1,8 @@
//! `POST /api/review-classifier/try-out` and
//! `GET /api/review-classifier/try-out/{id}`: run classifications against a
//! PR from the swarm UI, then read the result back.
//! `GET /api/review-classifier/rules`: a repo's own classifications, from
//! the rules file on its default branch.
//!
//! The POST reads the PR with this daemon's forge token, sends one request to
//! the review classifier on [`SUBJECT`] and answers 202 with an id at once:
@ -8,27 +10,29 @@
//! holds a request open. The reply is kept in memory, where the GET reads it
//! until [`RESULT_TTL`] after it arrived. Nothing reaches the forge.
//!
//! The rules are read with the same token on every request, with no cache.
//!
//! The operator is the `Remote-User` header the gateway sets, as for
//! `agent_input`; the classifier names them in its journal line.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write as _;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::Json;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, State};
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::review_classifier::{
Classification, MAX_CLASSIFICATIONS, Overall, RUN_DEADLINE, Reply, Row, SUBJECT, TryOut,
};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use crate::forge::{self, PullForReview};
use crate::forge::{self, DefaultBranchFiles, PullForReview};
use crate::{AppState, error_problem};
/// How long a finished run's result stays readable.
@ -37,6 +41,13 @@ pub(crate) const RESULT_TTL: Duration = Duration::from_hours(1);
/// The header the gateway names the session's user in.
const OPERATOR_HEADER: &str = "remote-user";
/// Where a repo keeps its rules, as YAML or as JSON. A repo holds one of the
/// two.
const RULES_PATHS: [&str; 2] = [
".hyperhive/review-classifier.yml",
".hyperhive/review-classifier.json",
];
/// A run to start.
#[derive(Debug, Deserialize, ToSchema)]
pub(crate) struct TryOutRequest {
@ -53,6 +64,45 @@ pub(crate) struct TryOutAccepted {
id: String,
}
/// Whose rules to read.
#[derive(Debug, Deserialize, IntoParams)]
pub(crate) struct RulesQuery {
/// `owner/name`.
repo: String,
}
/// A repo's classifications, as its default branch holds them.
#[derive(Debug, Serialize, ToSchema)]
pub(crate) struct Rules {
/// The file they were read from: `.hyperhive/review-classifier.yml` or
/// `.hyperhive/review-classifier.json`.
path: String,
/// The repo's default branch.
branch: String,
/// The commit on `branch` the file was read at.
commit: String,
/// One to ten, each with a distinct name, in the file's order.
classifications: Vec<Classification>,
}
/// A rules file: the one list, and nothing beside it.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesFile {
classifications: Vec<Rule>,
}
/// One entry of a rules file. `unknown` collects every key a
/// [`Classification`] doesn't have, which `deny_unknown_fields` can't do
/// beside a `flatten`.
#[derive(Deserialize)]
struct Rule {
#[serde(flatten)]
classification: Classification,
#[serde(flatten)]
unknown: BTreeMap<String, serde_json::Value>,
}
/// `running` lasts at most the run deadline, 31 minutes: ten
/// classifications at three minutes each, one after another, and a minute
/// more.
@ -187,6 +237,28 @@ impl PullSource for forge::Client {
}
}
/// Where a repo's files are read from: the forge in production, a fake in
/// the tests.
pub(crate) trait FileSource {
fn default_branch_files(
&self,
owner: &str,
repo: &str,
paths: &[&str],
) -> impl Future<Output = anyhow::Result<Option<DefaultBranchFiles>>> + Send;
}
impl FileSource for forge::Client {
async fn default_branch_files(
&self,
owner: &str,
repo: &str,
paths: &[&str],
) -> anyhow::Result<Option<DefaultBranchFiles>> {
forge::Client::default_branch_files(self, owner, repo, paths).await
}
}
/// Where a run is sent: the queue in production, a fake in the tests.
pub(crate) trait RunSink: Clone + Send + Sync + 'static {
fn run(&self, request: Vec<u8>) -> impl Future<Output = Reply> + Send;
@ -319,6 +391,134 @@ pub(crate) async fn get_try_out(
})
}
/// Read a repo's classifications from its default branch.
#[utoipa::path(
get,
path = "/api/review-classifier/rules",
params(RulesQuery),
responses(
(status = 200, description = "the classifications the default branch's rules file \
holds, and the commit it was read at", body = Rules),
(status = 400, description = "no `repo`, or a repo that is not owner/name with \
segments of `[A-Za-z0-9._-]` other than `.` and `..` (problem+json)", body = String),
(status = 404, description = "the forge knows no such repo; its default branch has no \
commit, as in an empty repo or one whose default branch doesn't exist; or its \
default branch holds neither `.hyperhive/review-classifier.yml` nor \
`.hyperhive/review-classifier.json` (problem+json)", body = String),
(status = 422, description = "the default branch holds both files; or the file does \
not parse as its extension says; or it holds a key other than `classifications`, \
or a classification with a key other than `name`, `description`, `polarity` and \
`threshold`; or it fails a check the try-out POST makes: no classification or \
more than ten, an empty trimmed name or description, two with one trimmed name, \
or a threshold over 100 (problem+json)", body = String),
(status = 503, description = "this host has no forge token yet; or the forge failed \
or did not answer within two minutes (problem+json)", body = String),
),
tag = "review-classifier"
)]
pub(crate) async fn get_rules(
State(state): State<AppState>,
query: Result<Query<RulesQuery>, QueryRejection>,
) -> Result<Json<Rules>, ProblemDetails> {
let Query(query) = query.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
let (owner, name) =
split_repo(&query.repo).map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?;
#[cfg(test)]
if let Some(fake) = state.try_outs.fake.clone() {
return rules(&*fake, owner, name).await;
}
let Some(forge) = state.forge.get() else {
return Err(error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"no forge access on this host yet",
));
};
rules(forge.as_ref(), owner, name).await
}
/// Read the rules file of `owner/name` and parse it.
async fn rules(
files: &impl FileSource,
owner: &str,
name: &str,
) -> Result<Json<Rules>, ProblemDetails> {
let repo = format!("{owner}/{name}");
let read = forge::within_forge_deadline(
"review rules",
files.default_branch_files(owner, name, &RULES_PATHS),
)
.await
.ok_or_else(|| {
error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"the forge did not answer within the deadline",
)
})?
.map_err(|e| rules_problem(&e, &repo))?
.ok_or_else(|| {
error_problem(
StatusCode::NOT_FOUND,
&format!("{repo} has no commit on its default branch, so no rules file"),
)
})?;
let [yml, json] = RULES_PATHS;
let (path, bytes) = match read.files.as_slice() {
[] => {
return Err(error_problem(
StatusCode::NOT_FOUND,
&format!(
"{repo}'s default branch {} holds neither {yml} nor {json}",
read.branch
),
));
}
[file] => file,
_ => {
return Err(error_problem(
StatusCode::UNPROCESSABLE_ENTITY,
&format!(
"{repo}'s default branch {} holds both {yml} and {json}: keep one",
read.branch
),
));
}
};
let classifications = parse_rules(path, bytes)
.map_err(|d| error_problem(StatusCode::UNPROCESSABLE_ENTITY, &d))?;
tracing::info!(%repo, branch = %read.branch, commit = %read.commit, %path, "review rules read");
Ok(Json(Rules {
path: path.clone(),
branch: read.branch,
commit: read.commit,
classifications,
}))
}
/// The classifications in the rules file at `path`, parsed as JSON for a
/// `.json` path and as YAML otherwise, or what is wrong with them.
fn parse_rules(path: &str, bytes: &[u8]) -> Result<Vec<Classification>, String> {
let file: RulesFile = if std::path::Path::new(path)
.extension()
.is_some_and(|e| e == "json")
{
serde_json::from_slice(bytes).map_err(|e| format!("{path}: {e}"))?
} else {
serde_norway::from_slice(bytes).map_err(|e| format!("{path}: {e}"))?
};
let mut classifications = Vec::with_capacity(file.classifications.len());
for rule in file.classifications {
if let Some(key) = rule.unknown.keys().next() {
return Err(format!(
"{path}: classification {:?} has an unknown field {key:?}",
rule.classification.name.trim()
));
}
classifications.push(rule.classification);
}
check_classifications(&classifications).map_err(|e| format!("{path}: {e}"))?;
Ok(classifications)
}
/// A request that passed every check, with its repo split.
#[derive(Debug)]
struct Run {
@ -331,40 +531,12 @@ struct Run {
impl Run {
/// The request, or what is wrong with it.
fn checked(req: TryOutRequest) -> Result<Self, String> {
let (owner, name) = req
.repo
.split_once('/')
.filter(|(o, n)| segment(o) && segment(n))
.ok_or_else(|| format!("repo {:?} is not owner/name", req.repo))?;
let (owner, name) = split_repo(&req.repo)?;
let pr = i64::try_from(req.pr)
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| format!("{} is not a PR number", req.pr))?;
let count = req.classifications.len();
if count == 0 || count > MAX_CLASSIFICATIONS {
return Err(format!(
"{count} classifications: a run carries 1 to {MAX_CLASSIFICATIONS}"
));
}
let mut names = HashSet::new();
for c in &req.classifications {
let name = c.name.trim();
if name.is_empty() {
return Err("a classification has no name".to_owned());
}
if c.description.trim().is_empty() {
return Err(format!("classification {name} has no description"));
}
if c.threshold > 100 {
return Err(format!(
"classification {name}'s threshold {} is over 100",
c.threshold
));
}
if !names.insert(name) {
return Err(format!("two classifications are named {name}"));
}
}
check_classifications(&req.classifications)?;
Ok(Self {
owner: owner.to_owned(),
name: name.to_owned(),
@ -374,6 +546,43 @@ impl Run {
}
}
/// `repo` as its owner and name, or what is wrong with it.
fn split_repo(repo: &str) -> Result<(&str, &str), String> {
repo.split_once('/')
.filter(|(o, n)| segment(o) && segment(n))
.ok_or_else(|| format!("repo {repo:?} is not owner/name"))
}
/// What is wrong with `classifications` as one run's, if anything.
fn check_classifications(classifications: &[Classification]) -> Result<(), String> {
let count = classifications.len();
if count == 0 || count > MAX_CLASSIFICATIONS {
return Err(format!(
"{count} classifications: a run carries 1 to {MAX_CLASSIFICATIONS}"
));
}
let mut names = HashSet::new();
for c in classifications {
let name = c.name.trim();
if name.is_empty() {
return Err("a classification has no name".to_owned());
}
if c.description.trim().is_empty() {
return Err(format!("classification {name} has no description"));
}
if c.threshold > 100 {
return Err(format!(
"classification {name}'s threshold {} is over 100",
c.threshold
));
}
if !names.insert(name) {
return Err(format!("two classifications are named {name}"));
}
}
Ok(())
}
/// One `owner` or `name` of a repo, as the forge allows them.
fn segment(s: &str) -> bool {
!s.is_empty()
@ -452,6 +661,20 @@ fn pull_problem(e: &anyhow::Error, repo: &str, pr: i64) -> ProblemDetails {
}
}
/// 404 when the forge knows no such repo, 503 for anything else.
fn rules_problem(e: &anyhow::Error, repo: &str) -> ProblemDetails {
match e.downcast_ref::<forgejo_api::ForgejoError>() {
Some(fe) if forge::is_repo_not_found(fe) => {
error_problem(StatusCode::NOT_FOUND, &format!("no repo {repo}"))
}
_ => {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, %repo, "reading a repo's review rules failed");
error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail)
}
}
}
/// A fresh run id: 32 hex digits from the kernel's CSPRNG.
fn mint_id() -> Result<String, String> {
let mut bytes = [0u8; 16];
@ -474,11 +697,14 @@ pub(crate) mod tests {
use super::*;
/// A forge holding one PR, `o/r` 7, and a classifier answering every run
/// with `reply`.
/// A forge holding one PR, `o/r` 7, with `files` on `o/r`'s default
/// branch, and a classifier answering every run with `reply`. `o/empty`
/// has no commit, `o/down` fails with something other than a 404, and
/// `o/hang` never answers.
#[derive(Clone)]
pub(crate) struct Fake {
reply: Reply,
files: Vec<(&'static str, String)>,
}
impl PullSource for Fake {
@ -500,6 +726,33 @@ pub(crate) mod tests {
}
}
impl FileSource for Fake {
async fn default_branch_files(
&self,
owner: &str,
repo: &str,
paths: &[&str],
) -> anyhow::Result<Option<DefaultBranchFiles>> {
match (owner, repo) {
("o", "r") => Ok(Some(DefaultBranchFiles {
branch: "main".to_owned(),
commit: "def456".to_owned(),
files: paths
.iter()
.filter_map(|p| self.files.iter().find(|(f, _)| f == p))
.map(|(f, body)| ((*f).to_owned(), body.clone().into_bytes()))
.collect(),
})),
("o", "empty") => Ok(None),
("o", "down") => Err(anyhow::anyhow!("the forge answered 500")),
("o", "hang") => std::future::pending().await,
_ => Err(
ForgejoError::ApiError(ApiErrorKind::NotFound { errors: None }.into()).into(),
),
}
}
}
impl RunSink for Fake {
async fn run(&self, _request: Vec<u8>) -> Reply {
self.reply.clone()
@ -524,8 +777,12 @@ pub(crate) mod tests {
}
fn app(reply: Reply) -> axum::Router {
app_with_files(reply, Vec::new())
}
fn app_with_files(reply: Reply, files: Vec<(&'static str, String)>) -> axum::Router {
let (mut state, _) = crate::tests::state_with_roster();
state.try_outs.fake = Some(Arc::new(Fake { reply }));
state.try_outs.fake = Some(Arc::new(Fake { reply, files }));
crate::build_app(state)
}
@ -738,4 +995,263 @@ pub(crate) mod tests {
assert!(!entry(None).expired(now + RUN_DEADLINE + RESULT_TTL));
assert!(entry(None).expired(now + RUN_DEADLINE + RESULT_TTL + Duration::from_secs(1)));
}
/// A rules file holding `classifications`, and `extra` beside them.
fn rules_value(
classifications: &serde_json::Value,
extra: &[(&str, &str)],
) -> serde_json::Value {
let mut file = serde_json::json!({ "classifications": classifications });
for (key, value) in extra {
file[*key] = (*value).into();
}
file
}
fn datetime() -> serde_json::Value {
serde_json::json!({
"name": "datetime",
"description": "All datetimes carry time zone information.",
"polarity": "positive",
"threshold": 80,
})
}
/// `file` as both formats, each with its path.
fn both_formats(file: &serde_json::Value) -> [(&'static str, String); 2] {
let [yml, json] = RULES_PATHS;
[
(yml, serde_norway::to_string(file).expect("encodes as YAML")),
(json, file.to_string()),
]
}
#[test]
fn a_rules_file_parses_the_same_as_yaml_and_as_json() {
let yaml = "\
classifications:
- name: datetime
description: All datetimes carry time zone information.
polarity: positive
threshold: 80
- name: no-ads
description: >-
A commit message contains a co-authored-by line or other
advertising for an AI model.
polarity: negative
threshold: 90
";
let json = r#"{"classifications": [
{"name": "datetime", "description": "All datetimes carry time zone information.",
"polarity": "positive", "threshold": 80},
{"name": "no-ads", "description": "A commit message contains a co-authored-by line or other advertising for an AI model.",
"polarity": "negative", "threshold": 90}
]}"#;
let want = vec![
Classification {
name: "datetime".to_owned(),
description: "All datetimes carry time zone information.".to_owned(),
polarity: Polarity::Positive,
threshold: 80,
},
Classification {
name: "no-ads".to_owned(),
description: "A commit message contains a co-authored-by line or other \
advertising for an AI model."
.to_owned(),
polarity: Polarity::Negative,
threshold: 90,
},
];
let [yml_path, json_path] = RULES_PATHS;
assert_eq!(parse_rules(yml_path, yaml.as_bytes()), Ok(want.clone()));
assert_eq!(parse_rules(json_path, json.as_bytes()), Ok(want));
}
#[test]
fn a_bad_rules_file_is_an_error_in_either_format() {
let mut unknown_rule_field = datetime();
unknown_rule_field["threshhold"] = 80.into();
let mut over_100 = datetime();
over_100["threshold"] = 101.into();
let mut no_name = datetime();
no_name["name"] = " ".into();
let mut bad_polarity = datetime();
bad_polarity["polarity"] = "sideways".into();
let mut no_polarity = datetime();
no_polarity
.as_object_mut()
.expect("an object")
.remove("polarity");
let eleven: Vec<_> = (0..11)
.map(|i| {
let mut c = datetime();
c["name"] = format!("c{i}").into();
c
})
.collect();
for (case, file, says) in [
("no list", serde_json::json!({}), "classifications"),
(
"an empty list",
rules_value(&serde_json::json!([]), &[]),
"0 classifications",
),
(
"an unknown top-level field",
rules_value(&serde_json::json!([datetime()]), &[("version", "1")]),
"version",
),
(
"an unknown classification field",
rules_value(&serde_json::json!([unknown_rule_field]), &[]),
"threshhold",
),
(
"eleven classifications",
rules_value(&eleven.into(), &[]),
"11 classifications",
),
(
"a threshold over 100",
rules_value(&serde_json::json!([over_100]), &[]),
"over 100",
),
(
"an empty name",
rules_value(&serde_json::json!([no_name]), &[]),
"no name",
),
(
"no polarity",
rules_value(&serde_json::json!([no_polarity]), &[]),
"polarity",
),
(
"a bad polarity",
rules_value(&serde_json::json!([bad_polarity]), &[]),
"sideways",
),
(
"one name twice",
rules_value(&serde_json::json!([datetime(), datetime()]), &[]),
"two classifications",
),
] {
for (path, body) in both_formats(&file) {
let err = parse_rules(path, body.as_bytes())
.expect_err(&format!("{case} in {path} must not parse"));
assert!(
err.starts_with(path) && err.contains(says),
"{case} in {path}: {err}"
);
}
}
let [yml, json] = RULES_PATHS;
for (path, body) in [(yml, "classifications: [\n"), (json, "{")] {
let err = parse_rules(path, body.as_bytes()).expect_err("must not parse");
assert!(err.starts_with(path), "{err}");
}
}
async fn get_rules(app: &axum::Router, query: &str) -> (StatusCode, serde_json::Value) {
let req = Request::get(format!("/api/review-classifier/rules{query}"))
.body(Body::empty())
.expect("a request");
send(app, req).await
}
#[tokio::test]
async fn a_repos_rules_are_200_with_the_commit_they_were_read_at() {
let file = rules_value(&serde_json::json!([datetime()]), &[]);
for (path, body) in both_formats(&file) {
let app = app_with_files(done(), vec![(path, body)]);
let (status, rules) = get_rules(&app, "?repo=o/r").await;
assert_eq!(status, StatusCode::OK, "{path}: {rules}");
assert_eq!(rules["path"], path);
assert_eq!(rules["branch"], "main");
assert_eq!(rules["commit"], "def456");
assert_eq!(rules["classifications"], serde_json::json!([datetime()]));
}
}
#[tokio::test]
async fn a_repo_without_rules_or_with_bad_ones_is_an_error() {
let good = rules_value(&serde_json::json!([datetime()]), &[]);
let bad = rules_value(&serde_json::json!([]), &[]);
let mut sideways = datetime();
sideways["polarity"] = "sideways".into();
let sideways = rules_value(&serde_json::json!([sideways]), &[]);
let [yml, json] = RULES_PATHS;
let one = |path: &'static str, file: &serde_json::Value| vec![(path, file.to_string())];
for (query, files, want, says) in [
("", Vec::new(), StatusCode::BAD_REQUEST, "repo"),
("?repo=o", Vec::new(), StatusCode::BAD_REQUEST, "owner/name"),
(
"?repo=o/elsewhere",
Vec::new(),
StatusCode::NOT_FOUND,
"no repo o/elsewhere",
),
(
"?repo=o/empty",
Vec::new(),
StatusCode::NOT_FOUND,
"no commit on its default branch",
),
("?repo=o/r", Vec::new(), StatusCode::NOT_FOUND, "neither"),
(
"?repo=o/down",
Vec::new(),
StatusCode::SERVICE_UNAVAILABLE,
"500",
),
(
"?repo=o/r",
[one(yml, &good), one(json, &good)].concat(),
StatusCode::UNPROCESSABLE_ENTITY,
"keep one",
),
(
"?repo=o/r",
one(json, &bad),
StatusCode::UNPROCESSABLE_ENTITY,
"0 classifications",
),
(
"?repo=o/r",
one(yml, &sideways),
StatusCode::UNPROCESSABLE_ENTITY,
"sideways",
),
] {
let app = app_with_files(done(), files);
let (status, problem) = get_rules(&app, query).await;
assert_eq!(status, want, "{query}: {problem}");
let detail = problem["detail"].as_str().expect("a problem+json detail");
assert!(detail.contains(says), "{query}: {detail}");
}
}
#[tokio::test]
async fn rules_without_forge_access_on_this_host_are_503() {
let (state, _) = crate::tests::state_with_roster();
let (status, problem) = get_rules(&crate::build_app(state), "?repo=o/r").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
assert_eq!(problem["detail"], "no forge access on this host yet");
}
/// The paused clock jumps to the deadline once nothing else can run, so
/// this takes no wall time.
#[tokio::test(start_paused = true)]
async fn rules_from_a_forge_that_never_answers_are_503_at_the_deadline() {
let start = tokio::time::Instant::now();
let (status, problem) = get_rules(&app(done()), "?repo=o/hang").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
assert_eq!(
problem["detail"],
"the forge did not answer within the deadline"
);
assert_eq!(start.elapsed(), forge::FORGE_DEADLINE);
}
}