use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use crate::branch::{self, Selection};
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::{self, Sessions};
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 => "is ready for review",
Goal::Merged => "is merged",
}
}
}
pub fn run(issue: &IssueUrl, goal: Goal) -> Result<String, 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 checked_out = launch
.run(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.ok();
let selection = branch::select(&launch, issue)?;
let branch = selection.branch().to_string();
let base = selection.base_branch(checked_out.as_deref())?;
preflight::check_base_branch(&launch, &base)?;
if interrupt::requested() {
return Err(anyhow!("interrupted").into());
}
let (worktree, prompt) = match &selection {
Selection::Fresh { .. } => (
Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?,
prompt::fresh(issue, &base, &branch),
),
Selection::Continuation { pr, .. } => (
Worktree::continue_existing(&launch, &issue.repo, &branch, &base)?,
prompt::continuation(issue, &base, &branch, pr.as_ref().map(|pr| pr.url.as_str())),
),
};
let mut log = session::log_path(issue, ×tamp, "implement")?;
implement(issue, &worktree, &base, &prompt, goal, ×tamp, &mut log)
.map_err(|error| failed_run::fail(issue, worktree, &base, &log, error))
}
fn implement(
issue: &IssueUrl,
worktree: &Worktree,
base: &str,
prompt: &str,
goal: Goal,
timestamp: &str,
log: &mut PathBuf,
) -> Result<String> {
let branch = worktree.branch();
let plugin = Plugin::write()?;
let sessions = Sessions {
issue,
timestamp,
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()?;
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)?;
}
let mut repair_loop = RepairLoop {
issue,
worktree,
base,
pr_url: &pr.url,
goal,
budgets: Budgets::default(),
};
let mut watched = repair_loop.run(&mut run_session)?;
loop {
ensure_pr_ready_and_mergeable(issue, branch)?;
if interrupt::requested() {
bail!("interrupted");
}
if goal == Goal::ReadyForReview {
return Ok(pr.url);
}
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(pr.url);
};
progress::step(format_args!("the merge failed: {error:#}"));
match repair_loop.round_after_failed_merge(&watched, &mut 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_merge_does(issue, branch, &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_merge_does(issue: &IssueUrl, branch: &str, comment: &str) -> Result<()> {
if github::merge_closes_issue(issue, branch)? || !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,
}
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();
loop {
if self.goal == Goal::Merged {
self.take_in_foreign_commits(run_session)?;
}
if worktree.merge_base_branch(base)? == Merge::Conflicted {
let kind = self.budgets.next_repair("conflict")?;
run_session(&kind, &prompt::conflict_repair(issue, base, branch, pr_url))?;
worktree.ensure_base_branch_merged(base)?;
continue;
}
worktree.push()?;
let head = worktree.head()?;
match ci::watch(issue, &head)? {
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) => {
let kind = self.budgets.next_repair("CI red")?;
run_session(
&kind,
&prompt::ci_fix_repair(issue, base, branch, pr_url, &failed),
)?;
}
}
}
}
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();
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 worktree.merge_new_commits()? == Merge::Conflicted {
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_new_commits_merged()?;
}
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)
})
}
}
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
),
}
}