use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JobOutcome {
pub job: String,
pub review: ReviewSummary,
pub landing: LandingSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReviewSummary {
pub outcome: ReviewOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub round: u32,
pub rounds: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reviewer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tried: Vec<TriedReviewer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refusals: Vec<Refusal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default)]
pub open_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open: Vec<OpenFinding>,
pub journal: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub journal_incomplete: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub journal_pending: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReviewOutcome {
Approved,
Unresolved,
Escalated,
NoVerdict,
Stopped,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TriedReviewer {
pub agent: String,
pub result: ReviewerResult,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReviewerResult {
Verdict,
Unavailable,
Declined,
Failed,
NoSlot,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Refusal {
pub agent: String,
pub reply: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpenFinding {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LandingSummary {
pub mode: LandMode,
pub status: LandingStatus,
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commits: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence: Option<LandingEvidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_by: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub landed_before_review: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unauthorized: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LandMode {
#[serde(rename = "none")]
NoLanding,
AfterApproval,
BeforeReview,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LandingStatus {
NotRequested,
NotAttempted,
Landed,
NotLanded,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LandingEvidence {
BuilderReported,
ReviewerConfirmed,
ReviewerDisputed,
Unconfirmed,
#[serde(other)]
Unknown,
}
const QUOTED_SUMMARY_CHARS: usize = 200;
const LISTED_FINDINGS: usize = 5;
const LISTED_COMMITS: usize = 3;
const QUOTED_DETAIL_CHARS: usize = 120;
const QUOTED_REFUSAL_CHARS: usize = 300;
pub fn describe_outcome(outcome: &JobOutcome) -> String {
let (review, findings) = review_lines(&outcome.review);
let mut text = review;
text.push('\n');
for finding in findings {
text.push_str(&finding);
text.push('\n');
}
for refusal in &outcome.review.refusals {
text.push_str(&format!(
" - reviewer {} declined, saying (untrusted): \"{}\"\n",
refusal.agent,
shorten(&refusal.reply, QUOTED_REFUSAL_CHARS)
));
}
text.push_str(&landing_line(&outcome.landing));
text.push('\n');
text
}
pub fn outcome_notice(outcome: &JobOutcome) -> String {
let (review, findings) = review_lines(&outcome.review);
let mut text = format!("{} · {review}", outcome.job);
for finding in findings {
text.push('\n');
text.push_str(&finding);
}
text.push_str(&format!(
"\n{} · {}",
outcome.job,
landing_line(&outcome.landing)
));
text
}
fn review_lines(review: &ReviewSummary) -> (String, Vec<String>) {
let (mut line, findings) = review_line(review);
if review.journal_incomplete && review.reason.as_deref() != Some("journal_error") {
line.push_str(&format!(
" · journal {} INCOMPLETE: a write failed",
review.journal
));
} else if review.journal_pending {
line.push_str(&format!(
" · journal {} still open: the job is still stopping, an update follows",
review.journal
));
}
(line, findings)
}
fn review_line(review: &ReviewSummary) -> (String, Vec<String>) {
let of = format!("round {} of {}", review.round, review.rounds);
let reviewer = reviewer_name(review);
let line = match review.outcome {
ReviewOutcome::Approved => format!("Review: approved · {of} · reviewer {reviewer}"),
ReviewOutcome::Unresolved => {
let noun = if review.open_count == 1 {
"finding"
} else {
"findings"
};
let mut findings: Vec<String> = review
.open
.iter()
.take(LISTED_FINDINGS)
.map(|finding| match &finding.location {
Some(location) => format!(" - \"{}\" ({location})", finding.title),
None => format!(" - \"{}\"", finding.title),
})
.collect();
let listed = u32::try_from(findings.len()).unwrap_or(u32::MAX);
if review.open_count > listed {
findings.push(format!(" - and {} more", review.open_count - listed));
}
return (
format!(
"Review: NOT approved · unresolved after {} of {} rounds · {} blocking {noun} \
open:",
review.round, review.rounds, review.open_count
),
findings,
);
}
ReviewOutcome::Escalated => {
let summary = review.summary.as_deref().unwrap_or_default();
format!(
"Review: NOT approved · escalated in {of} · reviewer {reviewer}: \"{}\"",
shorten(summary, QUOTED_SUMMARY_CHARS)
)
}
ReviewOutcome::NoVerdict => format!(
"Review: NOT approved · no verdict in {of} · {}",
no_verdict_phrase(review, &reviewer)
),
ReviewOutcome::Stopped if review.round == 0 => format!(
"Review: NOT approved · stopped before round 1 · {}",
stopped_phrase(review)
),
ReviewOutcome::Stopped => format!(
"Review: NOT approved · stopped in {of} · {}",
stopped_phrase(review)
),
ReviewOutcome::Unknown => {
format!("Review: unknown outcome · see journal {}", review.journal)
}
};
(line, Vec::new())
}
fn reviewer_name(review: &ReviewSummary) -> String {
match &review.fallback {
Some(fallback) => format!("{} ({fallback})", review.reviewer),
None => review.reviewer.clone(),
}
}
fn no_verdict_phrase(review: &ReviewSummary, reviewer: &str) -> String {
match review.reason.as_deref().unwrap_or_default() {
"reviewer_timeout" => format!("reviewer {reviewer} timed out"),
"reviewer_failed" => format!("reviewer {reviewer} failed"),
"reviewer_cancelled" => format!("reviewer {reviewer} was stopped"),
"reviewer_declined" => format!("reviewer {reviewer} declined"),
"reviewer_unavailable" => format!("reviewer {reviewer} is unavailable"),
"no_reviewer_available" => {
let tried: Vec<&str> = review
.tried
.iter()
.map(|tried| tried.agent.as_str())
.collect();
format!("no reviewer available ({})", tried.join(", "))
}
"no_conversation_slot" => "no room for a reviewer conversation".to_owned(),
"malformed_verdict" => format!("the verdict of reviewer {reviewer} stayed malformed"),
other => format!("reason {other}"),
}
}
fn stopped_phrase(review: &ReviewSummary) -> String {
match review.reason.as_deref().unwrap_or_default() {
"builder_failed" => "the builder turn failed".to_owned(),
"builder_timeout" => "the builder turn timed out".to_owned(),
"builder_declined" => "the builder declined".to_owned(),
"builder_cancelled" => "the builder turn was stopped".to_owned(),
"builder_no_session" => "the builder returned no conversation to continue".to_owned(),
"cancelled" if review.round == 0 => "the job was cancelled while queued".to_owned(),
"cancelled" => "the job was cancelled".to_owned(),
"journal_error" => "the journal could not be written".to_owned(),
other => format!("reason {other}"),
}
}
fn landing_line(landing: &LandingSummary) -> String {
let reason = landing.reason.as_deref().unwrap_or_default();
let unauthorized = if landing.unauthorized {
" · NOT authorized by this call"
} else {
""
};
match landing.status {
LandingStatus::NotRequested => "Landing: not requested".to_owned(),
LandingStatus::NotAttempted if reason.is_empty() => "Landing: not attempted".to_owned(),
LandingStatus::NotAttempted => format!("Landing: not attempted · {reason}"),
LandingStatus::Landed => {
let before = if landing.landed_before_review {
" before review"
} else {
""
};
format!(
"Landing: landed{before} · {} · {}{unauthorized}",
commits_on_ref(landing),
evidence_phrase(landing)
)
}
LandingStatus::NotLanded => format!(
"Landing: not landed · builder-reported{}{}{unauthorized}",
quoted_detail(landing),
earlier(landing)
),
LandingStatus::Failed => format!(
"Landing: failed · builder-reported · partial state possible{}{}{unauthorized}",
quoted_detail(landing),
earlier(landing)
),
LandingStatus::Unknown => {
let reason = if reason.is_empty() {
String::new()
} else {
format!(" · {reason}")
};
format!(
"Landing: unknown{reason}{} · check before relying on it{unauthorized}",
earlier(landing)
)
}
}
}
fn commits_on_ref(landing: &LandingSummary) -> String {
let mut commits: Vec<&str> = landing
.commits
.iter()
.take(LISTED_COMMITS)
.map(|commit| commit.get(..7).unwrap_or(commit))
.collect();
let more = landing.commits.len().saturating_sub(LISTED_COMMITS);
let more = if more > 0 {
format!(" +{more} more")
} else {
String::new()
};
if commits.is_empty() {
commits.push("no commits named");
}
format!(
"{}{more} → {}",
commits.join(", "),
landing.reference.as_deref().unwrap_or("an unnamed ref")
)
}
fn evidence_phrase(landing: &LandingSummary) -> String {
let checker = landing.checked_by.as_deref().unwrap_or("reviewer");
let reason = landing.reason.as_deref().unwrap_or("no reason given");
match landing.evidence {
Some(LandingEvidence::ReviewerConfirmed) => format!("confirmed by reviewer {checker}"),
Some(LandingEvidence::ReviewerDisputed) => {
format!("reviewer {checker} could NOT confirm: {reason}")
}
Some(LandingEvidence::Unconfirmed) => format!("NOT confirmed: {reason}"),
Some(LandingEvidence::Unknown) => "evidence unknown".to_owned(),
Some(LandingEvidence::BuilderReported) | None => {
"builder-reported, not verified".to_owned()
}
}
}
fn quoted_detail(landing: &LandingSummary) -> String {
landing
.detail
.as_deref()
.filter(|detail| !detail.is_empty())
.map_or_else(String::new, |detail| {
format!(": \"{}\"", shorten(detail, QUOTED_DETAIL_CHARS))
})
}
fn earlier(landing: &LandingSummary) -> String {
if landing.commits.is_empty() {
String::new()
} else {
format!("; earlier: {}", commits_on_ref(landing))
}
}
fn shorten(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_owned();
}
let mut short: String = text.chars().take(limit.saturating_sub(1)).collect();
short.push('…');
short
}
#[cfg(test)]
mod tests;