1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct JobOutcome {
12 pub job: String,
14 pub review: ReviewSummary,
16 pub landing: LandingSummary,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ReviewSummary {
23 pub outcome: ReviewOutcome,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub reason: Option<String>,
28 pub round: u32,
30 pub rounds: u32,
32 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub reviewer: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub fallback: Option<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub tried: Vec<TriedReviewer>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub refusals: Vec<Refusal>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub summary: Option<String>,
48 #[serde(default)]
50 pub open_count: u32,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub open: Vec<OpenFinding>,
54 pub journal: String,
56 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
60 pub journal_incomplete: bool,
61 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
66 pub journal_pending: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum ReviewOutcome {
74 Approved,
76 Unresolved,
78 Escalated,
80 NoVerdict,
83 Stopped,
86 #[serde(other)]
88 Unknown,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct TriedReviewer {
94 pub agent: String,
96 pub result: ReviewerResult,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103#[non_exhaustive]
104pub enum ReviewerResult {
105 Verdict,
107 Unavailable,
109 Declined,
111 Failed,
113 NoSlot,
115 #[serde(other)]
117 Unknown,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct Refusal {
123 pub agent: String,
125 pub reply: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct OpenFinding {
132 pub id: String,
134 pub title: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub location: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct LandingSummary {
144 pub mode: LandMode,
146 pub status: LandingStatus,
148 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
150 pub reference: Option<String>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub commits: Vec<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub evidence: Option<LandingEvidence>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub checked_by: Option<String>,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162 pub landed_before_review: bool,
163 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165 pub unauthorized: bool,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub reason: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub detail: Option<String>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum LandMode {
179 #[serde(rename = "none")]
181 NoLanding,
182 AfterApproval,
184 BeforeReview,
186 #[serde(other)]
188 Unknown,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194#[non_exhaustive]
195pub enum LandingStatus {
196 NotRequested,
198 NotAttempted,
200 Landed,
202 NotLanded,
204 Failed,
206 #[serde(other)]
209 Unknown,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216#[non_exhaustive]
217pub enum LandingEvidence {
218 BuilderReported,
220 ReviewerConfirmed,
222 ReviewerDisputed,
224 Unconfirmed,
226 #[serde(other)]
228 Unknown,
229}
230
231const QUOTED_SUMMARY_CHARS: usize = 200;
233const LISTED_FINDINGS: usize = 5;
235const LISTED_COMMITS: usize = 3;
237const QUOTED_DETAIL_CHARS: usize = 120;
239const QUOTED_REFUSAL_CHARS: usize = 300;
241
242pub fn describe_outcome(outcome: &JobOutcome) -> String {
246 let (review, findings) = review_lines(&outcome.review);
247 let mut text = review;
248 text.push('\n');
249 for finding in findings {
250 text.push_str(&finding);
251 text.push('\n');
252 }
253 for refusal in &outcome.review.refusals {
254 text.push_str(&format!(
255 " - reviewer {} declined, saying (untrusted): \"{}\"\n",
256 refusal.agent,
257 shorten(&refusal.reply, QUOTED_REFUSAL_CHARS)
258 ));
259 }
260 text.push_str(&landing_line(&outcome.landing));
261 text.push('\n');
262 text
263}
264
265pub fn outcome_notice(outcome: &JobOutcome) -> String {
270 let (review, findings) = review_lines(&outcome.review);
271 let mut text = format!("{} · {review}", outcome.job);
272 for finding in findings {
273 text.push('\n');
274 text.push_str(&finding);
275 }
276 text.push_str(&format!(
277 "\n{} · {}",
278 outcome.job,
279 landing_line(&outcome.landing)
280 ));
281 text
282}
283
284fn review_lines(review: &ReviewSummary) -> (String, Vec<String>) {
286 let (mut line, findings) = review_line(review);
287 if review.journal_incomplete && review.reason.as_deref() != Some("journal_error") {
288 line.push_str(&format!(
289 " · journal {} INCOMPLETE: a write failed",
290 review.journal
291 ));
292 } else if review.journal_pending {
293 line.push_str(&format!(
294 " · journal {} still open: the job is still stopping, an update follows",
295 review.journal
296 ));
297 }
298 (line, findings)
299}
300
301fn review_line(review: &ReviewSummary) -> (String, Vec<String>) {
302 let of = format!("round {} of {}", review.round, review.rounds);
303 let reviewer = reviewer_name(review);
304 let line = match review.outcome {
305 ReviewOutcome::Approved => format!("Review: approved · {of} · reviewer {reviewer}"),
306 ReviewOutcome::Unresolved => {
307 let noun = if review.open_count == 1 {
308 "finding"
309 } else {
310 "findings"
311 };
312 let mut findings: Vec<String> = review
313 .open
314 .iter()
315 .take(LISTED_FINDINGS)
316 .map(|finding| match &finding.location {
317 Some(location) => format!(" - \"{}\" ({location})", finding.title),
318 None => format!(" - \"{}\"", finding.title),
319 })
320 .collect();
321 let listed = u32::try_from(findings.len()).unwrap_or(u32::MAX);
322 if review.open_count > listed {
323 findings.push(format!(" - and {} more", review.open_count - listed));
324 }
325 return (
326 format!(
327 "Review: NOT approved · unresolved after {} of {} rounds · {} blocking {noun} \
328 open:",
329 review.round, review.rounds, review.open_count
330 ),
331 findings,
332 );
333 }
334 ReviewOutcome::Escalated => {
335 let summary = review.summary.as_deref().unwrap_or_default();
336 format!(
337 "Review: NOT approved · escalated in {of} · reviewer {reviewer}: \"{}\"",
338 shorten(summary, QUOTED_SUMMARY_CHARS)
339 )
340 }
341 ReviewOutcome::NoVerdict => format!(
342 "Review: NOT approved · no verdict in {of} · {}",
343 no_verdict_phrase(review, &reviewer)
344 ),
345 ReviewOutcome::Stopped if review.round == 0 => format!(
346 "Review: NOT approved · stopped before round 1 · {}",
347 stopped_phrase(review)
348 ),
349 ReviewOutcome::Stopped => format!(
350 "Review: NOT approved · stopped in {of} · {}",
351 stopped_phrase(review)
352 ),
353 ReviewOutcome::Unknown => {
354 format!("Review: unknown outcome · see journal {}", review.journal)
355 }
356 };
357 (line, Vec::new())
358}
359
360fn reviewer_name(review: &ReviewSummary) -> String {
362 match &review.fallback {
363 Some(fallback) => format!("{} ({fallback})", review.reviewer),
364 None => review.reviewer.clone(),
365 }
366}
367
368fn no_verdict_phrase(review: &ReviewSummary, reviewer: &str) -> String {
369 match review.reason.as_deref().unwrap_or_default() {
370 "reviewer_timeout" => format!("reviewer {reviewer} timed out"),
371 "reviewer_failed" => format!("reviewer {reviewer} failed"),
372 "reviewer_cancelled" => format!("reviewer {reviewer} was stopped"),
373 "reviewer_declined" => format!("reviewer {reviewer} declined"),
374 "reviewer_unavailable" => format!("reviewer {reviewer} is unavailable"),
375 "no_reviewer_available" => {
376 let tried: Vec<&str> = review
377 .tried
378 .iter()
379 .map(|tried| tried.agent.as_str())
380 .collect();
381 format!("no reviewer available ({})", tried.join(", "))
382 }
383 "no_conversation_slot" => "no room for a reviewer conversation".to_owned(),
384 "malformed_verdict" => format!("the verdict of reviewer {reviewer} stayed malformed"),
385 other => format!("reason {other}"),
386 }
387}
388
389fn stopped_phrase(review: &ReviewSummary) -> String {
390 match review.reason.as_deref().unwrap_or_default() {
391 "builder_failed" => "the builder turn failed".to_owned(),
392 "builder_timeout" => "the builder turn timed out".to_owned(),
393 "builder_declined" => "the builder declined".to_owned(),
394 "builder_cancelled" => "the builder turn was stopped".to_owned(),
395 "builder_no_session" => "the builder returned no conversation to continue".to_owned(),
396 "cancelled" if review.round == 0 => "the job was cancelled while queued".to_owned(),
397 "cancelled" => "the job was cancelled".to_owned(),
398 "journal_error" => "the journal could not be written".to_owned(),
399 other => format!("reason {other}"),
400 }
401}
402
403fn landing_line(landing: &LandingSummary) -> String {
405 let reason = landing.reason.as_deref().unwrap_or_default();
406 let unauthorized = if landing.unauthorized {
407 " · NOT authorized by this call"
408 } else {
409 ""
410 };
411 match landing.status {
412 LandingStatus::NotRequested => "Landing: not requested".to_owned(),
413 LandingStatus::NotAttempted if reason.is_empty() => "Landing: not attempted".to_owned(),
414 LandingStatus::NotAttempted => format!("Landing: not attempted · {reason}"),
415 LandingStatus::Landed => {
416 let before = if landing.landed_before_review {
417 " before review"
418 } else {
419 ""
420 };
421 format!(
422 "Landing: landed{before} · {} · {}{unauthorized}",
423 commits_on_ref(landing),
424 evidence_phrase(landing)
425 )
426 }
427 LandingStatus::NotLanded => format!(
428 "Landing: not landed · builder-reported{}{}{unauthorized}",
429 quoted_detail(landing),
430 earlier(landing)
431 ),
432 LandingStatus::Failed => format!(
433 "Landing: failed · builder-reported · partial state possible{}{}{unauthorized}",
434 quoted_detail(landing),
435 earlier(landing)
436 ),
437 LandingStatus::Unknown => {
438 let reason = if reason.is_empty() {
439 String::new()
440 } else {
441 format!(" · {reason}")
442 };
443 format!(
444 "Landing: unknown{reason}{} · check before relying on it{unauthorized}",
445 earlier(landing)
446 )
447 }
448 }
449}
450
451fn commits_on_ref(landing: &LandingSummary) -> String {
453 let mut commits: Vec<&str> = landing
454 .commits
455 .iter()
456 .take(LISTED_COMMITS)
457 .map(|commit| commit.get(..7).unwrap_or(commit))
458 .collect();
459 let more = landing.commits.len().saturating_sub(LISTED_COMMITS);
460 let more = if more > 0 {
461 format!(" +{more} more")
462 } else {
463 String::new()
464 };
465 if commits.is_empty() {
466 commits.push("no commits named");
467 }
468 format!(
469 "{}{more} → {}",
470 commits.join(", "),
471 landing.reference.as_deref().unwrap_or("an unnamed ref")
472 )
473}
474
475fn evidence_phrase(landing: &LandingSummary) -> String {
476 let checker = landing.checked_by.as_deref().unwrap_or("reviewer");
477 let reason = landing.reason.as_deref().unwrap_or("no reason given");
478 match landing.evidence {
479 Some(LandingEvidence::ReviewerConfirmed) => format!("confirmed by reviewer {checker}"),
480 Some(LandingEvidence::ReviewerDisputed) => {
481 format!("reviewer {checker} could NOT confirm: {reason}")
482 }
483 Some(LandingEvidence::Unconfirmed) => format!("NOT confirmed: {reason}"),
484 Some(LandingEvidence::Unknown) => "evidence unknown".to_owned(),
485 Some(LandingEvidence::BuilderReported) | None => {
486 "builder-reported, not verified".to_owned()
487 }
488 }
489}
490
491fn quoted_detail(landing: &LandingSummary) -> String {
493 landing
494 .detail
495 .as_deref()
496 .filter(|detail| !detail.is_empty())
497 .map_or_else(String::new, |detail| {
498 format!(": \"{}\"", shorten(detail, QUOTED_DETAIL_CHARS))
499 })
500}
501
502fn earlier(landing: &LandingSummary) -> String {
505 if landing.commits.is_empty() {
506 String::new()
507 } else {
508 format!("; earlier: {}", commits_on_ref(landing))
509 }
510}
511
512fn shorten(text: &str, limit: usize) -> String {
514 if text.chars().count() <= limit {
515 return text.to_owned();
516 }
517 let mut short: String = text.chars().take(limit.saturating_sub(1)).collect();
518 short.push('…');
519 short
520}
521
522#[cfg(test)]
523mod tests;