diff --git a/docs/architecture.md b/docs/architecture.md index 542d7ddb..6cf90248 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,8 +43,8 @@ Writing standard: plain language, ISO 24495-1:2023 - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). `prepare-merge` refreshes the published PR's exact base/head, rebases through the durable F3/F4 path, recomputes attribution and approvals, pushes a rewritten head whose review is still clear over exactly the PR head it rewrote, and runs allowlisted `cmd:` checks in a credential-free read-only container bound to the resulting head (#22). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks; required-check refresh, the already-fixed rerun, and the one-click guarded merge handoff remain for F6b. - - **Not built:** screens for the runner actions, the required-check refresh and one-click merge handoff (F6b), review rounds, and the Queue and Learning screens. + - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). `prepare-merge` now invokes that path after current pull-request base/head reads, refreshes attribution and approvals, pushes the rewritten head behind a durable push marker (F6a), and runs exact-head `cmd:` checks. With a runner, Approve & merge runs one merge job that prepares, waits up to 30 minutes for GitHub's required checks, and hands the exact pair to the guarded merge, whose already-fixed check now covers every base commit after the task's original base (F6b, `docs/implementation/merge-handoff.md`). + - **Not built:** screens for the runner actions, the merge job's full step list and Cancel button, review rounds, and the Queue and Learning screens. ## Terms used @@ -357,7 +357,7 @@ The diagram shows the designed transitions. With the `runner` block, running, ne ### Stored data -All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 18 today); an unknown version fails. +All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 19 today); an unknown version fails. | Group | Tables | Notes | |---|---|---| @@ -410,7 +410,7 @@ These are the decisions that shape the structure. The full list, with the reason | C, K | Guarded merge, merge queue | Done | | D | Agent isolation boundary | Done. Since the isolation gate: bounded diff export (#76), task-change inspection and the runner's commit as a bounded `git bundle` (#66, PRs #85 and #90), owner-scoped recovery for Ask (#95), the plan schema for Claude (#97), gitlink mounts and the pre-launch tree check (#81, #99, PR #100), and Codex refused in every phase (#93, PR #106). The runner-contract follow-ups (#51) are closed: the last one tests export and removal through a recovery handle after a real restart (PR #109). | | E | Planning library and suggestions | Done. E4 (PR #45) added the acceptance fixtures and recorded Claude output that replays through validation and the Store. | -| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. This branch adds F6a: the durable push of the rewritten head. F6b owns fresh required-check refresh, the already-fixed rerun, and the one-click exact-pair guarded-merge handoff; F6c owns common CI. | +| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). F5 (PR #149) added current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6a (PR #153) added the durable push of the rewritten head. This branch adds F6b: the one-click merge job (preparation, the required-check wait, the exact-pair guarded merge) and an already-fixed scan covering every base commit after the task's original base; F6c owns common CI. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | | G | Planning screen | Done (#145). The screen displays/imports the current plan, requests and renders a next-revision draft or suggestion cards, and applies results with revision-bound exact ambiguous replay. G4 composes the browser with the production planning setup, D-backed provider boundary, F endpoints and SQLite persistence; focused adapter, recorded-output and exact-head real-Docker planning suites cover the lower boundary. | @@ -419,7 +419,7 @@ These are the decisions that shape the structure. The full list, with the reason ## Known limits -- `prepare-merge` invokes the trusted rebase and sandboxed conflict machinery, refreshes the review, pushes the rewritten head, and runs head-bound `cmd:` checks. Until F6b of #22 lands, it does not wait for fresh required checks, rerun the already-fixed check, or hand the exact pair to guarded merge; a person clicks Merge once GitHub's checks on the pushed head pass. +- With a runner, Approve & merge runs one merge job in the background: preparation, a wait of up to 30 minutes for GitHub's required checks, then the guarded merge. A failing or slow check, or GitHub's refusal, leaves the task approved, merge blocked; the browser shows only the job's current step, not the design's full step list, and has no Cancel button for the job yet (cancel the task instead). - Linking is line-based. It cannot see an unrelated edit inside a declared file; only the review agent and you can catch that. - The container's guarantees are those of Docker. The agent can always reach its own vendor account. - `cmd:` acceptance checks run during `prepare-merge`, and their passing evidence is bound to the exact reviewed head. A plan item without current passing evidence still blocks merging. diff --git a/docs/implementation/guarded-merge.md b/docs/implementation/guarded-merge.md index 00b0e189..e619d1c9 100644 --- a/docs/implementation/guarded-merge.md +++ b/docs/implementation/guarded-merge.md @@ -27,7 +27,7 @@ The target is resolved again for each validation pass. Both passes, and the thir Merging blocks when any plan item is unreviewed or stale, an attributed file is outside that item's declared plan scope, an Ambiguous or Unplanned segment remains, a current change request is open, or a `cmd:` acceptance check lacks a passing result for the current head. Approval does not override an out-of-scope file; the plan must be amended. Historical change requests from an earlier revision or snapshot remain visible but do not block the current revision. -The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. The commit scan starts at the PR's base commit on its base branch. Merging requires that commit to equal the reviewed snapshot's base, which is the base the pre-PR check scanned from. The merge passes no own commits, because the task's commits reach the base branch only by this merge. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. +The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. The already-fixed check is the pre-PR check, `GhAlreadyFixedGateway` in `github/already-fixed.ts`, with the same rules (see “The check” in `pull-request-opening.md`): a closed issue, another open or merged PR that would close the issue or is linked manually, or a new commit on the base branch that mentions the issue. A PR that only mentions the issue does not count, in this repository or another; when the PR's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). The merge passes this PR, and every other PR the task records in this repository (#121), as the task's own PRs, matched by repository and number: each is excluded while open and counts once merged, as in the pre-PR check. Without a runner block only this PR is passed. Without a runner block, the commit scan starts at the PR's base commit on its base branch, and no own commits are passed. For a runner-published PR, it starts at the base-branch tip that the pre-PR already-fixed check of the task's first opening into the current base branch scanned through (each opening records its check from schema v19) when that check scanned from the task's original base, and otherwise, including a first opening recorded before v19, at the task's original base, so base commits that a pre-merge rebase brought under the PR are covered too, whichever of the task's openings the merge follows (F6b of #22, `merge-handoff.md`); the ledger's owned commits are passed as the task's own, as in the pre-PR check. Inspections that follow a submitting, queued or merged attempt's pinned pull request scan from the PR's base, as without a runner block; they only show status, since no merge is asked for while an attempt is active. Any result the check cannot complete is unknown and blocks merging, including a base branch name outside the pattern the check accepts. The check starts as soon as the PR is read, alongside the rule reads, and stops 0.65 seconds before the inspection's deadline (`MERGE_CHECK_SETTLE_MS` in `github/merge.ts`: both grace periods of the merge's `gh` runner, which the check also uses, plus a margin), so its processes settle before the inspection ends; a check stopped that way is unknown. If the rule reads fail, the check is stopped and awaited before the inspection rejects. When the PR read ends too late to leave that much time, the check is not started and is unknown. A check answer that arrives after the check was stopped is unknown, and an inspection whose caller aborted or whose deadline passed rejects even when its reads answer late. An injected check must name the merge repository. The blocker names what the check found (at most five closes, PRs and commits, by repository, number, state and short SHA, with a closing commit's SHA shortened too; never a commit message) or why it could not finish. Once this PR has merged, the check counts it as the fix, so the coordinator shows only the `pr-state` blocker, not an already-fixed one. Malformed pagination, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. A caller's own abort during the already-fixed check waits for its processes like any other `gh` call of the gateway, up to 0.4 seconds. An unreadable or incomplete rule source, a pending/missing/failing check, an already-fixed match or unknown result, a conflict, or a closed PR blocks merging. Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge call aborted at its 14-second deadline settles by 14.4 s. *Later (#48, #170):* a click can also be waiting on its issue-trust read, whose stop wait is 0.75 s, or on a review load, whose history worker gets 1 s after the abort before it is terminated; so it settles about 0.75 s after its deadline, or about 1 s or a little more after it when a review load was running, past the 14.5-second shutdown drain (traced, not tested); see [merge-queue.md](merge-queue.md). Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected. @@ -35,7 +35,7 @@ Shutdown sets a terminal admission flag before inspecting active work and checks ## Deferred runner work -This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. The already-fixed commit scan starts at the PR's current base commit; once a rebase moves that base, the scan must still cover the base-branch commits the rebase brought under the PR since the last check. +This slice does not mutate Git history or run plan commands. If the base moved, the gate sends the task back to review. A plan item with a `cmd:` acceptance check remains blocked because the current review service has no trusted container result. Issue #22 tracks rebasing through the ledger, recomputing review state, running commands in the container, and persisting results against the exact head. F6b closes the already-fixed gap: once a rebase moves the base, the scan still covers every base commit after the task's original base. ## Validation diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md new file mode 100644 index 00000000..66ddf5e4 --- /dev/null +++ b/docs/implementation/merge-handoff.md @@ -0,0 +1,93 @@ +# One-click merge handoff (F6b of #22) + +**Audience:** people who build or review codeboost's runner and merge code. +**Purpose:** say exactly what happens between a click on **Merge PR** and GitHub merging the task's pull request, and what stops it. + +## Summary + +With a runner, **Merge PR** no longer merges at once. It starts one **merge job** and returns. The job: + +1. prepares the merge (refresh, rebase, review check, push, `cmd:` checks), as `prepare-merge` does; +2. waits for GitHub's required checks on the pushed head, for up to 30 minutes; +3. merges through the existing guarded merge, which reruns the already-fixed check and pins the exact head. + +Any change that needs a person (a stale approval, a moved pull request, a failing check) stops the job and says why. A task cancelled while the job runs ends it `cancelled`, however the job learns of it, including during one of the job's own review loads. A failing or slow required check, or a refusal from GitHub, moves the task to **approved, merge blocked**; **Retry merge** starts the whole job again. The browser follows the job by polling. + +Without a runner, **Merge PR** works as before. + +## Terms + +| Term | Meaning | +|---|---| +| Merge job | The background work one click starts. One per task at a time. | +| Preparation | `PreMergeCoordinator.start`: refresh, rebase, review check, push of the rewritten head, `cmd:` checks. | +| Readiness | The record preparation leaves when it succeeds. It binds the task state version, review version, snapshot, base, head and command policy. The guarded merge refuses without it. | +| Required checks | The union of the checks GitHub's rulesets and classic branch protection require. | +| Guarded merge | `MergeCoordinator.merge`: two or more fresh inspections, then `gh pr merge --match-head-commit`. | +| Already-fixed check | The check that the issue is not fixed elsewhere: closed, another PR closes it, or a base-branch commit mentions it. | + +## The job, step by step + +| Step | What happens | It continues when | It stops when | +|---|---|---|---| +| Admission | The click's review token must be current, the task mergeable, the issue trusted (read from GitHub, as for `prepare-merge`), and no merge job, runner attempt, publish or preparation active. The review load that checks the token awaits a history worker (#170), so it comes first: a saved click replays before it, and after it a resend of the same click replays its job, shutdown refuses (503, nothing saved), and every other check and the job's record run in one turn. A load stopped by shutdown answers 503; a load that fails for another reason saves nothing and the click may be sent again. If a resend of the same click was admitted while this load or the click's trust read was pending, a failure of either answers that resend's job. Once the job is saved, a failure of the click's own review reload answers the job with `mergeRefreshRequired`: the page keeps its review, follows the job, and a refresh shows the reloaded review. A reload stopped by shutdown answers 503, and the resent click returns the job. The job's first step reads the task and review it binds the preparation to in one turn, after its own review load, and ends at once if the task was cancelled meanwhile. If the review changed between admission and the end of that load (another tab approved an item, accepted a segment, or imported a plan), its token no longer matches the click's: the job ends `failed` with "The review changed after Merge PR was clicked. Refresh to see the change.", saves no `prepare-merge` action and prepares nothing, as the direct merge refuses a review that changed during its validation. The job record is saved under the click's action ID and the click returns the review with the job's latest record (usually `running`, step `preparing`; it may already have moved on or ended). | always | the token is stale, the issue is not trusted, or something is busy. The refusal is saved under the click's action ID, so a resend replays it; a refusal because the server is shutting down (HTTP 503, also when shutdown stops the click's trust read), or because trust could not be read, saves nothing and may be sent again. A trust read that timed out, or that gh answered, keeps that answer even when shutdown started while gh stopped | +| Prepare | Only for the review the click was admitted for (see Admission). The job records its own `prepare-merge` action and runs preparation, which binds its readiness to the review version it read. | preparation is `ready` | preparation needs review (job: `review-required`) or fails (job: `failed`) | +| Wait for checks | The job inspects the pull request (fresh) at once, then again after 10 s, with the wait doubling up to 60 s. | every required check succeeds, or the required set was read and is empty, and nothing else blocks | a required check fails, or 30 minutes pass (job: `blocked`, if the preparation still applies: the block re-checks readiness in its transaction, and a review edit or other task change meanwhile ends the job `failed` instead); the pull request moved, another blocker appears, or GitHub or the local review cannot be read (job: `failed`; one failed read fails closed rather than retrying; each inspection also loads the review, which awaits a history worker since #170, within the same 30-minute limit) | +| Merge | The job calls the guarded merge with the current review token, not the click's: a rebase during preparation makes a new snapshot and so a new token. A review change after preparation is refused through readiness, which binds the review version. Its inspections rerun the already-fixed check. | GitHub merged or queued the head, as the job's own merge attempt records it when the job reads it after the guarded merge (job: `merged` or `queued`; if a merge-queue poll ended the attempt removed or failed while GitHub was asked, the guarded merge cannot save it queued and rejects, and the job ends as for a refusal; `submitted` when GitHub accepted the merge but saving its outcome failed, so the attempt is still submitting and reconciles from GitHub) | GitHub refused (job: `blocked`; `failed` when GitHub refused because the head changed, which needs a fresh review, or when the review, snapshot or plan revision changed while GitHub was asked: the block is for the review the merge attempt was admitted for. A review edit (a review note) may land while the attempt is in flight. The snapshot term is defensive for a merge job: jobs need a runner, and a runner server's review load reads the recorded head, so no HEAD observation records a new snapshot. A plan revision lands only after a merge-queue poll ended the attempt while GitHub was asked, as plan writes are refused while a merge attempt is in flight), validation refused (job: `failed`), or the outcome is unknown (job: `submitted`; the merge attempt reconciles as before) | + +A required check run passes when it completed with success, neutral or skipped, as GitHub counts required checks; a commit status passes only with success. When a check has several runs or several statuses with its name (a re-run), the one that started last counts. The same rule applies to commit statuses. A run or status not yet completed (queued, in progress or pending) with no start time counts as the latest. A completed run or status with no start time started before its completion time, so it is known to be older than, and is dropped for, any run or status of its kind that started after that time (or that is not yet completed and has no start time). Otherwise, including when it has no completion time either, its order relative to the latest is unknown, so it is never ordered below another: it ties with the latest. Among runs that tie, the one that passes least counts, so a run or status with no time can make the check fail or wait, but never makes it pass (fail closed). A check run and a commit status with the same name are not re-runs of each other: GitHub requires both to pass, so the latest of each counts, and the check is failing if either fails, otherwise pending if either is pending. A check that names an app matches only that app's check runs. While waiting, an unknown mergeability (GitHub is still computing it) and a pending or missing required check keep the job waiting. No inspection starts after the 30 minutes, and one still running when they end is stopped, so only checks seen passing within the limit count. An inspection that finds every check passing but finishes after the limit, before its stop arrives, also ends the job `blocked`, with "Required checks passed only after the 30-minute limit. Retry merge." and no waiting checks listed; one that finds a failing check or another blocker decides as it does within the limit. Only that stop counts as the wait timing out: an inspection that GitHub failed before the limit, but that settles after it (its stopped reads finishing), ends the job `failed` with GitHub's error. The guarded merge that follows inspects again and may ask GitHub to merge shortly after the limit; it refuses anything no longer clear. Unreadable branch rules never pass. Another blocker outranks a failing check: checks that fail on a head a collaborator pushed say nothing about this review, so the job ends `failed` and the task's status is unchanged. + +A push to the base branch during the wait is such a blocker: the next inspection sees the moved base, and the job ends `failed` with "The base branch moved; merging rebases onto it and prepares again, so the result may need review." The task stays in review. The person clicks **Merge PR** again: the new job rebases onto the new base and prepares again. If the rebase makes an approval stale, that job ends `review-required`. + +With merge jobs, the merge status words the blockers the job resolves as what merging does, because the screen offers no Rebase or Prepare control: a moved base reads as above, and preparation reads "The merge has not been prepared for the current review; merging prepares it." A push of the rewritten head whose outcome is not known yet is a preparation blocker too, and keeps its own detail: "A push of the rewritten head has not settled; merging reads its outcome and prepares again." The reason a job ends `failed` with uses the same wording when the job's own inspection or the guarded merge's first pass finds the blocker (for example, the base moved after the job's last inspection): the guarded merge's refusal carries the blocker, and the job words it. At the guarded merge's later passes this wording is defensive: during a job nothing changes the task's state version, and a moved base fails the pull request identity check first. Without a runner, and for a direct merge, the guarded merge's own wording is unchanged. + +## Outcomes + +| Job state | Task status afterwards | What the person does | +|---|---|---| +| `merged` | merged | nothing | +| `queued` | unchanged (in review, or approved, merge blocked on a retry) while GitHub's queue holds it | nothing | +| `submitted` | unchanged | refresh; the merge attempt settles from GitHub | +| `blocked` | approved, merge blocked | what the reason's last sentence says. After the 30-minute wait ran out, nothing failed: "Wait for the checks to pass, then Retry merge." (or wait for GitHub to determine mergeability). After a failing check or GitHub's refusal: "Fix it, then Retry merge." ("Fix them" for several failing checks). After GitHub refused a merge, a retry whose preparation rebases makes a new snapshot, and that needs every item approved again (the replacement-snapshot rule of `merge-queue.md`): the items show stale, the retry ends `review-required`, and the merge status asks for every item to be approved again. To change the task's code, use the scope-amendment flow, as from review: it accepts a blocked task. | +| `review-required` | unchanged | review the refreshed pull request, then merge again | +| `failed` | unchanged | do what the reason asks first, if anything (for example, review the new head), then merge again. The reason never says "merge again": the page adds "Merge again when ready." once | +| `cancelled` | cancelled | nothing | + +Moving the task to **approved, merge blocked** advances its state version, so the next job prepares again from the start. + +## The already-fixed range + +A rebase moves the pull request's base. The guarded merge would then scan only the base commits after the new base and miss the ones the rebase brought in. The scan must cover every base commit after the task's original base (the base of its first snapshot), since a rebase can bring any of them under the pull request. Each opening records the pre-PR check it followed (schema v19). That check of the task's **first opening into the current base branch** scanned the branch from its snapshot's base to the branch's tip then, and found nothing. When it scanned from the task's original base, each merge inspection scans from that tip to the branch's tip now. A later rebase onto the branch can bring in any commit after the original base: those up to that tip were scanned by that check, and later ones are in the merge's scan, so the scan covers them whichever of the task's openings into that branch the merge follows (after a re-publish recorded a newer check from a rebased base, and after a person closed the pull request and a new one was opened from a rebased base). A newer check is not a safe start: it scanned only from a rebased base, and a compare range excludes its own start commit. An opening into another base branch, left by a change of the `github.baseBranch` setting, says nothing about this branch and is not used. A first opening recorded before schema v19 has no check, and no record shows which base branch an older check scanned (a publish can record its check and stop before opening), so no other check stands in. Then, and when the first opening's check scanned from a rebased base (the base branch setting changed after a rebase), the scan starts at the task's original base. If that start is not on the base branch (for example, it was force-pushed), the answer is unknown and merging stays blocked. The check reads at most 250 base commits: if more landed since the scan's start, its answer is unknown and merging stays blocked, as for a pull request never rebased. There is no person override yet (the Continue action of `pull-request-opening.md` is a later slice). The task's own commits, the ledger's owned entries, are excluded, as in the pre-PR check. The status shown while a merge attempt is submitting, queued or merged scans from the pull request's base, as before: those inspections follow the attempt's pinned pull request, and no merge is asked for while an attempt is active. + +## Cancel, shutdown and restart + +| Event | Effect | +|---|---| +| Cancel during preparation | as for `prepare-merge`: the preparation stops, and the task closes when its work has settled | +| Cancel while waiting | the wait stops at once; the task closes; the job ends `cancelled` | +| Cancel during the guarded merge | before GitHub is asked, the merge stops and the job ends `cancelled`; once a merge attempt is submitting, a cancel is refused, as before | +| Shutdown | in shutdown's first, synchronous step, before requests drain, the job is told to stop: a wait ends at once, and nothing after it asks GitHub to merge. A preparation in progress stops only when preparation does, after the drain, as for a `prepare-merge` click; the job then stops without merging. The job is awaited before the store closes. While preparing or waiting, it stays `running` on disk, unless the task was cancelled before the job stopped: then it ends `cancelled`, also when its stopped work (such as one of its own review loads) settles only after shutdown began. A guarded merge in progress is stopped in that first step too (a direct click's merge gets the drain period instead), and its outcome is recorded as a click's is: if GitHub was not asked yet, the job stays `running`; if it was, the job ends as the merge attempt records it: `blocked` if GitHub refused (`failed` if the head changed, or the review changed while GitHub was asked), otherwise `submitted`, and the attempt reconciles as before | +| Ending the job fails | if the write that ends a job fails, or reading the task after the job failed does, the server logs, also during shutdown (after GitHub was asked to merge), "Could not end merge job …; it shows as running until the server restarts" with the store's message. The job stays `running` on disk: the page keeps following it, and a new click is refused while it runs. The next start settles it as below | +| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"). One that had (its merge action ID is saved before the guarded merge starts) ends as its merge attempt records it: `merged`, `queued`, `submitted`, or `blocked` after a refusal, which also moves the task to approved, merge blocked (`failed`, task unchanged, when the head changed or the review changed while GitHub was asked). A `prepare-merge` action and a push marker are settled as before | + +## Storage + +Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening followed) and `merge_jobs`: one row per job or refused click, with three columns: `id` (the click's action ID, primary key), `plan_key` (the task, indexed by `merge_jobs_plan`) and `data`. `data` is a JSON record holding the rest: the click's review token, the guarded merge's action ID once the job reaches that step, `state`, `step`, `reason`, required-check names and states, the merge result URL, `sequence` (the record's place among the task's jobs and refused clicks, from 1, so the browser orders jobs without comparing clock times), `revision` (the record's version, from 1; every saved change to the job adds one, so the browser orders records of one job without comparing clock times, which can tie or step back), start and update times, and `refused` for a refused click. Lookups by state (the running job, the latest job that was not refused) read these JSON fields. A refused click's row is replayed to that click and never shown as the task's job. Writing it never changes the task's state version, so the readiness preparation recorded stays valid while the job waits. A resend of the same click returns its row; another click while a job runs is refused. While a job runs, `prepare-merge` is refused (the job prepares the merge itself). `publish` is refused too, by publishing's own status rule: it publishes only a running task whose plan items have all run, or a task that needs a person, and a job runs only for a task in review or approved but merge blocked. The runner's `start` and `resume` actions are refused too ("A merge is running for this task; wait for it to end or cancel the task."), saved and replayed like other refusals, and neither is offered: a run would move the task's head, and the publish at its end would push it. So no run starts, and no publish can push the pull request's head or begin a refresh of it while the job prepares, waits or merges. Plan import stays allowed during a job, as at any time in review. It changes only the plan, never the branch or the pull request; the readiness the job prepared is then for an older review, so the job's next inspection ends it `failed` ("The merge has not been prepared for the current review; merging prepares it."), or the guarded merge refuses, and nothing is merged. + +## API and browser + +- `POST /api/action` with `merge` returns at once when the server runs merge jobs (a runner and a pull request published by it, which requires preparation): the reloaded review with the job's latest record as `job`, or, if that reload failed, `{ job, mergeRefreshRequired: true }`. The job may already have moved on or ended by then; the page follows a running job and, for an ended one, asks for a refresh. A resend of a click whose job has ended replays that job while a later one (another tab's retry) may run: the reloaded review then carries the later job, and the page follows the later of the two records (by `sequence`, or `revision` within one job), so it follows the running job rather than showing the replayed outcome. +- With merge jobs, the merge status in the review (`GET /api/review` and the click's answer) carries `job`, the task's latest job, and `settledBeforeJobMerge`: the blocker codes settled once a job asks GitHub to merge. It is built from the server's job-resolved blockers (preparation, base, acceptance, check) plus `mergeable`, and the page uses it rather than a copy of its own. +- `GET /api/merge` returns `{ queue, job }`: `job` is the task's latest job. A saved refusal is not a job; it is replayed only to its own click. +- A refused merge click (409, new or replayed) carries `job`, the task's latest job, as `GET /api/merge` would answer it. A page that holds an older review, such as a second tab, may click Merge PR while another tab's job runs; it is refused ("A merge is already running for this task."). When the carried job is running and later than the one the page shows, the page follows it as its poll would: the control shows the job's step, and the banner reads the refusal, then the step. The refusal is a definite answer, so the click's key is dropped, as for any other refusal; any other refusal still asks for a refresh. +- Merge PR is offered only while the task is in a mergeable status (read after the inspection) and the only blockers left are ones the job resolves or reports: preparation, a moved base (with a merge conflict it causes), `cmd:` checks, required checks (the job waits for pending ones and ends `blocked` on failing ones, so a person can retry after re-running CI), and mergeability GitHub is still computing. +- While a job runs, the Merge button keeps keyboard focus: it is marked `aria-disabled`, not disabled, and a repeat activation starts nothing. While the click's own request is in flight, the button reads **Requesting merge…** and stays marked, also when the page renders meanwhile (for example when a person selects an item). +- The browser polls `GET /api/merge` with the existing 2 s to 30 s backoff while a job runs, shows its step in the merge banner, and lists waiting checks in the blocker dialog. The control and the dialog's heading name the step the same way: **Preparing…**, **Waiting for checks…** or **Merging…**. A full review response read before the poll's latest record keeps that newer record: a later record of the same job (by its `revision`, never its `updatedAt`), or a later job (by its `sequence`, which another tab's click may have started). A kept running job keeps Merge PR not ready and its poll running, so a slow **Refresh**, review action, **Refresh plan**, plan import or **Apply** reload does not restart following a job the poll saw end, or stop following a newer job and offer Retry while it runs. A kept job that ended `queued` also keeps the merge attempt the poll followed for it (the attempt whose `actionId` is the job's `mergeActionId`), whatever queue the full response holds, and shows it as the poll did: **Merged** or a removal stays, and while GitHub's queue holds it the control reads **Merge queued** and the queue poll continues. A click's key is kept until its own job ends or a job newer than the one shown at the click is the latest, so a retry that failed with no answer is resent with the same key. When the job ends, the page updates only the merge control and banner (the outcome, and "Refresh to see the result"), so a person's selection and input survive. A job that ends `queued` left its merge attempt in GitHub's queue, and the poll that sees it end answers that attempt as `queue` (its `actionId` is the job's `mergeActionId`). The page then follows the attempt as it follows a queue merge, also when its review had no queue: the control reads **Merge queued** while GitHub's queue holds it, and the queue poll continues until GitHub merges or removes it, which the control and banner then read (**Merged** and "GitHub confirmed the reviewed head was merged.", or the removal) without a refresh. The click's answer is read the same way: when it carries a job that ended `queued` and that job's own attempt (another client's poll may have seen the attempt merged, removed or failed before the click's review reload read it), the control and banner read the attempt (**Merged**, or the removal, not **Merge queued**), and an attempt GitHub's queue still holds is polled until it ends. The job poll, the click's answer and a newer record kept over a full response present an ended job and its own attempt through one rule, in which the attempt's status takes precedence over the job's outcome in both the control and the banner. Whichever way the page learns the job ended (the poll, the click's answer, or a newer record kept over a full response), the control reads the outcome, as it does after a merge-queue merge: **Merged**, **Merge queued**, **Merge submitted**, **Merge blocked**, **Review needed**, **Merge stopped** or **Merge cancelled**. Its **Merge status** dialog lists the outcome with "Refresh to see the result". After **Merged**, **Merge queued** or **Merge submitted** the job asked GitHub to merge, which it does only once no blocker remains, so the dialog leaves out the blockers the merge status's `settledBeforeJobMerge` names (preparation, a moved base, `cmd:` checks, required checks and mergeability): the review the page holds was read before or during the job and still lists them. After any other outcome the job did not settle them (a blocked job's failing check still fails), so the dialog keeps every blocker the page last read, and **Refresh** shows which still apply. After **Refresh**, the page offers **Retry merge** when the job is `blocked`; its confirmation says the retry prepares the merge again and may rebase onto the current base and need a new review, not that it merges the exact reviewed head (that wording stays for a merge without a runner). The design's full step list and a Cancel button come later. + +## Tests + +`test/merge-job.test.ts` runs the job against the real Store and guarded merge with a faked GitHub and preparation. It covers the three wait cases the design names (zero required checks, one check pending then passing, a timeout), a failing check, GitHub still computing mergeability, preparation ending for review or failing, another blocker while waiting, a failed GitHub read while waiting (also one that settles after the wait limit), GitHub's refusal, an unknown merge outcome, a merge queue (`queued`), cancel while waiting (told or not) and during the guarded merge's validation, shutdown while waiting and during the guarded merge, startup settlement, resends, a second click, a stale token, a busy runner, which blockers keep Merge PR available (including Retry merge after `blocked`), the job-mode wording of a moved base and of preparation, and each ended reason's next step (wait after a timeout, fix after a failing check or refusal, nothing repeated in a failed reason). `test/pre-merge.test.ts` runs the #22 acceptance cases through the job with real preparation: a base move that leaves approvals valid merges; a rebase that makes an approval stale stops for review; a breaking command after a rebase fails the job; a collaborator push stops for review; a cancel during a command check ends the job `cancelled`. Further regressions cover a cancel, then shutdown, before the job's own review load rejects (the job ends `cancelled`), a plan import while the job's first review load is pending (the job ends `failed` and prepares nothing), a queue poll that records `removed` or `failed` while GitHub is asked to merge (the guarded merge rejects and the job ends `blocked`; with a plan import after the poll, the job ends `failed` and the task stays in review), and an inspection that finds the checks passing, or one failing, but finishes after the wait limit before its timer runs (a timeout, or the failing check). Server tests cover the merge route (including a second tab's refused click, which carries the running job), its trust admission (an untrusted click refused and replayed, a failed read saving nothing), `GET /api/merge`, the `prepare-merge` and `publish` refusals, the `start` refusal after a plan import and approval during the preparation, the `start` and `resume` refusals after the same during the check wait (the import then ends the job `failed`, with nothing run, pushed, published or merged), cancel closing the pull requests, and shutdown; a production test covers startup settlement; a browser test follows a job to merged, and others show that once a job ends (polled, answered to the click, or kept over an older full response) the control reads its outcome; after a job that reached the merge it drops the blockers the server names in `settledBeforeJobMerge`, and after a failed one it keeps a conflict and a check blocker. Others show that a job the poll sees end `queued` is followed through GitHub's queue to merged without a refresh, from a review with no queue, that a resent click whose answer replays its ended job follows the later running job the reloaded review carries, that a click refused because another tab's job runs follows that job, that a blocked banner states its reason's next step once, and that the running job's dialog names the step as the control does. + +## Check this document with a reader + +Before merging, ask someone who did not write it to answer, from this page alone: what one click does, when the task becomes approved, merge blocked, and why a job survives a review edit only by failing. diff --git a/github/merge.ts b/github/merge.ts index a113cda2..d7346cbe 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -64,6 +64,13 @@ export interface MergeTarget { ownPullRequests?: readonly number[]; /** The task branch of a published target (#121). The inspection then also reads `RemoteMergeState.published`. */ headBranch?: string; + /** + * Where the already-fixed commit scan starts, so it covers every base commit after the task's original base, which a + * rebase can bring under the PR (F6b of #22; the runner picks it, `merge-handoff.md`). Default: the PR's base commit. + */ + alreadyFixedSince?: string; + /** Commits the task made; the already-fixed scan does not count them. Default: none. */ + ownCommits?: readonly string[]; } export interface MergeResult { url: string; } @@ -104,7 +111,7 @@ export interface GhMergeConfig { baseBranch?: string; } -type ResolvedTarget = { pullRequest: number; ownPullRequests: readonly number[]; headBranch?: string }; +type ResolvedTarget = { pullRequest: number; ownPullRequests: readonly number[]; headBranch?: string; alreadyFixedSince?: string; ownCommits?: readonly string[] }; type BranchRules = Pick; export type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise; @@ -142,6 +149,48 @@ function timestamp(value: unknown, label: string): string { return value; } +/** + * The state GitHub gives one observed required check. A check run that completed SUCCESS, NEUTRAL or SKIPPED passes, as + * GitHub treats those conclusions for required checks; a commit status passes only with SUCCESS. + */ +function observedCheckState(check: Record): 'success' | 'pending' | 'failure' { + if (typeof check.name === 'string') { + const status = String(check.status ?? '').toUpperCase(); + const conclusion = String(check.conclusion ?? '').toUpperCase(); + if (['QUEUED','IN_PROGRESS','WAITING','PENDING','REQUESTED'].includes(status)) return 'pending'; + if (status === 'COMPLETED') return ['SUCCESS','NEUTRAL','SKIPPED'].includes(conclusion) ? 'success' : 'failure'; + return 'failure'; + } + const status = String(check.state ?? '').toUpperCase(); + return status === 'SUCCESS' ? 'success' : ['PENDING','EXPECTED'].includes(status) ? 'pending' : 'failure'; +} +/** A real time gh reports, or NaN for none or the zero time (year 1) it gives a run that has not started or finished. */ +function observedTime(value: unknown): number { + const at = typeof value === 'string' ? Date.parse(value) : NaN; + return Number.isFinite(at) && new Date(at).getUTCFullYear() > 1 ? at : NaN; +} +/** + * When an observed check run or status started, to pick the latest of several with one name, or NaN when that is not + * known. A run or commit status not yet completed (queued, in progress, pending) with no start time was queued after + * every started one, so it counts as the latest (+Infinity). A completed one with no start time gets NaN: its order + * relative to the latest is unknown (see `observedCheckOlderThan`). + */ +function observedCheckStart(check: Record): number { + const started = observedTime(check.startedAt); + if (Number.isFinite(started)) return started; + return observedCheckState(check) === 'pending' ? Number.POSITIVE_INFINITY : NaN; +} +/** + * For a completed run or commit status with no start time: its completion time, before which it started, so it is + * known to be older than any observation of its kind that started after that time. NaN when it has a start time, is not + * completed, or has no completion time. + */ +function observedCheckOlderThan(check: Record): number { + return Number.isNaN(observedCheckStart(check)) ? observedTime(check.completedAt) : NaN; +} +/** Among runs with the same start, the one that passes least counts, so a tie never reads as passing. */ +const checkStateRank = { failure: 0, pending: 1, success: 2 } as const; + /** GitHub CLI adapter. All arguments are literal argv; no shell is involved. */ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { readonly config: GhMergeConfig; @@ -176,7 +225,11 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { const own = target?.ownPullRequests ?? []; if (own.some(number => !validNumber(number))) throw new Error('Invalid pull request number.'); if (target?.headBranch !== undefined && (typeof target.headBranch !== 'string' || !BRANCH.test(target.headBranch))) throw new Error('Invalid pull request branch.'); - return { pullRequest, ownPullRequests: [...new Set([pullRequest, ...own])], ...(target?.headBranch !== undefined ? { headBranch: target.headBranch } : {}) }; + if (target?.alreadyFixedSince !== undefined && (typeof target.alreadyFixedSince !== 'string' || !SHA.test(target.alreadyFixedSince))) throw new Error('Invalid already-fixed scan start.'); + if (target?.ownCommits !== undefined && (!Array.isArray(target.ownCommits) || target.ownCommits.some(sha => typeof sha !== 'string' || !SHA.test(sha)))) throw new Error('Invalid own commit.'); + return { pullRequest, ownPullRequests: [...new Set([pullRequest, ...own])], ...(target?.headBranch !== undefined ? { headBranch: target.headBranch } : {}), + ...(target?.alreadyFixedSince !== undefined ? { alreadyFixedSince: target.alreadyFixedSince } : {}), + ...(target?.ownCommits !== undefined ? { ownCommits: [...new Set(target.ownCommits)].sort() } : {}) }; } async #json(args: readonly string[], signal?: AbortSignal): Promise { @@ -195,20 +248,21 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { /** * The pre-merge "already fixed" check runs the pre-PR check (`github/already-fixed.ts`) with the same rules. This PR and - * the task's other recorded PRs are its own: excluded while open, a match once merged. The base-branch scan starts at the - * PR's base commit, the base the merge is validated against; the task has no own commits on the base branch before its - * merge. + * the task's other recorded PRs are its own: excluded while open, a match once merged. The base-branch scan starts at + * `target.alreadyFixedSince` when given (a runner-published PR), else at the PR's base commit; `target.ownCommits`, the + * task's own commits a rebase may have rewritten, are not counted. * The check stops early enough that its `gh` processes, which may need both grace periods after an abort, settle before * the inspection's deadline; stopped that way it is `unknown`. */ - async #alreadyFixed(ownPullRequests: readonly number[], baseBranch: string, base: string, deadlineAt: number, signal?: AbortSignal): Promise> { + async #alreadyFixed(target: ResolvedTarget, baseBranch: string, base: string, deadlineAt: number, signal?: AbortSignal): Promise> { // A check started with no settle time left could leave processes running past the inspection's deadline. const runFor = deadlineAt - MERGE_CHECK_SETTLE_MS - Date.now(); if (runFor <= 0) return { alreadyFixed: 'unknown', alreadyFixedDetail: 'No time was left to run the check.' }; const stop = new AbortController(); const timer = setTimeout(() => stop.abort(new Error('The already-fixed check did not finish in time.')), runFor); try { - const result = await this.checks.check({ issue: this.config.issue, taskBase: base, baseBranch, ownPullRequests: [...ownPullRequests], ownCommits: new Set() }, + const result = await this.checks.check({ issue: this.config.issue, taskBase: target.alreadyFixedSince ?? base, baseBranch, + ownPullRequests: [...target.ownPullRequests], ownCommits: new Set(target.ownCommits ?? []) }, signal ? AbortSignal.any([signal, stop.signal]) : stop.signal); // An answer that arrives after the check was stopped is not used. (One after a caller's abort is refused by the // inspection itself, once the rule reads have ended.) @@ -244,7 +298,7 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { const stopOthers = (error: unknown) => { failed.abort(error); throw error; }; // Handled from the start: a stopped check can settle before the other stages, and an unhandled rejection meanwhile // would reach Node's process-wide handler (#48). - const alreadyFixed = this.#alreadyFixed(target.ownPullRequests, pr.baseRefName, base, deadlineAt, reads) + const alreadyFixed = this.#alreadyFixed(target, pr.baseRefName, base, deadlineAt, reads) .catch((error: unknown) => error instanceof Error ? error : new Error(String(error))); const [ruleRead, listRead] = await Promise.allSettled([ this.#rules(pr.baseRefName, pr.statusCheckRollup as Array>, reads).catch(stopOthers), @@ -354,24 +408,30 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway { } } const requiredChecks = [...requirements.values()].map(required => { - const match = observed.find(check => { + const matches = observed.filter(check => { const context = typeof check.name === 'string' ? check.name : check.context; const app = check.app && typeof check.app === 'object' ? (check.app as { databaseId?: unknown }).databaseId : null; return context === required.context && (required.appId === null || app === required.appId); }); - if (!match) return { ...required, state: 'missing' as const }; - let state: RequiredCheck['state']; - if (typeof match.name === 'string') { - const status = String(match.status ?? '').toUpperCase(); - const conclusion = String(match.conclusion ?? '').toUpperCase(); - if (['QUEUED','IN_PROGRESS','WAITING','PENDING','REQUESTED'].includes(status)) state = 'pending'; - else if (status === 'COMPLETED') state = conclusion === 'SUCCESS' ? 'success' : 'failure'; - else state = 'failure'; - } else { - const status = String(match.state ?? '').toUpperCase(); - state = status === 'SUCCESS' ? 'success' : ['PENDING','EXPECTED'].includes(status) ? 'pending' : 'failure'; - } - return { ...required, state }; + if (!matches.length) return { ...required, state: 'missing' as const }; + // A re-run adds a newer run (or status) with the same name; GitHub counts the latest one, so an older failure + // does not outrank a newer pending or passing run. A check run and a commit status with the same name are not + // re-runs of each other: GitHub requires both to pass. So the latest of each kind counts, and the one that passes + // least decides. + // The latest is the observation with the greatest known start (a not-completed one with no start counts as + // +Infinity). A completed observation with no start time is dropped when another of its kind started (or is + // not-completed with no start) strictly after its completion: it is known to be older. Otherwise its order is + // unknown, so it ties with the latest, and it can only make the result pass less. + const latestOfKind = (kind: Record[]) => { + if (kind.length === 0) return null; + const seen = kind.map(check => ({ state: observedCheckState(check), at: observedCheckStart(check), olderThan: observedCheckOlderThan(check) })); + const latest = Math.max(...seen.map(check => Number.isNaN(check.at) ? Number.NEGATIVE_INFINITY : check.at)); + return seen.filter(check => Number.isNaN(check.at) ? !(latest > check.olderThan) : check.at === latest) + .reduce((worst, next) => checkStateRank[next.state] < checkStateRank[worst.state] ? next : worst).state; + }; + const states = [latestOfKind(matches.filter(check => typeof check.name === 'string')), latestOfKind(matches.filter(check => typeof check.name !== 'string'))] + .filter(state => state !== null); + return { ...required, state: states.reduce((worst, next) => checkStateRank[next] < checkStateRank[worst] ? next : worst) }; }); return { rulesKnown, atomicBaseGuard, mergeQueue, requiredChecks }; } diff --git a/runner/merge-job.ts b/runner/merge-job.ts new file mode 100644 index 00000000..da567678 --- /dev/null +++ b/runner/merge-job.ts @@ -0,0 +1,397 @@ +import { randomUUID } from 'node:crypto'; +import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { MergeBlocked, MergeOutcomeUnknown, PUSH_UNSETTLED, type MergeCoordinator, type MergeStatus } from './merge.ts'; +import type { PreMergeCoordinator } from './pre-merge.ts'; +import type { ReviewService } from './review.ts'; +import { FIX_THEN_RETRY, MAX_MERGE_JOB_TOKEN_LENGTH, type MergeAttempt, type MergeJob, type MergeJobState } from './store.ts'; + +/** How long a job waits for GitHub's required checks on the pushed head (docs/implementation/merge-handoff.md). */ +export const REQUIRED_CHECK_WAIT_MS = 30 * 60_000; +/** The wait between required-check inspections starts here and doubles up to the maximum. */ +export const REQUIRED_CHECK_FIRST_DELAY_MS = 10_000; +export const REQUIRED_CHECK_MAX_DELAY_MS = 60_000; + +/** Why a job ends when the review changed between the click's admission and the job's first review load. */ +export const REVIEW_CHANGED_AFTER_CLICK = 'The review changed after Merge PR was clicked. Refresh to see the change.'; + +/** A job's answer to the browser: everything but the click's review token. */ +export type MergeJobView = Omit; +export const mergeJobView = ({ token: _token, ...job }: MergeJob): MergeJobView => job; + +export interface MergeJobTiming { checkWaitMs?: number; firstDelayMs?: number; maxDelayMs?: number } + +/** Why the wait for required checks ended. */ +type CheckVerdict = { kind: 'ready' } | { kind: 'wait'; checks: { context: string; state: string }[] } + | { kind: 'blocked'; reason: string; next: string } | { kind: 'failed'; reason: string } | { kind: 'cancelled' }; + +/** + * The one-click merge (F6b of #22): preparation, the wait for GitHub's required checks on the pushed head, then the + * guarded merge. One job per task at a time; its progress is saved in `merge_jobs`, never on the task, so the + * preparation's readiness record stays bound while the job waits. + */ +export class MergeJobCoordinator { + readonly service: ReviewService; + readonly preMerge: PreMergeCoordinator; + readonly merges: MergeCoordinator; + readonly checkWaitMs: number; readonly firstDelayMs: number; readonly maxDelayMs: number; + readonly settle: (fn: () => T) => T; + #active: Promise | null = null; + #abort: AbortController | null = null; + #closing = false; + + constructor(service: ReviewService, preMerge: PreMergeCoordinator, merges: MergeCoordinator, capability?: ShutdownCapability, + timing: MergeJobTiming = {}) { + this.service = service; this.preMerge = preMerge; this.merges = merges; + this.checkWaitMs = timing.checkWaitMs ?? REQUIRED_CHECK_WAIT_MS; + this.firstDelayMs = timing.firstDelayMs ?? REQUIRED_CHECK_FIRST_DELAY_MS; + this.maxDelayMs = timing.maxDelayMs ?? REQUIRED_CHECK_MAX_DELAY_MS; + for (const value of [this.checkWaitMs, this.firstDelayMs, this.maxDelayMs]) + if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid merge job timing.'); + this.settle = settleWith(capability); + } + + get active(): boolean { return this.#active !== null; } + /** The running job's settlement, for the server's after-work hooks; null when none runs. */ + get current(): Promise | null { return this.#active; } + /** Whether this click already has a saved job or refusal, which a resend replays without new admission reads. */ + saved(actionId: string): boolean { return this.service.store.getMergeJob(this.service.config.identity, actionId) !== null; } + latest(): MergeJobView | null { + const job = this.service.store.latestMergeJob(this.service.config.identity); + return job ? mergeJobView(job) : null; + } + + /** + * Admit the click and start its job after the caller's transaction commits. A resend of the same click returns its + * job without starting another. A definite refusal is saved under the click's action ID and replayed on resend. A + * refusal during shutdown saves nothing, so that click may be sent again, and neither does one for a click without a + * usable review token (missing, not a string, or too long): it is refused, as stale or because a job runs, and nothing + * is saved, since the store saves a refusal only under a usable token. A resend under an action ID already saved with + * another token is refused as a reused ID. + */ + async start(token: unknown, actionId: string, busy: () => string | null = () => null, signal?: AbortSignal): Promise { + const { store, config } = this.service, identity = config.identity; + const replay = () => { + const { job } = store.beginMergeJob(identity, { actionId, token: token as string }); + // A refused click replays its refusal; anything else is the job this click started. + if (job.refused) throw new GuardRefusal(job.reason ?? 'The merge was refused.'); + return mergeJobView(job); + }; + if (store.getMergeJob(identity, actionId)) return replay(); + // Shutdown decides nothing about this click: nothing is saved, and the route answers 503 so the same click is resent. + if (this.#closing) throw new ShuttingDownError(); + // The review load awaits a history worker (#170), so it comes first: everything after it, from the token check to the + // job's admission, runs in one turn. A load stopped by shutdown decides nothing, as above. + let reviewToken: string; + try { reviewToken = (await this.service.load({ signal })).token; } + catch (error) { + // A resend of this click may have been admitted while this load was pending: its job is this click's answer. + if (store.getMergeJob(identity, actionId)) return replay(); + if (this.#closing || signal?.aborted) throw new ShuttingDownError(); + throw error; + } + // A resend of this click, or shutdown, may have arrived while the review loaded. + if (store.getMergeJob(identity, actionId)) return replay(); + if (this.#closing) throw new ShuttingDownError(); + const refusal = this.#admissionRefusal(token, reviewToken, busy); + if (refusal !== null) { + // A definite refusal is this click's outcome; a resend replays it (as the guarded merge does). + if (typeof token === 'string' && token && token.length <= MAX_MERGE_JOB_TOKEN_LENGTH) store.recordMergeJobRefusal(identity, { actionId, token, reason: refusal }); + throw new GuardRefusal(refusal); + } + let job: MergeJob; + try { ({ job } = store.beginMergeJob(identity, { actionId, token: token as string })); } + catch (error) { + // The store's own refusal (a job still running on disk after its ending write failed) is definite too: saved and + // replayed like the refusals above. Its closed-task refusal is defensive here: admission refused that task above, + // in this same turn. + if (error instanceof GuardRefusal && !(error instanceof ActionIdReused)) + store.recordMergeJobRefusal(identity, { actionId, token: token as string, reason: error.message }); + throw error; + } + const abort = new AbortController(); this.#abort = abort; + const run = Promise.resolve().then(() => this.#run(job, abort.signal)).catch(error => { + // Anything unexpected ends the job, unless shutdown stopped it (startup then settles it) or the task was cancelled + // (a cancel during one of the job's own review loads, which await a history worker since #170). Never rejects: if + // even reading the task fails here, the job stays running for startup, as when ending it fails. + try { + // A cancel is checked before shutdown: a task cancelled before shutdown began ends the job `cancelled` even when + // the stopped work rejects only after shutdown began (the store's write gate closes after the job is awaited). + if ((abort.signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) return this.#end(job, abort.signal); + if (abort.signal.aborted && this.#closing) return; + this.#finish(job.id, 'failed', error instanceof Error ? error.message : String(error)); + } catch (unended) { this.#unended(job.id, unended); } + }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; } }); + this.#active = run; + return mergeJobView(job); + } + + /** Why this click may not start a job now, or null. A preparation stopping for shutdown throws instead (not definite). */ + #admissionRefusal(token: unknown, reviewToken: string, busy: () => string | null): string | null { + const { store, config } = this.service, identity = config.identity; + if (this.#active) return 'A merge is already running for this task.'; + if (typeof token !== 'string' || reviewToken !== token) return 'Stale review state. Refresh before merging.'; + const task = store.getTask(identity); + if (!MERGEABLE_STATUSES.includes(task.status)) return `The task is ${task.status}; merge it from review.`; + if (task.cancelRequested !== null) return 'The task is being cancelled.'; + const reason = busy(); + if (reason) return reason; + try { this.preMerge.assertStartable(); } + catch (error) { + if (error instanceof GuardRefusal && /shutting down/i.test(error.message)) throw new ShuttingDownError(); + if (!(error instanceof GuardRefusal)) throw error; + return error.message; + } + return null; + } + + /** The task was cancelled: stop waiting at once. Preparation and the guarded merge stop through their own routes. */ + cancel(): void { this.#abort?.abort(Object.assign(new Error('Task cancelled.'), { code: 'CANCELLED' })); } + + /** Shutdown's first, synchronous step: no job starts, and a running one stops before it can ask GitHub to merge. */ + stop(): void { + this.#closing = true; + this.#abort?.abort(new Error('Server shutdown.')); + } + async close(): Promise { + this.stop(); + await this.#active; + } + + #finish(id: string, state: Exclude, reason: string | null = null, url: string | null = null): void { + try { this.settle(() => this.service.store.finishMergeJob(this.service.config.identity, id, { state, reason, url })); } + catch (error) { this.#unended(id, error); } + } + + /** Startup settles a job that could not be ended now; until then it shows as running and a new click is refused. */ + #unended(id: string, error: unknown): void { + console.error(`Could not end merge job ${id}; it shows as running until the server restarts: ${JSON.stringify((error as Error)?.message ?? String(error))}`); + } + + async #run(job: MergeJob, signal: AbortSignal): Promise { + const { store, config } = this.service, identity = config.identity; + // 1. Prepare: refresh, rebase, review check, push, cmd: checks. + let view = await this.service.load({ signal }); + // The load awaited a history worker (#170). A cancel or shutdown during it ends here; otherwise the preparation is + // bound to the task and review as they are now, all read in this turn (the preparation refuses if they move). + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + // The click was admitted for the review its token names. A review change that landed before this load finished (an + // approval, an accepted segment, or a plan import from another tab) is one the person never saw, so the job prepares + // nothing, as the direct merge refuses a review that changed during its validation. Readiness binds the review + // version from here on, so a later change is refused by the preparation, an inspection or the guarded merge. + if (view.token !== job.token) return this.#finish(job.id, 'failed', REVIEW_CHANGED_AFTER_CLICK); + const prepareId = randomUUID(), task = store.getTask(identity), snapshot = store.getSnapshot(identity); + const reviewVersion = store.reviewVersion(identity); + // Refused here, before the placeholder below is saved, if a preparation cannot start now (the preparation's own check + // repeats it in this turn). Defensive: admission checked it, the server refuses a standalone preparation while a job + // runs, and shutdown aborts the job (checked above) in the turn before it stops preparation. + this.preMerge.assertStartable(); + store.userAction(identity, { actionId: prepareId, kind: 'prepare-merge', + request: { expectedStateVersion: task.stateVersion, expectedReviewVersion: reviewVersion, mergeJob: job.id } }, + () => ({ outcome: 'preparing' })); + const prepared = await this.preMerge.start({ stateVersion: task.stateVersion, reviewVersion, + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, actionId: prepareId }); + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + if (prepared.state !== 'ready') + return this.#finish(job.id, prepared.state === 'review-required' ? 'review-required' : 'failed', prepared.reason); + // 2. Wait for GitHub's required checks on the pushed head. + this.settle(() => store.updateMergeJob(identity, job.id, { step: 'checks' })); + const verdict = await this.#awaitChecks(job, signal); + if (verdict.kind === 'cancelled') return this.#end(job, signal); + // A failing or slow check blocks only while the readiness it was read for still holds (in the block's transaction). + if (verdict.kind === 'blocked') return this.#block(job, verdict.reason, verdict.next, true); + if (verdict.kind === 'failed') return this.#finish(job.id, 'failed', verdict.reason); + // 3. Merge through the guarded merge, which re-validates everything and reruns the already-fixed check. + // The merge's action ID is saved first: after a restart, the attempt carrying it gives the job's outcome. + const mergeId = randomUUID(); + this.settle(() => store.updateMergeJob(identity, job.id, { step: 'merging', checks: [], mergeActionId: mergeId })); + view = await this.service.load({ signal }); + // The current token, not the click's: a rebase during preparation makes a new snapshot, and so a new token. The + // readiness the guarded merge requires binds the review version preparation read, so a review change since is refused. + try { + // The job's signal stops the guarded merge as shutdown or a deadline stops a click: before GitHub is asked, nothing + // is applied; after, the outcome is unknown. + const merged = await this.merges.merge(view.token, mergeId, signal); + // A fulfilled merge does not prove its outcome: when saving it fails, the attempt stays submitting and reconciles + // from GitHub. This job's own attempt decides, as when the merge rejects. A queue poll that ended the attempt + // (removed or failed) while GitHub was asked makes the guarded merge reject instead; one ending it after the save is + // defensive here: the save and this read run in one turn, and a poll's result needs I/O. + const attempt = store.getMergeAttempt(identity); + if (attempt?.actionId !== mergeId || attempt.state === 'submitting') + return this.#finish(job.id, 'submitted', 'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'); + return this.#endAsAttempt(job, attempt, 'no reason given.', merged.result.url || attempt.url || null); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + // Once this job's merge attempt exists, GitHub was asked: its recorded state is the answer, whatever else (a cancel, + // shutdown) happened meanwhile. A refusal blocks (design: "GitHub refused the merge"); anything else is unknown. + const attempt = store.getMergeAttempt(identity); + if (attempt?.actionId === mergeId) return this.#endAsAttempt(job, attempt, reason, attempt.url); + if (error instanceof MergeOutcomeUnknown) return this.#finish(job.id, 'submitted', reason); + // A cancel or shutdown, whichever way the merge reported it (stopped, or refused because the task was cancelled). + if (signal.aborted || this.#cancelled()) return this.#end(job, signal); + // The guarded merge's own refusal asks the person to rebase or prepare; the job's reason says what merging does. + this.#finish(job.id, 'failed', error instanceof MergeBlocked && error.blocker ? `${error.prefix}${inJobTerms(error.blocker).message}` : reason); + } + } + + /** + * End the job as its own merge attempt records GitHub's answer, after the guarded merge fulfilled or rejected: one + * mapping for both, and for every state the attempt can reach. `reason` stands in when a refusal saved none. + */ + #endAsAttempt(job: MergeJob, attempt: MergeAttempt, reason: string, url: string | null): void { + if (attempt.state === 'merged' || attempt.state === 'queued') return this.#finish(job.id, attempt.state, null, url); + // Reached through the merge's rejection. After a fulfilled merge this is defensive (see #run): a removal or failure + // recorded while GitHub was asked makes the guarded merge reject, and nothing can end the attempt after its save + // before the job reads it. + if (attempt.state === 'failed' || attempt.state === 'removed') { + // A refusal because the head changed needs a fresh review, not a retry: the task is not "approved" any more. + if (attempt.requiresFreshReview) + return this.#finish(job.id, 'failed', `GitHub refused the merge: ${attempt.reason ?? reason} Review the new head first.`); + return this.#block(job, `GitHub refused the merge: ${attempt.reason ?? reason}`, FIX_THEN_RETRY); + } + this.#finish(job.id, 'submitted', reason); + } + + #cancelled(): boolean { + const task = this.service.store.getTask(this.service.config.identity); + return task.status === 'cancelled' || task.cancelRequested !== null; + } + /** A cancelled task ends the job, however the job learned of it; shutdown leaves it running on disk for startup. */ + #end(job: MergeJob, signal: AbortSignal): void { + if ((signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) + this.#finish(job.id, 'cancelled', 'Task cancelled.'); + } + + /** + * Approved, merge blocked: the task's next merge prepares again from the start. One Store transaction. `next` is the + * person's next step, saved after the reason only when the job ends blocked. + */ + #block(job: MergeJob, reason: string, next: string, whileReady = false): void { + const readyFor = whileReady ? { commandPolicyDigest: this.service.commandPolicyDigest() } : undefined; + try { this.settle(() => this.service.store.blockMergeJob(this.service.config.identity, job.id, reason, readyFor, next)); } + catch (error) { this.#unended(job.id, error); } + } + + async #awaitChecks(job: MergeJob, signal: AbortSignal): Promise { + const { store, config } = this.service, identity = config.identity; + const until = performance.now() + this.checkWaitMs; + let delay = this.firstDelayMs, waiting: { context: string; state: string }[] = [], waited = false; + // Nothing failed: the person waits for the checks (or GitHub) rather than fixing anything. Before any inspection + // finished within the limit, nothing was seen pending or computing. + const timedOut = (): CheckVerdict => ({ kind: 'blocked', reason: `Required checks did not pass within ${Math.round(this.checkWaitMs / 60_000)} minutes: ${ + waiting.length ? waiting.map(check => `${check.context} is ${check.state}`).join(', ') + : waited ? 'GitHub has not determined mergeability' : 'no inspection of them finished in time'}.`, + next: waiting.length || !waited ? 'Wait for the checks to pass, then Retry merge.' : 'Wait for GitHub to determine mergeability, then Retry merge.' }); + for (;;) { + if (signal.aborted) return { kind: 'cancelled' }; + const task = store.getTask(identity); + if (task.status === 'cancelled' || task.cancelRequested !== null) return { kind: 'cancelled' }; + // No inspection starts after the wait's limit, and each one is stopped when the limit arrives. + const left = Math.floor(until - performance.now()); + if (left < 1) return timedOut(); + const limit = AbortSignal.timeout(left); + let status: MergeStatus; + try { status = await this.merges.status(undefined, true, AbortSignal.any([signal, limit])); } + catch (error) { + if (signal.aborted) return { kind: 'cancelled' }; + // Only the limit's own abort is the wait's timeout: every layer below rejects with that reason, unchanged. A + // failure that came first (a GitHub stage failing while its stopped peers settle past the limit) keeps its error. + if (limit.aborted && error === limit.reason) return timedOut(); + return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; + } + const verdict = classify(status); + // Only checks seen passing within the limit count. Work at the end of an inspection can cross the limit before + // its timer runs, so a ready answer is checked against the clock too; a failing check or other blocker stands. + // The checks did pass, so the reason says so rather than naming the last wait, and the record drops its waiting list. + if (verdict.kind === 'ready' && performance.now() >= until) { + this.settle(() => store.updateMergeJob(identity, job.id, { checks: [] })); + return { kind: 'blocked', reason: `Required checks passed only after the ${Math.round(this.checkWaitMs / 60_000)}-minute limit.`, next: 'Retry merge.' }; + } + if (verdict.kind !== 'wait') return verdict; + waiting = verdict.checks; waited = true; + this.settle(() => store.updateMergeJob(identity, job.id, { checks: waiting })); + const remaining = until - performance.now(); + if (remaining <= 0) return timedOut(); + await sleep(Math.min(delay, remaining), signal).catch(() => undefined); + delay = Math.min(delay * 2, this.maxDelayMs); + } + } + +} + +/** Blockers the merge job resolves itself: it prepares (rebase, push, cmd: checks) and waits for required checks. */ +export const JOB_RESOLVES: ReadonlySet = new Set(['preparation', 'base', 'acceptance', 'check']); + +/** + * How a job-resolved blocker reads when merging runs as a job. The guarded merge's own wording asks the person to rebase + * or prepare, but the job's screen offers neither: merging does both (docs/implementation/merge-handoff.md). Used for + * the merge status the browser sees and for the reason a job ends `failed` with, so both say the same. + */ +const JOB_WORDING: Readonly> = Object.freeze({ + base: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.', + preparation: 'The merge has not been prepared for the current review; merging prepares it.', +}); +/** An unsettled push is a preparation blocker too, but its own: merging reads the push's outcome before it prepares. */ +const PUSH_UNSETTLED_JOB = 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.'; +export function inJobTerms(blocker: T): T { + const message = blocker.code === 'preparation' && blocker.message === PUSH_UNSETTLED ? PUSH_UNSETTLED_JOB : JOB_WORDING[blocker.code]; + return message === undefined ? blocker : { ...blocker, message }; +} + +/** + * Blockers settled by the time a job asks GitHub to merge: the job's resolved blockers, and mergeability, since the + * guarded merge starts only after `classify` found no blocker at all. A job that ended `merged`, `queued` or `submitted` + * got that far; the browser drops these from the review it read before or during that job. Any other outcome settled + * none of them for certain. + */ +export const SETTLED_BEFORE_JOB_MERGE: readonly string[] = Object.freeze([...JOB_RESOLVES, 'mergeable']); + +/** + * The merge status the browser sees when merging runs as a job: ready when only blockers the job resolves remain (or + * GitHub is still computing mergeability), and never while a job or merge attempt is in flight. After a blocked job the + * action is a retry. The latest job rides along, with the blockers a job that reached the merge has settled. + */ +export function withMergeJob(status: T, job: MergeJobView | null, taskStatus: string): T & { job: MergeJobView | null; settledBeforeJobMerge: readonly string[] } { + const running = job?.state === 'running'; + const attempt = status.queue?.state === 'submitting' || status.queue?.state === 'queued' || status.queue?.state === 'merged'; + const computing = status.remote?.mergeable === 'UNKNOWN'; + // A conflict after the base moved is the job's too: its preparation rebases, through the sandboxed conflict path. + const rebaseResolves = status.remote?.mergeable === 'CONFLICTING' && status.blockers.some(blocker => blocker.code === 'base'); + const personal = status.blockers.filter(blocker => !JOB_RESOLVES.has(blocker.code) && !(blocker.code === 'mergeable' && (computing || rebaseResolves))); + // The task's status is read after the inspection that produced `status`: a task cancelled or merged meanwhile is never + // offered Merge PR, even if that inspection had no task blocker. + const ready = !running && !attempt && status.remote !== null && personal.length === 0 + && MERGEABLE_STATUSES.includes(taskStatus as (typeof MERGEABLE_STATUSES)[number]); + return { ...status, blockers: status.blockers.map(inJobTerms), ready, + action: ready ? (taskStatus === 'approved but merge blocked' || status.action === 'retry' ? 'retry' : 'merge') : null, job, + settledBeforeJobMerge: SETTLED_BEFORE_JOB_MERGE }; +} + +/** + * One fresh inspection's answer for the wait: a failing required check blocks; pending or missing ones, and GitHub + * still computing mergeability, wait; any other blocker (the PR moved, readiness lost, the already-fixed check, rules) + * ends the job for a person to act. + */ +export function classify(status: MergeStatus): CheckVerdict { + // Another blocker comes first: checks failing on a head nobody reviewed (a collaborator pushed) say nothing about this + // review, so the job ends for a person to look, rather than blocking on those checks. + const computing = status.remote.mergeable === 'UNKNOWN'; + const other = status.blockers.filter(blocker => blocker.code !== 'check' && !(blocker.code === 'mergeable' && computing)); + if (other.length) return { kind: 'failed', reason: inJobTerms(other[0]!).message }; + const failing = status.remote.requiredChecks.filter(check => check.state === 'failure'); + if (failing.length) return { kind: 'blocked', reason: `Required check${failing.length === 1 ? '' : 's'} failed: ${failing.map(check => check.context).join(', ')}.`, + next: failing.length === 1 ? FIX_THEN_RETRY : 'Fix them, then Retry merge.' }; + const waiting = status.remote.requiredChecks.filter(check => check.state !== 'success') + .map(check => ({ context: check.context, state: check.state })); + if (waiting.length || computing) return { kind: 'wait', checks: waiting }; + return status.ready ? { kind: 'ready' } : { kind: 'failed', reason: status.blockers[0] ? inJobTerms(status.blockers[0]).message : 'Merge is blocked.' }; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(signal.reason); return; } + const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, ms); + const stop = () => { clearTimeout(timer); reject(signal.reason); }; + signal.addEventListener('abort', stop, { once: true }); + }); +} diff --git a/runner/merge.ts b/runner/merge.ts index b11fc8cf..d6b29995 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -1,5 +1,5 @@ import type { ReviewService, ReviewView } from './review.ts'; -import { mergeActionResponse, type MergeAttempt } from './store.ts'; +import { mergeActionResponse, type MergeAttempt, type TaskPullRequest } from './store.ts'; import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, assertUuidV4, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MERGE_INSPECTION_TIMEOUT_MS, MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type MergeQueueObservation, type MergeResult, type MergeTarget, type RemoteMergeState } from '../github/merge.ts'; import { openingMarker } from '../github/pull-requests.ts'; @@ -20,6 +20,20 @@ export class MergeNotApplied extends Error {} class CommittedFailure extends Error {} /** The click was admitted but GitHub's outcome is unknown; its attempt stays in flight, so the key must be kept. */ export class MergeOutcomeUnknown extends Error {} +/** + * Validation refused because a blocker remains. Its message is the guarded merge's own wording; the blocker rides along + * so a merge job can word its reason as what merging does (runner/merge-job.ts `inJobTerms`). + */ +export class MergeBlocked extends Error { + readonly prefix: string; + readonly blocker: MergeBlocker | undefined; + constructor(prefix: string, blocker: MergeBlocker | undefined) { + super(`${prefix}${blocker?.message ?? 'Merge is blocked.'}`); + this.prefix = prefix; this.blocker = blocker; + } +} +/** The preparation blocker's message while a push of the rewritten head has not settled. */ +export const PUSH_UNSETTLED = 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.'; export interface MergeQueueStatus { kind: MergeAttempt['kind']; state: MergeAttempt['state']; reviewedHead: string; url: string | null; reason: string | null; phase: MergeAttempt['phase']; position: number | null; occurredAt: string | null; retryable: boolean; @@ -119,8 +133,32 @@ export class MergeCoordinator { if (!latest || latest.number === null) throw new MergeTargetUnavailable(`The task has no published pull request into ${published.baseBranch}.`); if (published.configured !== undefined && published.configured !== latest.number) throw new MergeTargetUnavailable(`github.pullRequest is #${published.configured}, but the task's pull request is #${latest.number}. Remove github.pullRequest from the review configuration.`); - return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch }, openingId: latest.openingId, - marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; + return { target: { pullRequest: latest.number, ownPullRequests, headBranch: latest.headBranch, ...this.#alreadyFixedRange(here, published.baseBranch) }, + openingId: latest.openingId, marker: openingMarker(latest.openingId), headBranch: latest.headBranch }; + } + + /** + * The already-fixed scan of a runner-published PR (F6b of #22). It must cover every base commit after the task's + * original base (its first snapshot's), since a rebase can bring any of them under the PR. The pre-PR check of the + * task's first opening into the current base branch scanned that branch from its snapshot's base to its tip and found + * nothing; when that base is the task's original base, the scan starts at that tip: commits a later rebase onto the + * branch brings in up to that tip were scanned by that check, and later ones are in this scan, whichever of the + * task's openings the merge follows. An opening into another base branch says nothing about this one. A first + * opening recorded before schema v19 has no check, and no record shows which base branch an older check scanned (a + * publish may record a check and stop before opening), so no other check stands in. Then, and when the check scanned + * from a rebased base, the scan starts at the task's original base. The task's own commits are excluded. + */ + #alreadyFixedRange(here: TaskPullRequest[], base: string): { alreadyFixedSince: string; ownCommits: string[] } { + const { store, config } = this.service, identity = config.identity; + const original = store.firstSnapshot(identity).base; + const first = here.find(pr => pr.base === base); + const check = first?.checkId ? store.getAlreadyFixed(identity, first.checkId) : null; + const ownCommits = store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha); + // The outcome term is defensive: beginPullRequest saves a check ID only for a clear check (#assertCheckedHead). + if (check?.result.outcome !== 'clear') return { alreadyFixedSince: original, ownCommits }; + let scannedFrom: string | null = null; + try { scannedFrom = store.getSnapshot(identity, check.snapshotId).base; } catch { /* unknown: scan from the original base */ } + return { alreadyFixedSince: scannedFrom === original ? check.result.baseHead : original, ownCommits }; } /** Whether GitHub shows the task's published PR where it was opened, as the only open PR from its branch. */ @@ -209,7 +247,7 @@ export class MergeCoordinator { const task = this.service.store.getTask(this.service.config.identity); if (task.status !== 'merged' && !MERGEABLE_STATUSES.includes(task.status)) blockers.push({ code: 'task', message: `The task is ${task.status}; merge it from review.` }); if (task.pushInProgress) blockers.push({ code: 'preparation', - message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }); + message: PUSH_UNSETTLED }); else if (this.published?.requiresPreparation && view.expected.reviewVersion !== undefined && !this.service.store.preMergeReady(this.service.config.identity, { stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion, @@ -283,7 +321,18 @@ export class MergeCoordinator { ? attempt.reason ? `The merge submission outcome is unknown. ${attempt.reason} Waiting for GitHub reconciliation.` : attempt.kind === 'queue' ? 'The reviewed head is being submitted to the merge queue.' : 'The reviewed head is being submitted for direct merge.' : 'The reviewed head is queued. Waiting for GitHub to confirm the outcome.' }; if (attempt?.state === 'merged') return { code: 'queue-merged', message: 'GitHub confirmed that the reviewed head was merged.' }; - if (attempt && !this.#freshReviewComplete(attempt)) return { code: 'queue-head', message: attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.' }; + if (attempt && !this.#freshReviewComplete(attempt)) { + // A new snapshot or plan revision after an attempt (a rebase, for example) needs every item approved again for it; + // the attempt's own reason (often GitHub's refusal) would not say so. + const { store, config } = this.service; + const after = attempt.reason ? ` (${attempt.reason})` : ''; + const message = store.getSnapshot(config.identity).id !== attempt.snapshotId + ? `The pull request changed after the last merge attempt${after}. Approve every item again for the replacement snapshot, then merge.` + : store.getPlan(config.identity).revision !== attempt.revision + ? `The plan changed after the last merge attempt${after}. Approve every item again for the new plan revision, then merge.` + : attempt.reason ?? 'The pull request snapshot changed after the queue attempt. Review the replacement snapshot.'; + return { code: 'queue-head', message }; + } return null; } @@ -306,7 +355,7 @@ export class MergeCoordinator { * `actionId` is the click's idempotency key. A resend with the same key (after a lost response) returns the saved * attempt's current outcome and never submits again. */ - async merge(token: unknown, actionId?: unknown): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { + async merge(token: unknown, actionId?: unknown, caller?: AbortSignal): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { if (actionId !== undefined) assertUuidV4(actionId, 'Action ID'); // A resend of the click still in progress joins it, so it gets the final result rather than a saved interim state. if (this.#active && typeof actionId === 'string' && this.#activeClick?.actionId === actionId) { @@ -326,9 +375,13 @@ export class MergeCoordinator { if (typeof token !== 'string') throw new Error('Stale review state. Refresh before merging.'); const abort = new AbortController(); const timer = setTimeout(() => abort.abort(new Error('Merge request deadline exceeded.')), this.operationTimeoutMs); + // A caller that owns the merge (the one-click merge job) stops it as shutdown or a deadline would. + const stop = () => abort.abort(caller!.reason); + if (caller?.aborted) stop(); else caller?.addEventListener('abort', stop, { once: true }); this.#abort = abort; const attempt = this.#merge(token, abort.signal, actionId as string | undefined).finally(() => { clearTimeout(timer); + caller?.removeEventListener('abort', stop); if (this.#active === attempt) { this.#active = null; this.#activeClick = null; } if (this.#abort === abort) this.#abort = null; }); @@ -428,7 +481,7 @@ export class MergeCoordinator { // final PR inspection; a synchronous local trust check then closes the admission window without another await. const authorization = this.#authorize ? await this.#authorize(signal) : null; const { status, resolved } = await this.#statusForMerge(view, signal); - if (!status.ready) throw new Error(status.blockers[0]?.message ?? 'Merge is blocked.'); + if (!status.ready) throw new MergeBlocked('', status.blockers[0]); view = await this.service.load({ signal }); if (view.token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); const { status: finalStatus, resolved: finalTarget } = await this.#statusForMerge(view, signal); @@ -438,7 +491,10 @@ export class MergeCoordinator { if (finalStatus.remote.base !== status.remote.base || finalStatus.remote.head !== status.remote.head || !samePullRequest({ status: finalStatus, resolved: finalTarget })) throw new Error('The pull request changed during merge validation. Refresh before merging.'); if (finalStatus.remote.mergeQueue !== status.remote.mergeQueue) throw new Error('Merge-queue requirements changed during validation. Refresh before merging.'); - if (!finalStatus.ready) throw new Error(`Merge requirements changed during validation. ${finalStatus.blockers[0]!.message}`); + // From here on, a MergeBlocked's job wording (runner/merge-job.ts inJobTerms) is defensive in job mode: nothing changes + // the state version during a job, the reloads catch a changed token first, and a moved base trips the PR identity + // check first. Defensive tests in test/merge-job.test.ts pin it. + if (!finalStatus.ready) throw new MergeBlocked('Merge requirements changed during validation. ', finalStatus.blockers[0]); let commandStatus = finalStatus; let queueWatermark: string | null = null; if (status.remote.mergeQueue) { @@ -449,7 +505,7 @@ export class MergeCoordinator { commandStatus = command.status; if (commandStatus.remote.base !== finalStatus.remote.base || commandStatus.remote.head !== finalStatus.remote.head || commandStatus.remote.mergeQueue !== finalStatus.remote.mergeQueue || !samePullRequest(command)) throw new Error('Merge-queue requirements changed after queue correlation. Refresh before merging.'); - if (!commandStatus.ready) throw new Error(`Merge requirements changed after queue correlation. ${commandStatus.blockers[0]!.message}`); + if (!commandStatus.ready) throw new MergeBlocked('Merge requirements changed after queue correlation. ', commandStatus.blockers[0]); } if (authorization) { await authorization.refresh(); @@ -458,7 +514,7 @@ export class MergeCoordinator { || authorized.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(authorized)) throw new Error('The pull request changed during merge validation. Refresh before merging.'); if (!authorized.status.ready) - throw new Error(`Merge requirements changed during authorization validation. ${authorized.status.blockers[0]!.message}`); + throw new MergeBlocked('Merge requirements changed during authorization validation. ', authorized.status.blockers[0]); commandStatus = authorized.status; } if (commandStatus.remote.mergeQueue) { @@ -472,7 +528,7 @@ export class MergeCoordinator { || boundary.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(boundary)) throw new Error('The pull request changed after queue correlation. Refresh before merging.'); if (!boundary.status.ready) - throw new Error(`Merge requirements changed after queue correlation. ${boundary.status.blockers[0]!.message}`); + throw new MergeBlocked('Merge requirements changed after queue correlation. ', boundary.status.blockers[0]); commandStatus = boundary.status; } // The final review load awaits its history worker, so the pull request is inspected once more after it: the last @@ -484,7 +540,7 @@ export class MergeCoordinator { if (last.status.remote.base !== commandStatus.remote.base || last.status.remote.head !== commandStatus.remote.head || last.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(last)) throw new Error('The pull request changed during merge validation. Refresh before merging.'); - if (!last.status.ready) throw new Error(`Merge requirements changed during validation. ${last.status.blockers[0]!.message}`); + if (!last.status.ready) throw new MergeBlocked('Merge requirements changed during validation. ', last.status.blockers[0]); commandStatus = last.status; authorization?.validate(); if (this.service.store && this.service.config) { diff --git a/runner/production.ts b/runner/production.ts index 2d307e60..cf015259 100644 --- a/runner/production.ts +++ b/runner/production.ts @@ -264,6 +264,7 @@ export async function setUpRunner(o: { service: ReviewService; capability: Shutd deps: o.recovery ? o.recovery(image) : dRecoveryDeps(image, { abort: async (attemptId, resultHead, state) => (await getRebaser()).abort(attemptId, resultHead, state) }, rebasePlanKey) }); service.store.settleInterruptedPreMergeActions(identity); + service.store.settleInterruptedMergeJobs(identity); const repository = await getRepository(); const imageId = image(); const workspace = createTaskWorkspace({ store: service.store, runnerRoot: config.root, runnerOwner, repository, imageId, diff --git a/runner/store.ts b/runner/store.ts index 632801ef..d39f8e94 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -40,6 +40,8 @@ export interface TaskPullRequest { ownerReviewVersion: number; /** An update of this open PR that started and has not been confirmed; the PR may already show it. */ refresh: { head: string; draft: boolean; stateVersion: number } | null; + /** The clear already-fixed check this opening followed; null for an opening recorded before schema v19. */ + checkId: string | null; } export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; reviewVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -86,6 +88,10 @@ export interface PreMergeReadiness { stateVersion: number; reviewVersion: number; snapshotId: string; base: string; head: string; commandPolicyDigest: string; } export const MAX_REWRITE_LINEAGE_ROWS = 1_000; +/** The next step after a failing required check or GitHub's refusal blocks a merge job (docs/implementation/merge-handoff.md). */ +export const FIX_THEN_RETRY = 'Fix it, then Retry merge.'; +/** The longest review token a merge job or its saved refusal is saved under. */ +export const MAX_MERGE_JOB_TOKEN_LENGTH = 512; /** What a completed `prepare-merge` action replays after its background coordinator settles. */ export function preMergeActionResponse(result: PreMergeActionResult) { return { outcome: result.state, base: result.base, head: result.head, checked: result.checked, reason: result.reason }; @@ -129,6 +135,37 @@ export interface PushMarker { } /** What a read of the branch showed after a push: `to`, still `from`, or anything else. */ export type PushOutcome = 'pushed' | 'not-pushed' | 'moved'; +/** A one-click merge job (F6b of #22; docs/implementation/merge-handoff.md). */ +export type MergeJobState = 'running' | 'merged' | 'queued' | 'submitted' | 'blocked' | 'review-required' | 'failed' | 'cancelled'; +export type MergeJobStep = 'preparing' | 'checks' | 'merging'; +export interface MergeJob { + /** The click's action ID. */ + id: string; + /** The action ID of the guarded merge this job started, once it reaches that step: its attempt carries it. */ + mergeActionId: string | null; + /** The click was refused at admission; a resend replays the refusal (`reason`). */ + refused?: boolean; + /** The review token the click was made with; a resend must carry the same one. */ + token: string; + state: MergeJobState; + step: MergeJobStep | null; + reason: string | null; + /** Required checks that have not succeeded yet, as the last inspection read them. */ + checks: { context: string; state: string }[]; + url: string | null; + /** + * The job's place among the task's jobs and refused clicks, starting at 1: a later click has a higher number. The + * browser orders records of different jobs by it, since wall-clock times can step back. + */ + sequence: number; + /** + * The record's version, from 1: every saved change to the job adds one. The browser orders records of the same job by + * it, since two saves can share an update time or the clock can step back between them. + */ + revision: number; + startedAt: string; + updatedAt: string; +} export interface AttemptRecord { id: string; kind: AttemptKind; phase: string; item: string | null; state: AttemptState; context: InvocationContext; deadline: number; firstReason: FirstReason | null; stopReason: StopReason | null; exitCode: number | null; signal: string | null; result: unknown; @@ -206,8 +243,8 @@ export class Store { this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;'); this.#transaction(() => { const version = this.#get('PRAGMA user_version')!.user_version as number; - if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].includes(version)) throw new Error('Unsupported store schema version.'); - if (version === 18) return; + if (![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19].includes(version)) throw new Error('Unsupported store schema version.'); + if (version === 19) return; if (version === 0) this.#db.exec(` CREATE TABLE plans (key TEXT PRIMARY KEY, issue INTEGER NOT NULL, revision INTEGER NOT NULL, snapshot_id TEXT); CREATE TABLE revisions (key TEXT NOT NULL REFERENCES plans(key), revision INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,revision)); @@ -329,6 +366,17 @@ export class Store { this.#db.exec('ALTER TABLE tasks ADD COLUMN push_in_progress TEXT'); this.#db.exec('PRAGMA user_version=18'); } + // One-click merge jobs (F6b of #22). Kept apart from tasks, so a job's progress never changes the task state version. + if (version < 19) { + this.#db.exec(`CREATE TABLE IF NOT EXISTS merge_jobs ( + id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES plans(key), data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS merge_jobs_plan ON merge_jobs(plan_key);`); + // The pre-PR already-fixed check each opening followed: the merge's scan may start at the tip that check scanned + // through (docs/implementation/merge-handoff.md). + if (!this.#db.prepare('PRAGMA table_info(task_pull_requests)').all().some(column => column.name === 'check_id')) + this.#db.exec('ALTER TABLE task_pull_requests ADD COLUMN check_id TEXT'); + this.#db.exec('PRAGMA user_version=19'); + } }); } catch (error) { this.#db.close(); throw error; } } @@ -473,6 +521,12 @@ export class Store { this.#run("UPDATE requests SET state='invalidated',reason='Repository snapshot changed.' WHERE key=? AND state IN ('pending','ready')", key); return snapshot; } + /** The task's first snapshot: its base is where the task began, before any rebase. */ + firstSnapshot(identity: PlanIdentity): Snapshot { + const row = this.#get('SELECT data FROM snapshots WHERE key=? ORDER BY rowid ASC LIMIT 1', identityKey(identity)); + if (!row) throw new Error('Unknown snapshot.'); + return decode(row.data); + } getSnapshot(identity: PlanIdentity, id?: string): Snapshot { const key = identityKey(identity); const row = this.#get('SELECT data FROM snapshots WHERE key=? AND id=?', key, id ?? this.#current(key).snapshot_id!); @@ -2080,6 +2134,180 @@ export class Store { const saved = decode<{ ok?: unknown; value?: { outcome?: unknown }; preMergeReadiness?: PreMergeReadiness }>(row.response); return saved.ok === true && saved.value?.outcome === 'ready' && stable(saved.preMergeReadiness) === stable(readiness); } + /** + * Admit a merge job for this click. A resend of the same click returns its job; another click while a job runs, or a + * reused action ID with another token, is refused. Never touches the task. + */ + beginMergeJob(identity: PlanIdentity, input: { actionId: string; token: string; now?: string }): { job: MergeJob; resent: boolean } { + assertUuidV4(input.actionId, 'Action ID'); + const key = identityKey(identity); + return this.#transaction(() => { + const task = this.#task(key); + // A saved click is looked up first, so a resend under its ID with any other token (unusable ones too) is refused + // as a reused ID, as a different usable one is. + const existing = this.#get('SELECT data FROM merge_jobs WHERE id=?', input.actionId); + if (existing) { + const job = decode(existing.data); + if (job.token !== input.token || this.#get('SELECT plan_key FROM merge_jobs WHERE id=?', input.actionId)!.plan_key !== key) + throw new ActionIdReused('Action ID already used for a different request.'); + return { job, resent: true }; + } + if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); + if (this.#closed(task.status as TaskStatus)) throw new GuardRefusal(`The task is ${task.status}; it cannot be merged.`); + if (this.runningMergeJob(identity)) throw new GuardRefusal('A merge is already running for this task.'); + const now = input.now ?? new Date().toISOString(); + const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'running', step: 'preparing', reason: null, checks: [], + url: null, sequence: this.#nextMergeJobSequence(key), revision: 1, startedAt: now, updatedAt: now }; + this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); + return { job, resent: false }; + }); + } + /** + * Save a definite admission refusal under the click's action ID, so a resend replays it rather than starting a job. + * A resend of the same click returns the saved record; another request under that ID is refused as a reused ID. + */ + recordMergeJobRefusal(identity: PlanIdentity, input: { actionId: string; token: string; reason: string }): MergeJob { + assertUuidV4(input.actionId, 'Action ID'); + if (typeof input.token !== 'string' || !input.token || input.token.length > MAX_MERGE_JOB_TOKEN_LENGTH) throw new Error('A review token is required.'); + const key = identityKey(identity); + return this.#transaction(() => { + this.#task(key); + const existing = this.#get('SELECT plan_key, data FROM merge_jobs WHERE id=?', input.actionId); + if (existing) { + const job = decode(existing.data); + if (job.token !== input.token || existing.plan_key !== key) throw new ActionIdReused('Action ID already used for a different request.'); + return job; + } + const now = new Date().toISOString(); + const job: MergeJob = { id: input.actionId, mergeActionId: null, token: input.token, state: 'failed', step: null, + reason: input.reason.slice(0, 2_000), checks: [], url: null, sequence: this.#nextMergeJobSequence(key), revision: 1, startedAt: now, updatedAt: now, refused: true }; + this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); + return job; + }); + } + /** One more than the task's highest job sequence; rows in `merge_jobs` are never deleted, so it only grows. */ + #nextMergeJobSequence(key: string): number { + const row = this.#get(`SELECT COALESCE(MAX(json_extract(data,'$.sequence')),0) AS n FROM merge_jobs WHERE plan_key=?`, key) as { n: number }; + return row.n + 1; + } + /** Move a running job to another step or record what it waits for. A job no longer running is left unchanged. */ + updateMergeJob(identity: PlanIdentity, id: string, patch: { step?: MergeJobStep; reason?: string | null; checks?: { context: string; state: string }[]; + mergeActionId?: string }): MergeJob | null { + return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, ...patch } : null); + } + /** + * End a running job `blocked` and, in the same transaction, move a task still in review to approved, merge blocked, + * so the job and the task cannot disagree after a crash. A task that moved meanwhile keeps its status. + * With `readyFor`, the block applies only while the preparation's readiness still holds (checked in the same + * transaction): a check result that arrived after a review edit or another task change says nothing about the + * changed review, so the job then ends `failed` and the task keeps its status. `next`, the person's next step, follows + * the reason only when the job ends `blocked`: a `failed` end says why, and the page adds "Merge again when ready." + */ + blockMergeJob(identity: PlanIdentity, id: string, reason: string, readyFor?: { commandPolicyDigest: string }, next?: string): boolean { + const key = identityKey(identity); + return this.#transaction(() => { + const job = this.getMergeJob(identity, id); + if (!job) throw new Error('Unknown merge job.'); + // A job that already ended decides nothing about the task now. + if (job.state !== 'running') return false; + const task = this.#task(key); + if (readyFor) { + const snapshot = this.getSnapshot(identity); + if (!this.preMergeReady(identity, { stateVersion: task.state_version as number, reviewVersion: this.#current(key).review_version as number, + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: readyFor.commandPolicyDigest })) + return this.finishMergeJob(identity, id, { state: 'failed', + reason: 'The review or task changed while waiting for required checks; the preparation no longer applies.' }); + } + // GitHub refused the job's own merge attempt: the block applies only to the review that attempt was admitted for. + // A review edit, new snapshot or plan revision while GitHub was asked leaves the changed task as it is. A review edit + // (a review note, review_version only) may land while the attempt is in flight. The snapshot term is defensive: a + // merge job needs a runner, whose review load reads the recorded head, so no HEAD observation records a snapshot. A + // plan revision lands only once the attempt ended (#savePlan refuses while a merge is in flight), such as a queue + // poll ending it while GitHub was asked. + const attempt = job.mergeActionId ? this.getMergeAttempt(identity) : null; + if (attempt && attempt.actionId === job.mergeActionId) { + const plan = this.#current(key); + if (plan.revision !== attempt.revision || plan.snapshot_id !== attempt.snapshotId || plan.review_version !== attempt.reviewVersion) + return this.finishMergeJob(identity, id, { state: 'failed', + reason: `${reason} The review changed while GitHub was asked to merge; review it first.` }); + } + // The attempt and merge terms are defensive. A job blocks after its preparation settled (every check attempt it + // admitted ended), and while it runs nothing else admits an attempt or a merge: the server refuses start, resume, + // a standalone preparation and a second merge click; admission refuses item work in review; and a merge attempt is + // begun only by the job's own guarded merge. With `readyFor`, any later admission moved the state version, so the + // job ended `failed` above. Otherwise the job's own merge attempt ended (failed or removed) before the block, and + // startup recovery blocks only for such an attempt. + if (task.status === 'in review' && !this.#activeAttempt(key) && !this.#activeMerge(key) + && task.rebase_in_progress === null && (task.push_in_progress === null || task.push_in_progress === undefined)) { + this.#run('UPDATE tasks SET status=? WHERE plan_key=?', 'approved but merge blocked', key); + this.#touch(key); + } + return this.finishMergeJob(identity, id, { state: 'blocked', reason: next ? `${reason} ${next}` : reason }); + }); + } + /** End a running job. Returns false when it had already ended. */ + finishMergeJob(identity: PlanIdentity, id: string, outcome: { state: Exclude; reason?: string | null; url?: string | null }): boolean { + return this.#changeMergeJob(identity, id, job => job.state === 'running' + ? { ...job, state: outcome.state, step: null, reason: outcome.reason ?? null, url: outcome.url ?? job.url } : null) !== null; + } + #changeMergeJob(identity: PlanIdentity, id: string, change: (job: MergeJob) => MergeJob | null): MergeJob | null { + const key = identityKey(identity); + return this.#transaction(() => { + const row = this.#get('SELECT data FROM merge_jobs WHERE id=? AND plan_key=?', id, key); + if (!row) throw new Error('Unknown merge job.'); + const next = change(decode(row.data)); + if (!next) return null; + // Each saved change is strictly later than the one before, whatever the clock says. + const job = { ...next, revision: (Number.isSafeInteger(next.revision) ? next.revision : 1) + 1, updatedAt: new Date().toISOString() }; + this.#run('UPDATE merge_jobs SET data=? WHERE id=?', encode(job), id); + return job; + }); + } + getMergeJob(identity: PlanIdentity, id: string): MergeJob | null { + const row = this.#get('SELECT data FROM merge_jobs WHERE id=? AND plan_key=?', id, identityKey(identity)); + return row ? decode(row.data) : null; + } + /** The task's latest job. A refused click started none: its refusal is replayed to that click alone, never shown here. */ + latestMergeJob(identity: PlanIdentity): MergeJob | null { + const row = this.#get(`SELECT data FROM merge_jobs WHERE plan_key=? AND json_extract(data,'$.refused') IS NOT 1 + ORDER BY rowid DESC LIMIT 1`, identityKey(identity)); + return row ? decode(row.data) : null; + } + runningMergeJob(identity: PlanIdentity): MergeJob | null { + const row = this.#get(`SELECT data FROM merge_jobs WHERE plan_key=? AND json_extract(data,'$.state')='running' ORDER BY rowid DESC LIMIT 1`, + identityKey(identity)); + return row ? decode(row.data) : null; + } + /** + * At startup: a job the server stopped running cannot continue. If it had asked GitHub to merge, its merge attempt's + * recorded state is its outcome (a refusal blocks the task, as the job would have); otherwise it fails, and the person + * merges again. + */ + settleInterruptedMergeJobs(identity: PlanIdentity): void { + const running = this.runningMergeJob(identity); + if (!running) return; + const attempt = running.mergeActionId ? this.getMergeAttempt(identity) : null; + if (!attempt || attempt.actionId !== running.mergeActionId) { + this.finishMergeJob(identity, running.id, { state: 'failed', reason: 'The server restarted before the merge finished.' }); + return; + } + if (attempt.state === 'merged' || attempt.state === 'queued') { + this.finishMergeJob(identity, running.id, { state: attempt.state, url: attempt.url }); + return; + } + if (attempt.state === 'submitting') { + this.finishMergeJob(identity, running.id, { state: 'submitted', + reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.' }); + return; + } + if (attempt.requiresFreshReview) { + // The head changed: the task needs a fresh review, so it is not moved to approved, merge blocked. + this.finishMergeJob(identity, running.id, { state: 'failed', + reason: `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'} Review the new head first.` }); + return; + } + this.blockMergeJob(identity, running.id, `GitHub refused the merge: ${attempt.reason ?? 'no reason given.'}`, undefined, FIX_THEN_RETRY); + } /** Startup recovery makes an interrupted preparation definite and replayable before new work is admitted. */ settleInterruptedPreMergeActions(identity: PlanIdentity): void { const snapshot = this.getSnapshot(identity); @@ -2149,6 +2377,7 @@ export class Store { number: row.number as number | null, url: row.url as string | null, createdAt: row.created_at as string, ownerVersion: row.owner_version as number, ownerReviewVersion: row.owner_review_version as number, refresh: row.refresh_head === null ? null : { head: row.refresh_head as string, draft: row.refresh_draft === 1, stateVersion: row.refresh_version as number }, + checkId: (row.check_id as string | null | undefined) ?? null, }; } /** Every PR codeboost opened or started to open for the task, oldest first. */ @@ -2156,6 +2385,15 @@ export class Store { const key = identityKey(identity); this.#task(key); return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); } + /** + * The task's already-fixed check with this ID, or null if it has none with that ID. The guarded merge reads the check the + * task's first opening into the base branch recorded (`task_pull_requests.check_id`, schema v19) to decide where its + * already-fixed scan starts. + */ + getAlreadyFixed(identity: PlanIdentity, id: string): AlreadyFixedCheck | null { + const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? AND id=?', identityKey(identity), id); + return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; + } latestAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { const key = identityKey(identity); this.#task(key); const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', key); @@ -2221,9 +2459,9 @@ export class Store { if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); const openingId = randomUUID(), now = new Date().toISOString(); this.#touch(key); - this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, - this.#task(key).state_version as number, this.#current(key).review_version as number, now, now); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at,check_id) + VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, + this.#task(key).state_version as number, this.#current(key).review_version as number, now, now, input.checkId); return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index d4975f88..0a4a96d5 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { test, expect } from '@playwright/test'; +import { test, expect, type Page, type Route } from '@playwright/test'; import { openReview, refreshReview, reloadReview } from './review-page.ts'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -8,7 +8,8 @@ import { request as httpRequest, type IncomingMessage } from 'node:http'; import { createDemo } from '../../scripts/demo.ts'; import { choiceKeys } from '../../core/approvals.ts'; import { ReviewService } from '../../runner/review.ts'; -import { startServer } from '../../web/server.ts'; +import { startServer, type PlanningDeps } from '../../web/server.ts'; +import { SETTLED_BEFORE_JOB_MERGE, type MergeJobView } from '../../runner/merge-job.ts'; import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; import { fixtureGit } from '../fixtures/git.ts'; let root: string, app: Awaited>; @@ -19,6 +20,14 @@ function requestAdmitted(server: typeof app.server) { const admitted=new Promise(resolve=>{const onRequest=(req:IncomingMessage)=>{if(req.headers['x-codeboost-test-request']!==id)return;server.off('request',onRequest);resolve();};server.on('request',onRequest);}); return {admitted,marker}; } +// Reads the review a runner server reloads for a merge click. `route.fetch` with method GET resends the click's POST body, +// which the server never reads for a GET: it closes the connection over the unread body, and the reset surfaced as +// `read ECONNRESET`. A separate body-less GET with the page's token reaches the same handler. +async function clickReview(route: Route) { + const request=route.request(),response=await fetch(request.url().replace('/api/action','/api/review'),{headers:{'x-codeboost-token':request.headers()['x-codeboost-token']??''}}); + if(!response.ok)throw new Error(`The click's review reload answered ${response.status}.`); + return await response.json(); +} function removeDemoOutOfScope(config: typeof app.service.config) { chmodSync(join(config.repository,'run.sh'),0o644);fixtureGit(config.repository,'commit','-am','Restore declared scope'); } test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); }); test.afterEach(async () => { await app.close();rmSync(root,{recursive:true,force:true}); }); @@ -46,7 +55,8 @@ test('mints a new merge key for a retry once the attempt started by a retained k await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}keys.push(body.actionId); if(keys.length===1){await route.abort('failed');return;} await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Merge is blocked.'})});}); - await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept()); + const confirms:string[]=[]; + await openReview(page, app.url);page.on('dialog',dialog=>{confirms.push(dialog.message());void dialog.accept();}); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>keys.length).toBe(1);await expect(page.locator('#banner')).toContainText('Merge blocked'); // The lost click did reach GitHub (its response was lost), and that attempt later ends as refused. const expected={...view.expected,reviewVersion:view.expected.reviewVersion!};const attempt=app.service.store.beginMergeAttempt(config.identity,expected,view.snapshot.head,null,'direct',keys[0]); @@ -54,6 +64,8 @@ test('mints a new merge key for a retry once the attempt started by a retained k await refreshReview(page);await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); await page.getByRole('button',{name:'Retry merge',exact:true}).click();await expect.poll(()=>keys.length).toBe(2); expect(keys[1]).not.toBe(keys[0]); + // A direct merge's retry merges the exact reviewed head again (without a runner, nothing prepares or rebases it). + expect(confirms).toEqual(['Merge this reviewed pull request?','Retry merging this exact reviewed head?']); }); test('answers 503 when the merge deadline stops a click, so the browser may resend its key',async()=>{ test.setTimeout(60_000); @@ -631,3 +643,1021 @@ test('hides Retry until a timed-out invocation has actually settled',async({page settle('Late answer'); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible({timeout:10000}); }); +/** Whether the element is drawn with the muted surface background a control that cannot act uses (poll it: buttons may animate their background). */ +const looksInactive=(locator:import('@playwright/test').Locator)=>locator.evaluate(el=>{const probe=document.createElement('div');probe.style.background='var(--surface)';document.body.append(probe); + const surface=getComputedStyle(probe).backgroundColor;probe.remove();return getComputedStyle(el).backgroundColor===surface;}); +/** The blockers a runner server's review lists while a merge job prepares (runner/merge.ts): preparation has not completed + * for the review, and a required check is pending. The job resolves both. */ +const jobResolvedBlockers=[{code:'preparation',message:'The merge has not been prepared for the current review; merging prepares it.'},{code:'check',message:'ci is pending.'}]; +/** A runner server's merge status (runner/merge-job.ts withMergeJob): `merge` with `fields` over it, and the blocker codes a + * job settles once it asks GitHub to merge, which withMergeJob always sends. Build every runner-mode merge status with it. */ +const runnerMerge=(merge:Record,fields:Record={})=>({...merge,...fields,settledBeforeJobMerge:SETTLED_BEFORE_JOB_MERGE}); +/** A merge job record with every field the server sends (runner/merge-job.ts mergeJobView: the store's record without its + * token), `fields` overriding the defaults. A job saves its merge action ID before the guarded merge, so one in that step or + * past it (merged, queued, submitted) carries one by default. Every saved change adds one to a job's revision + * (runner/store.ts #changeMergeJob): the job is saved at 1 while it prepares, moves to checks at 2 with no waiting checks, + * saves each inspection that finds checks waiting (runner/merge-job.ts), then moves to merging and ends with one more + * save each. By default a record follows the course preparing 1, checks 2 (none waiting) or 3 (first wait), merging 4, + * ended 5; a test that sends a further inspection, a shorter course, or an older record on purpose passes its revision. */ +const mergeJobRecord=(fields:Partial={}):MergeJobView=>{ + const state=fields.state??'running',step=fields.step===undefined?(state==='running'?'preparing':null):fields.step; + const mergeActionId=step==='merging'||['merged','queued','submitted'].includes(state)?'0f6b0000-0000-4000-8000-00000000ac01':null; + const revision=state==='running'?(step==='checks'?((fields.checks??[]).length?3:2):({preparing:1,merging:4} as Record)[step??'']??1):5; + return {id:'0f6b0000-0000-4000-8000-000000000000',mergeActionId,reason:null,checks:[],url:null,sequence:1,revision,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z',...fields,state,step}; +}; +/** A blocked job's reason as production saves it: one failing required check (runner/merge-job.ts classify), then the + * next step that blocking appends (FIX_THEN_RETRY in runner/store.ts). */ +const CHECK_FAILED_BLOCK='Required check failed: ci. Fix it, then Retry merge.'; +/** The merge control and its dialog once the page saw its job end: the outcome, the refresh request, and only `others`. */ +async function expectEndedMergeJob(page:import('@playwright/test').Page,label:string,ended:string,others:string[]=[]){ + await expect(page.locator('#merge')).toHaveText(label); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#merge-details')).toHaveText('Merge status'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([ended,...others]); + await page.keyboard.press('Escape'); +} +test('shows only the outcome once a polled merge job ends, not the blockers it resolved (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[])=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f1',state,step,checks}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0; + // As a runner server answers: Merge PR is offered while only job-resolved blockers remain, and the click's review, + // reloaded while the job prepares, still lists them. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route),running=job('running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page follows the job on the click's review, which lists the job-resolved blockers. + expect((await (await answered).json()).merge.blockers.map((blocker:{code:string})=>blocker.code)).toEqual(['preparation','check']); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15_000}); + await expectEndedMergeJob(page,'Merged','Merged. Refresh to see the result.'); +}); +test('keeps every blocker when the click is answered with a job that ended without merging (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + // A collaborator pushed while the job waited: its inspection listed the moved head first, so the job failed with that + // reason alone (classify reports the first blocker). The same inspection found a merge conflict, and a required check + // is still pending; the job settled neither. + const head={code:'head',message:'The pull request head moved. Refresh the review.'}; + const conflict={code:'mergeable',message:'The pull request has merge conflicts.'}; + const failed=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f2',state:'failed',step:null,reason:head.message}); + let polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:failed,blockers:[...review.merge.blockers,head,conflict,...jobResolvedBlockers]}),job:failed})});}); + await page.route('**/api/merge',async route=>{polls++;await route.continue();}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // This job never merged, so the codes it would have settled (served by the review, pinned in test/runner-publishing.test.ts) + // stay listed. + await answered; + const ended=`Merge stopped. ${head.message} Merge again when ready. Refresh to see the result.`; + await expect(page.locator('#banner')).toHaveText(ended); + await expectEndedMergeJob(page,'Merge stopped',ended,[head.message,conflict.message,...jobResolvedBlockers.map(blocker=>blocker.message)]); + expect(polls).toBe(0); +}); +/** Ended jobs with the course production runs before each outcome and the reason it saves (runner/merge-job.ts). */ +const endedMergeJobs:{state:MergeJobView['state'];course:MergeJobView[];reason:string;label:string;banner:string}[]=[ + // GitHub refused the job's merge: the job blocks with the refusal and FIX_THEN_RETRY (#endAsAttempt). + {state:'blocked',course:[mergeJobRecord({step:'merging'})],reason:'GitHub refused the merge: Required status check "ci" is expected. Fix it, then Retry merge.',label:'Merge blocked', + banner:'Approved, merge blocked. GitHub refused the merge: Required status check "ci" is expected. Fix it, then Retry merge.'}, + // The preparation's rebase made an approval stale (runner/pre-merge.ts): the job ends from its preparing step. + {state:'review-required',course:[],reason:'P1 requires refreshed attribution or approval.',label:'Review needed', + banner:'Review needed before merging. P1 requires refreshed attribution or approval.'}, + // GitHub accepted the merge, but saving its outcome failed. + {state:'submitted',course:[mergeJobRecord({step:'merging'})],reason:'GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.',label:'Merge submitted', + banner:'Merge submitted; GitHub\'s outcome is not confirmed yet. GitHub accepted the merge, but its outcome was not saved; it reconciles from GitHub.'}, + // The task was cancelled while the job waited for checks. + {state:'cancelled',course:[mergeJobRecord({step:'checks',checks:[{context:'ci',state:'pending'}]})],reason:'Task cancelled.',label:'Merge cancelled',banner:'Merge cancelled.'}, +]; +for(const variant of endedMergeJobs)test(`names a merge job that ended ${variant.state} on its control and banner (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + // Each saved change adds one to the revision: preparing 1, then the course, then the end. A job past preparing has + // checked (revision 2) before it waits (3) or merges (4); the course's records carry those defaults. + const lastRevision=variant.course.at(-1)?.revision??1; + const ended=mergeJobRecord({state:variant.state,step:null,reason:variant.reason,revision:lastRevision+1, + mergeActionId:variant.course.some(job=>job.step==='merging')?'0f6b0000-0000-4000-8000-00000000ac01':null}); + const running=mergeJobRecord({step:'preparing'}); + let polls=0,released=false; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); + // The merging step is held until the test has read its banner. + await page.route('**/api/merge',async route=>{const next=polls{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + if(variant.course.some(job=>job.step==='merging')){ + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true',{timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Merging the reviewed head.'); + released=true; + } + const banner=`${variant.banner} Refresh to see the result.`; + await expect(page.locator('#banner')).toHaveText(banner,{timeout:15_000}); + // Only a job that asked GitHub to merge and was accepted (submitted) settled the blockers it resolves. + await expectEndedMergeJob(page,variant.label,banner,variant.state==='submitted'?[]:jobResolvedBlockers.map(blocker=>blocker.message)); +}); +test('names a failed merge-job poll as a merge-status failure (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-000000000001',state:'running',step:'checks'}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + // A job is running and no merge queue exists; GitHub's status cannot be read. + await page.route('**/api/merge',route=>route.fulfill({status:502,contentType:'application/json',body:JSON.stringify({error:'GitHub returned HTTP 502.'})})); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\. GitHub returned HTTP 502\./,{timeout:15_000}); +}); + +test('ends a running merge job the poll no longer reports, keeping every blocker (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'checks'}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); + // The job is no longer reported (no job record and no merge queue): the page cannot tell whether it reached the merge. + let polls=0; + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:null})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + const ended='The merge job ended. Refresh to see the result.'; + await expect(page.locator('#banner')).toHaveText(ended,{timeout:15_000}); + await expect(page.getByText('Could not refresh merge status')).toHaveCount(0); + // A job not known to have merged settled nothing, so every blocker the page last read stays listed. + await expectEndedMergeJob(page,'Merge ended',ended,jobResolvedBlockers.map(blocker=>blocker.message)); + const seen=polls;expect(seen).toBe(1); + // Past the 2 s poll delay (and its first back-off), no further status poll runs. + await page.waitForTimeout(5_000); + expect(polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText(ended); +}); + +test('keeps a draft typed while a merge-job click is in flight (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); + let started:MergeJobView|null=null,release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await held;const review=await clickReview(route),running=job(body.actionId,'running','preparing');started=running; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.getByLabel('Question about this item').fill('Before the click.'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The person keeps typing while the click's request is in flight. + await page.getByLabel('Question about this item').fill('Typed while the merge request was in flight.'); + release(); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); +}); + +for(const refusal of [{name:'refused',status:409,error:'Issue #1 is not trusted for its current author.'},{name:'not applied',status:503,error:'The review server is shutting down.'}]) +test(`keeps a draft typed while a merge-job click is in flight and then ${refusal.name} (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + // A merge-job refusal (409) carries the task's latest job, none here; a shutdown (503) carries only its error. + await held;await route.fulfill({status:refusal.status,contentType:'application/json',body:JSON.stringify(refusal.status===409?{error:refusal.error,job:null}:{error:refusal.error})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await page.getByLabel('Question about this item').fill('Before the click.'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The person keeps typing while the click's request is in flight; the click then fails. + await page.getByLabel('Question about this item').fill('Typed while the merge request was in flight.'); + release(); + await expect(page.locator('#banner')).toContainText(refusal.error); + await expect(page.getByLabel('Question about this item')).toHaveValue('Typed while the merge request was in flight.'); +}); + +// Records the delay of every timer of 500 ms or more the page schedules: the merge poll's delays, read without timing the +// round trips (which vary with machine load). +const recordPollDelays=(page:Page)=>page.addInitScript(()=>{const delays:number[]=[];(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays=delays;const native=window.setTimeout.bind(window);window.setTimeout=((handler:TimerHandler,timeout?:number,...args:unknown[])=>{if(typeof timeout==='number'&&timeout>=500)delays.push(timeout);return native(handler,timeout,...args);}) as typeof window.setTimeout;}); +const pollDelays=(page:Page)=>page.evaluate(()=>(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays.slice()); +// Runs a merge job whose click answers `steps[0]` and whose polls answer the rest in order, then returns the poll delays. +async function mergeJobPollDelays(page:Page,steps:MergeJobView[]){ + await recordPollDelays(page); + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const [clicked,...polled]=steps;let polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:clicked}),job:clicked})});}); + await page.route('**/api/merge',async route=>{const next=polled[Math.min(polls,polled.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:30_000}); + expect(polls).toBe(polled.length); + return pollDelays(page); +} + +test('backs off the merge-job poll while the job does not change, and polls soon again once its step changes (F6b)',async({page})=>{ + const checks=[{context:'ci',state:'pending'}],waiting=mergeJobRecord({state:'running',step:'checks',checks}),merging=mergeJobRecord({state:'running',step:'merging',checks}); + // The click shows `waiting`; poll 1 sees it again (no change); poll 2 a new step; poll 3 that step again; poll 4 the end. + const delays=await mergeJobPollDelays(page,[waiting,waiting,merging,merging,mergeJobRecord({state:'merged',step:null})]); + // 2 s after the click, doubled to 4 s after the unchanged poll, back to 2 s after the step changed, doubled again after + // the next unchanged one. The job's end schedules nothing. + expect(delays).toEqual([2000,4000,2000,4000]); +}); + +test('does not back off the merge-job poll when only the waiting checks change within a step (F6b)',async({page})=>{ + const waiting=(checks:MergeJobView['checks'],revision:number)=>mergeJobRecord({state:'running',step:'checks',checks,revision}); + // As production saves them: the first wait at revision 3, the changed check list at 4, then merging at 5 (not polled + // here) and the end at 6. + const pending=waiting([{context:'ci',state:'pending'}],3),progressed=waiting([{context:'ci',state:'pending'},{context:'lint',state:'pending'}],4); + // The click shows `pending`; poll 1 sees it again (no change); poll 2 a changed check list in the same step; poll 3 the end. + const delays=await mergeJobPollDelays(page,[pending,pending,progressed,mergeJobRecord({state:'merged',step:null,revision:6})]); + // 2 s after the click, doubled to 4 s after the unchanged poll; the changed checks keep it at 4 s (the step is the same, + // so the delay is not reset to 2 s either). + expect(delays).toEqual([2000,4000,4000]); +}); + +test('starts a new action for Retry merge once the job its retained key started has ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); + const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} + const review=await clickReview(route),running=job(body.actionId,'running','preparing',2);started=running; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); + // The first click's job was saved and has since ended blocked: the review offers Retry merge. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{job:first?job(first,'blocked',null):null,...(first?{ready:true,action:'retry'}:{})});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The retry is a new action: resending the first key would only replay the ended job. + expect(clicks[1]).not.toBe(clicks[0]); +}); + +test('starts a new action once another job became the latest after a retained key (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(id:string,state:MergeJobView['state'],step:MergeJobView['step'],sequence=1)=>mergeJobRecord({id,state,step,sequence,reason:state==='blocked'?CHECK_FAILED_BLOCK:null}); + const clicks:string[]=[];let first:string|null=null,started:MergeJobView|null=null; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + if(clicks.length===1){first=body.actionId;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});return;} + const review=await clickReview(route),running=job(body.actionId,'running','preparing',3);started=running; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started})})); + // The first click's job ended, then another client's job ran and ended blocked: the latest job is not this key's. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{job:first?job('0f6b0000-0000-4000-8000-0000000000b2','blocked',null,2):null,...(first?{ready:true,action:'retry'}:{})});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The retry is a new action: resending the first key would only replay the ended job. + expect(clicks[1]).not.toBe(clicks[0]); +}); + +test('resends the same action for Retry merge after a retry failed while an earlier job is shown (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const blocked=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000a1',state:'blocked',step:null,reason:CHECK_FAILED_BLOCK}); + // Every retry answers 503: nothing was applied, so the click is unresolved and its key must be resent. + const clicks:string[]=[]; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'The review server is shutting down.'})});}); + // An earlier job ended blocked: the review offers Retry merge, and keeps showing that job. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{job:blocked,ready:true,action:'retry'});await route.fulfill({response,json:body});}); + const confirms:string[]=[]; + await page.goto(app.url);page.on('dialog',dialog=>{confirms.push(dialog.message());void dialog.accept();}); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked. The review server is shutting down.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect.poll(()=>clicks.length).toBe(2); + // The earlier job is not this click's: the unresolved click is resent with its own key. + expect(clicks[1]).toBe(clicks[0]); + // A job's retry prepares again and may rebase onto a new head, so it does not promise the exact reviewed head. + const retry='Retry the merge? It prepares the merge again, which may rebase onto the current base and need a new review.'; + expect(confirms).toEqual([retry,retry]); +}); + +// The server orders records of one job by revision, never clock time: the held running record's update time may equal or +// follow the outcome's (two saves in one clock tick, or a clock step back between them). +for(const clock of [{name:'',runningAt:'2026-10-11T00:00:00Z'},{name:' with the same update time',runningAt:'2026-10-11T00:05:00Z'},{name:' with a later update time',runningAt:'2026-10-11T00:09:00Z'}]) +test(`keeps a merge-job outcome a poll saw while an older Refresh response was held${clock.name} (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing',updatedAt:clock.runningAt}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:5,updatedAt:'2026-10-11T00:05:00Z'}); + let ended=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); + await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:running})});}); + // Every review response was read while the job ran; the Refresh response is held until the poll has seen it end. As a + // runner server's review read during preparation does, each lists the blockers the job resolves. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:false,action:null,job:running,blockers:[...body.merge.blockers,...jobResolvedBlockers]}); + if(hold){hold=false;holding();await released;} + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await held; + // Selecting an item while Refresh waits renders the merge control, which schedules a poll; that poll sees the job end. + ended=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:10_000}); + const seen=polls; + release();await refreshed; + // The older Refresh response does not restore the running job: no poll restarts (the poll interval is 2 s), and the + // page still shows the outcome. + await page.waitForTimeout(3_000); + expect(polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); + // The held response's blockers were the job's own: the control and dialog show only the outcome. + await expectEndedMergeJob(page,'Merged','Merged. Refresh to see the result.'); +}); + +test('keeps a newer merge job a poll saw while a held Refresh response shows an earlier job ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + // Job A runs in this tab. Another tab's click starts job B after A ends; B's record is later than any of A's (sequence), + // while its clock times are earlier, as after a clock step back. + const jobA=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',state:'running',step:'preparing',sequence:1,startedAt:'2026-10-11T00:10:00Z',updatedAt:'2026-10-11T00:10:00Z'}); + const blockedA=mergeJobRecord({...jobA,state:'blocked',step:null,reason:CHECK_FAILED_BLOCK,revision:3,updatedAt:'2026-10-11T00:20:00Z'}); + const jobB=mergeJobRecord({...jobA,id:'0f6b0000-0000-4000-8000-0000000000e2',step:'checks',sequence:2,revision:2,startedAt:'2026-10-11T00:00:00Z',updatedAt:'2026-10-11T00:00:00Z'}); + let started=false,hold=false,polls=0,release!:()=>void,holding!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{holding=resolve;}); + await page.route('**/api/merge',route=>{polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:started?jobB:jobA})});}); + // The page loads with A running. The held Refresh response was read after A ended (blocked, Retry offered) and before B + // started; it is answered only after the poll has seen B running. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + if(hold){hold=false;body.merge=runnerMerge(body.merge,{ready:true,action:'retry',job:blockedA});holding();await released;} + else body.merge=runnerMerge(body.merge,{ready:false,action:null,job:jobA}); + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await held; + // Selecting an item while Refresh waits renders the merge control, which schedules a poll; that poll sees job B running. + started=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toBeVisible({timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks.'); + release(); + const answer=await (await refreshed).json(); + // The interleaving happened: the Refresh answer showed job A ended with Retry offered, after the poll saw B. + expect(answer.merge).toMatchObject({ready:true,action:'retry',job:{id:blockedA.id,state:'blocked'}}); + // The older Refresh response does not restore job A: B stays followed, not ready, and its poll keeps running (2 s). + const seen=polls; + await page.waitForTimeout(3_000); + expect(polls).toBeGreaterThan(seen); + const merge=page.locator('#merge'); + await expect(merge).toHaveText('Waiting for checks…'); + await expect(merge).toHaveAttribute('aria-disabled','true'); + await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0); + // Not ready while B runs: activating the control sends no merge request. + let actions=0,dialogs=0;page.on('request',request=>{if(request.url().endsWith('/api/action'))actions++;}); + page.on('dialog',dialog=>{dialogs++;void dialog.accept();}); + await merge.dispatchEvent('click');await page.waitForTimeout(500); + expect(dialogs).toBe(0);expect(actions).toBe(0); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks.'); +}); + +/** A runner server whose every full review shows a running merge job (each was read while it ran); the job poll reports + * it merged once `ended` is set. Used to hold one full response while a poll sees the job end. */ +async function mergeJobOutcomeRace(page:import('@playwright/test').Page,planning?:PlanningDeps){ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway,undefined,undefined,undefined,planning); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'running',step:'preparing'}); + const merged=mergeJobRecord({...running,mergeActionId:'0f6b0000-0000-4000-8000-00000000ac01',state:'merged',step:null,url:'https://github.com/owner/repo/pull/7',revision:5,updatedAt:'2026-10-11T00:05:00Z'}); + const race={ended:false,polls:0,hold:false,config,release:()=>{},held:Promise.resolve()}; + let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); + const released=new Promise(resolve=>{race.release=resolve;}); + await page.route('**/api/merge',route=>{race.polls++;return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:race.ended?merged:running})});}); + // A full response (review or action) read while the job ran. The one marked held waits, and the job ends meanwhile. + const answer=async(route:import('@playwright/test').Route)=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:false,action:null,job:running}); + if(race.hold){race.hold=false;race.ended=true;holding();await released;} + await route.fulfill({response,json:body});}; + await page.route('**/api/review',answer); + await page.route('**/api/action',answer); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toBeVisible(); + /** The poll saw the job end while the full response was held; release it and show it does not restore the running job. */ + const settle=async(response:Promise)=>{ + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:10_000}); + const seen=race.polls; + race.release();await response; + // No poll restarts (the poll interval is 2 s), and the page still shows the outcome. + await page.waitForTimeout(3_000); + expect(race.polls).toBe(seen); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveCount(0); + }; + return {race,settle}; +} + +test('keeps a merge-job outcome a poll saw while an older review action response was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + await page.getByRole('button',{name:'Request change',exact:true}).click(); + await page.getByLabel('Change to request').fill('Keep the merge outcome.'); + race.hold=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Save change request',exact:true}).click();await race.held; + // Selecting an item while the action waits renders the merge control, which schedules a poll; that poll sees the job end. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await settle(answered); + await page.getByRole('button',{name:/P1 Bound exponential retries/}).click(); + await expect(page.getByText('Keep the merge outcome.',{exact:true})).toBeVisible(); +}); + +test('keeps a merge-job outcome a poll saw while an older Plans refresh response was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + await page.getByRole('link',{name:'Plans',exact:true}).click(); + race.hold=true; + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#plans-refresh').click();await race.held; + await page.getByRole('link',{name:'Review',exact:true}).click(); + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await settle(refreshed); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r1 is current.'); +}); + +test('keeps a merge-job outcome a poll saw while an older plan-import reload was held (F6b)',async({page})=>{ + const {race,settle}=await mergeJobOutcomeRace(page); + const current=app.service.store.getPlan(race.config.identity),next={...structuredClone(current),revision:current.revision+1,summary:'Make retry limits visible to operators'}; + await page.getByRole('link',{name:'Plans',exact:true}).click(); + await page.getByLabel('Plan file').setInputFiles({name:'plan.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(next))}); + race.hold=true; + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // The import's success renders the merge control, which schedules a poll; the poll sees the job end during the reload. + await page.getByRole('button',{name:'Import next revision'}).click();await race.held; + await settle(reloaded); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r2 is current.'); +}); + +test('keeps a merge-job outcome a poll saw while an older Apply draft reload was held (F6b)',async({page})=>{ + let plan:unknown; + const planning:PlanningDeps={provider:{invoke:async()=>JSON.stringify(plan)},describe:()=>({repo:{name:'retry-service',baseRef:'main'},issue:{number:3,title:'Retries',body:'',comments:[]},approvedLessons:[],validate:()=>undefined})}; + const {race,settle}=await mergeJobOutcomeRace(page,planning); + const current=app.service.store.getPlan(race.config.identity); + plan={...structuredClone(current),revision:current.revision+1,summary:'Make retry limits visible to operators'}; + await page.getByRole('link',{name:'Plans',exact:true}).click(); + await page.getByRole('button',{name:'Draft next revision'}).click(); + const apply=page.getByRole('button',{name:'Apply draft',exact:true}); + await expect(apply).toBeVisible(); + race.hold=true; + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // Apply's success renders the merge control, which schedules a poll; the poll sees the job end during the reload. + await apply.click();await race.held; + await settle(reloaded); + await expect(page.locator('#plans-status')).toHaveText('✓ Revision r2 is current.'); +}); + +test('keeps the Merge button pending while its click is in flight, across a render (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d1',state:'running',step:'preparing'}); + let release!:()=>void,arrived!:()=>void;const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + arrived();await released; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:running})})); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click();await held; + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveText('Requesting merge…'); + // Selecting an item renders the page, merge control included, while the click's request is still in flight. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:/P2 Document retry behavior/})).toHaveAttribute('aria-current','true'); + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveText('Requesting merge…'); + await expect(button).toHaveJSProperty('disabled',false); + release(); + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); +}); + +test('keeps the direct Merge button disabled while its click is in flight, across a render',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let release!:()=>void,arrived!:()=>void; + const released=new Promise(resolve=>{release=resolve;}),held=new Promise(resolve=>{arrived=resolve;}); + // No runner: the click merges directly, and the server answers once GitHub has. + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + merge:async()=>{arrived();await released;return {url:'https://github.com/example/repo/pull/21'};}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page,app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click();await held; + await expect(button).toBeDisabled();await expect(button).toHaveText('Requesting merge…'); + // Selecting an item renders the page, merge control included, while the click's request is still in flight. + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.getByRole('button',{name:/P2 Document retry behavior/})).toHaveAttribute('aria-current','true'); + await expect(button).toBeDisabled();await expect(button).toHaveText('Requesting merge…'); + release(); + await expect(page.locator('#banner')).toContainText('Merge submitted.');await expect(button).toBeDisabled(); +}); + +test('keeps focus on the Merge button when a merge-job click is refused (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Issue #1 is not trusted for its current author.',job:null})});}); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + // The review loads asynchronously (#170); press only once the page offers the merge. + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled(); + await button.focus();await button.press('Enter'); + await expect(page.locator('#banner')).toHaveText(/^Merge blocked\. Issue #1 is not trusted/); + // The refusal leaves the control marked, not disabled, and focused (AGENTS.md, Async review UI). + await expect(button).toHaveAttribute('aria-disabled','true'); + await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); + // It is drawn as a control that cannot act, as a disabled one is. + await expect.poll(()=>looksInactive(button)).toBe(true); + // Activating it again starts nothing: the merge is not ready. A request would reach the + // route only after the confirm and the fetch, so give one time to arrive before counting. + await button.press('Enter'); + await page.waitForTimeout(1000); + expect(mergeClicks).toBe(1); +}); +test('follows the running merge job when a click from an older review is refused because another tab started it (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[])=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000b1',state,step,checks}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)];let polls=0,mergeClicks=0; + // This tab read its review before another tab's click started a job; the server refuses this click and, as + // web/server.ts does, carries the task's running job. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'A merge is already running for this task.',job:job('running','checks',[{context:'ci',state:'pending'}])})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page follows the other tab's job, as its poll would, rather than asking for a refresh. + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toHaveText('A merge is already running for this task. Waiting for required checks: ci is pending.'); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15_000}); + expect(polls).toBeGreaterThanOrEqual(2); + expect(mergeClicks).toBe(1); +}); +// A refused click's job is followed only when it is running and later than the job this page shows (web/public/app.js): +// the server attaches the task's latest job to every merge-job refusal (web/server.ts), so an ended one comes too, even +// one later than the job this page shows (another tab's job that has ended since this page read its review). +const shownJob=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c2',state:'blocked',step:null,reason:CHECK_FAILED_BLOCK,sequence:2,revision:4,updatedAt:'2026-10-11T00:00:05Z'}); +for(const refusal of [ + {name:'an ended job',shown:null,button:'Merge PR',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c1',state:'failed',step:null,reason:'The server restarted before the merge finished.'})}, + {name:'an ended job later than the job shown',shown:shownJob,button:'Retry merge',job:mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000c3',state:'failed',step:null,reason:'The server restarted before the merge finished.',sequence:3,updatedAt:'2026-10-11T00:00:09Z'})}, + // Defensive: the server's latest job only moves forward, so it cannot send a running record no later than the job this + // page shows. Kept to pin the "later than" half of the follow condition, which the reachable cases above do not reach. + {name:'a running record no later than the job shown (defensive)',shown:shownJob,button:'Retry merge',job:mergeJobRecord({...shownJob,state:'running',step:'checks',reason:null,checks:[{context:'ci',state:'pending'}],revision:3})}, +]){ + test(`asks for a refresh, not following it, when a refused merge click carries ${refusal.name} (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let polls=0,mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Stale review state. Refresh before merging.',job:refusal.job})});}); + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:refusal.job})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:refusal.shown?'retry':'merge',job:refusal.shown,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:refusal.button,exact:true}).click(); + await expect(page.locator('#banner')).toHaveText('Merge blocked. Stale review state. Refresh before merging.'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Stale review state. Refresh before merging. Refresh before trying again.'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(2500); // Past the first poll delay: the refused click's job is not followed. + expect(polls).toBe(0); + expect(mergeClicks).toBe(1); + }); +} +test('states the next step a blocked merge job names, once (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + // The reason runner/merge-job.ts saves when the 30-minute wait ends: nothing failed, so the step is to wait. + const reason='Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.'; + // The highest revision such a record can reach: preparing saves 1 and the checks step 2. Each inspection that finds checks + // waiting saves once; with the 30-minute limit and waits of 10 s doubling to 60 s, inspections start at most at 0, 10, 30 + // and 70 s, then every 60 s from 130 to 1750 s: 32 inspections, revisions 3 to 34. The timeout's block saves 35. + const id='0f6b0000-0000-4000-8000-0000000000b2',blocked=mergeJobRecord({id,state:'blocked',step:null,reason,revision:35}); + // The click is answered while the job prepares; the 30-minute wait ends long after, so the poll delivers the block. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route),running=mergeJobRecord({id,state:'running',step:'preparing'}); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:blocked})})); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toHaveText(`Approved, merge blocked. ${reason} Refresh to see the result.`); +}); +test('asks for a refresh when the click is answered with a merge job that has already ended (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let polls=0; + // The job failed before the click was answered: its first review load failed, as the click's reload did, so it was saved + // twice (preparing at revision 1, failed at revision 2) and never reached the merge (no merge action ID). + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:mergeJobRecord({state:'failed',step:null,reason:'History work exceeded its overall deadline.',revision:2}),mergeRefreshRequired:true})});}); + await page.route('**/api/merge',async route=>{polls++;await route.continue();}); + // A runner server reports merging as a job (its merge status carries `job`), as production does. + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Refresh to see the result.'); + await expect(page.locator('#merge')).toHaveJSProperty('disabled',false); // Marked, not disabled: it keeps focus. + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Refresh to see the result.'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(2500); // Past the first poll delay: an ended job is not polled. + expect(polls).toBe(0); +}); + +test('follows a started merge job when the server could not reload the review after the click (F6b, #170)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('merged',null)]; + let polls=0,reviews=0; + // The click's job started, but its review reload failed: the server answers only the job and asks for a refresh. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:job('running','preparing'),mergeRefreshRequired:true})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await expect(page.locator('#items button').first()).toBeVisible(); + const items=await page.locator('#items button').count(); + expect(items).toBeGreaterThan(0); + const reloadsBefore=reviews; + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page keeps its review and follows the job: its progress, then its end, with no reload. + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toContainText('Preparing the merge'); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.',{timeout:15000}); + expect(polls).toBeGreaterThanOrEqual(2); + // Give a background reload started by the job's end time to reach the route before counting. + await page.waitForTimeout(2500); + expect(reviews).toBe(reloadsBefore); + expect(await page.locator('#items button').count()).toBe(items); +}); + +test('follows a one-click merge job from preparation through the required-check wait to merged (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const job=(state:MergeJobView['state'],step:MergeJobView['step'],checks:MergeJobView['checks']=[],reason:string|null=null)=>mergeJobRecord({state,step,checks,reason}); + const steps=[job('running','checks',[{context:'ci',state:'pending'}]),job('running','merging'),job('merged',null)]; + let polls=0,reviews=0,mergeClicks=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}mergeClicks++; + const review=await clickReview(route),running=job('running','preparing'); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:running}),job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(polls,steps.length-1)];polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:next})});}); + // A runner server reports merging as a job (its merge status carries `job`). + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json();body.merge=runnerMerge(body.merge,{job:null});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const button=page.locator('#merge'); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The focused control keeps focus while its job runs: marked, not disabled (AGENTS.md, Async review UI). + await expect(page.getByRole('button',{name:'Preparing…',exact:true})).toHaveAttribute('aria-disabled','true'); + // Not disabled in the DOM (Playwright's toBeEnabled counts aria-disabled as disabled), and still focused. + await expect(button).toHaveJSProperty('disabled',false);await expect(button).toBeFocused(); + await expect.poll(()=>looksInactive(button)).toBe(true); + await expect(page.locator('#banner')).toHaveText('Preparing the merge: refresh, rebase, push and command checks.'); + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(button).toBeFocused(); + await expect(page.locator('#banner')).toContainText('Waiting for required checks: ci is pending.'); + // A repeat activation while the job runs starts nothing. + await button.press('Enter'); + await page.getByRole('button',{name:'Merge status',exact:true}).click(); + // The dialog names the step as the control does, not the job's raw step value. + await expect(page.locator('dialog strong')).toHaveText('Waiting for checks…'); + await expect(page.locator('dialog')).toContainText('ci: pending'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toHaveAttribute('aria-disabled','true'); + expect(mergeClicks).toBe(1); + const reloadsBeforeEnd=reviews; + // The job's end updates only the merge control (AGENTS.md, Async review UI): no background reload erases the + // person's selection or input. + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + // Give a background reload started by the job's end time to reach the route before counting. + await page.waitForTimeout(2500); + expect(reviews).toBe(reloadsBeforeEnd); + await expect(button).toHaveAttribute('aria-disabled','true');await expect(button).toHaveJSProperty('disabled',false); + expect(polls).toBe(3); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog')).toContainText('Merged. Refresh to see the result.'); + await page.keyboard.press('Escape'); + // Refresh loads the outcome. + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + expect(reviews).toBeGreaterThan(reloadsBeforeEnd); +}); +test('follows the merge queue a polled job ended in, from a review with no queue, to merged without Refresh (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + // Shapes as runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus) answer them: the job's attempt carries + // the job's merge action ID. + const job=(state:MergeJobView['state'],step:MergeJobView['step'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId:step==='preparing'?null:mergeActionId,state,step,revision}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:false}); + // The job ends queued; GitHub's queue merges two polls later. + const steps=[{queue:null,job:job('running','merging',4)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('queued'),job:job('queued',null,5)},{queue:queue('merged'),job:job('queued',null,5)}]; + const answered:Array<{queue:unknown;job:{state:string}}>=[];let reviews=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route),running=job('running','preparing',1); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:null,job:running,blockers:[...review.merge.blockers,...jobResolvedBlockers]}),job:running})});}); + await page.route('**/api/merge',async route=>{const next=steps[Math.min(answered.length,steps.length-1)]!;answered.push(next);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{reviews++;const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The page started from a review with no queue, and the click's answer has none either. + expect((await (await clicked).json()).merge.queue).toBeNull(); + const reviewsAfterClick=reviews; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:20_000}); + // The interleaving: a poll answered the ended `queued` job with its queue still active, and only a later poll saw the merge. + expect(answered.map(answer=>`${answer.job.state}/${(answer.queue as {state:string}|null)?.state??'none'}`).slice(0,4)).toEqual(['running/none','queued/queued','queued/queued','queued/merged']); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + // As after a queue merge: the merge status names the confirmed merge, and the blockers the job settled are gone. + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText(['GitHub confirmed that the reviewed head was merged.']); + await page.keyboard.press('Escape'); + expect(reviews).toBe(reviewsAfterClick); // No Refresh, and no background reload. +}); +test('follows a later running merge job when a resent click replays its own ended job (F6b)',async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const record=(id:string,sequence:number,state:MergeJobView['state'],step:MergeJobView['step'],reason:string|null=null)=>mergeJobRecord({id,sequence,state,step,reason,checks:step==='checks'?[{context:'ci',state:'pending'}]:[]}); + // The review shows an earlier job that ended blocked, so it offers Retry merge. + const shown=record('0f6b0000-0000-4000-8000-0000000000d0',1,'blocked',null,CHECK_FAILED_BLOCK); + const clicks:string[]=[];let jobB:ReturnType|null=null,polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}clicks.push(body.actionId); + // The first answer is lost before the server admitted the click; the server then saves its job A, which ends blocked, + // and another tab's retry starts job B. + if(clicks.length===1){await route.abort('failed');return;} + const jobA=record(body.actionId,2,'blocked',null,CHECK_FAILED_BLOCK);jobB=record('0f6b0000-0000-4000-8000-0000000000d3',3,'running','checks'); + // As web/server.ts answers a resend: the replayed job A, with the review reloaded meanwhile, whose job is the latest (B). + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:jobB}),job:jobA})});}); + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:jobB})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{job:shown,ready:true,action:'retry'});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>void dialog.accept()); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + await expect(page.locator('#banner')).toContainText('Merge blocked.'); + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').click();await reloaded; + const resent=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Retry merge',exact:true}).click(); + const answer=await (await resent).json(); + // The interleaving: the resend carried the first click's key, and its answer replayed that click's ended job A beside + // the later running job B. + expect(clicks).toHaveLength(2);expect(clicks[1]).toBe(clicks[0]); + expect(answer.job).toMatchObject({id:clicks[0],state:'blocked',sequence:2}); + expect(answer.merge.job).toMatchObject({state:'running',sequence:3}); + // The page follows B, not A's outcome. + await expect(page.getByRole('button',{name:'Waiting for checks…',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#banner')).toHaveText('Waiting for required checks: ci is pending.'); + await expect.poll(()=>polls,{timeout:10_000}).toBeGreaterThan(0); + await expect(page.locator('#merge')).toHaveText('Waiting for checks…'); + await expect(page.locator('#banner')).not.toContainText('Merge blocked'); +}); + +/** A runner server whose every full review shows its merge job running in the guarded merge (`merging`), with no merge + * attempt (each was read before the job asked GitHub). The job poll answers `answer(polls)`, in the shapes + * runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus) send: the job's attempt carries its merge action ID. + * A full review marked `race.hold` waits until `race.release()`. */ +async function endedJobQueueServer(page:import('@playwright/test').Page,answer:(polls:number,parts:{job:(state:MergeJobView['state'],revision:number)=>MergeJobView;queue:(state:string,actionId?:string)=>{state:string}})=>{queue:{state:string}|null;job:MergeJobView}){ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + const jobRecord=(state:MergeJobView['state'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId,state,step:state==='running'?'merging':null,revision}); + const queueRecord=(state:string,actionId=mergeActionId)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Required check failed.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'||state==='removed'?'2026-10-11T00:10:00Z':null,actionId,retryable:state==='removed'}); + const race={answered:[] as string[],hold:false,release:()=>{},held:Promise.resolve()}; + let holding!:()=>void;race.held=new Promise(resolve=>{holding=resolve;}); + const released=new Promise(resolve=>{race.release=resolve;}); + await page.route('**/api/merge',async route=>{const next=answer(race.answered.length,{job:jobRecord,queue:queueRecord}); + race.answered.push(`${next.job.state}/${next.queue?.state??'none'}`);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:false,action:null,queue:null,job:jobRecord('running',4),blockers:[...body.merge.blockers,...jobResolvedBlockers]}); + if(race.hold){race.hold=false;holding();await released;} + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.getByRole('button',{name:'Merging…',exact:true})).toBeVisible(); + return race; +} + +for(const variant of [{name:'merged',site:'Refresh',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'},{name:'still queued',site:'Refresh',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.'}, + {name:'merged',site:'Plans refresh',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.'}]) +test(`keeps the merge attempt a polled job ended queued in, ${variant.name}, while an older ${variant.site} response was held (F6b)`,async({page})=>{ + let ended=false; + // Before the job ends the poll sees it merging; then it ends `queued` with its attempt queued, and GitHub's queue + // merges it on the next poll (or still holds it). + const race=await endedJobQueueServer(page,(polls,{job,queue})=>!ended?{queue:null,job:job('running',4)} + :{queue:queue(variant.name==='merged'&&race.answered.filter(answer=>answer.startsWith('queued')).length>0?'merged':'queued'),job:job('queued',5)}); + race.hold=true; + if(variant.site==='Plans refresh')await page.getByRole('link',{name:'Plans',exact:true}).click(); + const refreshed=page.waitForResponse(response=>response.url().endsWith('/api/review')); + if(variant.site==='Plans refresh'){await page.locator('#plans-refresh').click();await race.held;await page.getByRole('link',{name:'Review',exact:true}).click();} + else{await page.locator('#review-link').click();await race.held;} + // Selecting an item while the reload waits renders the merge control, which schedules a poll; it sees the job end. + ended=true; + await page.getByRole('button',{name:/P2 Document retry behavior/}).click(); + await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText(variant.banner); + // The interleaving: before the held response was released, the poll saw the job end queued and followed its attempt. + const afterEnd=race.answered.filter(answer=>answer!=='running/none'); + if(variant.name==='merged')expect(afterEnd).toEqual(['queued/queued','queued/merged']); + else{expect(afterEnd.length).toBeGreaterThan(0);expect([...new Set(afterEnd)]).toEqual(['queued/queued']);} + const seen=race.answered.length; + race.release(); + const answer=await (await refreshed).json(); + // The held response was read while the job was merging, before it had an attempt. + expect(answer.merge).toMatchObject({queue:null,job:{state:'running',step:'merging'}}); + await page.waitForTimeout(3_000); + if(variant.name==='merged'){ + // Merged survives, and no poll restarts (the poll interval is 2 s). + expect(race.answered.length).toBe(seen); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog li')).toHaveText(['GitHub confirmed that the reviewed head was merged.']); + await page.keyboard.press('Escape'); + }else{ + // The attempt is still followed: the queue poll continues. + expect(race.answered.length).toBeGreaterThan(seen); + } + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); +}); + +test('does not follow another attempt the poll answers beside a job that ended queued (F6b)',async({page})=>{ + // The job ended queued, but the poll's attempt is another one (its action ID is not the job's merge action ID). + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',4)}:{queue:queue('queued','0f6b0000-0000-4000-8000-0000000000ee'),job:job('queued',5)}); + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + const ended='Merge queued. Waiting for GitHub to confirm the outcome. Refresh to see the result.'; + await expect(page.locator('#banner')).toHaveText(ended); + expect(race.answered).toEqual(['running/none','queued/queued']); + // The view is the job's outcome alone: the other attempt's queue is not shown or polled. + await page.waitForTimeout(3_000); + expect(race.answered).toHaveLength(2); + await expect(page.locator('#banner')).toHaveText(ended); + await expectEndedMergeJob(page,'Merge queued',ended); +}); + +for(const variant of [{state:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.',status:'GitHub confirmed that the reviewed head was merged.'}, + {state:'removed',label:'1 blocker',banner:'Removed from merge queue. Required check failed. Refresh to verify retry readiness.',status:'Required check failed. Refresh to verify retry readiness.'}]) +test(`shows at once a job's attempt already ${variant.state} when the poll sees the job end queued (F6b)`,async({page})=>{ + // One poll answers the job ended queued and its attempt already ended. + const race=await endedJobQueueServer(page,(polls,{job,queue})=>polls===0?{queue:null,job:job('running',4)}:{queue:queue(variant.state),job:job('queued',5)}); + await expect(page.locator('#merge')).toHaveText(variant.label,{timeout:15_000}); + expect(race.answered).toEqual(['running/none',`queued/${variant.state}`]); + await expect(page.locator('#banner')).toHaveText(variant.banner); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([variant.status]); + await page.keyboard.press('Escape'); + // Polling stops (the poll interval is 2 s). + await page.waitForTimeout(3_000); + expect(race.answered).toHaveLength(2); +}); + +for(const variant of [{state:'merged',label:'Merged',banner:'GitHub confirmed the reviewed head was merged.',status:'GitHub confirmed that the reviewed head was merged.'}, + {state:'removed',label:'1 blocker',banner:'Removed from merge queue. Required check failed. Refresh to verify retry readiness.',status:'Required check failed. Refresh to verify retry readiness.'}, + {state:'queued',label:'Merge queued',banner:'Merge queued at position 1. Waiting for GitHub.',status:'Merge queued. Waiting for GitHub to confirm the outcome. Refresh to see the result.'}]) +test(`shows the job's own attempt, ${variant.state}, when the click is answered with a job that ended queued (F6b)`,async({page})=>{ + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + app=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-0000000000e9'; + // Shapes as runner/merge-job.ts (mergeJobView) and runner/merge.ts (#queueStatus, #attemptBlocker) send them. + const queued=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e1',mergeActionId,state:'queued',step:null}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Required check failed.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:state==='merged'||state==='removed'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:state==='removed'}); + const attemptBlocker={merged:[{code:'queue-merged',message:'GitHub confirmed that the reviewed head was merged.'}],queued:[{code:'queue-active',message:'The reviewed head is queued. Waiting for GitHub to confirm the outcome.'}]}[variant.state]??[]; + const polls:string[]=[]; + // Another client's poll saw the job end queued, and its attempt reach `variant.state`, before this click's review reload + // read them: the click's answer carries both. + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:queue(variant.state),job:queued,blockers:[...attemptBlocker,...review.merge.blockers,...jobResolvedBlockers]}),job:queued})});}); + // A later poll sees GitHub merge an attempt that was still queued. + await page.route('**/api/merge',async route=>{const next={queue:queue(polls.length===0&&variant.state==='queued'?'queued':'merged'),job:queued};polls.push(`${next.job.state}/${next.queue.state}`); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(next)});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,{ready:true,action:'merge',queue:null,job:null,blockers:[...body.merge.blockers,...jobResolvedBlockers]});await route.fulfill({response,json:body});}); + await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + const clicked=page.waitForResponse(response=>response.url().endsWith('/api/action')); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + // The interleaving: the click's answer already holds the ended job and its attempt in `variant.state`; this page had polled nothing. + const answer=await (await clicked).json(); + expect(answer.job).toMatchObject({state:'queued',mergeActionId}); + expect(answer.merge.queue).toMatchObject({state:variant.state,actionId:mergeActionId}); + expect(polls).toEqual([]); + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); + await expect(page.locator('#merge')).toHaveAttribute('aria-disabled','true'); + await page.locator('#merge-details').click(); + await expect(page.locator('dialog h2')).toHaveText('Merge status'); + await expect(page.locator('dialog li')).toHaveText([variant.status]); + await page.keyboard.press('Escape'); + if(variant.state==='queued'){ + // The attempt is still in GitHub's queue: the queue poll follows it until GitHub merges it. + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + expect(polls.slice(0,2)).toEqual(['queued/queued','queued/merged']); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); + }else{ + // An ended attempt is not polled (the poll interval is 2 s), so its outcome stays without a Refresh. + await page.waitForTimeout(3_000); + expect(polls).toEqual([]); + await expect(page.locator('#merge')).toHaveText(variant.label); + await expect(page.locator('#banner')).toHaveText(variant.banner); + } +}); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts new file mode 100644 index 00000000..b6d9f28b --- /dev/null +++ b/test/merge-job.test.ts @@ -0,0 +1,1446 @@ +import { randomUUID } from 'node:crypto'; +import { afterEach, expect, it, vi } from 'vitest'; +import { MAX_MERGE_JOB_TOKEN_LENGTH, Store } from '../runner/store.ts'; +import { ActionIdReused, GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; +import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; +import { JOB_RESOLVES, MergeJobCoordinator, REVIEW_CHANGED_AFTER_CLICK, SETTLED_BEFORE_JOB_MERGE, withMergeJob, type MergeJobView } from '../runner/merge-job.ts'; +import type { PreMergeCoordinator, PreMergeResult } from '../runner/pre-merge.ts'; +import type { ReviewService } from '../runner/review.ts'; +import { MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type RemoteMergeState, type RequiredCheck } from '../github/merge.ts'; +import { openingMarker } from '../github/pull-requests.ts'; +import type { Plan, PlanContext } from '../core/plan.ts'; +import { commandDigest } from '../runner/checks.ts'; + +// F6b of #22: the one-click merge job (docs/implementation/merge-handoff.md). +const oid = (n: number) => n.toString(16).padStart(40, '0'); +const identity = { repositoryId: 'repo', taskId: 'Task_42', planId: 'plan' }; +const plan: Plan = { schema_version: 1, issue: 12, revision: 1, summary: 'Stop the crash', questions: [], items: [ + { id: 'P1', title: 'Guard input', intent: 'Reject empty input', files: [{ path: 'a.ts', kind: 'edit', renamed_from: null, change: 'Check it' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [] }] }; +const context: PlanContext = { identity, issue: 12, baseEntries: [{ path: 'a.ts', kind: 'file' }], pathKey: p => p, allowedCommands: [] }; +const BRANCH = 'codeboost/issue-12-task-42-0123456789abcdef'; +const PUBLISHED: PublishedTarget = { repository: 'owner/repo', baseBranch: 'main', requiresPreparation: true }; +const TIMING = { checkWaitMs: 2_000, firstDelayMs: 5, maxDelayMs: 20 }; +const stores: Store[] = [], jobs: MergeJobCoordinator[] = []; +afterEach(async () => { for (const job of jobs.splice(0)) await job.close(); for (const store of stores.splice(0)) store.close(); }); + +/** A task in review whose runner published PR 7 with head oid(2) over oid(1). */ +function publishedTask(): Store { + const store = new Store(':memory:'); stores.push(store); + store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + const opening = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: BRANCH, headSha: oid(2), draft: false }); + expect(store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: false }, true)).toBe('in review'); + return store; +} +function service(store: Store) { + const view = () => { + const snapshot = store.getSnapshot(identity); + return { + items: [{ id: 'P1', state: 'approved', outside: [], acceptance: [{ type: 'check', text: 'Works' }], checks: { tests: '– No tests defined' } }], + plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: snapshot.id, base: snapshot.base, head: snapshot.head }, token: 'review-token', + expected: { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, + }; + }; + // Asynchronous, as since #170 (the review load awaits a history worker). + return { store, config: { identity }, view, load: vi.fn(async () => view()), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService & { view: () => unknown }; +} +/** Preparation as F5/F6a end it: `ready` records exact readiness on the job's own prepare-merge action. */ +function preparation(store: Store, result: (call: number) => PreMergeResult['state'] | Error = () => 'ready') { + const calls: string[] = []; + const fake = { + assertStartable: () => undefined, + start: vi.fn(async (expected: { actionId: string }) => { + calls.push(expected.actionId); + const outcome = result(calls.length), snapshot = store.getSnapshot(identity); + if (outcome instanceof Error) throw outcome; + const readiness = { stateVersion: store.getTask(identity).stateVersion, reviewVersion: store.reviewVersion(identity), + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: commandDigest([]) }; + const settled = { state: outcome, base: snapshot.base, head: snapshot.head, checked: [], reason: outcome === 'ready' ? null : `preparation ${outcome}` }; + return store.settlePreMergeAction(identity, expected.actionId, settled, outcome === 'ready' ? readiness : null); + }), + }; + return { fake: fake as unknown as PreMergeCoordinator, calls }; +} +/** GitHub showing PR 7 as published; `checks(n)` gives the required checks of the n-th inspection. */ +function github(store: Store, options: { checks?: (n: number) => RequiredCheck[]; change?: (n: number) => Partial; + merge?: (signal?: AbortSignal) => Promise<{ url: string }>; queue?: boolean; unreadable?: (n: number) => boolean; + during?: (n: number) => void } = {}) { + let inspections = 0; + const merged: string[] = []; + const client: MergeGateway = { + inspect: vi.fn(async request => { + inspections++; + if (options.unreadable?.(inspections)) throw new Error('GitHub returned HTTP 502'); + // Something that happens locally while GitHub's answer is on its way. + options.during?.(inspections); + const record = store.taskPullRequests(identity).find(pr => pr.number === 7)!; + const snapshot = store.getSnapshot(identity); + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, + mergeQueue: options.queue === true, requiredChecks: options.checks?.(inspections) ?? [], alreadyFixed: 'clear', pullRequest: 7, draft: false, + ...(request?.target?.headBranch ? { published: { headBranch: request.target.headBranch, baseBranch: 'main', crossRepository: false, + marker: openingMarker(record.openingId), branchOpen: [{ number: 7, base: 'main' }] } } : {}), + ...options.change?.(inspections) } as RemoteMergeState; + }), + merge: vi.fn(async (head, request) => { merged.push(head); return options.merge ? options.merge(request?.signal) : { url: 'https://github.com/owner/repo/pull/7' }; }), + ...(options.queue ? { + queueWatermark: vi.fn(async () => 'CURSOR'), + inspectQueue: vi.fn(async (head: string) => ({ state: 'queued' as const, reviewedHead: head, entryId: 'E', phase: 'QUEUED' as const, position: 0, + enqueuedAt: '2026-10-11T00:00:00Z', queueHead: head })), + } satisfies MergeQueueGateway : {}), + }; + return { client, merged, inspections: () => inspections }; +} +function setup(options: Parameters[1] & { prepare?: Parameters[1]; timing?: typeof TIMING } = {}) { + const store = publishedTask(), review = service(store), gh = github(store, options); + const merges = new MergeCoordinator(review, gh.client, undefined, undefined, PUBLISHED); + const prep = preparation(store, options.prepare); + const coordinator = new MergeJobCoordinator(review, prep.fake, merges, undefined, options.timing ?? TIMING); jobs.push(coordinator); + return { store, review, gh, merges, prep, coordinator }; +} +const settled = async (coordinator: MergeJobCoordinator) => { await coordinator.current; return coordinator.latest()!; }; +/** GitHub's merge command, held once asked until released; `started` resolves when the guarded merge asks. */ +function holdGatewayMerge() { + let release!: () => void, started!: () => void, calls = 0; + const gate = new Promise(resolve => { release = resolve; }); + const asked = new Promise(resolve => { started = resolve; }); + const merge = async () => { calls++; started(); await gate; return { url: 'https://github.com/owner/repo/pull/7' }; }; + return { merge, release, started: asked, calls: () => calls }; +} +/** Records how the guarded merge the job calls settled: fulfilled, or the error it rejected with. */ +function guardedMergeOutcome(merges: MergeCoordinator) { + const merge = merges.merge.bind(merges); + let settledAs: { fulfilled: true } | { rejected: unknown } | null = null; + merges.merge = (async (...args: Parameters) => { + try { const result = await merge(...args); settledAs = { fulfilled: true }; return result; } + catch (error) { settledAs = { rejected: error }; throw error; } + }) as typeof merge; + return () => settledAs; +} +/** Holds the next review load until released or failed; a load whose signal aborts rejects with the abort's reason. */ +function holdNextLoad(review: ReviewService & { view: () => unknown }) { + let release!: () => void, fail!: (error: Error) => void, started!: (signal?: AbortSignal) => void; + const gate = new Promise((resolve, reject) => { release = resolve; fail = reject; }); + const loading = new Promise(resolve => { started = resolve; }); + (review.load as unknown as ReturnType).mockImplementationOnce(async (options?: { signal?: AbortSignal }) => { + started(options?.signal); + await new Promise((resolve, reject) => { + gate.then(resolve, reject); + options?.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true }); + }); + return review.view(); + }); + return { release, fail, loading }; +} +const pending = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'pending' }); +const passing = (context = 'ci'): RequiredCheck => ({ context, appId: null, state: 'success' }); + +it('merges at once when the required set was read and is empty', async () => { + const f = setup(); + const started = await f.coordinator.start('review-token', randomUUID()); + expect(started).toMatchObject({ state: 'running', step: 'preparing' }); + expect(started).not.toHaveProperty('token'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged', step: null, url: 'https://github.com/owner/repo/pull/7' }); + expect(f.gh.merged).toEqual([oid(2)]); + expect(f.prep.calls).toHaveLength(1); + expect(f.store.getTask(identity).status).toBe('merged'); +}); + +it('waits while a required check is pending, then merges once it passes', async () => { + const seen: string[] = []; + const f = setup({ checks: n => (n <= 2 ? [pending()] : [passing()]) }); + const job = await f.coordinator.start('review-token', randomUUID()); + const poll = setInterval(() => { const latest = f.coordinator.latest(); if (latest?.checks.length) seen.push(`${latest.step}:${latest.checks[0]!.state}`); }, 1); + const done = await settled(f.coordinator); clearInterval(poll); + expect(done).toMatchObject({ id: job.id, state: 'merged' }); + expect(seen).toContain('checks:pending'); + expect(f.gh.merged).toEqual([oid(2)]); +}); + +it('clears the pending checks it listed when it moves to merging', async () => { + let passes = false, release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const f = setup({ checks: () => [passes ? passing() : pending()], + merge: async () => { await held; return { url: 'https://github.com/owner/repo/pull/7' }; } }); + const job = await f.coordinator.start('review-token', randomUUID()); + try { + // The saved record lists the pending check while the job waits on it. + await vi.waitFor(() => expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] })); + // The check passes; the guarded merge is held, so the saved record is read at the merging step. + passes = true; + await vi.waitFor(() => expect(f.gh.merged).toEqual([oid(2)])); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'merging', checks: [] }); + } finally { release(); } + expect(await settled(f.coordinator)).toMatchObject({ id: job.id, state: 'merged', checks: [] }); +}); + +it('ends approved, merge blocked when required checks do not pass in time', async () => { + const f = setup({ checks: () => [pending('build'), { context: 'deploy', appId: null, state: 'missing' }] }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(done.state).toBe('blocked'); + expect(done.reason).toMatch(/did not pass within .*build is pending, deploy is missing/); + // Nothing failed, so the next step is to wait, not to fix anything (merge-handoff.md, Outcomes). + expect(done.reason).toMatch(/deploy is missing\. Wait for the checks to pass, then Retry merge\.$/); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('stops at once when a required check fails, without waiting for the time limit', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build. Fix it, then Retry merge.' }); + expect(f.gh.inspections()).toBe(1); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('names every failing required check, and the plural next step, when several fail', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }, pending('lint'), { context: 'deploy', appId: null, state: 'failure' }] }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: 'Required checks failed: build, deploy. Fix them, then Retry merge.' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('ends failed, without merging, when GitHub cannot be read while waiting for checks', async () => { + const f = setup({ checks: () => [pending()], unreadable: n => n === 2 }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/HTTP 502/) }); + expect(f.gh.inspections()).toBe(2); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends failed, leaving the task in review, when GitHub refuses because the head changed', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('Head branch was modified. Review and try the merge again.', 'refused'); } }); + await f.coordinator.start('review-token', randomUUID()); + // The page adds "Merge again when ready." to a failed job's reason, so the reason never says it too. + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/^GitHub refused the merge: Head branch was modified.*Review the new head first\.$/) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', requiresFreshReview: true }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends failed, leaving the task in review, when the review changed while GitHub was asked to merge and refused', async () => { + let storeRef: Store | null = null; + const f = setup({ merge: async () => { + // A person adds a change request while the merge command is on its way; then GitHub refuses. + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the merge.'); + throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + } }); + storeRef = f.store; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed' }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +// Defensive pin. Production cannot reach this for a merge job: jobs exist only with a runner, setUpRunner sets +// review.runnerRepository before the server listens, and ReviewService.load then reads the recorded snapshot head, not +// HEAD, so the HEAD observation that records this snapshot never runs. The test keeps blockMergeJob's snapshot term +// honest should another snapshot writer become reachable during a merge. +it('(defensive) ends failed, leaving the task in review, when a new snapshot was recorded while GitHub was asked to merge and refused', async () => { + let storeRef: Store | null = null, before = ''; + const f = setup({ merge: async () => { + // Records a new snapshot with no ledger entries, as the HEAD observation of a server without a runner does. + const store = storeRef!, snapshot = store.getSnapshot(identity); + // As that load does, only for a HEAD that moved from the snapshot's (the user's checkout moved on). + store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, oid(1), oid(3), []); + throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + } }); + storeRef = f.store; before = f.store.getSnapshot(identity).id; + expect(f.store.getSnapshot(identity).head).toBe(oid(2)); + const reviewVersion = f.store.reviewVersion(identity); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); + // Only the snapshot changed: the plan revision and review version are those the attempt was admitted for. + expect(f.store.getSnapshot(identity)).toMatchObject({ head: oid(3) }); + expect(f.store.getSnapshot(identity).id).not.toBe(before); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed', revision: 1, reviewVersion, snapshotId: before }); + expect(f.store.reviewVersion(identity)).toBe(reviewVersion); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends failed, leaving the task in review, when a queue poll removes the attempt and a plan revision lands while GitHub is asked to merge', async () => { + // GitHub's merge command is held, so the queue poll and the plan import run while it is on its way: in one process this + // is the only order (the guarded merge saves its outcome, then the job reads it, with no I/O in between). + const asked = holdGatewayMerge(); + const f = setup({ queue: true, merge: asked.merge }); + const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; + inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => + ({ state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' })); + const outcome = guardedMergeOutcome(f.merges); + const reviewVersion = f.store.reviewVersion(identity), snapshotId = f.store.getSnapshot(identity).id; + await f.coordinator.start('review-token', randomUUID()); + await asked.started; + // While the attempt is in flight, plan writes are refused. + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'submitting' }); + expect(() => f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1)).toThrow(/A merge is in progress/); + await f.merges.pollQueue(); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'removed' }); + // The removed attempt no longer holds plan writes back, so a plan import lands before GitHub answers. + f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); + expect(f.store.getPlan(identity).revision).toBe(2); + expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'merging' }); + asked.release(); + const done = await settled(f.coordinator); + // The interleaving happened: GitHub accepted the command, but the guarded merge could not save it queued and rejected + // with the removal, so the job ended through its error path. + expect(asked.calls()).toBe(1); + expect(outcome()).toMatchObject({ rejected: expect.objectContaining({ message: 'The merge queue removed it: checks failed.' }) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state: 'removed', revision: 1, snapshotId }); + expect(f.store.reviewVersion(identity)).toBe(reviewVersion); + expect(f.store.getSnapshot(identity).id).toBe(snapshotId); + expect(done).toMatchObject({ state: 'failed', reason: expect.stringMatching(/review changed while GitHub was asked to merge; review it first\.$/) }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends failed, leaving the task in review, when a failing check arrives after the review changed', async () => { + let storeRef: Store | null = null; + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], during: () => { + // A person adds a change request while the job's inspection is reading GitHub. + const store = storeRef!, snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the wait.'); + } }); + storeRef = f.store; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed while waiting for required checks; the preparation no longer applies\.$/) }); + expect(f.store.getTask(identity).status).toBe('in review'); + expect(f.gh.merged).toEqual([]); +}); + +it('waits while GitHub is still computing mergeability', async () => { + const f = setup({ change: n => (n <= 2 ? { mergeable: 'UNKNOWN' } : {}) }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); +}); + +it('ends for review or as failed when preparation does, and merges nothing', async () => { + for (const [outcome, state] of [['review-required', 'review-required'], ['failed', 'failed']] as const) { + const f = setup({ prepare: () => outcome }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state, reason: `preparation ${outcome}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + } +}); + +it('ends as failed, for a person, when another blocker appears while waiting', async () => { + const f = setup({ change: () => ({ base: oid(30) }) }); + await f.coordinator.start('review-token', randomUUID()); + // Merge PR rebases and prepares itself, and this screen offers no Rebase: the reason says what merging again does. + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', + reason: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }); + expect(f.gh.merged).toEqual([]); +}); + +it('ends approved, merge blocked when GitHub refuses the merge', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); } }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: .*expected\. Fix it, then Retry merge\.$/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('ends submitted when GitHub\'s merge outcome is unknown; the attempt stays in flight', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('connection reset', 'unknown'); } }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'submitted' }); + expect(f.store.getMergeAttempt(identity)?.state).toBe('submitting'); +}); + +it('stops waiting at once when the task is cancelled', async () => { + // The first wait between inspections is far longer than this test may wait: only the job being told can end it. + const f = setup({ checks: () => [pending()], timing: { checkWaitMs: 600_000, firstDelayMs: 300_000, maxDelayMs: 300_000 } }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.checks).toEqual([{ context: 'ci', state: 'pending' }])); + const inspected = f.gh.inspections(); + // As the cancel route does: the task's cancel commits first, then the job is told. + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + f.coordinator.cancel(); + const late = new Promise<'still waiting'>(resolve => setTimeout(() => resolve('still waiting'), 500).unref()); + expect(await Promise.race([settled(f.coordinator), late])).toMatchObject({ state: 'cancelled' }); + expect(f.gh.inspections()).toBe(inspected); + expect(f.gh.merged).toEqual([]); +}); + +it('keeps the readiness preparation recorded valid while the job records its progress', async () => { + const versions: number[] = []; + const f = setup({ checks: n => { versions.push(f.store.getTask(identity).stateVersion); return n <= 3 ? [pending()] : [passing()]; } }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); + // Every inspection, from the one after preparation through the guarded merge's own, read the same task version: + // the job's three progress writes between them never advanced the task, so the guarded merge admitted that readiness. + expect(versions.length).toBeGreaterThanOrEqual(4); + expect(new Set(versions).size).toBe(1); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'merged' }); +}); + +it('replays a resent click, refuses a second click while a job runs, and replays a refusal for a stale token', async () => { + const f = setup({ checks: () => [pending()] }); + const id = randomUUID(), stale = randomUUID(); + await expect(f.coordinator.start('stale-token', stale)).rejects.toThrow(/Stale review state/); + expect(f.store.getMergeJob(identity, stale)).toMatchObject({ state: 'failed', refused: true }); + await expect(f.coordinator.start('stale-token', stale)).rejects.toThrow(/Stale review state/); + const first = await f.coordinator.start('review-token', id); + expect(await f.coordinator.start('review-token', id)).toMatchObject({ id: first.id, state: 'running' }); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + const second = randomUUID(); + await expect(f.coordinator.start('review-token', second)).rejects.toThrow(/already running/); + expect(f.store.getMergeJob(identity, second)).toMatchObject({ refused: true }); + // The saved refusal is that click's alone: the running job stays the task's job, and Merge PR stays withheld. + expect(f.coordinator.latest()).toMatchObject({ id: first.id, state: 'running', step: 'checks' }); + const resolvable = { ready: false, action: null, blockers: [{ code: 'check', message: 'pending' }], remote: { mergeable: 'MERGEABLE' }, queue: null } as Parameters[0]; + expect(withMergeJob(resolvable, f.coordinator.latest(), 'in review')).toMatchObject({ ready: false, action: null }); + await expect(f.coordinator.start('other-token', id)).rejects.toThrow(/already used/); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + f.coordinator.cancel(); await settled(f.coordinator); + expect(f.prep.calls).toHaveLength(1); +}); + +it('resolves close only once the job has settled, so shutdown can await its last write', async () => { + const f = setup(); + // Preparation that finishes its own work after the shutdown abort (as a push or command check settling does). + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const prepared = f.prep.fake.start.bind(f.prep.fake); + (f.prep.fake as unknown as { start: typeof prepared }).start = async expected => { await held; return prepared(expected); }; + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'preparing' })); + let closed = false; + const closing = f.coordinator.close().then(() => { closed = true; }); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(closed).toBe(false); + release(); await closing; + expect(f.coordinator.active).toBe(false); + expect(f.gh.merged).toEqual([]); +}); + +it('leaves a job running on shutdown, and startup ends it as failed', async () => { + const f = setup({ checks: () => [pending()] }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + await f.coordinator.close(); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.coordinator.latest()).toMatchObject({ state: 'failed', reason: 'The server restarted before the merge finished.' }); + // Not a definite refusal: the route answers 503 and the browser keeps the click's key (nothing was saved). + const id = randomUUID(); + await expect(f.coordinator.start('review-token', id)).rejects.toThrow(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it('ends queued when the merge queue accepted the head', async () => { + const f = setup({ queue: true }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'queued' }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ kind: 'queue', state: 'queued' }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it.each([false, true])('ends submitted, not merged or queued, when GitHub accepted the merge but saving its outcome failed (queue: %s)', async queue => { + const f = setup({ queue }); + // MergeCoordinator resolves after GitHub accepted the command even when saving the outcome fails (test/merge.test.ts). + const fail = vi.fn(() => { throw new Error('local refresh failed'); }); + if (queue) f.store.queueMergeAttempt = fail; else f.store.finishMergeAttempt = fail; + await f.coordinator.start('review-token', randomUUID()); + const job = await settled(f.coordinator); + expect(fail).toHaveBeenCalledTimes(1); + expect(f.gh.merged).toEqual([oid(2)]); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: job.mergeActionId, state: 'submitting' }); + expect(job).toMatchObject({ state: 'submitted', reason: expect.stringContaining('reconciles from GitHub') }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it.each(['finish', 'block'] as const)('reports a job it could not end (%s), which stays running and refuses a new click (saved, so a resend replays it) until startup settles it', async kind => { + const f = setup(kind === 'block' ? { checks: () => [{ context: 'build', appId: null, state: 'failure' }] } : {}); + const fail = vi.fn(() => { throw new Error('disk full'); }); + const { blockMergeJob, finishMergeJob } = f.store; + // The finish case ends `submitted` (its attempt's outcome was not saved either), so the task stays in review and a new + // click reaches the running-job check. + if (kind === 'block') f.store.blockMergeJob = fail; + else { f.store.finishMergeJob = fail; f.store.finishMergeAttempt = () => { throw new Error('local refresh failed'); }; } + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const started = await f.coordinator.start('review-token', randomUUID()); + await f.coordinator.current; + expect(fail).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "disk full"`); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + // The coordinator has no job in memory; the store's running record refuses the click, and that refusal is saved. + const refused = randomUUID(); + expect(f.coordinator.active).toBe(false); + await expect(f.coordinator.start('review-token', refused)).rejects.toThrow('A merge is already running for this task.'); + expect(f.store.getMergeJob(identity, refused)).toMatchObject({ state: 'failed', refused: true, reason: 'A merge is already running for this task.' }); + // The next start settles it (runner/production.ts), once the store writes again. + Object.assign(f.store, { blockMergeJob, finishMergeJob }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: kind === 'block' ? 'failed' : 'submitted' }); + // A resend of the refused click replays its saved refusal (stale now, for that click only) and starts nothing. + const loads = (f.review.load as unknown as ReturnType).mock.calls.length; + await expect(f.coordinator.start('review-token', refused)).rejects.toThrow('A merge is already running for this task.'); + expect((f.review.load as unknown as ReturnType).mock.calls.length).toBe(loads); + expect(f.coordinator.active).toBe(false); + } finally { logged.mockRestore(); } +}); + +it('keeps a job running and reports it when even reading the task fails after the job failed', async () => { + const f = setup(); + const admission = holdNextLoad(f.review), run = holdNextLoad(f.review); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const getTask = f.store.getTask.bind(f.store); + try { + const starting = f.coordinator.start('review-token', randomUUID()); + await admission.loading; admission.release(); + const started = await starting; + await run.loading; + f.store.getTask = () => { throw new Error('database is locked'); }; + run.fail(new Error('History worker crashed.')); + // The job's promise settles without rejecting, so `close()` and the server's `.finally` (whose callback catches its own + // errors) see no rejection. + await expect(f.coordinator.current).resolves.toBeUndefined(); + f.store.getTask = getTask; + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "database is locked"`); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + } finally { f.store.getTask = getTask; logged.mockRestore(); } +}); + +it('leaves the task approved, merge blocked when a retried job is queued', async () => { + const f = setup({ queue: true, checks: n => (n === 1 ? [{ context: 'build', appId: null, state: 'failure' }] : []) }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'queued' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('refuses a click while the runner or publisher is busy, and replays that refusal once it is idle', async () => { + const f = setup(), id = randomUUID(); + await expect(f.coordinator.start('review-token', id, () => 'The runner is busy.')).rejects.toThrow('The runner is busy.'); + // The response was lost; the same click is resent after the runner became idle: it replays, starting nothing. + await expect(f.coordinator.start('review-token', id)).rejects.toThrow('The runner is busy.'); + await expect(f.coordinator.start('other-token', id)).rejects.toThrow(/already used/); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ state: 'failed', refused: true, reason: 'The runner is busy.' }); + expect(f.coordinator.latest()).toBeNull(); + expect(f.coordinator.active).toBe(false); + expect(f.prep.calls).toEqual([]); +}); + +it('saves nothing for a click refused because the server is shutting down, so it may be sent again', async () => { + const f = setup(), id = randomUUID(); + f.coordinator.stop(); + await expect(f.coordinator.start('review-token', id)).rejects.toThrow(/shutting down/); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it('ends cancelled when the task was cancelled while the job waited, even if the job was not told', async () => { + const f = setup({ checks: () => [pending()] }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); +}); + +it('ends cancelled when the task is cancelled during the guarded merge\'s validation', async () => { + let cancelAt = Number.POSITIVE_INFINITY, coordinatorRef: MergeJobCoordinator | null = null, storeRef: Store | null = null; + const f = setup({ change: n => { + if (n === cancelAt) { storeRef!.cancelTask(identity, storeRef!.getTask(identity).stateVersion, randomUUID()); coordinatorRef!.cancel(); } + return {}; + } }); + coordinatorRef = f.coordinator; storeRef = f.store; + // Inspection 1 is the wait; inspections 2+ are the guarded merge's validation passes. + cancelAt = 2; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); + expect(f.gh.merged).toEqual([]); +}); + +it('stops a guarded merge in flight on shutdown, so shutdown does not wait for GitHub', async () => { + const f = setup({ merge: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); + const closing = Date.now(); + await f.coordinator.close(); + expect(Date.now() - closing).toBeLessThan(2_000); + // GitHub was already asked, so its outcome is unknown: the job ends submitted and the attempt stays in flight. + expect(f.coordinator.latest()).toMatchObject({ state: 'submitted' }); + expect(f.store.getMergeAttempt(identity)?.state).toBe('submitting'); +}); + +it('offers Merge PR while only blockers the job resolves remain, and a retry after a blocked job', () => { + const remote = { mergeable: 'MERGEABLE' }; + const status = (codes: string[], extra: Partial<{ queue: { state: string } | null; remote: { mergeable: string } | null }> = {}) => + ({ ready: false, action: null, blockers: codes.map(code => ({ code, message: code })), remote, queue: null, ...extra }) as Parameters[0]; + const running = { state: 'running' } as MergeJobView, blocked = { state: 'blocked' } as MergeJobView; + expect(withMergeJob(status(['preparation', 'base', 'acceptance', 'check']), null, 'in review')).toMatchObject({ ready: true, action: 'merge', job: null }); + expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'UNKNOWN' } }), null, 'in review')).toMatchObject({ ready: true }); + expect(withMergeJob(status(['mergeable']), null, 'in review')).toMatchObject({ ready: false, action: null }); + // The blockers the job resolves read as what merging does: no Rebase or Prepare control is on the screen. + expect(withMergeJob(status(['base', 'preparation', 'head']), null, 'in review').blockers).toEqual([ + { code: 'base', message: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }, + { code: 'preparation', message: 'The merge has not been prepared for the current review; merging prepares it.' }, + { code: 'head', message: 'head' }]); + for (const code of ['approval', 'scope', 'unplanned', 'head', 'already-fixed', 'rules', 'base-guard', 'task']) + expect(withMergeJob(status(['preparation', code]), null, 'in review')).toMatchObject({ ready: false, action: null }); + expect(withMergeJob(status([]), running, 'in review')).toMatchObject({ ready: false, job: running }); + expect(withMergeJob(status([], { queue: { state: 'submitting' } }), null, 'in review')).toMatchObject({ ready: false }); + expect(withMergeJob(status([], { remote: null }), null, 'in review')).toMatchObject({ ready: false }); + expect(withMergeJob(status(['preparation']), blocked, 'approved but merge blocked')).toMatchObject({ ready: true, action: 'retry', job: blocked }); + // The task's status, read after the inspection, wins: a task cancelled or merged meanwhile is never offered Merge PR. + for (const closed of ['cancelled', 'merged', 'running']) + expect(withMergeJob(status([]), null, closed)).toMatchObject({ ready: false, action: null }); + // A conflict after the base moved is the job's rebase to resolve; a conflict on its own is a person's. + expect(withMergeJob(status(['base', 'mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: true }); + expect(withMergeJob(status(['mergeable'], { remote: { mergeable: 'CONFLICTING' } }), null, 'in review')).toMatchObject({ ready: false }); +}); + +it('sends the blockers a job settles before it merges: the ones it resolves, plus mergeability', () => { + const status = (codes: string[], mergeable = 'MERGEABLE') => + ({ ready: false, action: null, blockers: codes.map(code => ({ code, message: code })), remote: { mergeable }, queue: null }) as Parameters[0]; + const field = withMergeJob(status([]), null, 'in review').settledBeforeJobMerge; + // Pinned to the job's own set: a code added to or removed from JOB_RESOLVES, or from the field, fails here. + expect(field).toEqual(['preparation', 'base', 'acceptance', 'check', 'mergeable']); + expect(field).toEqual([...JOB_RESOLVES, 'mergeable']); + expect(SETTLED_BEFORE_JOB_MERGE).toBe(field); + // Every code but mergeability is one Merge PR is offered over, because the job resolves it. + for (const code of field.filter(code => code !== 'mergeable')) + expect(withMergeJob(status([code]), null, 'in review')).toMatchObject({ ready: true, settledBeforeJobMerge: field }); +}); + +it('ends as failed, not blocked, when checks fail on a head that moved while waiting', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }], change: () => ({ head: oid(40) }) }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: expect.stringMatching(/head moved/) }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('asks GitHub to merge nothing once stopped, even if the checks pass meanwhile', async () => { + let passing = false; + const f = setup({ checks: () => [passing ? { context: 'ci', appId: null, state: 'success' } : pending()] }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()?.step).toBe('checks')); + f.coordinator.stop(); passing = true; + await f.coordinator.current; + expect(f.gh.merged).toEqual([]); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); +}); + +it('asks GitHub to merge nothing when shutdown stopped the job before the guarded merge was entered', async () => { + const f = setup(); + // The step-3 review load (the job's step is `merging`) finishes even though shutdown aborted its signal, so the job + // reaches the guarded merge with a signal that is already aborted. + let release!: () => void, loaded!: () => void; + const gate = new Promise(resolve => { release = resolve; }), loading = new Promise(resolve => { loaded = resolve; }); + const load = f.review.load as unknown as ReturnType, view = load.getMockImplementation() as (...args: unknown[]) => unknown; + load.mockImplementation(async (...args: unknown[]) => { + if (f.coordinator.latest()?.step === 'merging') { loaded(); await gate; } + return view(...args); + }); + const entered: boolean[] = [], merge = f.merges.merge.bind(f.merges); + f.merges.merge = ((token, actionId, caller) => { entered.push(caller?.aborted ?? false); return merge(token, actionId, caller); }) as typeof f.merges.merge; + const started = await f.coordinator.start('review-token', randomUUID()); + await loading; + f.coordinator.stop(); + release(); + await f.coordinator.current; + expect(entered).toEqual([true]); // The guarded merge was entered, with the job's signal already aborted. + expect(f.gh.merged).toEqual([]); + expect(f.store.getMergeAttempt(identity)).toBeNull(); // No attempt was begun. + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running', step: 'merging' }); +}); + +it('ends blocked when GitHub refuses the merge while shutdown stops the job', async () => { + let coordinatorRef: MergeJobCoordinator | null = null; + const f = setup({ merge: async () => { coordinatorRef!.stop(); throw new MergeSubmissionError('Base branch was modified.', 'refused'); } }); + coordinatorRef = f.coordinator; + await f.coordinator.start('review-token', randomUUID()); + await f.coordinator.current; + expect(f.coordinator.latest()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it.each(['submitted', 'blocked'] as const)('reports a job it could not end during shutdown (%s), after GitHub was asked to merge', async kind => { + // Shutdown's first step (stop) has run, but GitHub was already asked, so the job still ends through its ending write. + let coordinatorRef: MergeJobCoordinator | null = null; + const f = setup({ merge: kind === 'blocked' + ? async () => { coordinatorRef!.stop(); throw new MergeSubmissionError('Base branch was modified.', 'refused'); } + : signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + coordinatorRef = f.coordinator; + const fail = vi.fn(() => { throw new Error('disk full'); }); + const { blockMergeJob, finishMergeJob } = f.store; + if (kind === 'blocked') f.store.blockMergeJob = fail; else f.store.finishMergeJob = fail; + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const started = await f.coordinator.start('review-token', randomUUID()); + if (kind === 'submitted') { await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); await f.coordinator.close(); } + else await f.coordinator.current; + expect(fail).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith(`Could not end merge job ${started.id}; it shows as running until the server restarts: "disk full"`); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running' }); + } finally { Object.assign(f.store, { blockMergeJob, finishMergeJob }); logged.mockRestore(); } +}); + +it('settles a job a crash left in its merge step from its merge attempt at startup', async () => { + for (const [settle, expected, status, outcome] of [ + [() => undefined, 'submitted', 'in review', { reason: 'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.', url: null }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'merged', url: 'https://github.com/owner/repo/pull/7' }), 'merged', 'merged', + { reason: null, url: 'https://github.com/owner/repo/pull/7' }], + [(store: Store, id: string) => store.queueMergeAttempt(identity, id, 'https://github.com/owner/repo/pull/7'), 'queued', 'in review', + { reason: null, url: 'https://github.com/owner/repo/pull/7' }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Head branch was modified.', requiresFreshReview: true }), 'failed', 'in review', + { reason: 'GitHub refused the merge: Head branch was modified. Review the new head first.', url: null }], + // GitHub refused while a person changed the review: the refusal says nothing about the changed review. + [(store: Store, id: string) => { + const snapshot = store.getSnapshot(identity); + store.addReviewNote(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, 'P1', 'change', 'Changed during the merge.'); + store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }); + }, 'failed', 'in review', + { reason: 'GitHub refused the merge: Base branch was modified. The review changed while GitHub was asked to merge; review it first.', url: null }], + [(store: Store, id: string) => store.finishMergeAttempt(identity, id, { state: 'failed', reason: 'Base branch was modified.' }), 'blocked', 'approved but merge blocked', + { reason: 'GitHub refused the merge: Base branch was modified. Fix it, then Retry merge.', url: null }], + ] as const) { + // GitHub never answers: the job is in its merge step when the process "crashes". + const f = setup({ merge: signal => new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })) }); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.gh.merged).toHaveLength(1)); + const job = f.store.runningMergeJob(identity)!, attempt = f.store.getMergeAttempt(identity)!; + expect(job).toMatchObject({ step: 'merging', mergeActionId: attempt.actionId }); + settle(f.store, attempt.id); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: expected, ...outcome }); + expect(f.store.getTask(identity).status).toBe(status); + // A blocked job's reason ends with its next step; a failed one's never says "merge again": the page adds that once. + const reason = f.store.getMergeJob(identity, job.id)!.reason ?? ''; + if (expected === 'blocked') expect(reason).toBe('GitHub refused the merge: Base branch was modified. Fix it, then Retry merge.'); + if (expected === 'failed') expect(reason).not.toMatch(/merge again/i); + } +}); + +it('refuses a merge job for a closed task in the store itself', () => { + const store = publishedTask(); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(() => store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' })) + .toThrow(new GuardRefusal('The task is cancelled; it cannot be merged.')); +}); + +it.each([['an empty', ''], ['a non-string', 42]] as const)('refuses a resend with %s token under a saved click as a reused ID', async (_kind, token) => { + const f = setup(), id = randomUUID(); + await f.coordinator.start('review-token', id, () => 'The runner is busy.').catch(() => undefined); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ refused: true }); + await expect(f.coordinator.start(token, id)).rejects.toThrow(ActionIdReused); +}); + +it('returns a saved refusal to a resend of the same click and refuses its ID for another request', () => { + const store = publishedTask(), id = randomUUID(); + const saved = store.recordMergeJobRefusal(identity, { actionId: id, token: 'review-token', reason: 'The runner is busy.' }); + expect(store.recordMergeJobRefusal(identity, { actionId: id, token: 'review-token', reason: 'Something else.' })).toEqual(saved); + expect(() => store.recordMergeJobRefusal(identity, { actionId: id, token: 'other-token', reason: 'The runner is busy.' })).toThrow(ActionIdReused); +}); + +it('refuses a click ID saved under another plan as a reused ID, even with the same token', () => { + const store = publishedTask(), id = randomUUID(); + const other = { ...identity, taskId: 'Task_43', planId: 'other' }; + store.createPlan(JSON.stringify(plan), 'json', { ...context, identity: other }, oid(1), oid(2)); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + expect(() => store.beginMergeJob(other, { actionId: id, token: 'review-token' })).toThrow(ActionIdReused); + expect(() => store.recordMergeJobRefusal(other, { actionId: id, token: 'review-token', reason: 'The runner is busy.' })).toThrow(ActionIdReused); + expect(store.getMergeJob(other, id)).toBeNull(); + expect(store.latestMergeJob(other)).toBeNull(); + expect(store.getMergeJob(identity, id)).toMatchObject({ state: 'running' }); +}); + +it('leaves a job that is no longer running unchanged when asked to update it', () => { + const store = publishedTask(), id = randomUUID(); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + expect(store.finishMergeJob(identity, id, { state: 'failed', reason: 'The pull request moved.' })).toBe(true); + const ended = store.getMergeJob(identity, id)!; + expect(store.updateMergeJob(identity, id, { step: 'merging', checks: [{ context: 'ci', state: 'pending' }] })).toBeNull(); + // Neither the step, the checks nor the revision moved: a late update cannot revive or rewrite an ended job. + expect(store.getMergeJob(identity, id)).toEqual(ended); +}); + +it('blocks the job and the task in one write, leaving a task that moved meanwhile as it is', () => { + const store = publishedTask(), first = randomUUID(), second = randomUUID(); + store.beginMergeJob(identity, { actionId: first, token: 'review-token' }); + const before = store.getTask(identity).stateVersion; + expect(store.blockMergeJob(identity, first, 'Required check failed: ci.')).toBe(true); + // The status change advances the task state version, so the next merge prepares again from the start. + expect(store.getTask(identity).stateVersion).toBe(before + 1); + expect(store.getMergeJob(identity, first)).toMatchObject({ state: 'blocked', reason: 'Required check failed: ci.' }); + expect(store.getTask(identity).status).toBe('approved but merge blocked'); + store.beginMergeJob(identity, { actionId: second, token: 'review-token' }); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('closed'); + expect(store.blockMergeJob(identity, second, 'late')).toBe(true); + expect(store.getTask(identity).status).toBe('cancelled'); +}); + +it('rolls the task back when ending a blocked job fails, and leaves the task alone for a job that already ended', () => { + const store = publishedTask(), id = randomUUID(); + store.beginMergeJob(identity, { actionId: id, token: 'review-token' }); + const finish = store.finishMergeJob.bind(store); + store.finishMergeJob = () => { throw new Error('disk full'); }; + expect(() => store.blockMergeJob(identity, id, 'Required check failed: ci.')).toThrow('disk full'); + // One transaction: the task did not move without its job. + expect(store.getTask(identity).status).toBe('in review'); + store.finishMergeJob = finish; + expect(store.finishMergeJob(identity, id, { state: 'failed', reason: 'The pull request moved.' })).toBe(true); + const before = store.getTask(identity).stateVersion; + expect(store.blockMergeJob(identity, id, 'late')).toBe(false); + expect(store.getTask(identity)).toMatchObject({ status: 'in review', stateVersion: before }); + expect(store.getMergeJob(identity, id)).toMatchObject({ state: 'failed' }); +}); + +it('decides a retried job only from its own merge attempt, never from an earlier refused one', async () => { + // The first job's merge is refused: its attempt stays on record as failed. + let refuse = true, conflictFrom = Number.POSITIVE_INFINITY; + const f = setup({ merge: async () => { if (refuse) throw new MergeSubmissionError('Base branch was modified.', 'refused'); return { url: 'u' }; }, + change: n => (n >= conflictFrom ? { mergeable: 'CONFLICTING' } : {}) }); + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'blocked' }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'failed' }); + // Retry: the guarded merge's second validation pass now sees a conflict, so it refuses before asking GitHub. + refuse = false; conflictFrom = f.gh.inspections() + 3; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: 'Merge requirements changed during validation. The pull request has merge conflicts.' }); + // At startup too: a job that saved its merge action ID but never got an attempt fails, whatever older attempts say. + const crashed = randomUUID(); + f.store.beginMergeJob(identity, { actionId: crashed, token: 'review-token' }); + f.store.updateMergeJob(identity, crashed, { step: 'merging', mergeActionId: randomUUID() }); + f.store.settleInterruptedMergeJobs(identity); + expect(f.store.getMergeJob(identity, crashed)).toMatchObject({ state: 'failed', reason: 'The server restarted before the merge finished.' }); +}); + +it('refuses, saves and replays a click on a task that is not mergeable or is being cancelled', async () => { + const f = setup(), queued = randomUUID(); + f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'queued'); + await expect(f.coordinator.start('review-token', queued)).rejects.toThrow('The task is queued; merge it from review.'); + expect(f.store.getMergeJob(identity, queued)).toMatchObject({ refused: true }); + f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'in review'); + // A resend replays the refusal, even though the task is in review again. + await expect(f.coordinator.start('review-token', queued)).rejects.toThrow('The task is queued; merge it from review.'); + // A cancel that waits for a push of the rewritten head to settle keeps the task in review, cancel requested. + const snapshot = f.store.getSnapshot(identity), reviewed = { revision: 1, snapshotId: snapshot.id, reviewVersion: f.store.reviewVersion(identity) }; + f.store.recordRebase(identity, reviewed, oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + const after = f.store.getSnapshot(identity); + f.store.beginPrePush(identity, { revision: 1, snapshotId: after.id, reviewVersion: f.store.reviewVersion(identity) }, f.store.getTask(identity).stateVersion, + { branch: BRANCH, from: oid(2), to: oid(12) }); + expect(f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + const cancelling = randomUUID(); + await expect(f.coordinator.start('review-token', cancelling)).rejects.toThrow('The task is being cancelled.'); + expect(f.store.getMergeJob(identity, cancelling)).toMatchObject({ refused: true }); + expect(f.prep.calls).toEqual([]); +}); + +it('answers a preparation stopping for shutdown as not applied, saving nothing', async () => { + const f = setup(), id = randomUUID(); + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('The server is shutting down.'); }; + await expect(f.coordinator.start('review-token', id)).rejects.toThrow(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + expect(f.prep.calls).toEqual([]); +}); + +it('refuses a click while a preparation is already running, and replays that refusal', async () => { + const f = setup(), id = randomUUID(); + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; + await expect(f.coordinator.start('review-token', id)).rejects.toThrow('Pre-merge preparation is already running.'); + expect(f.store.getMergeJob(identity, id)).toMatchObject({ refused: true }); + expect(f.prep.calls).toEqual([]); +}); + +it('starts no inspection after the wait limit and stops one that runs into it', async () => { + let inspections = 0, hang = false; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + inspections++; + // From the second inspection on, GitHub does not answer; only the wait's limit (or a cancel) ends the read. + if (hang && request?.signal) await new Promise((_resolve, reject) => request.signal!.addEventListener('abort', () => reject(request.signal!.reason), { once: true })); + return inspect(request); + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 400, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + await coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(coordinator.latest()?.step).toBe('checks')); + hang = true; + const started = Date.now(); + await coordinator.current; + expect(Date.now() - started).toBeLessThan(3_000); + expect(coordinator.latest()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/did not pass within/) }); + const after = inspections; + await new Promise(resolve => setTimeout(resolve, 100)); + expect(inspections).toBe(after); + expect(f.gh.merged).toEqual([]); +}); + +it('ends blocked, waiting for GitHub, when mergeability is still being computed at the wait limit', async () => { + // No required checks wait: only GitHub's mergeability is unknown at every inspection. + const f = setup({ change: () => ({ mergeable: 'UNKNOWN' }), timing: { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 } }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(f.gh.inspections()).toBeGreaterThan(1); + // A 300 ms limit rounds to 0 minutes in the reason. + expect(done).toMatchObject({ state: 'blocked', checks: [], + reason: 'Required checks did not pass within 0 minutes: GitHub has not determined mergeability. Wait for GitHub to determine mergeability, then Retry merge.' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + expect(f.gh.merged).toEqual([]); +}); + +it('ends blocked, waiting for the checks, when no inspection answers before the wait limit', async () => { + let inspections = 0; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + inspections++; + // GitHub never answers; only the wait's limit ends the read. + await new Promise((_resolve, reject) => request!.signal!.addEventListener('abort', () => reject(request!.signal!.reason), { once: true })); + return inspect(request); + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + expect(inspections).toBe(1); + expect(done).toMatchObject({ state: 'blocked', checks: [], + reason: 'Required checks did not pass within 0 minutes: no inspection of them finished in time. Wait for the checks to pass, then Retry merge.' }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + expect(f.gh.merged).toEqual([]); +}); + +it('keeps a GitHub failure that came before the wait limit, even when the inspection settles after it', async () => { + let inspections = 0, abortedFirst: unknown = null; + const f = setup({ checks: () => [pending()] }); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + if (++inspections < 2) return inspect(request); + // A stage fails at once; its stopped peers settle only after the wait's limit, and the gateway then reports the + // stage's own error (github/merge.ts inspect), not the abort. + const failure = new Error('gh: HTTP 502 from the rules read'); + await new Promise(resolve => request!.signal!.addEventListener('abort', resolve, { once: true })); + abortedFirst = request!.signal!.reason; + throw failure; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 300, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + // The interleaving happened: the wait's limit (a timeout, not a cancel) fired before the failed inspection settled. + expect((abortedFirst as Error | null)?.name).toBe('TimeoutError'); + expect(done).toMatchObject({ state: 'failed', reason: 'gh: HTTP 502 from the rules read' }); + expect(f.store.getTask(identity).status).toBe('in review'); + expect(f.gh.merged).toEqual([]); +}); + +it('numbers each job and refused click after the task\'s earlier ones, so the browser can order jobs without clocks', async () => { + const f = setup({ prepare: () => 'failed' }); + const first = await f.coordinator.start('review-token', randomUUID()); + await settled(f.coordinator); + const refusal = f.store.recordMergeJobRefusal(identity, { actionId: randomUUID(), token: 'review-token', reason: 'Refused.' }); + // A later click keeps a higher number even when the clock stepped back. + const second = f.store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token', now: '2000-01-01T00:00:00.000Z' }).job; + expect([first.sequence, refusal.sequence, second.sequence]).toEqual([1, 2, 3]); + expect(f.coordinator.latest()).toMatchObject({ id: second.id, sequence: 3 }); + f.store.finishMergeJob(identity, second.id, { state: 'failed' }); +}); + +it('makes every saved change to a job a later revision, even when the clock ties or steps back', () => { + const store = publishedTask(); + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const at = (time: string) => vi.setSystemTime(new Date(time)); + at('2026-10-11T00:00:00.000Z'); + const refusal = store.recordMergeJobRefusal(identity, { actionId: randomUUID(), token: 'review-token', reason: 'Refused.' }); + expect(refusal.revision).toBe(1); + const id = randomUUID(), records = [store.beginMergeJob(identity, { actionId: id, token: 'review-token' }).job]; + // Same clock tick as admission, then the same tick again, then a clock step back, then the block that ends it. + records.push(store.updateMergeJob(identity, id, { step: 'checks' })!); + records.push(store.updateMergeJob(identity, id, { checks: [{ context: 'ci', state: 'pending' }] })!); + at('2026-10-10T23:00:00.000Z'); + records.push(store.updateMergeJob(identity, id, { mergeActionId: randomUUID() })!); + at('2026-10-10T22:00:00.000Z'); + expect(store.blockMergeJob(identity, id, 'Required check failed: ci.')).toBe(true); + records.push(store.getMergeJob(identity, id)!); + // The interleaving happened: update times tied and then stepped back. + expect(records.map(job => job.updatedAt)).toEqual(['2026-10-11T00:00:00.000Z', '2026-10-11T00:00:00.000Z', '2026-10-11T00:00:00.000Z', + '2026-10-10T23:00:00.000Z', '2026-10-10T22:00:00.000Z']); + expect(records.map(job => job.revision)).toEqual([1, 2, 3, 4, 5]); + expect(records.at(-1)!.state).toBe('blocked'); + // Startup settlement and finishing are saved changes too. + const next = randomUUID(); + const begun = store.beginMergeJob(identity, { actionId: next, token: 'review-token' }).job; + store.settleInterruptedMergeJobs(identity); + const settledJob = store.getMergeJob(identity, next)!; + expect(settledJob.updatedAt).toBe(begun.updatedAt); + expect([begun.revision, settledJob.revision]).toEqual([1, 2]); + // An ended job is left unchanged: no save, no new revision. + expect(store.finishMergeJob(identity, next, { state: 'failed' })).toBe(false); + expect(store.getMergeJob(identity, next)!.revision).toBe(2); + } finally { vi.useRealTimers(); } +}); + +// Since #170 a click's admission awaits the review load; these pin what may happen while it does. +it('gives a resend of the same click, during the first send\'s review load, the same job', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const first = f.coordinator.start('review-token', id); + await held.loading; + const second = await f.coordinator.start('review-token', id); // Its own load is not held: it admits the job. + held.release(); + const firstAnswer = await first; + expect(firstAnswer.id).toBe(second.id); + expect(f.store.getMergeJob(identity, id)?.refused).toBeFalsy(); // Not saved as an "already running" refusal. + await settled(f.coordinator); +}); + +it('answers the resent click\'s job when the first send\'s admission load fails after the resend was admitted', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const first = f.coordinator.start('review-token', id); + await held.loading; + const second = await f.coordinator.start('review-token', id); // Its own load is not held: it admits the job. + expect(f.store.getMergeJob(identity, id)).not.toBeNull(); + held.fail(new Error('History work exceeded its overall deadline.')); + expect((await first).id).toBe(second.id); + await settled(f.coordinator); +}); + +it('answers the resent click\'s job when shutdown stops the first send\'s admission load after the resend was admitted', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never), request = new AbortController(); + const first = f.coordinator.start('review-token', id, () => null, request.signal); + await held.loading; + const second = await f.coordinator.start('review-token', id); + f.coordinator.stop(); + request.abort(new Error('Request cancelled during shutdown.')); + expect((await first).id).toBe(second.id); +}); + +it('admits nothing and saves nothing when shutdown starts during the admission load', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + f.coordinator.stop(); + held.release(); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + expect(f.coordinator.current).toBeNull(); +}); + +it('answers an admission load stopped by shutdown as not applied, saving nothing', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never), request = new AbortController(); + const starting = f.coordinator.start('review-token', id, () => null, request.signal); + await held.loading; + request.abort(new Error('Request cancelled during shutdown.')); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it('saves nothing when the admission load fails for another reason, so the click may be sent again', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + held.fail(new Error('History work exceeded its overall deadline.')); + await expect(starting).rejects.toThrow('History work exceeded its overall deadline.'); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + // The same click, sent again, starts the job. + expect(await f.coordinator.start('review-token', id)).toMatchObject({ state: 'running' }); + await settled(f.coordinator); +}); + +it('ends the job cancelled when the task is cancelled during the job\'s own review load', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load (its first step) is held. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + await f.coordinator.start('review-token', id); + await held.loading; + // As the cancel route does: the task's cancel commits, then the job is told. + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); + f.coordinator.cancel(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); + expect(f.gh.merged).toEqual([]); +}); +it('doubles the wait between required-check inspections from the first delay up to the maximum', async () => { + // Required checks stay pending long enough for the wait to reach its maximum; only the sleeps between inspections count. + const f = setup({ checks: n => (n <= 6 ? [pending()] : [passing()]) }); + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 3, maxDelayMs: 20 }); jobs.push(coordinator); + const waits: number[] = []; + const timer = setTimeout, spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number, ...args: unknown[]) => { + // Record the wait only while the job is between inspections of its required checks. + if (coordinator.latest()?.step === 'checks') waits.push(ms ?? 0); + return timer(handler, ms, ...args); + }) as typeof setTimeout); + try { + await coordinator.start('review-token', randomUUID()); + expect(await settled(coordinator)).toMatchObject({ state: 'merged' }); + } finally { spy.mockRestore(); } + expect(waits).toEqual([3, 6, 12, 20, 20, 20]); +}); + +it('waits for required checks with the production timing when constructed without one, as the server does', async () => { + // Required checks never pass. The clock and timers are fake, so the 30-minute wait runs at once. + const f = setup({ checks: () => [pending()] }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const waits: number[] = []; + try { + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges); jobs.push(coordinator); + const timer = setTimeout, spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number, ...args: unknown[]) => { + if (coordinator.latest()?.step === 'checks') waits.push(ms ?? 0); + return timer(handler, ms, ...args); + }) as typeof setTimeout); + try { + await coordinator.start('review-token', randomUUID()); + for (let turn = 0; coordinator.active && turn < 1_000; turn++) { + await new Promise(resolve => setImmediate(resolve)); + vi.advanceTimersToNextTimer(); + } + expect(await settled(coordinator)).toMatchObject({ state: 'blocked', + reason: 'Required checks did not pass within 30 minutes: ci is pending. Wait for the checks to pass, then Retry merge.' }); + } finally { spy.mockRestore(); } + } finally { vi.useRealTimers(); } + // 10 s first, doubling to the 60 s maximum, and the last wait cut to what is left of the 30 minutes. + expect(waits.slice(0, 5)).toEqual([10_000, 20_000, 40_000, 60_000, 60_000]); + expect(waits.reduce((sum, ms) => sum + ms, 0)).toBe(30 * 60_000); + expect(waits).toEqual([10_000, 20_000, 40_000, ...Array(28).fill(60_000), 50_000]); +}); + +it('refuses a merge job timing that is not a whole, non-negative number of milliseconds', () => { + const f = setup(); + for (const timing of [{ checkWaitMs: -1 }, { firstDelayMs: 1.5 }, { maxDelayMs: Number.NaN }, { checkWaitMs: Number.POSITIVE_INFINITY }]) + expect(() => new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, timing)).toThrow('Invalid merge job timing.'); + expect(() => new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 0, firstDelayMs: 0, maxDelayMs: 0 })).not.toThrow(); +}); + +it('words the guarded merge\'s refusal as what merging does when the base moves after the job\'s last ready inspection', async () => { + // Inspection 1 is the job's wait (ready); a push to the base lands before inspection 2, the guarded merge's first. + const f = setup({ change: n => (n >= 2 ? { base: oid(5) } : {}) }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: the wait's one inspection was ready, and the guarded merge's first (the moved base) refused. + expect(f.gh.inspections()).toBe(2); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', + reason: 'The base branch moved; merging rebases onto it and prepares again, so the result may need review.' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + // The direct path keeps the guarded merge's own wording. + await expect(f.merges.merge('review-token', randomUUID())).rejects.toThrow('The base branch moved. Rebase and review the resulting snapshot.'); +}); + +/** + * Records a new snapshot (head oid(3), no ledger entries) through Store.recordHistory, as runner/review.ts's HEAD + * observation does on a server without a runner. That advances the task's state version, so the job's preparation no + * longer applies; GitHub's head is unchanged. The fake review service's token does not change, so the guarded merge's + * reloads pass and only the later passes' readiness check sees the change. + * + * Not a production path for a merge job: a runner server sets review.runnerRepository before it listens, so its review + * load reads the recorded snapshot head and never records a HEAD observation. The tests that use this are defensive. + */ +function observeLocalHead(store: Store): void { + const snapshot = store.getSnapshot(identity); + store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, snapshot.base, oid(3), []); +} +const UNPREPARED_IN_JOB = 'The merge has not been prepared for the current review; merging prepares it.'; + +for (const { pass, at, prefix } of [ + { pass: 'the queue-correlation check', at: 3, prefix: 'Merge requirements changed after queue correlation. ' }, + { pass: 'the queue-boundary check', at: 4, prefix: 'Merge requirements changed after queue correlation. ' }, +] as const) { + // Defensive pin (see observeLocalHead): in job mode production cannot change the state version during the guarded + // merge, so this later pass's job wording is unreachable there; the test keeps the wording right if that changes. + it(`(defensive) words the guarded merge's refusal at ${pass} as what merging does when preparation no longer applies`, async () => { + // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the queue-correlation check, + // 5 the queue-boundary check. The observation lands while inspection `at` reads GitHub; the next one refuses. + const observed: number[] = []; + let storeRef: Store | null = null; + const f = setup({ queue: true, change: () => ({ head: oid(2) }), + during: n => { if (n === at) { observeLocalHead(storeRef!); observed.push(n); } } }); + storeRef = f.store; + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: the inspection after the observation was the guarded merge's last. + expect(observed).toEqual([at]); + expect(f.gh.inspections()).toBe(at + 1); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', reason: `${prefix}${UNPREPARED_IN_JOB}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); + }); +} + +// Defensive pin (see observeLocalHead): unreachable in production for a merge job, kept for the wording. +it('(defensive) words the guarded merge\'s refusal at the authorization check as what merging does when preparation no longer applies', async () => { + // Inspections: 1 the job's wait, 2 and 3 the guarded merge's validation passes, 4 the check after authorization. + const calls: string[] = []; + const f = setup({ change: () => ({ head: oid(2) }) }); + f.merges.setAuthorization(async () => ({ + // The observation lands while the issue's trust is read again from GitHub. + refresh: async () => { calls.push(`refresh after ${f.gh.inspections()}`); observeLocalHead(f.store); }, + validate: () => { calls.push('validate'); } })); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + // The interleaving happened: both validation passes were ready, and the inspection after the refresh refused. + expect(calls).toEqual(['refresh after 3']); + expect(f.gh.inspections()).toBe(4); + expect(f.store.getMergeAttempt(identity)).toBeNull(); + expect(done).toMatchObject({ state: 'failed', reason: `Merge requirements changed during authorization validation. ${UNPREPARED_IN_JOB}` }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('keeps the unsettled push in the job\'s wording of the preparation blocker', async () => { + const store = publishedTask(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A preparation rebased and began pushing; the push's outcome is not known yet (as test/merge-task-pr.test.ts sets up). + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.beginPrePush(identity, reviewed(), store.getTask(identity).stateVersion, { branch: BRANCH, from: oid(2), to: oid(12) }); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + const status = await merges.status(); + expect(status.blockers.filter(blocker => blocker.code === 'preparation')).toEqual([{ code: 'preparation', + message: 'A push of the rewritten head has not settled. Prepare the merge again to read its outcome.' }]); + expect(withMergeJob(status as Parameters[0], null, 'in review').blockers.filter(blocker => blocker.code === 'preparation')).toEqual([{ code: 'preparation', + message: 'A push of the rewritten head has not settled; merging reads its outcome and prepares again.' }]); +}); + +it('ends the job failed, preparing nothing, when the review changes between the click and the job\'s first review load', async () => { + const f = setup(), id = randomUUID(); + // The token covers the plan, as the real review's does: a plan import from another tab makes a new token. + const view = f.review.view; + f.review.view = () => ({ ...(view() as object), token: f.store.getPlan(identity).revision === 1 ? 'review-token' : 'review-token-2' }); + // The admission's load passes; the job's own first load is held. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => f.review.view()); + const held = holdNextLoad(f.review), actions = vi.spyOn(f.store, 'userAction'); + expect(await f.coordinator.start('review-token', id)).toMatchObject({ state: 'running', step: 'preparing' }); + await held.loading; + f.store.importRevision(JSON.stringify({ ...plan, revision: 2 }), 'json', context, 1); + // The interleaving happened: the click was admitted for the old review, and the review changed while the job's first + // load was pending, before it prepared anything. + expect(f.store.getPlan(identity).revision).toBe(2); + expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'preparing' }); + expect(f.prep.calls).toEqual([]); + held.release(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'failed', reason: REVIEW_CHANGED_AFTER_CLICK }); + expect(f.prep.calls).toEqual([]); + expect(actions.mock.calls.filter(([, action]) => action.kind === 'prepare-merge')).toEqual([]); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends the job cancelled when its own review load rejects only after the task was cancelled and shutdown began', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load is held, and rejects only when the test says so (not on abort). + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + let reject!: (error: unknown) => void, started!: (signal?: AbortSignal) => void; + const loading = new Promise(resolve => { started = resolve; }); + (f.review.load as unknown as ReturnType).mockImplementationOnce((options?: { signal?: AbortSignal }) => { + started(options?.signal); + return new Promise((_resolve, fail) => { reject = fail; }); + }); + await f.coordinator.start('review-token', id); + const signal = (await loading)!; + // As the cancel route does: the task's cancel commits, then the job is told. Then shutdown begins. + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); + f.coordinator.cancel(); + f.coordinator.stop(); + // The interleaving happened: the cancel aborted the job first (shutdown's abort came second and changed nothing), + // and the load is still pending when shutdown has begun. + expect((signal.reason as { code?: string }).code).toBe('CANCELLED'); + expect(f.coordinator.latest()).toMatchObject({ state: 'running' }); + reject(signal.reason); + await f.coordinator.close(); + expect(f.coordinator.latest()).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); + expect(f.gh.merged).toEqual([]); +}); + +it.each(['removed', 'failed'] as const)('ends the job from its attempt when a queue poll records %s while GitHub is asked to merge', async state => { + // GitHub's merge command is held, so the queue poll runs while it is on its way: in one process this is the only order + // (the guarded merge saves its outcome, then the job reads it, with no I/O in between). + const asked = holdGatewayMerge(); + const f = setup({ queue: true, merge: asked.merge }); + const inspectQueue = f.gh.client as unknown as { inspectQueue: ReturnType }; + inspectQueue.inspectQueue.mockImplementationOnce(async (head: string) => state === 'removed' + ? { state: 'removed', reviewedHead: head, removedAt: '2026-10-11T00:01:00Z', reason: 'The merge queue removed it: checks failed.' } + : { state: 'failed', reviewedHead: head, entryId: 'E', reason: 'The merge queue failed it: checks failed.' }); + const outcome = guardedMergeOutcome(f.merges); + await f.coordinator.start('review-token', randomUUID()); + await asked.started; + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state: 'submitting' }); + await f.merges.pollQueue(); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ state }); + asked.release(); + const done = await settled(f.coordinator); + // The interleaving happened: GitHub accepted the command, but the guarded merge could not save it queued and rejected + // with the poll's reason, so the job ended through its error path. + expect(outcome()).toMatchObject({ rejected: expect.objectContaining({ message: expect.stringMatching(/^The merge queue .* it: checks failed\.$/) }) }); + expect(f.store.getMergeAttempt(identity)).toMatchObject({ actionId: done.mergeActionId, state, requiresFreshReview: false }); + expect(done).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge: The merge queue .* it: checks failed\. Fix it, then Retry merge\.$/) }); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('times out, without merging, when a ready inspection finishes after the wait limit before its timer runs', async () => { + const f = setup({ checks: n => (n === 1 ? [pending()] : [passing()]) }); + let offset = 0, limitAbortedAtReady: boolean | null = null; + const now = performance.now.bind(performance); + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now() + offset); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + const status = await inspect(request); + // The second inspection (all checks passing) ends after the limit: the clock crosses it, but the limit's timer has + // not run (it counts real time, far from the limit). + if ((inspect as ReturnType).mock.calls.length === 2) { offset += 120_000; limitAbortedAtReady = request?.signal?.aborted ?? null; } + return status; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + try { + await coordinator.start('review-token', randomUUID()); + const done = await settled(coordinator); + // The interleaving happened: the ready inspection's signal had not been stopped by the limit when it answered. + expect(limitAbortedAtReady).toBe(false); + // GitHub reported the checks passing, so neither the reason nor the record names the earlier pending check. + expect(done).toMatchObject({ state: 'blocked', reason: 'Required checks passed only after the 1-minute limit. Retry merge.', checks: [] }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); + } finally { clock.mockRestore(); } +}); + +it('keeps a failing check found by an inspection that finishes after the wait limit', async () => { + const f = setup({ checks: n => (n === 1 ? [pending()] : [{ context: 'build', appId: null, state: 'failure' }]) }); + let offset = 0; + const now = performance.now.bind(performance); + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now() + offset); + const inspect = f.gh.client.inspect; + f.gh.client.inspect = (async (request: Parameters[0]) => { + const status = await inspect(request); + if ((inspect as ReturnType).mock.calls.length === 2) offset += 120_000; + return status; + }) as typeof inspect; + const coordinator = new MergeJobCoordinator(f.review, f.prep.fake, f.merges, undefined, { checkWaitMs: 60_000, firstDelayMs: 5, maxDelayMs: 5 }); jobs.push(coordinator); + try { + await coordinator.start('review-token', randomUUID()); + expect(await settled(coordinator)).toMatchObject({ state: 'blocked', reason: 'Required check failed: build. Fix it, then Retry merge.' }); + } finally { clock.mockRestore(); } +}); + +it('leaves the job running, with no ending write, when shutdown stops the job\'s own first review load', async () => { + const f = setup(), id = randomUUID(); + // The admission's load passes; the job's own first load is held and rejects on the shutdown abort, as a stopped load does. + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + const finish = vi.spyOn(f.store, 'finishMergeJob'); + const started = await f.coordinator.start('review-token', id); + const signal = (await held.loading)!; + f.coordinator.stop(); + expect(signal.aborted).toBe(true); // The load was pending when shutdown aborted it. + await f.coordinator.current; + expect(finish).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, started.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); + expect(f.prep.calls).toEqual([]); +}); + +it.each(['rejects', 'resolves failed'] as const)('leaves the job running, with no ending write, when its preparation %s after shutdown stopped it', async ending => { + const f = setup(); + // The preparation is held until shutdown aborts it, then rejects or resolves `failed` with shutdown's reason. + let started!: () => void; + const preparing = new Promise(resolve => { started = resolve; }); + const stopped: { end?: () => void } = {}; + (f.prep.fake as unknown as { start: (expected: { actionId: string }) => Promise }).start = expected => { + f.prep.calls.push(expected.actionId); started(); + return new Promise((resolve, reject) => { + stopped.end = () => { + const snapshot = f.store.getSnapshot(identity); + if (ending === 'rejects') reject(new Error('Server shutdown.')); + else resolve({ state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Server shutdown.' }); + }; + }); + }; + const finish = vi.spyOn(f.store, 'finishMergeJob'), update = vi.spyOn(f.store, 'updateMergeJob'); + const job = await f.coordinator.start('review-token', randomUUID()); + await preparing; + f.coordinator.stop(); + stopped.end!(); + await f.coordinator.current; + expect(f.prep.calls).toHaveLength(1); + expect(finish).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); + expect(f.gh.merged).toEqual([]); +}); + +it('starts no preparation when shutdown began while the job\'s first review load was finishing and the load still resolved', async () => { + const f = setup(); + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + // The job's first load ignores the abort and resolves when released (a load past its last abort check). + let release!: () => void, started!: (signal?: AbortSignal) => void; + const loading = new Promise(resolve => { started = resolve; }); + (f.review.load as unknown as ReturnType).mockImplementationOnce((options?: { signal?: AbortSignal }) => { + started(options?.signal); + return new Promise(resolve => { release = () => resolve((f.review as never as { view: () => unknown }).view()); }); + }); + const userAction = vi.spyOn(f.store, 'userAction'), finish = vi.spyOn(f.store, 'finishMergeJob'); + const job = await f.coordinator.start('review-token', randomUUID()); + const signal = (await loading)!; + f.coordinator.stop(); + // The interleaving happened: the signal was aborted while the load was still pending, and the load then resolved. + expect(signal.aborted).toBe(true); + release(); + await f.coordinator.current; + expect(f.prep.calls).toEqual([]); + expect(userAction.mock.calls.filter(([, action]) => (action as { kind: string }).kind === 'prepare-merge')).toEqual([]); + expect(finish).not.toHaveBeenCalled(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running', step: 'preparing', reason: null }); +}); + +it('answers shutdown, not the load\'s own error, when the admission load fails for another reason after shutdown began', async () => { + const f = setup(), id = randomUUID(), held = holdNextLoad(f.review as never); + const starting = f.coordinator.start('review-token', id); + await held.loading; + f.coordinator.stop(); + // No request signal: only the coordinator's own shutdown flag says the load failed during shutdown. + held.fail(new Error('History work exceeded its overall deadline.')); + await expect(starting).rejects.toBeInstanceOf(ShuttingDownError); + expect(f.store.getMergeJob(identity, id)).toBeNull(); +}); + +it.each([['an empty', ''], ['a non-string', 42], ['a too long', 'a'.repeat(MAX_MERGE_JOB_TOKEN_LENGTH + 1)]] as const)('refuses a click with %s token as stale, saving nothing, and refuses its resend the same way', async (_kind, token) => { + const f = setup(), id = randomUUID(); + for (let send = 0; send < 2; send++) { + const refused = await f.coordinator.start(token, id).then(() => null, (error: unknown) => error); + expect(refused).toBeInstanceOf(GuardRefusal); + expect((refused as Error).message).toBe('Stale review state. Refresh before merging.'); + // A refusal is saved under the click's token, so a click without a usable one has nothing to save it under. + expect(f.store.getMergeJob(identity, id)).toBeNull(); + } + expect(f.prep.calls).toEqual([]); +}); + +it('refuses a click without a usable token while a job runs as already running, saving nothing', async () => { + const f = setup({ checks: () => [pending()] }), id = randomUUID(); + await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.coordinator.latest()).toMatchObject({ state: 'running', step: 'checks' })); + const refused = await f.coordinator.start('', id).then(() => null, (error: unknown) => error); + expect(refused).toBeInstanceOf(GuardRefusal); + expect((refused as Error).message).toBe('A merge is already running for this task.'); + expect(f.store.getMergeJob(identity, id)).toBeNull(); + f.coordinator.cancel(); + await f.coordinator.current; +}); + +it('saves no prepare-merge action when preparation can no longer start once the job\'s first review load resolves', async () => { + // Defensive at the coordinator's boundary (see #run): the server refuses a standalone preparation while a job runs and + // stops the job before preparation at shutdown, so the fake's startability changes directly. + const f = setup(), id = randomUUID(); + (f.review.load as unknown as ReturnType).mockImplementationOnce(async () => (f.review as never as { view: () => unknown }).view()); + const held = holdNextLoad(f.review as never); + const userAction = vi.spyOn(f.store, 'userAction'); + await f.coordinator.start('review-token', id); + await held.loading; + (f.prep.fake as unknown as { assertStartable: () => void }).assertStartable = () => { throw new GuardRefusal('Pre-merge preparation is already running.'); }; + held.release(); + expect(await settled(f.coordinator)).toMatchObject({ id, state: 'failed', reason: 'Pre-merge preparation is already running.' }); + expect(userAction.mock.calls.filter(([, action]) => (action as { kind: string }).kind === 'prepare-merge')).toEqual([]); + expect(f.prep.calls).toEqual([]); + expect(f.gh.merged).toEqual([]); +}); + +it('keeps the guarded merge\'s Retry merge for a task still in review', () => { + const status = { ready: true, action: 'retry', blockers: [{ code: 'preparation', message: 'preparation' }], remote: { mergeable: 'MERGEABLE' }, queue: null } as Parameters[0]; + expect(withMergeJob(status, null, 'in review')).toMatchObject({ ready: true, action: 'retry' }); + expect(withMergeJob({ ...status, action: 'merge' }, null, 'in review')).toMatchObject({ ready: true, action: 'merge' }); +}); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index a5a13690..1c94d169 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; +import { DatabaseSync } from 'node:sqlite'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { MergeCoordinator, type PublishedTarget } from '../runner/merge.ts'; import type { ReviewService } from '../runner/review.ts'; @@ -30,8 +31,8 @@ const stores: Store[] = [], roots: string[] = []; afterEach(() => { for (const store of stores.splice(0)) store.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** A running task with head `oid(2)` over `oid(1)`, its last attempt settled. */ -function runningTask(): Store { - const store = new Store(':memory:'); stores.push(store); +function runningTask(path = ':memory:'): Store { + const store = new Store(path); stores.push(store); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); @@ -47,8 +48,8 @@ const opened = (store: Store, openingId: string, number: number, mayReview = tru const toReview = (store: Store) => store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); /** The runner published PR 7 for the task, which is now in review. */ -function published() { - const store = runningTask(); +function published(path?: string) { + const store = runningTask(path); const opening = begin(store); expect(opened(store, opening.openingId, 7)).toBe('in review'); return { store, opening }; @@ -303,7 +304,7 @@ describe('the merge gate with a runner block (#121)', () => { const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); const status = await merges.status(); expect(status.blockers).toEqual([]); - expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH }); + expect(gh.targets[0]).toEqual({ pullRequest: 7, ownPullRequests: [7], headBranch: BRANCH, alreadyFixedSince: oid(9), ownCommits: [oid(2)] }); await merges.merge('review-token'); expect(gh.merged).toEqual([7]); expect(store.getMergeAttempt(identity)).toMatchObject({ pullRequest: 7, state: 'merged' }); @@ -383,6 +384,162 @@ describe('the merge gate with a runner block (#121)', () => { expect(gh.merged).toEqual([]); }); + it('scans for an earlier fix from the tip the first opening\'s pre-PR check scanned through after a rebase, skipping the task\'s own commits', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A pre-merge rebase moved the reviewed base from oid(1) to oid(10) and rewrote the owned head oid(2) as oid(12). + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 7, alreadyFixedSince: oid(9) }); + expect([...gh.targets.at(-1)!.ownCommits!].sort()).toEqual([oid(12), oid(2)].sort()); + }); + + it('does not skip a foreign commit in the task\'s ledger when scanning for an earlier fix', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A base commit the task's history shows (for example one mentioning the issue) is foreign, not the task's own. + store.recordHistory(identity, reviewed(), oid(1), oid(2), [{ sha: oid(5), owner: null, origin: 'foreign', sourceSha: null }]); + expect(store.getLedger(identity).map(entry => [entry.sha, entry.origin])).toEqual([[oid(2), 'owned'], [oid(5), 'foreign']]); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)!.ownCommits).toEqual([oid(2)]); + }); + + it('keeps scanning from the PR\'s own pre-PR check after a re-publish records a newer check from a rebased base', async () => { + const { store } = published(), gh = github(store); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + // A re-publish after an amendment: the runner runs again and records a clear check scanned from the rebased base. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + expect(store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }).snapshotId).toBe(store.getSnapshot(identity).id); + expect(store.getSnapshot(identity).base).toBe(oid(10)); + toReview(store); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(9) }); + }); + + it('scans from the first opening\'s check for a new pull request opened after a rebase', async () => { + const { store } = published(); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // A pre-merge rebase brought base commits after oid(9) under the task. The person then closed PR 7 and published + // again: the new opening's own pre-PR check scanned only from the rebased base (to oid(19)), and a later check could + // not complete. Base commits between oid(9) and the rebased base were scanned by no check. + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); + const later = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: BRANCH, headSha: store.getSnapshot(identity).head, draft: false }); + store.recordPullRequestOpened(identity, later.openingId, { number: 8, url: url(8), headSha: store.getSnapshot(identity).head, draft: false }, false); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); + toReview(store); + const gh = github(store); + const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(9) }); + }); + + it('scans from the task\'s original base for a PR opened before schema v19, whatever later checks found', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); + const path = join(root, 'state.sqlite'); + const { store } = published(path); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + // A later re-publish recorded a clear check scanned from the rebased base, then one that could not complete. A compare + // range excludes its start, so starting at the later clear check's tip could skip the rebased base commit itself. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(19) } }); + store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'unknown', reason: 'GitHub could not be read.' } }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); + // The opening was recorded before v19: it carries no check. + store.close(); stores.splice(stores.indexOf(store), 1); + const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); + const reopened = new Store(path); stores.push(reopened); + expect(reopened.taskPullRequests(identity)[0]!.checkId).toBeNull(); + const gh = github(reopened); + const merges = new MergeCoordinator(service(reopened), gh.client, undefined, undefined, PUBLISHED); + await merges.status(undefined, true); + // No record shows which base branch an older check scanned, so none stands in: the scan covers every base commit. + expect(gh.targets.at(-1)).toMatchObject({ alreadyFixedSince: oid(1) }); + }); + + it('scans from the task\'s original base, not another base branch\'s check, for a PR opened before schema v19', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-v19-')); roots.push(root); + const path = join(root, 'state.sqlite'); + // PR 7 went into main after a clear check that scanned main from the original base oid(1) to oid(9). github.baseBranch + // then became release: the person closed PR 7 and published again, into release, after a check that scanned release + // to oid(29). Both openings were recorded before v19, so neither names its check; main's oid(9) says nothing about + // release. + const { store } = published(path); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: oid(2), draft: false }); + opened(store, release.openingId, 8, false); + toReview(store); + store.close(); stores.splice(stores.indexOf(store), 1); + const raw = new DatabaseSync(path); raw.exec('UPDATE task_pull_requests SET check_id=NULL'); raw.close(); + const reopened = new Store(path); stores.push(reopened); + expect(reopened.taskPullRequests(identity).map(pr => [pr.base, pr.checkId])).toEqual([['main', null], ['release', null]]); + const gh = github(reopened); + await new MergeCoordinator(service(reopened), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(1) }); + }); + + it('scans from the first opening into the current base branch after the base branch setting changed', async () => { + const { store } = published(); + // PR 7 went into main (its check scanned main to oid(9)). github.baseBranch then became release: the person closed + // PR 7 and published again, into release, after a check that scanned release to oid(29). main's tip says nothing + // about release. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: oid(2), draft: false }); + opened(store, release.openingId, 8, false); + toReview(store); + const gh = github(store); + await new MergeCoordinator(service(store), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(29) }); + }); + + it('scans from the task\'s original base when the first opening into the base branch followed a rebase', async () => { + const { store } = published(); + const reviewed = () => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); + // PR 7 went into main from the original base oid(1). A pre-merge rebase moved the task onto main's oid(10), then + // github.baseBranch became release (which contains oid(10)); the first opening into release followed a check that + // scanned release only from oid(10). Base commits between oid(1) and oid(10) were scanned on release by no check. + store.recordRebase(identity, reviewed(), oid(10), oid(12), [{ oldSha: oid(2), newSha: oid(12) }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(29) } }); + const release = store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'release', headBranch: BRANCH, headSha: store.getSnapshot(identity).head, draft: false }); + store.recordPullRequestOpened(identity, release.openingId, { number: 8, url: url(8), headSha: store.getSnapshot(identity).head, draft: false }, false); + toReview(store); + const gh = github(store); + await new MergeCoordinator(service(store), gh.client, undefined, undefined, { ...PUBLISHED, baseBranch: 'release' }).status(undefined, true); + expect(gh.targets.at(-1)).toMatchObject({ pullRequest: 8, alreadyFixedSince: oid(1) }); + }); + it('refuses pre-merge preparation for a pull request that is no longer open', async () => { const { store } = published(); const gh = github(store, () => ({ pullRequestState: 'CLOSED' })); @@ -409,7 +566,7 @@ describe('the merge gate with a runner block (#121)', () => { toReview(store); const gh = github(store); await new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED).status(); - expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH }); + expect(gh.targets[0]).toEqual({ pullRequest: 8, ownPullRequests: [7, 8], headBranch: BRANCH, alreadyFixedSince: oid(9), ownCommits: [oid(2)] }); }); it('refuses when the task\'s PR changes between the validation passes', async () => { diff --git a/test/merge.test.ts b/test/merge.test.ts index 5c7cdb49..ff24cd1b 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -733,6 +733,8 @@ it('requires current-snapshot approvals after an ordinary queue removal and forc await h.coordinator.pollQueue(); h.replaceHead(sha('c')); expect((await h.coordinator.status(h.view())).action).toBeNull(); + expect((await h.coordinator.status(h.view())).blockers).toEqual(expect.arrayContaining([{ code: 'queue-head', + message: 'The pull request changed after the last merge attempt (Checks failed.). Approve every item again for the replacement snapshot, then merge.' }])); h.store.saveReview(h.identity, h.view().expected, [{ item: 'P1', fingerprint: 'replacement-review' }], []); expect((await h.coordinator.status(h.view())).action).toBe('merge'); } finally { await h.coordinator.close(); h.store.close(); } @@ -745,6 +747,9 @@ it('requires current-revision approvals after a same-snapshot plan amendment', a await h.coordinator.pollQueue(); h.amendPlan(); expect((await h.coordinator.status(h.view())).action).toBeNull(); + // The snapshot is unchanged: the message names the plan, not a replacement snapshot. + expect((await h.coordinator.status(h.view())).blockers).toEqual(expect.arrayContaining([{ code: 'queue-head', + message: 'The plan changed after the last merge attempt (Checks failed.). Approve every item again for the new plan revision, then merge.' }])); h.store.saveReview(h.identity, h.view().expected, [{ item: 'P1', fingerprint: 'amended-plan-review' }], []); expect((await h.coordinator.status(h.view())).action).toBe('merge'); } finally { await h.coordinator.close(); h.store.close(); } @@ -935,6 +940,168 @@ it('parses required checks from both rule sources and pins the gh merge head', a expect(pullReads).toBe(2); }); +/** Inspects a pull request whose one required check, `test` from any app, has these observed runs and statuses. */ +async function requiredTestState(statusCheckRollup: unknown[], appId: number | null = null) { + const run = async (args: readonly string[]) => { + const joined = args.join(' '); + if (joined.startsWith('pr view 7')) return JSON.stringify({ number: 7, isDraft: false, baseRefName: 'main', baseRefOid: sha('a'), headRefName: 'feature', headRefOid: sha('b'), state: 'OPEN', mergeable: 'MERGEABLE', statusCheckRollup }); + if (joined.includes('/rules/branches/')) return JSON.stringify([[{ type: 'required_status_checks', parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: 'test', integration_id: appId }] } }]]); + if (/branches\/main$/.test(joined)) return JSON.stringify({ protected: false }); + if (joined.endsWith('/protection')) throw new Error('gh: Branch not protected (HTTP 404)'); + { const fixed = alreadyFixedReads(args); if (fixed !== null) return fixed; } + throw new Error(`Unexpected gh call: ${joined}`); + }; + const state = await new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run).inspect(); + expect(state.rulesKnown).toBe(true); + return state.requiredChecks.map(check => check.state); +} + +it.each([ + ['SUCCESS', 'success'], ['NEUTRAL', 'success'], ['SKIPPED', 'success'], + ['FAILURE', 'failure'], ['CANCELLED', 'failure'], ['TIMED_OUT', 'failure'], ['ACTION_REQUIRED', 'failure'], ['STARTUP_FAILURE', 'failure'], ['STALE', 'failure'], +] as const)('reads a required check run that completed %s as %s, as GitHub does for required checks', async (conclusion, expected) => { + expect(await requiredTestState([{ name: 'test', status: 'COMPLETED', conclusion, startedAt: '2026-10-11T00:00:00Z' }])).toEqual([expected]); +}); + +it.each([ + ['a newer pending re-run over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z', completedAt: '2026-10-11T00:01:00Z' }, + { name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:05:00Z', completedAt: '0001-01-01T00:00:00Z' }, + ], 'pending'], + ['a newer passing re-run over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['a newer failure over an older pass, listed first', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + ], 'failure'], + ['a queued re-run that has not started (zero time) over an older failure', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '', startedAt: '0001-01-01T00:00:00Z' }, + ], 'pending'], + ['the run that passes least when two started at once', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + ], 'failure'], + ['a started failure beside a completed skip with only zero times (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SKIPPED', startedAt: '0001-01-01T00:00:00Z', completedAt: '0001-01-01T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a started failure beside a completed neutral run with no time (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'NEUTRAL' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing run with no start time, completed after a failure started before its completion (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing run with no start time, completed after a failure started (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:03:00Z' }, + ], 'failure'], + ['a passing run with no start time, completed before a failure started (the later failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:06:00Z' }, + ], 'failure'], + ['a failed run with no start time, completed in the second a pass started (order unknown: a tie, the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a failed run with no start time, completed before a pass started (older: dropped)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:06:00Z' }, + ], 'success'], + ['a failed run with no start time, completed after a pass started (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:03:00Z' }, + ], 'failure'], + ['a failed run with no start time, completed before a queued run with no start (older: dropped)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', completedAt: '2026-10-11T00:05:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '' }, + ], 'pending'], + ['a queued run with no start time over a completed pass', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'QUEUED', conclusion: '' }, + ], 'pending'], + ['a newer commit status over an older failed one', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['a pending commit status with no start time over a started passing one', [ + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + { context: 'test', state: 'PENDING' }, + ], 'pending'], + ['a failed commit status with no time beside a started passing one (a tie: the failure counts)', [ + { context: 'test', state: 'FAILURE' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['an errored commit status with no time beside a started passing one (a tie: the error counts)', [ + { context: 'test', state: 'ERROR' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing commit status with no time beside a started failed one (a tie: the failure counts)', [ + { context: 'test', state: 'SUCCESS' }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a passing run with no time beside a started failed one (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a failed run with no time beside a started passing one (a tie: the failure counts)', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a started failed commit status beside a passing one with no time (a tie: the failure counts)', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS' }, + ], 'failure'], +] as const)('counts the latest observation of a required check: %s', async (_case, rollup, expected) => { + expect(await requiredTestState([...rollup])).toEqual([expected]); +}); + +// GitHub: "If a check and a commit status have the same name, both must pass when that name is required." The latest +// of each kind counts, and the one that passes least decides; a newer observation of one kind never replaces the other. +it.each([ + ['a newer passing status does not hide a failed check run', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a newer passing status does not hide a pending check run', [ + { name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'pending'], + ['a newer passing check run does not hide a failed status', [ + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['a failure outranks a pending of the other kind', [ + { name: 'test', status: 'QUEUED', conclusion: '', startedAt: '0001-01-01T00:00:00Z' }, + { context: 'test', state: 'ERROR', startedAt: '2026-10-11T00:05:00Z' }, + ], 'failure'], + ['both kinds passing', [ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:05:00Z' }, + ], 'success'], + ['each kind re-run: the latest of each passes', [ + { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:01:00Z' }, + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:02:00Z' }, + { context: 'test', state: 'SUCCESS', startedAt: '2026-10-11T00:03:00Z' }, + ], 'success'], + ['only a check run', [{ name: 'test', status: 'IN_PROGRESS', conclusion: '', startedAt: '2026-10-11T00:00:00Z' }], 'pending'], + ['only a commit status', [{ context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:00:00Z' }], 'failure'], +] as const)('requires both a check run and a commit status with the required name: %s', async (_case, rollup, expected) => { + expect(await requiredTestState([...rollup])).toEqual([expected]); +}); + +it('matches a requirement bound to an app only against that app\'s check runs, never a commit status', async () => { + expect(await requiredTestState([ + { name: 'test', status: 'COMPLETED', conclusion: 'SUCCESS', startedAt: '2026-10-11T00:00:00Z', app: { databaseId: 10 } }, + { context: 'test', state: 'FAILURE', startedAt: '2026-10-11T00:05:00Z' }, + ], 10)).toEqual(['success']); +}); + it('rejects an unsupported runtime merge method', () => { expect(() => new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21, method: 'typo' as 'merge' })).toThrow(/merge method/i); }); @@ -1319,6 +1486,17 @@ it('gives an injected check the merge inputs and fails closed on an unknown outc expect(() => new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run, { check: checks.check })).toThrow(/merge repository/); }); +it('scans for an earlier fix from the target\'s scan start and skips the task\'s own commits (F6b)', async () => { + const inputs: AlreadyFixedInput[] = []; + const checks: AlreadyFixedGateway = { repository: 'Owner/Repo', check: async input => { inputs.push(input); return { outcome: 'clear', baseHead: sha('9') }; } }; + const run = async (args: readonly string[]) => mergeReads(args, { baseRefName: 'release/1', baseRefOid: sha('c') }); + const client = new GhMergeGateway({ repository: 'owner/repo', pullRequest: 7, issue: 21 }, run, checks); + await client.inspect({ fresh: true, target: { pullRequest: 7, alreadyFixedSince: sha('a'), ownCommits: [sha('e'), sha('d'), sha('e')] } }); + expect(inputs[0]).toEqual({ issue: 21, taskBase: sha('a'), baseBranch: 'release/1', ownPullRequests: [7], ownCommits: new Set([sha('d'), sha('e')]) }); + await expect(client.inspect({ fresh: true, target: { pullRequest: 7, alreadyFixedSince: 'main' } })).rejects.toThrow(/scan start/); + await expect(client.inspect({ fresh: true, target: { pullRequest: 7, ownCommits: ['x'] } })).rejects.toThrow(/own commit/); +}); + it('keeps the caller cancellation when the merge check is aborted', async () => { const controller = new AbortController(); let started!: () => void; diff --git a/test/planning-drafts.test.ts b/test/planning-drafts.test.ts index 56290fd1..9f57ce3e 100644 --- a/test/planning-drafts.test.ts +++ b/test/planning-drafts.test.ts @@ -170,7 +170,7 @@ it.each([9, 10])('migrates a v%i database to the current schema, reading its exi const migrated = open(); expect(migrated.getSuggestions(identity, id)).toMatchObject({ mode: 'suggest', state: 'pending' }); const db = new DatabaseSync(path); - try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); } finally { db.close(); } + try { expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); } finally { db.close(); } }); it('adds checkpoint bindings to v14 planning requests without losing their state', () => { const { store, path, open } = fixture(), id = store.beginSuggestions(identity, state(store), 'draft'); @@ -181,7 +181,7 @@ it('adds checkpoint bindings to v14 planning requests without losing their state expect(migrated.getDraft(identity, id).state).toBe('pending'); const db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); expect(db.prepare('PRAGMA table_info(requests)').all().some(column => column.name === 'continuation')).toBe(true); } finally { db.close(); } }); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 53210f1f..b598245c 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, expect, it, vi } from 'vitest'; import { COMMAND_CHECK_SETTLEMENT_RESERVE_MS, PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS, PreMergeCoordinator, - type PreMergeAuthorization } from '../runner/pre-merge.ts'; + type PreMergeAuthorization, type PreMergeResult } from '../runner/pre-merge.ts'; +import { MergeCoordinator } from '../runner/merge.ts'; +import { MergeJobCoordinator } from '../runner/merge-job.ts'; +import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; +import { openingMarker } from '../github/pull-requests.ts'; import { ReviewService } from '../runner/review.ts'; import { createDemo } from '../scripts/demo.ts'; import { fixtureGit } from './fixtures/git.ts'; @@ -40,7 +44,17 @@ const unusedPush = { push: async () => { throw new Error('No push expected.'); } vi.setConfig({ testTimeout: 15_000 }); afterEach(() => { vi.restoreAllMocks(); for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -const markRunnerOwned = (service: ReviewService) => { +/** The task's PR as the runner publishes it (F6b tests): a clear pre-PR check, then PR 7 from BRANCH into main. */ +const publishPullRequest = (service: ReviewService) => { + const identity = service.config.identity, store = service.store, snapshot = store.getSnapshot(identity); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: snapshot.id, + reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: snapshot.base } }); + const opening = store.beginPullRequest(identity, { checkId: check.id, repository: 'acme/app', base: 'main', headBranch: BRANCH, + headSha: snapshot.head, draft: false }); + expect(store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/acme/app/pull/7', + headSha: snapshot.head, draft: false }, true)).toBe('in review'); +}; +const markRunnerOwned = (service: ReviewService, published = false) => { const identity = service.config.identity; service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'queued'); const attempt = service.store.admitAttempt(identity, { expectedStateVersion: service.store.getTask(identity).stateVersion, @@ -48,7 +62,8 @@ const markRunnerOwned = (service: ReviewService) => { service.store.markRunning(identity, attempt.id); service.store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true, result: { unchanged: false, head: service.store.getSnapshot(identity).head } }); - service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'in review'); + if (published) publishPullRequest(service); + else service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'in review'); service.config.runnerRepository = service.config.repository; }; const markRunnerUnchanged = (service: ReviewService) => { @@ -95,6 +110,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: rebaseResult?: (service: ReviewService, input: { oldHead: string; onto: string }, run: number) => string | undefined; /** Branch reads never answer; only their abort signal ends them. */ branchReadHangs?: boolean; + /** The runner published the task's PR 7 (merging then requires preparation, as in production). */ + published?: boolean; + /** Set up everything but start no preparation; `result` is then null. */ + deferStart?: boolean; /** Remote fetches fail once codeboost has pushed. */ fetchFailsAfterPush?: boolean; /** A collaborator pushes this commit to the branch just before codeboost's push, which is then refused. */ @@ -141,7 +160,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: service.store.createPlan(JSON.stringify(plan), 'json', context, base, head); let view = await service.load(); service.store.recordHistory(identity, view.expected, base, head, [{ sha: head, owner: 'P1', origin: 'owned', sourceSha: null }]); - if (options.runnerCommitted !== false) markRunnerOwned(service); else markRunnerUnchanged(service); + if (options.runnerCommitted !== false) markRunnerOwned(service, options.published === true); else markRunnerUnchanged(service); view = await service.load(); view = await service.act({ action: 'approve', item: 'P1', token: view.token }); let coordinator!: PreMergeCoordinator, rebaseRuns = 0; let rebaseAborts = 0; @@ -294,7 +313,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: const task = service.store.getTask(identity); const actionRequest = { attemptId: undefined, expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion }; if (options.actionId) service.store.userAction(identity, { actionId: options.actionId, kind: 'prepare-merge', request: actionRequest }, () => ({ outcome: 'preparing' })); - const result = await coordinator.start({ stateVersion: task.stateVersion, + const result = options.deferStart ? null as unknown as PreMergeResult : await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, ...(options.actionId ? { actionId: options.actionId } : {}) }); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, actionRequest, pushes, remote: () => remotePair, @@ -1789,3 +1808,110 @@ it('starts no settlement read when less than the process-settlement reserve is l expect(pushMarker(fixture)).toMatchObject({ to: fixture.rebased }); await fixture.coordinator.close(); await fixture.runner.close(); }); + +// F6b of #22: the acceptance cases, run through the one-click merge job with real preparation. +async function mergeJob(rebasedText: string, commandExit?: number, options: Parameters[2] = {}, refuse: () => boolean = () => false) { + const fixture = await rebaseFixture(rebasedText, commandExit, { ...options, deferStart: true, published: true }); + const merged: string[] = []; + // As production builds it with a runner: the task's published PR, which requires durable preparation readiness. + const gateway: MergeGateway = { + inspect: async request => { + const pr = fixture.service.store.taskPullRequests(fixture.service.config.identity).find(row => row.number === request?.target?.pullRequest)!; + return { base: fixture.remote().base, head: fixture.remote().head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', + rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false, + published: { headBranch: request!.target!.headBranch!, baseBranch: 'main', crossRepository: false, marker: openingMarker(pr.openingId), + branchOpen: [{ number: 7, base: 'main' }] } }; + }, + merge: async head => { + merged.push(head); + if (refuse()) throw new MergeSubmissionError('Required status check "ci" is expected.', 'refused'); + return { url: 'https://github.com/acme/app/pull/7' }; + }, + }; + const merges = new MergeCoordinator(fixture.service, gateway, undefined, undefined, + { repository: 'acme/app', baseBranch: 'main', requiresPreparation: true }); + const jobs = new MergeJobCoordinator(fixture.service, fixture.coordinator, merges, undefined, { checkWaitMs: 2_000, firstDelayMs: 5, maxDelayMs: 20 }); + const start = async () => jobs.start((await fixture.service.load()).token, randomUUID()); + const settled = async () => { await jobs.current; return jobs.latest()!; }; + const close = async () => { await jobs.close(); await fixture.coordinator.close(); await fixture.runner.close(); }; + return { ...fixture, merged, merges, jobs, start, settled, close }; +} + +it('merges through the job when a moved base leaves approvals valid, pushing and checking the rewritten head', async () => { + const f = await mergeJob('feature\n', 0); + await f.start(); + expect(await f.settled()).toMatchObject({ state: 'merged' }); + expect(f.pushes).toHaveLength(1); + expect(f.checkedHeads).toEqual([f.rebased]); + expect(f.merged).toEqual([f.rebased]); + await f.close(); +}); + +it('stops the job for review when the rebase makes an approval stale, and merges nothing', async () => { + const f = await mergeJob('changed by rebase\n'); + await f.start(); + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/requires refreshed attribution or approval/) }); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('fails the job when a command check breaks on the rewritten head', async () => { + const f = await mergeJob('feature\n', 1); + await f.start(); + expect(await f.settled()).toMatchObject({ state: 'failed', reason: expect.stringMatching(/command checks did not pass/) }); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('stops the job for review when a collaborator pushed to the pull request', async () => { + const f = await mergeJob('feature\n'); + const repository = f.service.config.repository; + writeFileSync(join(repository, 'late.txt'), 'collaborator\n'); fixtureGit(repository, 'add', 'late.txt'); fixtureGit(repository, 'commit', '-qm', 'collaborator'); + f.moveRemote({ base: f.remote().base, head: fixtureGit(repository, 'rev-parse', 'HEAD') }); + await f.start(); + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/head moved/) }); + expect(f.pushes).toEqual([]); + expect(f.merged).toEqual([]); + await f.close(); +}); + +it('stops a retry for review when its preparation rebases after GitHub refused the merge', async () => { + let refuse = true; + const f = await mergeJob('feature\n', 0, { + // The retry's rebase replays the already-rebased head onto the moved base, keeping its content. + rebaseResult: (service, input, run) => run < 2 ? undefined : fixtureGit(service.config.repository, 'commit-tree', + fixtureGit(service.config.repository, 'rev-parse', `${input.oldHead}^{tree}`), '-p', input.onto, '-m', 'feature, rebased again'), + }, () => refuse); + await f.start(); + expect(await f.settled()).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(f.service.store.getTask(f.service.config.identity).status).toBe('approved but merge blocked'); + // The base moves again before the person retries: the retry's preparation rebases, making a new snapshot. + refuse = false; + const repository = f.service.config.repository, base = f.remote().base; + const moved = fixtureGit(repository, 'commit-tree', fixtureGit(repository, 'rev-parse', `${base}^{tree}`), '-p', base, '-m', 'base moved after the refusal'); + f.moveRemote({ base: moved, head: f.remote().head }); + await f.start(); + // After a merge attempt, a replacement snapshot needs every item approved again (#24): the retry stops for review. + expect(await f.settled()).toMatchObject({ state: 'review-required', reason: expect.stringMatching(/requires refreshed attribution or approval/) }); + expect((await f.service.load()).items.every(item => item.state === 'stale')).toBe(true); + expect((await f.merges.status()).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'queue-head', message: expect.stringMatching(/Approve every item again for the replacement snapshot/) })])); + expect(f.merged).toHaveLength(1); + await f.close(); +}, 60_000); + +it('ends the job cancelled when the task is cancelled while a command check runs', async () => { + const f = await mergeJob('feature\n', 0, { commandWaitsForCancellation: true }); + await f.start(); + // The preparation's review loads each await a history worker (#170) before the check starts. + await vi.waitFor(() => expect(f.runner.isActive(f.service.config.identity)).toBe(true), { timeout: 20_000 }); + const identity = f.service.config.identity; + // As the cancel route does: the task's cancel commits (it waits on the running check), then the job is told. + expect(f.coordinator.cancelTask(f.service.store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + f.jobs.cancel(); + expect(await f.settled()).toMatchObject({ state: 'cancelled' }); + expect(new Set(f.cancelReasons)).toEqual(new Set(['cancelled'])); + expect(f.merged).toEqual([]); + await vi.waitFor(() => expect(f.service.store.getTask(identity).status).toBe('cancelled')); + await f.close(); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts index 8322ffab..3f347e5f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -358,7 +358,7 @@ describe('schema v7', () => { expect(upgraded.taskPullRequests(identity)).toEqual([]); expect(upgraded.latestAlreadyFixed(identity)).toBeNull(); const db = new DatabaseSync(path, { readOnly: true }); - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); const names = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index') AND (name LIKE '%pull_requests%' OR name LIKE 'already_fixed_checks%') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); expect(names).toEqual(['already_fixed_checks', 'already_fixed_checks_task', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening', 'task_pull_requests_task']); db.close(); diff --git a/test/runner-lifecycle-store.test.ts b/test/runner-lifecycle-store.test.ts index 21a4e9c0..b22d5772 100644 --- a/test/runner-lifecycle-store.test.ts +++ b/test/runner-lifecycle-store.test.ts @@ -105,7 +105,7 @@ describe('schema v12 review ordering', () => { migrated.saveReview(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: migratedVersion }, [{ item: 'P1', fingerprint: 'approved-after-upgrade' }], []); expect(migrated.unapprovedExecutionItems(identity, 1)).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); @@ -783,7 +783,7 @@ describe('runner commits and preparation groups (#87)', () => { db.exec('ALTER TABLE attempts DROP COLUMN preparation_identity; PRAGMA user_version=15;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, preparationIdentity: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); @@ -917,7 +917,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_finding; PRAGMA user_version=7;'); db.close(); const reopened = open(path); expect(reopened.getAttempt(identity, attempt.id).safetyFinding).toBeNull(); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('adds the durable owed marker to a version 12 database', () => { @@ -927,7 +927,7 @@ describe('durable safety findings (#87 item 3)', () => { db.exec('ALTER TABLE attempts DROP COLUMN safety_owed; PRAGMA user_version=12;'); db.close(); const reopened = open(path); expect(reopened.owedSafetyFindings()).toEqual([]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('restores and clears an escalation owed behind a human gate across real store reopens', () => { @@ -967,7 +967,7 @@ describe('allocation baseline (#91)', () => { db.exec('ALTER TABLE attempts DROP COLUMN metadata_baseline; ALTER TABLE attempts DROP COLUMN storage_base; PRAGMA user_version=8;'); db.close(); const reopened = open(path); expect(reopened.interruptedAttempts()).toEqual([expect.objectContaining({ id: attempt.id, metadataBaseline: null, storageBase: null })]); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); }); describe('publish outcomes (#103)', () => { @@ -983,7 +983,7 @@ describe('publish outcomes (#103)', () => { reopened.recordPublish(identity, { outcome: 'opened', draft: false, message: 'Pull request #1 is open.', number: 1, url: 'https://github.com/o/r/pull/1' }); expect(reopened.lastPublish(identity)).toMatchObject({ outcome: 'opened', number: 1 }); expect(reopened.getTask(identity)).toEqual(before); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('stamps an outcome with the version the publish saw, and refuses one it cannot have seen (#114)', () => { const { store } = queued(), version = store.getTask(identity).stateVersion; @@ -1023,7 +1023,7 @@ describe('pre-merge push marker (F6a of #22)', () => { const legacy = new DatabaseSync(path); legacy.exec('ALTER TABLE tasks DROP COLUMN push_in_progress; PRAGMA user_version=17;'); legacy.close(); expect(open(path).getTask(identity).pushInProgress).toBeNull(); - expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(new DatabaseSync(path).prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); }); it('claims only a push of the current head over a durable rewrite predecessor', () => { diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index e6d51c57..b30ec341 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -122,6 +122,17 @@ describe('runner startup', () => { expect(await assembly.sources.vendor(service.config.identity)).toBe('claude'); expect(() => assembly.sources.planContext({ ...service.config.identity, planId: 'other' })).toThrow(/only its configured plan/); }); + // Where in startup this runs does not matter: the server builds its merge job coordinator, and listens, only after + // setUpRunner resolves (web/server.ts), so no new job can start or be shown before the leftover one has ended. + it('fails a merge job an earlier process left running, during runner startup (F6b)', async () => { + const { root, service } = fixture(), calls: string[] = []; + const actionId = randomUUID(); + service.store.beginMergeJob(service.config.identity, { actionId, token: 'review-token' }); + await setUpRunner({ service, capability, config: { root: join(root, 'runner'), committer }, lock: lock(calls), env: { CLAUDE_CODE_OAUTH_TOKEN: 't' }, + buildImage: () => 'sha256:x', recovery: () => recovery(calls) }); + expect(service.store.getMergeJob(service.config.identity, actionId)).toMatchObject({ state: 'failed', + reason: expect.stringMatching(/server restarted/) }); + }); it('pushes a prepared rewrite only under an exact lease on the PR head it rewrote', async () => { const { root, service } = fixture(), calls: string[] = []; const assembly = await setUpRunner({ service, capability, config: { root: join(root, 'runner'), committer }, lock: lock(calls), env: { CLAUDE_CODE_OAUTH_TOKEN: 't' }, diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 2e841a7f..296da0ad 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -9,14 +9,14 @@ import { startServer } from '../web/server.ts'; import { Store, type PublishRecord } from '../runner/store.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import type { ReviewService } from '../runner/review.ts'; -import type { PreparedAttempt, RunnerDeps } from '../runner/coordinator.ts'; +import type { PreparedAttempt, RunnerCoordinator, RunnerDeps } from '../runner/coordinator.ts'; import { SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace } from '../runner/execution.ts'; import { GitBranchPusher } from '../runner/branch-push.ts'; import { PullRequestPublisher } from '../runner/publish.ts'; -import { ensureCommit, openRunnerRepository } from '../runner/runner-repository.ts'; +import { ensureCommit, openRunnerRepository, retainSnapshotCommit } from '../runner/runner-repository.ts'; import { baseBranch } from '../runner/production.ts'; import { MergeCoordinator } from '../runner/merge.ts'; -import type { MergeGateway } from '../github/merge.ts'; +import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; import { OWED_REFUSAL } from '../runner/publishing.ts'; import { PullRequestMisplaced, openingMarker, type OpenPullRequestInput, type PullRequestGateway } from '../github/pull-requests.ts'; import type { AlreadyFixedGateway } from '../github/already-fixed.ts'; @@ -24,6 +24,8 @@ import type { PlanIdentity } from '../core/identity.ts'; import { approveItem } from '../core/approvals.ts'; import { GhIssueGateway, type IssueTrustGateway } from '../github/issues.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; +import { MergeJobCoordinator, SETTLED_BEFORE_JOB_MERGE, type MergeJobTiming } from '../runner/merge-job.ts'; +import type { RemoteMergeState, RequiredCheck } from '../github/merge.ts'; import { RemoteTimeout } from '../github/timeout.ts'; import { CommandFailed } from '../github/run-with-input.ts'; @@ -233,21 +235,37 @@ function world(): World { * of the three coordinator paths that can raise a finding. `before` shapes the Store before the runner exists, as an * earlier process would have left it. */ -async function serve(w: World, options: { before?: (service: ReviewService) => void; onPushSpawn?: (n: number, app: () => App, close: () => Promise) => void; demo?: boolean; startup?: boolean; settleMs?: number; env?: NodeJS.ProcessEnv; hold?: Promise; shortRetryMs?: number; findingSource?: FindingSource; - issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService) => PreMergeCoordinator } = {}) { +async function serve(w: World, options: { before?: (service: ReviewService) => void | Promise; onPushSpawn?: (n: number, app: () => App, close: () => Promise) => void; demo?: boolean; startup?: boolean; settleMs?: number; env?: NodeJS.ProcessEnv; hold?: Promise; shortRetryMs?: number; findingSource?: FindingSource; + issueGateway?: IssueTrustGateway; preMerge?: (service: ReviewService, runner: RunnerCoordinator) => PreMergeCoordinator; mergeGateway?: MergeGateway; mergeJobTiming?: MergeJobTiming; + /** The runner also admits command-check attempts, as pre-merge preparation starts them (held by `hold` too). */ + checks?: boolean; + /** A runner block and no github.pullRequest: merging targets the PR the runner published, after preparation. */ + runnerPublished?: boolean; + /** + * As a production runner server (`setUpRunner`): before any review load, the runner-owned repository holds the + * snapshot's commits and is set as `runnerRepository`, so reviews read `snapshot.head` there, never the user's HEAD. + */ + runnerReview?: boolean } = {}) { let app: App | undefined, spawns = 0, closing: Promise | undefined; const close = () => closing ??= app!.close(); let branchOf: (identity: PlanIdentity) => string = () => ''; let findings: SafetyFindings | undefined; - const config = { ...w.demo, demo: options.demo ?? false, ...(options.issueGateway ? { github: { repository: options.issueGateway.repository, issue: 3, pullRequest: 1, baseBranch: 'main' } } : {}) }; - app = await startServer(config, 0, undefined, undefined, 2_000, options.issueGateway, undefined, undefined, async service => { + const config = { ...w.demo, demo: options.demo ?? false, ...(options.issueGateway ? { github: { repository: options.issueGateway.repository, issue: 3, + ...(options.runnerPublished ? {} : { pullRequest: 1 }), baseBranch: 'main' } } : {}), ...(options.runnerPublished ? { runner: {} } : {}) }; + app = await startServer(config, 0, undefined, options.mergeGateway, 2_000, options.issueGateway, undefined, undefined, async service => { + if (options.runnerReview) { + const repository = await openRunnerRepository({ runnerRoot: join(w.root, 'runner'), runnerOwner: OWNER, repositoryId: service.config.identity.repositoryId, source: service.config.repository }); + const snapshot = service.store.getSnapshot(service.config.identity); + for (const commit of new Set([snapshot.base, snapshot.head])) await retainSnapshotCommit(repository, commit); + service.config.runnerRepository = repository.path; + } const identity = service.config.identity, task = service.store.getTask(identity), plan = service.store.getPlan(identity); if (task.status !== 'merged' && task.status !== 'cancelled' && service.store.unapprovedExecutionItems(identity, plan.revision).length) { const review = await service.load(); service.store.saveReview(identity, review.expected, review.items.map(item => approveItem(review.plan, review.segments, item.id, identity, item.count === 0)), []); } - options.before?.(service); + await options.before?.(service); const repository = await openRunnerRepository({ runnerRoot: join(w.root, 'runner'), runnerOwner: OWNER, repositoryId: service.config.identity.repositoryId, source: service.config.repository }); await ensureCommit(repository, service.store.getSnapshot(service.config.identity).head); const prepared: PreparedAttempt = { clone: { id: 'clone', taskId: 'task', directory: '/tmp/x', head: 'f'.repeat(40) }, vendor: 'claude', approvedArgv: [] }; @@ -278,15 +296,15 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v }; const deps: RunnerDeps = source ? executionDeps(service.store, workspace, launch, sources, OWNER, findings) - : { runnerOwner: OWNER, kinds: ['execute'], prepare: async () => prepared, cleanupPreparation: async () => undefined, + : { runnerOwner: OWNER, kinds: options.checks ? ['execute', 'check'] : ['execute'], prepare: async () => prepared, cleanupPreparation: async () => undefined, start: input => ({ attemptId: input.attemptId, cancel: () => undefined, // `hold`: the attempt keeps running until the test releases it. settled: (options.hold ?? Promise.resolve()).then(() => ({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: '', stderr: '' })) }), validate: () => ({ head: service.store.getSnapshot(service.config.identity).head, unchanged: true, inScope: [], outOfScope: [] }) }; - return { deps, sources, findings, publisher, ...(options.preMerge ? { preMerge: () => options.preMerge!(service) } : {}), + return { deps, sources, findings, publisher, ...(options.preMerge ? { preMerge: (runner: RunnerCoordinator) => options.preMerge!(service, runner) } : {}), ...(options.env ? { env: options.env } : {}), ...(options.shortRetryMs ? { shortRetryMs: options.shortRetryMs } : {}), recovery: { finalized: [], requeue: [], removedDirectories: [], unknownEntries: [], unmatchedStorage: [], repairedMerges: [] } }; - }); + }, options.mergeJobTiming ? { mergeJobTiming: options.mergeJobTiming } : {}); cleanups.push(close); // As the CLI does once it has verified the lock. if (options.startup !== false) app.publishOwed(() => undefined); @@ -1750,3 +1768,696 @@ describe('closing a cancelled task\'s pull requests (#111)', () => { expect(await act(app, 'publish')).toMatchObject({ status: 409, body: { error: 'The task is cancelled; its pull requests are closed, not published.' } }); }); }); + +describe('the one-click merge job (F6b of #22)', () => { + const issueGateway: IssueTrustGateway = { + repository: REPO, + async fetch() { return { repository: REPO, retrievedAt: new Date().toISOString(), issues: [] }; }, + async issueAccess(number) { return { number, authorLogin: 'member', collaborator: true }; }, + async issueText(number) { return { number, title: '', body: '', comments: [] }; }, + }; + /** Preparation as F5/F6a end it: readiness recorded exactly on its own action. */ + const preparation = (service: ReviewService) => ({ + get active() { return false; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + start: async (expected: { actionId: string }) => { + const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); + const readiness = { stateVersion: service.store.getTask(identity).stateVersion, reviewVersion: service.store.reviewVersion(identity), + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: service.commandPolicyDigest() }; + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'ready', base: snapshot.base, head: snapshot.head, checked: [], reason: null }, readiness); + }, + } as unknown as PreMergeCoordinator); + /** GitHub showing the task's published PR at the reviewed pair; `checks()` gives its required checks now. */ + const gateway = (store: () => Store, checks: () => RequiredCheck[], hang: () => boolean = () => false): + MergeGateway & { merged: string[]; inspections: () => number; hung: AbortSignal[] } => { + const merged: string[] = [], hung: AbortSignal[] = []; + let inspections = 0; + return { merged, hung, inspections: () => inspections, + inspect: async request => { + inspections++; + // From this inspection on, GitHub does not answer; only the caller's abort ends the read. + if (hang() && request?.signal) { + hung.push(request.signal); + await new Promise((_resolve, reject) => request.signal!.addEventListener('abort', () => reject(request.signal!.reason), { once: true })); + } + const identity = createIdentity(), pr = store().taskPullRequests(identity).find(row => row.number === request?.target?.pullRequest); + const snapshot = store().getSnapshot(identity); + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, + mergeQueue: false, requiredChecks: checks(), alreadyFixed: 'clear', pullRequest: request!.target!.pullRequest, draft: false, + published: { headBranch: request!.target!.headBranch!, baseBranch: 'main', crossRepository: false, marker: openingMarker(pr!.openingId), + branchOpen: [{ number: request!.target!.pullRequest, base: 'main' }] } } as RemoteMergeState; + }, + merge: async head => { merged.push(head); return { url: 'https://github.com/acme/app/pull/100' }; } }; + }; + let createIdentity: () => PlanIdentity = () => { throw new Error('not set'); }; + /** + * The user's repository moves on after the task's snapshot: a runner server never reads it, so a review that observed + * the user's HEAD would record this commit as a new snapshot (`mergeableReview` asserts that it does not). + */ + const moveUserHead = (w: World) => { + git(w.demo.repository, 'commit', '-q', '--allow-empty', '-m', 'A local commit the runner never reads'); + }; + const acceptAndApprove = async (service: ReviewService) => { + let view = await service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = await service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) if (item.state !== 'approved') + view = await service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + }; + /** + * A review a person could merge, reached as on a runner server (`runnerReview`): the person amends the plan so P2 + * declares the demo's run.sh mode change (plan import, as /api/plan/import does) and approves it, the runner completes + * every item, and the person accepts the unplanned change and approves every item. Every load reads `snapshot.head` + * in the runner-owned repository, so the snapshot never moves to the user's HEAD. + */ + const mergeableReview = async (service: ReviewService) => { + const s = service.store, id = service.config.identity, initial = s.getSnapshot(id); + expect(service.config.runnerRepository).toBeDefined(); + expect(service.reviewRepository()).toEqual({ path: service.config.runnerRepository, runnerOwned: true }); + expect(git(service.config.repository, 'rev-parse', 'HEAD')).not.toBe(initial.head); + const plan = s.getPlan(id); + plan.items.find(item => item.id === 'P2')!.files.push({ path: 'run.sh', kind: 'edit', renamed_from: null, change: 'Make the script executable.' }); + s.importRevision(JSON.stringify(plan), 'json', service.planContext(), plan.revision); + const amended = await service.load(); + s.saveReview(id, amended.expected, amended.items.map(item => approveItem(amended.plan, amended.segments, item.id, id, item.count === 0)), []); + completeAll(service); + await acceptAndApprove(service); + const view = await service.load(); + expect(view.items.every(item => item.state === 'approved')).toBe(true); + expect(view.segments.filter(segment => segment.scope === 'out-of-scope')).toEqual([]); + expect(s.getSnapshot(id)).toEqual(initial); + }; + const review = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/review`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; + const merge = async (app: App, token: string, actionId = randomUUID()) => { + const response = await fetch(`${new URL(app.url).origin}/api/action`, { method: 'POST', headers: { 'x-codeboost-token': app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'merge', token, actionId }) }); + return { status: response.status, body: await response.json() as Record }; + }; + const mergeState = async (app: App) => (await fetch(`${new URL(app.url).origin}/api/merge`, { headers: { 'x-codeboost-token': app.token } })).json() as Promise>; + + it('offers Merge PR while only blockers the job resolves remain, then waits for checks and merges, refusing prepare-merge and publish meanwhile', async () => { + const w = world(); moveUserHead(w); + let checks: RequiredCheck[] = [{ context: 'ci', appId: null, state: 'pending' }], storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => checks); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const before = await review(served.app); + // Not prepared yet, and a required check pends: both are the job's to resolve. + expect(before.merge.blockers.map((blocker: { code: string }) => blocker.code)).toEqual(expect.arrayContaining(['preparation', 'check'])); + // Worded as what Merge PR does: the screen has no Prepare control. + expect(before.merge.blockers.find((blocker: { code: string }) => blocker.code === 'preparation').message) + .toBe('The merge has not been prepared for the current review; merging prepares it.'); + // A runner server sends the codes a job settles before it merges, which the page drops once a job reached the merge. + expect(before.merge.settledBeforeJobMerge).toEqual([...SETTLED_BEFORE_JOB_MERGE]); + expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); + const started = await merge(served.app, before.token); + expect(started.status).toBe(200); + expect(started.body.job).toMatchObject({ state: 'running' }); + expect(started.body.merge).toMatchObject({ ready: false, job: { state: 'running' } }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }), { timeout: 10_000 }); + // A second tab still holds the review read before the click: its click is refused, and the refusal carries the + // running job so that page follows it. A resend replays the refusal with the task's latest job, still never as a job. + const secondTab = randomUUID(), refused = await merge(served.app, before.token, secondTab); + expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is already running for this task.', job: { id: started.body.job.id, state: 'running', step: 'checks' } } }); + expect(await merge(served.app, before.token, secondTab)).toMatchObject({ status: 409, body: { error: refused.body.error, job: { id: started.body.job.id } } }); + expect((await mergeState(served.app)).job).toMatchObject({ id: started.body.job.id }); + expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; it prepares the merge itself.' } }); + // A publish cannot begin a refresh of the PR the job waits on: publishing refuses a task in review. + expect(await act(served.app, 'publish')).toMatchObject({ status: 409, body: { error: expect.stringMatching(/The task is in review/) } }); + expect(served.store.taskPullRequests(served.identity).every(pr => pr.refresh === null)).toBe(true); + checks = [{ context: 'ci', appId: null, state: 'success' }]; + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + expect(gh.merged).toEqual([served.store.getSnapshot(served.identity).head]); + expect(served.store.getTask(served.identity).status).toBe('merged'); + }, 60_000); + + it('refuses start and resume while a merge job waits, even after a new plan revision is imported and approved; the import only ends the job failed', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + const base = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); + // The job's fresh inspections after its first are held until the test releases them (or the job's signal stops them), + // so the job is still waiting for checks at every step below, whatever the machine's speed. + let fresh = 0, release!: () => void; + const released = new Promise(resolve => { release = resolve; }); + const gh: MergeGateway = { merge: base.merge, inspect: async request => { + if (request?.fresh && ++fresh > 1) await new Promise((resolve, reject) => { + void released.then(resolve); + request.signal?.addEventListener('abort', () => reject(request.signal!.reason), { once: true }); + }); + return base.inspect(request); + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + // The first inspection has answered (its pending check is recorded) and the second is held. + await vi.waitFor(async () => { + expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'checks', checks: [{ context: 'ci', state: 'pending' }] }); + expect(fresh).toBe(2); + }, { timeout: 10_000 }); + // As a person would: import a new revision of the plan (plan import has no task-status guard), then approve it all. + const plan = served.store.getPlan(served.identity); + const imported = await fetch(`${new URL(served.app.url).origin}/api/plan/import`, { method: 'POST', headers: { 'x-codeboost-token': served.app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ source: JSON.stringify(plan), format: 'json', expectedRevision: plan.revision, actionId: randomUUID() }) }); + expect(imported.status).toBe(200); + expect(served.store.getPlan(served.identity).revision).toBe(plan.revision + 1); + await acceptAndApprove(served.app.service); + expect(served.store.unapprovedExecutionItems(served.identity, plan.revision + 1)).toEqual([]); + expect(served.store.getTask(served.identity).status).toBe('in review'); + const calls = [...w.github.calls], branchHead = w.github.head(served.branch), attempts = served.store.recentAttempts(served.identity, 50).length; + // Neither is offered, and both are refused, while the job runs. + expect(await view(served.app)).toMatchObject({ startable: false, resumable: false }); + for (const action of ['start', 'resume']) { + const id = randomUUID(), refused = await act(served.app, action, id); + expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; wait for it to end or cancel the task.' } }); + // Saved like other refusals: the same action ID replays it. + expect(await act(served.app, action, id)).toMatchObject({ status: 409, body: { error: refused.body.error } }); + } + expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'checks' }); + expect(fresh).toBe(2); + // What the import itself does to the job: the readiness it prepared is for the old review, so its next inspection + // ends it failed, and nothing is merged, run, pushed or published. + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'failed', + reason: 'The merge has not been prepared for the current review; merging prepares it.' }), { timeout: 10_000 }); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); + expect(served.app.publishing!.busy(served.identity)).toBe(false); + expect(w.github.calls).toEqual(calls); + expect(w.github.head(served.branch)).toBe(branchHead); + expect(base.merged).toEqual([]); + }, 60_000); + + it('refuses start while a merge job prepares, even after a new plan revision is imported and approved', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, release!: () => void, preparing = false; + const held = new Promise(resolve => { release = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, runnerPublished: true, + // A preparation held until the test releases it; it then fails, as a real one does once the review changed under it. + preMerge: service => ({ + get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + start: async (expected: { actionId: string }) => { + preparing = true; + await held; + preparing = false; + const identity = service.config.identity, snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'The review changed during preparation.' }, null); + }, + } as unknown as PreMergeCoordinator) }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => { expect(preparing).toBe(true); expect((await mergeState(served.app)).job).toMatchObject({ state: 'running', step: 'preparing' }); }, { timeout: 10_000 }); + const plan = served.store.getPlan(served.identity); + const imported = await fetch(`${new URL(served.app.url).origin}/api/plan/import`, { method: 'POST', headers: { 'x-codeboost-token': served.app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ source: JSON.stringify(plan), format: 'json', expectedRevision: plan.revision, actionId: randomUUID() }) }); + expect(imported.status).toBe(200); + await acceptAndApprove(served.app.service); + const calls = [...w.github.calls], attempts = served.store.recentAttempts(served.identity, 50).length; + expect(await view(served.app)).toMatchObject({ startable: false }); + expect(await act(served.app, 'start')).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; wait for it to end or cancel the task.' } }); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'failed' }), { timeout: 10_000 }); + expect(w.github.calls).toEqual(calls); + expect(gh.merged).toEqual([]); + }, 60_000); + + it('admits issue trust before a merge job starts: an untrusted click is refused and replayed, a failed read saves nothing', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, access: 'untrusted' | 'unreadable' | 'trusted' = 'trusted', reads = 0, prepared = 0; + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, async issueAccess(number) { + reads++; + if (access === 'unreadable') throw new Error('GitHub returned HTTP 502'); + return access === 'trusted' ? { number, authorLogin: 'member', collaborator: true } : { number, authorLogin: 'outside', collaborator: false }; + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, mergeGateway: gh, runnerPublished: true, + preMerge: service => { const real = preparation(service); return { ...real, get active() { return false; }, get last() { return null; }, + start: (expected: { actionId: string }) => { prepared++; return (real.start as (value: unknown) => unknown)(expected); } } as unknown as PreMergeCoordinator; } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // A failed read decides nothing: nothing is saved, so the same click may be sent again. + access = 'unreadable'; + expect((await merge(served.app, token, id)).status).toBe(502); + expect(served.store.getMergeJob(served.identity, id)).toBeNull(); + access = 'untrusted'; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: expect.stringMatching(/not trusted/) } }); + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ refused: true }); + // The resend replays the refusal without another read, even once the issue is trusted. + access = 'trusted'; const before = reads; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: expect.stringMatching(/not trusted/) } }); + expect(reads).toBe(before); + expect(prepared).toBe(0); + expect((await mergeState(served.app)).job).toBeNull(); + // A new click on a trusted issue starts the job. + expect((await merge(served.app, token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + expect(prepared).toBe(1); + }, 60_000); + + it('refuses a merge click while a preparation\'s command check holds the runner, and the resend replays the refusal', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, preparing = false; + const hold = Promise.withResolvers(); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, + runnerPublished: true, checks: true, hold: hold.promise, + // A preparation started by prepare-merge while the task is in review: it runs a command-check attempt through the + // real runner (held until the test releases it) and waits for it to settle, as production's does. + preMerge: (service, runner) => ({ + get active() { return preparing; }, get last() { return null; }, settlePendingPush: async () => null, + cancelTask: () => 'closed' as const, close: async () => undefined, + assertStartable() { if (preparing) throw new GuardRefusal('Pre-merge preparation is already running.'); }, + start: async (expected: { actionId: string }) => { + preparing = true; + try { + const identity = service.config.identity; + runner.start(identity, { expectedStateVersion: service.store.getTask(identity).stateVersion, kind: 'check', + item: service.store.getPlan(identity).items[0]!.id, deadline: Date.now() + 60_000, expectedContext: service.store.currentContext(identity) }); + await runner.settled(identity); + const snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'The task or review changed during preparation. Reload first.' }, null); + } finally { preparing = false; } + }, + } as unknown as PreMergeCoordinator) }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(await act(served.app, 'prepare-merge')).toMatchObject({ status: 200 }); + await vi.waitFor(() => expect(served.app.runner!.isActive(served.identity)).toBe(true), { timeout: 10_000 }); + const { token } = await review(served.app), id = randomUUID(), busy = 'The runner or publisher is busy; the merge cannot start yet.'; + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: busy, job: null } }); + // No job started: the click's refusal is saved instead. + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ refused: true, reason: busy }); + expect((await mergeState(served.app)).job).toBeNull(); + hold.resolve(); + await vi.waitFor(() => { expect(preparing).toBe(false); expect(served.app.runner!.isActive(served.identity)).toBe(false); }, { timeout: 10_000 }); + // The resend replays the saved refusal, though the runner is idle now. + expect(await merge(served.app, token, id)).toMatchObject({ status: 409, body: { error: busy, job: null } }); + expect((await mergeState(served.app)).job).toBeNull(); + expect(gh.merged).toEqual([]); + }, 60_000); + + it('answers 503 and saves nothing when shutdown stops a merge click\'s trust read', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, hang = false, reading = false; + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number, options = {}) { + if (!hang) return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + reading = true; + // GitHub does not answer; only the request's own abort (at the end of shutdown's drain) ends the read. + return new Promise((_resolve, reject) => options.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true })); + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hang = true; + const clicked = merge(served.app, token, id); + await vi.waitFor(() => expect(reading).toBe(true), { timeout: 10_000 }); + const closed = served.close(); + expect(await clicked).toMatchObject({ status: 503 }); + await closed; + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } + }, 60_000); + + it('answers a merge click whose trust read fails with the job a resend of the same click saved meanwhile', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, hold = false, fail!: (error: Error) => void, reading!: () => void; + const held = new Promise(resolve => { reading = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number) { + // The first click's read is held until it fails; the resend's read (and the publish's before) answers at once. + if (hold) { hold = false; reading(); return new Promise((_resolve, reject) => { fail = reject; }); } + return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + hold = true; + const first = merge(served.app, token, id); + await held; + const second = await merge(served.app, token, id); + expect(second).toMatchObject({ status: 200, body: { job: { id } } }); + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ id }); + fail(new Error('GitHub returned HTTP 502')); + expect(await first).toMatchObject({ status: 200, body: { job: { id } } }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + }, 60_000); + + it.each([ + ['timed out', () => new RemoteTimeout('Issue retrieval timed out.'), 503, 'Issue trust could not be verified: Issue retrieval timed out.'], + ['gh answered', () => new CommandFailed('gh failed (exit 1): HTTP 404: Not Found', '', ''), 502, 'Issue trust could not be verified: gh failed (exit 1): HTTP 404: Not Found'], + ] as const)('keeps a merge click\'s trust read failure (%s) when shutdown starts while gh stops, saving nothing', async (_name, failure, status, error) => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, hang = false, fail!: (error: Error) => void, reading!: () => void; + const held = new Promise(resolve => { reading = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const gate: IssueTrustGateway = { ...issueGateway, issueAccess(number) { + if (!hang) return Promise.resolve({ number, authorLogin: 'member', collaborator: true }); + reading(); + return new Promise((_resolve, reject) => { fail = reject; }); + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway: gate, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hang = true; + const clicked = merge(served.app, token, id); + await held; + // Shutdown's first step runs at once; the read then ends with its own failure, which happened first. + const closed = served.close(); + fail(failure()); + expect(await clicked).toMatchObject({ status, body: { error } }); + await closed; + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toBeNull(); } finally { reopened.close(); } + }, 60_000); + + it('answers 503 when shutdown stops the reload after a merge job was saved, keeping the saved job for a resend', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, hanging = false; + // From the click on, GitHub does not answer; only the caller's abort ends each read. + const gh = gateway(() => storeRef!, () => [], () => hanging); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(), database = served.app.service.config.database; + hanging = true; + const clicked = merge(served.app, token, id); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, id)).not.toBeNull(), { timeout: 10_000 }); + await vi.waitFor(() => expect(gh.hung.length).toBeGreaterThan(0), { timeout: 10_000 }); + const closed = served.close(); + expect(await clicked).toMatchObject({ status: 503 }); + await closed; + // The job was saved under the click's action ID, which a resend replays (merge-job.test.ts covers the replay). + const reopened = new Store(database); + try { expect(reopened.getMergeJob(served.identity, id)).toMatchObject({ id }); } finally { reopened.close(); } + }, 60_000); + + it('answers the started job when its click cannot reload the review, and the resent click returns the job', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // The click's own reload after its job was saved fails once (as a history worker timing out would). The admission load + // and the reload share the request's signal; the job's loads carry the job's own signal. + const service = served.app.service, realLoad = service.load.bind(service), requestSignals = new Set(); + let failed = false; + service.load = async (options?: Parameters[0]) => { + const signal = options?.signal; + if (signal && !failed && requestSignals.has(signal)) { failed = true; throw new Error('History work exceeded its overall deadline.'); } + if (signal) requestSignals.add(signal); + return realLoad(options); + }; + const clicked = await merge(served.app, token, id); + expect(failed).toBe(true); + const saved = served.store.getMergeJob(served.identity, id); + expect(saved).not.toBeNull(); // The click's job was saved and started. + // The job itself, so the page follows it, and a request to refresh the review. + expect(clicked).toMatchObject({ status: 200, body: { job: { id: saved!.id }, mergeRefreshRequired: true } }); + // The same click, sent again, replays its job. + const resent = await merge(served.app, token, id); + expect(resent).toMatchObject({ status: 200, body: { job: { id: served.store.getMergeJob(served.identity, id)!.id } } }); + }, 60_000); + + it('replays a click\'s own job on resend after that job ended and another click started a later job', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + // A failing required check ends each job blocked, which leaves the task mergeable again (Retry merge). + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const first = await review(served.app), clickA = randomUUID(); + expect((await merge(served.app, first.token, clickA)).body.job).toMatchObject({ id: clickA }); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, clickA)).toMatchObject({ state: 'blocked' }), { timeout: 10_000 }); + // Another tab reads the review again and clicks Retry merge: job B is now the task's latest job. + const clickB = randomUUID(); + expect((await merge(served.app, (await review(served.app)).token, clickB)).body.job).toMatchObject({ id: clickB }); + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, clickB)).toMatchObject({ state: 'blocked' }), { timeout: 10_000 }); + expect(served.store.latestMergeJob(served.identity)).toMatchObject({ id: clickB }); + // Click A, resent (its answer was lost): the answer is job A, not the task's latest job. + expect(await merge(served.app, first.token, clickA)).toMatchObject({ status: 200, body: { job: { id: clickA, state: 'blocked' } } }); + // The same when the resend's reload fails: both jobs have ended, so the only review load now is that reload. + const service = served.app.service, realLoad = service.load.bind(service); + let failed = false; + service.load = async (options?: Parameters[0]) => { + if (!failed) { failed = true; throw new Error('History work exceeded its overall deadline.'); } + return realLoad(options); + }; + try { + expect(await merge(served.app, first.token, clickA)).toMatchObject({ status: 200, body: { job: { id: clickA, state: 'blocked' }, mergeRefreshRequired: true } }); + expect(failed).toBe(true); + } finally { service.load = realLoad; } + }, 60_000); + + it('answers a merge click with the job\'s latest record when it ended during the click\'s reload', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, slow = false; + const base = gateway(() => storeRef!, () => []); + // The click's reload reads GitHub's display status (not fresh), and that read is held until the job has merged; the + // job's own fresh inspections answer at once. So the job ends during the click's reload, whatever the machine's speed. + const gh: MergeGateway = { merge: base.merge, inspect: async request => { + if (slow && !request?.fresh) await vi.waitFor(() => expect(storeRef!.latestMergeJob(createIdentity())?.state).toBe('merged'), { timeout: 40_000, interval: 50 }); + return base.inspect(request); + } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + slow = true; + const clicked = await merge(served.app, token, id); + // The job merged while the click's response was being prepared: the response says so, not "preparing". + expect(served.store.getMergeJob(served.identity, id)).toMatchObject({ state: 'merged' }); + expect(clicked).toMatchObject({ status: 200, body: { job: { id, state: 'merged' } } }); + }, 60_000); + + it('answers a merge click whose reload fails with the job\'s latest record, once the job ended during that reload', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const { token } = await review(served.app), id = randomUUID(); + // The admission load and the click's reload share the request's signal; the job's loads carry its own. The reload + // waits until the job has merged, then fails (as a history worker timing out would). + const service = served.app.service, realLoad = service.load.bind(service), requestSignals = new Set(); + let failed = false; + service.load = async (options?: Parameters[0]) => { + const signal = options?.signal; + if (signal && !failed && requestSignals.has(signal)) { + failed = true; + await vi.waitFor(() => expect(served.store.getMergeJob(served.identity, id)?.state).toBe('merged'), { timeout: 40_000, interval: 50 }); + throw new Error('History work exceeded its overall deadline.'); + } + if (signal) requestSignals.add(signal); + return realLoad(options); + }; + const clicked = await merge(served.app, token, id); + expect(failed).toBe(true); + // The admission's record said "running, preparing"; the answer is the job as it is now. + expect(clicked).toMatchObject({ status: 200, body: { job: { id, state: 'merged' }, mergeRefreshRequired: true } }); + }, 60_000); + + it('offers Merge PR to a task in review and Retry merge once a job ended blocked, from the task\'s current status', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'failure' }]); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const before = await review(served.app); + expect(served.store.getTask(served.identity).status).toBe('in review'); + expect(before.merge).toMatchObject({ ready: true, action: 'merge', job: null }); + const started = await merge(served.app, before.token); + expect(started.status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ id: started.body.job.id, state: 'blocked' }), { timeout: 10_000 }); + expect(served.store.getTask(served.identity).status).toBe('approved but merge blocked'); + const after = await review(served.app); + // The guarded merge has no attempt to retry, so only the task's status makes this a retry. + expect(after.merge.queue).toBeNull(); + expect(after.merge).toMatchObject({ ready: true, action: 'retry', job: { id: started.body.job.id, state: 'blocked' } }); + }, 60_000); + + it('closes the pull requests of a task whose cancel waited for the merge job\'s preparation to settle', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, release!: () => void, preparing = false; + const held = new Promise(resolve => { release = resolve; }); + // The stub records a rebase to commits that do not exist, so it starts only once the click has been answered: the + // click's own review reload (which awaits a history worker, #170) must not read them. + let answered!: () => void; + const clickAnswered = new Promise(resolve => { answered = resolve; }); + const gh = gateway(() => storeRef!, () => []); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, mergeGateway: gh, runnerPublished: true, + // A preparation caught in its push: it holds a real push marker, so a cancel waits ("stopping") until the push + // settles, and clearing the marker closes the task, as production does. + preMerge: service => { + const identity = service.config.identity; + let marker: { attemptId: string } | null = null; + return { + get active() { return preparing; }, get last() { return null; }, assertStartable() {}, settlePendingPush: async () => null, close: async () => undefined, + cancelTask: (stateVersion: number, actionId: string) => service.store.cancelTask(identity, stateVersion, actionId), + start: async (expected: { actionId: string }) => { + preparing = true; + await clickAnswered; + const reviewed = () => ({ revision: service.store.getPlan(identity).revision, snapshotId: service.store.getSnapshot(identity).id, reviewVersion: service.store.reviewVersion(identity) }); + const head = service.store.getSnapshot(identity).head, rewritten = 'e'.repeat(40); + service.store.recordRebase(identity, reviewed(), 'f'.repeat(40), rewritten, [{ oldSha: head, newSha: rewritten }]); + marker = service.store.beginPrePush(identity, reviewed(), service.store.getTask(identity).stateVersion, + { branch: service.store.taskPullRequests(identity)[0]!.headBranch, from: head, to: rewritten }); + await held; + service.store.finishPrePush(service.store.getTask(identity).planKey, marker.attemptId); + preparing = false; + const snapshot = service.store.getSnapshot(identity); + return service.store.settlePreMergeAction(identity, expected.actionId, + { state: 'failed', base: snapshot.base, head: snapshot.head, checked: [], reason: 'Task cancelled.' }, null); + }, + } as unknown as PreMergeCoordinator; + } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + answered(); + await vi.waitFor(() => expect(served.store.getTask(served.identity).pushInProgress).not.toBeNull(), { timeout: 10_000 }); + expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'stopping' }); + expect(served.store.getTask(served.identity)).toMatchObject({ status: 'in review', cancelRequested: expect.any(String) }); + expect(w.github.prs[0]!.open).toBe(true); + release(); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 10_000 }); + await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); + expect(served.store.getTask(served.identity).status).toBe('cancelled'); + }, 60_000); + + it('stops a waiting merge job promptly on shutdown and leaves it running on disk for the next startup', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, hanging = false; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }], () => hanging); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 30, maxDelayMs: 30 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks' }), { timeout: 10_000 }); + // GitHub stops answering the job's next inspection; only the job's own abort can end that read. + hanging = true; + await vi.waitFor(() => expect(gh.hung).toHaveLength(1), { timeout: 10_000 }); + const database = served.app.service.config.database, closing = Date.now(); + const closed = served.close(); + // Shutdown's first synchronous turn stops the job, before any drain, so it cannot ask GitHub to merge meanwhile. + expect(gh.hung[0]!.aborted).toBe(true); + await closed; + expect(Date.now() - closing).toBeLessThan(10_000); + const reopened = new Store(database); + try { expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'running', step: 'checks' }); } finally { reopened.close(); } + }, 60_000); + + it('closes the merge job before the store, so a job settling after the shutdown abort records its outcome', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, merging: AbortSignal | null = null; + const base = gateway(() => storeRef!, () => []); + // GitHub was asked to merge; after the abort it answers, a little later, with a refusal. + const gh: MergeGateway = { inspect: base.inspect, merge: (_head, options) => new Promise((_resolve, reject) => { + merging = options!.signal!; + merging.addEventListener('abort', () => setTimeout(() => reject(new MergeSubmissionError('Base branch was modified.', 'refused')), 300), { once: true }); + }) }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(() => expect(merging).not.toBeNull(), { timeout: 10_000 }); + const database = served.app.service.config.database, order: string[] = []; + const jobClose = MergeJobCoordinator.prototype.close, storeClose = Store.prototype.close; + const jobs = vi.spyOn(MergeJobCoordinator.prototype, 'close').mockImplementation(async function (this: MergeJobCoordinator) { + await jobClose.call(this); order.push('job settled'); + }); + const stores = vi.spyOn(Store.prototype, 'close').mockImplementation(function (this: Store) { order.push('store closed'); return storeClose.call(this); }); + try { await served.close(); } finally { jobs.mockRestore(); stores.mockRestore(); } + // Shutdown closed the job before the store (close awaiting the job is shown in merge-job.test.ts), and the job's last + // write landed: it ended as its merge attempt records it. + expect(order[0]).toBe('job settled'); + expect(order).toContain('store closed'); + const reopened = new Store(database); + try { + expect(reopened.latestMergeJob(served.identity)).toMatchObject({ state: 'blocked', reason: expect.stringMatching(/^GitHub refused the merge/) }); + expect(reopened.getTask(served.identity).status).toBe('approved but merge blocked'); + } finally { reopened.close(); } + }, 60_000); + + it('stops a waiting merge job when the task is cancelled, and closes the task\'s pull requests', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null; + const gh = gateway(() => storeRef!, () => [{ context: 'ci', appId: null, state: 'pending' }]); + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, runnerPublished: true, + // Waits far longer than this test: only the cancel route's own stop can end the job in time. + mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 60_000, maxDelayMs: 60_000 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks' }), { timeout: 10_000 }); + expect((await act(served.app, 'cancel-task')).body.result).toEqual({ outcome: 'closed' }); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ state: 'cancelled' }), { timeout: 5_000 }); + await vi.waitFor(() => expect(w.github.prs[0]!.open).toBe(false), { timeout: 20_000 }); + expect(gh.merged).toEqual([]); + }, 60_000); + + it('logs, and does not reject, when reading the task fails as the server follows a settled merge job', async () => { + const w = world(); moveUserHead(w); + let storeRef: Store | null = null, release!: () => void; + const answered = new Promise(resolve => { release = resolve; }); + const base = gateway(() => storeRef!, () => []); + // GitHub answers the merge only once the click was answered, so the job settles after the click's own review reload. + const gh: MergeGateway = { inspect: base.inspect, merge: async () => { await answered; return { url: 'https://github.com/acme/app/pull/100' }; } }; + const served = await serve(w, { runnerReview: true, before: mergeableReview, issueGateway, preMerge: preparation, mergeGateway: gh, + runnerPublished: true, mergeJobTiming: { checkWaitMs: 20_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const unhandled: unknown[] = [], onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + const logged = vi.spyOn(console, 'error'); + // The first task read after the job's ending write fails: that is the server's `.finally` following the job. + let failNext = false; + const getTask = served.store.getTask.bind(served.store), finishMergeJob = served.store.finishMergeJob.bind(served.store); + served.store.finishMergeJob = ((...args: Parameters) => { const ended = finishMergeJob(...args); failNext = true; return ended; }) as Store['finishMergeJob']; + served.store.getTask = ((identity: PlanIdentity) => { + if (failNext) { failNext = false; throw new Error('database is locked'); } + return getTask(identity); + }) as Store['getTask']; + try { + expect((await merge(served.app, (await review(served.app)).token)).status).toBe(200); + release(); + await vi.waitFor(() => expect(served.store.latestMergeJob(served.identity)).toMatchObject({ state: 'merged' }), { timeout: 10_000 }); + // The server's callback runs a few microtasks after the ending write; an unhandled rejection is reported after them. + await new Promise(resolve => setTimeout(resolve, 50)); + expect(failNext).toBe(false); + expect(unhandled).toEqual([]); + expect(logged).toHaveBeenCalledWith('Could not close the cancelled task\'s pull requests after its merge job: "database is locked"'); + } finally { + process.off('unhandledRejection', onUnhandled); + served.store.getTask = getTask; served.store.finishMergeJob = finishMergeJob; logged.mockRestore(); + } + }, 60_000); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 4ed4e74b..bc4cbc63 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -121,7 +121,7 @@ it('scopes issue trust to repository and current author, supports revoke, and mi legacy.exec('DROP TABLE issue_trust; ALTER TABLE attempts DROP COLUMN prompt_comments; PRAGMA user_version=16;'); legacy.close(); const migrated = open(path), db = new DatabaseSync(path); try { - expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 18 }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 }); expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='issue_trust'").get()).toEqual({ name: 'issue_trust' }); expect(db.prepare('PRAGMA table_info(attempts)').all().some(column => column.name === 'prompt_comments')).toBe(true); expect(migrated.issueTrust('owner/a', 5)).toBeNull(); diff --git a/web/public/app.js b/web/public/app.js index eee8c730..5ed6783e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -36,6 +36,9 @@ let snippetSelection = null; // Kept until the server answers, so a resend after a lost response replays the same click instead of merging twice. // The key travels with the exact request it was made for: a later Refresh changes data.token, not this request. let mergeAction = null; +// True while a Merge PR click's request is in flight: a render meanwhile (selecting an item, a plan reload) keeps the +// control marked and labelled as pending instead of offering it again from `merge.ready`. +let mergeClickPending = false; const statusClass = (text) => text.startsWith("✓") ? "good" @@ -65,7 +68,8 @@ async function api(path, body) { body: body ? JSON.stringify(body) : undefined, }); const value = await response.json(); - if (!response.ok) throw Object.assign(new Error(value.error || "Request failed."), { status: response.status, outcomeUnknown: value.outcomeUnknown === true }); + if (!response.ok) throw Object.assign(new Error(value.error || "Request failed."), { status: response.status, outcomeUnknown: value.outcomeUnknown === true, + ...(value.job && typeof value.job === "object" ? { job: value.job } : {}) }); return value; } // Before the first review load no item is selected; text typed then is kept under "" until render() selects an item. @@ -145,11 +149,11 @@ async function refresh() { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); snippetSelection = null; render(); - if (newerQueue) showMergeQueueStatus(newerQueue); + showNewerMergeObservations(newer); setPlanStatus(planStatusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== reviewGeneration) return; @@ -171,8 +175,44 @@ function renderMerge() { if (!merge?.available) return; // Once the attempt this retained key started has ended, a retry is a new action and needs a new key. if (mergeAction && merge.queue?.actionId === mergeAction.actionId && ["merged", "removed", "failed"].includes(merge.queue.state)) mergeAction = null; - $("merge").disabled = !merge.ready; - $("merge").textContent = merge.action === "retry" + // Likewise once the merge job this key started has ended (a job is saved under its click's action ID), or a job newer + // than the one shown when the key was made is the latest: one job runs at a time, so this key's job has ended or never + // started. Either way a click is a new action. The job shown when the key was made says nothing about its click. + if (mergeAction && merge.job && (merge.job.id === mergeAction.actionId ? merge.job.state !== "running" : merge.job.id !== mergeAction.jobBefore)) + mergeAction = null; + // With merge jobs (their status carries `job`), a click's answer arrives while the control may hold focus, so it is + // marked, never disabled; activation is ignored while the merge is not ready. + if ("job" in merge) { + $("merge").disabled = false; + if (merge.ready) $("merge").removeAttribute("aria-disabled"); + else $("merge").setAttribute("aria-disabled", "true"); + } else { + $("merge").disabled = !merge.ready; + $("merge").removeAttribute("aria-disabled"); + } + if (mergeClickPending) { + if ("job" in merge) $("merge").setAttribute("aria-disabled", "true"); + else $("merge").disabled = true; + $("merge").textContent = "Requesting merge…"; + $("merge-details").textContent = merge.queue ? "Merge status" : "Review blockers"; + scheduleMergePoll(); + return; + } + const job = merge.job; + if (job?.state === "running") { + // Keep the control focusable while its job runs; repeat activation is ignored because the merge is not ready. + $("merge").setAttribute("aria-disabled", "true"); + $("merge").textContent = mergeJobStepLabel(job); + $("merge-details").hidden = false; + $("merge-details").textContent = "Merge status"; + scheduleMergePoll(); + return; + } + // The page saw its job end and has not reloaded: name the outcome, not the blockers the job already resolved. + const jobEnded = merge.blockers.some(blocker => blocker.code === "job-refresh"); + $("merge").textContent = jobEnded + ? endedMergeJobLabel(job) + : merge.action === "retry" ? "Retry merge" : merge.queue?.state === "submitting" ? "Submitting…" @@ -183,15 +223,16 @@ function renderMerge() { : merge.ready ? "Merge PR" : `${merge.blockers.length} blocker${merge.blockers.length === 1 ? "" : "s"}`; - $("merge-details").textContent = merge.queue ? "Merge status" : "Review blockers"; + $("merge-details").textContent = merge.queue || jobEnded ? "Merge status" : "Review blockers"; scheduleMergePoll(); } function scheduleMergePoll() { if (mergePollTimer) clearTimeout(mergePollTimer); mergePollTimer = null; const queue = data?.merge?.queue; - const state = queue?.state; - if (queue?.kind === "queue" && ["submitting", "queued"].includes(state)) { + const job = data?.merge?.job; + const state = job?.state === "running" ? `job:${job.step}` : queue?.state; + if (job?.state === "running" || (queue?.kind === "queue" && ["submitting", "queued"].includes(state))) { if (state !== mergePollState) { mergePollState = state; mergePollDelay = 2000; @@ -210,6 +251,26 @@ async function pollMergeQueue(generation) { try { const update = await api("/api/merge"); if (generation !== mergeGeneration || !data?.merge?.available) return; + if (data.merge.job?.state === "running") { + // A full review response read before this observation must not restore an older record of this job. + mergeObservationGeneration++; + // A merge job (F6b): follow its step. Once it ends, update only the merge control, as the merge-queue poll does: + // a background reload would erase the person's selection and input; Refresh shows the outcome. + const job = update.job; + if (job?.state === "running") { + const changed = job.step !== data.merge.job.step || JSON.stringify(job.checks) !== JSON.stringify(data.merge.job.checks); + data = { ...data, merge: { ...data.merge, job } }; + $("banner").textContent = mergeJobText(job); + if (!changed) backOffMergePoll(); + renderMerge(); + return; + } + // A job that ended `queued` left its attempt in GitHub's queue: follow it, as after a click answered with it. + data = withEndedJobAndAttempt(data, job, update.queue); + showEndedJobAndAttempt(job, update.queue); + renderMerge(); + return; + } const queue = update.queue; mergeObservationGeneration++; data = withMergeQueue(data, queue); @@ -218,12 +279,50 @@ async function pollMergeQueue(generation) { renderMerge(); } catch (error) { if (generation !== mergeGeneration || !data?.merge?.available) return; - $("banner").textContent = `Could not refresh merge-queue status. ${error.message}`; + $("banner").textContent = `Could not refresh merge status. ${error.message}`; backOffMergePoll(); scheduleMergePoll(); } } -const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge"]); +function mergeJobText(job) { + if (job.state === "running") return job.step === "checks" + ? `Waiting for required checks${job.checks.length ? `: ${job.checks.map(check => `${check.context} is ${check.state}`).join(", ")}` : ""}.` + : job.step === "merging" ? "Merging the reviewed head." : "Preparing the merge: refresh, rebase, push and command checks."; + return { + merged: "Merged.", queued: "Merge queued. Waiting for GitHub to confirm the outcome.", + submitted: `Merge submitted; GitHub's outcome is not confirmed yet. ${job.reason ?? ""}`.trim(), + // The server's reason ends with the next step, which depends on the cause (a timeout waits; a failing check is fixed). + blocked: `Approved, merge blocked. ${job.reason ?? "Retry merge when ready."}`, + "review-required": `Review needed before merging. ${job.reason ?? ""}`.trim(), + // A failed job's reason says why (and anything to do first); the next step is always to merge again, said once here. + failed: `Merge stopped.${job.reason ? ` ${job.reason}` : ""} Merge again when ready.`, cancelled: "Merge cancelled.", + }[job.state] ?? job.state; +} +/** The running job's step, as the Merge control and its status dialog name it. */ +function mergeJobStepLabel(job) { + return { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; +} +const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge", "job-refresh"]); +/** Outcomes of a job that asked GitHub to merge, which it does only once no blocker remains. */ +const mergeJobReachedMerge = new Set(["merged", "queued", "submitted"]); +/** + * The view once the page learns its merge job ended, without a reload: the job's outcome and "Refresh to see the + * result". A job that reached the merge (merged, queued or submitted) settled the blockers the server lists in + * `settledBeforeJobMerge` (preparation, a moved base, `cmd:` checks, required checks, mergeability), so a review read + * before or during it no longer lists them. Any other outcome keeps every blocker the page last read: a blocked job's + * failing check still fails, and Refresh shows what still applies. A job the poll no longer reports (`job` null) is + * not known to have reached the merge, so it also keeps every blocker. The control reads the outcome, as after a queue merge. + */ +function withEndedMergeJob(view, job, message) { + const settled = new Set(mergeJobReachedMerge.has(job?.state) ? view.merge.settledBeforeJobMerge ?? [] : []); + const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code) && !settled.has(blocker.code)); + return { ...view, merge: { ...view.merge, ready: false, action: null, job, blockers: [{ code: "job-refresh", message }, ...blockers] } }; +} +/** The merge control's label for a job the page saw end: its outcome, as the control reads a queue merge's. */ +function endedMergeJobLabel(job) { + return { merged: "Merged", queued: "Merge queued", submitted: "Merge submitted", blocked: "Merge blocked", "review-required": "Review needed", + failed: "Merge stopped", cancelled: "Merge cancelled" }[job?.state] ?? "Merge ended"; +} function withMergeQueue(view, queue) { const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); if (queue?.state === "merged") @@ -234,6 +333,37 @@ function withMergeQueue(view, queue) { blockers.unshift(...view.merge.blockers.filter(blocker => blocker.code === "queue-active")); return { ...view, merge: { ...view.merge, ready: false, action: null, queue, blockers } }; } +/** + * The view once a job is seen ended `queued`, with the merge attempt `queue` the same poll answered. The job's attempt + * (its action ID is the job's `mergeActionId`) is followed as the queue poll follows one: while GitHub's queue holds it, + * the control keeps the job's outcome and the queue poll continues; once it is merged, removed or failed, the control and + * banner read that, as they do for a queue merge. Any other job or attempt leaves the view unchanged. + */ +function withEndedJobQueue(view, job, queue) { + if (!isEndedJobQueue(job, queue)) return view; + return ["submitting", "queued"].includes(queue.state) ? { ...view, merge: { ...view.merge, queue } } : withMergeQueue(view, queue); +} +/** Whether `queue` is the merge attempt a job that ended `queued` left in GitHub's queue (its action ID is the job's `mergeActionId`). */ +function isEndedJobQueue(job, queue) { + return job?.state === "queued" && Boolean(queue?.actionId) && queue.actionId === job.mergeActionId; +} +/** The banner text for a job the page saw end (`job` null: the poll no longer reports it). */ +function endedMergeJobText(job) { + return `${job ? mergeJobText(job) : "The merge job ended."} Refresh to see the result.`; +} +/** + * The view once the page learns its job ended, with the job's own attempt when `queue` is it. The job poll, the click's + * answer and a newer observation kept over a full response all present an ended job through this and + * showEndedJobAndAttempt, so an attempt GitHub already merged, removed or failed keeps its outcome and an active one is polled. + */ +function withEndedJobAndAttempt(view, job, queue) { + return withEndedJobQueue(withEndedMergeJob(view, job, endedMergeJobText(job)), job, queue); +} +/** The banner for withEndedJobAndAttempt: the job's outcome, then its own attempt's status, which takes precedence. */ +function showEndedJobAndAttempt(job, queue) { + $("banner").textContent = endedMergeJobText(job); + if (isEndedJobQueue(job, queue)) showMergeQueueStatus(queue); +} function showMergeQueueStatus(queue) { if (queue?.state === "merged") { $("banner").textContent = "GitHub confirmed the reviewed head was merged."; @@ -268,10 +398,10 @@ async function act(command) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); - data = newerQueue ? withMergeQueue(updated, newerQueue) : updated; + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = withNewerMergeObservations(updated, newer); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); + showNewerMergeObservations(newer); return true; } catch (error) { if (generation !== reviewGeneration) return false; @@ -539,25 +669,72 @@ $("approve").onclick = () => { $("reload").onclick = refresh; $("merge-details").onclick = () => { if (!data?.merge?.available) return; - const queue = data.merge.queue; + const queue = data.merge.queue, job = data.merge.job; + if (job?.state === "running") { + showDialog(`

Merge status

${esc(mergeJobStepLabel(job))}

${esc(mergeJobText(job))}

${job.checks.length + ? `
    ${job.checks.map(check => `
  • ${esc(check.context)}: ${esc(check.state)}
  • `).join("")}
` : ""}`); + return; + } + const jobEnded = data.merge.blockers.some(blocker => blocker.code === "job-refresh"); showDialog( - `

${queue ? "Merge status" : "Merge blockers"}

${queue ? `

${esc(queue.state)} · reviewed head ${esc(queue.reviewedHead.slice(0, 12))}

${queue.phase ? `

GitHub phase: ${esc(queue.phase)}${queue.position === null ? "" : ` · position ${queue.position}`}

` : ""}${queue.reason ? `

${esc(queue.reason)}

` : ""}${queue.observationError ? `

${esc(queue.observationError)}

` : ""}` : ""}
    ${data.merge.blockers.map((blocker) => `
  • ${esc(blocker.message)}
  • `).join("")}
`, + `

${queue || jobEnded ? "Merge status" : "Merge blockers"}

${queue ? `

${esc(queue.state)} · reviewed head ${esc(queue.reviewedHead.slice(0, 12))}

${queue.phase ? `

GitHub phase: ${esc(queue.phase)}${queue.position === null ? "" : ` · position ${queue.position}`}

` : ""}${queue.reason ? `

${esc(queue.reason)}

` : ""}${queue.observationError ? `

${esc(queue.observationError)}

` : ""}` : ""}
    ${data.merge.blockers.map((blocker) => `
  • ${esc(blocker.message)}
  • `).join("")}
`, ); }; +/** + * The merge click's confirmation. A direct retry merges the exact reviewed head again; a merge job's retry prepares + * again first, and a rebase there makes a new head and snapshot (docs/implementation/merge-handoff.md, Outcomes). + */ +function mergeConfirmText(merge) { + if (merge.action !== "retry") return "Merge this reviewed pull request?"; + return "job" in merge + ? "Retry the merge? It prepares the merge again, which may rebase onto the current base and need a new review." + : "Retry merging this exact reviewed head?"; +} $("merge").onclick = async () => { - if (busy || planImportPending || !data?.merge?.ready || !window.confirm(data.merge.action === "retry" ? "Retry merging this exact reviewed head?" : "Merge this reviewed pull request?")) return; + if (busy || planImportPending || !data?.merge?.ready || !window.confirm(mergeConfirmText(data.merge))) return; supersedePlanRefresh(); const busyOwner = beginBusy("write"); reviewGeneration++; mergeGeneration++; mergePollState = null; mergePollDelay = 2000; - $("merge").disabled = true; + // With merge jobs, the click returns at once and the focused control must keep focus: mark it, do not disable it. + if (data.merge && "job" in data.merge) $("merge").setAttribute("aria-disabled", "true"); + else $("merge").disabled = true; + mergeClickPending = true; + $("merge").textContent = "Requesting merge…"; try { rememberDraft(); - mergeAction ??= { actionId: crypto.randomUUID(), token: data.token }; + mergeAction ??= { actionId: crypto.randomUUID(), token: data.token, jobBefore: data.merge.job?.id ?? null }; const updated = await api("/api/action", { action: "merge", token: mergeAction.token, actionId: mergeAction.actionId }); + mergeClickPending = false; + // The composer stayed editable while the request was in flight: keep what the person typed meanwhile. + rememberDraft(); mergeAction = null; + if (updated.job) { + // A merge job runs in the background (F6b); the page follows it. When the server could not reload the review after + // starting the job, it answers only the job: keep the page's review and follow the job on it. A resend can replay + // this click's ended job while a later one (another tab's retry) runs: the reloaded review then carries that later + // job, and the page follows the later record (by sequence, or revision within one job). + const job = !updated.mergeRefreshRequired && updated.merge?.job && laterMergeJob(updated.merge.job, updated.job) ? updated.merge.job : updated.job; + data = updated.mergeRefreshRequired + ? { ...data, merge: { ...data.merge, ready: false, action: null, job } } + : { ...updated, merge: { ...updated.merge, job } }; + if (job.state !== "running") { + // The job had already ended when the click was answered: say how to see its result, as the job poll does when a + // job ends. A job that ended `queued` is shown with its own attempt, as the poll shows it: another client's poll + // may have seen that attempt merged, removed or failed before this click's review reload read it, or it is still + // queued and the queue poll follows it. + const queue = data.merge.queue; + data = withEndedJobAndAttempt(data, job, queue); + render(); + showEndedJobAndAttempt(job, queue); + return; + } + render(); + $("banner").textContent = mergeJobText(job); + return; + } const queued = updated.mergeQueue?.state === "queued" || updated.mergeQueue?.state === "submitting"; const blocker = { code: queued ? "queue-active" : "merge-submitted", message: queued ? "The reviewed head is queued. Waiting for GitHub to confirm the outcome." : "Merge was submitted. Refresh to confirm GitHub state." }; data = updated.mergeRefreshRequired @@ -566,9 +743,19 @@ $("merge").onclick = async () => { render(); $("banner").textContent = `${queued ? "Merge queued" : "Merge submitted"}. ${updated.mergeResult.url}`; } catch (error) { + mergeClickPending = false; + rememberDraft(); // Only a parsed, definite server answer resolves this click. No response, an unreadable body, 503 (nothing // applied) or an admitted attempt with an unknown outcome keeps the key until that attempt ends. if (typeof error.status === "number" && error.status !== 503 && !error.outcomeUnknown) mergeAction = null; + // A merge-job refusal carries the task's latest job. When that is a running job later than the one this page shows + // (another tab's click started it), follow it as the poll would, instead of asking for a refresh. + if (error.job?.state === "running" && data.merge && "job" in data.merge && laterMergeJob(error.job, data.merge.job)) { + data = withMergeJobObservation(data, error.job); + render(); + $("banner").textContent = `${error.message} ${mergeJobText(error.job)}`; + return; + } data = { ...data, merge: { ...data.merge, ready: false, blockers: [{ code: "stale-merge", message: `${error.message} Refresh before trying again.` }] } }; render(); $("banner").textContent = `Merge blocked. ${error.message}`; @@ -840,6 +1027,58 @@ function preserveSettledQuestionAnswers(updated) { : note; }) }; } +/** + * A poll's record of the task's latest job, when the poll saw it later than a full response read before it. Records of + * one job order by the server's per-job revision; a later job (another tab's click) outranks any record of an earlier one, by the + * server's per-task job sequence, never by clock time. + */ +function newerMergeJob(updated, observationOwner) { + const newer = data?.merge?.job, older = updated.merge?.job; + if (observationOwner === mergeObservationGeneration || !newer || !older) return null; + return laterMergeJob(newer, older) ? newer : null; +} +/** + * Whether `newer` is a later record than `older` (none counts as earliest): by revision within a job, else by sequence. + * Never by clock time: two saves can share an update time, and the clock can step back between them. + */ +function laterMergeJob(newer, older) { + if (!older) return true; + return newer.id === older.id ? Number.isSafeInteger(newer.revision) && Number.isSafeInteger(older.revision) && newer.revision > older.revision + : Number.isSafeInteger(newer.sequence) && Number.isSafeInteger(older.sequence) && newer.sequence > older.sequence; +} +/** + * The view with a newer observation of its job, as the job poll shows it: a running job is not ready to merge (the + * full response may offer Retry for an earlier job), and an ended job asks for a refresh, with its own attempt `queue`. + */ +function withMergeJobObservation(view, job, queue = null) { + // Removing `job-refresh` here is defensive: the server never sends it, and the page adds it only once it saw a job + // end, after which nothing makes the merge ready again (so no refused click carries a running job) until Refresh. + if (job.state === "running") return { ...view, merge: { ...view.merge, ready: false, action: null, job, + blockers: view.merge.blockers.filter(blocker => blocker.code !== "job-refresh") } }; + return withEndedJobAndAttempt(view, job, queue); +} +/** + * The poll's observations that a full response read before them must keep: the merge attempt (`queue`), the task's latest + * job (`job`), and, for a kept job that ended `queued`, the attempt the poll followed for it (`jobQueue`, whose action ID + * is the job's `mergeActionId`). The full response's own queue may be none or another attempt's, so `queue` alone would + * drop the attempt the poll saw merged, removed or failed, or still queued and polled. + */ +function newerMergeObservations(updated, observationOwner) { + const queue = newerMergeQueue(updated, observationOwner), job = newerMergeJob(updated, observationOwner), followed = data?.merge?.queue; + const jobQueue = isEndedJobQueue(job, followed) ? followed : null; + return { queue, job, jobQueue }; +} +/** The full response `view` with the newer observations kept, as the poll presented them. */ +function withNewerMergeObservations(view, { queue, job, jobQueue }) { + const kept = queue ? withMergeQueue(view, queue) : view; + return job ? withMergeJobObservation(kept, job, jobQueue) : kept; +} +/** The banner for the newer observations kept, as the poll showed them; call after render. */ +function showNewerMergeObservations({ queue, job, jobQueue }) { + if (queue) showMergeQueueStatus(queue); + if (job?.state === "running") $("banner").textContent = mergeJobText(job); + else if (job) showEndedJobAndAttempt(job, jobQueue); +} function newerMergeQueue(updated, observationOwner) { const newer = data?.merge?.queue, older = updated.merge?.queue; const fullResponseIsTerminal = ["merged", "removed", "failed"].includes(older?.state); @@ -1262,10 +1501,10 @@ async function importPlan(event) { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); + showNewerMergeObservations(newer); if ($("plan-file").files[0] === file) $("plan-file").value = ""; renderPlanFile(); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); @@ -1448,10 +1687,10 @@ async function applyPlanAuthor(index) { mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); + showNewerMergeObservations(newer); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { request.observationError = `Revision r${revision} was applied, but the current plan could not reload. ${error.message}`; @@ -1710,10 +1949,10 @@ $("plans-refresh").onclick = async () => { mergePollState = null; mergePollDelay = 2000; rememberDraft(); - const newerQueue = newerMergeQueue(updated, mergeObservationOwner); - data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + const newer = newerMergeObservations(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(withNewerMergeObservations(updated, newer)); render(); - if (newerQueue) showMergeQueueStatus(newerQueue); + showNewerMergeObservations(newer); setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { if (generation !== planOperationGeneration || sharedGeneration !== reviewGeneration) return; diff --git a/web/public/style.css b/web/public/style.css index f767008a..1272ef7d 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -100,7 +100,7 @@ button[aria-disabled="true"] { } button.primary, #approve, -#merge:not(:disabled) { +#merge:not(:disabled):not([aria-disabled="true"]) { background: var(--primary); color: var(--on-primary); border-color: var(--primary); @@ -112,7 +112,7 @@ button.primary[aria-disabled="true"] { border-color: var(--line); } #approve:hover, -#merge:not(:disabled):hover { +#merge:not(:disabled):not([aria-disabled="true"]):hover { background: var(--hover); } [hidden] { diff --git a/web/server.ts b/web/server.ts index df503cad..6e566193 100644 --- a/web/server.ts +++ b/web/server.ts @@ -24,6 +24,7 @@ import type { AuthorProvider } from '../core/planning-author.ts'; import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; import { MAX_SOURCE_BYTES } from '../core/parse-v1.ts'; import type { PreMergeCoordinator } from '../runner/pre-merge.ts'; +import { MergeJobCoordinator, withMergeJob, type MergeJobTiming } from '../runner/merge-job.ts'; export type PlanningDescription = Pick & { repo: { name: string; baseRef: string }; /** Revalidates any mutable authority carried by this description at the synchronous prompt-construction boundary. */ @@ -70,12 +71,16 @@ export const RUNNER_NOT_IN_DEMO = 'Demos do not run the runner. Use a review con export interface ServerTestHooks { /** Test-only gate for forcing a response lifecycle transition before the Issues wait is armed. */ beforeIssueRefreshWait?(): Promise | void; + /** Shorter required-check waits for tests of the one-click merge (F6b). */ + mergeJobTiming?: MergeJobTiming; } export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent, mergeGateway?: MergeGateway, shutdownDrainMs = MAX_SHUTDOWN_DRAIN_MS, issueGateway?: IssueGateway, runnerDeps?: RunnerDeps, planningInput?: PlanningDeps | PlanningSetup, runnerSetup?: RunnerSetup, testHooks: ServerTestHooks = {}) { if (!Number.isSafeInteger(shutdownDrainMs) || shutdownDrainMs < 1 || shutdownDrainMs > MAX_SHUTDOWN_DRAIN_MS) throw new Error('Invalid shutdown drain deadline.'); const service = new ReviewService(config), token = randomBytes(32).toString('hex'); let questions: Questions, merges: MergeCoordinator | null, issues: IssueBoard, runner: RunnerCoordinator | null, suggestions: SuggestionCoordinator | null; let preMerge: PreMergeCoordinator | null = null; + /** The one-click merge (F6b of #22); only with a runner-published PR, which requires preparation. */ + let mergeJobs: MergeJobCoordinator | null = null; let planning: PlanningDeps | undefined, issueSource: IssueGateway | null; /** Runs a task's plan items; one per Store, like the coordinator. Only the production runner has one. */ let executor: ItemExecutor | null = null; @@ -129,6 +134,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge validate: () => requireTrustedIssue(access), }; }); + if (preMerge && merges && merges.published?.requiresPreparation) + mergeJobs = new MergeJobCoordinator(service, preMerge, merges, capability, testHooks.mergeJobTiming); executor = new ItemExecutor(service.store, runner, assembly.sources, assembly.findings, { capability }); // A demo never publishes, whatever its github block or an injected setup provides (setUpRunner refuses demos too). if (assembly.publisher && !config.demo) { const coordinator = runner; publishing = new TaskPublishing(service.store, assembly.publisher(() => coordinator.closing), runner, executor, capability, assembly.env, { @@ -142,7 +149,13 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge } // History reading and linking run in a worker (runner/history-job.ts); `signal` ends with the request at shutdown. const loadReview=async(signal?:AbortSignal)=>{const view=await service.load({signal});return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};}; - const load=async(signal?:AbortSignal)=>{const view=await loadReview(signal);return {...view,merge:merges?await merges.displayStatus(view,signal):{available:false}};}; + const mergeView=async(view:Awaited>,signal?:AbortSignal)=>{ + if(!merges)return {available:false}; + const status=await merges.displayStatus(view,signal); + // With a runner, Merge PR starts a merge job, which itself prepares and waits for required checks (F6b of #22). + return mergeJobs?withMergeJob(status,mergeJobs.latest(),service.store.getTask(identity).status):status; + }; + const load=async(signal?:AbortSignal)=>{const view=await loadReview(signal);return {...view,merge:await mergeView(view,signal)};}; const answerStatuses=()=>service.store.getReviewNotes(config.identity) .filter(note=>note.kind==='question') .map(note=>({id:note.id,answer:note.answer,answerActive:questions.isRunning(note.id)})); @@ -207,6 +220,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (executor.busy(identity)) throw new GuardRefusal('An earlier run of this task is still finishing; try again when that run has ended.'); // A publish reads the task head and pushes it; a new item must not move it meanwhile. if (publishing?.busy(identity)) throw new GuardRefusal('A pull request is being published for this task; try again when it has finished.'); + // A merge job prepares, waits for checks and merges the PR's head. A run would move that head, and its end publishes + // (pushes the head, refreshes the PR) under the job. Before owed work too: settling it ends in the same publish. + if (mergeJobs?.active) throw new GuardRefusal('A merge is running for this task; wait for it to end or cancel the task.'); // Recovery owns this claim for Resume even when owed work will settle instead of admitting an item. Start must not // consume it and strand the task behind the resulting human gate. if (action === 'start' && task.requeuePending) throw new GuardRefusal('Recovery left this task to requeue; it cannot start until Resume resolves that claim.'); @@ -420,6 +436,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge service.store.afterCommit(() => afterPreMerge(preMerge!.settlePendingPush())); return { outcome: 'stopping' as const }; } + // A merge job stops its wait at once; its preparation and guarded merge stop through their own routes. + if (mergeJobs?.active) service.store.afterCommit(() => mergeJobs!.cancel()); const outcome = preMerge?.active ? preMerge.cancelTask(expectedStateVersion as number, actionId as string) : runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) @@ -434,6 +452,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (action === 'prepare-merge') { if (access) requireTrustedIssue(access); if (!preMerge) throw new GuardRefusal('Pre-merge preparation is not configured.'); + if (mergeJobs?.active) throw new GuardRefusal('A merge is running for this task; it prepares the merge itself.'); if (runner.isActive(identity) || executor?.busy(identity) || publishing?.busy(identity)) throw new GuardRefusal('The runner or publisher is busy; pre-merge preparation cannot start yet.'); preMerge.assertStartable(); @@ -623,7 +642,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (stopping) { json(503, { error: 'The review server is shutting down.' }); return; } if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; } if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:answerStatuses()});return; } - if (req.method === 'GET' && path === '/api/merge') { if(!merges)throw new Error('Merging is not configured for this review.');json(200,{queue:await merges.pollQueue()});return; } + if (req.method === 'GET' && path === '/api/merge') { if(!merges)throw new Error('Merging is not configured for this review.');json(200,{queue:await merges.pollQueue(),job:mergeJobs?.latest()??null});return; } if (req.method === 'GET' && path === '/api/issues') { json(200, issues.view()); return; } if (req.method === 'GET' && path === '/api/review') { json(200, await load(requestAbort.signal)); return; } if (req.method === 'GET' && path === '/api/runner') { json(200, runnerView()); return; } @@ -703,6 +722,70 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // Every merge click carries its idempotency key; a request without one would bypass replay. if(!isUuidV4(input.actionId)) { json(400,{error:'A merge request needs a UUID v4 actionId.'}); return; } if(!merges)throw new Error('Merging is not configured for this review.'); + // With a runner, one click starts the whole merge job (F6b of #22) and returns at once; the browser follows it + // through GET /api/merge. + if(mergeJobs) { + // Trust is admitted before the job starts, as for prepare-merge: an untrusted issue's click is refused, and the + // refusal saved. A failed read decides nothing: it saves nothing, and the same click may be sent again. + let untrusted: string|null=null; + if(config.github&&!mergeJobs.saved(input.actionId)) { + // Defensive: unreachable today. The merge check after the body is parsed already answers 503 once `stopping` + // is set, and nothing awaits between it and here; `runner.closing` is set only by shutdown, in the same turn + // as `stopping`. It keeps a later await added above from sending a trust read to GitHub during shutdown. + if(stopping||runner?.closing)throw new ShuttingDownError(); + let access: IssueAccess|null=null; + try { access=await readIssueAccess(requestAbort.signal); } + catch (error) { + // A resend of this click may have saved its job while this read ran: that job is the answer (start replays it). + if(!mergeJobs.saved(input.actionId)) { + // The read's own timeout, or gh's answer, came first: a shutdown that started while gh stopped does not + // replace it. Otherwise a read stopped by shutdown decides nothing: 503, and the browser resends the click. + if(!timedOut(error)&&!ghAnswered(error)&&(stopping||runner?.closing||requestAbort.signal.aborted))throw new ShuttingDownError(); + throw error; + } + } + if(access) try { requireTrustedIssue(access); } catch (error) { if(!(error instanceof GuardRefusal))throw error; untrusted=error.message; } + } + let job: Awaited>; + // The executor term is defensive. Admission needs a task in review (or approved, merge blocked), and an executor + // run holds its task out of those statuses: an item run is queued or running (admission refuses item work in + // review), only a publish moves a task to in review, and it does so only after the run has returned (a publish + // refuses while the executor is busy). A run started from review that only settles owed work ends synchronously, + // so it is busy only for the microtasks before its promise settles. + try { + job=await mergeJobs.start(input.token,input.actionId,()=>untrusted??(runner?.isActive(identity)||executor?.busy(identity)||publishing?.busy(identity) + ?'The runner or publisher is busy; the merge cannot start yet.':null),requestAbort.signal); + } catch (error) { + // A refusal (new or replayed) carries the task's latest job, so a page holding an older review, such as a + // second tab, follows the job another click started. A saved refusal is still never shown as a job. + if(error instanceof GuardRefusal) { json(409,{error:error.message,job:mergeJobs.latest()}); return; } + throw error; + } + const running=mergeJobs.current; + // The job's run never rejects, but this callback's own promise would: it must not throw (an unhandled rejection + // exits the server), so a failed read or close start is logged instead. The `stopping` term is defensive: + // shutdown sets it in the same synchronous turn as it closes publishing, and a closing publisher starts no close + // (its `owed` reads the shutdown refusal as nothing owed) and has already aborted any publish in progress. The + // cancelled task's pull requests are closed by the next startup, whose publish of owed work finds the close owed. + if(running)void running.finally(()=>{ + try { if(publishing&&!stopping&&service.store.getTask(identity).status==='cancelled')publishing.taskCancelled(identity); } + catch (error) { console.error(`Could not close the cancelled task's pull requests after its merge job: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } + }); + // The job is saved, so its click is settled. The reload awaits a history worker (#170) while the job's preparation + // writes, so it can fail without anything being wrong with the job: answer the job, and the page follows it and + // asks for a refresh (as after a direct merge whose reload fails). A reload stopped by shutdown answers 503, and + // the resent click replays the job. + let loaded: Awaited>; + try { loaded=await load(requestAbort.signal); } + catch (error) { + if(stopping||runner?.closing||requestAbort.signal.aborted)throw new ShuttingDownError(); + const latest=mergeJobs.latest(); + json(200,{job:latest?.id===job.id?latest:job,mergeRefreshRequired:true});return; + } + // The job may have moved on (or ended) while the review reloaded: answer its latest record, not the admission's. + const latest=mergeJobs.latest(); + json(200,{...loaded,job:latest?.id===job.id?latest:job});return; + } let merged: Awaited>; // A merge stopped for a passing reason applied nothing, so answer 503 and the browser resends the same key. try { merged=await merges.merge(input.token,input.actionId); } @@ -807,6 +890,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge url: `http://127.0.0.1:${address.port}/#${token}`, close: async () => { // Step 1, one synchronous turn: reject new API requests and new runner work. Admitted requests drain (step 2). stopping = true; + // Same turn: a merge job (F6b) stops before it can ask GitHub to merge; its settlement is awaited below. + mergeJobs?.stop(); // Same turn as the admission flag: a request already reading its body must not start a new Ask worker. questions.stopAdmission(); runner?.rejectAdmission(); @@ -828,7 +913,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge // whole pre-merge lifecycle before the write gate closes, so those settlement writes cannot be refused. const failures: unknown[] = publishingFailure === undefined ? [] : [publishingFailure]; const step = async (run: () => Promise | unknown) => { try { await run(); } catch (error) { failures.push(error); } }; - await step(() => preMerge?.close()); + // A merge job awaits its preparation, so both stop together: the job is aborted while preparation is. + await step(() => Promise.all([mergeJobs?.close(), preMerge?.close()])); // Step 3: after the drain, close the Store write gate. A request-path write still pending after the abort // (for example merge reconciliation after a GitHub await) now fails with 503; settling coordinators keep the capability. service.store.closeWrites();