use argonauts_core::TaskId;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::task::ReviewEvent;
use argonauts_core::names::{ModelRef, RoleName};
use argonauts_core::settings::fallback::Pick;
use argonauts_core::steps::judgment::{ReviewComment, ReviewOutput, ReviewVerdict};
use argonauts_core::{Note, NoteLevel};
use crate::engine::host::Host;
use crate::engine::job_fallback::{self, Job};
use crate::error::EngineResult;
use crate::fallback::rules;
use crate::labels;
use crate::roles::display_name;
pub(super) fn rules_for(host: &Host, reviewer: &ModelRef) -> Option<(usize, Pick)> {
let applied = rules::applied(host, RoleName::Reviewer, &reviewer.agent_id());
let together = usize::try_from(applied.together).unwrap_or(usize::MAX);
let pick = applied
.pick
.unwrap_or(Pick::default_for(RoleName::Reviewer));
(together >= 2).then_some((together, pick))
}
pub(super) async fn from_the_start(host: &Host, job: &Job, reviewer: &ModelRef) -> Vec<ModelRef> {
let Some((together, _)) = rules_for(host, reviewer) else {
return Vec::new();
};
if !job_fallback::is_out(host, reviewer).await {
return Vec::new();
}
job_fallback::together(host, job, reviewer, &[], together)
}
pub(super) fn joining(
host: &Host,
job: &Job,
reviewer: &ModelRef,
first: &ModelRef,
) -> Vec<ModelRef> {
match rules_for(host, reviewer) {
Some((together, _)) => job_fallback::together(host, job, reviewer, &[first], together - 1),
None => Vec::new(),
}
}
pub(super) fn given(host: &Host, task: &TaskId, round: u32, by: &ModelRef) -> Option<ReviewOutput> {
host.read(|state| {
let reviews = &state.tasks.get(task)?.reviews;
reviews.iter().find_map(|event| match event {
ReviewEvent::Reviewed {
reviewer,
round: r,
review,
..
} if *r == round && reviewer == by => Some(review.clone()),
_ => None,
})
})
}
pub(super) fn say_who_reviews(
host: &Host,
task: &TaskId,
round: u32,
reviewer: &ModelRef,
more: &[ModelRef],
already: &[&ModelRef],
) -> EngineResult<()> {
if more.iter().all(|by| given(host, task, round, by).is_some()) {
return Ok(());
}
let label = host.read(|state| labels::task_label(state, task));
let names: Vec<String> = already
.iter()
.copied()
.chain(more)
.map(display_name)
.collect();
let who = match names.as_slice() {
[] | [_] => return Ok(()),
[one, other] => format!("{one} and {other} both review"),
[rest @ .., last] => format!("{} and {last} all review", rest.join(", ")),
};
let pick = rules_for(host, reviewer).map_or(Pick::Merge, |(_, pick)| pick);
let how = match pick {
Pick::Judge => "the judge settles them if they disagree",
Pick::Merge | Pick::FirstPassing => "every finding from each is kept",
};
let text = format!(
"{who} {label} in place of {} · {how} · /fallback changes this",
display_name(reviewer)
);
let level = NoteLevel::Info;
host.record(Some(task), None, RunEventKind::Note(Note { level, text }))?;
Ok(())
}
pub(super) fn counted_by_reviewer(
host: &Host,
reviews: Vec<(ModelRef, ModelRef, ReviewOutput)>,
) -> Vec<ReviewOutput> {
let mut by_reviewer: Vec<(ModelRef, Vec<ReviewOutput>)> = Vec::new();
for (asked, _, review) in reviews {
match by_reviewer.iter_mut().find(|(known, _)| *known == asked) {
Some((_, theirs)) => theirs.push(review),
None => by_reviewer.push((asked, vec![review])),
}
}
by_reviewer
.into_iter()
.flat_map(|(asked, theirs)| match rules_for(host, &asked) {
Some((_, pick)) => counted(theirs, pick),
None => theirs,
})
.collect()
}
pub(super) fn counted(reviews: Vec<ReviewOutput>, pick: Pick) -> Vec<ReviewOutput> {
match pick {
Pick::Judge => reviews,
Pick::Merge | Pick::FirstPassing if reviews.len() < 2 => reviews,
Pick::Merge | Pick::FirstPassing => vec![merged(&reviews)],
}
}
fn merged(reviews: &[ReviewOutput]) -> ReviewOutput {
let severity = |verdict: ReviewVerdict| match verdict {
ReviewVerdict::Approve => 0,
ReviewVerdict::RequestChanges => 1,
ReviewVerdict::Reject => 2,
};
let verdict = reviews
.iter()
.map(|review| review.verdict)
.max_by_key(|verdict| severity(*verdict))
.unwrap_or(ReviewVerdict::Approve);
let mut summaries: Vec<&str> = Vec::new();
let mut comments: Vec<ReviewComment> = Vec::new();
for review in reviews {
let summary = review.summary.trim();
if !summary.is_empty() && !summaries.iter().any(|kept| same_words(kept, summary)) {
summaries.push(summary);
}
for comment in &review.comments {
let repeat = comments.iter().any(|kept| {
kept.path == comment.path
&& kept.line == comment.line
&& same_words(&kept.text, &comment.text)
});
if !repeat {
comments.push(comment.clone());
}
}
}
ReviewOutput {
verdict,
summary: summaries.join(" "),
comments,
tamper_suspected: reviews.iter().any(|review| review.tamper_suspected),
}
}
fn same_words(a: &str, b: &str) -> bool {
let words =
|text: &str| -> Vec<String> { text.split_whitespace().map(str::to_lowercase).collect() };
words(a) == words(b)
}
#[cfg(test)]
mod tests {
use argonauts_core::steps::plan::Severity;
use super::*;
fn comment(path: &str, line: u32, text: &str) -> ReviewComment {
ReviewComment {
path: path.to_owned(),
line: Some(line),
severity: Severity::High,
text: text.to_owned(),
}
}
fn review(verdict: ReviewVerdict, summary: &str, comments: Vec<ReviewComment>) -> ReviewOutput {
ReviewOutput {
verdict,
summary: summary.to_owned(),
comments,
tamper_suspected: false,
}
}
#[test]
fn merge_keeps_every_finding_once_and_the_most_severe_verdict() {
let missing_test = comment("src/due.rs", 12, "The due date needs a test.");
let first = review(
ReviewVerdict::Approve,
"Reads well.",
vec![missing_test.clone()],
);
let second = review(
ReviewVerdict::RequestChanges,
"A case is missing.",
vec![
comment("src/due.rs", 12, "the due date needs a test."),
comment("src/due.rs", 30, "An empty date panics."),
],
);
let reviews = vec![first, second];
let counted_as = counted(reviews.clone(), Pick::Merge);
assert_eq!(counted_as.len(), 1);
let one = &counted_as[0];
assert_eq!(one.verdict, ReviewVerdict::RequestChanges);
assert_eq!(one.summary, "Reads well. A case is missing.");
assert_eq!(
one.comments,
[
missing_test,
comment("src/due.rs", 30, "An empty date panics.")
]
);
assert_eq!(counted(reviews.clone(), Pick::FirstPassing), counted_as);
assert_eq!(counted(reviews.clone(), Pick::Judge), reviews);
}
}