use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, Utc};
use crate::failed_run::FailedRun;
use crate::git::Git;
use crate::github;
use crate::interrupt;
use crate::issue::{IssueUrl, Repo};
use crate::plugin::Plugin;
use crate::preflight;
use crate::progress;
use crate::prompt;
use crate::run;
use crate::session::{Logs, Sessions};
use crate::spec_run::{self, NEEDS_TRIAGE, READY_FOR_AGENT};
use crate::worktree::ReviewWorktree;
const REVIEW: &str = "architecture-review";
#[derive(Debug)]
pub enum Outcome {
PlanReady(IssueUrl),
IdeaFiled(IssueUrl),
AlreadyFiled(IssueUrl),
}
impl Outcome {
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
}
}
}
}
impl fmt::Display for Outcome {
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>, logs_dir: &Path, launch_pull: bool) -> Result<Outcome, FailedRun> {
let started = Utc::now();
let timestamp = started.format("%Y%m%dT%H%M%SZ").to_string();
let (launch, origin, repo) = launch()?;
preflight::check_identity(&launch)?;
let base = launch
.run(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.ok()
.context("HEAD is detached; check out the branch the Architecture review should scan")?;
preflight::check_base_branch(&launch, &base)?;
if launch_pull {
run::pull_base_branch(&launch, Some(&base), &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, ×tamp);
let mut log = logs.path(REVIEW);
review(worktree, &base, focus, &logs, &mut log)
.and_then(|final_message| conclude(final_message.as_deref(), &origin, started))
.map_err(|error| FailedRun {
log: log.exists().then_some(log),
..FailedRun::from(error)
})
}
fn launch() -> Result<(Git, String, Repo)> {
let launch = Git::new(std::env::current_dir().context("no current directory")?);
let origin = launch.run(&["config", "remote.origin.url"])?;
let repo = Repo::of_origin(&origin)
.with_context(|| format!("origin {origin} is not a GitHub repository"))?;
Ok((launch, origin, repo))
}
pub fn repo() -> Result<Repo> {
launch().map(|(_, _, repo)| repo)
}
fn review(
worktree: ReviewWorktree,
base: &str,
focus: Option<&str>,
logs: &Logs,
log: &mut PathBuf,
) -> Result<Option<String>> {
let plugin = Plugin::write()?;
let sessions = Sessions {
logs,
worktree: worktree.path(),
plugin_dir: plugin.path(),
};
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.run_to_final_message(REVIEW, &prompt::architecture_review(base, focus), log)
}
#[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>) -> Result<Outcome> {
match final_message.and_then(Report::read) {
Some(Report::Plan(plan)) => {
mark_plan_ready(&plan, origin, started)?;
Ok(Outcome::PlanReady(plan))
}
Some(Report::Idea(idea)) => Ok(Outcome::IdeaFiled(idea)),
Some(Report::AlreadyFiled(issue)) => Ok(Outcome::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} on #{}",
plan.number
));
let mut labels: Vec<&str> = kept
.iter()
.map(String::as_str)
.filter(|&label| label != READY_FOR_AGENT)
.collect();
labels.push(READY_FOR_AGENT);
github::set_labels(plan, &labels)
}
fn labels_to_keep(plan: &IssueUrl, origin: &str, started: DateTime<Utc>) -> Result<Vec<String>> {
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 mut kept = issue.labels;
kept.retain(|label| label != NEEDS_TRIAGE);
if let Some(unready) = spec_run::unready_label(&kept) {
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:?}");
}
}
}