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_wire::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::routes::{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_wire::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::routes::tests::state_with_roster();
|
|
state.try_outs.fake = Some(Arc::new(fake));
|
|
crate::startup::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}");
|
|
}
|
|
}
|
|
}
|