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
222 lines
8.2 KiB
Rust
222 lines
8.2 KiB
Rust
//! An agent's GitHub personal access token: an operator hands us the token, we
|
|
//! put it in the swarm's secret store.
|
|
//!
|
|
//! The agent end is `nix/agent-modules/github-token.nix`, which reads it under
|
|
//! the agent's own certificate into the `github-token` file its `gh` wrapper,
|
|
//! git credential helper and `hive-github-notify` read. No hive is in the path.
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use serde::Deserialize;
|
|
use swarm_secret_client::github;
|
|
use utoipa::ToSchema;
|
|
|
|
use super::linked_accounts::link;
|
|
use super::{AppState, error_problem, swarm_hive};
|
|
|
|
/// The token to store for one agent.
|
|
///
|
|
/// No `Debug` derive: this carries a token.
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct PutGithubAccountRequest {
|
|
/// The personal access token. Never logged, and never returned by this
|
|
/// route.
|
|
token: String,
|
|
}
|
|
|
|
/// Store an agent's GitHub token, unless it has one stored already.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/hives/{hive}/agents/{agent}/github-account",
|
|
params(
|
|
("hive" = String, Path, description = "hive the agent runs on"),
|
|
("agent" = String, Path, description = "agent the token belongs to"),
|
|
),
|
|
request_body = PutGithubAccountRequest,
|
|
responses(
|
|
(status = 204, description = "stored"),
|
|
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
|
(status = 409, description = "the agent has a token stored already; nothing was written (problem+json)", body = String),
|
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub async fn put_github_account(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
|
Json(req): Json<PutGithubAccountRequest>,
|
|
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
|
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
|
let agent = hive_types::Ident::parse(&agent)
|
|
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
|
.into_string();
|
|
let secret_path = github::account_path(&agent)
|
|
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
|
let credential = credential(&req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
|
|
|
|
let store = state.accounts.open().await.map_err(|e| {
|
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
})?;
|
|
link(&store, &secret_path, &credential).await.map_err(|e| {
|
|
// The path names the agent; the value is not in it.
|
|
tracing::warn!(path = %secret_path, error = ?e, "linking the github token failed");
|
|
e.problem(&existing(&agent))
|
|
})?;
|
|
|
|
tracing::info!(%hive, %agent, "github token stored");
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// The token a refused link names.
|
|
fn existing(agent: &str) -> String {
|
|
format!("agent {agent} already has a github token")
|
|
}
|
|
|
|
/// The request as it is stored, or why it cannot be.
|
|
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
|
|
let token = req.token.trim();
|
|
if token.is_empty() {
|
|
return Err("token is required");
|
|
}
|
|
Ok(github::Credential {
|
|
value: token.to_owned(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
|
use super::{PutGithubAccountRequest, credential};
|
|
|
|
fn request(token: &str) -> PutGithubAccountRequest {
|
|
PutGithubAccountRequest {
|
|
token: token.to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_token_is_kept_without_surrounding_whitespace() {
|
|
let c = credential(&request(" t0k3n\n")).expect("valid");
|
|
assert_eq!(c.value, "t0k3n");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_token_is_refused() {
|
|
assert!(credential(&request(" ")).is_err());
|
|
assert!(credential(&request("")).is_err());
|
|
}
|
|
|
|
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
|
|
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
|
super::super::AppState {
|
|
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
|
name: "pr1ma".to_owned(),
|
|
domain: "pr1ma.example".to_owned(),
|
|
}]),
|
|
links: std::sync::Arc::new(Vec::new()),
|
|
status: None,
|
|
wanted: None,
|
|
agent_status: None,
|
|
agent_icons: None,
|
|
reported_inference: None,
|
|
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
|
hive_jobq::Graph::new(),
|
|
hive_jobq::resources::ResourceTable::new(),
|
|
))),
|
|
webhook_secret: None,
|
|
config_prs: std::sync::Arc::default(),
|
|
swarm_name: None,
|
|
auth: None,
|
|
forge: std::sync::Arc::default(),
|
|
create_gate: std::sync::Arc::default(),
|
|
accounts: Accounts::Fake(Arc::clone(store)),
|
|
try_outs: super::super::review_classifier::TryOuts::default(),
|
|
review_slots: super::super::review_slots(),
|
|
}
|
|
}
|
|
|
|
async fn put(
|
|
store: &Arc<FakeStore>,
|
|
agent: &str,
|
|
token: &str,
|
|
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
|
|
super::put_github_account(
|
|
axum::extract::State(state(store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
|
axum::Json(request(token)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// An agent name or an empty token is refused before the store.
|
|
#[tokio::test]
|
|
async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() {
|
|
let store = Arc::default();
|
|
for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] {
|
|
let problem = put(&store, agent, token).await.expect_err("refused");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{agent:?}: {problem:?}"
|
|
);
|
|
}
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// The control: a plain agent and a token are written.
|
|
#[tokio::test]
|
|
async fn a_plain_request_reaches_the_store() {
|
|
use super::super::linked_accounts::AccountStore;
|
|
use swarm_secret_client::github;
|
|
|
|
let store = Arc::default();
|
|
let status = put(&store, "atlas", "t0k3n")
|
|
.await
|
|
.expect("nothing is stored");
|
|
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
|
assert_eq!(store.written(), ["swarm/agents/atlas/github-token"]);
|
|
let stored: github::Credential = store
|
|
.read_optional("swarm/agents/atlas/github-token")
|
|
.await
|
|
.expect("store answers")
|
|
.expect("stored");
|
|
assert_eq!(stored.value, "t0k3n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn linking_a_second_token_is_a_409_and_the_first_stays() {
|
|
use super::super::linked_accounts::{AccountStore, link};
|
|
use swarm_secret_client::github;
|
|
|
|
let store = FakeStore::default();
|
|
let path = github::account_path("atlas").expect("a valid path");
|
|
let first = credential(&request("t0k3n-first")).expect("valid");
|
|
let second = credential(&request("t0k3n-second")).expect("valid");
|
|
|
|
link(&store, &path, &first)
|
|
.await
|
|
.expect("nothing is stored");
|
|
let problem = link(&store, &path, &second)
|
|
.await
|
|
.expect_err("a token is stored")
|
|
.problem(&super::existing("atlas"));
|
|
|
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
|
let detail = problem.detail.expect("a detail");
|
|
assert!(
|
|
detail.contains("agent atlas already has a github token"),
|
|
"{detail}"
|
|
);
|
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
|
let kept: github::Credential = store
|
|
.read_optional(&path)
|
|
.await
|
|
.expect("store answers")
|
|
.expect("still stored");
|
|
assert_eq!(kept.value, "t0k3n-first");
|
|
}
|
|
}
|