use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use chrono::{DateTime, Utc};
use crate::command_log;
use crate::failed_run::FailedRun;
use crate::github;
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::labels::{Label, Labels, NEEDS_TRIAGE, READY_FOR_AGENT};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::plugin::Plugin;
use crate::progress;
use crate::prompt;
use crate::run;
use crate::session::{Logs, Sessions};
use crate::worktree::ReviewWorktree;
const REVIEW: &str = "architecture-review";
const ARCHITECT_PLAN: Label = Label::new(
"architect-plan",
"An Architect plan: the Spec or Ticket an Architecture review published",
);
#[derive(Debug)]
pub enum Outcome {
Skipped(Skipped),
Reviewed(Reviewed),
}
impl Outcome {
pub fn name(&self) -> &'static str {
match self {
Self::Skipped(_) => "skipped",
Self::Reviewed(reviewed) => reviewed.review(),
}
}
pub fn urls(&self) -> Vec<&str> {
match self {
Self::Skipped(Skipped::AlreadyRunning(_)) => Vec::new(),
Self::Skipped(Skipped::OpenPlans(plans)) => {
plans.iter().map(|(plan, _)| plan.url.as_str()).collect()
}
Self::Reviewed(reviewed) => vec![reviewed.url()],
}
}
}
impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Skipped(skipped) => skipped.fmt(f),
Self::Reviewed(reviewed) => reviewed.fmt(f),
}
}
}
#[derive(Debug)]
pub enum Skipped {
AlreadyRunning(AlreadyRunning),
OpenPlans(Vec<(IssueUrl, String)>),
}
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) => {
let still_open: Vec<String> = plans
.iter()
.map(|(plan, title)| {
format!(
"Architect plan #{} \"{title}\" is still open: pick it up with thirdshift {}",
plan.number, plan.url
)
})
.collect();
f.write_str(&still_open.join("; "))
}
}
}
}
#[derive(Debug)]
pub enum Reviewed {
PlanReady { plan: IssueUrl, base: String },
IdeaFiled(IssueUrl),
AlreadyFiled(IssueUrl),
}
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 { plan: issue, .. }
| Self::IdeaFiled(issue)
| Self::AlreadyFiled(issue) => &issue.url,
}
}
}
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>,
logs_dir: &Path,
launch_pull: bool,
) -> Result<Outcome, FailedRun> {
let started = Utc::now();
let Launch {
git: launch,
origin,
repo,
checked_out,
base,
} = match launch::start(base)? {
Start::Clear(launch) => launch,
Start::AlreadyRunning(running) => {
return Ok(Outcome::Skipped(Skipped::AlreadyRunning(running)));
}
};
let open_plans = github::open_issues_labelled(&repo.slug(), ARCHITECT_PLAN)?;
if !open_plans.is_empty() {
let open_plans = open_plans.into_iter().map(|plan| (plan.issue, plan.title));
return Ok(Outcome::Skipped(Skipped::OpenPlans(open_plans.collect())));
}
command_log::keep(command_log::of_architect_run(logs_dir, &repo));
if launch_pull {
run::pull_base_branch(&launch, checked_out.as_deref(), &base);
}
if interrupt::requested() {
return Err(anyhow!("interrupted").into());
}
let worktree = ReviewWorktree::create(&launch, &repo.name, &base)?;
let logs = Logs::of_architect_run(&repo, logs_dir);
let mut log = logs.path(REVIEW);
review(worktree, &base, focus, &origin, started, &logs, &mut log)
.map(Outcome::Reviewed)
.map_err(|error| FailedRun {
log: log.exists().then_some(log),
..FailedRun::from(error)
})
}
fn review(
worktree: ReviewWorktree,
base: &str,
focus: Option<&str>,
origin: &str,
started: DateTime<Utc>,
logs: &Logs,
log: &mut PathBuf,
) -> Result<Reviewed> {
let plugin = Plugin::write()?;
match focus {
Some(focus) => progress::step(format_args!(
"starting the Architecture review of {base}, focused on: {focus}"
)),
None => progress::step(format_args!("starting the Architecture review of {base}")),
}
Sessions::within(logs, worktree.path(), plugin.path(), |sessions| {
let prompt = prompt::architecture_review(base, focus);
let final_message = sessions.run_to_final_message(REVIEW, &prompt, log)?;
conclude(final_message.as_deref(), origin, started, base)
})
}
#[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(
final_message: Option<&str>,
origin: &str,
started: DateTime<Utc>,
base: &str,
) -> Result<Reviewed> {
match final_message.and_then(Report::read) {
Some(Report::Plan(plan)) => {
mark_plan_ready(&plan, origin, started)?;
let base = base.to_string();
Ok(Reviewed::PlanReady { plan, base })
}
Some(Report::Idea(idea)) => Ok(Reviewed::IdeaFiled(idea)),
Some(Report::AlreadyFiled(issue)) => Ok(Reviewed::AlreadyFiled(issue)),
None => bail!("the Architecture review ended without the final line its prompt asks for"),
}
}
fn mark_plan_ready(plan: &IssueUrl, origin: &str, started: DateTime<Utc>) -> Result<()> {
progress::step(format_args!(
"the Architecture review published the plan {}",
plan.url
));
let kept = labels_to_keep(plan, origin, started)?;
if interrupt::requested() {
bail!("interrupted");
}
progress::step(format_args!(
"marking the plan ready: swapping {NEEDS_TRIAGE} for {READY_FOR_AGENT} and adding {ARCHITECT_PLAN} on #{}",
plan.number
));
github::ensure_labels(&plan.repo_slug(), &[ARCHITECT_PLAN])?;
github::set_labels(plan, &kept.swapped(&[], &[READY_FOR_AGENT, ARCHITECT_PLAN]))
}
fn labels_to_keep(plan: &IssueUrl, origin: &str, started: DateTime<Utc>) -> Result<Labels> {
if !plan.matches_origin(origin) {
bail!(
"the plan {} is not in the repository at origin {origin}",
plan.url
);
}
let issue = github::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
);
}
let kept = issue.labels.swapped(&[NEEDS_TRIAGE], &[]);
if let Some(unready) = kept.unready() {
bail!("the plan {} is labelled {unready}", plan.url);
}
Ok(kept)
}
#[cfg(test)]
mod tests {
use super::*;
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:?}");
}
}
}