review try-out: open a PR with the classifications on the page
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
This commit is contained in:
parent
6ed88ad70a
commit
49b15e7775
10 changed files with 1231 additions and 53 deletions
|
|
@ -2,7 +2,8 @@
|
|||
//! `GET /api/review-classifier/try-out/{id}`: run classifications against a
|
||||
//! PR from the swarm UI, then read the result back.
|
||||
//! `GET /api/review-classifier/rules`: a repo's own classifications, from
|
||||
//! the rules file on its default branch.
|
||||
//! the rules file on its default branch. [`open_pr`] puts classifications on
|
||||
//! a new branch of the repo, for a PR.
|
||||
//!
|
||||
//! The POST reads the PR with this daemon's forge token, sends one request to
|
||||
//! the review classifier on [`SUBJECT`] and answers 202 with an id at once:
|
||||
|
|
@ -39,6 +40,7 @@ use self::settings::{Settings, SettingsSource};
|
|||
use crate::forge::{self, DefaultBranchFiles, PullForReview};
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
pub(crate) mod open_pr;
|
||||
pub(crate) mod review;
|
||||
pub(crate) mod runs;
|
||||
pub(crate) mod settings;
|
||||
|
|
@ -496,7 +498,7 @@ async fn rules(
|
|||
),
|
||||
));
|
||||
}
|
||||
[file] => file,
|
||||
[file] => (&file.path, &file.bytes),
|
||||
_ => {
|
||||
return Err(error_problem(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -765,17 +767,23 @@ pub(crate) mod tests {
|
|||
use tower::ServiceExt as _;
|
||||
|
||||
use super::*;
|
||||
use crate::forge::BranchFile;
|
||||
use crate::forge::classifier_account::{BranchCommit, CommitAuthor};
|
||||
|
||||
/// A forge holding one PR, `o/r` 7, with `files` on `o/r`'s default
|
||||
/// branch, `settings` stored, and a classifier answering every run with
|
||||
/// `reply` and keeping its request. `o/empty` has no commit, `o/down`
|
||||
/// fails with something other than a 404, and `o/hang` never answers.
|
||||
/// The forge has the accounts `alice` and `bob`, and keeps each branch
|
||||
/// pushed with its repo, or refuses it with `push_refusal`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Fake {
|
||||
reply: Reply,
|
||||
files: Vec<(&'static str, String)>,
|
||||
pub(crate) settings: Settings,
|
||||
pub(crate) sent: Arc<std::sync::Mutex<Vec<TryOut>>>,
|
||||
pub(crate) pushed: Arc<std::sync::Mutex<Vec<(String, BranchCommit)>>>,
|
||||
pub(crate) push_refusal: Option<StatusCode>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
|
|
@ -785,10 +793,37 @@ pub(crate) mod tests {
|
|||
files,
|
||||
settings: Settings::default(),
|
||||
sent: Arc::default(),
|
||||
pushed: Arc::default(),
|
||||
push_refusal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl open_pr::BranchSink for Fake {
|
||||
async fn commit_author(&self, login: &str) -> anyhow::Result<Option<CommitAuthor>> {
|
||||
let name = match login {
|
||||
"alice" => "Alice",
|
||||
"bob" => "bob",
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(CommitAuthor {
|
||||
name: name.to_owned(),
|
||||
email: format!("{login}@example.test"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn push(&self, owner: &str, repo: &str, commit: BranchCommit) -> anyhow::Result<()> {
|
||||
if let Some(status) = self.push_refusal {
|
||||
return Err(ForgejoError::UnexpectedStatusCode(status).into());
|
||||
}
|
||||
self.pushed
|
||||
.lock()
|
||||
.expect("unpoisoned")
|
||||
.push((format!("{owner}/{repo}"), commit));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsSource for Fake {
|
||||
async fn settings(&self) -> anyhow::Result<Settings> {
|
||||
Ok(self.settings.clone())
|
||||
|
|
@ -825,10 +860,15 @@ pub(crate) mod tests {
|
|||
("o", "r") => Ok(Some(DefaultBranchFiles {
|
||||
branch: "main".to_owned(),
|
||||
commit: "def456".to_owned(),
|
||||
web_url: Some(url::Url::parse("https://forge.test/o/r").expect("a URL")),
|
||||
files: paths
|
||||
.iter()
|
||||
.filter_map(|p| self.files.iter().find(|(f, _)| f == p))
|
||||
.map(|(f, body)| ((*f).to_owned(), body.clone().into_bytes()))
|
||||
.map(|(f, body)| BranchFile {
|
||||
path: (*f).to_owned(),
|
||||
sha: format!("sha-{f}"),
|
||||
bytes: body.clone().into_bytes(),
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
("o", "empty") => Ok(None),
|
||||
|
|
|
|||
Loading…
Reference in a new issue