use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
mod repair_loop;
mod stage;
use crate::pull_request::{AlreadyMerged, Identified, MergeAttempt, PullRequest};
use crate::base_fix::{self, BaseFix};
use crate::child_run::ChildAsks;
use crate::ci::{self, Ci, FailedChecks};
use crate::failed_run::{FailedRun, PolicyRefusal, interrupted_or};
use crate::git::Git;
use crate::github::GitHub;
use crate::harness::Choice;
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::progress;
use crate::prompt;
use crate::recovery::{self, Preservation, Recovery};
use crate::run::{Goal, Reached};
use crate::run_ending::Cause;
use crate::security::review::{Hold as SecurityHold, Outcome as SecurityOutcome, Review};
use crate::session::{Kind, Logs, Sessions};
use crate::skills;
use crate::trust::{self, IssueText};
use crate::worktree::{ForeignCommits, Merge, PendingMerge, Preserved, Worktree};
use repair_loop::{Repair, Upstream};
use stage::{CommandLog, CommandRecord, Stage, Tracker};
pub struct Delivery<'a> {
pub security_review: bool,
pub child_asks: &'a ChildAsks,
pub issue: &'a IssueUrl,
pub base: &'a str,
pub goal: Goal,
pub base_fix: &'a mut BaseFix,
pub logs: &'a Logs,
pub started: DateTime<Utc>,
}
pub struct Opening {
pub session: OpeningSession,
pub prompt: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpeningSession {
Implementation,
SpecReview,
}
impl OpeningSession {
pub fn kind(self) -> Kind {
match self {
Self::Implementation => Kind::Implementation,
Self::SpecReview => Kind::SpecReview,
}
}
pub fn catches_up_from_origin(self) -> bool {
self == Self::SpecReview
}
fn stage(self) -> Stage {
match self {
Self::Implementation => Stage::Implementation,
Self::SpecReview => Stage::SpecReview,
}
}
}
impl Delivery<'_> {
pub fn deliver(
self,
worktree: Worktree,
opening: Opening,
pull_request: &mut PullRequest,
checklist: Option<&str>,
) -> Result<Reached, FailedRun> {
let tracker = Tracker::new(opening.session.stage(), CommandLog);
let route = Route {
security_review: self.security_review,
issue: self.issue,
base: self.base,
branch: worktree.branch(),
goal: self.goal,
harness: &self.child_asks.harness,
};
pull_request.begin_delivery();
let (issue, started, path) = (self.issue, self.started, worktree.path());
let before_each = move || {
no_untrusted_edit_since(issue, started)?;
hand_over_issue(issue, path)
};
let (delivered, log) = Sessions::checked_within(
self.logs,
worktree.path(),
&self.child_asks.harness,
&before_each,
|sessions| {
let mut outside = InWorktree {
tracker: &tracker,
issue: self.issue,
worktree: &worktree,
base: self.base,
base_fix: self.base_fix,
child_asks: self.child_asks,
sessions,
pull_request,
};
route.steps(&mut outside, &opening, checklist)
},
);
match delivered {
Ok(pr_url) => Ok(Reached {
pr_url,
goal: self.goal,
log,
ticket_lines: Vec::new(),
}),
Err(error) => {
if let Some(merged) = error.downcast_ref::<AlreadyMerged>()
&& worktree.fully_landed_at(&merged.head).unwrap_or(false)
{
progress::step("the pull request was merged while Delivery was running");
return Ok(Reached {
pr_url: merged.pr.url.clone(),
goal: Goal::Merged,
log,
ticket_lines: Vec::new(),
});
}
let stopped_at = stopped_at(&tracker, &error);
let path = worktree.path().to_path_buf();
let mut outside = OnFailure {
base: self.base,
worktree,
pull_request,
};
Err(fail(&mut outside, stopped_at, path, log, error))
}
}
}
}
struct Route<'a> {
security_review: bool,
issue: &'a IssueUrl,
base: &'a str,
branch: &'a str,
goal: Goal,
harness: &'a Choice,
}
impl Route<'_> {
fn steps<O: Outside>(
&self,
outside: &mut O,
opening: &Opening,
checklist: Option<&str>,
) -> Result<String> {
let session = opening.session;
if session.catches_up_from_origin() {
outside.catch_up()?;
}
outside.stage(session.stage());
outside.session(session.kind(), &opening.prompt)?;
let review = match self.security_review.then(|| {
outside.stage(Stage::SecurityReview);
outside.security_review(&prompt::security_review(self.issue, self.base, self.branch))
}) {
Some(Err(error)) if error.is::<UntrustedEdit>() => return Err(error),
review => review.map(SecurityOutcome::of),
};
outside.stage(Stage::Push);
outside.push()?;
self.write_built_with(outside);
outside.stage(Stage::PullRequestReadiness);
let pr = outside.mark_pr_ready(checklist)?;
let hold = review.as_ref().and_then(SecurityOutcome::hold);
if let Some(review) = &review
&& let Err(error) = outside.record_security_review(review)
{
outside.warn(
&error,
"could not write the Security review outcome in the pull request's body"
.to_string(),
);
}
let goal = if hold.is_some() {
Goal::ReadyForReview
} else {
self.goal
};
outside.take_to_goal(&pr.url, goal)?;
if let Some(hold) = hold {
outside.step(hold.to_string());
if self.goal == Goal::Merged {
return Err(hold.into());
}
}
if self.goal == Goal::Merged {
self.after_merge(outside, &pr);
}
Ok(pr.url)
}
fn write_built_with(&self, outside: &mut impl Outside) {
let built_with = self.harness.built_with();
if let Err(error) = outside.write_built_with(self.harness) {
outside.warn(
&error,
format!("could not write \"{built_with}\" in the pull request's body"),
);
}
}
fn after_merge(&self, outside: &mut impl Outside, pr: &Identified) {
let branch = self.branch;
if let Err(error) = retry_if_interrupted(outside, |outside| outside.delete_branch()) {
outside.warn(
&error,
format!(
"could not delete {branch} on origin, so delete it by hand: \
git push origin --delete {branch}"
),
);
}
let comment = format!(
"Closed by #{}, merged into {} by a thirdshift Merge run.",
pr.number, self.base
);
if let Err(error) = retry_if_interrupted(outside, |outside| {
self.close_unless_closed(outside, &comment)
}) {
let issue = self.issue;
outside.warn(
&error,
format!(
"could not close issue #{number} ({url}), so if it is still open, close it by hand: \
gh issue close {number} --repo {repo} --comment '{quoted}'",
number = issue.number,
url = issue.url,
repo = issue.repo_slug(),
quoted = comment.replace('\'', r"'\''")
),
);
}
}
fn close_unless_closed(&self, outside: &mut impl Outside, comment: &str) -> Result<()> {
if !outside.issue_is_open()? {
return Ok(());
}
outside.step(format!("closing issue #{}", self.issue.number));
outside.close_issue(comment)
}
}
fn retry_if_interrupted<O: Outside>(
outside: &mut O,
step: impl Fn(&mut O) -> Result<()>,
) -> Result<()> {
match step(outside) {
Err(_) if outside.interrupted() => step(outside),
done => done,
}
}
fn fail(
outside: &mut impl FailedOutside,
stopped_at: recovery::Stage,
worktree: PathBuf,
log: Option<PathBuf>,
error: anyhow::Error,
) -> FailedRun {
let interrupted = outside.interrupted();
let error = interrupted_or(error, interrupted);
let reason = Cause::of(&error).first_line().to_string();
let keep_ready = error.is::<PolicyRefusal>() || error.is::<SecurityHold>();
let preservation = if keep_ready {
Preservation::Remote
} else {
match outside.preserve_failed_run(&reason) {
Ok(Preserved::Pushed) => Preservation::Remote,
Ok(Preserved::NoChanges) => Preservation::None,
Err(problem) => {
outside.step(format!(
"could not push the failed run's work, so it may exist only locally: {problem:#}"
));
Preservation::Local
}
}
};
let pr_url = match outside.finish_pr(keep_ready) {
Ok(pr_url) => pr_url,
Err(problem) => {
outside.step(format!("could not convert the PR to a draft: {problem:#}"));
None
}
};
let mut recovery = Recovery::new(stopped_at, preservation);
recovery.record_worktree(worktree);
FailedRun {
recovery: Some(Box::new(recovery)),
error,
pr_url,
log,
interrupted,
ticket_lines: Vec::new(),
}
}
fn stopped_at(tracker: &Tracker<impl CommandRecord>, error: &anyhow::Error) -> recovery::Stage {
if error.is::<SecurityHold>() {
tracker.stop_at_security_review();
}
tracker.stopped_at()
}
trait Outside {
fn stage(&mut self, stage: Stage);
fn catch_up(&mut self) -> Result<()>;
fn session(&mut self, kind: Kind, prompt: &str) -> Result<()>;
fn security_review(&mut self, prompt: &str) -> Result<Review>;
fn record_security_review(&mut self, review: &SecurityOutcome) -> Result<()>;
fn push(&mut self) -> Result<()>;
fn write_built_with(&mut self, choice: &Choice) -> Result<()>;
fn mark_pr_ready(&mut self, checklist: Option<&str>) -> Result<Identified>;
fn take_to_goal(&mut self, pr_url: &str, goal: Goal) -> Result<()>;
fn delete_branch(&mut self) -> Result<()>;
fn issue_is_open(&mut self) -> Result<bool>;
fn close_issue(&mut self, comment: &str) -> Result<()>;
fn interrupted(&mut self) -> bool;
fn step(&mut self, line: String);
fn warn(&mut self, error: &anyhow::Error, warning: String);
}
trait FailedOutside {
fn preserve_failed_run(&mut self, reason: &str) -> Result<Preserved>;
fn finish_pr(&mut self, keep_ready: bool) -> Result<Option<String>>;
fn interrupted(&mut self) -> bool;
fn step(&mut self, line: String);
}
fn no_untrusted_edit_since(issue: &IssueUrl, started: DateTime<Utc>) -> Result<()> {
match trust::untrusted_edit(&GitHub::new(), issue, Some(started))? {
Some(text) => Err(UntrustedEdit {
number: issue.number,
text,
}
.into()),
None => Ok(()),
}
}
fn hand_over_issue(issue: &IssueUrl, worktree: &Path) -> Result<()> {
skills::exclude(&Git::new(worktree), &format!("/{}", prompt::ISSUE_FILE))?;
let text = GitHub::new().issue_for_session(issue)?;
let path = worktree.join(prompt::ISSUE_FILE);
fs::write(&path, text).with_context(|| format!("could not write {}", path.display()))
}
#[derive(Debug)]
struct UntrustedEdit {
number: u64,
text: IssueText,
}
impl std::fmt::Display for UntrustedEdit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"#{} {} edited since the Run started by an account that is not a Trusted account; starting no further session",
self.number, self.text
)
}
}
impl std::error::Error for UntrustedEdit {}
struct InWorktree<'a> {
tracker: &'a Tracker,
issue: &'a IssueUrl,
worktree: &'a Worktree,
base: &'a str,
base_fix: &'a mut BaseFix,
child_asks: &'a ChildAsks,
sessions: &'a Sessions<'a>,
pull_request: &'a mut PullRequest,
}
impl Outside for InWorktree<'_> {
fn stage(&mut self, stage: Stage) {
self.tracker.enter(stage);
}
fn catch_up(&mut self) -> Result<()> {
self.worktree.fast_forward_to_origin()
}
fn session(&mut self, kind: Kind, prompt: &str) -> Result<()> {
self.sessions.run(kind, prompt)?;
self.pull_request.ensure_actionable(false)
}
fn security_review(&mut self, prompt: &str) -> Result<Review> {
self.pull_request.ensure_actionable(false)?;
let review = crate::security::review::run(
self.sessions,
self.worktree,
self.issue,
self.base,
prompt,
)?;
self.pull_request.ensure_actionable(false)?;
Ok(review)
}
fn record_security_review(&mut self, review: &SecurityOutcome) -> Result<()> {
self.pull_request.record_security_review(&review.entry())
}
fn push(&mut self) -> Result<()> {
self.pull_request.ensure_actionable(false)?;
self.worktree
.push_if_actionable(&mut || self.pull_request.ensure_actionable(false))
}
fn write_built_with(&mut self, choice: &Choice) -> Result<()> {
self.pull_request.write_built_with(choice)
}
fn mark_pr_ready(&mut self, checklist: Option<&str>) -> Result<Identified> {
self.pull_request.mark_ready(checklist)
}
fn take_to_goal(&mut self, pr_url: &str, goal: Goal) -> Result<()> {
let mut outside = RunOutside {
tracker: self.tracker,
issue: self.issue,
worktree: self.worktree,
base: self.base,
pr_url,
base_fix: self.base_fix,
child_asks: self.child_asks,
sessions: self.sessions,
pull_request: self.pull_request,
};
repair_loop::take_to_goal(&mut outside, self.base, goal)
}
fn delete_branch(&mut self) -> Result<()> {
self.worktree.delete_from_origin()
}
fn issue_is_open(&mut self) -> Result<bool> {
GitHub::new().completion().issue_is_open(self.issue)
}
fn close_issue(&mut self, comment: &str) -> Result<()> {
GitHub::new().completion().close_issue(self.issue, comment)
}
fn interrupted(&mut self) -> bool {
interrupt::requested()
}
fn step(&mut self, line: String) {
progress::step(line);
}
fn warn(&mut self, error: &anyhow::Error, warning: String) {
progress::warn(error, format_args!("{warning}"));
}
}
struct OnFailure<'a> {
base: &'a str,
worktree: Worktree,
pull_request: &'a mut PullRequest,
}
impl FailedOutside for OnFailure<'_> {
fn preserve_failed_run(&mut self, reason: &str) -> Result<Preserved> {
if let Err(error) = self.pull_request.ensure_preservable() {
self.worktree.retain_local();
return Err(error);
}
self.worktree
.preserve_failed_run(self.base, reason, &mut || {
self.pull_request.ensure_preservable()
})
}
fn finish_pr(&mut self, keep_ready: bool) -> Result<Option<String>> {
self.pull_request.finish_failed_run(keep_ready)
}
fn interrupted(&mut self) -> bool {
interrupt::requested()
}
fn step(&mut self, line: String) {
progress::step(line);
}
}
struct RunOutside<'a> {
tracker: &'a Tracker,
issue: &'a IssueUrl,
worktree: &'a Worktree,
base: &'a str,
pr_url: &'a str,
base_fix: &'a mut BaseFix,
child_asks: &'a ChildAsks,
sessions: &'a Sessions<'a>,
pull_request: &'a mut PullRequest,
}
impl repair_loop::Outside for RunOutside<'_> {
type Pending = PendingMerge;
fn stage(&mut self, stage: Stage) {
self.tracker.enter(stage);
}
fn merge_base_branch(&mut self) -> Result<Merge> {
self.pull_request.ensure_actionable(true)?;
self.worktree.merge_base_branch(self.base)
}
fn merge_new_commits(&mut self, observed: ForeignCommits) -> Result<Merge> {
self.worktree.merge_new_commits(observed)
}
fn ensure_merged(&mut self, pending: &PendingMerge) -> Result<()> {
self.worktree.ensure_merged(pending)
}
fn push(&mut self) -> Result<()> {
self.pull_request.ensure_actionable(true)?;
self.worktree
.push_if_actionable(&mut || self.pull_request.ensure_actionable(true))
}
fn head(&mut self) -> Result<String> {
self.worktree.head()
}
fn same_tree_as(&mut self, commit: &str) -> Result<bool> {
self.worktree.same_tree_as(commit)
}
fn base_branch_moved(&mut self) -> Result<bool> {
self.worktree.base_branch_moved(self.base)
}
fn new_commits_on_origin(&mut self) -> Result<ForeignCommits> {
self.pull_request.ensure_actionable(true)?;
self.worktree.new_commits_on_origin()
}
fn watch(&mut self, head: &str, base_commit: Option<&str>) -> Result<Ci> {
ci::watch(self.issue, head, base_commit, &mut || {
self.pull_request.ensure_actionable(true)
})
}
fn rerun(
&mut self,
head: &str,
base_commit: Option<&str>,
failed: &FailedChecks,
) -> Result<Option<Ci>> {
ci::rerun(self.issue, head, base_commit, failed, &mut || {
self.pull_request.ensure_actionable(true)
})
}
fn repair(&mut self, kind: Kind, repair: Repair) -> Result<()> {
let (issue, base, pr_url) = (self.issue, self.base, self.pr_url);
let branch = self.worktree.branch();
let prompt = match repair {
Repair::Conflict(Upstream::BaseBranch) => {
prompt::conflict_repair(issue, base, branch, pr_url)
}
Repair::Conflict(Upstream::IssueBranch) => {
prompt::conflict_repair(issue, branch, branch, pr_url)
}
Repair::CiFix(failed) => prompt::ci_fix_repair(issue, base, branch, pr_url, failed),
Repair::Review { from } => prompt::review_repair(issue, branch, pr_url, from),
};
self.pull_request.ensure_actionable(true)?;
self.sessions.run(kind, &prompt)?;
self.pull_request.ensure_actionable(true)
}
fn sees_inherited_failures(&mut self) -> bool {
self.base_fix.sees_inherited_failures()
}
fn base_fix(&mut self, base_commit: &str, failed: &FailedChecks) -> Result<()> {
let outside = base_fix::LaunchAndGitHub {
launch: self.worktree.launch(),
issue: self.issue,
asks: self.child_asks,
};
self.base_fix
.fix(outside, self.pr_url, self.base, base_commit, failed)
}
fn ensure_pr_ready_and_mergeable(&mut self) -> Result<()> {
self.pull_request.ensure_ready_and_mergeable()
}
fn merge(&mut self, head: &str) -> Result<MergeAttempt> {
self.pull_request.merge(head)
}
fn interrupt_requested(&mut self) -> bool {
interrupt::requested()
}
fn progress(&mut self, line: String) {
progress::step(line);
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::{anyhow, bail};
use stage::Recorded;
const PR_URL: &str = "https://github.com/acme/widgets/pull/12";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Fails {
CatchUp,
Session,
Push,
Annotation,
Checklist,
Ready,
Goal,
Preserve,
Finish,
Delete,
Close,
}
#[derive(Debug, PartialEq, Eq)]
enum Call {
CatchUp,
Session(Kind),
Push,
Annotation,
Checklist,
Ready,
Goal(Goal),
Preserve(String),
Finish(bool),
Delete,
Close(String),
Warning(String),
Step(String),
}
#[derive(Default)]
struct Scripted {
calls: Vec<Call>,
stages: Vec<Stage>,
reviews_security: bool,
security_held: bool,
failing: Option<Fails>,
interrupted: bool,
refused: bool,
issue_closed: bool,
log: Option<PathBuf>,
}
impl Scripted {
fn check(&self, at: Fails) -> Result<()> {
if self.failing == Some(at) {
bail!("{at:?} failed");
}
Ok(())
}
}
impl Outside for Scripted {
fn record_security_review(&mut self, _: &SecurityOutcome) -> Result<()> {
Ok(())
}
fn stage(&mut self, stage: Stage) {
self.stages.push(stage);
}
fn security_review(&mut self, _: &str) -> Result<Review> {
if self.security_held {
return Review::from_final_message(Some("Security review: incomplete: timed out"));
}
Review::from_final_message(Some(
"Security review: {\"unaddressed_count\":0,\"findings\":[],\"pre_existing_count\":0}",
))
}
fn catch_up(&mut self) -> Result<()> {
self.calls.push(Call::CatchUp);
self.check(Fails::CatchUp)
}
fn session(&mut self, kind: Kind, _: &str) -> Result<()> {
self.calls.push(Call::Session(kind));
self.log = Some(PathBuf::from(format!("/logs/{}.jsonl", kind.token())));
self.check(Fails::Session)
}
fn push(&mut self) -> Result<()> {
self.calls.push(Call::Push);
self.check(Fails::Push)
}
fn write_built_with(&mut self, _: &Choice) -> Result<()> {
self.calls.push(Call::Annotation);
self.check(Fails::Annotation)
}
fn mark_pr_ready(&mut self, checklist: Option<&str>) -> Result<Identified> {
if checklist.is_some() {
self.calls.push(Call::Checklist);
self.check(Fails::Checklist)?;
}
self.calls.push(Call::Ready);
self.check(Fails::Ready)?;
Ok(Identified {
number: 12,
url: PR_URL.to_string(),
})
}
fn take_to_goal(&mut self, _: &str, goal: Goal) -> Result<()> {
self.calls.push(Call::Goal(goal));
if self.refused {
return Err(anyhow!("merge refused").context(PolicyRefusal::NothingToFix));
}
self.check(Fails::Goal)
}
fn delete_branch(&mut self) -> Result<()> {
self.calls.push(Call::Delete);
self.check(Fails::Delete)
}
fn issue_is_open(&mut self) -> Result<bool> {
Ok(!self.issue_closed)
}
fn close_issue(&mut self, comment: &str) -> Result<()> {
self.calls.push(Call::Close(comment.to_string()));
self.check(Fails::Close)
}
fn interrupted(&mut self) -> bool {
self.interrupted
}
fn step(&mut self, line: String) {
self.calls.push(Call::Step(line));
}
fn warn(&mut self, _: &anyhow::Error, warning: String) {
self.calls.push(Call::Warning(warning));
}
}
impl FailedOutside for Scripted {
fn preserve_failed_run(&mut self, reason: &str) -> Result<Preserved> {
self.calls.push(Call::Preserve(reason.to_string()));
self.check(Fails::Preserve)?;
Ok(Preserved::Pushed)
}
fn finish_pr(&mut self, keep_ready: bool) -> Result<Option<String>> {
self.calls.push(Call::Finish(keep_ready));
self.check(Fails::Finish)?;
Ok(Some(PR_URL.to_string()))
}
fn interrupted(&mut self) -> bool {
self.interrupted
}
fn step(&mut self, line: String) {
self.calls.push(Call::Step(line));
}
}
fn deliver(outside: &mut Scripted, goal: Goal, spec: bool) -> Result<String, FailedRun> {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap();
let route = Route {
security_review: outside.reviews_security,
issue: &issue,
base: "main",
branch: "issue-7",
goal,
harness: &Choice::default(),
};
let opening = Opening {
session: if spec {
OpeningSession::SpecReview
} else {
OpeningSession::Implementation
},
prompt: String::new(),
};
route
.steps(outside, &opening, spec.then_some("Tickets"))
.map_err(|error| {
let tracker = Tracker::new(opening.session.stage(), Recorded::default());
for stage in &outside.stages {
tracker.enter(*stage);
}
let stopped_at = stopped_at(&tracker, &error);
fail(
outside,
stopped_at,
PathBuf::from("/worktree"),
outside.log.clone(),
error,
)
})
}
fn stopped(failed: &FailedRun) -> recovery::Stage {
failed.recovery.as_ref().expect("no recovery").stage()
}
#[test]
fn spec_delivery_pushes_then_annotates_then_restores_checklist_before_readiness() {
let mut outside = Scripted::default();
assert_eq!(
deliver(&mut outside, Goal::ReadyForReview, true)
.ok()
.as_deref(),
Some(PR_URL)
);
assert_eq!(
outside.calls,
[
Call::CatchUp,
Call::Session(Kind::SpecReview),
Call::Push,
Call::Annotation,
Call::Checklist,
Call::Ready,
Call::Goal(Goal::ReadyForReview)
]
);
}
#[test]
fn spec_delivery_catches_up_before_its_opening_session() {
let mut outside = Scripted::default();
assert!(deliver(&mut outside, Goal::ReadyForReview, true).is_ok());
assert_eq!(
outside.calls[..2],
[Call::CatchUp, Call::Session(Kind::SpecReview)]
);
}
#[test]
fn annotation_failure_warns_and_still_restores_checklist_and_marks_ready() {
let mut outside = Scripted {
failing: Some(Fails::Annotation),
..Default::default()
};
assert!(deliver(&mut outside, Goal::ReadyForReview, true).is_ok());
assert!(
matches!(&outside.calls[4], Call::Warning(line) if line.contains("could not write"))
);
assert_eq!(
outside.calls[5..],
[
Call::Checklist,
Call::Ready,
Call::Goal(Goal::ReadyForReview)
]
);
}
#[test]
fn only_confirmed_self_merge_deletes_the_branch_and_closes_the_issue() {
let mut outside = Scripted::default();
assert!(deliver(&mut outside, Goal::Merged, false).is_ok());
assert!(outside.calls.contains(&Call::Delete));
assert!(outside.calls.contains(&Call::Close(
"Closed by #12, merged into main by a thirdshift Merge run.".to_string()
)));
}
#[test]
fn each_delivery_failure_preserves_work_then_finishes_the_pr_with_its_primary_cause() {
for failing in [
Fails::CatchUp,
Fails::Session,
Fails::Push,
Fails::Checklist,
Fails::Ready,
Fails::Goal,
] {
let mut outside = Scripted {
failing: Some(failing),
..Default::default()
};
let failed = deliver(&mut outside, Goal::Merged, true).unwrap_err();
assert_eq!(failed.error.to_string(), format!("{failing:?} failed"));
assert_eq!(failed.pr_url.as_deref(), Some(PR_URL));
assert_eq!(
failed.log,
if failing == Fails::CatchUp {
None
} else {
Some(PathBuf::from("/logs/spec-review.jsonl"))
}
);
assert_eq!(
outside.calls[outside.calls.len() - 2..],
[
Call::Preserve(format!("{failing:?} failed")),
Call::Finish(false)
]
);
assert!(!outside.calls.contains(&Call::Delete));
}
}
#[test]
fn interruption_preserves_work_and_keeps_the_recorded_interruption() {
let mut outside = Scripted {
failing: Some(Fails::Session),
interrupted: true,
..Default::default()
};
let failed = deliver(&mut outside, Goal::ReadyForReview, false).unwrap_err();
assert_eq!(failed.error.to_string(), "interrupted");
assert!(failed.interrupted);
assert!(
outside
.calls
.contains(&Call::Preserve("interrupted".to_string()))
);
assert!(outside.calls.contains(&Call::Finish(false)));
}
#[test]
fn policy_refusal_keeps_the_pr_ready_without_a_preservation_push() {
let mut outside = Scripted {
refused: true,
..Default::default()
};
let failed = deliver(&mut outside, Goal::Merged, false).unwrap_err();
assert!(failed.error.is::<PolicyRefusal>());
assert_eq!(failed.pr_url.as_deref(), Some(PR_URL));
assert!(outside.calls.contains(&Call::Finish(true)));
assert!(
!outside
.calls
.iter()
.any(|call| matches!(call, Call::Preserve(_)))
);
}
#[test]
fn failed_run_finishing_errors_warn_and_preserve_the_primary_error_and_log() {
for failing in [Fails::Preserve, Fails::Finish] {
let mut outside = Scripted {
failing: Some(failing),
..Default::default()
};
let failed = fail(
&mut outside,
recovery::Stage::Implementation,
PathBuf::from("/worktree"),
Some(PathBuf::from("/logs/implement.jsonl")),
anyhow!("original\nextra context"),
);
assert_eq!(failed.error.to_string(), "original\nextra context");
assert_eq!(failed.log, Some(PathBuf::from("/logs/implement.jsonl")));
assert_eq!(outside.calls[0], Call::Preserve("original".to_string()));
assert!(
outside
.calls
.iter()
.any(|call| matches!(call, Call::Step(line) if line.contains("could not")))
);
}
}
#[test]
fn post_merge_errors_are_warnings_and_an_already_closed_issue_stays_closed() {
for failing in [Fails::Delete, Fails::Close] {
let mut outside = Scripted {
failing: Some(failing),
..Default::default()
};
assert!(deliver(&mut outside, Goal::Merged, false).is_ok());
assert!(
outside
.calls
.iter()
.any(|call| matches!(call, Call::Warning(_)))
);
}
let mut outside = Scripted {
issue_closed: true,
..Default::default()
};
assert!(deliver(&mut outside, Goal::Merged, false).is_ok());
assert!(
!outside
.calls
.iter()
.any(|call| matches!(call, Call::Close(_)))
);
}
#[test]
fn a_delivery_enters_each_stage_before_its_step() {
let mut outside = Scripted {
reviews_security: true,
..Default::default()
};
assert!(deliver(&mut outside, Goal::ReadyForReview, false).is_ok());
assert_eq!(
outside.stages,
[
Stage::Implementation,
Stage::SecurityReview,
Stage::Push,
Stage::PullRequestReadiness
]
);
}
#[test]
fn a_spec_delivery_opens_in_its_spec_review() {
for failing in [Fails::CatchUp, Fails::Session] {
let mut outside = Scripted {
failing: Some(failing),
..Default::default()
};
let failed = deliver(&mut outside, Goal::ReadyForReview, true).unwrap_err();
assert_eq!(stopped(&failed), recovery::Stage::SpecReview, "{failing:?}");
}
}
#[test]
fn each_failed_step_stops_at_its_stage() {
for (failing, stage) in [
(Fails::Session, recovery::Stage::Implementation),
(Fails::Push, recovery::Stage::Push),
(Fails::Ready, recovery::Stage::PullRequest),
] {
let mut outside = Scripted {
failing: Some(failing),
..Default::default()
};
let failed = deliver(&mut outside, Goal::ReadyForReview, false).unwrap_err();
assert_eq!(stopped(&failed), stage, "{failing:?}");
}
}
#[test]
fn a_security_hold_stops_at_the_security_review_without_entering_it_again() {
let mut outside = Scripted {
reviews_security: true,
security_held: true,
..Default::default()
};
let failed = deliver(&mut outside, Goal::Merged, false).unwrap_err();
assert!(failed.error.is::<SecurityHold>(), "{:#}", failed.error);
assert_eq!(stopped(&failed), recovery::Stage::SecurityReview);
assert_eq!(
outside.stages,
[
Stage::Implementation,
Stage::SecurityReview,
Stage::Push,
Stage::PullRequestReadiness
]
);
}
}