use std::num::NonZeroUsize;
use std::path::PathBuf;
use anyhow::{Context, anyhow};
use crate::asks::Asks;
use crate::base_fix::{Advice, BaseFix};
use crate::branch::{self, Selection};
use crate::child_run::Kind;
use crate::claim;
use crate::delivery::{Delivery, Opening};
use crate::failed_run::FailedRun;
use crate::git::Git;
use crate::github::{self, Ticket};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::logs::{self, Work};
use crate::preflight;
use crate::progress;
use crate::prompt;
use crate::session::Logs;
use crate::spec_run;
use crate::worktree::Worktree;
const IMPLEMENT: &str = "implement";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Goal {
ReadyForReview,
Merged,
}
impl Goal {
pub fn outcome(self) -> &'static str {
match self {
Goal::ReadyForReview => "ready for review",
Goal::Merged => "merged",
}
}
}
pub struct Reached {
pub pr_url: String,
pub goal: Goal,
pub log: Option<PathBuf>,
pub ticket_lines: Vec<String>,
}
pub struct Ended {
pub outcome: Result<Reached, FailedRun>,
pub base_fix: Option<String>,
pub advice: Vec<Advice>,
}
#[derive(Clone, Copy)]
pub enum StartedBy<'a> {
Command,
Dispatch { base: &'a str },
Child(&'a Kind),
}
impl<'a> StartedBy<'a> {
fn child(self) -> Option<&'a Kind> {
match self {
StartedBy::Child(kind) => Some(kind),
StartedBy::Command | StartedBy::Dispatch { .. } => None,
}
}
fn makes_claim(self) -> bool {
self.child().is_none()
}
fn given_base(self) -> Option<&'a str> {
match self {
StartedBy::Command => None,
StartedBy::Dispatch { base } => Some(base),
StartedBy::Child(kind) => Some(kind.base()),
}
}
}
pub fn run_to_end(issue: &IssueUrl, asks: &Asks, started_by: StartedBy) -> Ended {
let mut base_fix = BaseFix::new(started_by.child(), asks.base_fix.clone());
let outcome = run(issue, asks, started_by, &mut base_fix);
Ended {
outcome,
base_fix: base_fix.report(),
advice: base_fix.into_advice(),
}
}
fn run(
issue: &IssueUrl,
asks: &Asks,
started_by: StartedBy,
base_fix: &mut BaseFix,
) -> Result<Reached, FailedRun> {
let launch = Git::new(std::env::current_dir().context("no current directory")?);
preflight::check(&launch, issue)?;
let tickets = match started_by.child() {
Some(_) => Vec::new(),
None => github::tickets(issue)?,
};
logs::started(match tickets.is_empty() {
true => Work::Run(issue),
false => Work::SpecRun(issue),
});
if asks.parallel_asked && tickets.is_empty() {
return Err(anyhow!(
"parallel is only for a Spec, and #{} has no sub-issues",
issue.number
)
.into());
}
let checked_out = launch
.run(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.ok();
let selection = branch::select(&launch, issue)?;
if matches!(selection, Selection::Fresh { .. })
&& spec_run::all_closed(tickets.iter().map(|ticket| ticket.is_open))
{
return Err(
anyhow!("every Ticket is closed and there is no Spec branch; nothing to do").into(),
);
}
let base = selection.base_branch(started_by.given_base(), checked_out.as_deref())?;
preflight::check_base_branch(&launch, &base)?;
if asks.launch_pull {
pull_base_branch(&launch, checked_out.as_deref(), &base);
}
if interrupt::requested() {
return Err(anyhow!("interrupted").into());
}
let claim = started_by
.makes_claim()
.then(|| claim::make(issue))
.transpose()?;
let logs = Logs::of_run(issue);
let delivery = Delivery {
issue,
base: &base,
goal: asks.goal,
base_fix,
logs: &logs,
};
let outcome = run_in_worktree(tickets, &launch, &selection, delivery, asks.tickets_at_once);
if let Some(claim) = claim {
match &outcome {
Ok(reached) if reached.goal == Goal::Merged => claim.remove_if_closed(),
Ok(_) => {}
Err(_) => claim.release_if_nothing_on_origin(&launch),
}
}
outcome
}
fn run_in_worktree(
tickets: Vec<Ticket>,
launch: &Git,
selection: &Selection,
delivery: Delivery,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
let (issue, base) = (delivery.issue, delivery.base);
let branch = selection.branch();
let worktree = match selection {
Selection::Fresh { .. } => Worktree::create_fresh(launch, &issue.repo, branch, base)?,
Selection::Continuation { .. } => {
Worktree::continue_existing(launch, &issue.repo, branch, base)?
}
};
if !tickets.is_empty() {
return spec_run::run(tickets, worktree, delivery, parallel);
}
let prompt = match selection {
Selection::Fresh { .. } => prompt::fresh(issue, base, branch),
Selection::Continuation { pr, .. } => {
prompt::continuation(issue, base, branch, pr.as_ref().map(|pr| pr.url.as_str()))
}
};
let opening = Opening {
kind: IMPLEMENT,
prompt,
catch_up_from_origin: false,
};
delivery.deliver(worktree, opening, || Ok(()))
}
pub fn pull_base_branch(launch: &Git, checked_out: Option<&str>, base: &str) {
if checked_out != Some(base) {
return;
}
let origin_base = format!("origin/{base}");
let up_to_date = launch.succeeds(&["merge-base", "--is-ancestor", &origin_base, "HEAD"]);
if up_to_date.unwrap_or(false) {
return;
}
progress::step(format_args!(
"updating {base} in the Launch directory from {origin_base}"
));
if let Err(error) = launch.run(&["merge", "--ff-only", "--quiet", &origin_base]) {
progress::warn(
&error,
format_args!(
"could not update {base} in the Launch directory, \
so update it by hand: git pull --ff-only origin {base}"
),
);
}
}