thirdshift 0.3.0

Turn a GitHub issue into a ready-for-review pull request, unattended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! One Run: from an Issue URL to a checked PR, or to a Failed run.

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};

/// Where a Run takes its PR: ready for review, or, in a Merge run, merged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Goal {
    ReadyForReview,
    Merged,
}

impl Goal {
    /// What became of the PR once the Run reached this goal, as in
    /// "PR <url> is merged".
    pub fn outcome(self) -> &'static str {
        match self {
            Goal::ReadyForReview => "is ready for review",
            Goal::Merged => "is merged",
        }
    }
}

/// Take `issue` to a ready PR, or in a Merge run a merged one, and return the
/// PR's URL. Any failure after the worktree exists, including a merge that
/// fails, goes through the Failed run path. The worktree, the local
/// Issue branch and the plugin directory are gone when this returns, except
/// that a Failed run whose work did not reach origin keeps the worktree and
/// branch.
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, &timestamp, "implement")?;
    implement(issue, &worktree, &base, &prompt, goal, &timestamp, &mut log)
        .map_err(|error| failed_run::fail(issue, worktree, &base, &log, error))
}

/// The implement session given `prompt`, the checks on the PR it opened or
/// updated, keeping that PR mergeable and, in a Merge run, the Self-merge.
/// A merge that fails goes back round the Repair loop and is tried again on
/// the new head; if that round finds nothing to fix, the Run fails with a
/// `PolicyRefusal`. `log` is left at the most recent session's log.
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 => {
                // Only a PR still ready and mergeable is left ready.
                ensure_pr_ready_and_mergeable(issue, branch)?;
                return Err(error.context(PolicyRefusal));
            }
        }
    }
}

/// The Self-merge's steps after the merge: delete the Issue branch on origin,
/// and close the issue if the merge does not. The merge can't be undone, so
/// these never fail the Run, and an interrupt no longer stops it: a step that
/// fails is a warning naming the fix to make by hand.
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"'\''")
            ),
        );
    }
}

/// Close `issue` with `comment`, unless it is closed already or merging the
/// PR for `branch` closes it.
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)
}

/// Run `step`, and once more if it failed with the Run interrupted: Ctrl-C in
/// a terminal also kills the git or gh the step was running.
fn retry_if_interrupted(step: impl Fn() -> Result<()>) -> Result<()> {
    step().or_else(|error| {
        if interrupt::requested() {
            step()
        } else {
            Err(error)
        }
    })
}

/// Report `error`, then a warning saying what to do about it by hand.
fn warn(error: &anyhow::Error, warning: std::fmt::Arguments) {
    progress::step(format_args!("{error:#}"));
    progress::step(format_args!("warning: {warning}"));
}

/// The most Repair sessions a Run starts, conflict, CI-fix and review
/// combined.
const MAX_REPAIRS: usize = 5;

/// The most times a Run goes round again because the Base branch moved while
/// CI ran or since a merge was tried, or, in a Merge run, because Foreign
/// commits arrived, whether or not the merge that follows needs a Repair. A
/// clean merge of the Base branch uses no Repair, so without this a busy Base
/// branch could keep a Run going forever.
const MAX_UPSTREAM_MOVES: usize = 5;

/// What a Run has spent of its Repair and upstream-move budgets, across every round
/// of the Repair loop, those after a failed merge included.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Budgets {
    repairs: usize,
    upstream_moves: usize,
}

impl Budgets {
    /// Counts the Repair about to start, as `repair-<n>`, or fails if it would
    /// be one too many.
    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))
    }

    /// Counts a round taken because `upstream` moved, saying `why`, or fails
    /// if it would be one too many.
    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(())
    }

    /// Counts a round taken because `origin/<base>` moved `when`.
    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"),
        )
    }
}

/// What a round of the Repair loop after a failed merge came to.
enum Round {
    /// A head whose CI was found green or absent, to try the merge on.
    NewHead(String),
    /// The same head, with no Repair and no upstream move: a policy refusal.
    NothingToFix,
}

/// Keeps the PR mergeable and its CI green, within the Run's budgets.
struct RepairLoop<'a> {
    issue: &'a IssueUrl,
    worktree: &'a Worktree,
    base: &'a str,
    pr_url: &'a str,
    goal: Goal,
    budgets: Budgets,
}

impl RepairLoop<'_> {
    /// In a Merge run, first take in any Foreign commits. Then merge the
    /// Base branch (never rebase), push, and watch CI on the head commit,
    /// starting a Repair session through `run_session` for a conflict or red
    /// CI and then going round again, since the Base branch may have moved
    /// meanwhile. Green or absent CI also goes round again if the Base branch
    /// moved while CI ran, or, in a Merge run, if Foreign commits arrived.
    /// Returns the head commit whose CI was last watched and found green or
    /// absent. Fails once a Repair beyond `MAX_REPAIRS`, or a round beyond
    /// `MAX_UPSTREAM_MOVES`, would be needed.
    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),
                    )?;
                }
            }
        }
    }

    /// Fetch the Issue branch from origin and merge in any Foreign commits on
    /// it (never rebase), counting that as a round and handing a conflict to
    /// a conflict Repair. Then a review Repair reviews them from the head the
    /// Run last knew as its own, the local head before the merge.
    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),
        )
    }

    /// Go round again after a merge of `watched` failed, counting a Base
    /// branch that moved since as an upstream move. GitHub's error text is never
    /// consulted.
    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)
        })
    }
}

/// The PR whose head is `branch`, failing unless it exists and is open.
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)
}

/// Fail unless the PR for `branch` is still open, ready for review, and
/// mergeable, waiting up to the grace period for GitHub to work out the last.
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
        ),
    }
}