use std::process::ExitCode;
use crate::base_fix::Advice;
use crate::command::{Ending, Skip};
use crate::failed_run::FailedRun;
use crate::logs;
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(ending: &Ending) -> ExitCode {
Shown::of_ending(ending).print()
}
pub fn summary(ending: &Ending) -> String {
match ending {
Ending::Run(ended) => run_summary(ended),
Ending::Architect {
review: Err(failed),
..
} => failure_summary(failed),
Ending::Architect {
review: Ok(reviewed),
dispatched: Some(dispatched),
} => format!(
"plan {} dispatched: {}",
reviewed.url(),
run_summary(dispatched)
),
Ending::Architect {
review: Ok(reviewed),
dispatched: None,
} => reviewed.to_string(),
Ending::Skipped(skip) => skip.reason.clone(),
}
}
fn run_summary(ended: &Ended) -> String {
match &ended.outcome {
Ok(reached) => format!("PR {} is {}", reached.pr_url, reached.goal.outcome()),
Err(failed) => failure_summary(failed),
}
}
fn failure_summary(failed: &FailedRun) -> String {
let cause = format!("{:#}", failed.error);
format!("failed: {}", cause.lines().next().unwrap_or_default())
}
struct Shown {
steps: Vec<String>,
urls: Vec<String>,
success: bool,
}
impl Shown {
fn of_ending(ending: &Ending) -> Self {
match ending {
Ending::Run(ended)
| Ending::Architect {
dispatched: Some(ended),
..
} => Shown::of(ended),
Ending::Architect {
review: Ok(reviewed),
dispatched: None,
} => Shown {
steps: vec![reviewed.to_string()],
urls: vec![reviewed.url().to_string()],
success: true,
},
Ending::Architect {
review: Err(failed),
dispatched: None,
} => Shown::of_failure(failed, &[]),
Ending::Skipped(Skip { reason, urls }) => Shown {
steps: vec![reason.clone()],
urls: urls.clone(),
success: true,
},
}
}
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) = logs::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 {
logs::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::architect::Reviewed;
use crate::issue::IssueUrl;
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";
const PLAN: &str = "https://github.com/acme/widgets/issues/40";
fn plan() -> Reviewed {
Reviewed::PlanReady {
plan: IssueUrl::parse(PLAN).unwrap(),
base: "main".to_string(),
}
}
fn idea() -> Reviewed {
Reviewed::IdeaFiled(IssueUrl::parse(PLAN).unwrap())
}
fn failed_run(cause: &str) -> FailedRun {
FailedRun {
error: anyhow!("{cause}"),
pr_url: None,
log: Some(PathBuf::from(LOG)),
interrupted: false,
ticket_lines: Vec::new(),
}
}
fn shown(ending: &Ending) -> (Vec<String>, Vec<String>, bool) {
let shown = Shown::of_ending(ending);
(shown.steps, shown.urls, shown.success)
}
fn strings(lines: &[&str]) -> Vec<String> {
lines.iter().map(|line| line.to_string()).collect()
}
#[test]
fn a_run_shows_and_sums_up_its_ending_as_it_ended() {
let ending = Ending::Run(with_base_fix(reached(Goal::Merged), "merged"));
assert_eq!(
shown(&ending),
(
vec![
format!("Base fix: {BASE_FIX_ISSUE} merged"),
format!("PR {PR} is merged"),
],
strings(&[PR]),
true
)
);
assert_eq!(summary(&ending), format!("PR {PR} is merged"));
let ending = Ending::Run(failed(
"claude exited 1\nno such model",
Some(LOG),
Some(PR),
));
assert_eq!(
shown(&ending),
(
vec![
"claude exited 1\nno such model".to_string(),
format!("session log: {LOG}"),
],
strings(&[PR]),
false
)
);
assert_eq!(summary(&ending), "failed: claude exited 1");
}
#[test]
fn an_architect_run_that_dispatched_its_plan_shows_as_that_run_and_sums_up_both() {
let ending = Ending::Architect {
review: Ok(plan()),
dispatched: Some(reached(Goal::ReadyForReview)),
};
assert_eq!(
shown(&ending),
(
vec![format!("PR {PR} is ready for review")],
strings(&[PR]),
true
)
);
assert_eq!(
summary(&ending),
format!("plan {PLAN} dispatched: PR {PR} is ready for review")
);
let ending = Ending::Architect {
review: Ok(plan()),
dispatched: Some(failed("CI red on test", Some(LOG), None)),
};
assert!(!shown(&ending).2);
assert_eq!(
summary(&ending),
format!("plan {PLAN} dispatched: failed: CI red on test")
);
}
#[test]
fn an_architect_run_that_dispatched_nothing_shows_and_sums_up_its_review() {
let ending = Ending::Architect {
review: Ok(idea()),
dispatched: None,
};
let line = format!("no Strong candidate: the Architecture review filed the idea {PLAN}");
assert_eq!(shown(&ending), (vec![line.clone()], strings(&[PLAN]), true));
assert_eq!(summary(&ending), line);
let ending = Ending::Architect {
review: Ok(plan()),
dispatched: None,
};
let line = format!("plan {PLAN} is ready for an agent");
assert_eq!(shown(&ending), (vec![line.clone()], strings(&[PLAN]), true));
assert_eq!(summary(&ending), line);
}
#[test]
fn an_architect_run_whose_review_failed_shows_and_sums_up_the_failure() {
let ending = Ending::Architect {
review: Err(failed_run("claude exited 1\nno such model")),
dispatched: None,
};
assert_eq!(
shown(&ending),
(
vec![
"claude exited 1\nno such model".to_string(),
format!("session log: {LOG}"),
],
Vec::new(),
false
)
);
assert_eq!(summary(&ending), "failed: claude exited 1");
}
#[test]
fn a_skipped_pass_shows_its_reason_and_its_urls_and_exits_0() {
let reason = format!("Ready issue #40 \"Export\" goes first: {PLAN}");
let ending = Ending::Skipped(Skip {
reason: reason.clone(),
urls: strings(&[PLAN]),
});
assert_eq!(
shown(&ending),
(vec![reason.clone()], strings(&[PLAN]), true)
);
assert_eq!(summary(&ending), reason);
let ending = Ending::Skipped(Skip {
reason: "no Ready issue on acme/widgets".to_string(),
urls: Vec::new(),
});
assert_eq!(
shown(&ending),
(
strings(&["no Ready issue on acme/widgets"]),
Vec::new(),
true
)
);
}
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);
}
}