use std::fmt;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, Utc};
use crate::failed_run::FailedRun;
use crate::github::{self, ListedIssue};
use crate::interrupt;
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Label, Labels, NEEDS_TRIAGE, READY_FOR_AGENT};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::logs::{self, Pass, Work};
use crate::progress;
use crate::prompt;
use crate::ready::{self, ReadyIssue};
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",
);
const ARCHITECT_IDEA: Label = Label::new(
"architect-idea",
"An Architect idea: the issue an Architecture review with no Strong candidate ended on",
);
#[derive(Debug)]
pub enum Outcome {
Skipped(Skipped),
Reviewed(Reviewed),
}
#[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 { 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>,
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) => {
let repo = &running.0;
logs::skipped(Pass::ArchitectRun, repo, &running);
return Ok(Outcome::Skipped(Skipped::AlreadyRunning(running)));
}
};
let open_plans = github::open_issues_labelled(&repo.slug(), ARCHITECT_PLAN)?;
if !open_plans.is_empty() {
return Ok(skip(&repo, Skipped::OpenPlans(open_plans)));
}
let ideas = github::open_issues_labelled(&repo.slug(), ARCHITECT_IDEA)?;
let waiting: Vec<_> = ideas
.into_iter()
.filter(|idea| idea.labels.has(NEEDS_TRIAGE))
.collect();
if !waiting.is_empty() {
return Ok(skip(&repo, Skipped::IdeasWaiting(waiting)));
}
if let Some(ReadyIssue { listed, .. }) = ready::first(&launch, &repo)? {
return Ok(skip(&repo, Skipped::ReadyIssue(listed)));
}
logs::started(Work::ArchitectRun(&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);
let (reviewed, log) = review(worktree, &base, focus, &origin, started, &logs);
reviewed.map(Outcome::Reviewed).map_err(|error| FailedRun {
log,
..FailedRun::from(error)
})
}
fn skip(repo: &Repo, skipped: Skipped) -> Outcome {
logs::skipped(Pass::ArchitectRun, repo, &skipped);
Outcome::Skipped(skipped)
}
fn review(
worktree: ReviewWorktree,
base: &str,
focus: Option<&str>,
origin: &str,
started: DateTime<Utc>,
logs: &Logs,
) -> (Result<Reviewed>, Option<PathBuf>) {
Sessions::within(logs, worktree.path(), |sessions| {
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}")),
}
let prompt = prompt::architecture_review(base, focus);
let final_message = sessions.run_to_final_message(REVIEW, &prompt)?;
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)) => {
label_idea(&idea)?;
Ok(Reviewed::IdeaFiled(idea))
}
Some(Report::AlreadyFiled(issue)) => {
label_idea(&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 label_idea(idea: &IssueUrl) -> Result<()> {
if interrupt::requested() {
bail!("interrupted");
}
progress::step(format_args!(
"labelling #{} an Architect idea: adding {NEEDS_TRIAGE} and {ARCHITECT_IDEA}",
idea.number
));
let label = || -> Result<()> {
github::ensure_labels(&idea.repo_slug(), &[ARCHITECT_IDEA])?;
let labels = github::issue_labels(idea)?;
github::set_labels(idea, &labels.swapped(&[], &[NEEDS_TRIAGE, ARCHITECT_IDEA]))
};
label().with_context(|| format!("could not label the Architect idea #{}", idea.number))
}
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:?}");
}
}
}