use std::fmt;
use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, Utc};
use crate::asks::Flags;
use crate::config::UserConfig;
use crate::failed_run::FailedRun;
use crate::github::{Issue, ListedIssue};
use crate::harness::Choice;
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Edit, Label, NEEDS_TRIAGE, READY_FOR_AGENT};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::logs::{self, Pass, Work};
use crate::pass::{Dispatch, LaunchAndGitHub, Outside};
use crate::prompt;
use crate::ready::ReadyIssue;
use crate::run::Ended;
const ARCHITECT_PLAN: Label = Label::new(
"architect-plan",
"An Architect plan: the Spec or Ticket an Architecture review published",
);
const ARCHITECT_IDEA: Label = Label::new(
"architect-idea",
"An Architect idea: the issue an Architecture review with no Strong candidate ended on",
);
pub enum Outcome {
Skipped(Skipped),
Ran {
review: Result<Reviewed, FailedRun>,
dispatched: Option<Ended>,
},
}
#[derive(Debug)]
pub enum Skipped {
AlreadyRunning(AlreadyRunning),
OpenPlans(Vec<ListedIssue>),
IdeasWaiting(Vec<ListedIssue>),
ReadyIssue(ListedIssue),
}
impl Skipped {
pub fn urls(&self) -> Vec<&str> {
match self {
Self::AlreadyRunning(_) => Vec::new(),
Self::OpenPlans(issues) | Self::IdeasWaiting(issues) => issues
.iter()
.map(|listed| listed.issue.url.as_str())
.collect(),
Self::ReadyIssue(listed) => vec![listed.issue.url.as_str()],
}
}
}
impl fmt::Display for Skipped {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::AlreadyRunning(running) => running.fmt(f),
Self::OpenPlans(plans) => write_joined(f, plans, |plan, title| {
format!(
"Architect plan #{} \"{title}\" is still open: pick it up with thirdshift {}",
plan.number, plan.url
)
}),
Self::IdeasWaiting(ideas) => write_joined(f, ideas, |idea, title| {
format!(
"Architect idea #{} \"{title}\" is waiting for triage: {}",
idea.number, idea.url
)
}),
Self::ReadyIssue(ListedIssue { issue, title, .. }) => write!(
f,
"Ready issue #{} \"{title}\" goes first: {}",
issue.number, issue.url
),
}
}
}
fn write_joined(
f: &mut fmt::Formatter,
issues: &[ListedIssue],
says: impl Fn(&IssueUrl, &str) -> String,
) -> fmt::Result {
let said: Vec<String> = issues
.iter()
.map(|listed| says(&listed.issue, &listed.title))
.collect();
f.write_str(&said.join("; "))
}
#[derive(Debug)]
pub enum Reviewed {
PlanReady(IssueUrl, Option<String>),
IdeaFiled(IssueUrl, Option<String>),
AlreadyFiled(IssueUrl, Option<String>),
}
impl Reviewed {
pub fn review(&self) -> &'static str {
match self {
Self::PlanReady(..) => "plan published",
Self::IdeaFiled(..) => "idea filed",
Self::AlreadyFiled(..) => "idea already filed",
}
}
pub fn url(&self) -> &str {
match self {
Self::PlanReady(issue, _)
| Self::IdeaFiled(issue, _)
| Self::AlreadyFiled(issue, _) => &issue.url,
}
}
pub fn title(&self) -> Option<&str> {
match self {
Self::PlanReady(_, title)
| Self::IdeaFiled(_, title)
| Self::AlreadyFiled(_, title) => title.as_deref(),
}
}
}
impl fmt::Display for Reviewed {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let url = self.url();
match self {
Self::PlanReady(..) => write!(f, "plan {url} is ready for an agent"),
Self::IdeaFiled(..) => write!(
f,
"no Strong candidate: the Architecture review filed the idea {url}"
),
Self::AlreadyFiled(..) => write!(
f,
"no Strong candidate: {url} already covers the Architecture review's top recommendation, so it filed nothing"
),
}
}
}
pub fn run(
focus: Option<&str>,
base: Option<&str>,
plan_only: bool,
flags: &Flags,
config: &UserConfig,
harness: &mut Choice,
) -> Outcome {
let started = Utc::now();
let Launch {
directory,
repo,
base,
} = match launch::start(base) {
Ok(Start::Clear(launch)) => launch,
Ok(Start::AlreadyRunning(running)) => {
logs::skipped(Pass::Architect, &running.0, &running);
return Outcome::Skipped(Skipped::AlreadyRunning(running));
}
Err(error) => return failed(error.into()),
};
let mut outside = LaunchAndGitHub {
launch: &directory,
base: &base,
repo: &repo,
harness,
flags,
config,
};
let architect_run = ArchitectRun {
repo: &repo,
base: base.name(),
origin: directory.origin(),
focus,
started,
pull: config.launch_pull,
plan_only,
};
run_through(&mut outside, &architect_run)
}
struct ArchitectRun<'a> {
repo: &'a Repo,
base: &'a str,
origin: &'a str,
focus: Option<&'a str>,
started: DateTime<Utc>,
pull: bool,
plan_only: bool,
}
fn run_through(outside: &mut impl Outside, architect_run: &ArchitectRun) -> Outcome {
match gates(outside) {
Ok(Decision::GoAhead) => {}
Ok(Decision::Skip(skipped)) => {
outside.skipped(Pass::Architect, &skipped);
return Outcome::Skipped(skipped);
}
Err(error) => return failed(error.into()),
}
let review = up_to_the_dispatch(outside, architect_run);
let dispatched = match &review {
Ok(Reviewed::PlanReady(plan, _)) if !architect_run.plan_only => {
outside.step(format!(
"dispatching the plan {url}, as thirdshift {url} would",
url = plan.url
));
Some(outside.dispatch(Dispatch::ArchitectPlan {
plan,
base: architect_run.base,
}))
}
_ => None,
};
Outcome::Ran { review, dispatched }
}
fn failed(run: FailedRun) -> Outcome {
Outcome::Ran {
review: Err(run),
dispatched: None,
}
}
fn up_to_the_dispatch(
outside: &mut impl Outside,
architect_run: &ArchitectRun,
) -> Result<Reviewed, FailedRun> {
let ArchitectRun {
repo,
base,
origin,
focus,
started,
pull,
..
} = *architect_run;
outside.check_harness()?;
outside.started(Work::ArchitectRun(repo));
if pull {
outside.pull();
}
if outside.interrupted() {
return Err(FailedRun {
interrupted: true,
..FailedRun::from(anyhow!("interrupted"))
});
}
let starting = match focus {
Some(focus) => format!("starting the Architecture review of {base}, focused on: {focus}"),
None => format!("starting the Architecture review of {base}"),
};
let prompt = prompt::architecture_review(base, focus);
let (reviewed, log) = outside.review(base, starting, &prompt, |outside, final_message| {
conclude(outside, final_message, origin, started)
});
reviewed.map_err(|error| FailedRun {
log,
..FailedRun::from(error)
})
}
enum Decision {
Skip(Skipped),
GoAhead,
}
fn gates(outside: &mut impl Outside) -> Result<Decision> {
let open_plans = outside.open_issues(ARCHITECT_PLAN)?;
if !open_plans.is_empty() {
return Ok(Decision::Skip(Skipped::OpenPlans(open_plans)));
}
let waiting: Vec<_> = outside
.open_issues(ARCHITECT_IDEA)?
.into_iter()
.filter(|idea| idea.labels.has(NEEDS_TRIAGE))
.collect();
if !waiting.is_empty() {
return Ok(Decision::Skip(Skipped::IdeasWaiting(waiting)));
}
Ok(match outside.ready_issue()? {
Some(ReadyIssue { listed, .. }) => Decision::Skip(Skipped::ReadyIssue(listed)),
None => Decision::GoAhead,
})
}
#[derive(Debug, PartialEq, Eq)]
enum Report {
Plan(IssueUrl),
Idea(IssueUrl),
AlreadyFiled(IssueUrl),
}
impl Report {
fn read(final_message: &str) -> Option<Self> {
let line = final_message
.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())?;
let naming = |start: &str, kind: fn(IssueUrl) -> Report| {
let url = line.strip_prefix(start)?;
IssueUrl::parse(url).ok().map(kind)
};
naming(prompt::PLAN_LINE, Report::Plan)
.or_else(|| naming(prompt::IDEA_LINE, Report::Idea))
.or_else(|| naming(prompt::ALREADY_FILED_LINE, Report::AlreadyFiled))
}
}
fn conclude(
outside: &mut impl Outside,
final_message: Option<&str>,
origin: &str,
started: DateTime<Utc>,
) -> Result<Reviewed> {
match final_message.and_then(Report::read) {
Some(Report::Plan(plan)) => {
let title = mark_plan_ready(outside, &plan, origin, started)?;
Ok(Reviewed::PlanReady(plan, title))
}
Some(Report::Idea(idea)) => {
let title = label_idea(outside, &idea)?;
Ok(Reviewed::IdeaFiled(idea, title))
}
Some(Report::AlreadyFiled(issue)) => {
let title = label_idea(outside, &issue)?;
Ok(Reviewed::AlreadyFiled(issue, title))
}
None => bail!("the Architecture review ended without the final line its prompt asks for"),
}
}
fn mark_plan_ready(
outside: &mut impl Outside,
plan: &IssueUrl,
origin: &str,
started: DateTime<Utc>,
) -> Result<Option<String>> {
outside.step(format!(
"the Architecture review published the plan {}",
plan.url
));
let issue = check_plan(outside, plan, origin, started)?;
if outside.interrupted() {
bail!("interrupted");
}
outside.step(format!(
"marking the plan ready: swapping {NEEDS_TRIAGE} for {READY_FOR_AGENT} and adding {ARCHITECT_PLAN} on #{}",
plan.number
));
outside.apply(&Edit::of(
plan,
issue.labels,
&[NEEDS_TRIAGE],
&[READY_FOR_AGENT, ARCHITECT_PLAN],
))?;
Ok(issue.title)
}
fn label_idea(outside: &mut impl Outside, idea: &IssueUrl) -> Result<Option<String>> {
if outside.interrupted() {
bail!("interrupted");
}
outside.step(format!(
"labelling #{} an Architect idea: adding {NEEDS_TRIAGE} and {ARCHITECT_IDEA}",
idea.number
));
outside
.issue(idea)
.and_then(|viewed| {
outside.apply(&Edit::of(
idea,
viewed.labels,
&[],
&[NEEDS_TRIAGE, ARCHITECT_IDEA],
))?;
Ok(viewed.title)
})
.with_context(|| format!("could not label the Architect idea #{}", idea.number))
}
fn check_plan(
outside: &mut impl Outside,
plan: &IssueUrl,
origin: &str,
started: DateTime<Utc>,
) -> Result<Issue> {
if !plan.matches_origin(origin) {
bail!(
"the plan {} is not in the repository at origin {origin}",
plan.url
);
}
let issue = outside.issue(plan)?;
if !issue.is_open {
bail!("the plan {} is closed", plan.url);
}
if issue.created.timestamp() < started.timestamp() {
bail!(
"the plan {} was created before this Architect run started",
plan.url
);
}
if let Some(unready) = issue.labels.swapped(&[NEEDS_TRIAGE], &[]).unready() {
bail!("the plan {} is labelled {unready}", plan.url);
}
Ok(issue)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::github;
use crate::pass::{Call, InMemory, ready_for_review, widgets};
const URL: &str = "https://github.com/acme/widgets/issues/8";
fn issue() -> IssueUrl {
IssueUrl::parse(URL).unwrap()
}
#[test]
fn the_report_is_the_last_line_whichever_of_the_three_it_is() {
for (line, report) in [
("Architecture review plan: ", Report::Plan(issue())),
("Architecture review idea: ", Report::Idea(issue())),
(
"Architecture review already filed: ",
Report::AlreadyFiled(issue()),
),
] {
let message = format!("Reviewed the codebase.\n\n{line}{URL}\n\n");
assert_eq!(Report::read(&message), Some(report), "{line}");
}
}
#[test]
fn a_final_message_without_the_line_as_asked_for_reports_nothing() {
for message in [
"",
"Published the plan.",
"Architecture review plan: https://github.com/acme/widgets/pull/8",
"Architecture review plan: #8",
"Architecture review plan: https://github.com/acme/widgets/issues/8 (a Spec)",
"The plan: https://github.com/acme/widgets/issues/8",
"Architecture review plan: https://github.com/acme/widgets/issues/8\nThat's all.",
] {
assert_eq!(Report::read(message), None, "{message:?}");
}
}
fn decide(mut repo: InMemory) -> (Option<String>, Vec<String>, Vec<Call>) {
let decision = gates(&mut repo).unwrap();
let (line, urls) = match decision {
Decision::Skip(skipped) => (
Some(skipped.to_string()),
skipped.urls().into_iter().map(String::from).collect(),
),
Decision::GoAhead => (None, Vec::new()),
};
(line, urls, repo.calls)
}
fn url(number: u64) -> String {
format!("https://github.com/acme/widgets/issues/{number}")
}
const PLANS: Call = Call::Open("architect-plan");
const IDEAS: Call = Call::Open("architect-idea");
#[test]
fn with_no_plan_idea_or_ready_issue_the_architect_run_goes_ahead_having_read_each_gate() {
let repo = InMemory::default()
.issue(3, false, &["architect-plan"])
.issue(4, false, &["architect-idea", "needs-triage"])
.issue(7, true, &["needs-triage", "architecture"]);
let (line, _, calls) = decide(repo);
assert_eq!(line, None);
assert_eq!(calls, [PLANS, IDEAS, Call::ReadySearch]);
}
#[test]
fn an_open_plan_skips_naming_each_plan_with_no_read_after() {
let repo = InMemory::default()
.issue(5, true, &["architect-plan"])
.issue(6, true, &["architect-plan", "ready-for-agent"])
.issue(7, true, &["architect-idea", "needs-triage"])
.ready(8, false);
let (line, urls, calls) = decide(repo);
assert_eq!(
line.unwrap(),
format!(
"Architect plan #5 \"Issue 5\" is still open: pick it up with thirdshift {}; \
Architect plan #6 \"Issue 6\" is still open: pick it up with thirdshift {}",
url(5),
url(6)
)
);
assert_eq!(urls, [url(5), url(6)]);
assert_eq!(calls, [PLANS]);
}
#[test]
fn a_waiting_idea_skips_naming_each_waiting_idea_with_no_ready_issue_search() {
let repo = InMemory::default()
.issue(5, true, &["architect-idea", "needs-triage"])
.issue(6, true, &["architect-idea", "ready-for-agent"])
.issue(7, true, &["Architect-Idea", "Needs-Triage"])
.ready(8, false);
let (line, urls, calls) = decide(repo);
assert_eq!(
line.unwrap(),
format!(
"Architect idea #5 \"Issue 5\" is waiting for triage: {}; \
Architect idea #7 \"Issue 7\" is waiting for triage: {}",
url(5),
url(7)
)
);
assert_eq!(urls, [url(5), url(7)]);
assert_eq!(calls, [PLANS, IDEAS]);
}
#[test]
fn an_idea_no_longer_labelled_needs_triage_does_not_skip() {
for decided in ["ready-for-agent", "wontfix", "ready-for-human"] {
let repo = InMemory::default().issue(5, true, &["architect-idea", decided]);
let (line, _, calls) = decide(repo);
assert_eq!(line, None, "{decided}");
assert_eq!(calls, [PLANS, IDEAS, Call::ReadySearch], "{decided}");
}
}
#[test]
fn a_ready_issue_skips_naming_it() {
let repo = InMemory::default().ready(9, true);
let (line, urls, calls) = decide(repo);
assert_eq!(
line.unwrap(),
format!("Ready issue #9 \"Issue 9\" goes first: {}", url(9))
);
assert_eq!(urls, [url(9)]);
assert_eq!(calls, [PLANS, IDEAS, Call::ReadySearch]);
}
#[test]
fn an_open_plan_wins_over_a_waiting_idea_and_a_ready_issue() {
for idea in [true, false] {
let mut repo = InMemory::default()
.issue(5, true, &["architect-plan"])
.ready(8, false);
if idea {
repo = repo.issue(6, true, &["architect-idea", "needs-triage"]);
}
let (_, urls, _) = decide(repo);
assert_eq!(urls, [url(5)], "with an idea waiting: {idea}");
}
}
#[test]
fn an_idea_triaged_ready_for_agent_skips_as_the_ready_issue_it_is() {
let repo = InMemory::default()
.issue(7, true, &["architect-idea", "ready-for-agent"])
.ready(7, false);
let (line, urls, calls) = decide(repo);
assert!(line.unwrap().starts_with("Ready issue #7"));
assert_eq!(urls, [url(7)]);
assert_eq!(calls, [PLANS, IDEAS, Call::ReadySearch]);
}
#[test]
fn a_waiting_idea_wins_over_a_ready_issue() {
let repo = InMemory::default()
.issue(6, true, &["architect-idea", "needs-triage"])
.ready(8, false);
let (line, urls, _) = decide(repo);
assert!(line.unwrap().starts_with("Architect idea #6"));
assert_eq!(urls, [url(6)]);
}
const ORIGIN: &str = "git@github.com:acme/widgets.git";
fn started() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-10-04T09:00:00.750Z")
.unwrap()
.to_utc()
}
fn viewed(open: bool, after: i64, labels: &[&str]) -> github::Issue {
github::Issue {
title: None,
is_open: open,
labels: labels.iter().copied().collect(),
created: started() + chrono::Duration::seconds(after),
}
}
fn message(line: &str) -> String {
format!("Reviewed the codebase.\n\n{line}{}\n", url(8))
}
fn concluded(mut repo: InMemory, final_message: &str) -> (Result<String, String>, Vec<Call>) {
let ended = conclude(&mut repo, Some(final_message), ORIGIN, started())
.map(|reviewed| reviewed.to_string())
.map_err(|error| format!("{error:#}"));
(ended, repo.calls)
}
fn step(line: &str) -> Call {
Call::Step(line.to_string())
}
fn edit(issue: u64, off: &[&'static str], on: &[&'static str], labels: &[&str]) -> Call {
Call::Edit {
issue,
off: off.to_vec(),
on: on.to_vec(),
labels: labels.iter().map(|label| label.to_string()).collect(),
}
}
const PUBLISHED: &str =
"the Architecture review published the plan https://github.com/acme/widgets/issues/8";
const MARKING: &str = "marking the plan ready: swapping needs-triage for ready-for-agent and adding architect-plan on #8";
const LABELLING: &str =
"labelling #8 an Architect idea: adding needs-triage and architect-idea";
#[test]
fn a_plan_is_viewed_once_and_marked_ready_in_one_edit_keeping_its_other_labels() {
let repo = InMemory::default().viewable(
8,
viewed(true, 0, &["architecture", "needs-triage", "Spec"]),
);
let (ended, calls) = concluded(repo, &message(prompt::PLAN_LINE));
assert_eq!(
ended.unwrap(),
format!("plan {} is ready for an agent", url(8))
);
assert_eq!(
calls,
[
step(PUBLISHED),
Call::View(8),
step(MARKING),
edit(
8,
&["needs-triage"],
&["ready-for-agent", "architect-plan"],
&["architecture", "Spec", "ready-for-agent", "architect-plan"],
),
]
);
}
#[test]
fn a_plan_with_needs_triage_in_another_case_has_it_taken_off() {
let repo = InMemory::default().viewable(8, viewed(true, 0, &["Needs-Triage"]));
let (ended, calls) = concluded(repo, &message(prompt::PLAN_LINE));
assert!(ended.is_ok());
assert_eq!(
calls.last().unwrap(),
&edit(
8,
&["needs-triage"],
&["ready-for-agent", "architect-plan"],
&["ready-for-agent", "architect-plan"],
)
);
}
#[test]
fn a_plan_in_another_repository_is_refused_without_a_view() {
let repo = InMemory::default().viewable(8, viewed(true, 0, &["needs-triage"]));
let other = "Architecture review plan: https://github.com/other/widgets/issues/8";
let (ended, calls) = concluded(repo, other);
assert_eq!(
ended.unwrap_err(),
format!(
"the plan https://github.com/other/widgets/issues/8 is not in the repository at origin {ORIGIN}"
)
);
assert_eq!(
calls,
[step(
"the Architecture review published the plan https://github.com/other/widgets/issues/8"
)]
);
}
#[test]
fn a_plan_failing_a_check_is_refused_with_its_cause_and_no_edit() {
for (plan, cause) in [
(viewed(false, 0, &["needs-triage"]), "is closed"),
(
viewed(true, -1, &["needs-triage"]),
"was created before this Architect run started",
),
(
viewed(true, 0, &["needs-triage", "ready-for-human"]),
"is labelled ready-for-human",
),
(
viewed(true, 0, &["needs-triage", "Needs-Info"]),
"is labelled needs-info",
),
(viewed(true, 0, &["wontfix"]), "is labelled wontfix"),
] {
let repo = InMemory::default().viewable(8, plan);
let (ended, calls) = concluded(repo, &message(prompt::PLAN_LINE));
assert_eq!(ended.unwrap_err(), format!("the plan {} {cause}", url(8)));
assert_eq!(calls, [step(PUBLISHED), Call::View(8)], "{cause}");
}
}
#[test]
fn a_plan_created_in_the_second_the_run_started_passes() {
let mut plan = viewed(true, 0, &["needs-triage"]);
plan.created = DateTime::parse_from_rfc3339("2026-10-04T09:00:00Z")
.unwrap()
.to_utc();
let repo = InMemory::default().viewable(8, plan);
let (ended, _) = concluded(repo, &message(prompt::PLAN_LINE));
assert!(ended.is_ok(), "{ended:?}");
}
#[test]
fn an_interrupt_after_the_plans_checks_makes_no_edit() {
let repo = InMemory::default()
.viewable(8, viewed(true, 0, &["needs-triage"]))
.interrupted();
let (ended, calls) = concluded(repo, &message(prompt::PLAN_LINE));
assert_eq!(ended.unwrap_err(), "interrupted");
assert_eq!(calls, [step(PUBLISHED), Call::View(8)]);
}
#[test]
fn an_idea_filed_or_already_filed_is_labelled_an_architect_idea_keeping_its_other_labels() {
for (line, ending) in [
(
prompt::IDEA_LINE,
format!(
"no Strong candidate: the Architecture review filed the idea {}",
url(8)
),
),
(
prompt::ALREADY_FILED_LINE,
format!(
"no Strong candidate: {} already covers the Architecture review's top recommendation, so it filed nothing",
url(8)
),
),
] {
let repo = InMemory::default()
.viewable(8, viewed(true, -3600, &["architecture", "needs-triage"]));
let (ended, calls) = concluded(repo, &message(line));
assert_eq!(ended.unwrap(), ending);
assert_eq!(
calls,
[
step(LABELLING),
Call::View(8),
edit(
8,
&[],
&["architect-idea"],
&["architecture", "needs-triage", "architect-idea"],
),
],
"{line}"
);
}
}
#[test]
fn an_idea_already_filed_and_triaged_goes_back_to_needs_triage_keeping_its_other_labels() {
for (triaged, on, after) in [
(
&["ready-for-human", "architecture"][..],
&["needs-triage", "architect-idea"][..],
&[
"ready-for-human",
"architecture",
"needs-triage",
"architect-idea",
][..],
),
(
&["Architecture", "WONTFIX", "Architect-Idea"],
&["needs-triage"],
&["Architecture", "WONTFIX", "needs-triage", "architect-idea"],
),
(
&["needs-info"],
&["needs-triage", "architect-idea"],
&["needs-info", "needs-triage", "architect-idea"],
),
(
&[],
&["needs-triage", "architect-idea"],
&["needs-triage", "architect-idea"],
),
] {
let repo = InMemory::default().viewable(8, viewed(true, -3600, triaged));
let (ended, calls) = concluded(repo, &message(prompt::ALREADY_FILED_LINE));
assert!(ended.is_ok(), "{triaged:?}: {ended:?}");
assert_eq!(
calls,
[step(LABELLING), Call::View(8), edit(8, &[], on, after)],
"{triaged:?}"
);
}
}
#[test]
fn an_idea_that_cannot_be_viewed_fails_naming_it_ahead_of_the_cause() {
let (ended, calls) = concluded(InMemory::default(), &message(prompt::IDEA_LINE));
assert_eq!(
ended.unwrap_err(),
"could not label the Architect idea #8: gh: Could not resolve to an issue with the number of 8"
);
assert_eq!(calls, [step(LABELLING), Call::View(8)]);
}
#[test]
fn an_idea_that_cannot_be_edited_fails_naming_it_ahead_of_the_cause() {
let repo = InMemory::default()
.viewable(8, viewed(true, 0, &["bug"]))
.edit_failing(8);
let (ended, calls) = concluded(repo, &message(prompt::ALREADY_FILED_LINE));
assert_eq!(
ended.unwrap_err(),
"could not label the Architect idea #8: gh: could not edit #8"
);
assert_eq!(
calls,
[
step(LABELLING),
Call::View(8),
edit(
8,
&[],
&["needs-triage", "architect-idea"],
&["bug", "needs-triage", "architect-idea"],
),
]
);
}
#[test]
fn an_interrupt_before_an_idea_is_viewed_makes_no_call() {
let repo = InMemory::default()
.viewable(8, viewed(true, 0, &[]))
.interrupted();
let (ended, calls) = concluded(repo, &message(prompt::IDEA_LINE));
assert_eq!(ended.unwrap_err(), "interrupted");
assert_eq!(calls, []);
}
#[test]
fn a_final_message_without_the_line_as_asked_for_fails_with_no_call_made() {
let repo = InMemory::default().viewable(8, viewed(true, 0, &["needs-triage"]));
let (ended, calls) = concluded(repo, "Published the plan.");
assert_eq!(
ended.unwrap_err(),
"the Architecture review ended without the final line its prompt asks for"
);
assert_eq!(calls, []);
}
#[test]
fn no_final_message_fails_with_no_call_made() {
let mut repo = InMemory::default();
let ended = conclude(&mut repo, None, ORIGIN, started());
assert_eq!(
ended.unwrap_err().to_string(),
"the Architecture review ended without the final line its prompt asks for"
);
assert_eq!(repo.calls, []);
}
fn architect(
repo: InMemory,
asked: impl FnOnce(&mut ArchitectRun<'_>),
) -> (Outcome, Vec<Call>) {
let mut repo = repo.dispatched_ending(ready_for_review());
let widgets = widgets();
let mut architect_run = ArchitectRun {
repo: &widgets,
base: "main",
origin: ORIGIN,
focus: None,
started: started(),
pull: false,
plan_only: false,
};
asked(&mut architect_run);
let outcome = run_through(&mut repo, &architect_run);
(outcome, repo.calls)
}
fn review_and_dispatch(outcome: Outcome) -> (Result<String, String>, bool) {
let Outcome::Ran { review, dispatched } = outcome else {
panic!("the Architect run was skipped");
};
let review = review
.map(|reviewed| reviewed.to_string())
.map_err(|failed| format!("{:#}", failed.error));
(review, dispatched.is_some())
}
fn reviewing_main() -> [Call; 2] {
[
Call::Review {
base: "main".to_string(),
prompt: prompt::architecture_review("main", None),
},
step("starting the Architecture review of main"),
]
}
fn publishing_a_plan() -> InMemory {
InMemory::default()
.reviewed(&message(prompt::PLAN_LINE))
.viewable(8, viewed(true, 0, &["needs-triage"]))
}
#[test]
fn a_skipped_architect_run_records_its_reason_with_no_harness_check_start_pull_or_review() {
for (repo, gates_read) in [
(InMemory::default().issue(5, true, &["architect-plan"]), 1),
(
InMemory::default().issue(5, true, &["architect-idea", "needs-triage"]),
2,
),
(InMemory::default().ready(5, false), 3),
] {
let repo = repo.reviewed(&message(prompt::PLAN_LINE));
let (outcome, calls) = architect(repo, |run| run.pull = true);
let Outcome::Skipped(skipped) = outcome else {
panic!("the Architect run was not skipped");
};
let mut expected: Vec<Call> = [PLANS, IDEAS, Call::ReadySearch]
.into_iter()
.take(gates_read)
.collect();
expected.push(Call::Skipped(skipped.to_string()));
assert_eq!(calls, expected, "{skipped}");
}
}
#[test]
fn a_harness_that_fails_its_check_fails_the_architect_run_before_its_start() {
let repo = publishing_a_plan().harness_failing();
let (outcome, calls) = architect(repo, |run| run.pull = true);
assert_eq!(
review_and_dispatch(outcome),
(Err("claude is not on PATH".to_string()), false)
);
assert_eq!(calls, [PLANS, IDEAS, Call::ReadySearch, Call::HarnessCheck]);
}
#[test]
fn the_pull_is_made_only_when_asked_after_the_start_is_recorded() {
for pull in [false, true] {
let (_, calls) = architect(publishing_a_plan(), |run| run.pull = pull);
let mut expected = vec![
PLANS,
IDEAS,
Call::ReadySearch,
Call::HarnessCheck,
Call::Started(None),
];
if pull {
expected.push(Call::Pull);
}
expected.extend(reviewing_main());
assert_eq!(calls[..expected.len()], expected, "pull: {pull}");
}
}
#[test]
fn an_interrupt_after_the_gates_fails_the_architect_run_as_interrupted_with_no_review() {
let repo = publishing_a_plan().interrupted();
let (outcome, calls) = architect(repo, |run| run.pull = true);
let Outcome::Ran {
review: Err(failed),
dispatched: None,
} = outcome
else {
panic!("the Architect run did not fail, or dispatched a run");
};
assert_eq!(failed.error.to_string(), "interrupted");
assert!(failed.interrupted);
assert_eq!(failed.log, None);
assert_eq!(
calls,
[
PLANS,
IDEAS,
Call::ReadySearch,
Call::HarnessCheck,
Call::Started(None),
Call::Pull,
]
);
}
#[test]
fn the_reviews_prompt_carries_the_base_branch_and_the_focus() {
let (_, calls) = architect(publishing_a_plan(), |run| {
run.base = "develop";
run.focus = Some("the Spec run");
});
let review = calls
.iter()
.position(|call| matches!(call, Call::Review { .. }))
.expect("no review session");
let Call::Review { base, prompt } = &calls[review] else {
unreachable!();
};
assert_eq!(
calls[review + 1],
step("starting the Architecture review of develop, focused on: the Spec run")
);
assert_eq!(base, "develop");
assert!(
prompt.contains("the base branch develop:"),
"prompt: {prompt}"
);
assert!(
prompt.contains("Focus the review on: the Spec run\n"),
"prompt: {prompt}"
);
}
#[test]
fn a_plan_is_dispatched_on_the_base_branch_after_the_label_swap_unless_plan_only() {
for plan_only in [false, true] {
let (outcome, calls) = architect(publishing_a_plan(), |run| {
run.base = "develop";
run.plan_only = plan_only;
});
assert_eq!(
review_and_dispatch(outcome),
(
Ok(format!("plan {} is ready for an agent", url(8))),
!plan_only
)
);
let swap = calls
.iter()
.position(|call| matches!(call, Call::Edit { .. }))
.expect("the plan was not marked ready");
let dispatch = [
step(&format!(
"dispatching the plan {url}, as thirdshift {url} would",
url = url(8)
)),
Call::DispatchPlan {
plan: 8,
base: "develop".to_string(),
},
];
let after_swap: &[Call] = if plan_only { &[] } else { &dispatch };
assert_eq!(calls[swap + 1..], *after_swap, "plan-only: {plan_only}");
}
}
#[test]
fn an_idea_filed_or_already_filed_dispatches_nothing() {
for line in [prompt::IDEA_LINE, prompt::ALREADY_FILED_LINE] {
let repo = InMemory::default()
.reviewed(&message(line))
.viewable(8, viewed(true, -3600, &[]));
let (outcome, calls) = architect(repo, |_| {});
let (review, dispatched) = review_and_dispatch(outcome);
assert!(review.is_ok(), "{line}: {review:?}");
assert!(!dispatched, "{line}");
assert!(
matches!(calls.last(), Some(Call::Edit { .. })),
"{line}: {calls:?}"
);
}
}
const SESSION_LOG: &str = "/logs/acme/widgets/sessions/architect-1-architecture-review.jsonl";
#[test]
fn a_failed_review_dispatches_nothing_and_carries_the_reviews_session_log() {
for repo in [
InMemory::default().review_failing("claude exited with status 1"),
InMemory::default().reviewed("Published the plan."),
InMemory::default()
.reviewed(&message(prompt::PLAN_LINE))
.viewable(8, viewed(false, 0, &["needs-triage"])),
] {
let (outcome, calls) = architect(repo.session_log(SESSION_LOG), |_| {});
let Outcome::Ran {
review: Err(failed),
dispatched: None,
} = outcome
else {
panic!("the review did not fail, or a run was dispatched");
};
assert_eq!(
failed.log.as_deref(),
Some(Path::new(SESSION_LOG)),
"{:#}",
failed.error
);
assert!(
!calls
.iter()
.any(|call| matches!(call, Call::DispatchPlan { .. })),
"{:#}",
failed.error
);
}
}
}