Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/review_classifier/open_pr.rs

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(), &current, &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}");
}
}
}