use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use crate::base_fix::{BaseFix, BaseFixAsk};
use crate::branch::{self, Selection};
use crate::child_run::Kind;
use crate::ci::{self, Ci};
use crate::failed_run::{self, FailedRun, PolicyRefusal};
use crate::git::Git;
use crate::github::{self, Mergeable, PullRequest};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::plugin::Plugin;
use crate::poll;
use crate::preflight;
use crate::progress;
use crate::prompt;
use crate::session::{Logs, Sessions};
use crate::spec_run::{self, Parallel};
use crate::worktree::{Merge, 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>,
}
pub struct Ended {
pub outcome: Result<Reached, FailedRun>,
pub base_fix: Option<String>,
}
pub fn run_to_end(
issue: &IssueUrl,
goal: Goal,
logs_dir: &Path,
launch_pull: bool,
parallel: Parallel,
child: Option<&Kind>,
base_fix: BaseFixAsk,
) -> Ended {
let mut base_fix = BaseFix::new(child, base_fix);
let outcome = run(
issue,
goal,
logs_dir,
launch_pull,
parallel,
child.map(Kind::base),
&mut base_fix,
);
Ended {
outcome,
base_fix: base_fix.report(),
}
}
fn run(
issue: &IssueUrl,
goal: Goal,
logs_dir: &Path,
launch_pull: bool,
parallel: Parallel,
given_base: Option<&str>,
base_fix: &mut BaseFix,
) -> Result<Reached, FailedRun> {
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let launch = Git::new(std::env::current_dir().context("no current directory")?);
preflight::check(&launch, issue)?;
let tickets = match given_base {
Some(_) => Vec::new(),
None => github::tickets(issue)?,
};
if 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 = match given_base {
Some(given_base) => Some(given_base.to_string()),
None => launch
.run(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.ok(),
};
let selection = branch::select(&launch, issue)?;
if matches!(selection, Selection::Fresh { .. }) && spec_run::all_closed(&tickets) {
return Err(
anyhow!("every Ticket is closed and there is no Spec branch; nothing to do").into(),
);
}
let branch = selection.branch().to_string();
let base = selection.base_branch(checked_out.as_deref())?;
preflight::check_base_branch(&launch, &base)?;
if launch_pull {
pull_base_branch(&launch, checked_out.as_deref(), &base);
}
if interrupt::requested() {
return Err(anyhow!("interrupted").into());
}
let worktree = match &selection {
Selection::Fresh { .. } => Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?,
Selection::Continuation { .. } => {
Worktree::continue_existing(&launch, &issue.repo, &branch, &base)?
}
};
let logs = Logs::of_run(issue, logs_dir, ×tamp);
if !tickets.is_empty() {
return spec_run::run(
issue,
tickets,
worktree,
&base,
goal,
base_fix,
&logs,
parallel.tickets,
);
}
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 mut log = logs.path("implement");
let implemented = implement(
issue, &worktree, &base, &prompt, goal, base_fix, &logs, &mut log,
);
match implemented {
Ok(pr_url) => Ok(Reached {
pr_url,
goal,
log: Some(log),
ticket_lines: Vec::new(),
}),
Err(error) => Err(failed_run::fail(issue, worktree, &base, &log, error)),
}
}
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]) {
warn(
&error,
format_args!(
"could not update {base} in the Launch directory, \
so update it by hand: git pull --ff-only origin {base}"
),
);
}
}
#[allow(clippy::too_many_arguments)]
fn implement(
issue: &IssueUrl,
worktree: &Worktree,
base: &str,
prompt: &str,
goal: Goal,
base_fix: &mut BaseFix,
logs: &Logs,
log: &mut PathBuf,
) -> Result<String> {
let plugin = Plugin::write()?;
let sessions = Sessions {
logs,
worktree: worktree.path(),
plugin_dir: plugin.path(),
};
let mut run_session = |kind: &str, prompt: &str| sessions.run(kind, prompt, log);
run_session("implement", prompt)?;
worktree.push()?;
let pr = mark_pr_ready(issue, worktree.branch(), base)?;
deliver(issue, worktree, base, &pr, goal, base_fix, &mut run_session)?;
Ok(pr.url)
}
pub fn deliver(
issue: &IssueUrl,
worktree: &Worktree,
base: &str,
pr: &PullRequest,
goal: Goal,
base_fix: &mut BaseFix,
run_session: &mut impl FnMut(&str, &str) -> Result<()>,
) -> Result<()> {
let branch = worktree.branch();
let mut repair_loop = RepairLoop {
issue,
worktree,
base,
pr_url: &pr.url,
goal,
budgets: Budgets::default(),
base_fix,
};
let mut watched = repair_loop.run(run_session)?;
loop {
ensure_pr_ready_and_mergeable(issue, branch)?;
if interrupt::requested() {
bail!("interrupted");
}
if goal == Goal::ReadyForReview {
return Ok(());
}
progress::step(format_args!("merging the PR into {base}"));
let Err(error) = github::merge(issue, branch, &watched) else {
after_merge(issue, worktree, pr);
return Ok(());
};
progress::step(format_args!("the merge failed: {error:#}"));
match repair_loop.round_after_failed_merge(&watched, run_session)? {
Round::NewHead(head) => watched = head,
Round::NothingToFix => {
ensure_pr_ready_and_mergeable(issue, branch)?;
return Err(error.context(PolicyRefusal));
}
}
}
}
fn after_merge(issue: &IssueUrl, worktree: &Worktree, pr: &PullRequest) {
let branch = worktree.branch();
if let Err(error) = retry_if_interrupted(|| worktree.delete_from_origin()) {
warn(
&error,
format_args!(
"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, pr.base
);
if let Err(error) = retry_if_interrupted(|| close_unless_closed(issue, &comment)) {
warn(
&error,
format_args!(
"could not close issue #{number}, so if it is still open, close it by hand: \
gh issue close {number} --repo {repo} --comment '{quoted}'",
number = issue.number,
repo = issue.repo_slug(),
quoted = comment.replace('\'', r"'\''")
),
);
}
}
fn close_unless_closed(issue: &IssueUrl, comment: &str) -> Result<()> {
if !github::issue_is_open(issue)? {
return Ok(());
}
progress::step(format_args!("closing issue #{}", issue.number));
github::close_issue(issue, comment)
}
fn retry_if_interrupted(step: impl Fn() -> Result<()>) -> Result<()> {
step().or_else(|error| {
if interrupt::requested() {
step()
} else {
Err(error)
}
})
}
fn warn(error: &anyhow::Error, warning: std::fmt::Arguments) {
progress::step(format_args!("{error:#}"));
progress::step(format_args!("warning: {warning}"));
}
const MAX_REPAIRS: usize = 5;
const MAX_UPSTREAM_MOVES: usize = 5;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Budgets {
repairs: usize,
upstream_moves: usize,
}
impl Budgets {
fn next_repair(&mut self, cause: &str) -> Result<String> {
if self.repairs == MAX_REPAIRS {
bail!("repairs exhausted: {cause}");
}
self.repairs += 1;
progress::step(format_args!(
"{cause}; starting Repair {} of {MAX_REPAIRS}",
self.repairs
));
Ok(format!("repair-{}", self.repairs))
}
fn count_upstream_move(&mut self, upstream: &str, why: std::fmt::Arguments) -> Result<()> {
if self.upstream_moves == MAX_UPSTREAM_MOVES {
bail!("{upstream} kept moving: merged it again {MAX_UPSTREAM_MOVES} times");
}
self.upstream_moves += 1;
progress::step(why);
Ok(())
}
fn count_base_move(&mut self, base: &str, when: &str) -> Result<()> {
self.count_upstream_move(
&format!("origin/{base}"),
format_args!("origin/{base} moved {when}; merging it again"),
)
}
}
enum Round {
NewHead(String),
NothingToFix,
}
struct RepairLoop<'a> {
issue: &'a IssueUrl,
worktree: &'a Worktree,
base: &'a str,
pr_url: &'a str,
goal: Goal,
budgets: Budgets,
base_fix: &'a mut BaseFix,
}
impl RepairLoop<'_> {
fn run(&mut self, run_session: &mut impl FnMut(&str, &str) -> Result<()>) -> Result<String> {
let (issue, worktree, base, pr_url) = (self.issue, self.worktree, self.base, self.pr_url);
let branch = worktree.branch();
let mut handed_to_repair = None;
loop {
if self.goal == Goal::Merged {
self.take_in_foreign_commits(run_session)?;
}
if let Merge::Conflicted(pending) = worktree.merge_base_branch(base)? {
let kind = self.budgets.next_repair("conflict")?;
run_session(&kind, &prompt::conflict_repair(issue, base, branch, pr_url))?;
worktree.ensure_merged(&pending)?;
continue;
}
worktree.push()?;
let head = worktree.head()?;
if handed_to_repair.as_ref() == Some(&head) {
bail!(
"CI red on {} and the Repair found nothing to fix on the branch",
ci::short(&head)
);
}
let base_commit = worktree.merged_base_commit(base)?;
let compared_with = self
.base_fix
.sees_inherited_failures()
.then_some(base_commit.as_str());
match ci::watch(issue, &head, compared_with)? {
Ci::Absent | Ci::Passed => {
if worktree.base_branch_moved(base)? {
self.budgets.count_base_move(base, "while CI ran")?;
} else if self.goal == Goal::ReadyForReview
|| worktree.new_commits_on_origin()?.is_empty()
{
return Ok(head);
}
}
Ci::Failed(failed) => {
if !failed.inherited.is_empty() {
progress::step(format_args!(
"Inherited failures (also failing on {base} at {}): {}",
ci::short(&base_commit),
ci::check_names(&failed.inherited)
));
}
if failed.own.is_empty() {
if worktree.base_branch_moved(base)? {
self.budgets.count_base_move(base, "while CI ran")?;
continue;
}
self.base_fix.fix(
worktree.launch(),
issue,
pr_url,
base,
&base_commit,
&failed.inherited,
)?;
continue;
}
let kind = self.budgets.next_repair("CI red")?;
run_session(
&kind,
&prompt::ci_fix_repair(issue, base, branch, pr_url, &failed),
)?;
handed_to_repair = Some(head);
}
}
}
}
fn take_in_foreign_commits(
&mut self,
run_session: &mut impl FnMut(&str, &str) -> Result<()>,
) -> Result<()> {
let (issue, worktree, pr_url) = (self.issue, self.worktree, self.pr_url);
let branch = worktree.branch();
let upstream = worktree.upstream();
loop {
let foreign = worktree.new_commits_on_origin()?;
if foreign.is_empty() {
return Ok(());
}
let own_head = worktree.head()?;
self.budgets.count_upstream_move(
&upstream,
format_args!("{upstream} has new commits; merging them in"),
)?;
for sha in &foreign {
progress::step(format_args!("merging new commit {sha} from {upstream}"));
}
if let Merge::Conflicted(pending) = worktree.merge_new_commits()? {
let kind = self
.budgets
.next_repair(&format!("conflict with new commits on {upstream}"))?;
run_session(
&kind,
&prompt::conflict_repair(issue, branch, branch, pr_url),
)?;
worktree.ensure_merged(&pending)?;
}
let kind = self
.budgets
.next_repair(&format!("new commits on {upstream} to review"))?;
run_session(
&kind,
&prompt::review_repair(issue, branch, pr_url, &own_head),
)?;
}
}
fn round_after_failed_merge(
&mut self,
watched: &str,
run_session: &mut impl FnMut(&str, &str) -> Result<()>,
) -> Result<Round> {
let before = self.budgets;
if self.worktree.base_branch_moved(self.base)? {
self.budgets
.count_base_move(self.base, "since the merge was tried")?;
}
let head = self.run(run_session)?;
Ok(if head == watched && self.budgets == before {
Round::NothingToFix
} else {
Round::NewHead(head)
})
}
}
pub fn mark_pr_ready(issue: &IssueUrl, branch: &str, base: &str) -> Result<PullRequest> {
progress::step("checking the PR");
let pr = open_pr(issue, branch)?;
if pr.base != base {
bail!("PR targets {}, not {base}", pr.base);
}
if pr.is_draft {
github::mark_ready(issue, branch)?;
}
Ok(pr)
}
fn open_pr(issue: &IssueUrl, branch: &str) -> Result<PullRequest> {
let pr = github::pull_request_for(issue, branch)?.context("no PR found")?;
if !pr.is_open() {
bail!("PR {} is {}, not open", pr.url, pr.state);
}
Ok(pr)
}
fn ensure_pr_ready_and_mergeable(issue: &IssueUrl, branch: &str) -> Result<()> {
progress::step("checking the PR is open, ready and mergeable");
let pr = open_pr(issue, branch)?;
if pr.is_draft {
bail!("PR {} is a draft", pr.url);
}
let mergeable = poll::within(poll::grace_period(), || {
Ok(Some(github::mergeable(issue, branch)?).filter(|m| *m != Mergeable::Unknown))
})?;
match mergeable {
Some(Mergeable::Yes) => Ok(()),
Some(Mergeable::No) => bail!("PR {} is not mergeable", pr.url),
_ => bail!(
"GitHub has not worked out whether PR {} is mergeable",
pr.url
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repairs_of_every_kind_share_one_cap_and_the_one_too_many_names_its_cause() {
let mut budgets = Budgets::default();
for n in 1..=MAX_REPAIRS {
let cause = if n % 2 == 0 { "conflict" } else { "CI red" };
assert_eq!(budgets.next_repair(cause).unwrap(), format!("repair-{n}"));
}
for cause in ["new commits on origin/issue-7 to review", "conflict"] {
let error = budgets.next_repair(cause).unwrap_err();
assert_eq!(error.to_string(), format!("repairs exhausted: {cause}"));
}
assert_eq!(budgets.repairs, MAX_REPAIRS);
}
#[test]
fn upstream_moves_of_the_base_or_the_issue_branch_share_one_budget() {
let mut budgets = Budgets::default();
for _ in 1..MAX_UPSTREAM_MOVES {
budgets.count_base_move("main", "while CI ran").unwrap();
}
budgets
.count_upstream_move("origin/issue-7", format_args!("new commits"))
.unwrap();
let error = budgets.count_base_move("main", "while CI ran").unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/main kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
let error = budgets
.count_upstream_move("origin/issue-7", format_args!("new commits"))
.unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/issue-7 kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
}
#[test]
fn the_repair_cap_and_the_upstream_move_budget_are_spent_apart() {
let mut budgets = Budgets::default();
for _ in 0..MAX_REPAIRS {
budgets.next_repair("CI red").unwrap();
}
for _ in 0..MAX_UPSTREAM_MOVES {
budgets.count_base_move("main", "while CI ran").unwrap();
}
assert_eq!(
budgets,
Budgets {
repairs: MAX_REPAIRS,
upstream_moves: MAX_UPSTREAM_MOVES
}
);
}
}