Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: read review rules at a literal nested path

forgejo-api escapes the whole file path of a raw-file request, so the
rules file went out as `raw/.hyperhive%2Freview-classifier.yml` and
reached the forge only if every proxy and router in between decoded the
`%2F`. The controller now sends that one request itself: `raw_file_url`
escapes each path segment on its own and joins them with `/`, and
`forge::Client` holds a second HTTP client with the same token for it.

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

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

Part of #5096.
This commit is contained in:
atlas 2026-10-12 08:32:00 +02:00 • committed by mara
commit 2b1573c2ba
5 changed files with 259 additions and 44 deletions

View file

@ -401,8 +401,9 @@ pub(crate) async fn get_try_out(
holds, and the commit it was read at", body = Rules),
(status = 400, description = "no `repo`, or a repo that is not owner/name with \
segments of `[A-Za-z0-9._-]` other than `.` and `..` (problem+json)", body = String),
(status = 404, description = "the forge knows no such repo, the repo has no commit, \
or its default branch holds neither `.hyperhive/review-classifier.yml` nor \
(status = 404, description = "the forge knows no such repo; its default branch has no \
commit, as in an empty repo or one whose default branch doesn't exist; or its \
default branch holds neither `.hyperhive/review-classifier.yml` nor \
`.hyperhive/review-classifier.json` (problem+json)", body = String),
(status = 422, description = "the default branch holds both files; or the file does \
not parse as its extension says; or it holds a key other than `classifications`, \
@ -457,7 +458,7 @@ async fn rules(
.ok_or_else(|| {
error_problem(
StatusCode::NOT_FOUND,
&format!("{repo} has no commit, so no rules file"),
&format!("{repo} has no commit on its default branch, so no rules file"),
)
})?;
let [yml, json] = RULES_PATHS;
@ -698,7 +699,8 @@ pub(crate) mod tests {
/// A forge holding one PR, `o/r` 7, with `files` on `o/r`'s default
/// branch, and a classifier answering every run with `reply`. `o/empty`
/// has no commit, and `o/down` fails with something other than a 404.
/// has no commit, `o/down` fails with something other than a 404, and
/// `o/hang` never answers.
#[derive(Clone)]
pub(crate) struct Fake {
reply: Reply,
@ -743,6 +745,7 @@ pub(crate) mod tests {
})),
("o", "empty") => Ok(None),
("o", "down") => Err(anyhow::anyhow!("the forge answered 500")),
("o", "hang") => std::future::pending().await,
_ => Err(
ForgejoError::ApiError(ApiErrorKind::NotFound { errors: None }.into()).into(),
),
@ -1073,6 +1076,8 @@ classifications:
over_100["threshold"] = 101.into();
let mut no_name = datetime();
no_name["name"] = " ".into();
let mut bad_polarity = datetime();
bad_polarity["polarity"] = "sideways".into();
let mut no_polarity = datetime();
no_polarity
.as_object_mut()
@ -1122,6 +1127,11 @@ classifications:
rules_value(&serde_json::json!([no_polarity]), &[]),
"polarity",
),
(
"a bad polarity",
rules_value(&serde_json::json!([bad_polarity]), &[]),
"sideways",
),
(
"one name twice",
rules_value(&serde_json::json!([datetime(), datetime()]), &[]),
@ -1169,6 +1179,9 @@ classifications:
async fn a_repo_without_rules_or_with_bad_ones_is_an_error() {
let good = rules_value(&serde_json::json!([datetime()]), &[]);
let bad = rules_value(&serde_json::json!([]), &[]);
let mut sideways = datetime();
sideways["polarity"] = "sideways".into();
let sideways = rules_value(&serde_json::json!([sideways]), &[]);
let [yml, json] = RULES_PATHS;
let one = |path: &'static str, file: &serde_json::Value| vec![(path, file.to_string())];
for (query, files, want, says) in [
@ -1184,7 +1197,7 @@ classifications:
"?repo=o/empty",
Vec::new(),
StatusCode::NOT_FOUND,
"no commit",
"no commit on its default branch",
),
("?repo=o/r", Vec::new(), StatusCode::NOT_FOUND, "neither"),
(
@ -1205,6 +1218,12 @@ classifications:
StatusCode::UNPROCESSABLE_ENTITY,
"0 classifications",
),
(
"?repo=o/r",
one(yml, &sideways),
StatusCode::UNPROCESSABLE_ENTITY,
"sideways",
),
] {
let app = app_with_files(done(), files);
let (status, problem) = get_rules(&app, query).await;
@ -1213,4 +1232,25 @@ classifications:
assert!(detail.contains(says), "{query}: {detail}");
}
}
#[tokio::test]
async fn rules_without_forge_access_on_this_host_are_503() {
let (state, _) = crate::tests::state_with_roster();
let (status, problem) = get_rules(&crate::build_app(state), "?repo=o/r").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
assert_eq!(problem["detail"], "no forge access on this host yet");
}
/// The paused clock jumps to the deadline once nothing else can run, so
/// this takes no wall time.
#[tokio::test(start_paused = true)]
async fn rules_from_a_forge_that_never_answers_are_503_at_the_deadline() {
let start = tokio::time::Instant::now();
let (status, problem) = get_rules(&app(done()), "?repo=o/hang").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
assert_eq!(
problem["detail"],
"the forge did not answer within the deadline"
);
assert_eq!(start.elapsed(), forge::FORGE_DEADLINE);
}
}