use std::process::ExitCode;
use crate::architect;
use crate::base_fix::Advice;
use crate::command_log;
use crate::failed_run::FailedRun;
use crate::progress::{self, ChildLine};
use crate::run::Ended;
const SESSION_LOG: &str = "session log: ";
const COMMAND_LOG: &str = "command log: ";
const BASE_FIX: &str = "Base fix: ";
pub fn show(ended: &Ended) -> ExitCode {
Shown::of(ended).print()
}
pub fn show_failure(failed: &FailedRun) -> ExitCode {
Shown::of_failure(failed, &[]).print()
}
pub fn show_architect(outcome: &architect::Outcome) -> ExitCode {
Shown {
steps: vec![outcome.to_string()],
urls: outcome.urls().into_iter().map(String::from).collect(),
success: true,
}
.print()
}
struct Shown {
steps: Vec<String>,
urls: Vec<String>,
success: bool,
}
impl Shown {
fn of(ended: &Ended) -> Self {
let mut shown = match &ended.outcome {
Ok(reached) => Shown {
steps: vec![format!(
"PR {} is {}",
reached.pr_url,
reached.goal.outcome()
)],
urls: vec![reached.pr_url.clone()],
success: true,
},
Err(failed) => Shown::of_failure(failed, &ended.advice),
};
if let Some(report) = &ended.base_fix {
shown.steps.insert(0, format!("{BASE_FIX}{report}"));
}
shown
}
fn of_failure(failed: &FailedRun, advice: &[Advice]) -> Self {
let mut steps = vec![format!("{:#}", failed.error)];
steps.extend(advice.iter().map(Advice::to_string));
if let Some(log) = &failed.log {
steps.push(format!("{SESSION_LOG}{}", log.display()));
}
if let Some(log) = command_log::path() {
steps.push(format!("{COMMAND_LOG}{}", log.display()));
}
Shown {
steps,
urls: failed.pr_url.iter().cloned().collect(),
success: false,
}
}
fn print(self) -> ExitCode {
for step in self.steps {
progress::step(step);
}
for url in self.urls {
command_log::print(&url);
}
if self.success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct ChildEnding {
pub outcome: Result<Option<String>, ChildFailure>,
pub base_fix: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ChildFailure {
pub cause: Option<String>,
pub log: Option<String>,
}
#[derive(Default)]
pub struct Reader {
cause: Option<String>,
log: Option<String>,
base_fix: Option<String>,
}
impl Reader {
pub fn read(&mut self, line: ChildLine<'_>) {
let ChildLine::Own(message) = line else {
return;
};
if let Some(report) = message.strip_prefix(BASE_FIX) {
self.base_fix = Some(report.to_string());
} else if let Some(log) = message.strip_prefix(SESSION_LOG) {
self.log = Some(log.to_string());
} else if !Advice::is_line(message) {
self.cause = Some(message.to_string());
self.log = None;
}
}
pub fn finish(self, stdout: &str, exited_zero: bool) -> ChildEnding {
let outcome = if exited_zero {
Ok(stdout.lines().last().map(String::from))
} else {
Err(ChildFailure {
cause: self.cause,
log: self.log,
})
};
ChildEnding {
outcome,
base_fix: self.base_fix,
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use anyhow::anyhow;
use super::*;
use crate::run::{Goal, Reached};
const PR: &str = "https://github.com/acme/widgets/pull/31";
const LOG: &str = "/home/me/.thirdshift/logs/widgets-21-implement.jsonl";
const BASE_FIX_ISSUE: &str = "https://github.com/acme/widgets/issues/8";
fn reached(goal: Goal) -> Ended {
Ended {
outcome: Ok(Reached {
pr_url: PR.to_string(),
goal,
log: Some(PathBuf::from(LOG)),
ticket_lines: Vec::new(),
}),
base_fix: None,
advice: Vec::new(),
}
}
fn failed(cause: &str, log: Option<&str>, pr_url: Option<&str>) -> Ended {
Ended {
outcome: Err(FailedRun {
error: anyhow!("{cause}"),
pr_url: pr_url.map(String::from),
log: log.map(PathBuf::from),
interrupted: false,
ticket_lines: Vec::new(),
}),
base_fix: None,
advice: Vec::new(),
}
}
fn with_base_fix(ended: Ended, became: &str) -> Ended {
Ended {
base_fix: Some(format!("{BASE_FIX_ISSUE} {became}")),
..ended
}
}
fn stderr(shown: &Shown) -> Vec<String> {
shown
.steps
.iter()
.flat_map(|step| {
progress::stamped(step)
.lines()
.map(String::from)
.collect::<Vec<_>>()
})
.collect()
}
fn read_back_after(before: &[String], ended: &Ended) -> ChildEnding {
let shown = Shown::of(ended);
let mut reader = Reader::default();
for line in before.iter().chain(&stderr(&shown)) {
reader.read(ChildLine::of(line));
}
let stdout = shown
.urls
.iter()
.map(|url| format!("{url}\n"))
.collect::<String>();
reader.finish(&stdout, shown.success)
}
fn read_back(ended: &Ended) -> ChildEnding {
read_back_after(&[], ended)
}
fn failure(cause: &str, log: Option<&str>) -> Result<Option<String>, ChildFailure> {
Err(ChildFailure {
cause: Some(cause.to_string()),
log: log.map(String::from),
})
}
#[test]
fn a_run_that_reached_its_goal_reads_back_as_its_pull_request() {
for goal in [Goal::ReadyForReview, Goal::Merged] {
assert_eq!(
read_back(&reached(goal)),
ChildEnding {
outcome: Ok(Some(PR.to_string())),
base_fix: None,
}
);
}
}
#[test]
fn a_run_that_reached_its_goal_after_a_base_fix_reads_back_with_what_became_of_it() {
assert_eq!(
read_back(&with_base_fix(reached(Goal::Merged), "merged")),
ChildEnding {
outcome: Ok(Some(PR.to_string())),
base_fix: Some(format!("{BASE_FIX_ISSUE} merged")),
}
);
}
#[test]
fn a_failed_run_reads_back_as_its_cause_and_its_session_log() {
assert_eq!(
read_back(&failed("claude exited 1", Some(LOG), None)),
ChildEnding {
outcome: failure("claude exited 1", Some(LOG)),
base_fix: None,
}
);
}
#[test]
fn a_failed_run_with_no_session_log_reads_back_as_its_cause() {
assert_eq!(
read_back(&failed("origin mismatch", None, None)),
ChildEnding {
outcome: failure("origin mismatch", None),
base_fix: None,
}
);
}
#[test]
fn a_failed_run_that_left_a_pull_request_open_reads_back_as_a_failure() {
assert_eq!(
read_back(&failed("CI red on test", Some(LOG), Some(PR))),
ChildEnding {
outcome: failure("CI red on test", Some(LOG)),
base_fix: None,
}
);
}
#[test]
fn a_cause_of_several_lines_reads_back_as_its_first_line() {
let cause = "git fetch origin issue-21 failed: \
error: fetching ref refs/remotes/origin/issue-21 failed\n\
From https://github.com/acme/widgets\n\
* branch issue-21 -> FETCH_HEAD";
for log in [Some(LOG), None] {
assert_eq!(
read_back(&failed(cause, log, None)),
ChildEnding {
outcome: failure(
"git fetch origin issue-21 failed: \
error: fetching ref refs/remotes/origin/issue-21 failed",
log
),
base_fix: None,
}
);
}
}
#[test]
fn the_advice_a_failed_run_gives_is_neither_its_cause_nor_its_session_log() {
let advice = vec![
Advice {
label: "Base check",
value: "test: https://ci.example/main/test".to_string(),
},
Advice {
label: "Retry with",
value: "thirdshift base-fix https://github.com/acme/widgets/issues/21".to_string(),
},
Advice {
label: "Or set",
value: "base.fix = true".to_string(),
},
];
let cause = "CI red on test, which fails on main too: fix main first";
for log in [Some(LOG), None] {
let ended = Ended {
advice: advice.clone(),
..failed(cause, log, Some(PR))
};
assert_eq!(
read_back(&ended),
ChildEnding {
outcome: failure(cause, log),
base_fix: None,
}
);
}
}
#[test]
fn a_run_that_failed_after_a_base_fix_reads_back_with_what_became_of_it() {
let cause = format!("Base fix {BASE_FIX_ISSUE} failed: claude exited 1");
assert_eq!(
read_back(&with_base_fix(
failed(&cause, Some(LOG), Some(PR)),
"not merged"
)),
ChildEnding {
outcome: failure(&cause, Some(LOG)),
base_fix: Some(format!("{BASE_FIX_ISSUE} not merged")),
}
);
}
#[test]
fn lines_relayed_from_a_child_of_its_own_are_not_its_ending() {
let base_fix = with_base_fix(
failed(
"claude exited 1\nno such model",
Some("/logs/8.jsonl"),
None,
),
"not merged",
);
let relayed: Vec<String> = stderr(&Shown::of(&base_fix))
.iter()
.map(|line| progress::relayed(8, line))
.collect();
assert_eq!(
read_back_after(&relayed, &failed("interrupted", None, None)),
ChildEnding {
outcome: failure("interrupted", None),
base_fix: None,
}
);
assert_eq!(
read_back_after(&relayed, &reached(Goal::Merged)),
ChildEnding {
outcome: Ok(Some(PR.to_string())),
base_fix: None,
}
);
}
#[test]
fn a_child_that_showed_nothing_reads_back_with_no_cause() {
let nothing = ChildEnding {
outcome: Err(ChildFailure {
cause: None,
log: None,
}),
base_fix: None,
};
assert_eq!(Reader::default().finish("", false), nothing);
let mut reader = Reader::default();
for line in stderr(&Shown::of(&with_base_fix(
failed("claude exited 1", Some(LOG), None),
"not merged",
))) {
reader.read(ChildLine::of(&progress::relayed(8, &line)));
}
assert_eq!(reader.finish("", false), nothing);
}
}