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
837 lines
31 KiB
Rust
837 lines
31 KiB
Rust
//! `POST /api/review-classifier/open-pr`: put classifications written on the
|
|
//! try-out page on a new branch of the repo, for the operator to open a PR
|
|
//! from.
|
|
//!
|
|
//! The classifications are merged by trimmed name into the rules file on the
|
|
//! repo's default branch: one with a name the file holds replaces that entry,
|
|
//! one with a new name goes at the end, and every other entry stays. A repo
|
|
//! with no rules file gets `.hyperhive/review-classifier.yml`. In a YAML file,
|
|
//! the entries the merge leaves alone keep their text, comments included.
|
|
//!
|
|
//! The commit is made by the classifier's forge account
|
|
//! ([`LOGIN`]) with its own token, on a branch of the repo itself, so that
|
|
//! account needs write access to the repo. Its author is the operator: the
|
|
//! `Remote-User` the gateway sets names the forge account of the same login,
|
|
//! since a forge account made by an SSO login is named after the SSO user.
|
|
//! The answer links to the forge's compare page for the branch, where the
|
|
//! operator opens the PR as themselves.
|
|
|
|
use std::fmt::Write as _;
|
|
use std::future::Future;
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use axum::extract::rejection::JsonRejection;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use forgejo_api::{ApiErrorKind, ForgejoError};
|
|
use problem_details::ProblemDetails;
|
|
use serde::{Deserialize, Serialize};
|
|
use swarm_queue_client::review_classifier::{Classification, Polarity};
|
|
use utoipa::ToSchema;
|
|
|
|
use super::{FileSource, RULES_PATHS, check_classifications, operator, parse_rules, split_repo};
|
|
use crate::forge::classifier_account::{BranchCommit, CommitAuthor, LOGIN};
|
|
use crate::forge::{self, BranchFile};
|
|
use crate::{AppState, error_problem};
|
|
|
|
/// Where a branch's name starts, so the forge's branch list groups them.
|
|
const BRANCH_PREFIX: &str = "review-classifier/";
|
|
|
|
/// The longest a line of a folded YAML description gets, indent included.
|
|
const YAML_WIDTH: usize = 78;
|
|
|
|
/// Classifications to propose to a repo.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub(crate) struct OpenPrRequest {
|
|
/// `owner/name`.
|
|
repo: String,
|
|
/// One to ten, each with a distinct name.
|
|
classifications: Vec<Classification>,
|
|
}
|
|
|
|
/// The branch the commit went on.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub(crate) struct OpenPrBranch {
|
|
branch: String,
|
|
/// The rules file the commit wrote.
|
|
path: String,
|
|
/// The forge's page comparing `branch` with the default branch, where
|
|
/// the PR is opened.
|
|
compare_url: String,
|
|
}
|
|
|
|
/// Where the commit is made: the forge in production, a fake in the tests.
|
|
pub(crate) trait BranchSink {
|
|
/// The forge account `login` as a commit author, or `None` when the
|
|
/// forge has none.
|
|
fn commit_author(
|
|
&self,
|
|
login: &str,
|
|
) -> impl Future<Output = anyhow::Result<Option<CommitAuthor>>> + Send;
|
|
|
|
fn push(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
commit: BranchCommit,
|
|
) -> impl Future<Output = anyhow::Result<()>> + Send;
|
|
}
|
|
|
|
impl BranchSink for forge::Client {
|
|
async fn commit_author(&self, login: &str) -> anyhow::Result<Option<CommitAuthor>> {
|
|
forge::Client::commit_author(self, login).await
|
|
}
|
|
|
|
async fn push(&self, owner: &str, repo: &str, commit: BranchCommit) -> anyhow::Result<()> {
|
|
self.push_classifier_branch(owner, repo, commit).await
|
|
}
|
|
}
|
|
|
|
/// Put classifications on a new branch, authored by the operator.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/review-classifier/open-pr",
|
|
request_body = OpenPrRequest,
|
|
responses(
|
|
(status = 201, description = "the branch is on the forge; open the PR at \
|
|
`compare_url`", body = OpenPrBranch),
|
|
(status = 400, description = "the body is not this shape, or fails a check the \
|
|
try-out POST makes on its repo or classifications (problem+json)", body = String),
|
|
(status = 401, description = "the request carries no `Remote-User`, so there is no \
|
|
author (problem+json)", body = String),
|
|
(status = 403, description = "the forge refused the classifier's account the push: \
|
|
it is not a collaborator with write access on the repo (problem+json)",
|
|
body = String),
|
|
(status = 404, description = "the forge knows no such repo, or its default branch \
|
|
has no commit (problem+json)", body = String),
|
|
(status = 409, description = "the forge has no account named like the operator, who \
|
|
has to log in to it once; or the default branch's rules file changed while the \
|
|
commit was made (problem+json)", body = String),
|
|
(status = 422, description = "the default branch holds both rules files, or one the \
|
|
`rules` GET refuses; the rules already hold every classification as it is; or \
|
|
the merged rules fail the try-out POST's checks, such as more than ten \
|
|
(problem+json)", body = String),
|
|
(status = 503, description = "this host has no forge token yet, or the classifier's \
|
|
account has none in the store; or the forge failed or did not answer within two \
|
|
minutes (problem+json)", body = String),
|
|
),
|
|
tag = "review-classifier"
|
|
)]
|
|
pub(crate) async fn post_open_pr(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
body: Result<Json<OpenPrRequest>, JsonRejection>,
|
|
) -> Result<(StatusCode, Json<OpenPrBranch>), ProblemDetails> {
|
|
let operator = operator(&headers).ok_or_else(|| {
|
|
error_problem(
|
|
StatusCode::UNAUTHORIZED,
|
|
"no Remote-User header: the gateway names the operator, and this request did not \
|
|
come through it",
|
|
)
|
|
})?;
|
|
let Json(req) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
|
|
let (owner, name) =
|
|
split_repo(&req.repo).map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?;
|
|
check_classifications(&req.classifications)
|
|
.map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?;
|
|
#[cfg(test)]
|
|
if let Some(fake) = state.try_outs.fake.clone() {
|
|
return open(&*fake, &operator, owner, name, req.classifications).await;
|
|
}
|
|
let Some(forge) = state.forge.get() else {
|
|
return Err(error_problem(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"no forge access on this host yet",
|
|
));
|
|
};
|
|
open(forge.as_ref(), &operator, owner, name, req.classifications).await
|
|
}
|
|
|
|
/// Resolve the author, merge the rules and push the branch.
|
|
async fn open<F: FileSource + BranchSink + Sync>(
|
|
forge: &F,
|
|
operator: &str,
|
|
owner: &str,
|
|
name: &str,
|
|
rows: Vec<Classification>,
|
|
) -> Result<(StatusCode, Json<OpenPrBranch>), ProblemDetails> {
|
|
let repo = format!("{owner}/{name}");
|
|
let author = deadline(forge.commit_author(operator))
|
|
.await?
|
|
.map_err(|e| forge_problem(&e, &repo))?
|
|
.ok_or_else(|| {
|
|
error_problem(
|
|
StatusCode::CONFLICT,
|
|
&format!(
|
|
"the forge has no account {operator}, to name as the commit's author: log in \
|
|
to the forge once, then try again"
|
|
),
|
|
)
|
|
})?;
|
|
let read = deadline(forge.default_branch_files(owner, name, &RULES_PATHS))
|
|
.await?
|
|
.map_err(|e| forge_problem(&e, &repo))?
|
|
.ok_or_else(|| {
|
|
error_problem(
|
|
StatusCode::NOT_FOUND,
|
|
&format!("{repo} has no commit on its default branch to branch from"),
|
|
)
|
|
})?;
|
|
let existing = match read.files.as_slice() {
|
|
[] => None,
|
|
[file] => Some(file),
|
|
_ => {
|
|
return Err(error_problem(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
&format!(
|
|
"{repo}'s default branch {} holds both rules files: keep one",
|
|
read.branch
|
|
),
|
|
));
|
|
}
|
|
};
|
|
let merged =
|
|
merge(existing, rows).map_err(|d| error_problem(StatusCode::UNPROCESSABLE_ENTITY, &d))?;
|
|
let web_url = read.web_url.ok_or_else(|| {
|
|
error_problem(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
&format!("the forge named no web page for {repo}"),
|
|
)
|
|
})?;
|
|
let branch =
|
|
branch_name(operator).map_err(|e| error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e))?;
|
|
let compare_url = format!(
|
|
"{}/compare/{}...{branch}",
|
|
web_url.as_str().trim_end_matches('/'),
|
|
read.branch
|
|
);
|
|
let commit = BranchCommit {
|
|
base: read.branch,
|
|
branch: branch.clone(),
|
|
path: merged.path.clone(),
|
|
sha: merged.sha,
|
|
content: merged.content,
|
|
message: merged.message,
|
|
author,
|
|
};
|
|
deadline(forge.push(owner, name, commit))
|
|
.await?
|
|
.map_err(|e| push_problem(&e, &repo))?;
|
|
tracing::info!(%repo, %branch, %operator, path = %merged.path, "review classifier branch pushed");
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(OpenPrBranch {
|
|
branch,
|
|
path: merged.path,
|
|
compare_url,
|
|
}),
|
|
))
|
|
}
|
|
|
|
async fn deadline<T>(work: impl Future<Output = T>) -> Result<T, ProblemDetails> {
|
|
forge::within_forge_deadline("review classifier branch", work)
|
|
.await
|
|
.ok_or_else(|| {
|
|
error_problem(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"the forge did not answer within the deadline",
|
|
)
|
|
})
|
|
}
|
|
|
|
/// A fresh branch for `operator`: [`BRANCH_PREFIX`], their name in the
|
|
/// characters a ref takes without question, and eight random hex digits.
|
|
fn branch_name(operator: &str) -> Result<String, String> {
|
|
let mut bytes = [0u8; 4];
|
|
getrandom::fill(&mut bytes).map_err(|e| format!("drawing a branch name: {e}"))?;
|
|
let who: String = operator
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() {
|
|
c.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect();
|
|
let mut name = format!("{BRANCH_PREFIX}{who}-");
|
|
for b in bytes {
|
|
let _ = write!(name, "{b:02x}");
|
|
}
|
|
Ok(name)
|
|
}
|
|
|
|
/// 404 for a repo the forge doesn't know, 503 for anything else.
|
|
fn forge_problem(e: &anyhow::Error, repo: &str) -> ProblemDetails {
|
|
if forge_status(e) == Some(StatusCode::NOT_FOUND) {
|
|
return error_problem(StatusCode::NOT_FOUND, &format!("no repo {repo}"));
|
|
}
|
|
let detail = format!("{e:#}");
|
|
tracing::warn!(error = %detail, %repo, "preparing a review classifier branch failed");
|
|
error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail)
|
|
}
|
|
|
|
/// 403 when the account may not push, 409 when the rules file or the
|
|
/// branch changed under the commit, 503 for anything else.
|
|
fn push_problem(e: &anyhow::Error, repo: &str) -> ProblemDetails {
|
|
let detail = format!("{e:#}");
|
|
tracing::warn!(error = %detail, %repo, "pushing a review classifier branch failed");
|
|
match forge_status(e) {
|
|
// The forge hides a repo the account can't read behind a 404.
|
|
Some(StatusCode::FORBIDDEN | StatusCode::NOT_FOUND) => error_problem(
|
|
StatusCode::FORBIDDEN,
|
|
&format!(
|
|
"{LOGIN} may not push to {repo}: a repo admin adds it as a collaborator with \
|
|
write access ({detail})"
|
|
),
|
|
),
|
|
Some(StatusCode::CONFLICT | StatusCode::UNPROCESSABLE_ENTITY) => error_problem(
|
|
StatusCode::CONFLICT,
|
|
&format!("the default branch changed while the branch was made: try again ({detail})"),
|
|
),
|
|
_ => error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail),
|
|
}
|
|
}
|
|
|
|
/// The forge's status, when a forge answer is what failed.
|
|
fn forge_status(e: &anyhow::Error) -> Option<StatusCode> {
|
|
match e.downcast_ref::<ForgejoError>()? {
|
|
ForgejoError::ApiError(api) => match api.error_kind() {
|
|
ApiErrorKind::Forbidden | ApiErrorKind::Unauthorized => Some(StatusCode::FORBIDDEN),
|
|
ApiErrorKind::NotFound { .. } => Some(StatusCode::NOT_FOUND),
|
|
ApiErrorKind::ValidationFailed => Some(StatusCode::UNPROCESSABLE_ENTITY),
|
|
ApiErrorKind::Other(s) => Some(*s),
|
|
_ => None,
|
|
},
|
|
ForgejoError::UnexpectedStatusCode(s) => Some(*s),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The rules file a commit writes.
|
|
#[derive(Debug)]
|
|
struct Merged {
|
|
path: String,
|
|
/// The blob it replaces; `None` when the repo has no rules file.
|
|
sha: Option<String>,
|
|
content: Vec<u8>,
|
|
message: String,
|
|
}
|
|
|
|
/// `rows` merged into `existing`, or why they can't be.
|
|
fn merge(existing: Option<&BranchFile>, rows: Vec<Classification>) -> Result<Merged, String> {
|
|
let [yml, _] = RULES_PATHS;
|
|
let (path, sha, text, current) = match existing {
|
|
Some(file) => (
|
|
file.path.clone(),
|
|
Some(file.sha.clone()),
|
|
Some(String::from_utf8_lossy(&file.bytes).into_owned()),
|
|
parse_rules(&file.path, &file.bytes)?,
|
|
),
|
|
None => (yml.to_owned(), None, None, Vec::new()),
|
|
};
|
|
let mut merged = current.clone();
|
|
let (mut added, mut changed) = (Vec::new(), Vec::new());
|
|
for row in rows {
|
|
let row = Classification {
|
|
name: row.name.trim().to_owned(),
|
|
description: row.description.trim().to_owned(),
|
|
..row
|
|
};
|
|
match merged.iter().position(|c| c.name.trim() == row.name) {
|
|
Some(i) if merged[i] == row => {}
|
|
Some(i) => {
|
|
changed.push(row.name.clone());
|
|
merged[i] = row;
|
|
}
|
|
None => {
|
|
added.push(row.name.clone());
|
|
merged.push(row);
|
|
}
|
|
}
|
|
}
|
|
if added.is_empty() && changed.is_empty() {
|
|
return Err(format!(
|
|
"{path} already holds every classification as it is"
|
|
));
|
|
}
|
|
check_classifications(&merged).map_err(|e| format!("{path} with these merged: {e}"))?;
|
|
let content = if std::path::Path::new(&path)
|
|
.extension()
|
|
.is_some_and(|e| e == "json")
|
|
{
|
|
let mut json = serde_json::to_string_pretty(&serde_json::json!({
|
|
"classifications": merged,
|
|
}))
|
|
.map_err(|e| format!("encoding {path}: {e}"))?;
|
|
json.push('\n');
|
|
json
|
|
} else {
|
|
yaml(text.as_deref(), ¤t, &merged)
|
|
};
|
|
let mut message = String::from("review classifier:");
|
|
for (verb, names) in [("add", &added), ("update", &changed)] {
|
|
if !names.is_empty() {
|
|
let sep = if message.ends_with(':') { " " } else { "; " };
|
|
let _ = write!(message, "{sep}{verb} {}", names.join(", "));
|
|
}
|
|
}
|
|
Ok(Merged {
|
|
path,
|
|
sha,
|
|
content: content.into_bytes(),
|
|
message,
|
|
})
|
|
}
|
|
|
|
/// `merged` as a YAML rules file. Each entry of `text` that `merged` holds
|
|
/// unchanged at the same place keeps its text, as long as the result reads
|
|
/// back as `merged`; otherwise the whole file is written out.
|
|
fn yaml(text: Option<&str>, current: &[Classification], merged: &[Classification]) -> String {
|
|
if let Some((head, entries, indent)) = text.and_then(entry_lines) {
|
|
let mut out = head;
|
|
for (i, c) in merged.iter().enumerate() {
|
|
match entries.get(i) {
|
|
Some(kept) if current.get(i) == Some(c) => out.push_str(kept),
|
|
_ => {
|
|
if !out.is_empty() && !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&yaml_entry(c, &indent));
|
|
}
|
|
}
|
|
}
|
|
if parse_rules(".yml", out.as_bytes()).as_deref() == Ok(merged) {
|
|
return out;
|
|
}
|
|
}
|
|
let mut out = String::from("classifications:\n");
|
|
for c in merged {
|
|
out.push_str(&yaml_entry(c, " "));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `text` split into what comes before the first entry of its
|
|
/// `classifications:` list, the text of each entry up to the next one, and
|
|
/// the indent of their `-`. `None` when no entry follows that key. The split
|
|
/// is only a guess at the layout: [`yaml`] keeps it when what it builds reads
|
|
/// back as it should.
|
|
fn entry_lines(text: &str) -> Option<(String, Vec<String>, String)> {
|
|
let lines: Vec<&str> = text.split_inclusive('\n').collect();
|
|
let key = lines
|
|
.iter()
|
|
.position(|l| l.trim_end() == "classifications:")?;
|
|
let first = key
|
|
+ 1
|
|
+ lines[key + 1..]
|
|
.iter()
|
|
.position(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))?;
|
|
let indent: String = lines[first].chars().take_while(|c| *c == ' ').collect();
|
|
let mut entries: Vec<String> = Vec::new();
|
|
for line in &lines[first..] {
|
|
if line
|
|
.strip_prefix(indent.as_str())
|
|
.is_some_and(|rest| rest.starts_with("- "))
|
|
{
|
|
entries.push((*line).to_owned());
|
|
} else {
|
|
entries.last_mut()?.push_str(line);
|
|
}
|
|
}
|
|
Some((lines[..first].concat(), entries, indent))
|
|
}
|
|
|
|
/// One classification as an entry of a YAML block list, its `-` at
|
|
/// `indent`.
|
|
fn yaml_entry(c: &Classification, indent: &str) -> String {
|
|
let polarity = match c.polarity {
|
|
Polarity::Positive => "positive",
|
|
Polarity::Negative => "negative",
|
|
};
|
|
let inner = format!("{indent} ");
|
|
let mut out = format!("{indent}- name: {}\n", yaml_name(&c.name));
|
|
if foldable(&c.description) {
|
|
let _ = writeln!(out, "{inner}description: >-");
|
|
let lead = format!("{inner} ");
|
|
let mut line = String::new();
|
|
for word in c.description.split(' ') {
|
|
if !line.is_empty() && lead.len() + line.len() + 1 + word.len() > YAML_WIDTH {
|
|
let _ = writeln!(out, "{lead}{line}");
|
|
line.clear();
|
|
}
|
|
if !line.is_empty() {
|
|
line.push(' ');
|
|
}
|
|
line.push_str(word);
|
|
}
|
|
let _ = writeln!(out, "{lead}{line}");
|
|
} else {
|
|
let _ = writeln!(out, "{inner}description: {}", json_string(&c.description));
|
|
}
|
|
let _ = writeln!(out, "{inner}polarity: {polarity}");
|
|
let _ = writeln!(out, "{inner}threshold: {}", c.threshold);
|
|
out
|
|
}
|
|
|
|
/// `name` bare when it can't read as anything but that string, quoted
|
|
/// otherwise.
|
|
fn yaml_name(name: &str) -> String {
|
|
let bare = name.starts_with(|c: char| c.is_ascii_lowercase())
|
|
&& name
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
|
&& !matches!(
|
|
name,
|
|
"y" | "n" | "yes" | "no" | "on" | "off" | "true" | "false" | "null"
|
|
);
|
|
if bare {
|
|
name.to_owned()
|
|
} else {
|
|
json_string(name)
|
|
}
|
|
}
|
|
|
|
/// Whether a folded block scalar reads back as exactly `s`, which is trimmed
|
|
/// and not empty: one line of printable text, words split by single spaces.
|
|
fn foldable(s: &str) -> bool {
|
|
!s.contains(" ") && !s.chars().any(char::is_control)
|
|
}
|
|
|
|
/// `s` as a double-quoted scalar; JSON's escapes are YAML's too.
|
|
fn json_string(s: &str) -> String {
|
|
serde_json::Value::from(s).to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use swarm_queue_client::review_classifier::{Overall, Reply};
|
|
use tower::ServiceExt as _;
|
|
|
|
use super::super::tests::Fake;
|
|
use super::*;
|
|
|
|
fn rule(name: &str, description: &str) -> Classification {
|
|
Classification {
|
|
name: name.to_owned(),
|
|
description: description.to_owned(),
|
|
polarity: Polarity::Negative,
|
|
threshold: 70,
|
|
}
|
|
}
|
|
|
|
fn file(path: &str, text: &str) -> BranchFile {
|
|
BranchFile {
|
|
path: path.to_owned(),
|
|
sha: "b1".to_owned(),
|
|
bytes: text.as_bytes().to_vec(),
|
|
}
|
|
}
|
|
|
|
const OWN: &str = include_str!("../../../.hyperhive/review-classifier.yml");
|
|
|
|
#[test]
|
|
fn a_new_rule_leaves_every_existing_entry_as_it_was_written() {
|
|
let [yml, _] = RULES_PATHS;
|
|
let before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse");
|
|
let new = rule(
|
|
"no-todo",
|
|
"The diff adds a TODO or FIXME comment without a tracker reference next to it, \
|
|
which leaves the follow-up with nobody to own it.",
|
|
);
|
|
let merged = merge(Some(&file(yml, OWN)), vec![new.clone()]).expect("merges");
|
|
let text = String::from_utf8(merged.content).expect("utf-8");
|
|
assert!(text.starts_with(OWN), "{text}");
|
|
assert_eq!(merged.sha.as_deref(), Some("b1"));
|
|
assert_eq!(merged.message, "review classifier: add no-todo");
|
|
let after = parse_rules(yml, text.as_bytes()).expect("parses");
|
|
assert_eq!(after, [before, vec![new.clone()]].concat());
|
|
assert!(text.lines().all(|l| l.len() <= YAML_WIDTH), "{text}");
|
|
|
|
let unterminated = OWN.trim_end();
|
|
let merged = merge(Some(&file(yml, unterminated)), vec![new]).expect("merges");
|
|
let text = String::from_utf8(merged.content).expect("utf-8");
|
|
assert!(
|
|
text.starts_with(&format!("{unterminated}\n - name: no-todo\n")),
|
|
"{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rule_with_a_known_name_replaces_that_entry_in_place() {
|
|
let [yml, json] = RULES_PATHS;
|
|
let mut before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse");
|
|
let mut edited = before[1].clone();
|
|
edited.threshold = 90;
|
|
let padded = Classification {
|
|
name: format!(" {} ", edited.name),
|
|
..edited.clone()
|
|
};
|
|
let merged = merge(Some(&file(yml, OWN)), vec![padded.clone()]).expect("merges");
|
|
let after = parse_rules(yml, &merged.content).expect("parses");
|
|
before[1] = edited.clone();
|
|
assert_eq!(after, before);
|
|
assert_eq!(
|
|
merged.message,
|
|
format!("review classifier: update {}", edited.name)
|
|
);
|
|
|
|
let as_json = serde_json::json!({ "classifications": [&before[0], &before[2]] });
|
|
let merged = merge(
|
|
Some(&file(json, &as_json.to_string())),
|
|
vec![padded, rule("x", "y")],
|
|
)
|
|
.expect("merges");
|
|
let after = parse_rules(json, &merged.content).expect("parses");
|
|
assert_eq!(
|
|
after,
|
|
[
|
|
before[0].clone(),
|
|
before[2].clone(),
|
|
edited.clone(),
|
|
rule("x", "y")
|
|
]
|
|
);
|
|
assert_eq!(
|
|
merged.message,
|
|
format!("review classifier: add {}, x", edited.name)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_rules_file_creates_the_yaml_one() {
|
|
let rules = vec![
|
|
rule("no-ads", "No ads."),
|
|
rule("null", "N."),
|
|
rule("Yes: #1", "A \"quote\"."),
|
|
// A full line, then a double space where the wrap falls.
|
|
rule("spaced", &format!("{} y", "x".repeat(72))),
|
|
rule("broken", "A\nnewline."),
|
|
];
|
|
let merged = merge(None, rules.clone()).expect("merges");
|
|
let [yml, _] = RULES_PATHS;
|
|
assert_eq!(merged.path, yml);
|
|
assert_eq!(merged.sha, None);
|
|
let after = parse_rules(yml, &merged.content).expect("parses");
|
|
assert_eq!(after, rules);
|
|
}
|
|
|
|
#[test]
|
|
fn a_list_the_splice_cant_follow_is_written_out_whole() {
|
|
let [yml, _] = RULES_PATHS;
|
|
let flow =
|
|
"classifications: [{name: a, description: b, polarity: negative, threshold: 70}]";
|
|
let merged = merge(Some(&file(yml, flow)), vec![rule("c", "d")]).expect("merges");
|
|
let after = parse_rules(yml, &merged.content).expect("parses");
|
|
assert_eq!(after, [rule("a", "b"), rule("c", "d")]);
|
|
|
|
// Kept as it is, `b` would point at an anchor the new `a` lacks.
|
|
let aliased = "\
|
|
classifications:
|
|
- name: a
|
|
description: &shared Shared.
|
|
polarity: negative
|
|
threshold: 70
|
|
- name: b
|
|
description: *shared
|
|
polarity: negative
|
|
threshold: 70
|
|
";
|
|
let mut a = rule("a", "Its own.");
|
|
a.threshold = 90;
|
|
let merged = merge(Some(&file(yml, aliased)), vec![a.clone()]).expect("merges");
|
|
let after = parse_rules(yml, &merged.content).expect("parses");
|
|
assert_eq!(after, [a, rule("b", "Shared.")]);
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_new_or_too_many_or_a_bad_file_is_refused() {
|
|
let [yml, _] = RULES_PATHS;
|
|
let before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse");
|
|
let err = merge(Some(&file(yml, OWN)), before.clone()).expect_err("nothing to change");
|
|
assert!(err.contains("already holds"), "{err}");
|
|
let many: Vec<_> = (0..=10 - before.len())
|
|
.map(|i| rule(&format!("extra-{i}"), "d"))
|
|
.collect();
|
|
let err = merge(Some(&file(yml, OWN)), many).expect_err("eleven");
|
|
assert!(err.contains("11 classifications"), "{err}");
|
|
let err = merge(Some(&file(yml, "nope: 1")), vec![rule("a", "b")]).expect_err("bad");
|
|
assert!(err.contains(yml), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_branch_name_is_a_plain_ref_under_the_prefix() {
|
|
let name = branch_name("Alice Smith/../x").expect("a name");
|
|
let (who, hex) = name
|
|
.strip_prefix(BRANCH_PREFIX)
|
|
.and_then(|r| r.rsplit_once('-'))
|
|
.expect("prefix and suffix");
|
|
assert_eq!(who, "alice-smith----x");
|
|
assert_eq!(hex.len(), 8);
|
|
assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()), "{name}");
|
|
}
|
|
|
|
fn app(fake: Fake) -> axum::Router {
|
|
let (mut state, _) = crate::tests::state_with_roster();
|
|
state.try_outs.fake = Some(Arc::new(fake));
|
|
crate::build_app(state)
|
|
}
|
|
|
|
fn fake() -> Fake {
|
|
Fake::new(
|
|
Reply::Done {
|
|
rows: Vec::new(),
|
|
overall: Overall::Approve,
|
|
},
|
|
vec![super::super::tests::rules_json()],
|
|
)
|
|
}
|
|
|
|
fn body(repo: &str) -> String {
|
|
serde_json::json!({
|
|
"repo": repo,
|
|
"classifications": [{
|
|
"name": "no-ads",
|
|
"description": "No ads.",
|
|
"polarity": "negative",
|
|
"threshold": 80,
|
|
}],
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
async fn post(
|
|
app: &axum::Router,
|
|
user: Option<&str>,
|
|
body: String,
|
|
) -> (StatusCode, serde_json::Value) {
|
|
let mut req = Request::post("/api/review-classifier/open-pr")
|
|
.header("content-type", "application/json");
|
|
if let Some(user) = user {
|
|
req = req.header("remote-user", user);
|
|
}
|
|
let res = app
|
|
.clone()
|
|
.oneshot(req.body(Body::from(body)).expect("a request"))
|
|
.await
|
|
.expect("infallible");
|
|
let status = res.status();
|
|
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
|
|
.await
|
|
.expect("a body");
|
|
(status, serde_json::from_slice(&bytes).unwrap_or_default())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_branch_is_pushed_as_the_operator_and_linked_for_the_pr() {
|
|
let fake = fake();
|
|
let pushed = Arc::clone(&fake.pushed);
|
|
let (status, answer) = post(&app(fake), Some("alice"), body("o/r")).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{answer}");
|
|
let branch = answer["branch"].as_str().expect("a branch");
|
|
assert!(branch.starts_with("review-classifier/alice-"), "{branch}");
|
|
assert_eq!(
|
|
answer["compare_url"],
|
|
format!("https://forge.test/o/r/compare/main...{branch}")
|
|
);
|
|
let [_, json] = RULES_PATHS;
|
|
assert_eq!(answer["path"], json);
|
|
let pushed = pushed.lock().expect("unpoisoned");
|
|
let [(repo, commit)] = pushed.as_slice() else {
|
|
panic!("one push expected: {pushed:?}");
|
|
};
|
|
assert_eq!(repo, "o/r");
|
|
assert_eq!(
|
|
commit.author,
|
|
CommitAuthor {
|
|
name: "Alice".to_owned(),
|
|
email: "alice@example.test".to_owned(),
|
|
}
|
|
);
|
|
assert_eq!(commit.base, "main");
|
|
assert_eq!(commit.branch, branch);
|
|
assert_eq!(commit.sha.as_deref(), Some(format!("sha-{json}").as_str()));
|
|
let names: Vec<_> = parse_rules(json, &commit.content)
|
|
.expect("parses")
|
|
.into_iter()
|
|
.map(|c| c.name)
|
|
.collect();
|
|
assert_eq!(names, ["datetime", "no-ads"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_author_is_the_session_user_not_one_in_the_body() {
|
|
let fake = fake();
|
|
let pushed = Arc::clone(&fake.pushed);
|
|
let mut body: serde_json::Value = serde_json::from_str(&body("o/r")).expect("json");
|
|
body["author"] = "alice".into();
|
|
let (status, answer) = post(&app(fake), Some("bob"), body.to_string()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{answer}");
|
|
let pushed = pushed.lock().expect("unpoisoned");
|
|
assert_eq!(pushed[0].1.author.email, "bob@example.test");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_session_user_or_no_forge_account_pushes_nothing() {
|
|
for (user, want, says) in [
|
|
(None, StatusCode::UNAUTHORIZED, "Remote-User"),
|
|
(Some(" "), StatusCode::UNAUTHORIZED, "Remote-User"),
|
|
(
|
|
Some("mallory"),
|
|
StatusCode::CONFLICT,
|
|
"log in to the forge once",
|
|
),
|
|
] {
|
|
let fake = fake();
|
|
let pushed = Arc::clone(&fake.pushed);
|
|
let (status, problem) = post(&app(fake), user, body("o/r")).await;
|
|
assert_eq!(status, want, "{user:?}: {problem}");
|
|
let detail = problem["detail"].as_str().expect("a detail");
|
|
assert!(detail.contains(says), "{user:?}: {detail}");
|
|
assert!(pushed.lock().expect("unpoisoned").is_empty(), "{user:?}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_refused_push_names_the_missing_access() {
|
|
for (refusal, want, says) in [
|
|
(StatusCode::FORBIDDEN, StatusCode::FORBIDDEN, "write access"),
|
|
(StatusCode::NOT_FOUND, StatusCode::FORBIDDEN, "write access"),
|
|
(StatusCode::CONFLICT, StatusCode::CONFLICT, "try again"),
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"500",
|
|
),
|
|
] {
|
|
let mut fake = fake();
|
|
fake.push_refusal = Some(refusal);
|
|
let (status, problem) = post(&app(fake), Some("alice"), body("o/r")).await;
|
|
assert_eq!(status, want, "{refusal}: {problem}");
|
|
let detail = problem["detail"].as_str().expect("a detail");
|
|
assert!(detail.contains(says), "{refusal}: {detail}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_bad_body_or_repo_is_refused_before_the_forge() {
|
|
for (body, want) in [
|
|
("{".to_owned(), StatusCode::BAD_REQUEST),
|
|
(body("o"), StatusCode::BAD_REQUEST),
|
|
(
|
|
serde_json::json!({ "repo": "o/r", "classifications": [] }).to_string(),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(body("o/elsewhere"), StatusCode::NOT_FOUND),
|
|
(body("o/empty"), StatusCode::NOT_FOUND),
|
|
(body("o/down"), StatusCode::SERVICE_UNAVAILABLE),
|
|
] {
|
|
let (status, problem) = post(&app(fake()), Some("alice"), body.clone()).await;
|
|
assert_eq!(status, want, "{body}: {problem}");
|
|
}
|
|
}
|
|
}
|