diff --git a/AGENTS.md b/AGENTS.md index 556c1fcc..d4a97ddf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ For features with background jobs, polling, retries, cancellation, or shutdown: - Preserve completed historical results, but visibly mark them stale when their snapshot, plan revision, assignment, or referenced code no longer matches. - When polling updates one part of the screen, update only that state. Preserve scroll position unless the user was already following the bottom. - While a request is in flight, do not disable the control that has keyboard focus; disabling it drops focus to the page. Mark it `aria-disabled`, ignore repeat activation with an in-flight guard, and test that focus stays on the control after the response. +- When a background update (a poll, a reload, an answer) hides or removes the control that has keyboard focus, move focus to a named fallback that is still shown and not `disabled` (an `aria-disabled` control qualifies) once the update has set every control's final state, and test that focus lands there for each way the control can be hidden. Decide a focusable control's visibility once per update: hiding it and showing it again blurs it. - When a row or control's visual selection determines the current content or input, expose the same state with the appropriate accessibility attribute, such as `aria-current` or `aria-selected`, and test it across navigation. ## Required race regressions @@ -53,6 +54,8 @@ Every reproduced race requires a failing-before and passing-after regression. As A race regression must assert that the interleaving it names actually happened (for example which abort stopped which stage, or that a deadline fired before a release) before it uses the outcome as evidence. Otherwise machine load can turn it into a test of a different order that still passes. +A check that an event changed nothing (a late answer, a stale poll, a reload read earlier) must first prove the page handled that event, then read the state once: wait for a render or a state the event causes, or for a request the page sends only after handling it, and hold any later answer that could repair the state before the read. A fixed wait, the network response alone, or a retrying assertion on a state that already held before the event is not that proof: a page that handles the event late, or wrongly, then passes. A check that nothing is sent after an event can only be made over a window: wait longer than the shortest delay after which the page would send it, and say so beside the check. + ## Review readiness - Before requesting or re-requesting an automated Copilot review, self-review the full current diff, fix every issue found, and repeat the self-review and fix cycle until a complete pass finds no new issues. Re-run the relevant validation after fixes, then complete the mandatory independent-agent review loop below before requesting Copilot review. diff --git a/DESIGN.md b/DESIGN.md index caaea175..dcd77d19 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -144,9 +144,11 @@ components: | Token | Meaning | Icon and word | |---|---|---| | `success` | OK: passed, in scope, approved | ✓ plus a word such as "OK" or "Passed" | -| `warning` | Attribution warning, stale | ! plus a word such as "Stale" or "Ambiguous" | +| `warning` | Attribution warning, stale, or a step that did not finish normally | ! plus a word such as "Stale" or "Ambiguous" ("Stopped" for a cancelled merge step, "Unknown" when GitHub's merge outcome is not known) | | `error` | Scope or test failure, blocker, unplanned | ✕ plus a word such as "Fail" or "Unplanned" | -| `neutral-status` | Not applicable | – plus "N/A" | +| `neutral-status` | Not applicable | – plus "N/A" (or "Skipped" for a step that was not needed) | +| `neutral-status` | Not started yet (a step that is waiting) | ○ plus "Waiting" | +| `text` | In progress (a step that is running) | ● plus "Running" | - `success` is much lighter than `error`. This lets red-green color-blind users still tell them apart by brightness. Never rely on hue alone. - **Contrast (checked 2026-09-22):** every text and status color is at least 5.0:1 on every surface. Body text is 14.6:1 on `surface`. @@ -174,7 +176,7 @@ components: | 1280–1439px | The conversation pane collapses to a tab. The plan list narrows to 208px | | Below 1280px | A notice asks for a wider window | -**Frame:** a 44px app bar (the shared menu, design review D13), then a 48px review strip (issue, PR, revision, progress, blockers), then full-height panes. Panes can be resized and collapsed. +**Frame:** a 44px app bar (the shared menu, design review D13), then a 48px review strip (issue, PR, revision, progress, blockers), then, while a merge job runs and until the review reloads, the merge step bar, then full-height panes. Panes can be resized and collapsed. **Rhythm:** a 4px base grid. Rows are 32px. Pane padding is 12px. Space between sections is 16px or 24px. Keep density tight inside lists and give the code pane the most room. @@ -257,3 +259,7 @@ There are no shadows and no glows. Depth comes from the surface brightness steps | 2026-09-22 | The serif "second voice" for the user's own words was not adopted | The user chose variant B. Two faces are enough | | 2026-09-22 | Logo: terminal-prompt mark (`>_`) in a rounded square, in `text` color | Taken from the approved review-screen mockup. It was left out of the first HTML screen and restored at the user's request | | 2026-09-22 | Contrast-checked values kept instead of values extracted from the mockup image | The extracted values (for example error #DA1E28) failed contrast on dark surfaces | +| 2026-10-21 | Two progress marks for the merge step list: ○ "Waiting" in `neutral-status` and ● "Running" in `text` | Steps that have not started or are under way are not outcomes, so they take no outcome color. Approved by the user (PR #194) | +| 2026-10-21 | The not-applicable mark also reads "Skipped" for a merge step that was not needed | Same meaning as N/A, worded for a step. Part of the step list the user approved (PR #194) | +| 2026-10-21 | The warning mark also reads "Stopped" (a cancelled merge step) and "Unknown" (GitHub's merge outcome not known) | Both mean "look at this, it did not finish normally". Approved by the user (PR #194) | +| 2026-10-21 | While a merge job runs, and after it ends until the review reloads, a step bar sits between the review strip and the panes | It shows the merge step list and Cancel merge without replacing the review strip's merge control. Approved by the user (PR #194) | diff --git a/README.md b/README.md index 5b3f2361..e4f91dfb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Review agent-made Git changes one plan item at a time. The approved plan lists each item's files and acceptance checks; the review engine shows which item produced each change and flags foreign or overlapping work. -**Status:** the plan/linking library, SQLite store, local review screen, Plans screen (plan import, plus agent drafts and suggestions with revision-bound Apply; drafts and suggestions need a configured `github` review, not the demo), opt-in code-writing runner, durable local rebase, exact-head plan command checks, and the one-click merge job are implemented. Run `npm run demo` and open its private local URL. Ask, planning and the runner use Claude Code inside locked-down containers; Codex is refused because it reads code only by running commands ([why](docs/implementation/agent-isolation.md#codex-is-refused-in-every-phase)). They need Docker, plus `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`). Startup builds the agent image, which can take a few minutes. A configured GitHub review can merge only after the guarded exact-head gate passes. The agent container, vendor-only network, credential-free command-check profile, and Claude/Codex adapters are described in [agent isolation](docs/implementation/agent-isolation.md). With a runner, the **Merge PR** button pushes the rebased head, waits for its required checks, and hands that exact pair to the guarded merge ([merge handoff](docs/implementation/merge-handoff.md)). The paired human review experiment was cancelled before results were recorded and no longer blocks roadmap work; optional future validation is tracked in [#19](https://github.com/codeabovelab/codeboost/issues/19). +**Status:** the plan/linking library, SQLite store, local review screen, Plans screen (plan import, plus agent drafts and suggestions with revision-bound Apply; drafts and suggestions need a configured `github` review, not the demo), opt-in code-writing runner, durable local rebase, exact-head plan command checks, and the one-click merge job (with its step list and a **Cancel merge** button) are implemented. Run `npm run demo` and open its private local URL. Ask, planning and the runner use Claude Code inside locked-down containers; Codex is refused because it reads code only by running commands ([why](docs/implementation/agent-isolation.md#codex-is-refused-in-every-phase)). They need Docker, plus `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`). Startup builds the agent image, which can take a few minutes. A configured GitHub review can merge only after the guarded exact-head gate passes. The agent container, vendor-only network, credential-free command-check profile, and Claude/Codex adapters are described in [agent isolation](docs/implementation/agent-isolation.md). With a runner, the **Merge PR** button pushes the rebased head, waits for its required checks, and hands that exact pair to the guarded merge ([merge handoff](docs/implementation/merge-handoff.md)). The paired human review experiment was cancelled before results were recorded and no longer blocks roadmap work; optional future validation is tracked in [#19](https://github.com/codeabovelab/codeboost/issues/19). ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 86ebafbc..3ec52c13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,9 +42,9 @@ Writing standard: plain language, ISO 24495-1:2023 - **Build state:** - **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). The **Merge PR** button (the design's Approve & merge) merges that pull request (#121). Cancelling a task closes its pull requests (#111). Apart from **Merge PR**, these are API actions; the screen has no buttons for them yet. + - **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). The **Merge PR** button (the design's Approve & merge) merges that pull request (#121). Cancelling a task closes its pull requests (#111). Apart from **Merge PR** and **Cancel merge** (which stops a running merge job without cancelling the task), these are API actions; the screen has no buttons for them yet. - **In use for pre-merge automation (#22, closed):** 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, PR #153), and runs exact-head `cmd:` checks. With a runner, **Merge PR** 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, PR #161, `docs/implementation/merge-handoff.md`). `test` and `real-docker` run every offline T9 suite on every pull request, and the live Claude probes run nightly (F6c, PR #177, `docs/implementation/ci.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. + - **Not built:** screens for the runner actions, review rounds, and the Queue and Learning screens. ## Terms used @@ -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. F6a (PR #153) added the durable push of the rewritten head. F6b (PR #161) added 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 (PR #177) runs every T9 suite in CI and adds the nightly live Claude probes; T9 is complete (PR #178) and #22 is closed; its follow-ups are #174 and #176. T6 and T12 are verified, and PR #183 also fixed reading required checks that come only from a ruleset, for a token with admin rights. Required checks are now also read for a token without admin rights (observed with read-only access to public repositories): classic checks then come from the branch read, which has no `strict`, so classic protection gives such a token no atomic base guard. Startup now refuses a `pathIdentity` that disagrees with a probe of task storage (#162, PR #180), and its recovery removes what an interrupted probe left (#181); follow-up #179. | +| 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. F6b (PR #161) added 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 (PR #177) runs every T9 suite in CI and adds the nightly live Claude probes; T9 is complete (PR #178) and #22 is closed; its follow-ups #176 (PR #195) and #174 (PR #197) are closed, and the merge job's step list and Cancel button are built (PR #194; D16's named failure banners and the Queue row are not, #199). T6 and T12 are verified, and PR #183 also fixed reading required checks that come only from a ruleset, for a token with admin rights. Required checks are now also read for a token without admin rights (observed with read-only access to public repositories): classic checks then come from the branch read, which has no `strict`, so classic protection gives such a token no atomic base guard. Startup now refuses a `pathIdentity` that disagrees with a probe of task storage (#162, PR #180), and its recovery removes what an interrupted probe left (#181); follow-up #179. | | 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 -- With a runner, **Merge PR** 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). +- With a runner, **Merge PR** 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 the design's step list with each step's state and elapsed time, and a **Cancel merge** button that stops the job without cancelling the task ([merge handoff](implementation/merge-handoff.md#step-list-and-cancel)). - 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/designs/codeboost-plan-indexed-review.md b/docs/designs/codeboost-plan-indexed-review.md index d337d755..47d112c3 100644 --- a/docs/designs/codeboost-plan-indexed-review.md +++ b/docs/designs/codeboost-plan-indexed-review.md @@ -373,7 +373,7 @@ Added by the design review. Each rule cites the design-review decision (Dn) that | Approved, merge blocked | "GitHub refused the merge:" plus GitHub's exact message | **Retry merge** | Open on GitHub | **While merging (D16).** -- The header turns into a step list: **Fetch → Rebase → Push → Re-run checks → GitHub checks → Already-fixed check → Merge**. Each step shows its status and elapsed time. A **Cancel** button is always shown. +- The header turns into a step list: **Fetch → Rebase → Push → Re-run checks → GitHub checks → Already-fixed check → Merge**. Each step shows its status and elapsed time. A **Cancel** button is always shown. *Amended 2026-10-21 (user decisions, PR #194):* the step list is a bar under the review strip, and the header keeps the merge control and its blockers; **Cancel merge** shows only while the merge job runs, since an ended job has nothing to cancel. - You can leave the screen. The steps keep running, and the task row in the Queue shows the current step. - Each failure ends in a named banner that jumps to the first affected item, for example: - "Rebase changed P2 and P4. Review them again." @@ -568,7 +568,7 @@ The numbers below identify delivery milestones, not a requirement to implement t 1. **Plan format and linking engine.** A code library, tested with sample git histories. *Done.* 2. **Read-only review screen.** It works on any branch whose commits are in the commit ledger, with a plan loaded into the database. It shows rows, segments, the four checks, approvals, and the per-item conversation. It does not merge (engineering review, O8). *Done; the screen has since gained the merge gate from build step 4.* 3. **Optional validation.** A future real-PR comparison may test the assumptions in "How we will know it works," but it is non-blocking under the superseding decision above. -4. **Merge gate and merging** (product workflow step 9): the merge rules, the pre-merge sequence, and merging through `gh`. Increment 1 (PR #23), merge-queue support (#38, #46, closing #24) and the pre-merge automation (lane F, F3–F6, #22) have merged. *Mostly done:* #174 and #176 are open, and the merge job's full step list and Cancel button are not built. See the scope and completion criteria below. +4. **Merge gate and merging** (product workflow step 9): the merge rules, the pre-merge sequence, and merging through `gh`. Increment 1 (PR #23), merge-queue support (#38, #46, closing #24) and the pre-merge automation (lane F, F3–F6, #22) have merged. *Done:* the last items are closed: #176 (PR #195), #174 (PR #197), and the merge job's step list and Cancel button (PR #194). The rest of D16 and DT7 (named failure banners, the Queue-row step, the blocker list with links) is tracked in #199. See the scope and completion criteria below. 5. **Running agents.** Per-task clones, containers, agent adapters, permissions, one invocation per plan item, review rounds, the "already fixed" check, and opening PRs (step 6). Isolation boundary merged (lane D, PRs #31, #40, #44, #47, #50), and the runner merged (lane F; see the lane table in `docs/architecture.md`). Not built: review rounds and screen buttons for the runner actions. 6. **Planning screen.** Writing plans with an agent, and approving plan changes (steps 2 and 3). *Done for T18:* planning backend (lane E, E1–E4) and the Plans screen (lane G, G1–G4, #145; PRs #148, #150, #151, #152). Not built: paste import, in-screen editing, the amendment-proposal view, check warnings, answering questions and a whole-plan approval step (#155). The suggestion cards do not yet conform to the v1 contract (#159). 7. **Queue, schedule, and recovery** (steps 4 and 5). @@ -588,9 +588,9 @@ Increment 1 alone did not rebase or run acceptance commands: a moved base or a m - [x] Deliver and validate increment 1 against its final head, including unit/integration and browser regressions, typecheck, and the required review loop. - [x] Add the automated pre-merge rebase path and preserve ledger mappings and attribution. Resolve foreign-commit conflicts under the approved agent policy (T3, T11); return conflicts requiring human action to review. *Done in F3–F5 (PRs #136, #138, #140, #144, #149).* - [x] Integrate runner-controlled, containerized execution of approved `cmd:` argv and bind results to the resulting head (T6; depends on the agent isolation and phase enforcement work in build step 5, including T1, T2, and T9). *Done in F5 (PR #149).* -- [ ] Validate the complete pre-merge sequence, including the already-fixed check, approval freshness, refreshed required checks, guarded merge, and races involving either base or head changes (T6, T7, T12). *Built in F6a–F6c (PRs #153, #161, #177), and T7 is verified.* T6 and T12 are verified (PR #183). Open: #174, #176, and the merge job's full step list and Cancel button. +- [x] Validate the complete pre-merge sequence, including the already-fixed check, approval freshness, refreshed required checks, guarded merge, and races involving either base or head changes (T6, T7, T12). *Built in F6a–F6c (PRs #153, #161, #177), and T7 is verified.* T6 and T12 are verified (PR #183). The merge job's step list and Cancel button are built (PR #194). The rest of D16 and DT7 (named failure banners, the Queue-row step, the blocker list with links) is tracked in #199. #176 was closed by PR #195 and #174 by PR #197. -**Sequencing decision.** The guarded gate can land before the agent runner. Build step 5 supplied the execution prerequisites, and the pre-merge automation shipped only after they were integrated (F3–F6, #22). Build step 4 stays open until the open items above are done: #174, #176, and the merge job's full step list and Cancel button. The safe refusal in increment 1 was an intentional interim behavior, not evidence that the automation had shipped. Optional human validation in #19 remains non-blocking. +**Sequencing decision.** The guarded gate can land before the agent runner. Build step 5 supplied the execution prerequisites, and the pre-merge automation shipped only after they were integrated (F3–F6, #22). Build step 4 stayed open until those items were done: #174, #176, and the step list and Cancel button. All three are now merged. The safe refusal in increment 1 was an intentional interim behavior, not evidence that the automation had shipped. Optional human validation in #19 remains non-blocking. ## Open questions @@ -1982,7 +1982,7 @@ F, G and H ran together within these ownership boundaries. G and H are done; for Built from this review's findings. Each task comes from a specific decision above. Run with Claude Code or Codex, and tick each one as you ship it. Effort ratios assumed: features about 30x, tests about 50x, architecture about 5x. -**Status (checked against `main` on 2026-09-26, lane B0).** T1, T2, T4, T5, T10, T13 and T14 meet their Verify lines and are ticked, with evidence under each (T1, T2 and T14 re-checked 2026-09-26 after lane D merged). T9 was ticked on 2026-10-19 (checked against `main` that day), with evidence under it. T3, T7 and T11 were ticked on 2026-10-20 (checked against `main` at `eafe2be`), with evidence under each. T6 and T12 were ticked on 2026-10-20 (PR #183), with evidence under each. Lane F6 (common CI) added the workflows in `docs/implementation/ci.md`: `test` and `real-docker` run on every pull request with no path filter, and `Live probes` runs the live Claude probes nightly and by hand. The ruleset "main checks" requires `test` and `real-docker` on `main`, both passed on the combined head `ba8294d`, and a `Live probes` run passed with the `CLAUDE_CODE_OAUTH_TOKEN` secret. Ask calls the `agents/` boundary from `runner/question-container.ts`; it has no host adapter. The planned files `core/segments`, `core/choices` and `core/attribution` were never created. That logic lives in `core/linking.ts` (segments and ledger attribution) and `core/approvals.ts` (approvals and duplicate-segment choices). The Files lines below now name the real files. +**Status (checked against `main` on 2026-09-26; merge items updated 2026-10-21, lane B0).** T1, T2, T4, T5, T10, T13 and T14 meet their Verify lines and are ticked, with evidence under each (T1, T2 and T14 re-checked 2026-09-26 after lane D merged). T9 was ticked on 2026-10-19 (checked against `main` that day), with evidence under it. T3, T7 and T11 were ticked on 2026-10-20 (checked against `main` at `eafe2be`), with evidence under each. T6 and T12 were ticked on 2026-10-20 (PR #183), with evidence under each. Lane F6 (common CI) added the workflows in `docs/implementation/ci.md`: `test` and `real-docker` run on every pull request with no path filter, and `Live probes` runs the live Claude probes nightly and by hand. The ruleset "main checks" requires `test` and `real-docker` on `main`, both passed on the combined head `ba8294d`, and a `Live probes` run passed with the `CLAUDE_CODE_OAUTH_TOKEN` secret. Ask calls the `agents/` boundary from `runner/question-container.ts`; it has no host adapter. The planned files `core/segments`, `core/choices` and `core/attribution` were never created. That logic lives in `core/linking.ts` (segments and ledger attribution) and `core/approvals.ts` (approvals and duplicate-segment choices). The Files lines below now name the real files. These `T` IDs are requirement identifiers, not the build-order numbers. Build step 4 (merge gate and merging) spans T3, T6, T7, T11 and T12; it is unrelated to the numbering of T4. See “Build step 4: scope and progress” for its increments and remaining criteria. An increment must not mark a broader requirement complete while any of its acceptance criteria remain deferred. @@ -2174,7 +2174,7 @@ Built from this review's decisions. Tick each one as you ship it. **Status (checked against `main` on 2026-09-26; Plans entries updated after G4, PR #152).** Only DT1 is complete. Most DTs are partly built. Do not tick one until its Verify line passes. - Built: two-tab composer and "n pending changes" tags (part of DT4); the notice below 1280px (part of DT13); the `?` shortcut help (part of DT12); a blocker list from the guarded merge gate (part of DT7). -- Missing: "Send N change requests" and the reject flow (DT4, needs build step 5's reject loop); the merge step list (D16, DT7); menu links for Queue, Lessons and Learning, which are plain text until their screens exist (DT8, build steps 7 to 9; Issues became a link in #55 and Plans in G1, #148); the approved Plans mockup's expandable row editing (not built, #155) and bottom-drawer plan assistant (the built screen puts the assistant in a side panel); Lessons inbox and Learning screen (DT14, DT15, build step 9). +- Missing: "Send N change requests" and the reject flow (DT4, needs build step 5's reject loop); the merge blocker list with links (DT7); D16's named failure banners that jump to the first affected item, and its current step on the Queue screen's task row (the step list and Cancel of D16 are built; the rest of D16 and DT7 is #199); menu links for Queue, Lessons and Learning, which are plain text until their screens exist (DT8, build steps 7 to 9; Issues became a link in #55 and Plans in G1, #148); the approved Plans mockup's expandable row editing (not built, #155) and bottom-drawer plan assistant (the built screen puts the assistant in a side panel); Lessons inbox and Learning screen (DT14, DT15, build step 9). - Not yet audited against their Verify lines: DT2, DT3, DT5, DT6, DT9, DT10, DT11. - File paths in the DTs (`web/review`, `web/shell` and others) are proposed names. Today all UI code is in `web/public/`. diff --git a/docs/implementation/merge-handoff.md b/docs/implementation/merge-handoff.md index 19899465..d92e9a65 100644 --- a/docs/implementation/merge-handoff.md +++ b/docs/implementation/merge-handoff.md @@ -47,7 +47,7 @@ With merge jobs, the merge status words the blockers the job resolves as what me |---|---|---| | `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 | +| `submitted` | unchanged | refresh; the merge attempt settles from GitHub, and while GitHub's queue holds it the page follows it, as for `queued` | | `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 | @@ -59,6 +59,37 @@ Moving the task to **approved, merge blocked** advances its state version, so th 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. +## Step list and Cancel + +While a job runs, a step list under the review strip shows the design's seven steps (D16), in order: **Fetch → Rebase → Push → Re-run checks → GitHub checks → Already-fixed check → Merge**. Each step shows an icon, a word and its elapsed time (from the step's saved times to the browser's clock, so a clock difference between browser and server can skew a running step's time; a step ended by startup settlement after a restart counts the time the server was down): ○ Waiting, ● Running, ✓ Done, – Skipped, ✕ Failed, ! Stopped or ! Unknown. + +| Step | Running while | Skipped when | +|---|---|---| +| Fetch | preparation reads and fetches the pull request, until the head is known not to have moved | never | +| Rebase | preparation rebases onto the moved base and reloads the review | the base has not moved | +| Push | preparation pushes the rewritten head and waits for GitHub to show it | the head did not change | +| Re-run checks | preparation runs the plan items' `cmd:` checks on the exact head | no plan item has a `cmd:` check | +| GitHub checks | the job waits for the required checks | never | +| Already-fixed check | the guarded merge validates, rerunning the already-fixed check in each pass | never | +| Merge | GitHub has been asked to merge, until it answers | never | + +The job saves each change to the list in its `merge_jobs` record, so the list survives a reload and a second tab sees it. Starting a step finishes any step still running. Steps are display only: the job never waits for a step write, and one that fails is logged ("Could not record merge step …") without stopping the job. When the job ends, the step that was running takes the outcome's word: **Done** after `merged` or `queued`, **Unknown** after `submitted`, **Stopped** after `cancelled`, and **Failed** otherwise. Steps the job never reached stay **Waiting**. The list says where the job stopped, not why: the banner gives the reason. So: + +- The step that was running is **Failed** even when its own work succeeded and a check during it stopped the job. For example, **Fetch** fails when the fetch shows the pull request head moved, **Push** fails when the pull request moves after the push, and **Already-fixed check** fails for any refusal in the guarded merge's validation, such as a pull request that changed. +- When no step was running, the list stays as it was. For example: preparation stops for review after the rebase (a stale approval); its final inspection or authorization, after **Re-run checks**, refuses; it refuses before **Fetch** starts (an earlier push was settled, or cleanup is unresolved); the review changed between the click and the job's first review load; or the merge was cancelled during that load. Then every step may still read **Waiting**. + +The list stays on screen after the job ends until the review reloads (**Refresh**, or any review action, plan import or **Apply**), also while the page follows the merge queue a job ended `queued` in. **Cancel merge** shows only while the job runs, not "always" as the design says: an ended job has nothing to cancel. When Cancel, **Merge PR** or the blockers button (**Review blockers** or **Merge status**) hides (the job ends, the bar hides, a reload clears the blockers or makes merging unavailable, or a load fails), keyboard focus on it moves to **Merge PR**, or to **Refresh** when Merge PR is hidden or `disabled`. The page reads the focused control before it changes any control and moves focus once every control's final state is set. A job saved before the list existed has no `steps`; the page then shows its step as before, without the list or Cancel. + +**Cancel merge** stops the job without cancelling the task. The page sends `{action: "cancel-merge", jobId}` to `POST /api/action`: + +- The job is marked `cancelRequested` first, then told to stop, and so is its preparation. Preparation stops as for a task cancel: a rebase is aborted and cleaned up, and a `cmd:` check is stopped as cancelled. +- The job ends `cancelled`, with "You cancelled the merge; the task and its pull request stay open." The task keeps its status (in review, or approved, merge blocked), its pull request stays open, and **Merge PR** may be clicked again. A task cancelled before the job's work stops ends the job as a cancelled task instead. +- Once the job's merge attempt exists, GitHub has been asked: stopping then could only make the outcome unknown, so the server refuses ("GitHub has been asked to merge; wait for its outcome.") and the page marks the button unavailable (`aria-disabled`, so it keeps keyboard focus) once the Merge step has started, and says why beside it. If that step's write failed (steps are display only), the button stays available, and a click shows the refusal in the banner. +- If the cancel is answered with a job that already ended (it merged before the page saw it, or another tab cancelled it), the page polls the merge status at once (or, when a poll is already on its way, 2 s after it), and the poll shows the outcome, as when it sees a job end. An answer with a newer running job another tab started is followed at once, and a poll that read an older job is discarded. An answer with a newer job that ended asks the poll at once too, also when the page already shows its own job ended (the poll then presents the newer job's outcome, with its queue attempt), and polls continue until the page shows that job, also when a poll already on its way showed the page's own job end. A newer job the page follows, from a cancel's answer, a poll or a full review response read before it was followed, does not keep the older job's queue attempt: only its own attempt (by its merge action ID) is followed. The merge click's answer is reconciled the same way: a later record a cancel's answer or a poll showed while the click was on its way (of the click's job, or a newer job) is kept over it, so an accepted cancel is not undone. Once the page shows a job ended, a poll that read that job, or an older one, before it ended changes nothing, and neither does an answer about anything but that job's own merge attempt: a job that ended `queued` or `submitted` follows its own attempt (by its merge action ID). A refused merge click while the page follows a running job (a click needs Merge PR ready, so a cancel's answer or a poll showed that job while the click was on its way) keeps it followed, with the refusal's message before the job's step; a later ended record the refusal carries (of that job, or of a newer job) is presented, and an older one is not, except that one that ended `queued` or `submitted` is left to the poll, which follows its attempt. Once an answer carried an ended job, Cancel reads **Cancel requested** and stays unavailable for every record older than the latest ended record any answer carried (an earlier record of that job, or an older job), so a poll or reload read before it ended, an older job's cancel answered late, or a poll that read the job replaced does not offer Cancel again. A newer job another tab starts afterwards offers Cancel. An answer the page has already seen (the poll showed the job end first) changes nothing. A refusal's message is shown only while the cancelled job is still the one shown and still running, so it never replaces an outcome the poll showed. It is the only explanation of a Cancel that stayed enabled, so each poll shows it again until the job's record changes, also after a reload or a failed poll rewrote the banner. +- A cancel while the task itself is being cancelled is refused ("The task is being cancelled."): the task cancel already stops the job and closes the pull requests. +- A cancel of a job that ended, a refused click, an unknown job, or a job still running on disk that this server does not run (its ending write failed) is refused (HTTP 409, with the task's latest job). During shutdown it answers 503. A repeat of the same cancel answers the job while it runs (200), unless the task is being cancelled, and once it has ended is refused as above with the ended job, which the page shows; so the request needs no key of its own. +- A restart after the cancel was saved, but before GitHub was asked, settles the job `cancelled`, not `failed`. + ## Cancel, shutdown and restart | Event | Effect | @@ -66,13 +97,14 @@ A rebase moves the pull request's base. The guarded merge would then scan only t | 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 | +| **Cancel merge** (the merge alone) | as for a task cancel at each step, except that the task keeps its status and its pull request: see [Step list and Cancel](#step-list-and-cancel) | +| 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 or the merge 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 | +| Restart | a `running` job that had not asked GitHub to merge becomes `failed` ("The server restarted before the merge finished"), or `cancelled` if the person had cancelled the merge (with "Task cancelled." when the task was cancelled too; see [Step list and Cancel](#step-list-and-cancel)). 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. +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, the step list (`steps`, with each step's state and start and end times) and `cancelRequested` (see [Step list and Cancel](#step-list-and-cancel)), `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 merge or 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 @@ -82,12 +114,14 @@ Schema v19 adds `task_pull_requests.check_id` (the pre-PR check each opening fol - 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. +- 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 step list and its **Cancel merge** button are described in [Step list and Cancel](#step-list-and-cancel). ## 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. +The step list and **Cancel merge** (D16) are covered by: `test/merge-job.test.ts` (the seven steps and their times, skipped and failed steps, a failed step write, a merge cancel during preparation, the checks wait, the guarded merge's validation and the first review load, refusals once GitHub was asked, while the task is being cancelled and for an ended or unknown job, a task cancel winning, cancel then shutdown, an inspection answering after a cancel or after shutdown began, a resend that joins or replays a merge click, Fetch Failed when preparation stops for review, the Merge step staying Waiting when the merge attempt cannot be saved, a refused cancel for a job whose ending write failed, and restart settlement); `test/pre-merge.test.ts` (the steps preparation reports, a throwing progress callback, and a merge cancel during a rebase, a push and a command check, each leaving the task in review); `test/runner-publishing.test.ts` (the `cancel-merge` route); and the browser tests tagged D16 in `test/browser/review.spec.ts` (the list and its accessible text, Cancel and its refusals, answers in every order against the poll, the poll's speed when the list's step changes, and focus), plus the F6b test that follows a job ended in the merge queue (the queue poll keeping the list). + ## 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/runner/merge-job.ts b/runner/merge-job.ts index da567678..87cbe5c5 100644 --- a/runner/merge-job.ts +++ b/runner/merge-job.ts @@ -3,7 +3,8 @@ import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, se 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'; +import { FIX_THEN_RETRY, MAX_MERGE_JOB_TOKEN_LENGTH, MERGE_CANCELLED, type MergeAttempt, type MergeJob, type MergeJobState, + type MergeStepName } 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; @@ -37,6 +38,8 @@ export class MergeJobCoordinator { readonly settle: (fn: () => T) => T; #active: Promise | null = null; #abort: AbortController | null = null; + /** The running job's ID, while `#active` is set. */ + #activeId: string | null = null; #closing = false; constructor(service: ReviewService, preMerge: PreMergeCoordinator, merges: MergeCoordinator, capability?: ShutdownCapability, @@ -116,15 +119,48 @@ export class MergeJobCoordinator { 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); + const code = (abort.signal.reason as { code?: string } | undefined)?.code; + if (code === 'CANCELLED' || code === 'MERGE_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; + }).finally(() => { if (this.#active === run) { this.#active = null; this.#abort = null; this.#activeId = null; } }); + this.#active = run; this.#activeId = job.id; return mergeJobView(job); } + /** + * The merge header's Cancel: stop this job, and its preparation, without cancelling the task. The task keeps its + * status and its pull request, and Merge PR may be clicked again. Refused once the job's merge attempt exists (GitHub + * has been asked, so stopping could only make its outcome unknown), and for a job that ended or that this server is + * not running (one still running on disk after its ending write failed). A repeat of the same cancel answers the job. + */ + cancelMerge(jobId: string): MergeJobView { + const { store, config } = this.service, identity = config.identity; + if (this.#closing) throw new ShuttingDownError(); + const job = store.getMergeJob(identity, jobId); + if (!job || job.refused) throw new GuardRefusal('There is no such merge to cancel.'); + if (job.state !== 'running') throw new GuardRefusal('This merge has already ended.'); + if (this.#activeId !== jobId) throw new GuardRefusal('This merge shows as running until the server restarts; it cannot be cancelled.'); + // A task cancel already stops this job and closes the task's pull requests, so the merge-only cancel would be wrong. + if (this.#cancelled()) throw new GuardRefusal('The task is being cancelled.'); + const attempt = job.mergeActionId ? store.getMergeAttempt(identity) : null; + if (attempt && attempt.actionId === job.mergeActionId) throw new GuardRefusal('GitHub has been asked to merge; wait for its outcome.'); + // Saved before anything stops, so a restart before the job ends settles it cancelled rather than failed. + const saved = store.requestMergeJobCancel(identity, jobId); + if (!saved) throw new GuardRefusal('This merge has already ended.'); + // While a job runs no other preparation can start, so a running preparation is this job's. + this.#abort?.abort(Object.assign(new Error(MERGE_CANCELLED), { code: 'MERGE_CANCELLED' })); + this.preMerge.stopForMergeCancel(); + return mergeJobView(saved); + } + + /** Record a step of the list for display. A failed write leaves the list behind; it never stops the job. */ + #step(id: string, name: MergeStepName, state: 'running' | 'done' | 'skipped'): void { + try { this.settle(() => this.service.store.markMergeStep(this.service.config.identity, id, name, state)); } + catch (error) { console.error(`Could not record merge step ${name} of job ${id}: ${JSON.stringify((error as Error)?.message ?? String(error))}`); } + } + /** 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; @@ -189,12 +225,14 @@ export class MergeJobCoordinator { 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 }); + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, actionId: prepareId }, + (name, state) => this.#step(job.id, name, state)); 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' })); + this.#step(job.id, 'checks', 'running'); 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). @@ -204,13 +242,15 @@ export class MergeJobCoordinator { // 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 })); + // The guarded merge's validation passes rerun the already-fixed check; the merge step starts when GitHub is asked. + this.#step(job.id, 'already-fixed', 'running'); 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); + const merged = await this.merges.merge(view.token, mergeId, signal, () => this.#step(job.id, 'merge', 'running')); // 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 @@ -255,10 +295,15 @@ export class MergeJobCoordinator { 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. */ + /** + * A cancelled task ends the job, however the job learned of it; so does a cancel of the merge alone, which leaves the + * task as it is. A task cancel wins over an earlier merge cancel. Shutdown alone leaves the job running on disk for + * startup; after a cancel of either kind the job ends cancelled. + */ #end(job: MergeJob, signal: AbortSignal): void { - if ((signal.reason as { code?: string } | undefined)?.code === 'CANCELLED' || this.#cancelled()) - this.#finish(job.id, 'cancelled', 'Task cancelled.'); + const code = (signal.reason as { code?: string } | undefined)?.code; + if (code === 'CANCELLED' || this.#cancelled()) this.#finish(job.id, 'cancelled', 'Task cancelled.'); + else if (code === 'MERGE_CANCELLED') this.#finish(job.id, 'cancelled', MERGE_CANCELLED); } /** @@ -298,6 +343,8 @@ export class MergeJobCoordinator { if (limit.aborted && error === limit.reason) return timedOut(); return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }; } + // An inspection that answered after a cancel or shutdown decides nothing: the stop wins, as when it rejects. + if (signal.aborted) return { kind: 'cancelled' }; 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. @@ -307,8 +354,11 @@ export class MergeJobCoordinator { 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; + // Saved only when they change (the job starts, and the merging step leaves it, with none), so an unchanged inspection + // makes no new revision and the page's poll backs off. + if (JSON.stringify(verdict.checks) !== JSON.stringify(waiting)) + this.settle(() => store.updateMergeJob(identity, job.id, { checks: verdict.checks })); 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); diff --git a/runner/merge.ts b/runner/merge.ts index d6b29995..e3c99f89 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -354,8 +354,11 @@ 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. + * `onAsking`, for the merge job's step list, is told once every validation pass (the already-fixed check included) has + * passed and the attempt is saved (when a store is configured), in the same turn as the call that asks GitHub to + * merge. Display only: an error it throws is ignored. A click that joins or replays another does not call it. */ - async merge(token: unknown, actionId?: unknown, caller?: AbortSignal): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { + async merge(token: unknown, actionId?: unknown, caller?: AbortSignal, onAsking?: () => void): 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) { @@ -379,7 +382,7 @@ export class MergeCoordinator { 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(() => { + const attempt = this.#merge(token, abort.signal, actionId as string | undefined, onAsking).finally(() => { clearTimeout(timer); caller?.removeEventListener('abort', stop); if (this.#active === attempt) { this.#active = null; this.#activeClick = null; } @@ -469,7 +472,7 @@ export class MergeCoordinator { return replayed; } - async #merge(token: string, signal: AbortSignal, actionId?: string): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { + async #merge(token: string, signal: AbortSignal, actionId?: string, onAsking?: () => void): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { let queueAttempt: MergeAttempt | null = null; const action = actionId ? { actionId, kind: 'merge', request: { token } } : null; try { @@ -565,6 +568,7 @@ export class MergeCoordinator { if (!begun) throw new Error('This merge click was already submitted. Refresh to see its outcome.'); queueAttempt = begun; } + try { onAsking?.(); } catch { /* Display only. */ } const result = await this.gateway.merge(commandStatus.remote.head, { signal, pullRequest: commandStatus.remote.pullRequest }); if (queueAttempt) { // The enqueue command has already committed externally. A local refresh failure must not diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 837ac56f..4d013487 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -8,7 +8,7 @@ import { MAX_REBASE_TIMEOUT_MS, MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOU RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts'; import type { ReviewService, ReviewView } from './review.ts'; import { BranchPushRefused } from './branch-push.ts'; -import type { PreMergeReadiness, PushMarker, PushOutcome, RebaseMarker } from './store.ts'; +import { MERGE_CANCELLED, type MergeStepName, type PreMergeReadiness, type PushMarker, type PushOutcome, type RebaseMarker } from './store.ts'; /** `branch` is the task PR's head branch, present when the runner published the PR. */ export interface RemotePair { base: string; head: string; branch?: string } @@ -41,6 +41,11 @@ export interface PreMergeResult { base: string; head: string; checked: readonly string[]; reason: string | null; } type PreparedResult = PreMergeResult & { readiness?: PreMergeReadiness }; +/** + * Told when a preparation step starts, finishes or is not needed, for the merge job's step list. Display only: the + * preparation never waits for it, and an error it throws is ignored. + */ +export type PreMergeProgress = (step: Extract, state: 'running' | 'done' | 'skipped') => void; // D may spend 30 s on its first cleanup and 60 s retrying it; F may then spend 30 s releasing task storage. Keep all // of that ownership settlement inside the preparation's one overall deadline. @@ -109,7 +114,8 @@ export class PreMergeCoordinator { if (this.#closing) throw new GuardRefusal('The server is shutting down.'); if (this.#active) throw new GuardRefusal('Pre-merge preparation is already running.'); } - start(expected: { stateVersion: number; reviewVersion: number; snapshotId: string; base: string; head: string; actionId?: string }): Promise { + start(expected: { stateVersion: number; reviewVersion: number; snapshotId: string; base: string; head: string; actionId?: string }, + progress?: PreMergeProgress): Promise { this.assertStartable(); const controller = new AbortController(); this.#abort = controller; const deadline = performance.now() + this.operationTimeoutMs; @@ -136,7 +142,7 @@ export class PreMergeCoordinator { }; const active = Promise.resolve().then(() => this.#run(expected, controller.signal, deadline, observation => { failurePair = observation.pair; failureBinding = observation.binding; }, - checked => { failureChecked = Object.freeze([...checked]); })).then(remember, error => { + checked => { failureChecked = Object.freeze([...checked]); }, progress)).then(remember, error => { // Failure settlement must not rebuild Git history: the original failure may itself be a repository-read error. const result: PreMergeResult = { state: 'failed', ...failurePair, checked: failureChecked, reason: error instanceof Error ? error.message : String(error) }; @@ -166,6 +172,11 @@ export class PreMergeCoordinator { this.#closing = true; this.#shutdown.abort(new Error('Server shutdown.')); this.#abort?.abort(new Error('Server shutdown.')); await this.#active; } + /** + * The person cancelled the merge job that owns the running preparation, not the task: stop it as a task cancel does + * (a rebase is cleaned up, a command check is stopped as cancelled), but leave the task as it is. + */ + stopForMergeCancel(): void { this.#abort?.abort(Object.assign(new Error(MERGE_CANCELLED), { code: 'MERGE_CANCELLED' })); } /** Cancel the task and promptly stop whichever rebase, remote read or command check this preparation owns. */ cancelTask(expectedStateVersion: number, actionId: string): 'closed' | 'stopping' { const identity = this.service.config.identity; @@ -357,8 +368,9 @@ export class PreMergeCoordinator { } async #run(expected: { stateVersion: number; reviewVersion: number; snapshotId: string; actionId?: string }, signal: AbortSignal, deadline: number, onObserved: (value: { pair: RemotePair; binding: { stateVersion: number; reviewVersion: number; - snapshotId: string } }) => void, onChecked: (checked: readonly string[]) => void): Promise { + snapshotId: string } }) => void, onChecked: (checked: readonly string[]) => void, progress?: PreMergeProgress): Promise { const identity = this.service.config.identity; + const step: PreMergeProgress = (name, state) => { try { progress?.(name, state); } catch { /* Display only. */ } }; const remaining = () => { const value = Math.ceil(deadline - performance.now()); if (value < 1) throw Object.assign(new Error('Pre-merge preparation deadline exceeded.'), { code: 'ETIMEDOUT' }); @@ -456,6 +468,7 @@ export class PreMergeCoordinator { || this.service.store.getSnapshot(identity).id !== guard.snapshotId) throw new GuardRefusal('The task or review changed during preparation. Reload first.'); }; + step('fetch', 'running'); let initial = await this.remote.inspect(signal); await this.remote.fetch(initial, signal); signal.throwIfAborted(); assertCurrent(expected); // A rewrite is pushed only once its review is clear. Until then (a review stop, or a failed or refused push), a retry @@ -468,7 +481,10 @@ export class PreMergeCoordinator { return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: 'The pull request head moved; attribution and approvals were refreshed.' }; } - if (initial.base !== view.snapshot.base) { + step('fetch', 'done'); + if (initial.base === view.snapshot.base) step('rebase', 'skipped'); + else { + step('rebase', 'running'); const repository = this.service.reviewRepository().path; const old = (await readHistoryInWorker(repository, view.snapshot.base, view.snapshot.head, { maxDurationMs: Math.min(30_000, reviewBudget()), signal })).commits.map(commit => commit.sha); @@ -511,11 +527,14 @@ export class PreMergeCoordinator { const rebasedSnapshot = this.service.store.getSnapshot(identity); bindRebaseFailure(rebased, { base: rebasedSnapshot.base, head: rebasedSnapshot.head }, rebased.stateVersion); view = await reload(rebased); + step('rebase', 'done'); } const blocker = this.#reviewBlocker(view); if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: blocker }; - if (initial.head !== view.snapshot.head) { + if (initial.head === view.snapshot.head) step('push', 'skipped'); + else { + step('push', 'running'); const { result: pushed, stateVersion: pushState } = await this.#pushRewrite(initial, view, signal, deadline, authorize, bindRebaseFailure); // Bind any later failure to the state the push (or refusal) left, so it is reported as current, not stale. @@ -548,9 +567,12 @@ export class PreMergeCoordinator { assertCurrent(pushedBinding); initial = seen; view = await reload(pushedBinding); + step('push', 'done'); } const checked: string[] = []; - for (const item of view.items.filter(candidate => candidate.acceptance.some(check => check.type === 'cmd'))) { + const commandItems = view.items.filter(candidate => candidate.acceptance.some(check => check.type === 'cmd')); + step('commands', commandItems.length ? 'running' : 'skipped'); + for (const item of commandItems) { signal.throwIfAborted(); task = this.service.store.getTask(identity); const attempt = this.runner.start(identity, { expectedStateVersion: task.stateVersion, kind: 'check', item: item.id, @@ -602,6 +624,7 @@ export class PreMergeCoordinator { const changed = this.#reviewBlocker(view); if (changed) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: changed }; } + if (commandItems.length) step('commands', 'done'); const prepared = { base: view.snapshot.base, head: view.snapshot.head }; const guarded = { stateVersion: this.service.store.getTask(identity).stateVersion, // Rebase and command attempts advance task/snapshot state, but this preparation never owns a review edit. diff --git a/runner/store.ts b/runner/store.ts index d39f8e94..293e3bcb 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -90,6 +90,8 @@ export interface PreMergeReadiness { 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.'; +/** Why a job ends when the person cancelled the merge, not the task: the task and its pull request stay open. */ +export const MERGE_CANCELLED = 'You cancelled the merge; the task and its pull request stay open.'; /** 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. */ @@ -138,6 +140,23 @@ 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'; +/** The design's merge step list (D16), in order: what the job's header shows while it runs. */ +export const MERGE_STEP_NAMES = Object.freeze(['fetch', 'rebase', 'push', 'commands', 'checks', 'already-fixed', 'merge'] as const); +export type MergeStepName = (typeof MERGE_STEP_NAMES)[number]; +/** + * `stopped`: the job was cancelled while this step ran. `unknown`: GitHub was asked to merge and its answer is not known. + * A job that ends leaves no step running. + */ +export type MergeStepState = 'pending' | 'running' | 'done' | 'skipped' | 'failed' | 'stopped' | 'unknown'; +export interface MergeStep { name: MergeStepName; state: MergeStepState; startedAt: string | null; endedAt: string | null } +export const pendingMergeSteps = (): MergeStep[] => MERGE_STEP_NAMES.map(name => ({ name, state: 'pending', startedAt: null, endedAt: null })); +/** The step that was running when a job ended takes the outcome's word; the steps it never reached stay pending. */ +function endedSteps(steps: MergeStep[], outcome: Exclude): MergeStep[] { + const state: MergeStepState = outcome === 'merged' || outcome === 'queued' ? 'done' : outcome === 'submitted' ? 'unknown' + : outcome === 'cancelled' ? 'stopped' : 'failed'; + const now = new Date().toISOString(); + return steps.map(step => step.state === 'running' ? { ...step, state, endedAt: now } : step); +} export interface MergeJob { /** The click's action ID. */ id: string; @@ -165,6 +184,10 @@ export interface MergeJob { revision: number; startedAt: string; updatedAt: string; + /** The step list, in `MERGE_STEP_NAMES` order. Absent on records saved before it existed and on refused clicks. */ + steps?: MergeStep[]; + /** The person cancelled the merge (not the task) while it ran; the job ends `cancelled` once its work stops. */ + cancelRequested?: boolean; } export interface AttemptRecord { id: string; kind: AttemptKind; phase: string; item: string | null; state: AttemptState; context: InvocationContext; deadline: number; @@ -2157,7 +2180,7 @@ export class Store { 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 }; + url: null, sequence: this.#nextMergeJobSequence(key), revision: 1, startedAt: now, updatedAt: now, steps: pendingMergeSteps() }; this.#run('INSERT INTO merge_jobs VALUES (?,?,?)', job.id, key, encode(job)); return { job, resent: false }; }); @@ -2195,6 +2218,25 @@ export class Store { mergeActionId?: string }): MergeJob | null { return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, ...patch } : null); } + /** + * Start, finish or skip one step of a running job. Starting a step finishes any other step still running (the job has + * moved on). A job without a step list (saved before it existed) or no longer running is left unchanged. + */ + markMergeStep(identity: PlanIdentity, id: string, name: MergeStepName, state: 'running' | 'done' | 'skipped'): MergeJob | null { + return this.#changeMergeJob(identity, id, job => { + if (job.state !== 'running' || !job.steps) return null; + const now = new Date().toISOString(); + return { ...job, steps: job.steps.map(step => { + if (step.name === name) return state === 'running' ? { ...step, state, startedAt: now, endedAt: null } + : { ...step, state, startedAt: state === 'skipped' ? null : step.startedAt ?? now, endedAt: now }; + return state === 'running' && step.state === 'running' ? { ...step, state: 'done', endedAt: now } : step; + }) }; + }); + } + /** Record that the person cancelled the running job's merge. Returns null when the job is no longer running. */ + requestMergeJobCancel(identity: PlanIdentity, id: string): MergeJob | null { + return this.#changeMergeJob(identity, id, job => job.state === 'running' ? { ...job, cancelRequested: true } : 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. @@ -2248,7 +2290,8 @@ export class Store { /** 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; + ? { ...job, state: outcome.state, step: null, reason: outcome.reason ?? null, url: outcome.url ?? job.url, + ...(job.steps ? { steps: endedSteps(job.steps, outcome.state) } : {}) } : null) !== null; } #changeMergeJob(identity: PlanIdentity, id: string, change: (job: MergeJob) => MergeJob | null): MergeJob | null { const key = identityKey(identity); @@ -2280,15 +2323,21 @@ export class Store { } /** * 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. + * recorded state is its outcome (a refusal blocks the task, as the job would have). Otherwise it fails, and the person + * merges again, or, when the person had cancelled the merge, it ends cancelled ("Task cancelled." if the task was + * cancelled too). */ 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.' }); + // GitHub was never asked: a merge the person had cancelled is cancelled, as it would have ended without the restart. + // A task cancelled too wins, as it does while the job runs: its pull requests close, so the merge-only words would be wrong. + const task = this.getTask(identity); + if (running.cancelRequested) this.finishMergeJob(identity, running.id, { state: 'cancelled', + reason: task.status === 'cancelled' || task.cancelRequested !== null ? 'Task cancelled.' : MERGE_CANCELLED }); + else this.finishMergeJob(identity, running.id, { state: 'failed', reason: 'The server restarted before the merge finished.' }); return; } if (attempt.state === 'merged' || attempt.state === 'queued') { diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index 0a4a96d5..930a9f83 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -655,16 +655,24 @@ const runnerMerge=(merge:Record,fields:Record={} /** 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. */ + * (runner/store.ts #changeMergeJob), including each step-list write and each change in the waiting checks, so a real job + * reaches each step at a higher revision than these defaults. The defaults keep only the order: preparing 1, checks 2 + * (none waiting) or 3 (waiting), merging 4, ended 5. A test that needs another order 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}; }; +/** Arms a watch on the merge header's Cancel button and returns a wait for its next `count` renders. Every render of the + * merge control (renderMerge) while the step bar is shown rewrites Cancel's label and title, changed or not, so a render + * proves the page handled the answer before it, even one that leaves Cancel as it was. Arm it before the event; await the returned wait after it. */ +async function armCancelRenders(page:import('@playwright/test').Page,count:number){ + await page.evaluate(n=>{(window as unknown as {cancelRenders:Promise}).cancelRenders=new Promise(resolve=>{let seen=0; + const observer=new MutationObserver(()=>{if(++seen>=n){observer.disconnect();resolve();}}); + observer.observe(document.getElementById('merge-cancel')!,{childList:true,attributes:true,characterData:true,subtree:true});});},count); + return ()=>page.evaluate(()=>(window as unknown as {cancelRenders:Promise}).cancelRenders); +} /** 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.'; @@ -823,6 +831,1530 @@ test('ends a running merge job the poll no longer reports, keeping every blocker await expect(page.locator('#banner')).toHaveText(ended); }); +/** A job's step list as the server saves it: `states` in order of the design's steps, timed from 00:00:00. */ +const mergeSteps=(states:string[])=>['fetch','rebase','push','commands','checks','already-fixed','merge'].map((name,index)=>{const state=states[index]??'pending'; + return {name,state,startedAt:state==='pending'||state==='skipped'?null:`2026-10-11T00:00:0${index}Z`,endedAt:['pending','running'].includes(state)?null:`2026-10-11T00:00:0${index+1}Z`};}) as NonNullable; +test('shows the merge step list while a job runs, and cancels the merge alone from it (D16)',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 id='0f6b0000-0000-4000-8000-0000000000d1'; + const rebasing=mergeJobRecord({id,state:'running',step:'preparing',revision:3,steps:mergeSteps(['done','running'])}); + const cancelling=mergeJobRecord({...rebasing,revision:4,cancelRequested:true}); + const cancelled=mergeJobRecord({...rebasing,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','stopped'])}); + let cancelAsked=false,cancels:unknown[]=[]; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){cancels.push(body);cancelAsked=true;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:cancelling})});return;} + if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route),running=mergeJobRecord({id,state:'running',step:'preparing',steps:mergeSteps([])}); + 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 poll shows the rebase running until the cancel was asked. The first poll after it answers the older record (read + // before the cancel was saved), which must not undo the adopted answer; then the job is cancelling, then cancelled. The poll + // after the older record is sent only once that record's answer was rendered, and is held until the page was read. + let afterCancel=0,releaseNext!:()=>void,sawNext!:()=>void,holdTick=false,releaseTick!:()=>void; + const nextHeld=new Promise(resolve=>releaseNext=resolve),next=new Promise(resolve=>sawNext=resolve),tickHeld=new Promise(resolve=>releaseTick=resolve); + await page.route('**/api/merge',async route=>{if(holdTick)await tickHeld;const job=!cancelAsked?rebasing:[rebasing,cancelling][afterCancel++]??cancelled; + if(cancelAsked&&afterCancel===2){sawNext();await nextHeld;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,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:'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 expect(page.locator('#merge-steps-bar')).toBeHidden(); + await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + const steps=page.getByRole('list',{name:'Merge steps'}); + await expect(steps.getByRole('listitem')).toHaveCount(7); + await expect(steps.getByRole('listitem').first()).toHaveText('✓ Fetch Done, 1 s',{timeout:10_000}); + await expect(steps.getByRole('listitem').nth(1)).toHaveText(/^● Rebase Running, \d+ (s|min \d\d s)$/); + await expect(steps.getByRole('listitem').nth(2)).toHaveText('○ Push Waiting'); + // The 1 s timer rewrites only the times: the list items themselves stay, so a screen reader keeps its place. Polls are + // held first (once one is held, no poll renders, since the next is sent only after the last answer was rendered), so + // only the timer can change the running step's time; it changes twice, a second apart. + const polledBeforeTick=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + holdTick=true;await polledBeforeTick; + const first=await steps.getByRole('listitem').first().elementHandle(); + const running=steps.getByRole('listitem').nth(1); + for(let tick=0;tick<2;tick++){const before=await running.textContent();await expect(running).not.toHaveText(before!,{timeout:3_000});} + expect(await first!.evaluate(node=>node.isConnected)).toBe(true); + holdTick=false;releaseTick(); + const cancel=page.getByRole('button',{name:'Cancel merge',exact:true}); + await expect(cancel).toBeVisible(); + await expect(cancel).not.toHaveAttribute('aria-disabled','true'); + // An enabled Cancel is not described by the hidden "GitHub has been asked" note. + await expect(cancel).toHaveAccessibleDescription('Stop this merge. The task and its pull request stay open.'); + const stalePoll=page.waitForResponse(response=>response.url().endsWith('/api/merge')&&cancelAsked); + await cancel.click(); + // The route records the cancel when its request reaches it, which the click does not wait for. + await expect.poll(()=>cancels).toEqual([{action:'cancel-merge',jobId:id}]); + await expect(page.getByRole('button',{name:'Cancel requested',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#merge')).toHaveText('Cancelling…'); + await expect(page.locator('#banner')).toHaveText('Cancelling the merge. The task and its pull request stay open.'); + // The older record the next poll answers changes nothing on screen. Read once, after it was rendered. + expect((await (await stalePoll).json()).job.revision).toBe(3); + await next; + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,cancel:document.getElementById('merge-cancel')!.textContent, + unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({merge:'Cancelling…',cancel:'Cancel requested',unavailable:'true'}); + releaseNext(); + // The job ends: the list stays with the stopped step until Refresh, and Cancel goes away. + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + await expect(page.locator('#banner')).toContainText('Merge cancelled. You cancelled the merge; the task and its pull request stay open.'); + await expect(steps.getByRole('listitem').nth(1)).toHaveText('! Rebase Stopped, 1 s'); + await expect(page.locator('#merge-cancel')).toBeHidden(); + // The click left focus on Cancel; once it hides, focus moves to the merge control, not the page. + await expect(page.locator('#merge')).toBeFocused(); + expect(cancels).toHaveLength(1); +}); +test('shows the outcome at once when a cancel is answered with a job that already ended (D16)',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 id='0f6b0000-0000-4000-8000-0000000000d3'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const merged=mergeJobRecord({id,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + // The job merged before the poll saw it; the cancel is refused with the ended job, and the poll then reports it too. + let ended=false,polls=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){ended=true;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:merged})});return;} + 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:waiting}),job:waiting})});}); + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?merged:waiting})});}); + 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});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('#merge-cancel')).toBeVisible({timeout:10_000}); + const before=polls; + const polled=page.waitForResponse(response=>response.url().endsWith('/api/merge')&&ended); + const clicked=Date.now(); + await page.locator('#merge-cancel').click(); + // The page asks the poll at once rather than waiting out its delay (2 s at least), and shows the outcome with the list. + await polled; + expect(Date.now()-clicked).toBeLessThan(1_500); + expect(polls).toBe(before+1); + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:5_000}); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').last()).toHaveText('✓ Merge Done, 1 s'); + await expect(page.locator('#merge-cancel')).toBeHidden(); + await expect(page.locator('#merge')).toBeFocused(); +}); +test('moves focus off Cancel to the merge control when the whole step bar hides (D16)',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 id='0f6b0000-0000-4000-8000-0000000000d7'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + // While Cancel holds focus, the poll stops reporting the job: the page ends it with no record, so the whole bar hides. + let gone=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:waiting}),job:waiting})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:gone?null:waiting})})); + 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});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('#merge-cancel')).toBeVisible({timeout:10_000}); + await page.locator('#merge-cancel').focus(); + gone=true; + await expect(page.locator('#merge-steps-bar')).toBeHidden({timeout:15_000}); + await expect(page.locator('#merge')).toHaveText('Merge ended'); + await expect(page.locator('#merge')).toBeFocused(); +}); +test('shows a cancelled merge within seconds even after the merge-status poll has backed off (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 id='0f6b0000-0000-4000-8000-0000000000d4'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const cancelling=mergeJobRecord({...waiting,revision:4,cancelRequested:true}); + const cancelled=mergeJobRecord({...waiting,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + let cancelAsked=false;const polls:number[]=[]; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){cancelAsked=true;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:cancelling})});return;} + 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:waiting}),job:waiting})});}); + // The job waits unchanged, so the poll backs off (2 s, 4 s, 8 s, ...); once the cancel was asked the job has ended. + await page.route('**/api/merge',async route=>{polls.push(Date.now());await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:cancelAsked?cancelled:waiting})});}); + 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});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(); + // Three unchanged answers: the next poll waits 16 s. + await expect.poll(()=>polls.length,{timeout:30_000}).toBeGreaterThanOrEqual(3); + const clicked=Date.now(); + await page.locator('#merge-cancel').click(); + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:6_000}); + expect(Date.now()-clicked).toBeLessThan(4_000); +}); +for (const answer of ['ended','newer'] as const) test(`polls again within seconds when a cancel is answered with ${answer==='ended'?'an ended job':'a newer job'} while a poll is on its way (D16)`,async({page})=>{ + test.setTimeout(120_000); + 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 id='0f6b0000-0000-4000-8000-0000000000d5'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + // Ended: it merged before the poll saw it. Newer: another tab's cancel ended it, then that tab started a new job. + const later=answer==='ended' + ?mergeJobRecord({id,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}) + :mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d6',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + let release!:()=>void,arrived!:()=>void,hold=false,ended=false;const polls:number[]=[]; + const held=new Promise(resolve=>release=resolve),inFlight=new Promise(resolve=>arrived=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){ended=true;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:later})});return;} + 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:waiting}),job:waiting})});}); + // The held poll read the job before it ended, so it answers the running record when released. + await page.route('**/api/merge',async route=>{polls.push(Date.now()); + if(hold&&!ended){hold=false;arrived();await held;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:waiting})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?later:waiting})});}); + 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});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(); + // Let the poll back off (2 s, 4 s, 8 s), then hold the next one in flight. + await expect.poll(()=>polls.length,{timeout:30_000}).toBeGreaterThanOrEqual(3); + hold=true;await inFlight; + await page.locator('#merge-cancel').click(); + // The refusal is shown while the cancelled job is still the one shown; a newer job is followed at once instead. + if(answer==='ended'){ + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. This merge has already ended.'); + // Until the poll presents the outcome, Cancel stays unavailable rather than offering a cancel that is refused. + await expect(page.locator('#merge-cancel')).toHaveAttribute('aria-disabled','true'); + await expect(page.locator('#merge-cancel')).toHaveText('Cancel requested'); + } + else await expect(page.locator('#banner')).toHaveText('Preparing the merge: refresh, rebase, push and command checks.'); + // The held poll's answer is rendered (it renders the merge control even when it changes nothing shown). + const rendered=await armCancelRenders(page,1); + const released=Date.now();release(); + // The next poll comes 2 s after the held one, not after its backed-off delay of 16 s or more. + if(answer==='ended'){ + // The held poll answers the job's older running record: Cancel stays unavailable until the outcome shows. + await rendered(); + // The next poll comes 2 s after the held one, so the outcome is not shown yet. Read once, not retried: the later + // Merged state also marks Cancel unavailable. + expect(await page.evaluate(()=>{const cancel=document.getElementById('merge-cancel')!,merge=document.getElementById('merge')!; + return {merge:merge.textContent,shown:!cancel.hidden,unavailable:cancel.getAttribute('aria-disabled'),label:cancel.textContent};})) + .toEqual({merge:expect.not.stringMatching(/^Merged$/),shown:true,unavailable:'true',label:'Cancel requested'}); + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:6_000}); + expect(Date.now()-released).toBeLessThan(5_000); + } else { + // The held poll, read before the newer job, now answers the older job's running record: it must be discarded. + const pollsAtRelease=polls.length; + await rendered(); + expect(await page.evaluate(()=>({step:document.querySelector('#merge-steps li')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({step:expect.stringMatching(/^● Fetch Running/),banner:'Preparing the merge: refresh, rebase, push and command checks.'}); + await expect.poll(()=>polls.length,{timeout:6_000}).toBeGreaterThan(pollsAtRelease); + expect(Date.now()-released).toBeLessThan(5_000); + } +}); +test('keeps focus on Merge status while the poll follows a running job with no blockers (D16)',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 revision=4;const merging=()=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000dd',state:'running',step:'merging',revision,steps:mergeSteps(['done','skipped','skipped','skipped','done','running'])}); + let polls=0; + // The review the page loads has no blockers (as after a Refresh during the merging step); each poll answers a later record. + 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:merging(),blockers:[]});await route.fulfill({response,json:body});}); + await page.route('**/api/merge',async route=>{polls++;revision++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:merging()})});}); + await page.goto(app.url); + await expect(page.locator('#merge-details')).toBeVisible({timeout:10_000}); + await page.locator('#merge-details').focus(); + // A poll is sent only once the last answer was rendered: two more polls after the focus prove one render after it. + const polledAtFocus=polls; + await expect.poll(()=>polls,{timeout:15_000}).toBeGreaterThanOrEqual(polledAtFocus+2); + expect(await page.evaluate(()=>document.activeElement?.id)).toBe('merge-details'); +}); +for (const runner of [true,false] as const) test(`moves focus off the blockers button to Merge PR when a reload clears the last blocker, ${runner?'with':'without'} a runner (D16)`,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 reloaded=false; + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // With a runner Merge PR is offered (aria-disabled only while a job runs); without one it is disabled until the last + // blocker clears, so the fallback must wait for its final enabled state. + body.merge=runner?runnerMerge(body.merge,{ready:true,action:'merge',job:null,blockers:reloaded?[]:[...body.merge.blockers,...jobResolvedBlockers]}) + :{...body.merge,ready:reloaded,action:reloaded?'merge':null,blockers:reloaded?[]:[...body.merge.blockers,...jobResolvedBlockers]}; + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.locator('#merge-details')).toBeVisible({timeout:10_000}); + await page.locator('#merge-details').focus(); + reloaded=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').dispatchEvent('click'); + await answered; + await expect(page.locator('#merge-details')).toBeHidden(); + await expect(page.locator('#merge')).toBeVisible(); + await expect(page.locator('#merge')).toBeEnabled(); + await expect(page.locator('#merge')).toBeFocused(); +}); +test('moves focus off the blockers button to Refresh when the reload leaves Merge PR disabled (defensive, D16)',async({page})=>{ + // Defensive: the server sends no blockers only with `ready: true`, so Merge PR is enabled then. This pins the fallback's + // "not disabled" rule for an answer that leaves Merge PR disabled with nothing to explain it. + 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 reloaded=false; + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // Without a runner (no `job` key): blockers keep Merge PR disabled; the reload clears them but stays not ready. + body.merge={...body.merge,ready:false,action:null,queue:null,blockers:reloaded?[]:[...body.merge.blockers,...jobResolvedBlockers]}; + await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.locator('#merge-details')).toBeVisible({timeout:10_000}); + await page.locator('#merge-details').focus(); + reloaded=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#review-link').dispatchEvent('click'); + await answered; + await expect(page.locator('#merge-details')).toBeHidden(); + await expect(page.locator('#merge')).toBeDisabled(); + await expect(page.locator('#reload')).toBeFocused(); +}); +for (const control of ['merge','merge-details'] as const) for (const reload of ['unavailable','failed'] as const) test(`moves focus off ${control==='merge'?'Merge PR':'the blockers button'} to Refresh when a reload ${reload==='failed'?'fails':'makes merging unavailable'} (D16)`,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 reloaded=false; + await page.route('**/api/review',async route=>{ + if(reloaded&&reload==='failed'){await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({error:'Could not read this branch\'s history.'})});return;} + const response=await route.fetch(),body=await response.json(); + body.merge=reloaded?{...body.merge,available:false}: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); + await expect(page.locator(`#${control}`)).toBeVisible({timeout:10_000}); + await page.locator(`#${control}`).focus(); + reloaded=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // A synthetic click on the Review link reloads the review without moving focus. + await page.locator('#review-link').dispatchEvent('click'); + await answered; + await expect(page.locator(`#${control}`)).toBeHidden(); + await expect(page.locator('#reload')).toBeFocused(); +}); +for (const reload of ['unavailable','failed','ended'] as const) test(`moves focus off Cancel when a reload ${{failed:'fails',unavailable:'hides the merge control',ended:'shows the job ended'}[reload]} (D16)`,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 waiting=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d8',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + let reloaded=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:waiting}),job:waiting})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:waiting})})); + // The reload answers a review whose merging is not available (the merge control hides), or fails (every merge control hides). + await page.route('**/api/review',async route=>{ + if(reloaded&&reload==='failed'){await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({error:'Could not read this branch\'s history.'})});return;} + const response=await route.fetch(),body=await response.json(); + body.merge=reloaded?(reload==='ended'?runnerMerge(body.merge,{ready:false,action:null,job:mergeJobRecord({...waiting,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7'})}):{...body.merge,available:false}) + :runnerMerge(body.merge,{ready:true,action:'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('#merge-cancel')).toBeVisible({timeout:10_000}); + await page.locator('#merge-cancel').focus(); + reloaded=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/review')); + // A synthetic click on the Review link reloads the review without moving focus off Cancel. + await page.locator('#review-link').dispatchEvent('click'); + await answered; + await expect(page.locator('#merge-steps-bar')).toBeHidden(); + if(reload==='ended'){ + // The merge control is still shown, so focus moves to it. + await expect(page.locator('#merge')).toBeVisible(); + await expect(page.locator('#merge')).toBeFocused(); + } else { + await expect(page.locator('#merge')).toBeHidden(); + await expect(page.locator('#reload')).toBeFocused(); + } +}); +for (const refusal of ['ended','older','newer'] as const) test(`keeps what the poll showed when a cancel's refusal carrying ${{ended:'the ended job',older:'an older record of it',newer:'a newer running job'}[refusal]} arrives after the poll saw the job end (D16)`,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 id='0f6b0000-0000-4000-8000-0000000000da'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const merged=mergeJobRecord({id,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const newer=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000db',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + // The refusal's job: the same ended record, an older running record of it (read before the job ended), or a newer job. + const carried={ended:merged,older:waiting,newer}[refusal]; + const reason={ended:'This merge has already ended.',older:'GitHub has been asked to merge; wait for its outcome.',newer:'This merge has already ended.'}[refusal]; + let release!:()=>void,asked!:()=>void,ended=false; + const held=new Promise(resolve=>release=resolve),cancelAsked=new Promise(resolve=>asked=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){asked();await held;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:reason,job:carried})});return;} + 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:waiting}),job:waiting})});}); + // Once the cancel was sent, the poll sees the job merged; the refusal is held until then. + const polls:string[]=[]; + await page.route('**/api/merge',async route=>{const job=ended?merged:waiting;polls.push(job.state);await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,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:'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('#merge-cancel')).toBeVisible({timeout:10_000}); + await page.locator('#merge-cancel').click(); + await cancelAsked;ended=true; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + const pollsBefore=polls.length; + // Read once the page rendered the refusal's answer; the ended job is shown, so no poll renders meanwhile. + const rendered=await armCancelRenders(page,1); + release();await rendered(); + const shown=()=>page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent, + first:document.querySelector('#merge-steps li')!.textContent,last:document.querySelector('#merge-steps li:last-child')!.textContent})); + if(refusal==='newer'){ + // The newer job is followed: its step list and step show, also after the next poll (which answers it). + const followed={merge:'Preparing…',banner:'Preparing the merge: refresh, rebase, push and command checks.',first:expect.stringMatching(/^● Fetch Running/),last:expect.anything()}; + expect(await shown()).toEqual(followed); + const polled=await armCancelRenders(page,1);await polled(); + expect(await shown()).toEqual(followed); + } else { + // The outcome, its banner and the step list stay; the refusal is not shown. + expect(await shown()).toEqual({merge:'Merged',banner:'Merged. Refresh to see the result.',first:expect.anything(),last:'✓ Merge Done, 1 s'}); + // No poll is forced, and none follows: a check that nothing is sent can only be made over a window, so it waits longer + // than the shortest delay after which the page sends a poll (2 s). + await page.waitForTimeout(2_500); + expect(polls.length).toBe(pollsBefore); + } +}); +test('shows the refusal in the banner when an enabled Cancel is refused because GitHub was asked (D16)',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 Merge step's write failed (steps are display only), so the list still shows it waiting while GitHub was asked. + const merging=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000dc',state:'running',step:'merging',steps:mergeSteps(['done','skipped','skipped','skipped','done','running'])}); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'GitHub has been asked to merge; wait for its outcome.',job:merging})});return;} + if(body.action!=='merge'){await route.continue();return;} + started=true;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:merging}),job:merging})});}); + let failNext=false,later=false; + await page.route('**/api/merge',route=>{if(failNext){failNext=false;return route.fulfill({status:502,contentType:'application/json',body:JSON.stringify({error:'GitHub returned HTTP 502.'})});} + return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:later?{...merging,revision:merging.revision+1}:merging})});}); + // As a runner server answers a full review: the running job rides along once the click started it. + let started=false; + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,started?{ready:false,action:null,job:merging}:{ready:true,action:'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(); + const cancel=page.getByRole('button',{name:'Cancel merge',exact:true}); + await expect(cancel).not.toHaveAttribute('aria-disabled','true',{timeout:10_000}); + await cancel.click(); + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. GitHub has been asked to merge; wait for its outcome.'); + await expect(page.locator('#merge')).toHaveText('Merging…'); + // The next poll answers the same record: the refusal stays, as the only explanation of the enabled Cancel. + const polled=await armCancelRenders(page,1);await polled(); + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. GitHub has been asked to merge; wait for its outcome.'); + // One poll fails and rewrites the banner; the next poll, answering the same record, shows the refusal again. + failNext=true; + await expect(page.locator('#banner')).toHaveText(/^Could not refresh merge status\./,{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. GitHub has been asked to merge; wait for its outcome.',{timeout:40_000}); + // Refresh rewrites the banner too; the next poll, answering the same record, shows the refusal again. + // (Then the job's record changes, below, and the job's own text comes back.) + const reloaded=page.waitForResponse(response=>response.url().endsWith('/api/review')); + await page.locator('#reload').click();await reloaded; + await expect(page.locator('#banner')).not.toHaveText(/^Could not cancel the merge\./); + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. GitHub has been asked to merge; wait for its outcome.',{timeout:40_000}); + // A later record of the job ends the held refusal: the banner says the job's step again. + later=true; + await expect(page.locator('#banner')).toHaveText('Merging the reviewed head.',{timeout:40_000}); +}); +test('keeps an adopted cancel when a poll on its way answers the older record of the same job (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 id='0f6b0000-0000-4000-8000-0000000000de'; + const waiting=mergeJobRecord({id,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const cancelling=mergeJobRecord({...waiting,revision:4,cancelRequested:true}); + let release!:()=>void,arrived!:()=>void,hold=false,cancelled=false;const polls:number[]=[]; + const held=new Promise(resolve=>release=resolve),inFlight=new Promise(resolve=>arrived=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){cancelled=true;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:cancelling})});return;} + 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:waiting}),job:waiting})});}); + // The held poll read the record before the cancel was saved, so it answers revision 3; later polls answer 4. + await page.route('**/api/merge',async route=>{polls.push(Date.now()); + if(hold){hold=false;arrived();await held;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:waiting})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:cancelled?cancelling:waiting})});}); + 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});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('#merge-cancel')).toBeVisible({timeout:10_000}); + hold=true;await inFlight; + // Two renders: the click's own (pending), then the answer's. The held poll is in flight, so no other poll renders. + const answered=await armCancelRenders(page,2); + await page.locator('#merge-cancel').click();await answered(); + await expect(page.locator('#merge-cancel')).toHaveText('Cancel requested'); + const heldRendered=await armCancelRenders(page,1); + release();await heldRendered(); + // Read once: the older record must not undo the adopted cancel. + expect(await page.evaluate(()=>({cancel:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled'), + merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({cancel:'Cancel requested',unavailable:'true',merge:'Cancelling…',banner:'Cancelling the merge. The task and its pull request stay open.'}); +}); +// Each step state's mark, word and color, as DESIGN.md's status table gives them. +const stepLooks={done:['✓','Done','--success'],running:['●','Running','--text'],pending:['○','Waiting','--neutral'],skipped:['–','Skipped','--neutral'], + failed:['✕','Failed','--error'],stopped:['!','Stopped','--warning'],unknown:['!','Unknown','--warning']} as const; +for (const outcome of [ + {name:'running',state:'running',step:'checks',steps:['done','skipped','skipped','skipped','running']}, + {name:'failed',state:'failed',step:null,steps:['done','skipped','failed']}, + {name:'cancelled',state:'cancelled',step:null,steps:['done','stopped']}, + {name:'submitted',state:'submitted',step:null,steps:['done','skipped','skipped','skipped','done','done','unknown']}, +] as const) test(`shows each step's mark, word and color for a ${outcome.name} job (D16)`,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 id='0f6b0000-0000-4000-8000-0000000000df'; + const running=mergeJobRecord({id,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const shown=mergeJobRecord({id,state:outcome.state,step:outcome.step,revision:7,steps:mergeSteps([...outcome.steps]),reason:outcome.state==='running'?null:'Stopped here.'}); + 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})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:shown})})); + 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});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(); + const items=page.getByRole('list',{name:'Merge steps'}).getByRole('listitem'); + const states=mergeSteps([...outcome.steps]).map(step=>step.state as keyof typeof stepLooks); + await expect(items.nth(outcome.steps.length-1)).toHaveText(new RegExp(`^${stepLooks[states[outcome.steps.length-1]!][0]} `),{timeout:15_000}); + const looks=await page.evaluate(()=>[...document.querySelectorAll('#merge-steps li')].map(item=>({ + mark:item.querySelector('.mark')!.textContent,word:item.querySelector('.word')!.textContent,color:getComputedStyle(item.querySelector('.mark')!).color, + wordColor:getComputedStyle(item.querySelector('.word')!).color}))); + const tokens=await page.evaluate(names=>Object.fromEntries(names.map(name=>{const probe=document.createElement('span');probe.style.color=`var(${name})`;document.body.append(probe); + const color=getComputedStyle(probe).color;probe.remove();return [name,color];})),['--success','--text','--neutral','--error','--warning']); + expect(looks).toEqual(states.map(state=>{const [mark,word,token]=stepLooks[state];return {mark,word,color:tokens[token],wordColor:tokens[token]};})); +}); +test('keeps the pending cancel to its own job when another tab starts a newer one (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e3',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e4',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + let releaseA!:()=>void,releaseB!:()=>void,releasePolls!:()=>void,newer=false,holdPolls=false;const cancels:string[]=[]; + const heldA=new Promise(resolve=>releaseA=resolve),heldB=new Promise(resolve=>releaseB=resolve),heldPolls=new Promise(resolve=>releasePolls=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){cancels.push(body.jobId); + // A's cancel is answered only after another tab ended A and started B; B's is held until the end. + if(body.jobId===a.id){await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + await heldB;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:{...b,revision:3,cancelRequested:true}})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{if(holdPolls)await heldPolls;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:newer?b:a})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click(); + await expect(cancel).toHaveText('Cancel requested'); + // Another tab ended A and started B; the poll shows B while A's cancel is still on its way. + newer=true; + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/,{timeout:15_000}); + // B was never cancelled: its Cancel is offered. + await expect(cancel).toHaveText('Cancel merge'); + await expect(cancel).not.toHaveAttribute('aria-disabled','true'); + // Polls are held from here, so no poll renders Cancel while A's answer is checked: the next poll is sent only once the + // last answer was rendered. + const polled=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + holdPolls=true;await polled; + await cancel.click(); + await expect(cancel).toHaveText('Cancel requested'); + // A's answer arrives: it must not clear B's own pending cancel. + const renderedA=await armCancelRenders(page,1); + releaseA();await renderedA(); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({label:'Cancel requested',unavailable:'true'}); + expect(cancels).toEqual([a.id,b.id]); + releaseB();releasePolls(); +}); +test('keeps Cancel unavailable for an ended newer job when a poll read before it answers for the older job (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e5',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e6',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bEnded=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let phase:'a'|'hold'|'b-ended'='a',nextSeen=false,releaseStale!:()=>void,sawHeld!:()=>void,releaseNext!:()=>void,sawNext!:()=>void; + const stale=new Promise(resolve=>releaseStale=resolve),held=new Promise(resolve=>sawHeld=resolve); + const nextHeld=new Promise(resolve=>releaseNext=resolve),next=new Promise(resolve=>sawNext=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is refused with the newer job B another tab started; B's cancel is refused because B has just ended. + if(body.action==='cancel-merge'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify(body.jobId===a.id?{error:'This merge has already ended.',job:b}:{error:'This merge has already ended.',job:bEnded})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // The held poll read A while A still ran; it answers only after B's cancel was answered as ended. + if(phase==='hold'){phase='b-ended';sawHeld();await stale;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:a})});return;} + // The next poll is scheduled only once the held poll's answer was rendered; it is held until Cancel was read. + if(phase==='b-ended'&&!nextSeen){nextSeen=true;sawNext();await nextHeld;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:bEnded})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + phase='hold';await held; + // With that poll in flight, A's cancel is answered with B, which the page follows. + await cancel.click(); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/); + await expect(cancel).toHaveText('Cancel merge'); + // B's cancel is answered with B ended: Cancel stays unavailable until the page shows B's outcome. + // Two renders: the click's own (pending), then the answer's. + const renderedB=await armCancelRenders(page,2); + await cancel.click();await renderedB(); + await expect(cancel).toHaveText('Cancel requested'); + await expect(cancel).toHaveAttribute('aria-disabled','true'); + // The poll read before then answers for A: it says nothing about B, so Cancel stays unavailable. + releaseStale();await next; + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({label:'Cancel requested',unavailable:'true'}); + // The next poll presents B's outcome. + releaseNext(); + await expect(cancel).toBeHidden({timeout:15_000}); +}); +test('keeps Cancel unavailable for an ended newer job when an older job\'s cancel answers after it (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e7',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e8',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bEnded=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let phase:'a'|'b'|'hold'='a',releaseA!:()=>void,releasePoll!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),heldPoll=new Promise(resolve=>releasePoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // Both cancels are refused with the latest job, as the route answers: B, which has just ended. A's answer is late. + if(body.action==='cancel-merge'){if(body.jobId===a.id)await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:bEnded})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // The poll that would present B's outcome is held until A's late answer has arrived. + if(phase==='hold'){await heldPoll;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:bEnded})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:b})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click(); + await expect(cancel).toHaveText('Cancel requested'); + // While A's cancel is on its way, the poll shows the newer job B another tab started. + phase='b'; + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/,{timeout:15_000}); + await expect(cancel).toHaveText('Cancel merge'); + // B's cancel is answered first, with B ended: Cancel stays unavailable until the page shows B's outcome. + // Polls are held from here: once one is held, no poll renders Cancel, because the next poll is sent only once the last + // answer was rendered. Then two renders: the click's own (pending), then the answer's. + const polled=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + phase='hold';await polled; + const renderedB=await armCancelRenders(page,2); + await cancel.click();await renderedB(); + await expect(cancel).toHaveText('Cancel requested'); + await expect(cancel).toHaveAttribute('aria-disabled','true'); + // A's late answer carries the same ended B: B is still shown running, so its Cancel stays unavailable. + // Cancel is read once the page rendered that answer. + const renderedA=await armCancelRenders(page,1); + releaseA();await renderedA(); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({label:'Cancel requested',unavailable:'true'}); + // The held poll presents B's outcome. + releasePoll(); + await expect(cancel).toBeHidden({timeout:15_000}); +}); +test('keeps Cancel unavailable when a poll read before a newer job ended answers its running record (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e9',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ea',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bEnded=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const c=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000eb',state:'running',step:'preparing',sequence:3,revision:2,steps:mergeSteps(['running'])}); + let phase:'a'|'hold'|'b'|'c'='a',release!:()=>void,sawHeld!:()=>void; + const held=new Promise(resolve=>release=resolve),seen=new Promise(resolve=>sawHeld=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is refused with the latest job, as the route answers: B, another tab's job, which has just ended. + if(body.action==='cancel-merge'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:bEnded})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // The held poll read B while B still ran; it answers only after A's cancel was answered with B ended. Later polls + // answer as the test's phase says. + if(phase==='hold'){phase='b';sawHeld();await held;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:b})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:phase==='b'?b:c})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + phase='hold';await seen; + // A, still shown running, is answered with B ended: Cancel stays unavailable until the page shows B's outcome. + const answeredA=page.waitForResponse(response=>response.url().endsWith('/api/action')&&response.request().postDataJSON().action==='cancel-merge'); + await cancel.click();await answeredA; + // The refusal is shown once the answer was handled, A still being the job shown running. + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. This merge has already ended.'); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({label:'Cancel requested',unavailable:'true'}); + // The poll answers B's running record, read before B ended: the page follows B, and its Cancel stays unavailable. + // The next poll is scheduled only once the held poll's answer was rendered; it answers B running too, which changes + // nothing. + const nextPoll=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + release();await nextPoll; + expect(await page.evaluate(()=>({first:document.querySelector('#merge-steps li')!.textContent,label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled'),hidden:document.getElementById('merge-cancel')!.hidden}))) + .toEqual({first:expect.stringMatching(/^● Fetch Running/),label:'Cancel requested',unavailable:'true',hidden:false}); + // A newer job C that another tab starts afterwards offers Cancel. + phase='c'; + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/); + await expect(cancel).toHaveText('Cancel merge',{timeout:15_000}); + await expect(cancel).not.toHaveAttribute('aria-disabled','true'); +}); +test('keeps Cancel unavailable when a Refresh read before the job ended answers after the cancel (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ec',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aEnded=mergeJobRecord({id:a.id,state:'merged',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let started=false,holdReview=false,holdPoll=false,ended=false,releaseReview!:()=>void,sawReview!:()=>void,releasePoll!:()=>void; + const heldReview=new Promise(resolve=>releaseReview=resolve),seenReview=new Promise(resolve=>sawReview=resolve),heldPoll=new Promise(resolve=>releasePoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // The job ended before the cancel arrived: the route refuses it with the job's ended record. + if(body.action==='cancel-merge'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:aEnded})});return;} + if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route);started=true; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // The poll the cancel's answer asks for is held until the Refresh has answered, which discards it. + if(holdPoll){holdPoll=false;await heldPoll;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:ended?aEnded:a})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // Once the job runs, the review carries it running: the held Refresh read it before it ended. + body.merge=started?runnerMerge(body.merge,{ready:false,action:null,job:a}):runnerMerge(body.merge,{ready:true,action:'merge',job:null});if(started)body.job=a; + if(holdReview){holdReview=false;sawReview();await heldReview;} + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + // Refresh does nothing while the click's own work finishes, so it is clicked until its request is sent (a click while + // that Refresh is on its way does nothing either). + holdReview=true; + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await Promise.race([seenReview,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Refresh not sent yet.')),500))]);}).toPass({timeout:15_000}); + // While the Refresh is on its way, the cancel is answered with the job ended. + holdPoll=true; + const answered=page.waitForResponse(response=>response.url().endsWith('/api/action')&&response.request().postDataJSON().action==='cancel-merge'); + await cancel.click();await answered; + // The refusal holds the banner until the Refresh renders its answer (the outcome poll is held). + await expect(page.locator('#banner')).toHaveText('Could not cancel the merge. This merge has already ended.'); + // The Refresh answers with the job running, read before it ended: once it has rendered, Cancel stays unavailable. + releaseReview(); + await expect(page.locator('#banner')).not.toHaveText(/^Could not cancel the merge\./); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled'),hidden:document.getElementById('merge-cancel')!.hidden}))) + .toEqual({label:'Cancel requested',unavailable:'true',hidden:false}); + // The next poll presents the outcome. + ended=true;releasePoll(); + await expect(cancel).toBeHidden({timeout:15_000}); +}); +test('keeps the latest ended record when an older job\'s cancel answers late, across three jobs (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ed',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ee',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bEnded=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const c=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ef',state:'running',step:'preparing',sequence:3,revision:2,steps:mergeSteps(['running'])}); + const cEnded=mergeJobRecord({id:c.id,sequence:c.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let phase:'a'|'b'|'hold'|'c-ended'='a',releaseA!:()=>void,releasePolls!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),heldPolls=new Promise(resolve=>releasePolls=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // Each cancel is refused with the latest job when it was read: A's (answered late) with B ended, B's with C ended. + if(body.action==='cancel-merge'){if(body.jobId===a.id)await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:body.jobId===a.id?bEnded:cEnded})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // Held polls read C while it still ran; they answer C's running record once released. + if(phase==='hold'){await heldPolls;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:c})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:phase==='b'?b:cEnded})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click(); + await expect(cancel).toHaveText('Cancel requested'); + // While A's cancel is on its way, the poll shows B, another tab's job. + phase='b'; + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/,{timeout:15_000}); + await expect(cancel).toHaveText('Cancel merge'); + // Polls are held from here: once one is held, no poll renders Cancel, because the next is sent only once the last + // answer was rendered. B's cancel is answered with C ended (two renders: the click's own, then the answer's). + const polled=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + phase='hold';await polled; + const renderedB=await armCancelRenders(page,2); + await cancel.click();await renderedB(); + await expect(cancel).toHaveText('Cancel requested'); + // A's late answer carries B ended: newer than the B shown, older than the C ended already held. + const renderedA=await armCancelRenders(page,1); + releaseA();await renderedA(); + // The held poll answers C's running record, read before C ended: the page follows C, and its Cancel stays unavailable. + const renderedPoll=await armCancelRenders(page,1); + releasePolls();await renderedPoll(); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled'),hidden:document.getElementById('merge-cancel')!.hidden}))) + .toEqual({label:'Cancel requested',unavailable:'true',hidden:false}); + // The next poll presents C's outcome. + phase='c-ended'; + await expect(cancel).toBeHidden({timeout:15_000}); +}); +test('replaces the held ended record with a later one when a newer job\'s cancel is answered with its end (D16)',async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f0',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aEnded=mergeJobRecord({id:a.id,state:'merged',step:null,revision:4,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f1',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bEnded=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let phase:'a'|'hold1'|'hold2'|'b-ended'='a',release1!:()=>void,release2!:()=>void; + const held1=new Promise(resolve=>release1=resolve),held2=new Promise(resolve=>release2=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // Each cancel is refused with its own job ended: A's with A ended (held), then B's with B ended, a later record. + if(body.action==='cancel-merge'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:body.jobId===a.id?aEnded:bEnded})});return;} + 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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // Both held polls read B while it ran (after A ended, before B did). + if(phase==='hold1'){await held1;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:b})});return;} + if(phase==='hold2'){await held2;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:b})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:bEnded})});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + // With a poll held (none renders meanwhile), A's cancel is answered with A ended: two renders, the click's and the answer's. + let polled=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + phase='hold1';await polled; + const renderedA=await armCancelRenders(page,2); + await cancel.click();await renderedA(); + await expect(cancel).toHaveText('Cancel requested'); + // The held poll answers B running: B is newer than the A ended held, so B offers Cancel. + polled=page.waitForRequest(request=>request.url().endsWith('/api/merge')); + const renderedPoll1=await armCancelRenders(page,1); + phase='hold2';release1();await renderedPoll1(); + await expect(cancel).toHaveText('Cancel merge'); + // With the next poll held, B's cancel is answered with B ended, which replaces the A ended held. + await polled; + const renderedB=await armCancelRenders(page,2); + await cancel.click();await renderedB(); + await expect(cancel).toHaveText('Cancel requested'); + // The held poll answers B's running record, read before B ended: Cancel stays unavailable. + const renderedPoll2=await armCancelRenders(page,1); + release2();await renderedPoll2(); + expect(await page.evaluate(()=>({label:document.getElementById('merge-cancel')!.textContent,unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual({label:'Cancel requested',unavailable:'true'}); + // The next poll presents B's outcome. + phase='b-ended'; + await expect(cancel).toBeHidden({timeout:15_000}); +}); +for (const after of ['poll','refresh'] as const) test(`presents a newer ended job a late cancel answer carries after the shown job ended, ${after==='poll'?'through the poll it asks':'and keeps Cancel unavailable for a Refresh read while it ran'} (D16)`,async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f2',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + // Another tab cancelled A, then started B, which merged. + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f3',state:'running',step:'preparing',sequence:2,revision:2,steps:mergeSteps(['running'])}); + const bMerged=mergeJobRecord({id:b.id,sequence:b.sequence,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + let started=false,phase:'a'|'a-cancelled'|'hold'|'b-merged'='a',holdReview=false,releaseA!:()=>void,sawCancel!:()=>void,releasePoll!:()=>void,sawPoll!:()=>void,releaseReview!:()=>void,sawReview!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelSent=new Promise(resolve=>sawCancel=resolve); + const heldPoll=new Promise(resolve=>releasePoll=resolve),pollSent=new Promise(resolve=>sawPoll=resolve); + const heldReview=new Promise(resolve=>releaseReview=resolve),reviewSent=new Promise(resolve=>sawReview=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, merged. + if(body.action==='cancel-merge'){sawCancel();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:bMerged})});return;} + if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route);started=true; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + if(phase==='hold'){phase='b-merged';sawPoll();await heldPoll;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:phase==='a-cancelled'?aCancelled:bMerged})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // The held Refresh read B while it ran. + body.merge=started?runnerMerge(body.merge,{ready:false,action:null,job:b}):runnerMerge(body.merge,{ready:true,action:'merge',job:null});if(started)body.job=b; + if(holdReview){holdReview=false;sawReview();await heldReview;} + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelSent; + // While A's cancel is on its way, the poll shows A cancelled (by another tab): the page shows that outcome. + phase='a-cancelled'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + if(after==='refresh'){ + // Refresh does nothing while other work finishes, so it is clicked until its request is sent. + holdReview=true; + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await Promise.race([reviewSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Refresh not sent yet.')),500))]);}).toPass({timeout:15_000}); + } + // A's answer carries B merged: the page asks the poll at once (held here, sent only once the answer was handled). + phase='hold';releaseA();await pollSent; + if(after==='poll'){ + releasePoll(); + // The poll presents B's outcome, not A's. + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:10_000}); + await expect(page.locator('#banner')).toHaveText('Merged. Refresh to see the result.'); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').last()).toHaveText('✓ Merge Done, 1 s'); + } else { + // The Refresh, read while B ran, answers B running: B is followed, and Cancel stays unavailable (B is known ended). + const rendered=await armCancelRenders(page,1); + releaseReview();await rendered(); + expect(await page.evaluate(()=>({step:document.querySelector('#merge-steps li')!.textContent,label:document.getElementById('merge-cancel')!.textContent, + unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled'),hidden:document.getElementById('merge-cancel')!.hidden}))) + .toEqual({step:expect.stringMatching(/^● Fetch Running/),label:'Cancel requested',unavailable:'true',hidden:false}); + // The held poll is discarded (the Refresh began a new generation); the next poll presents B's outcome. + releasePoll(); + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + } +}); +test('shows a newer ended job a cancel answer carried when the poll already on its way shows the shown job end (D16)',async({page})=>{ + test.setTimeout(90_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f4',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + // Another tab cancelled A, then started B, which failed. + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + const bFailed=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f5',sequence:2,state:'failed',step:null,revision:6,reason:'Fetch failed.',steps:mergeSteps(['failed'])}); + let started=false,polls=0,phase:'a'|'hold'|'after'='a',releaseA!:()=>void,sawCancel!:()=>void,releasePoll!:()=>void,sawPoll!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelSent=new Promise(resolve=>sawCancel=resolve); + const heldPoll=new Promise(resolve=>releasePoll=resolve),pollSent=new Promise(resolve=>sawPoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, failed. + if(body.action==='cancel-merge'){sawCancel();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:bFailed})});return;} + if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route);started=true; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{polls++; + // The held poll read A cancelled, before B started; later polls answer B failed. + if(phase==='hold'){phase='after';sawPoll();await heldPoll;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:aCancelled})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:phase==='a'?a:bFailed})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=started?runnerMerge(body.merge,{ready:false,action:null,job:a}):runnerMerge(body.merge,{ready:true,action:'merge',job:null});if(started)body.job=a; + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelSent; + // A poll is on its way when A's answer arrives (handled once Cancel renders it). + phase='hold';await pollSent; + const answered=await armCancelRenders(page,1); + releaseA();await answered(); + // That poll shows A cancelled; the next poll, 2 s later, shows B's outcome. + releasePoll(); + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:10_000}); + await expect(page.locator('#merge')).toHaveText('Merge stopped',{timeout:10_000}); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^✕ Fetch Failed/); + // Once B is shown, polling stops. A check that nothing is sent can only be made over a window: it waits longer than the + // shortest delay after which the page polls (2 s). + const pollsAtOutcome=polls; + await page.waitForTimeout(3_000); + expect(polls).toBe(pollsAtOutcome); +}); +test('keeps a later job\'s outcome followed from a job that ended queued, not the older job\'s queue attempt (D16)',async({page})=>{ + test.setTimeout(90_000); + 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-00000000ac06'; + const done=mergeSteps(['done','skipped','skipped','skipped','done','done','done']); + const a=(state:MergeJobView['state'],step:MergeJobView['step'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f6',mergeActionId,state,step,revision,steps:state==='running'?mergeSteps(['done','skipped','skipped','skipped','done','done','running']):done}); + const b=(state:MergeJobView['state'],revision:number)=>mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f7',sequence:2,mergeActionId:null,state,step:state==='running'?'preparing':null,revision,reason:state==='cancelled'?'You cancelled the merge; the task and its pull request stay open.':null,steps:state==='running'?mergeSteps(['running']):mergeSteps(['stopped'])}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Removed by GitHub.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:null,actionId:mergeActionId,retryable:state==='removed'}); + // A ends queued, with its attempt in GitHub's queue; GitHub removes it; another tab starts B, which is cancelled. + const answers=[{queue:null,job:a('running','merging',4)},{queue:queue('queued'),job:a('queued',null,5)},{queue:queue('removed'),job:b('running',2)},{queue:queue('removed'),job:b('cancelled',5)}]; + 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),running=a('running','merging',4); + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:null,job:running}),job:running})});}); + await page.route('**/api/merge',async route=>{const next=answers[Math.min(polls++,answers.length-1)]!;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});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('#merge')).toHaveText('Merge queued',{timeout:30_000}); + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:30_000}); + // B's outcome stays: A's attempt is not B's, so no poll follows it and nothing replaces the outcome. A check that nothing + // is sent can only be made over a window: it waits longer than the shortest delay after which the page polls (2 s). + const pollsAtOutcome=polls; + await page.waitForTimeout(3_000); + expect(polls).toBe(pollsAtOutcome); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({merge:'Merge cancelled',banner:expect.stringMatching(/^Merge cancelled\. /)}); +}); +test('keeps a newer job\'s outcome a late cancel answer started following after a job that ended queued (D16)',async({page})=>{ + test.setTimeout(90_000); + 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-00000000ac08'; + const a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f8',mergeActionId:null,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aQueued=mergeJobRecord({id:a.id,mergeActionId,state:'queued',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f9',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bCancelled=mergeJobRecord({id:b.id,sequence:2,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['stopped'])}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Removed by GitHub.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:null,actionId:mergeActionId,retryable:state==='removed'}); + let phase:'a'|'a-queued'|'b-hold'|'b'|'b-cancelled'='a',polls=0,releaseA!:()=>void,sawCancel!:()=>void,releasePoll!:()=>void,sawPoll!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelSent=new Promise(resolve=>sawCancel=resolve); + const heldPoll=new Promise(resolve=>releasePoll=resolve),pollSent=new Promise(resolve=>sawPoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late: by then A ended queued, GitHub removed its attempt, and another tab started B. + if(body.action==='cancel-merge'){sawCancel();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + 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:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{polls++; + // From A's queued outcome on, the next poll is held until the page followed B from A's answer. + if(phase==='b-hold'){phase='b';sawPoll();await heldPoll;} + const answer={a:{queue:null,job:a},'a-queued':{queue:queue('queued'),job:aQueued},b:{queue:queue('removed'),job:b},'b-cancelled':{queue:queue('removed'),job:bCancelled}}[phase]; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(answer)});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelSent; + // While A's cancel is on its way, the poll shows A ended queued, its attempt in GitHub's queue. + phase='a-queued'; + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + // A's answer carries B running: the page follows B from that answer (renders it), without A's attempt. The poll on + // its way is held, so no poll answer is handled meanwhile. + phase='b-hold';await pollSent; + const pollsBeforeAnswer=polls,followed=await armCancelRenders(page,1); + releaseA();await followed(); + expect(polls).toBe(pollsBeforeAnswer); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/); + // The held poll answers B running, the same job: it does not drop a queue itself. + releasePoll(); + phase='b-cancelled'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + // B's outcome stays: no poll follows A's attempt. A check that nothing is sent can only be made over a window: it + // waits longer than the shortest delay after which the page polls (2 s). + const pollsAtOutcome=polls; + await page.waitForTimeout(3_000); + expect(polls).toBe(pollsAtOutcome); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({merge:'Merge cancelled',banner:expect.stringMatching(/^Merge cancelled\. /)}); +}); +for (const shown of ['running','ended'] as const) test(`follows a later job's own queue attempt to merged when the shown job is ${shown} (D16)`,async({page})=>{ + test.setTimeout(90_000); + 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-00000000ac0b'; + const a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000fa',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + // Another tab's job B ended queued: its own attempt (by its merge action ID) is in GitHub's queue. + const bQueued=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000fb',sequence:2,mergeActionId,state:'queued',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const queue=(state:'queued'|'merged')=>({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:null,actionId:mergeActionId,retryable:false}); + let phase:'a'|'a-cancelled'|'b-queued'|'b-merged'='a',releaseA!:()=>void,sawCancel!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelSent=new Promise(resolve=>sawCancel=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, ended queued. + if(body.action==='cancel-merge'){sawCancel();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:bQueued})});return;} + 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:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + const answer={a:{queue:null,job:a},'a-cancelled':{queue:null,job:aCancelled},'b-queued':{queue:queue('queued'),job:bQueued},'b-merged':{queue:queue('merged'),job:bQueued}}[phase]; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(answer)});}); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + if(shown==='running'){ + // The poll reads B while the page still shows A running. + phase='b-queued'; + } else { + // While A's cancel is on its way, the poll shows A cancelled; then A's answer carries B, and the page polls for it. + await cancel.click();await cancelSent; + phase='a-cancelled'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + phase='b-queued';releaseA(); + } + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + // B's own attempt is followed: when GitHub merges it, the control and banner say so without a Refresh. + phase='b-merged'; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); +}); +test('does not let a Refresh read before a newer job was followed restore the older job\'s queue attempt (D16)',async({page})=>{ + test.setTimeout(120_000); + 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-00000000ac0c'; + const a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000fc',mergeActionId:null,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aQueued=mergeJobRecord({id:a.id,mergeActionId,state:'queued',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000fd',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bCancelled=mergeJobRecord({id:b.id,sequence:2,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['stopped'])}); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='removed'?'Removed by GitHub.':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:null,actionId:mergeActionId,retryable:state==='removed'}); + let phase:'a'|'a-queued'|'b'|'b-cancelled'='a',polls=0,started=false,holdReview=false,releaseA!:()=>void,sawCancel!:()=>void,releaseReview!:()=>void,sawReview!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelSent=new Promise(resolve=>sawCancel=resolve); + const heldReview=new Promise(resolve=>releaseReview=resolve),reviewSent=new Promise(resolve=>sawReview=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late: by then A ended queued, GitHub removed its attempt, and another tab started B. + if(body.action==='cancel-merge'){sawCancel();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + if(body.action!=='merge'){await route.continue();return;} + const review=await clickReview(route);started=true; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,queue:null,job:a}),job:a})});}); + await page.route('**/api/merge',async route=>{polls++; + const answer={a:{queue:null,job:a},'a-queued':{queue:queue('queued'),job:aQueued},b:{queue:queue('removed'),job:b},'b-cancelled':{queue:queue('removed'),job:bCancelled}}[phase]; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(answer)});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // The held Refresh read A ended queued, with A's attempt in GitHub's queue. + body.merge=started?runnerMerge(body.merge,{ready:false,action:null,queue:queue('queued'),job:aQueued}):runnerMerge(body.merge,{ready:true,action:'merge',queue:null,job:null}); + if(holdReview){holdReview=false;sawReview();await heldReview;} + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelSent; + // While A's cancel is on its way, the poll shows A ended queued. + phase='a-queued'; + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + // A Refresh is sent and held (it stops polling until it answers); Refresh does nothing while other work finishes, so + // it is clicked until its request is sent. + holdReview=true; + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await Promise.race([reviewSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Refresh not sent yet.')),500))]);}).toPass({timeout:15_000}); + // A's answer carries B running: the page follows B (renders it) while the Refresh is on its way. + phase='b'; + const followed=await armCancelRenders(page,1); + releaseA();await followed(); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/); + // The Refresh answers A queued with A's attempt: B is kept, without A's attempt. + const refreshed=await armCancelRenders(page,1); + releaseReview();await refreshed(); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^● Fetch Running/); + phase='b-cancelled'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + // B's outcome stays: no poll follows A's attempt. A check that nothing is sent can only be made over a window: it + // waits longer than the shortest delay after which the page polls (2 s). + const pollsAtOutcome=polls; + await page.waitForTimeout(3_000); + expect(polls).toBe(pollsAtOutcome); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({merge:'Merge cancelled',banner:expect.stringMatching(/^Merge cancelled\. /)}); +}); +for (const variant of ['cancel','refresh-required','newer-job','poll-ended'] as const) test(`keeps what the page showed while a merge click was on its way over that click's older answer: ${{cancel:'an accepted cancel of its job','refresh-required':'an accepted cancel, under an answer with only the job','newer-job':'a newer job','poll-ended':'its job ended, shown by a poll'}[variant]} (D16)`,async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000fe',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + // B is the click's job; C is a newer job another tab started while the click was on its way. + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ff',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bCancelling=mergeJobRecord({...b,revision:3,cancelRequested:true}); + const bFailed=mergeJobRecord({id:b.id,sequence:2,state:'failed',step:null,revision:4,reason:'Fetch failed.',steps:mergeSteps(['failed'])}); + const c=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000f9',sequence:3,state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + let clicks=0,stage:'a'|'a-ended'|'b'='a',holdPolls=false,releaseA!:()=>void,sawCancelA!:()=>void,releaseClickB!:()=>void,sawClickB!:()=>void,releasePolls!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelASent=new Promise(resolve=>sawCancelA=resolve); + const heldClickB=new Promise(resolve=>releaseClickB=resolve),clickBSent=new Promise(resolve=>sawClickB=resolve),heldPolls=new Promise(resolve=>releasePolls=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){ + // A's cancel is answered late, refused with the latest job: B running (or C, newer). B's cancel is accepted. + if(body.jobId===a.id){sawCancelA();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:variant==='newer-job'?c:b})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({job:bCancelling})});return;} + if(body.action!=='merge'){await route.continue();return;} + const first=++clicks===1;if(!first)sawClickB(); + const review=await clickReview(route); + if(first){await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:a}),job:a})});return;} + // The second click (B) is answered last, with the record B had when the click was answered: a full answer, or only + // the job when the server could not reload the review (web/server.ts). + await heldClickB; + const answer=variant==='refresh-required'?{job:b,mergeRefreshRequired:true}:{...review,merge:runnerMerge(review.merge,{ready:false,action:null,job:b}),job:b}; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(answer)});}); + await page.route('**/api/merge',async route=>{if(holdPolls)await heldPolls; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:stage==='a'?a:stage==='a-ended'?aCancelled:bFailed})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + // After A ended, the review is ready for a new merge again, with A as the task's latest job. + body.merge=runnerMerge(body.merge,stage==='a'?{ready:true,action:'merge',job:null}:{ready:true,action:'merge',job:aCancelled}); + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelASent; + // While A's cancel is on its way, the poll shows A cancelled (by another tab), and a Refresh makes Merge PR available. + stage='a-ended'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeVisible({timeout:500});}).toPass({timeout:15_000}); + // Merge PR is clicked again (job B); its answer is held. Polls are held from here, so none repairs what follows. + // (Merge PR does nothing while the Refresh finishes, so it is clicked until its request is sent.) + holdPolls=true;stage='b'; + await expect(async()=>{await page.getByRole('button',{name:'Merge PR',exact:true}).click({timeout:500}); + await Promise.race([clickBSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Merge click not sent yet.')),500))]);}).toPass({timeout:15_000}); + // A's answer carries a running job: the page follows it while the click is on its way. + const followed=await armCancelRenders(page,1); + releaseA();await followed(); + let expected:Record; + if(variant==='cancel'||variant==='refresh-required'){ + await expect(cancel).toHaveText('Cancel merge'); + // B's cancel is accepted with a later record of B (cancel requested): two renders, the click's own and the answer's. + const cancelled=await armCancelRenders(page,2); + await cancel.click();await cancelled(); + await expect(cancel).toHaveText('Cancel requested'); + expected={merge:'Cancelling…',label:'Cancel requested',unavailable:'true'}; + } else if(variant==='newer-job'){ + // The page follows C, newer than the click's job (the control reads "Requesting merge…" while the click is on its way). + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^✓ Fetch Done/); + expected={merge:'Waiting for checks…',label:'Cancel merge',unavailable:null}; + } else { + // The held polls are released: one shows B ended (failed), and polling stops. + releasePolls(); + await expect(page.locator('#banner')).toHaveText(/^Merge stopped\./,{timeout:15_000}); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').first()).toHaveText(/^✕ Fetch Failed/); + expected={merge:'Merge stopped',label:'Cancel merge',unavailable:null}; + } + // The click's answer, with B's older running record, arrives: what the page showed is kept. + const answered=await armCancelRenders(page,1); + releaseClickB();await answered(); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,label:document.getElementById('merge-cancel')!.textContent, + unavailable:document.getElementById('merge-cancel')!.getAttribute('aria-disabled')}))) + .toEqual(expected); + releasePolls(); +}); +for (const read of ['running','ended','own-attempt','blocked-own'] as const) test(`keeps a job a merge click showed ended against a poll already on its way that read it ${{running:'running',ended:'ended, with no attempt','own-attempt':'running, with its own attempt','blocked-own':'blocked, with its own attempt failed'}[read]} (D16)`,async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e2',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e3',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bFailed=mergeJobRecord({id:b.id,sequence:2,state:'failed',step:null,revision:4,reason:'Fetch failed.',steps:mergeSteps(['failed'])}); + // In the own-attempt case, B asked GitHub to merge and ended queued, its attempt in GitHub's queue. + const mergeActionId='0f6b0000-0000-4000-8000-00000000ac0e',head=view.snapshot.head; + const bMerging=mergeJobRecord({id:b.id,sequence:2,mergeActionId,state:'running',step:'merging',revision:4,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','running'])}); + const bQueued=mergeJobRecord({id:b.id,sequence:2,mergeActionId,state:'queued',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + // In the blocked-own case, GitHub refused B's attempt: B ended blocked, its own attempt failed. + const bBlocked=mergeJobRecord({id:b.id,sequence:2,mergeActionId,state:'blocked',step:null,revision:6,reason:'GitHub refused the merge: nope. Fix it, then Retry merge.',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','failed'])}); + const attempt=(state:'submitting'|'queued'|'failed')=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:state==='failed'?'nope':null,phase:state==='queued'?'AWAITING_CHECKS':null,position:state==='queued'?1:null,occurredAt:null,actionId:mergeActionId,retryable:false}); + let clicks=0,stage:'a'|'a-ended'|'b'='a',releaseA!:()=>void,sawCancelA!:()=>void,releaseClickB!:()=>void,sawClickB!:()=>void,releasePoll!:()=>void,sawPoll!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelASent=new Promise(resolve=>sawCancelA=resolve); + const heldClickB=new Promise(resolve=>releaseClickB=resolve),clickBSent=new Promise(resolve=>sawClickB=resolve); + const heldPoll=new Promise(resolve=>releasePoll=resolve),pollSent=new Promise(resolve=>sawPoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, running. + if(body.action==='cancel-merge'){sawCancelA();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + if(body.action!=='merge'){await route.continue();return;} + const first=++clicks===1;if(!first)sawClickB(); + const review=await clickReview(route); + // The second click (B) is answered with B failed (or ended queued, with its attempt queued). + if(!first)await heldClickB; + const job=first?a:read==='own-attempt'?bQueued:read==='blocked-own'?bBlocked:bFailed,queue=!first&&read==='own-attempt'?attempt('queued'):null; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({...review,merge:runnerMerge(review.merge,{ready:false,action:null,job,queue}),job})});}); + await page.route('**/api/merge',async route=>{ + // From B on, a poll is held: it read B running (or B failed with no merge attempt, or B at the Merge step with its + // attempt submitting), and answers after the click showed B ended. + if(stage==='b'){sawPoll();await heldPoll; + const answer={running:{queue:null,job:b},ended:{queue:null,job:bFailed},'own-attempt':{queue:attempt('submitting'),job:bMerging},'blocked-own':{queue:attempt('failed'),job:bBlocked}}[read]; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(answer)});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:stage==='a'?a:aCancelled})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,stage==='a'?{ready:true,action:'merge',job:null}:{ready:true,action:'merge',job:aCancelled}); + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelASent; + stage='a-ended'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeVisible({timeout:500});}).toPass({timeout:15_000}); + stage='b'; + await expect(async()=>{await page.getByRole('button',{name:'Merge PR',exact:true}).click({timeout:500}); + await Promise.race([clickBSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Merge click not sent yet.')),500))]);}).toPass({timeout:15_000}); + // A's answer carries B running: the page follows B, and its poll is sent while the click is still on its way. + const followed=await armCancelRenders(page,1); + releaseA();await followed(); + await pollSent; + // The click answers with B ended: the outcome shows. + const outcome=read==='own-attempt'?'Merge queued':read==='blocked-own'?'Merge blocked':'Merge stopped'; + releaseClickB(); + await expect(page.locator('#merge')).toHaveText(outcome,{timeout:15_000}); + // The poll then answers: B's outcome stays. + const polled=await armCancelRenders(page,1); + releasePoll();await polled(); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,details:document.getElementById('merge-details')!.hidden}))) + .toEqual({merge:outcome,details:false}); + if(read==='blocked-own')await expect(page.locator('#banner')).toHaveText(/Fix it, then Retry merge\./); + else if(read!=='own-attempt')await expect(page.locator('#banner')).toHaveText(/^Merge stopped\./); +}); +for (const carried of ['running','none','ended','older'] as const) test(`keeps following the running job a cancel answer showed when a merge click on its way is refused ${{running:'with it',none:'with no job',ended:'with a later record of it that ended, presented',older:'with an older job\'s ended record'}[carried]} (D16)`,async({page})=>{ + test.setTimeout(120_000); + 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 a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e4',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + // B: another tab's job, started while this page's click was on its way. + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e5',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bMerged=mergeJobRecord({id:b.id,sequence:2,state:'merged',step:null,revision:6,url:'https://github.com/owner/repo/pull/7',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + // Refused because B runs (with B's record), or for a reason that carries no job or an older job (the review changed). + const refusal=carried==='running'?'A merge is running for this task; wait for it to end, or cancel the merge or the task.':'The review changed after Merge PR was clicked. Refresh to see the change.'; + let clicks=0,stage:'a'|'a-ended'|'b'='a',holdPolls=false,releaseA!:()=>void,sawCancelA!:()=>void,releaseClickB!:()=>void,sawClickB!:()=>void,releasePolls!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelASent=new Promise(resolve=>sawCancelA=resolve); + const heldClickB=new Promise(resolve=>releaseClickB=resolve),clickBSent=new Promise(resolve=>sawClickB=resolve),heldPolls=new Promise(resolve=>releasePolls=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, running. + if(body.action==='cancel-merge'){sawCancelA();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + if(body.action!=='merge'){await route.continue();return;} + const first=++clicks===1;if(!first)sawClickB(); + // The second click is refused, with B's record (the same one A's answer carried) or with no job. + // (Older: refused while A was still the task's latest job, so it carries A cancelled.) + if(!first){await heldClickB;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify(carried==='none'?{error:refusal}:{error:refusal,job:carried==='ended'?bMerged:carried==='older'?aCancelled:b})});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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{if(holdPolls)await heldPolls; + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:stage==='a'?a:stage==='a-ended'?aCancelled:b})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,stage==='a'?{ready:true,action:'merge',job:null}:{ready:true,action:'merge',job:aCancelled}); + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelASent; + stage='a-ended'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeVisible({timeout:500});}).toPass({timeout:15_000}); + // Polls are held from here, so none repairs what follows. + holdPolls=true;stage='b'; + await expect(async()=>{await page.getByRole('button',{name:'Merge PR',exact:true}).click({timeout:500}); + await Promise.race([clickBSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Merge click not sent yet.')),500))]);}).toPass({timeout:15_000}); + const followed=await armCancelRenders(page,1); + releaseA();await followed(); + await expect(cancel).toHaveText('Cancel merge'); + // The click's refusal, with B's record, arrives: the page keeps following B, and says why the click was refused. + const answered=await armCancelRenders(page,1); + releaseClickB();await answered(); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent, + label:document.getElementById('merge-cancel')!.textContent,steps:!document.getElementById('merge-steps-bar')!.hidden}))) + .toEqual(carried==='ended' + // B merged meanwhile: the refusal carried that later record, and the page shows B's outcome. + ?{merge:'Merged',banner:expect.stringMatching(/^Merged\. /),label:expect.anything(),steps:true} + :{merge:'Preparing…',banner:`${refusal} Preparing the merge: refresh, rebase, push and command checks.`,label:'Cancel merge',steps:true}); + releasePolls(); +}); +test('follows the merge attempt of a job that ended submitted, loaded from a full review (D16)',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 server restarted while GitHub was asked to merge: the job ended submitted, its attempt still in GitHub's queue + // (runner/store.ts settleInterruptedMergeJobs); the review carries both. + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-00000000ac01'; + const job=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e6',state:'submitted',step:null,reason:'The server restarted while GitHub\'s merge outcome was unknown; it reconciles from GitHub.'}); + expect(job.mergeActionId).toBe(mergeActionId); + 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}); + let merged=false; + await page.route('**/api/merge',async route=>{await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:queue(merged?'merged':'queued'),job})});}); + 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:queue('queued'),job,blockers:[{code:'queue-active',message:'The reviewed head is queued.'}]});await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + // GitHub merges the attempt: the page follows it without a Refresh. + merged=true; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); +}); +test('keeps a blocked outcome when a reload during the Merge step left its own attempt submitting (D16)',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 review was read at the Merge step, with the job's own attempt submitting; GitHub then refused the merge. + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-00000000ac01'; + const merging=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e7',state:'running',step:'merging',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','running'])}); + const blocked=mergeJobRecord({id:merging.id,mergeActionId,state:'blocked',step:null,reason:'GitHub refused the merge: nope. Fix it, then Retry merge.',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','failed'])}); + expect(merging.mergeActionId).toBe(mergeActionId); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:null,reason:state==='failed'?'nope':null,phase:null,position:null,occurredAt:null,actionId:mergeActionId,retryable:false}); + let polls=0; + await page.route('**/api/merge',async route=>{polls++;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:queue('failed'),job:blocked})});}); + 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:queue('submitting'),job:merging,blockers:[]});await route.fulfill({response,json:body});}); + await page.goto(app.url); + await expect(page.locator('#merge')).toHaveText('Merge blocked',{timeout:15_000}); + // The outcome stays, and polling stops: the attempt is the blocked job's own, read while it asked GitHub. A check that + // nothing is sent can only be made over a window: it waits longer than the shortest delay after which the page polls (2 s). + const pollsAtOutcome=polls; + await page.waitForTimeout(3_000); + expect(polls).toBe(pollsAtOutcome); + expect(await page.evaluate(()=>({merge:document.getElementById('merge')!.textContent,banner:document.getElementById('banner')!.textContent}))) + .toEqual({merge:'Merge blocked',banner:expect.stringMatching(/Fix it, then Retry merge\./)}); +}); +test('follows the own attempt of a job a poll saw end submitted (D16)',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 job's merge outcome was unknown: it ended submitted, its own attempt in GitHub's queue, which then merges it. + const head=view.snapshot.head,mergeActionId='0f6b0000-0000-4000-8000-00000000ac01'; + const running=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e8',state:'running',step:'preparing',steps:mergeSteps(['running'])}); + const submitted=mergeJobRecord({id:running.id,state:'submitted',step:null,reason:'GitHub\'s merge outcome is unknown; the merge attempt settles from GitHub.',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','unknown'])}); + expect(submitted.mergeActionId).toBe(mergeActionId); + const queue=(state:string)=>({kind:'queue',state,reviewedHead:head,url:null,reason:null,phase:null,position:state==='queued'?1:null,occurredAt:null,actionId:mergeActionId,retryable:false}); + let merged=false; + await page.route('**/api/merge',async route=>{await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:queue(merged?'merged':'queued'),job:submitted})});}); + 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:running,blockers:[]});await route.fulfill({response,json:body});}); + await page.goto(app.url); + // The poll sees the job end submitted, with its own attempt queued: the page follows the attempt. + await expect(page.locator('#merge')).toHaveText('Merge queued',{timeout:15_000}); + merged=true; + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:15_000}); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); +}); +test('follows the own attempt of a later queued record a refused merge click carries, through the poll (D16)',async({page})=>{ + test.setTimeout(120_000); + 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-00000000ac0f'; + const a=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000e9',state:'running',step:'checks',revision:3,steps:mergeSteps(['done','skipped','skipped','skipped','running'])}); + const aCancelled=mergeJobRecord({id:a.id,state:'cancelled',step:null,revision:5,cancelRequested:true,reason:'You cancelled the merge; the task and its pull request stay open.',steps:mergeSteps(['done','skipped','skipped','skipped','stopped'])}); + // B: another tab's job, which ends queued; GitHub then merges its attempt. + const b=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000ea',sequence:2,state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const bQueued=mergeJobRecord({id:b.id,sequence:2,mergeActionId,state:'queued',step:null,revision:6,steps:mergeSteps(['done','skipped','skipped','skipped','done','done','done'])}); + const attempt=(state:string)=>({kind:'queue',state,reviewedHead:head,url:'https://github.com/example/repo/pull/7',reason:null,phase:null,position:null,occurredAt:state==='merged'?'2026-10-11T00:10:00Z':null,actionId:mergeActionId,retryable:false}); + let clicks=0,stage:'a'|'a-ended'|'b'='a',bPolls=0,releaseA!:()=>void,sawCancelA!:()=>void,releaseClickB!:()=>void,sawClickB!:()=>void,releasePoll!:()=>void,sawPoll!:()=>void; + const heldA=new Promise(resolve=>releaseA=resolve),cancelASent=new Promise(resolve=>sawCancelA=resolve); + const heldClickB=new Promise(resolve=>releaseClickB=resolve),clickBSent=new Promise(resolve=>sawClickB=resolve); + const heldPoll=new Promise(resolve=>releasePoll=resolve),pollSent=new Promise(resolve=>sawPoll=resolve); + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + // A's cancel is answered late, refused with the latest job: B, running. + if(body.action==='cancel-merge'){sawCancelA();await heldA;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'This merge has already ended.',job:b})});return;} + if(body.action!=='merge'){await route.continue();return;} + const first=++clicks===1;if(!first)sawClickB(); + // The second click is refused, carrying B ended queued (a refusal carries no queue attempt). + if(!first){await heldClickB;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'The review changed after Merge PR was clicked. Refresh to see the change.',job:bQueued})});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:a}),job:a})});}); + await page.route('**/api/merge',async route=>{ + // From B on, the first poll read B running and is held until after the refusal; later polls see B queued, its attempt + // merged by GitHub. + if(stage==='b'){const n=++bPolls;if(n===1){sawPoll();await heldPoll;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(n===1?{queue:null,job:b}:{queue:attempt('merged'),job:bQueued})});return;} + await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:stage==='a'?a:aCancelled})});}); + await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json(); + body.merge=runnerMerge(body.merge,stage==='a'?{ready:true,action:'merge',job:null}:{ready:true,action:'merge',job:aCancelled}); + 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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveText('Cancel merge',{timeout:10_000}); + await cancel.click();await cancelASent; + stage='a-ended'; + await expect(page.locator('#merge')).toHaveText('Merge cancelled',{timeout:15_000}); + await expect(async()=>{await page.getByRole('button',{name:'Refresh',exact:true}).click(); + await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeVisible({timeout:500});}).toPass({timeout:15_000}); + stage='b'; + await expect(async()=>{await page.getByRole('button',{name:'Merge PR',exact:true}).click({timeout:500}); + await Promise.race([clickBSent,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Merge click not sent yet.')),500))]);}).toPass({timeout:15_000}); + // A's answer carries B running: the page follows B, and its first poll is held. + const followed=await armCancelRenders(page,1); + releaseA();await followed(); + await pollSent; + // The refusal carries B ended queued: the page keeps following B, and the polls then follow B's attempt to merged. + const refused=await armCancelRenders(page,1); + releaseClickB();await refused(); + releasePoll(); + await expect(page.locator('#merge')).toHaveText('Merged',{timeout:20_000}); + await expect(page.locator('#banner')).toHaveText('GitHub confirmed the reviewed head was merged.'); +}); +test('marks Cancel unavailable once the merge job asked GitHub to merge (D16)',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 merging=mergeJobRecord({id:'0f6b0000-0000-4000-8000-0000000000d2',state:'running',step:'merging',steps:mergeSteps(['done','skipped','skipped','skipped','done','done','running'])}); + let cancels=0; + await page.route('**/api/action',async route=>{const body=route.request().postDataJSON(); + if(body.action==='cancel-merge'){cancels++;await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'GitHub has been asked to merge; wait for its outcome.',job:merging})});return;} + 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:merging}),job:merging})});}); + await page.route('**/api/merge',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:null,job:merging})})); + 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});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(); + const cancel=page.locator('#merge-cancel'); + await expect(cancel).toHaveAttribute('aria-disabled','true',{timeout:10_000}); + await expect(cancel).toHaveAttribute('title','GitHub has been asked to merge; wait for its outcome.'); + // An `aria-disabled` Cancel ignores clicks, so the reason is written beside it and describes it. + await expect(page.locator('#merge-cancel-note')).toBeVisible(); + await expect(cancel).toHaveAccessibleDescription('GitHub has been asked to merge; wait for its outcome.'); + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').nth(1)).toHaveText('– Rebase Skipped'); + await cancel.click({force:true}); + // Nothing is sent: a check that nothing is sent can only be made over a window, so it waits longer than the shortest + // delay after which the page would send it (a cancel is sent at once; the page's shortest poll delay is 2 s). + await page.waitForTimeout(2_500); + expect(cancels).toBe(0); + await expect(page.locator('#banner')).not.toHaveText(/^Could not cancel the merge\./); +}); 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.');}}; @@ -913,6 +2445,14 @@ test('does not back off the merge-job poll when only the waiting checks change w expect(delays).toEqual([2000,4000,4000]); }); +test('polls soon again when only the running step of the list changes within a job step (D16)',async({page})=>{ + // As production saves them: preparation reports Fetch, then Rebase, while the job's own step stays `preparing`. + const fetching=mergeJobRecord({state:'running',step:'preparing',revision:2,steps:mergeSteps(['running'])}); + const rebasing=mergeJobRecord({state:'running',step:'preparing',revision:3,steps:mergeSteps(['done','running'])}); + const delays=await mergeJobPollDelays(page,[fetching,fetching,rebasing,rebasing,mergeJobRecord({state:'merged',step:null,revision:6})]); + // 2 s, doubled to 4 s after the unchanged poll, back to 2 s once the running list step changed, then doubled again. + expect(delays).toEqual([2000,4000,2000,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.');}}; @@ -1440,7 +2980,11 @@ test('follows the merge queue a polled job ended in, from a review with no queue 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}); + // With the step list (D16): the Merge step runs, then is done when the job ends queued. + const jobSteps=(state:MergeJobView['state'],step:MergeJobView['step'])=>state==='running' + ?mergeSteps(step==='preparing'?['running']:['done','skipped','skipped','skipped','done','done','running']) + :mergeSteps(['done','skipped','skipped','skipped','done','done','done']); + 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,steps:jobSteps(state,step)}); 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)}]; @@ -1462,6 +3006,9 @@ test('follows the merge queue a polled job ended in, from a review with no queue 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'); + // The job's step list stays on screen through the queue polls and after GitHub merged, until Refresh (D16). + await expect(page.getByRole('list',{name:'Merge steps'}).getByRole('listitem').last()).toHaveText('✓ Merge Done, 1 s'); + await expect(page.locator('#merge-cancel')).toBeHidden(); // 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'); diff --git a/test/merge-job.test.ts b/test/merge-job.test.ts index b6d9f28b..8c22bd1c 100644 --- a/test/merge-job.test.ts +++ b/test/merge-job.test.ts @@ -1,10 +1,14 @@ import { randomUUID } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { afterEach, expect, it, vi } from 'vitest'; -import { MAX_MERGE_JOB_TOKEN_LENGTH, Store } from '../runner/store.ts'; +import { MAX_MERGE_JOB_TOKEN_LENGTH, MERGE_CANCELLED, MERGE_STEP_NAMES, Store, type MergeStep } 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 { PreMergeCoordinator, PreMergeProgress, 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'; @@ -24,8 +28,8 @@ 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); +function publishedTask(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'); @@ -54,8 +58,12 @@ function preparation(store: Store, result: (call: number) => PreMergeResult['sta const calls: string[] = []; const fake = { assertStartable: () => undefined, - start: vi.fn(async (expected: { actionId: string }) => { + stopForMergeCancel: vi.fn(), + start: vi.fn(async (expected: { actionId: string }, progress?: PreMergeProgress) => { calls.push(expected.actionId); + // As production reports a preparation with nothing to rebase, push or run (runner/pre-merge.ts). + progress?.('fetch', 'running'); progress?.('fetch', 'done'); + progress?.('rebase', 'skipped'); progress?.('push', 'skipped'); progress?.('commands', 'skipped'); 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), @@ -436,7 +444,10 @@ it('leaves a job running on shutdown, and startup ends it as failed', async () = 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' }); + const done = await settled(f.coordinator); + expect(done).toMatchObject({ state: 'queued' }); + // A job that ended queued shows its Merge step done, as merged does. + expect(done.steps!.find(step => step.name === 'merge')).toMatchObject({ state: 'done', endedAt: expect.any(String) }); expect(f.store.getMergeAttempt(identity)).toMatchObject({ kind: 'queue', state: 'queued' }); expect(f.store.getTask(identity).status).toBe('in review'); }); @@ -505,6 +516,8 @@ it('keeps a job running and reports it when even reading the task fails after th 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' }); + // Nothing runs it any more, so a merge-only cancel is refused rather than accepted and never ended. + expect(() => f.coordinator.cancelMerge(started.id)).toThrow(/running until the server restarts/); } finally { f.store.getTask = getTask; logged.mockRestore(); } }); @@ -1444,3 +1457,416 @@ it('keeps the guarded merge\'s Retry merge for a task still in review', () => { expect(withMergeJob(status, null, 'in review')).toMatchObject({ ready: true, action: 'retry' }); expect(withMergeJob({ ...status, action: 'merge' }, null, 'in review')).toMatchObject({ ready: true, action: 'merge' }); }); + +// The design's merge step list and the header's Cancel (D16, DT7; docs/implementation/merge-handoff.md, "Step list"). +const stepStates = (job: { steps?: MergeStep[] } | null) => Object.fromEntries((job?.steps ?? []).map(step => [step.name, step.state])); +/** Holds the job's preparation once it has started fetching, until `finish` settles it with `state` and `reason`. */ +function holdPreparation(f: ReturnType) { + let begun!: () => void, finish!: (state: PreMergeResult['state'], reason: string | null) => void; + const started = new Promise(resolve => { begun = resolve; }); + const gate = new Promise<{ state: PreMergeResult['state']; reason: string | null }>(resolve => { finish = (state, reason) => resolve({ state, reason }); }); + (f.prep.fake.start as unknown as ReturnType).mockImplementationOnce(async (expected: { actionId: string }, progress?: PreMergeProgress) => { + f.prep.calls.push(expected.actionId); + progress?.('fetch', 'running'); begun(); + const { state, reason } = await gate, snapshot = f.store.getSnapshot(identity); + const readiness = state === 'ready' ? { stateVersion: f.store.getTask(identity).stateVersion, reviewVersion: f.store.reviewVersion(identity), + snapshotId: snapshot.id, base: snapshot.base, head: snapshot.head, commandPolicyDigest: commandDigest([]) } : null; + return f.store.settlePreMergeAction(identity, expected.actionId, { state, base: snapshot.base, head: snapshot.head, checked: [], reason }, readiness); + }); + return { started, finish }; +} + +it('starts every job with the seven design steps pending, in order', async () => { + const f = setup(), held = holdPreparation(f); + const job = await f.coordinator.start('review-token', randomUUID()); + expect(job.steps?.map(step => step.name)).toEqual(['fetch', 'rebase', 'push', 'commands', 'checks', 'already-fixed', 'merge']); + expect(job.steps?.map(step => step.name)).toEqual([...MERGE_STEP_NAMES]); + expect(job.steps?.every(step => step.state === 'pending' && step.startedAt === null && step.endedAt === null)).toBe(true); + await held.started; + expect(stepStates(f.coordinator.latest())).toMatchObject({ fetch: 'running', rebase: 'pending' }); + held.finish('ready', null); + expect((await settled(f.coordinator)).state).toBe('merged'); +}); + +it('records each step a merge passes through, with its start and end times, and the steps preparation skipped', async () => { + let release!: () => void, passes = false; + 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()); + await vi.waitFor(() => expect(stepStates(f.store.getMergeJob(identity, job.id))).toMatchObject({ checks: 'running' })); + expect(stepStates(f.store.getMergeJob(identity, job.id))).toEqual({ fetch: 'done', rebase: 'skipped', push: 'skipped', commands: 'skipped', + checks: 'running', 'already-fixed': 'pending', merge: 'pending' }); + passes = true; + // GitHub has been asked (the merge command is held): the already-fixed check passed in the guarded merge's validation. + await vi.waitFor(() => expect(f.gh.merged).toEqual([oid(2)])); + expect(stepStates(f.store.getMergeJob(identity, job.id))).toMatchObject({ checks: 'done', 'already-fixed': 'done', merge: 'running' }); + release(); + const done = await settled(f.coordinator); + expect(done.state).toBe('merged'); + expect(stepStates(done)).toEqual({ fetch: 'done', rebase: 'skipped', push: 'skipped', commands: 'skipped', checks: 'done', 'already-fixed': 'done', merge: 'done' }); + for (const step of done.steps!) { + if (step.state === 'skipped') { expect(step.startedAt).toBeNull(); expect(step.endedAt).not.toBeNull(); continue; } + expect(Date.parse(step.endedAt!)).toBeGreaterThanOrEqual(Date.parse(step.startedAt!)); + } +}); + +it('marks the step that was running failed when the job ends there, and leaves later steps pending', async () => { + const failing = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); + await failing.coordinator.start('review-token', randomUUID()); + const blocked = await settled(failing.coordinator); + expect(blocked.state).toBe('blocked'); + expect(stepStates(blocked)).toMatchObject({ commands: 'skipped', checks: 'failed', 'already-fixed': 'pending', merge: 'pending' }); + + const refused = setup({ merge: async () => { throw new MergeSubmissionError('Base branch was modified.', 'refused'); } }); + await refused.coordinator.start('review-token', randomUUID()); + const done = await settled(refused.coordinator); + expect(done.state).toBe('blocked'); + expect(stepStates(done)).toMatchObject({ checks: 'done', 'already-fixed': 'done', merge: 'failed' }); + + const review = setup({ prepare: () => 'review-required' }); + await review.coordinator.start('review-token', randomUUID()); + const reviewed = await settled(review.coordinator); + expect(reviewed.state).toBe('review-required'); + // The fake preparation finished every step it reports; nothing was running when it stopped for review. + expect(stepStates(reviewed)).toMatchObject({ fetch: 'done', checks: 'pending', merge: 'pending' }); +}); + +it('marks the merge step unknown when GitHub\'s merge outcome is unknown', async () => { + const f = setup({ merge: async () => { throw new MergeSubmissionError('connection reset', 'unknown'); } }); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(done.state).toBe('submitted'); + expect(stepStates(done)).toMatchObject({ 'already-fixed': 'done', merge: 'unknown' }); +}); + +it('keeps merging when a step cannot be recorded: the list is display only', async () => { + const f = setup(); + const mark = vi.spyOn(f.store, 'markMergeStep').mockImplementation(() => { throw new Error('disk I/O error'); }); + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); + expect(mark).toHaveBeenCalled(); + expect(errors.mock.calls.some(([message]) => /Could not record merge step .*disk I\/O error/.test(String(message)))).toBe(true); + expect(f.gh.merged).toEqual([oid(2)]); + } finally { errors.mockRestore(); } +}); + +it('cancels a merge during preparation without cancelling the task, and a new click merges', async () => { + const f = setup(), held = holdPreparation(f); + const job = await f.coordinator.start('review-token', randomUUID()); + await held.started; + const answer = f.coordinator.cancelMerge(job.id); + expect(answer).toMatchObject({ id: job.id, state: 'running', cancelRequested: true }); + expect(answer).not.toHaveProperty('token'); + // The preparation is told to stop, as a task cancel stops it, and settles once its work has. + expect(f.prep.fake.stopForMergeCancel).toHaveBeenCalledTimes(1); + // A repeat of the same cancel answers the job. + expect(f.coordinator.cancelMerge(job.id)).toMatchObject({ id: job.id, cancelRequested: true }); + held.finish('failed', MERGE_CANCELLED); + const done = await settled(f.coordinator); + expect(done).toMatchObject({ id: job.id, state: 'cancelled', reason: MERGE_CANCELLED }); + expect(stepStates(done)).toMatchObject({ fetch: 'stopped', rebase: 'pending', merge: 'pending' }); + expect(f.gh.merged).toEqual([]); + const task = f.store.getTask(identity); + expect(task.status).toBe('in review'); + expect(task.cancelRequested).toBeNull(); + expect(f.store.taskPullRequests(identity).find(pr => pr.number === 7)).toBeDefined(); + // Merge PR may be clicked again. + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); +}); + +it('cancels a merge while it waits for required checks, leaving an approved, merge blocked task blocked', async () => { + const f = setup({ checks: () => [pending()] }); + // A retry: the task is approved, merge blocked before the job starts. + f.store.transitionTask(identity, f.store.getTask(identity).stateVersion, 'approved but merge blocked'); + const job = await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ step: 'checks', checks: [{ state: 'pending' }] })); + f.coordinator.cancelMerge(job.id); + const done = await settled(f.coordinator); + expect(done).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); + expect(stepStates(done)).toMatchObject({ fetch: 'done', checks: 'stopped', 'already-fixed': 'pending' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity).status).toBe('approved but merge blocked'); +}); + +it('refuses to cancel a merge once GitHub has been asked, and the merge ends as GitHub answers', async () => { + const hold = holdGatewayMerge(); + const f = setup({ merge: hold.merge }); + const job = await f.coordinator.start('review-token', randomUUID()); + await hold.started; + expect(() => f.coordinator.cancelMerge(job.id)).toThrow(new GuardRefusal('GitHub has been asked to merge; wait for its outcome.')); + expect(f.store.getMergeJob(identity, job.id)?.cancelRequested).toBeUndefined(); + hold.release(); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); +}); + +it('refuses to cancel a merge that ended, an unknown or refused one, one this server is not running, or any during shutdown', async () => { + const f = setup(); + const job = await f.coordinator.start('review-token', randomUUID()); + await settled(f.coordinator); + expect(() => f.coordinator.cancelMerge(job.id)).toThrow(new GuardRefusal('This merge has already ended.')); + expect(() => f.coordinator.cancelMerge(randomUUID())).toThrow(new GuardRefusal('There is no such merge to cancel.')); + const refusedId = randomUUID(); + await expect(f.coordinator.start('stale-token', refusedId)).rejects.toBeInstanceOf(GuardRefusal); + expect(() => f.coordinator.cancelMerge(refusedId)).toThrow(new GuardRefusal('There is no such merge to cancel.')); + // Running on disk (its ending write failed before a restart), but not run by this coordinator. + const g = setup(); + const orphan = g.store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }).job; + expect(() => g.coordinator.cancelMerge(orphan.id)).toThrow(/running until the server restarts/); + g.coordinator.stop(); + expect(() => g.coordinator.cancelMerge(orphan.id)).toThrow(ShuttingDownError); +}); + +it('ends a job the person cancelled as a cancelled task when the task is cancelled before its work stops', async () => { + const f = setup(), held = holdPreparation(f); + const job = await f.coordinator.start('review-token', randomUUID()); + await held.started; + f.coordinator.cancelMerge(job.id); + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); + f.coordinator.cancel(); + held.finish('failed', 'Task cancelled.'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); +}); + +it('settles a cancelled merge that a restart interrupted before GitHub was asked as cancelled, and ends its running step', () => { + const store = publishedTask(); + const { job } = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }); + store.markMergeStep(identity, job.id, 'fetch', 'running'); + store.requestMergeJobCancel(identity, job.id); + store.settleInterruptedMergeJobs(identity); + const settledJob = store.getMergeJob(identity, job.id)!; + expect(settledJob).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); + expect(stepStates(settledJob)).toMatchObject({ fetch: 'stopped', rebase: 'pending' }); + expect(store.getTask(identity).status).toBe('in review'); + + // Without a cancel, the restart fails the job, as before, and its running step with it. + const other = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }).job; + store.markMergeStep(identity, other.id, 'rebase', 'running'); + store.settleInterruptedMergeJobs(identity); + expect(store.getMergeJob(identity, other.id)).toMatchObject({ state: 'failed', reason: 'The server restarted before the merge finished.' }); + expect(stepStates(store.getMergeJob(identity, other.id))).toMatchObject({ rebase: 'failed' }); +}); + +it('moves the step list on: starting a step finishes the one still running, and a job that ended or has no list is left unchanged', () => { + const store = publishedTask(); + const { job } = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }); + store.markMergeStep(identity, job.id, 'checks', 'running'); + const moved = store.markMergeStep(identity, job.id, 'already-fixed', 'running')!; + expect(stepStates(moved)).toMatchObject({ checks: 'done', 'already-fixed': 'running' }); + expect(moved.steps!.find(step => step.name === 'checks')!.endedAt).not.toBeNull(); + store.finishMergeJob(identity, job.id, { state: 'failed', reason: 'x' }); + expect(store.markMergeStep(identity, job.id, 'merge', 'running')).toBeNull(); + expect(store.requestMergeJobCancel(identity, job.id)).toBeNull(); +}); + +it('leaves a job saved before step lists existed without one, through its steps and its end', () => { + const path = join(mkdtempSync(join(tmpdir(), 'codeboost-merge-steps-')), 'store.db'); + const store = publishedTask(path); + const { job } = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }); + // As a record written before this change: no `steps` field in its data. + const raw = new DatabaseSync(path); + raw.prepare(`UPDATE merge_jobs SET data=json_remove(data,'$.steps') WHERE id=?`).run(job.id); raw.close(); + expect(store.getMergeJob(identity, job.id)).not.toHaveProperty('steps'); + expect(store.markMergeStep(identity, job.id, 'fetch', 'running')).toBeNull(); + expect(store.finishMergeJob(identity, job.id, { state: 'failed', reason: 'x' })).toBe(true); + expect(store.getMergeJob(identity, job.id)).not.toHaveProperty('steps'); +}); + +it('cancels a merge during the guarded merge\'s validation, before GitHub is asked, leaving the task in review', async () => { + let cancelAt = Number.POSITIVE_INFINITY, coordinatorRef: MergeJobCoordinator | null = null, storeRef: Store | null = null; + const seen: unknown[] = []; + const f = setup({ change: n => { + if (n === cancelAt) { + const running = storeRef!.runningMergeJob(identity)!; + // The job saved its merge action ID and runs the already-fixed check, but no merge attempt exists yet. + seen.push({ step: running.step, mergeActionId: running.mergeActionId, attempt: storeRef!.getMergeAttempt(identity)?.actionId ?? null, + steps: stepStates(running) }); + coordinatorRef!.cancelMerge(running.id); + } + 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()); + const done = await settled(f.coordinator); + expect(seen).toEqual([{ step: 'merging', mergeActionId: expect.any(String), attempt: null, steps: expect.objectContaining({ 'already-fixed': 'running', merge: 'pending' }) }]); + expect(done).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); + expect(stepStates(done)).toMatchObject({ 'already-fixed': 'stopped', merge: 'pending' }); + expect(f.gh.merged).toEqual([]); + expect(f.store.getTask(identity)).toMatchObject({ status: 'in review', cancelRequested: null }); +}); + +it('cancels a merge during the job\'s own first review load, preparing nothing', 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); + const signal = await held.loading; + f.coordinator.cancelMerge(id); + // The cancel stopped the held load through the job's signal. + expect(signal?.aborted).toBe(true); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); + expect(f.prep.calls).toEqual([]); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('ends a cancelled merge cancelled, not left running, when shutdown follows before its work stops', async () => { + const f = setup(), held = holdPreparation(f); + const job = await f.coordinator.start('review-token', randomUUID()); + await held.started; + f.coordinator.cancelMerge(job.id); + // Shutdown's first step, then the preparation settles (as it does after the drain). + f.coordinator.stop(); + held.finish('failed', MERGE_CANCELLED); + await f.coordinator.close(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); +}); + +it('settles a merge the person cancelled as a cancelled task at restart when the task was cancelled too', () => { + const store = publishedTask(); + const { job } = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }); + store.requestMergeJobCancel(identity, job.id); + store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); + store.settleInterruptedMergeJobs(identity); + expect(store.getMergeJob(identity, job.id)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); +}); + +it('ends a cancelled merge cancelled when an inspection answers a failing check after the cancel, leaving the task in review', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); + // GitHub's answer is already on its way when the person cancels: a status read that ignores the stop and answers. + const status = f.merges.status.bind(f.merges); + let answered = 0; + f.merges.status = (async () => { + const read = await status(undefined, true); + f.coordinator.cancelMerge(f.store.runningMergeJob(identity)!.id); answered++; + return read; + }) as typeof f.merges.status; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: MERGE_CANCELLED }); + expect(answered).toBe(1); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('refuses a merge-only cancel while the task itself is being cancelled, and the job ends as a cancelled task', async () => { + const f = setup(), held = holdPreparation(f); + const job = await f.coordinator.start('review-token', randomUUID()); + await held.started; + f.store.cancelTask(identity, f.store.getTask(identity).stateVersion, randomUUID()); + const task = f.store.getTask(identity); + expect(task.status === 'cancelled' || task.cancelRequested !== null).toBe(true); + expect(() => f.coordinator.cancelMerge(job.id)).toThrow(new GuardRefusal('The task is being cancelled.')); + expect(f.store.getMergeJob(identity, job.id)?.cancelRequested).toBeUndefined(); + f.coordinator.cancel(); + held.finish('failed', 'Task cancelled.'); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); +}); + +it('saves the waiting checks only when they change, so unchanged inspections add no revision for the page to poll on', async () => { + let state: 'pending' | 'missing' = 'pending'; + const f = setup({ checks: () => [{ context: 'ci', appId: null, state }], timing: { checkWaitMs: 60_000, firstDelayMs: 5, maxDelayMs: 5 } }); + const job = await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ step: 'checks', checks: [{ context: 'ci', state: 'pending' }] })); + const revision = f.store.getMergeJob(identity, job.id)!.revision, seen = f.gh.inspections(); + // Several more inspections answer the same waiting checks: nothing new is saved. + await vi.waitFor(() => expect(f.gh.inspections()).toBeGreaterThanOrEqual(seen + 3)); + expect(f.store.getMergeJob(identity, job.id)!.revision).toBe(revision); + // A change in what is waiting is saved. + state = 'missing'; + await vi.waitFor(() => expect(f.store.getMergeJob(identity, job.id)!.checks).toEqual([{ context: 'ci', state: 'missing' }])); + expect(f.store.getMergeJob(identity, job.id)!.revision).toBeGreaterThan(revision); + f.coordinator.cancelMerge(job.id); + expect(await settled(f.coordinator)).toMatchObject({ state: 'cancelled' }); +}); + +it('asks GitHub to merge even when the step-list callback throws, since it is display only', async () => { + const f = setup(); + const merge = f.merges.merge.bind(f.merges); + let called = 0; + f.merges.merge = ((token, actionId, signal) => merge(token, actionId, signal, () => { called++; throw new Error('display failed'); })) as typeof f.merges.merge; + await f.coordinator.start('review-token', randomUUID()); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); + expect(called).toBe(1); + expect(f.gh.merged).toEqual([oid(2)]); +}); + +it('does not tell the step list again when a merge click is replayed from its saved outcome', async () => { + const f = setup(); + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(done).toMatchObject({ state: 'merged' }); + // The job's own guarded merge saved its outcome under its merge action ID; a resend of that click replays it. + let told = 0; + await f.merges.merge('review-token', done.mergeActionId!, undefined, () => { told++; }); + expect(told).toBe(0); + expect(f.gh.merged).toEqual([oid(2)]); +}); + +it('marks Fetch failed when preparation stops for review while fetching, leaving later steps waiting', async () => { + const f = setup(), held = holdPreparation(f); + await f.coordinator.start('review-token', randomUUID()); + await held.started; + held.finish('review-required', 'The pull request head moved; attribution and approvals were refreshed.'); + const done = await settled(f.coordinator); + expect(done.state).toBe('review-required'); + expect(stepStates(done)).toMatchObject({ fetch: 'failed', rebase: 'pending', checks: 'pending', merge: 'pending' }); +}); + +it('leaves the job running, deciding nothing, when an inspection answers a failing check after shutdown began', async () => { + const f = setup({ checks: () => [{ context: 'build', appId: null, state: 'failure' }] }); + const status = f.merges.status.bind(f.merges); + let answered = 0; + f.merges.status = (async () => { + const read = await status(undefined, true); + // Shutdown's first step arrives while GitHub's answer is on its way; the read ignores it and answers. + f.coordinator.stop(); answered++; + return read; + }) as typeof f.merges.status; + const job = await f.coordinator.start('review-token', randomUUID()); + await vi.waitFor(() => expect(answered).toBe(1)); + await f.coordinator.close(); + expect(f.store.getMergeJob(identity, job.id)).toMatchObject({ state: 'running' }); + expect(f.store.getTask(identity).status).toBe('in review'); +}); + +it('does not tell the step list again when a resend joins a merge click still in progress', async () => { + const hold = holdGatewayMerge(); + const f = setup({ merge: hold.merge }); + const job = await f.coordinator.start('review-token', randomUUID()); + await hold.started; + const mergeId = f.store.getMergeJob(identity, job.id)!.mergeActionId!; + let told = 0; + const joined = f.merges.merge('review-token', mergeId, undefined, () => { told++; }); + hold.release(); + await joined; + expect(told).toBe(0); + expect(await settled(f.coordinator)).toMatchObject({ state: 'merged' }); + expect(hold.calls()).toBe(1); +}); + +it('keeps the Merge step waiting when the merge attempt cannot be saved, since GitHub was not asked', async () => { + const f = setup(); + f.store.beginMergeAttempt = (() => { throw new Error('disk I/O error'); }) as typeof f.store.beginMergeAttempt; + await f.coordinator.start('review-token', randomUUID()); + const done = await settled(f.coordinator); + expect(done.state).toBe('failed'); + expect(stepStates(done)).toMatchObject({ 'already-fixed': 'failed', merge: 'pending' }); + expect(f.gh.merged).toEqual([]); +}); + +it('settles a merge the person cancelled as a cancelled task at restart when the task cancel is still stopping', async () => { + const store = publishedTask(); + const { job } = store.beginMergeJob(identity, { actionId: randomUUID(), token: 'review-token' }); + store.requestMergeJobCancel(identity, job.id); + // A rebase was under way when the server stopped, so the task cancel could only be requested, not closed. + const snapshot = store.getSnapshot(identity); + store.beginRebase(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, store.getTask(identity).stateVersion, + { oldBase: snapshot.base, oldHead: snapshot.head, oldHistory: [snapshot.head], onto: oid(3) }); + expect(store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID())).toBe('stopping'); + expect(store.getTask(identity)).toMatchObject({ status: 'in review', cancelRequested: expect.any(String) }); + store.settleInterruptedMergeJobs(identity); + expect(store.getMergeJob(identity, job.id)).toMatchObject({ state: 'cancelled', reason: 'Task cancelled.' }); +}); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 81c5c6b6..44a59de9 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -4,7 +4,8 @@ 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, type PreMergeResult } from '../runner/pre-merge.ts'; + type PreMergeAuthorization, type PreMergeProgress, type PreMergeResult } from '../runner/pre-merge.ts'; +import { MERGE_CANCELLED } from '../runner/store.ts'; import { MergeCoordinator } from '../runner/merge.ts'; import { MergeJobCoordinator } from '../runner/merge-job.ts'; import { MergeSubmissionError, type MergeGateway } from '../github/merge.ts'; @@ -89,6 +90,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: /** Runs while each review load the preparation starts is in flight. */ duringLoad?: (service: ReviewService) => void; closeDuringCommand?: boolean; + /** The merge job cancels its merge (not the task) while the command check runs. */ + mergeCancelDuringCommand?: boolean; + /** Told of each preparation step, as the merge job's step list is. */ + progress?: PreMergeProgress; closeAfterCommandAdmission?: boolean; commandWaitsForDeadline?: boolean; commandWaitsForCancellation?: boolean; @@ -237,10 +242,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: }; const cancelReasons: string[] = []; const runner = new RunnerCoordinator(service.store, commandCheckDeps(service.store, workspace, input => { - if (options.closeDuringCommand) { + if (options.closeDuringCommand || options.mergeCancelDuringCommand) { let settle!: (result: InvocationResult) => void; const settled = new Promise(resolve => { settle = resolve; }); - queueMicrotask(() => { void coordinator.close(); }); + queueMicrotask(() => { if (options.mergeCancelDuringCommand) coordinator.stopForMergeCancel(); else void coordinator.close(); }); return { attemptId: input.attemptId, settled, cancel(reason) { cancelReasons.push(reason); settle({ attemptId: input.attemptId, context: input.context, exitCode: null, @@ -353,7 +358,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: if (options.actionId) service.store.userAction(identity, { actionId: options.actionId, kind: 'prepare-merge', request: actionRequest }, () => ({ outcome: 'preparing' })); 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 } : {}) }); + ...(options.actionId ? { actionId: options.actionId } : {}) }, options.progress); return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons, actionRequest, pushes, remote: () => remotePair, branchReads: () => branchReads, fetchesAfterPush: () => fetchesAfterPush, /** The branch moves (as GitHub's Git data shows it); the PR API reports `shown` for the next `reads` inspections. */ @@ -934,6 +939,44 @@ it('refuses to record a head moved before preparation when the task changes whil await coordinator.close(); }); +it('reports Rebase and Push skipped when neither the base nor the head moved', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-unmoved-')); roots.push(root); + const repository = join(root, 'repo'); fixtureGit(root, 'init', '-q', '-b', 'main', repository); + fixtureGit(repository, 'config', 'user.name', 'Test'); fixtureGit(repository, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(repository, 'a.txt'), 'base\n'); fixtureGit(repository, 'add', '.'); fixtureGit(repository, 'commit', '-qm', 'base'); + const base = fixtureGit(repository, 'rev-parse', 'HEAD'); + fixtureGit(repository, 'switch', '-qc', 'feature'); + writeFileSync(join(repository, 'a.txt'), 'feature\n'); fixtureGit(repository, 'commit', '-am', 'feature', '-q'); + const head = fixtureGit(repository, 'rev-parse', 'HEAD'); + const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' }; + const config = { database: join(root, 'review.sqlite'), repository, runnerRepository: repository, identity, + pathIdentity: { caseSensitive: true, unicodeNormalization: 'none' as const } }; + const service = new ReviewService(config); services.push(service); + const plan: Plan = { schema_version: 1, revision: 1, issue: 1, summary: 'One change', questions: [], items: [{ id: 'P1', + title: 'Change a', intent: 'Change a', files: [{ path: 'a.txt', kind: 'edit', renamed_from: null, change: 'Change it' }], + acceptance: [{ type: 'check', text: 'a changed' }], depends_on: [] }] }; + const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a.txt', kind: 'file' }], pathKey: path => path, allowedCommands: [] }; + 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 }]); + markRunnerOwned(service); + view = await service.load(); view = await service.act({ action: 'approve', item: 'P1', token: view.token }); + // GitHub shows the reviewed pair: nothing to rebase and nothing to push. + const coordinator = new PreMergeCoordinator(service, + { start() { throw new Error('No command checks expected.'); }, settled: async () => undefined, + stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), + get unreleased() { return null; } } as never, + { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, + { inspect: async () => ({ base, head }), fetch: async () => undefined, ...unusedPush }); + const task = service.store.getTask(identity); + const events: string[] = []; + const result = await coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, + base, head }, (step, state) => { events.push(`${step}:${state}`); }); + expect(result).toMatchObject({ state: 'ready', base, head }); + expect(events).toEqual(['fetch:running', 'fetch:done', 'rebase:skipped', 'push:skipped', 'commands:skipped']); + await coordinator.close(); +}); + it('refreshes the moved head against its prior base when the PR base and head advance together', async () => { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); @@ -958,10 +1001,14 @@ it('refreshes the moved head against its prior base when the PR base and head ad { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, { inspect: async () => moved, fetch: async () => undefined, ...unusedPush }); const task = service.store.getTask(config.identity); + const events: string[] = []; const result = await coordinator.start({ stateVersion: task.stateVersion, - reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); + reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }, + (step, state) => { events.push(`${step}:${state}`); }); expect(result.reason).toMatch(/head moved/); expect(result).toMatchObject({ state: 'review-required', base: initial.base, head: moved.head, checked: [] }); + // Fetch is still running when the moved head stops preparation, so the job's step list shows it Failed (D16). + expect(events).toEqual(['fetch:running']); const refreshed = await service.load(); expect(refreshed.snapshot).toMatchObject({ base: initial.base, head: moved.head }); expect(refreshed.segments.some(segment => segment.row === 'Unplanned')).toBe(true); @@ -2093,3 +2140,65 @@ it('ends the job cancelled when the task is cancelled while a command check runs await vi.waitFor(() => expect(f.service.store.getTask(identity).status).toBe('cancelled')); await f.close(); }); + +// The merge job's step list (D16) and the header's Cancel, which stops the merge but not the task. +const progressLog = () => { + const events: string[] = []; + return { events, progress: ((step, state) => { events.push(`${step}:${state}`); }) as PreMergeProgress }; +}; + +it('reports each preparation step as it starts and finishes: fetch, rebase, push, then command checks', async () => { + const log = progressLog(); + const fixture = await rebaseFixture('feature\n', 0, { progress: log.progress }); + expect(fixture.result).toMatchObject({ state: 'ready', checked: ['P1'] }); + expect(log.events).toEqual(['fetch:running', 'fetch:done', 'rebase:running', 'rebase:done', 'push:running', 'push:done', + 'commands:running', 'commands:done']); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('reports command checks skipped when no plan item has one, and stops reporting where preparation stops for review', async () => { + const skipped = progressLog(); + const ready = await rebaseFixture('feature\n', undefined, { progress: skipped.progress }); + expect(ready.result).toMatchObject({ state: 'ready', checked: [] }); + expect(skipped.events).toEqual(['fetch:running', 'fetch:done', 'rebase:running', 'rebase:done', 'push:running', 'push:done', 'commands:skipped']); + await ready.coordinator.close(); await ready.runner.close(); + + const review = progressLog(); + const stale = await rebaseFixture('changed by rebase\n', undefined, { progress: review.progress }); + expect(stale.result.state).toBe('review-required'); + expect(review.events).toEqual(['fetch:running', 'fetch:done', 'rebase:running', 'rebase:done']); + await stale.coordinator.close(); await stale.runner.close(); +}); + +it('prepares as before when the progress callback throws: the step list is display only', async () => { + const fixture = await rebaseFixture('feature\n', 0, { progress: () => { throw new Error('display failed'); } }); + expect(fixture.result).toMatchObject({ state: 'ready', checked: ['P1'] }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('stops a command check as cancelled when the merge is cancelled, and leaves the task as it was', async () => { + const fixture = await rebaseFixture('feature\n', 0, { mergeCancelDuringCommand: true }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: MERGE_CANCELLED }); + expect(fixture.cancelReasons).toEqual(['cancelled']); + const store = fixture.service.store, id = fixture.service.config.identity; + expect(store.getAttempts(id).find(attempt => attempt.kind === 'check')).toMatchObject({ state: 'cancelled' }); + expect(store.getTask(id)).toMatchObject({ status: 'in review', cancelRequested: null }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('aborts an owned rebase and cleans it up when the merge is cancelled, leaving the task in review', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringRebase(_service, coordinator) { coordinator.stopForMergeCancel(); } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: MERGE_CANCELLED }); + const task = fixture.service.store.getTask(fixture.service.config.identity); + expect(task).toMatchObject({ status: 'in review', cancelRequested: null, rebaseInProgress: null }); + expect(fixture.pushes).toEqual([]); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('pushes nothing when the merge is cancelled while the push reads the remote, settles the push and keeps the task in review', async () => { + const fixture = await rebaseFixture('feature\n', undefined, { duringPushRead(_service, coordinator) { coordinator.stopForMergeCancel(); } }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: MERGE_CANCELLED }); + expect(fixture.remote().head).not.toBe(fixture.rebased); + expect(fixture.service.store.getTask(fixture.service.config.identity)).toMatchObject({ status: 'in review', cancelRequested: null, pushInProgress: null }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index b7dbbb7d..f9ad395e 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -7,7 +7,7 @@ import { fixtureGit as git } from './fixtures/git.ts'; import { ghIssueNotFound } from './fixtures/gh-answers.ts'; import { createDemo } from '../scripts/demo.ts'; import { startServer } from '../web/server.ts'; -import { Store, type PublishRecord } from '../runner/store.ts'; +import { MERGE_CANCELLED, Store, type PublishRecord } from '../runner/store.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import type { ReviewService } from '../runner/review.ts'; import type { PreparedAttempt, RunnerCoordinator, RunnerDeps } from '../runner/coordinator.ts'; @@ -1779,7 +1779,7 @@ describe('the one-click merge job (F6b of #22)', () => { /** 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, + cancelTask: () => 'closed' as const, close: async () => undefined, stopForMergeCancel() {}, 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), @@ -1894,6 +1894,38 @@ describe('the one-click merge job (F6b of #22)', () => { expect(served.store.getTask(served.identity).status).toBe('merged'); }, 60_000); + it('cancels a running merge job from the merge header without cancelling the task, and refuses a cancel once the job ended', 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, + mergeJobTiming: { checkWaitMs: 600_000, firstDelayMs: 20, maxDelayMs: 50 } }); + storeRef = served.store; createIdentity = () => served.identity; + await publishSettled(served.app, served.identity); + const cancel = async (jobId: unknown) => { + const response = await fetch(`${new URL(served.app.url).origin}/api/action`, { method: 'POST', headers: { 'x-codeboost-token': served.app.token, 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'cancel-merge', jobId }) }); + return { status: response.status, body: await response.json() as Record }; + }; + expect(await cancel('not-a-uuid')).toMatchObject({ status: 400, body: { error: 'A merge cancel needs the job\'s UUID v4 jobId.' } }); + const started = await merge(served.app, (await review(served.app)).token); + expect(started.status).toBe(200); + const id = started.body.job.id; + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ step: 'checks', steps: expect.arrayContaining([{ name: 'checks', state: 'running', + startedAt: expect.any(String), endedAt: null }]) }), { timeout: 10_000 }); + const pullRequests = served.store.taskPullRequests(served.identity); + const answered = await cancel(id); + expect(answered).toMatchObject({ status: 200, body: { job: { id, cancelRequested: true } } }); + expect(answered.body.job).not.toHaveProperty('token'); + await vi.waitFor(async () => expect((await mergeState(served.app)).job).toMatchObject({ id, state: 'cancelled', reason: MERGE_CANCELLED }), { timeout: 10_000 }); + expect(gh.merged).toEqual([]); + expect(served.store.getTask(served.identity)).toMatchObject({ status: 'in review', cancelRequested: null }); + expect(served.store.taskPullRequests(served.identity)).toEqual(pullRequests); + expect(await cancel(id)).toMatchObject({ status: 409, body: { error: 'This merge has already ended.', job: { id, state: 'cancelled' } } }); + // Merge PR is offered again. + expect((await review(served.app)).merge).toMatchObject({ ready: true, action: 'merge' }); + }, 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; @@ -1933,7 +1965,7 @@ describe('the one-click merge job (F6b of #22)', () => { 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.' } }); + expect(refused).toMatchObject({ status: 409, body: { error: 'A merge is running for this task; wait for it to end, or cancel the merge or 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 } }); } @@ -1982,7 +2014,7 @@ describe('the one-click merge job (F6b of #22)', () => { 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(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 merge or the task.' } }); expect(served.store.getTask(served.identity).status).toBe('in review'); expect(served.store.recentAttempts(served.identity, 50)).toHaveLength(attempts); release(); diff --git a/web/public/app.js b/web/public/app.js index 202fd4ef..da0deda4 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -116,8 +116,12 @@ function showFailure(message, planStatusOwner) { ]) $(id).innerHTML = ""; $("approve").hidden = true; + // Focus on a merge control about to be hidden moves to Refresh rather than falling to the page. + if (["merge", "merge-details", "merge-cancel"].includes(document.activeElement?.id)) $("reload").focus(); $("merge").hidden = true; $("merge-details").hidden = true; + $("merge-steps-bar").hidden = true; + if (mergeStepTimer) { clearInterval(mergeStepTimer); mergeStepTimer = null; } $("message").disabled = true; $("save-note").disabled = true; $("previous").disabled = true; @@ -172,10 +176,28 @@ async function refresh() { if (!data) renderPlans(); } } +/** + * Where focus goes when the focused merge control hides: Merge PR while it is shown and not `disabled` (an + * `aria-disabled` Merge PR still takes focus), otherwise Refresh. + */ +function mergeFocusFallback() { + return !$("merge").hidden && !$("merge").disabled ? $("merge") : $("reload"); +} function renderMerge() { + // Read before rendering: hiding the focused control itself blurs it at once (hiding its parent does so at the next + // frame). A render that hides the focused merge control, its blockers button or Cancel moves focus to the fallback once + // every control's final state is set (AGENTS.md). + const focused = document.activeElement; + renderMergeControls(); + renderMergeSteps(focused); + if ((focused === $("merge") || focused === $("merge-details")) && focused.hidden) mergeFocusFallback().focus(); +} +function renderMergeControls() { const merge = data?.merge; $("merge").hidden = !merge?.available; - $("merge-details").hidden = !merge?.available || (!merge.blockers.length && !merge.queue); + // Decided once: a running job shows Merge status even with no blockers, and hiding then showing the focused button + // would blur it (and move focus) on every poll. + $("merge-details").hidden = !merge?.available || (!merge.blockers.length && !merge.queue && merge.job?.state !== "running"); 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; @@ -207,7 +229,6 @@ function renderMerge() { // 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; @@ -237,8 +258,13 @@ function scheduleMergePoll() { mergePollTimer = null; const queue = data?.merge?.queue; 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))) { + // A new step of the list, or a cancel, polls quickly again, as a new job step does: a cancel usually ends the job at once. + const state = job?.state === "running" + ? `job:${job.step}:${job.steps?.find(step => step.state === "running")?.name ?? ""}:${job.cancelRequested ? "cancel" : ""}` : queue?.state; + // A cancel's answer carried an ended job later than the one shown: polls continue until the page shows it, also when a + // poll already on its way showed the shown job end. + const unshownEnd = !!job && !!mergeCancelEnded && laterMergeJob(mergeCancelEnded, job); + if (job?.state === "running" || unshownEnd || (queue?.kind === "queue" && ["submitting", "queued"].includes(state))) { if (state !== mergePollState) { mergePollState = state; mergePollDelay = 2000; @@ -271,16 +297,38 @@ async function pollMergeQueue(generation) { const update = await api("/api/merge"); current = settle(); if (!current) return; - if (data.merge.job?.state === "running") { + const job = update.job; + const shownJob = data.merge.job; + // A poll read before a cancel's answer was adopted must not restore an older record of the same job, or a record + // of an older job than the one shown (a newer job the cancel's answer carried). + const stale = shownJob?.state === "running" && !!job + && (job.id === shownJob.id ? job.revision < shownJob.revision : !laterMergeJob(job, shownJob)); + // A later job is followed also once the shown job ended (a cancel's answer that carried it asks this poll, or the + // queue poll after a job that ended `queued` reads it). The shown job's queue attempt is not the later job's: it is + // dropped, and only the later job's own attempt is kept (by its merge action ID). + const laterJob = !!shownJob && !!job && job.id !== shownJob.id && laterMergeJob(job, shownJob); + if (laterJob) data = { ...data, merge: { ...data.merge, queue: null } }; + if (shownJob?.state === "running" || laterJob) { // 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 (stale) { + backOffMergePoll(); + renderMerge(); + return; + } 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); + // Any saved change (a step, the waiting checks, a cancel) is a later revision of the record. + const changed = job.revision !== data.merge.job.revision || job.step !== data.merge.job.step + || JSON.stringify(job.checks) !== JSON.stringify(data.merge.job.checks); + // A later job is followed as a newer job is (withMergeJobObservation), without the ended job's blocker. + data = laterJob ? withMergeJobObservation(data, job) : { ...data, merge: { ...data.merge, job } }; + // A cancel's refusal stays in the banner until the job's record changes: it is the only explanation of a Cancel + // that stayed enabled. + // Written again on every poll, so a reload or a failed poll that rewrote the banner meanwhile is corrected. + if (mergeCancelRefusal?.key !== `${job.id}:${job.revision}`) mergeCancelRefusal = null; + $("banner").textContent = mergeCancelRefusal ? mergeCancelRefusal.text : mergeJobText(job); if (!changed) backOffMergePoll(); renderMerge(); return; @@ -292,6 +340,17 @@ async function pollMergeQueue(generation) { return; } const queue = update.queue; + // An ended job the page shows keeps its outcome against a poll that read it, or an older job, before it ended (one + // already on its way when a click's answer showed it end), and against an answer that is not about its own merge + // attempt. Its own attempt (by its merge action ID: a job that ended `queued` or `submitted`) is still followed. + if (shownJob && shownJob.state !== "running") { + const readEarlier = !!job && laterMergeJob(shownJob, job); + if (readEarlier || !isEndedJobQueue(shownJob, queue)) { + backOffMergePoll(); + renderMerge(); + return; + } + } mergeObservationGeneration++; data = withMergeQueue(data, queue); showMergeQueueStatus(queue); @@ -306,6 +365,7 @@ async function pollMergeQueue(generation) { } } function mergeJobText(job) { + if (job.state === "running" && job.cancelRequested) return "Cancelling the merge. The task and its pull request stay open."; 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."; @@ -316,13 +376,151 @@ function mergeJobText(job) { 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.", + failed: `Merge stopped.${job.reason ? ` ${job.reason}` : ""} Merge again when ready.`, + // A cancel of the merge alone says the task stays open; a cancelled task needs no more words. + cancelled: job.reason && job.reason !== "Task cancelled." ? `Merge cancelled. ${job.reason}` : "Merge cancelled.", }[job.state] ?? job.state; } /** The running job's step, as the Merge control and its status dialog name it. */ function mergeJobStepLabel(job) { + if (job.cancelRequested) return "Cancelling…"; return { preparing: "Preparing…", checks: "Waiting for checks…", merging: "Merging…" }[job.step] ?? "Merging…"; } +// The design's merge step list (D16). Each state is an icon, a word and a color (DESIGN.md). +const mergeStepNames = { fetch: "Fetch", rebase: "Rebase", push: "Push", commands: "Re-run checks", checks: "GitHub checks", + "already-fixed": "Already-fixed check", merge: "Merge" }; +const mergeStepStates = { pending: ["○", "Waiting"], running: ["●", "Running"], done: ["✓", "Done"], skipped: ["–", "Skipped"], + failed: ["✕", "Failed"], stopped: ["!", "Stopped"], unknown: ["!", "Unknown"] }; +/** The ID of the job a cancel request is on its way for, or null. */ +let mergeCancelPending = null; +/** + * The latest ended job record a cancel's answer carried, or null. Any record older than it (an earlier revision of that + * job, or an older job) is shown with Cancel requested: a poll or reload read before that job ended still reports it, or + * the job it replaced, running. + */ +let mergeCancelEnded = null; +/** A cancel's refusal and the `id:revision` of the job record it was given for, or null. */ +let mergeCancelRefusal = null; +let mergeStepTimer = null; +function mergeStepElapsed(step, now = Date.now()) { + if (!step.startedAt) return ""; + const ms = Math.max(0, (step.endedAt ? Date.parse(step.endedAt) : now) - Date.parse(step.startedAt)); + const seconds = Math.floor(ms / 1000); + return seconds < 60 ? `${seconds} s` : `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, "0")} s`; +} +/** + * The step list under the review strip, with Cancel, while a job runs and after the page saw it end (until the review + * reloads). A job saved before step lists existed has none: the control and banner say its step, as before. `focused` is + * the element that had focus before the update began (the caller reads it before any control hides). + */ +function renderMergeSteps(focused = document.activeElement) { + const job = data?.merge?.available ? data.merge.job : null; + const ended = data?.merge?.blockers?.some(blocker => blocker.code === "job-refresh") || (!!job && data.merge.endedJobShown === job.id); + const shown = !!job?.steps && (job.state === "running" || ended); + $("merge-steps-bar").hidden = !shown; + if (mergeStepTimer && !(shown && job.state === "running")) { clearInterval(mergeStepTimer); mergeStepTimer = null; } + // Hiding a focused Cancel (or the whole bar) would drop keyboard focus to the page: it moves to the fallback instead. + if ((!shown || job.state !== "running") && focused === $("merge-cancel")) mergeFocusFallback().focus(); + if (!shown) return; + // Rebuilt only when a step changes; the timer below rewrites the elapsed times alone, so a screen reader keeps its place. + const key = JSON.stringify(job.steps); + if ($("merge-steps").dataset.steps !== key) { + $("merge-steps").dataset.steps = key; + $("merge-steps").innerHTML = job.steps.map(step => { + const [icon, word] = mergeStepStates[step.state] ?? ["?", step.state]; + // Screen readers skip the mark and read the name, the word and the time (DESIGN.md). + return `
  • ${esc(mergeStepNames[step.name] ?? step.name)} ${esc(word)}
  • `; + }).join(""); + } + updateMergeStepTimes(job); + const cancel = $("merge-cancel"); + cancel.hidden = job.state !== "running"; + // GitHub was asked once the merge step started: stopping then could only make the outcome unknown, so the server refuses. + const asked = job.steps.some(step => step.name === "merge" && step.state !== "pending"); + const pending = mergeCancelPending === job.id || (!!mergeCancelEnded && laterMergeJob(mergeCancelEnded, job)); + const blocked = asked || job.cancelRequested || pending; + if (blocked) cancel.setAttribute("aria-disabled", "true"); else cancel.removeAttribute("aria-disabled"); + cancel.textContent = job.cancelRequested || pending ? "Cancel requested" : "Cancel merge"; + cancel.title = asked ? "GitHub has been asked to merge; wait for its outcome." : "Stop this merge. The task and its pull request stay open."; + // An `aria-disabled` Cancel ignores clicks, so the reason is also written out, and describes the button only while shown (a + // hidden element named by aria-describedby is still read). + const noted = asked && job.state === "running"; + $("merge-cancel-note").hidden = !noted; + if (noted) cancel.setAttribute("aria-describedby", "merge-cancel-note"); else cancel.removeAttribute("aria-describedby"); + if (job.state === "running" && !mergeStepTimer) mergeStepTimer = setInterval(() => { + const current = data?.merge?.job; + if (current?.steps && current.state === "running") updateMergeStepTimes(current); + }, 1000); +} +/** Each step's elapsed time, as text after its word. */ +function updateMergeStepTimes(job) { + const items = $("merge-steps").children; + job.steps.forEach((step, index) => { + const elapsed = mergeStepElapsed(step), time = items[index]?.querySelector(".elapsed"); + if (time) time.textContent = elapsed ? `, ${elapsed}` : ""; + }); +} +$("merge-cancel").onclick = async () => { + const job = data?.merge?.job; + if (!job || job.state !== "running" || $("merge-cancel").getAttribute("aria-disabled") === "true") return; + mergeCancelPending = job.id; + renderMergeSteps(); + // The ended record an answer carried: shown with the outcome by the poll it asks for. + let pollNow = false, ended = null; + const adopt = answered => { + const shownJob = data?.merge?.job; + if (!answered || !shownJob) return; + if (answered.id === shownJob.id) { + // Already observed (an older or the same record, including the ended record the poll already showed): nothing to do. + if (!(answered.revision > shownJob.revision)) return; + // The job ended: the poll presents the outcome, follows a queued attempt and keeps the step list, so it is asked + // at once. A later running record is adopted. + if (answered.state !== "running") { pollNow = true; ended = answered; return; } + mergeObservationGeneration++; + data = { ...data, merge: { ...data.merge, job: answered } }; + $("banner").textContent = mergeJobText(answered); + return; + } + // A newer job another tab started: followed as the poll follows a job, whatever this page last saw of the older one. + // A newer job that ended is presented by the poll (with its queue attempt), also when the shown job ended first. + if (!laterMergeJob(answered, shownJob)) return; + if (answered.state === "running") { + mergeObservationGeneration++; + // The older job's queue attempt is not the newer job's. + data = withMergeJobObservation({ ...data, merge: { ...data.merge, queue: null } }, answered); + $("banner").textContent = mergeJobText(answered); + } else { pollNow = true; ended = answered; } + }; + try { adopt((await api("/api/action", { action: "cancel-merge", jobId: job.id })).job); } + catch (error) { + adopt(error.job); + // Only while the job this cancel was for is still shown running: an outcome the poll showed meanwhile, or a newer + // job, says more than the refusal. + if (data?.merge?.job?.id === job.id && data.merge.job.state === "running") { + mergeCancelRefusal = { key: `${job.id}:${data.merge.job.revision}`, text: `Could not cancel the merge. ${error.message}` }; + $("banner").textContent = mergeCancelRefusal.text; + } + } finally { + // Only this cancel's own pending state: a later cancel of a newer job keeps its own. + if (mergeCancelPending === job.id) mergeCancelPending = null; + // Answered as ended: Cancel stays unavailable for every record older than the ended one, until the page shows it. An + // answer is newer than the record shown, not always than the one held (a late answer for an older job), so the later + // ended record is kept. + if (pollNow && (!mergeCancelEnded || laterMergeJob(ended, mergeCancelEnded))) mergeCancelEnded = ended; + renderMerge(); + if (pollNow) { + if (mergePollInFlight?.generation === mergeGeneration) { + // That poll may have read the job before it ended: whatever it answers, the next poll comes 2 s after it. + mergePollState = null; + mergePollDelay = 2000; + } else { + // A poll in flight for an older generation is discarded when it settles, so it does not stand in for this one. + if (mergePollTimer) clearTimeout(mergePollTimer); + void pollMergeQueue(mergeGeneration); + } + } + } +}; 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"]); @@ -337,7 +535,10 @@ const mergeJobReachedMerge = new Set(["merged", "queued", "submitted"]); 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] } }; + // `endedJobShown` keeps the step list of the job seen ending on screen until the review reloads, also once the poll that follows + // its queued attempt has replaced the `job-refresh` blocker with the attempt's own status. + return { ...view, merge: { ...view.merge, ready: false, action: null, job, endedJobShown: job?.id ?? null, + 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) { @@ -361,12 +562,21 @@ function withMergeQueue(view, queue) { * 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; + if (!isEndedJobQueue(job, queue)) { + // A job that ended otherwise (blocked, failed) is described by its outcome: a copy of its own attempt the page kept + // while it asked GitHub is dropped, so that copy does not keep polling or replace the outcome. + const keptOwn = !!view.merge.queue?.actionId && view.merge.queue.actionId === job?.mergeActionId; + return keptOwn && !followsOwnAttempt(job) ? { ...view, merge: { ...view.merge, queue: null } } : 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`). */ +/** Whether an ended job follows its own merge attempt: it left the attempt with GitHub (queued, or an unknown outcome). */ +function followsOwnAttempt(job) { + return job?.state === "queued" || job?.state === "submitted"; +} +/** Whether `queue` is the merge attempt a job that ended `queued` or `submitted` left with GitHub (its action ID is the job's `mergeActionId`). */ function isEndedJobQueue(job, queue) { - return job?.state === "queued" && Boolean(queue?.actionId) && queue.actionId === job.mergeActionId; + return followsOwnAttempt(job) && 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) { @@ -731,6 +941,7 @@ $("merge").onclick = async () => { try { rememberDraft(); mergeAction ??= { actionId: crypto.randomUUID(), token: data.token, jobBefore: data.merge.job?.id ?? null }; + const mergeObservationOwner = mergeObservationGeneration; 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. @@ -742,9 +953,19 @@ $("merge").onclick = async () => { // 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 + const answered = updated.mergeRefreshRequired ? { ...data, merge: { ...data.merge, ready: false, action: null, job } } : { ...updated, merge: { ...updated.merge, job } }; + // A cancel's answer or a poll may have shown a later record (of this job, or a newer job) while the click was on + // its way: it is kept over the click's answer, with only its own queue attempt, as a full review response keeps it. + const newer = newerMergeObservations(answered, mergeObservationOwner); + if (newer.job) { + data = withNewerMergeObservations(answered, newer); + render(); + showNewerMergeObservations(newer); + return; + } + data = answered; 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 @@ -781,6 +1002,24 @@ $("merge").onclick = async () => { $("banner").textContent = `${error.message} ${mergeJobText(error.job)}`; return; } + // The page follows a running job: a click needs `ready`, which excludes one, so a cancel's answer or a poll showed it + // while the click was on its way. It keeps following it, unless the refusal carries a later record that ended. + const shown = data.merge?.job; + if (shown?.state === "running") { + // A later ended record (of that job, or of a newer job) is presented, as the click's answer presents an ended job; + // an older one is not. One that ended `queued` or `submitted` is left to the poll, which follows its attempt: the + // refusal carries none. + if (error.job && error.job.state !== "running" && laterMergeJob(error.job, shown) && !followsOwnAttempt(error.job)) { + const queue = data.merge.queue; + data = withMergeJobObservation(data, error.job, queue); + render(); + showEndedJobAndAttempt(error.job, queue); + return; + } + render(); + $("banner").textContent = `${error.message} ${mergeJobText(shown)}`; + 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}`; @@ -1096,7 +1335,11 @@ function newerMergeObservations(updated, observationOwner) { /** 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; + if (!job) return kept; + // A kept job other than the response's: the response's queue attempt is the older job's (the response was read before + // the kept job), so it is dropped. A kept job's own attempt the page followed is `jobQueue`. + const sameJob = job.id === kept.merge.job?.id; + return withMergeJobObservation(sameJob ? kept : { ...kept, merge: { ...kept.merge, queue: null } }, job, jobQueue); } /** The banner for the newer observations kept, as the poll showed them; call after render. */ function showNewerMergeObservations({ queue, job, jobQueue }) { diff --git a/web/public/index.html b/web/public/index.html index 3d04ddaa..a0530e0b 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -115,6 +115,11 @@

    Loading plan…

    > +