use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use chrono::{DateTime, TimeDelta, Utc};
use crate::asks::Asks;
use crate::base_fix::{Advice, BaseFix};
use crate::branch::{self, Selection};
use crate::child_run::{ChildAsks, Kind};
use crate::claim::{self, Claim};
use crate::delivery::{Delivery, Opening, OpeningSession};
use crate::failed_run::FailedRun;
use crate::github::{GitHub, Ticket};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::launch::LaunchDirectory;
use crate::logs::{self, Work};
use crate::progress;
use crate::prompt;
use crate::session::Logs;
use crate::spec_run;
use crate::trust;
use crate::worktree::Worktree;
#[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>,
}
const STARTED_MARGIN: TimeDelta = TimeDelta::minutes(5);
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: &mut Asks, started_by: StartedBy) -> Ended {
let mut base_fix = BaseFix::new(started_by.child(), asks.base_fix.clone());
let mut outcome = run(issue, asks, started_by, &mut base_fix);
if let Err(failed) = &mut outcome
&& let Some(recovery) = &mut failed.recovery
{
recovery.add_child_work(base_fix.local_work().iter().cloned());
}
Ended {
outcome,
base_fix: base_fix.report(),
advice: base_fix.into_advice(),
}
}
fn run(
issue: &IssueUrl,
asks: &mut Asks,
started_by: StartedBy,
base_fix: &mut BaseFix,
) -> Result<Reached, FailedRun> {
let directory = LaunchDirectory::open_for_run(issue)?;
if let StartedBy::Command = started_by {
match trust::issue_trust(&GitHub::new(), issue) {
Ok(Some(check)) => progress::step(format!("warning: #{} {check}", issue.number)),
Ok(None) => {}
Err(error) => progress::step(format!(
"warning: could not check trust for #{}: {error:#}",
issue.number
)),
}
asks.harness.check()?;
}
let asks = &*asks;
let child_asks = ChildAsks::of(asks);
let mut outside = LaunchAndGitHub {
directory: &directory,
issue,
goal: asks.goal,
security_review: child_asks.runs_security_review(started_by.child()),
child_asks: &child_asks,
base_fix,
logs: Logs::of_run(issue),
claim: None,
issue_branch: None,
child: started_by.child().is_some(),
started: Utc::now() - STARTED_MARGIN,
};
let outcome = start(
&mut outside,
issue,
asks,
started_by,
directory.checked_out(),
);
outside.record_branch_left();
outcome
}
fn start<O: Outside>(
outside: &mut O,
issue: &IssueUrl,
asks: &Asks,
started_by: StartedBy,
checked_out: Option<&str>,
) -> Result<Reached, FailedRun> {
let tickets = match started_by.child() {
Some(_) => Vec::new(),
None => outside.tickets()?,
};
outside.started(match (started_by.child(), tickets.is_empty()) {
(Some(Kind::BaseFix { .. }), _) => Work::BaseFix(issue),
(_, 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 selection = outside.select()?;
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 given = started_by.given_base();
let named = selection
.pr_base(given, checked_out, |line| outside.step(line))
.or(given);
let base = outside.prepare_base_branch(named, asks.launch_pull)?;
if outside.interrupted() {
return Err(FailedRun {
interrupted: true,
..anyhow!("interrupted").into()
});
}
let claims = started_by.makes_claim();
if claims {
outside.make_claim()?;
}
let is_spec = !tickets.is_empty();
let mut outcome = start_in_worktree(
outside,
issue,
tickets,
&selection,
&base,
asks.tickets_at_once,
);
if let Err(failed) = &mut outcome
&& let Some(recovery) = &mut failed.recovery
{
recovery.add_command_context(
crate::recovery::Continuation {
command: crate::args::continuation(issue, asks, is_spec),
launch: outside.launch_path().to_path_buf(),
base: base.clone(),
branch: selection.branch().to_string(),
},
matches!(selection, Selection::Continuation { .. }),
);
}
if claims {
outside.end_claim(outcome.as_ref().ok().map(|reached| reached.goal));
}
outcome
}
fn start_in_worktree<O: Outside>(
outside: &mut O,
issue: &IssueUrl,
tickets: Vec<Ticket>,
selection: &Selection,
base: &str,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
let branch = selection.branch();
let (checkout, prompt) = match selection {
Selection::Fresh { .. } => (Checkout::Fresh, prompt::fresh(issue, base, branch)),
Selection::Continuation { pr, .. } => (
Checkout::Continuation,
prompt::continuation(issue, base, branch, pr.as_ref().map(|pr| pr.url.as_str())),
),
};
let worktree = outside.worktree(checkout, branch, base)?;
if !tickets.is_empty() {
return outside.spec_run(tickets, worktree, base, parallel);
}
let opening = Opening {
session: OpeningSession::Implementation,
prompt,
};
outside.deliver(worktree, base, opening)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Checkout {
Fresh,
Continuation,
}
trait Outside {
type Worktree;
fn launch_path(&self) -> &Path;
fn tickets(&mut self) -> Result<Vec<Ticket>>;
fn started(&mut self, work: Work);
fn select(&mut self) -> Result<Selection>;
fn prepare_base_branch(&mut self, named: Option<&str>, pull: bool) -> Result<String>;
fn interrupted(&mut self) -> bool;
fn make_claim(&mut self) -> Result<()>;
fn end_claim(&mut self, reached: Option<Goal>);
fn worktree(&mut self, checkout: Checkout, branch: &str, base: &str) -> Result<Self::Worktree>;
fn spec_run(
&mut self,
tickets: Vec<Ticket>,
worktree: Self::Worktree,
base: &str,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun>;
fn deliver(
&mut self,
worktree: Self::Worktree,
base: &str,
opening: Opening,
) -> Result<Reached, FailedRun>;
fn step(&mut self, line: String);
}
struct LaunchAndGitHub<'a> {
directory: &'a LaunchDirectory,
issue: &'a IssueUrl,
goal: Goal,
security_review: bool,
child_asks: &'a ChildAsks,
base_fix: &'a mut BaseFix,
logs: Logs,
claim: Option<Claim<'a>>,
issue_branch: Option<String>,
child: bool,
started: DateTime<Utc>,
}
impl LaunchAndGitHub<'_> {
fn record_branch_left(&self) {
let Some(branch) = self.issue_branch.as_deref() else {
return;
};
let git = self.directory.git().completion();
let observed = (|| -> Result<bool> {
let reference = format!("refs/heads/{branch}");
if git
.run(&["for-each-ref", "--format=%(refname)", &reference])?
.lines()
.any(|name| name == reference)
{
return Ok(true);
}
git.on_origin(branch)
})();
match observed {
Ok(left) => logs::issue_branch(left.then_some(branch)),
Err(error) => {
logs::issue_branch(Some(branch));
progress::step(format_args!(
"warning: could not check whether Issue branch {branch} remains: {error:#}"
));
}
}
}
fn delivery<'d>(&'d mut self, base: &'d str) -> Delivery<'d> {
Delivery {
security_review: self.security_review,
child_asks: self.child_asks,
issue: self.issue,
base,
goal: self.goal,
base_fix: self.base_fix,
logs: &self.logs,
started: self.started,
}
}
}
impl<'a> Outside for LaunchAndGitHub<'a> {
type Worktree = Worktree;
fn launch_path(&self) -> &Path {
self.directory.git().dir()
}
fn tickets(&mut self) -> Result<Vec<Ticket>> {
GitHub::new().tickets(self.issue)
}
fn started(&mut self, work: Work) {
logs::started(work, &self.child_asks.harness);
if self.child {
logs::issue_title(GitHub::new().issue_title(self.issue).ok());
}
}
fn select(&mut self) -> Result<Selection> {
let selection = branch::select(self.directory.git(), &GitHub::new(), self.issue)?;
self.issue_branch = Some(selection.branch().into());
logs::issue_branch(Some(selection.branch()));
Ok(selection)
}
fn prepare_base_branch(&mut self, named: Option<&str>, pull: bool) -> Result<String> {
let base = self.directory.prepare_base_branch(named)?;
if pull {
base.pull();
}
Ok(base.name().to_string())
}
fn interrupted(&mut self) -> bool {
interrupt::requested()
}
fn make_claim(&mut self) -> Result<()> {
self.claim = Some(claim::make(
self.issue,
self.directory.git(),
self.issue_branch
.as_deref()
.expect("branch selected before Claim"),
)?);
logs::issue_title(GitHub::new().issue_title(self.issue).ok());
Ok(())
}
fn end_claim(&mut self, reached: Option<Goal>) {
if let Some(claim) = self.claim.take() {
claim.end(reached);
}
}
fn worktree(&mut self, checkout: Checkout, branch: &str, base: &str) -> Result<Worktree> {
let (launch, repo) = (self.directory.git(), &self.issue.repo);
match checkout {
Checkout::Fresh => Worktree::create_fresh(launch, repo, branch, base),
Checkout::Continuation => Worktree::continue_existing(launch, repo, branch, base),
}
}
fn spec_run(
&mut self,
tickets: Vec<Ticket>,
worktree: Worktree,
base: &str,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
spec_run::run(tickets, worktree, self.delivery(base), parallel)
}
fn deliver(
&mut self,
worktree: Worktree,
base: &str,
opening: Opening,
) -> Result<Reached, FailedRun> {
let mut pull_request = crate::pull_request::PullRequest::new(
self.issue,
worktree.branch(),
base,
GitHub::new(),
);
self.delivery(base)
.deliver(worktree, opening, &mut pull_request, None)
}
fn step(&mut self, line: String) {
progress::step(line);
}
}
#[cfg(test)]
mod tests {
use anyhow::bail;
use super::*;
use crate::base_fix::BaseFixAsk;
use crate::github::{PrState, PullRequest};
use crate::harness::Choice;
use crate::labels::Labels;
use crate::notification::NotificationAsk;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
Tickets,
Started { spec_run: bool },
Select,
PrepareBaseBranch { named: Option<String>, pull: bool },
Interrupted,
MakeClaim,
EndClaim(Option<Goal>),
Worktree(Checkout, String, String),
SpecRun(Checkout, usize, String, usize),
Deliver(Checkout, String, String),
Step(String),
}
#[derive(Default)]
struct Script {
tickets: Vec<bool>,
selection: Option<Selection>,
base_fails: bool,
interrupted: bool,
claim_fails: bool,
worktree_fails: bool,
work_fails: bool,
}
struct Scripted {
script: Script,
goal: Goal,
calls: Vec<Call>,
}
impl Scripted {
fn new(script: Script) -> Self {
Scripted {
script,
goal: Goal::ReadyForReview,
calls: Vec::new(),
}
}
fn work_ends(&self) -> Result<Reached, FailedRun> {
if self.script.work_fails {
return Err(anyhow!("the work failed").into());
}
Ok(Reached {
pr_url: PR_URL.to_string(),
goal: self.goal,
log: None,
ticket_lines: Vec::new(),
})
}
}
impl Outside for Scripted {
type Worktree = Checkout;
fn launch_path(&self) -> &Path {
Path::new("/launch")
}
fn tickets(&mut self) -> Result<Vec<Ticket>> {
self.calls.push(Call::Tickets);
Ok(self
.script
.tickets
.iter()
.enumerate()
.map(|(at, &is_open)| ticket(10 + at as u64, is_open))
.collect())
}
fn started(&mut self, work: Work) {
let spec_run = match work {
Work::Run(_) | Work::BaseFix(_) => false,
Work::SpecRun(_) => true,
Work::PickupRun(_) | Work::ArchitectRun(_) | Work::SecurityRun(_) => {
panic!("not a Run's work")
}
};
self.calls.push(Call::Started { spec_run });
}
fn select(&mut self) -> Result<Selection> {
self.calls.push(Call::Select);
Ok(self.script.selection.take().unwrap_or_else(fresh))
}
fn prepare_base_branch(&mut self, named: Option<&str>, pull: bool) -> Result<String> {
self.calls.push(Call::PrepareBaseBranch {
named: named.map(String::from),
pull,
});
if self.script.base_fails {
bail!(
"base branch gone does not exist on origin or locally; \
create or obtain that branch first, or select a valid Base branch"
);
}
Ok(named.unwrap_or(CHECKED_OUT).to_string())
}
fn interrupted(&mut self) -> bool {
self.calls.push(Call::Interrupted);
self.script.interrupted
}
fn make_claim(&mut self) -> Result<()> {
self.calls.push(Call::MakeClaim);
if self.script.claim_fails {
bail!("could not make the Claim on #7");
}
Ok(())
}
fn end_claim(&mut self, reached: Option<Goal>) {
self.calls.push(Call::EndClaim(reached));
}
fn worktree(&mut self, checkout: Checkout, branch: &str, base: &str) -> Result<Checkout> {
self.calls.push(Call::Worktree(
checkout,
branch.to_string(),
base.to_string(),
));
if self.script.worktree_fails {
bail!("could not create the worktree");
}
Ok(checkout)
}
fn spec_run(
&mut self,
tickets: Vec<Ticket>,
worktree: Checkout,
base: &str,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
self.calls.push(Call::SpecRun(
worktree,
tickets.len(),
base.to_string(),
parallel.get(),
));
self.work_ends()
}
fn deliver(
&mut self,
worktree: Checkout,
base: &str,
opening: Opening,
) -> Result<Reached, FailedRun> {
assert_eq!(opening.session, OpeningSession::Implementation);
self.calls
.push(Call::Deliver(worktree, base.to_string(), opening.prompt));
self.work_ends()
}
fn step(&mut self, line: String) {
self.calls.push(Call::Step(line));
}
}
const PR_URL: &str = "https://github.com/acme/widgets/pull/1";
const CHECKED_OUT: &str = "main";
fn seven() -> IssueUrl {
IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap()
}
fn ticket(number: u64, is_open: bool) -> Ticket {
Ticket {
issue: IssueUrl::parse(&format!("https://github.com/acme/widgets/issues/{number}"))
.unwrap(),
is_open,
labels: Labels::default(),
has_sub_issues: false,
blockers: Vec::new(),
open_blockers: Vec::new(),
}
}
fn fresh() -> Selection {
Selection::Fresh {
branch: "issue-7".to_string(),
}
}
fn continued(pr_base: Option<&str>) -> Selection {
Selection::Continuation {
branch: "issue-7".to_string(),
pr: pr_base.map(|base| PullRequest {
number: 1,
url: PR_URL.to_string(),
state: PrState::Open,
head: "issue-7".to_string(),
base: base.to_string(),
is_draft: false,
}),
}
}
fn asks() -> Asks {
Asks {
security_fix: false,
security_review: false,
goal: Goal::ReadyForReview,
notification: NotificationAsk::Skip,
tickets_at_once: NonZeroUsize::new(1).unwrap(),
parallel_asked: false,
base_fix: BaseFixAsk::Forbid,
launch_pull: false,
harness: Choice::default(),
}
}
fn start_by(
outside: &mut Scripted,
asks: &Asks,
started_by: StartedBy,
) -> Result<Reached, FailedRun> {
start(outside, &seven(), asks, started_by, Some(CHECKED_OUT))
}
fn run_on(script: Script) -> (Vec<Call>, Result<Reached, FailedRun>) {
run_asked(script, &asks())
}
fn run_asked(script: Script, asks: &Asks) -> (Vec<Call>, Result<Reached, FailedRun>) {
let mut outside = Scripted::new(script);
let ended = start_by(&mut outside, asks, StartedBy::Command);
(outside.calls, ended)
}
fn cause(ended: Result<Reached, FailedRun>) -> String {
match ended {
Ok(_) => panic!("the Run reached its goal"),
Err(failed) => format!("{:#}", failed.error),
}
}
fn base_branch(named: Option<&str>) -> Call {
Call::PrepareBaseBranch {
named: named.map(String::from),
pull: false,
}
}
fn worktree(checkout: Checkout, base: &str) -> Call {
Call::Worktree(checkout, "issue-7".to_string(), base.to_string())
}
fn deliver_fresh(base: &str) -> Call {
Call::Deliver(
Checkout::Fresh,
base.to_string(),
prompt::fresh(&seven(), base, "issue-7"),
)
}
fn assert_in_turn(calls: &[Call], in_turn: &[Call]) {
assert!(
calls.windows(in_turn.len()).any(|window| window == in_turn),
"{in_turn:?} not in turn in {calls:?}"
);
}
fn made_claim_or_worktree(calls: &[Call]) -> bool {
calls
.iter()
.any(|call| matches!(call, Call::MakeClaim | Call::Worktree(..)))
}
#[test]
fn a_child_run_reads_no_tickets_and_makes_no_claim() {
let kind = Kind::Ticket {
spec_branch: "issue-3".to_string(),
};
let mut outside = Scripted::new(Script::default());
let ended = start_by(&mut outside, &asks(), StartedBy::Child(&kind));
assert!(ended.is_ok());
assert_eq!(
outside.calls,
[
Call::Started { spec_run: false },
Call::Select,
base_branch(Some("issue-3")),
Call::Interrupted,
worktree(Checkout::Fresh, "issue-3"),
deliver_fresh("issue-3"),
]
);
}
#[test]
fn a_run_its_command_started_makes_the_claim_before_the_worktree_and_ends_it() {
let (calls, ended) = run_on(Script::default());
assert_eq!(
ended.ok().map(|reached| reached.pr_url),
Some(PR_URL.to_string())
);
assert_eq!(
calls,
[
Call::Tickets,
Call::Started { spec_run: false },
Call::Select,
base_branch(None),
Call::Interrupted,
Call::MakeClaim,
worktree(Checkout::Fresh, "main"),
deliver_fresh("main"),
Call::EndClaim(Some(Goal::ReadyForReview)),
]
);
}
#[test]
fn a_dispatched_run_makes_the_claim_and_ends_it() {
let mut outside = Scripted::new(Script::default());
let ended = start_by(
&mut outside,
&asks(),
StartedBy::Dispatch { base: "develop" },
);
assert!(ended.is_ok());
assert_eq!(
outside.calls,
[
Call::Tickets,
Call::Started { spec_run: false },
Call::Select,
base_branch(Some("develop")),
Call::Interrupted,
Call::MakeClaim,
worktree(Checkout::Fresh, "develop"),
deliver_fresh("develop"),
Call::EndClaim(Some(Goal::ReadyForReview)),
]
);
}
#[test]
fn parallel_on_an_issue_with_no_sub_issues_is_recorded_as_a_run_and_fails_before_selection() {
let asks = Asks {
parallel_asked: true,
..asks()
};
let (calls, ended) = run_asked(Script::default(), &asks);
assert_eq!(
cause(ended),
"parallel is only for a Spec, and #7 has no sub-issues"
);
assert_eq!(calls, [Call::Tickets, Call::Started { spec_run: false }]);
}
#[test]
fn a_fresh_spec_with_every_ticket_closed_is_recorded_as_a_spec_run_and_fails_before_its_base() {
let (calls, ended) = run_on(Script {
tickets: vec![false, false],
..Script::default()
});
assert_eq!(
cause(ended),
"every Ticket is closed and there is no Spec branch; nothing to do"
);
assert_eq!(
calls,
[
Call::Tickets,
Call::Started { spec_run: true },
Call::Select
]
);
}
#[test]
fn neither_refusal_makes_a_claim_or_a_worktree() {
let parallel = Asks {
parallel_asked: true,
..asks()
};
let all_closed = Script {
tickets: vec![false],
..Script::default()
};
for (calls, ended) in [run_asked(Script::default(), ¶llel), run_on(all_closed)] {
assert!(ended.is_err());
assert!(!made_claim_or_worktree(&calls), "{calls:?}");
}
}
#[test]
fn a_continuation_of_a_spec_with_every_ticket_closed_goes_on_to_the_spec_run() {
let (calls, ended) = run_on(Script {
tickets: vec![false, false],
selection: Some(continued(None)),
..Script::default()
});
assert!(ended.is_ok());
assert!(calls.contains(&Call::SpecRun(
Checkout::Continuation,
2,
"main".to_string(),
1
)));
}
#[test]
fn an_issue_with_no_sub_issues_is_never_refused_as_all_closed() {
let (calls, ended) = run_on(Script::default());
assert!(ended.is_ok());
assert!(calls.contains(&deliver_fresh("main")));
}
#[test]
fn a_continuations_open_pr_names_the_base_branch_over_the_one_given() {
let mut outside = Scripted::new(Script {
selection: Some(continued(Some("release"))),
..Script::default()
});
let ended = start_by(
&mut outside,
&asks(),
StartedBy::Dispatch { base: "develop" },
);
assert!(ended.is_ok());
let line = format!(
"continuing issue-7 and its PR {PR_URL}, so the Base branch is release, \
not the given develop"
);
assert_in_turn(
&outside.calls,
&[Call::Select, Call::Step(line), base_branch(Some("release"))],
);
}
#[test]
fn a_continuations_open_pr_names_the_base_branch_over_the_one_checked_out() {
let (calls, _) = run_on(Script {
selection: Some(continued(Some("release"))),
..Script::default()
});
let line = format!(
"continuing issue-7 and its PR {PR_URL}, so the Base branch is release, \
not the checked-out main"
);
assert_in_turn(
&calls,
&[Call::Select, Call::Step(line), base_branch(Some("release"))],
);
}
#[test]
fn an_open_pr_into_the_branch_checked_out_says_nothing_of_it() {
let (calls, _) = run_on(Script {
selection: Some(continued(Some("main"))),
..Script::default()
});
assert_in_turn(&calls, &[Call::Select, base_branch(Some("main"))]);
}
#[test]
fn without_an_open_pr_the_branch_given_names_the_base_branch() {
let mut outside = Scripted::new(Script {
selection: Some(continued(None)),
..Script::default()
});
let ended = start_by(
&mut outside,
&asks(),
StartedBy::Dispatch { base: "develop" },
);
assert!(ended.is_ok());
assert_in_turn(
&outside.calls,
&[Call::Select, base_branch(Some("develop"))],
);
}
#[test]
fn with_nothing_given_no_branch_is_named_and_the_checked_out_branch_settles_it() {
let (calls, _) = run_on(Script::default());
assert_in_turn(&calls, &[Call::Select, base_branch(None)]);
assert!(calls.contains(&worktree(Checkout::Fresh, CHECKED_OUT)));
}
#[test]
fn a_base_branch_that_cannot_be_settled_fails_before_the_claim() {
let (calls, ended) = run_on(Script {
base_fails: true,
..Script::default()
});
assert_eq!(
cause(ended),
"base branch gone does not exist on origin or locally; \
create or obtain that branch first, or select a valid Base branch"
);
assert_eq!(calls.last(), Some(&base_branch(None)));
}
#[test]
fn the_pull_asked_for_pulls_the_settled_base_branch_before_the_claim() {
let asks = Asks {
launch_pull: true,
..asks()
};
let mut outside = Scripted::new(Script::default());
let ended = start_by(&mut outside, &asks, StartedBy::Dispatch { base: "develop" });
assert!(ended.is_ok());
assert_in_turn(
&outside.calls,
&[
Call::PrepareBaseBranch {
named: Some("develop".to_string()),
pull: true,
},
Call::Interrupted,
Call::MakeClaim,
],
);
}
#[test]
fn a_continuing_spec_prepares_its_open_prs_base_with_pull_before_the_claim() {
let asks = Asks {
launch_pull: true,
..asks()
};
let mut outside = Scripted::new(Script {
tickets: vec![true],
selection: Some(continued(Some("release"))),
..Script::default()
});
let ended = start_by(&mut outside, &asks, StartedBy::Dispatch { base: "develop" });
assert!(ended.is_ok());
assert_in_turn(
&outside.calls,
&[
Call::PrepareBaseBranch {
named: Some("release".to_string()),
pull: true,
},
Call::Interrupted,
Call::MakeClaim,
worktree(Checkout::Continuation, "release"),
Call::SpecRun(Checkout::Continuation, 1, "release".to_string(), 1),
],
);
}
#[test]
fn without_the_pull_nothing_is_pulled() {
let (calls, _) = run_on(Script::default());
assert!(calls.contains(&base_branch(None)));
}
#[test]
fn an_interrupt_before_the_claim_fails_the_run_as_interrupted() {
let (calls, ended) = run_on(Script {
interrupted: true,
..Script::default()
});
let failed = ended.err().expect("the Run reached its goal");
assert!(failed.interrupted);
assert_eq!(failed.error.to_string(), "interrupted");
assert_eq!(calls.last(), Some(&Call::Interrupted));
assert!(!made_claim_or_worktree(&calls), "{calls:?}");
}
#[test]
fn a_claim_that_cannot_be_made_fails_the_run_before_the_worktree() {
let (calls, ended) = run_on(Script {
claim_fails: true,
..Script::default()
});
assert_eq!(cause(ended), "could not make the Claim on #7");
assert_eq!(calls.last(), Some(&Call::MakeClaim));
}
#[test]
fn the_claim_is_ended_with_the_goal_the_run_reached() {
let mut outside = Scripted::new(Script::default());
outside.goal = Goal::Merged;
let ended = start_by(&mut outside, &asks(), StartedBy::Command);
assert!(ended.is_ok());
assert_eq!(
outside.calls.last(),
Some(&Call::EndClaim(Some(Goal::Merged)))
);
}
#[test]
fn the_claim_is_ended_with_none_when_the_worktree_cannot_be_created() {
let (calls, ended) = run_on(Script {
worktree_fails: true,
..Script::default()
});
assert_eq!(cause(ended), "could not create the worktree");
assert_eq!(
calls[calls.len() - 2..],
[worktree(Checkout::Fresh, "main"), Call::EndClaim(None)]
);
}
#[test]
fn the_claim_is_ended_with_none_when_the_delivery_fails() {
let (calls, ended) = run_on(Script {
work_fails: true,
..Script::default()
});
assert!(ended.is_err());
assert_eq!(
calls[calls.len() - 2..],
[deliver_fresh("main"), Call::EndClaim(None)]
);
}
#[test]
fn the_claim_is_ended_with_none_when_the_spec_run_fails() {
let (calls, ended) = run_on(Script {
tickets: vec![true],
work_fails: true,
..Script::default()
});
assert!(ended.is_err());
assert_eq!(
calls[calls.len() - 2..],
[
Call::SpecRun(Checkout::Fresh, 1, "main".to_string(), 1),
Call::EndClaim(None),
]
);
}
#[test]
fn a_fresh_selection_creates_a_fresh_worktree_and_opens_with_the_fresh_prompt() {
let (calls, _) = run_on(Script::default());
assert_in_turn(
&calls,
&[worktree(Checkout::Fresh, "main"), deliver_fresh("main")],
);
}
#[test]
fn a_continuation_continues_the_worktree_and_opens_with_the_continuation_prompt() {
for pr_base in [None, Some("main")] {
let (calls, _) = run_on(Script {
selection: Some(continued(pr_base)),
..Script::default()
});
let pr_url = pr_base.map(|_| PR_URL);
let prompt = prompt::continuation(&seven(), "main", "issue-7", pr_url);
assert_in_turn(
&calls,
&[
worktree(Checkout::Continuation, "main"),
Call::Deliver(Checkout::Continuation, "main".to_string(), prompt),
],
);
}
}
#[test]
fn tickets_hand_the_worktree_to_the_spec_run_with_the_number_to_run_at_once() {
let asks = Asks {
tickets_at_once: NonZeroUsize::new(3).unwrap(),
..asks()
};
let (calls, ended) = run_asked(
Script {
tickets: vec![true, false],
..Script::default()
},
&asks,
);
assert!(ended.is_ok());
assert!(calls.contains(&Call::Started { spec_run: true }));
assert_in_turn(
&calls,
&[
worktree(Checkout::Fresh, "main"),
Call::SpecRun(Checkout::Fresh, 2, "main".to_string(), 3),
],
);
}
}