diff --git a/AGENTS.md b/AGENTS.md index 5c09f8bc..654d1f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ For features with background jobs, polling, retries, cancellation, or shutdown: - Define the lifecycle states and ownership before implementation: pending, running, completed, failed, cancelled, stale, and closing. - Treat persisted state, in-memory jobs, subprocesses, HTTP requests, and rendered UI as separate state holders. Define how each transitions and settles. - Never apply a background response without proving it is still current. Use a generation, attempt ID, version, or guarded merge so older polling responses cannot overwrite newer actions. +- When the local state has more than one version counter (for example a task's state version and its plan's review version), an in-flight action owns all of them, and applying its response requires every one to be unchanged. One counter does not cover writes that advance only another. +- A current response may still move a record only along a transition allowed from the state the action was guarded for. Derive the new status from that state as well as the response; a response alone must never move a record out of a state that waits for a person. - Do not release a concurrency slot when cancellation is requested. Keep the job tracked until its underlying invocation or subprocess has terminated. - Do not let a retry replace a locally active job, even when its persisted lease has expired or wall-clock time changes. - Validate retry context against the current snapshot, plan revision, assignment, and referenced code. If any context is stale, disable retry and require a new request. @@ -64,13 +66,21 @@ Every reproduced race requires a failing-before and passing-after regression. As - A bounded safety scan must fail closed when its limit is exceeded. Never truncate evidence and report the result as clear. - Align subprocess output limits with every payload the schema accepts, or tighten the upstream page and field bounds; valid bounded input must not fail only because the transport budget is smaller. +- Pass text whose size follows user or agent input to a subprocess on stdin, never as an argument: the OS limits one argument's size and cannot pass a NUL, so valid bounded input can fail to spawn. - Exclude the subject of a duplicate or supersession check by stable identity only. A shared branch name or other mutable attribute does not prove two records are the same subject. +- Exclude the subject's own records only in the states the exclusion is for. A check that skips its own open PR must still count its own merged PR, or its own close of the issue, as done. - Preserve repository identity with pull request numbers in cross-reference scans. Never resolve or exclude a repository-qualified reference by number alone. +- When a relation can be added and removed (a manually linked PR, a label, an assignment), replay its add and remove events in order and count only its latest state. An add event alone does not prove the relation still holds. - After the final asynchronous external validation, re-read the local generation immediately before an irreversible action. A generation check performed before that await is insufficient. +- Check an operation's source-state preconditions before any shortcut or early return that writes state or reports success, not only on the main path. +- Honour a cancellation signal that is already aborted before the first durable write, not only after awaits: a path with no await otherwise writes after the caller cancelled. - Batch and briefly cache read-only status probes, and give the combined operation an overall deadline below the serving request timeout. +- Count the subprocess shutdown grace periods (SIGTERM-to-SIGKILL wait, pipe drain) inside that overall deadline: the operation ends when its processes have settled, not when the abort fires. +- After an external state change (ready, draft, close), read the record back and require the new state before recording success; a command that exits 0 does not prove the change applied. - Budget a multi-stage validation across all sequential stages; giving each stage the full request allowance does not create an overall deadline. - Preserve the distinction between an explicit unbound identity and missing or malformed authorization metadata. Missing or malformed identities must fail closed. - Validate every field used to classify an external record as clear, including enum values and required nullable fields. Partial records and malformed policy objects must fail closed. +- Select every member the API documents for a union or enum you classify (for example every GraphQL `Closer` type). Fail closed only on types outside that list; a missing known member turns every result into a false unknown. - Validate coupled lifecycle fields as allowed combinations. A terminal-looking conclusion must not override an active or unknown status. - Treat a successful external command as the transition it actually performed. If it can enqueue or schedule work, model and verify that lifecycle before reporting the final action as complete. - For safety-critical API responses, require and validate every requested field before any early return, including terminal-success paths. Treat an omitted field differently from an explicit `null` allowed by the API contract. @@ -86,7 +96,12 @@ Every reproduced race requires a failing-before and passing-after regression. As - When a durable external-action attempt is bound to an older snapshot, require approvals or evidence recorded against the replacement generation before another action, whether or not the prior outcome explicitly requested fresh review. A mismatch with the old context is not itself fresh review. - If the external lifecycle mechanism or mode changes between validation passes, abort before the irreversible command. Create durable lifecycle ownership from the final stable mode, never from an earlier observation. - When an irreversible command has an ambiguous timeout, cancellation, transport, or unknown outcome, retain durable in-flight ownership and reconcile external state before enabling retry. Only a confirmed refusal may become retryable failure. +- Record in-flight ownership before the first external write of an operation, not before a later step: when a push moves an open PR's head, the refresh it belongs to is already in flight. +- Order an operation's external writes so that any step the remote can definitely refuse comes before the writes that cannot be taken back. A refusal after a push or a description change leaves a half-updated record. - Correlate retry observations to the current attempt with an immutable external identity or event boundary, and fail closed when multiple post-boundary action sequences appear. Matching only the resource or commit identity can replay another attempt's terminal event. +- When recovery looks for the result of an earlier attempt, recognise the identities of all earlier attempts, abandoned ones included. A result that appears after its attempt was abandoned must be adopted, not treated as foreign, or recovery can never settle. +- Read an identity marker only from its reserved position (for example the first line of a description). Text around it may be agent-controlled, so a marker-shaped string elsewhere must never identify or disqualify a record. +- When the external record is observed, correct the local copy of any field the operation owns (such as a draft flag) from it. A change that landed but whose confirmation was lost is otherwise never repaired, because the next run sees nothing left to change. - Make an idempotency key required at the API boundary for every replayable action, and look up its saved outcome before any other guard, including in-flight, validation and coordinator shutdown guards. The one exception is the server's HTTP 503 during shutdown, which applies nothing; the client must keep the key and resend it. Save every definite outcome under the key (refusals before admission too), keep the saved response current with the durable outcome it reports, and replay failures as failures with complete result fields. Only a passing, nothing-applied outcome (shutdown, abort, deadline, storage error) stays resendable. - When a refusal must also change durable state (for example, handing an expired task to a person), commit that change outside the refused transaction, together with the saved refusal. Never write it inside the transaction the refusal rolls back. @@ -94,6 +109,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - Treat the cleanup handle of an external resource (container, volume, network, temporary directory) as owned state. If removal fails, keep the handle, record it durably before its in-memory owner can be dropped (shutdown, crash, abandon, restart), and fail closed until removal is confirmed. Never delete the durable evidence before the final release report has been saved. - Give every subprocess an explicit allowlisted environment. Pass credentials only to the component that needs them, through a separate channel. Name-based scrubbing of an inherited environment is not isolation. Run Git with the repository's hardened invocation: no user or system config, no hooks, no lazy fetch, no network protocols. +- Build that allowlist from each tool's documented credential and configuration channels on every supported platform (for example, the D-Bus session bus that a Linux keyring uses), and test that each is passed. - Treat paths read from a durable record or discovered on disk as untrusted. Before deleting, opening or probing one, validate its exact location and name, not only its basename, and never follow a link to it. Keep files that other local users must not plant or swap, such as lock files, in a directory only the current user can write. Write durable records through a unique temporary file opened exclusively, and delete it if the write fails. - Exclude other processes with an OS-level lock held for the owner's lifetime, keyed by the resource's stable identity rather than a path spelling. A PID liveness check never authorizes taking over a lock. Run shared one-time startup work single-flight under that lock, and keep the lock until the work has finished. @@ -101,6 +117,8 @@ Every reproduced race requires a failing-before and passing-after regression. As - When a subprocess reports a problem only as a warning and carries on, decide pass or fail by what each message means for the result, not by whether anything was printed: fail on messages that mean it did less than it should (for example could not read a path), and let through messages about harmless input the agent controls. Test both a benign case and a failing case, and filter the output as it arrives so that no volume of benign messages can push a failure out of a bounded buffer. - Quote or escape agent-controlled text (file names, paths, branch names) wherever it lands in output that people or tools parse, such as diffs, notices, logs or reports, so it cannot forge that output's structure. +- Neutralise issue references (`#N`, `GH-N`, `owner/repo#N`, issue URLs) in any text codeboost writes that can become a commit message, such as a PR title or description, and neutralise @-mentions in any of that text that is not fenced (a title). Code fences do not protect commit messages, and GitHub closes issues from closing keywords in default-branch commits. +- Never spread a collection whose size follows unbounded input into function arguments (`Math.max(...runs)`); engines limit the argument count, so use a loop. - A hardened Git invocation must also keep Git out of nested repositories and populated submodules, whose own config and hooks are the agent's: pass `--ignore-submodules` on the command line (the config default does not bind plumbing or override `.gitmodules`), and never run Git with a nested repository as its working directory. - Decide a filesystem fact (whether a name exists, where a link resolves, what a path's entries are) on the file system that will serve it, not on a copy. A host that folds case or Unicode normalization, or decodes names as text, answers differently from the container's; check links and names inside the container, as its kernel resolves them, by raw bytes. - Never infer that agent-written content is unchanged from timestamps, sizes or an index's stat cache: not every write moves them (tmpfs, for one). Compare the content itself. diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts new file mode 100644 index 00000000..bc2a75a1 --- /dev/null +++ b/core/pull-request-body.ts @@ -0,0 +1,85 @@ +import type { Plan } from './plan.ts'; +import { cutText as cut } from './text.ts'; + +/** GitHub refuses a PR description longer than 65,536 characters; stay below it with room for the frame. */ +export const MAX_BODY = 60_000; +export const MAX_TITLE = 200; +const MAX_PROBLEMS = 20, MAX_PROBLEM = 2000; + +/** + * Plan text and open problems go inside fenced code blocks. GitHub does not act on closing keywords ("Fixes #12") or + * @-mentions inside code, so text from the plan or from agent output cannot close other issues or notify people. + * The fence is longer than any backtick run in the text, so the text cannot end the block. Control characters other + * than newline and tab (a NUL from agent output, say), and lone UTF-16 surrogates, become U+FFFD: GitHub may refuse + * them, and a refusal would repeat on every publish. + */ +export function fenced(text: string): string { + // A loop, not Math.max(...runs): plan text is not length-bounded, and spreading every run can overflow the stack. + let longest = 0; + for (const match of text.matchAll(/`+/g)) longest = Math.max(longest, match[0].length); + const fence = '`'.repeat(Math.max(3, longest + 1)); + return `${fence}text\n${text.toWellFormed().replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '\ufffd')}\n${fence}`; +} + +/** + * GitHub also reads closing keywords ("Fixes #7") in commit messages on the default branch, fenced or not, and a squash + * or merge commit can carry the PR title and description. So every issue reference in plan text and problems is + * neutralised: `#7` becomes `#7`, `GH-7` becomes `GH‑7` (non-breaking hyphen), and `/issues/7` or `/pull/7` in a URL gets a + * division slash. The text stays readable, and no merge method can close another issue through it. + */ +export function neutralizeReferences(text: string): string { + return text.replace(/#(?=\d)/g, '#').replace(/\b(GH)-(?=\d)/gi, '$1‑').replace(/\/(issues|pull)\/(?=\d)/gi, '/$1∕'); +} + +function planText(plan: Plan, full: boolean): string { + return plan.items.map(item => { + const lines = [`${item.id}: ${item.title}`]; + if (full) { + lines.push(` Intent: ${item.intent}`); + for (const file of item.files) lines.push(` ${file.kind} ${file.renamed_from ? `${file.renamed_from} -> ` : ''}${file.path}: ${file.change}`); + for (const check of item.acceptance) lines.push(` ${check.type}: ${check.text}`); + if (item.depends_on.length) lines.push(` After: ${item.depends_on.join(', ')}`); + } + return lines.join('\n'); + }).join('\n\n'); +} +const planTextSafe = (plan: Plan, full: boolean) => neutralizeReferences(planText(plan, full)); + +/** A title is not fenced, so an @-mention in it would notify: `@name` becomes `@name`. */ +export function neutralizeMentions(text: string): string { + return text.replace(/@(?=[A-Za-z0-9])/g, '@'); +} + +/** Single line, no control characters, no issue references except its own, no mentions, bounded in UTF-16 units. */ +export function pullRequestTitle(plan: Plan): string { + const summary = neutralizeMentions(neutralizeReferences(plan.summary.toWellFormed().replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim())) || 'codeboost plan'; + const suffix = ` (#${plan.issue})`; + return cut(summary, MAX_TITLE - suffix.length) + suffix; +} + +/** + * The PR description: the marker that lets codeboost find the PR again, the issue link, and the plan. A needs-human + * draft also lists its open problems. When the full plan is too long, only item IDs and titles are listed. + */ +export function pullRequestBody(input: { plan: Plan; marker: string; problems?: readonly string[] }): string { + const { plan, marker } = input; + // The full problems stay in codeboost; the description shows a bounded summary of them. + const all = input.problems ?? [], shown = all.slice(0, MAX_PROBLEMS).map(problem => neutralizeReferences(cut(problem, MAX_PROBLEM))); + const problems = all.length > shown.length ? [...shown, `(${all.length - shown.length} more in codeboost)`] : shown; + const build = (full: boolean) => [ + marker, + `Fixes #${plan.issue}`, + '', + `Opened by codeboost from plan revision r${plan.revision}. Review it one plan item at a time in codeboost.`, + ...(problems.length ? ['', '**Needs human.** These problems are still open:', '', fenced(problems.join('\n\n'))] : []), + '', + full ? '**Plan**' : '**Plan** (items only; the full plan is too long for this description)', + '', + fenced(planTextSafe(plan, full)), + ].join('\n'); + const body = build(true); + if (body.length <= MAX_BODY) return body; + const short = build(false); + if (short.length <= MAX_BODY) return short; + throw new Error('The plan and its open problems are too long for a pull request description.'); +} diff --git a/core/text.ts b/core/text.ts new file mode 100644 index 00000000..67a22c7a --- /dev/null +++ b/core/text.ts @@ -0,0 +1,7 @@ +/** Cut to at most `max` UTF-16 units (the unit every length bound here counts), never inside a surrogate pair. */ +export function cutText(text: string, max: number): string { + if (text.length <= max) return text; + let end = max - 1; + if (/[\ud800-\udbff]/.test(text[end - 1] ?? '')) end--; + return `${text.slice(0, end)}…`; +} diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md new file mode 100644 index 00000000..8c729123 --- /dev/null +++ b/docs/implementation/pull-request-opening.md @@ -0,0 +1,138 @@ +# Opening the task's pull request (F2d) + +**Who this is for:** people who build or review lane F, the runner. +**What it covers:** what codeboost does after a task's plan items have run: the check for whether the issue is already fixed, then opening the task's pull request (PR), or a draft PR when the task needs a person. + +## Summary + +- Before it opens a PR, codeboost checks whether the issue is already fixed. A match, or a check that cannot be completed, opens no PR. A running task then moves to **possibly already fixed**. +- When the check is clear, codeboost pushes the task head to the task's branch and opens the PR. The task moves to **in review**. +- A task in **needs human** gets a draft PR with its open problems. If the check matches, no draft is opened and the task stays in needs human. +- A later run of the same task reuses its PR while it is still open. It does not open a second one. +- codeboost records each opening before it calls GitHub. If the outcome is lost, the next publish finds the PR by a marker in its description. An opening that GitHub does not show yet stays owned for 10 minutes before it is abandoned. +- A GitHub response changes the task status only if the task is unchanged since the call began. + +## Terms + +| Term | Meaning | +|---|---| +| Check | The pre-PR "already fixed" check in `github/already-fixed.ts`. | +| Own PR | A PR that codeboost opened for this task, identified by repository and number. | +| Own commit | A commit in the task's ledger with origin `owned`. | +| Opening | The Store record written just before the GitHub call that opens a PR. Its state is `opening`, `opened` or `abandoned`. | +| Marker | The HTML comment `` that is the PR description's first line. Only that line identifies the PR; a marker-shaped string anywhere else (plan text, problems) is ignored. | +| Publish | `PullRequestPublisher.publish` in `runner/publish.ts`: the check, push, and open (or reuse) steps together. | + +## The check + +The check matches when any of these is true: + +| Signal | Source | Not a match | +|---|---|---| +| The issue is closed. | The issue state and its latest close event (GraphQL). The closer is a PR, a commit, or a Projects workflow. A close by the task's own PR or own commit also counts: it means that PR merged, so the fix is already in. | A reopened issue. | +| Another open or merged PR links to the issue. | Cross-reference events that would close the issue (`willCloseTarget`: a closing keyword such as `Fixes #12`). GitHub closes issues only from PRs into the default branch, so `willCloseTarget` is false for every PR into another branch; when the task's base is not the default branch, a PR in this repository into that same base that references the issue counts too (decided 2026-09-30). Manual links: "connected" and "disconnected" events replayed in order. Both sides of a manual link are read, because which side GitHub reports as the subject depends on where the link was made; the linked PR is the side that is a PR, and a link between two PRs or to an unknown type makes the check `unknown`. | The task's own open PRs, matched by repository and number (its own merged PR is a match). Closed, unmerged PRs. A manual link whose latest event is a disconnect. A PR that only mentions the issue, in this repository or another (decided 2026-09-30: on cli/cli a third of open issues had such mentions, mostly merged PRs in unrelated repositories). | +| A new commit on the base branch mentions the issue. | The commits from the task's base to the current base branch head. | Own commits. `#123` when the issue is `#12`. `other/repo#12`. A token inside a URL path or a longer path-like token (`https://example.com/GH-12`, `mirror/owner/repo#12`). | + +A commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that would close the issue, or is linked manually, counts as a match. It is not excluded by number, because its number belongs to another repository. GitHub turns `willCloseTarget` false once the issue is closed, so a merged PR that closed the issue is reported through the closed state instead. + +An abandoned opening's PR has no recorded number, so the check cannot exclude it by number. The main path looks the branch up first and adds a visible PR's number to the own PRs. If GitHub's PR list still does not show that PR after the 10-minute settle time but the issue timeline already does, the check counts the task's own PR as another PR, and the task moves to possibly already fixed. That fails closed: a person sees the PR and continues. + +**The check fails closed.** It returns `unknown` in each of these cases, and `unknown` is handled like a match: + +- more than 100 timeline events, or more than 250 new base commits; +- a task base that is not an ancestor of the base branch, or a comparison that lists a commit twice; +- a closed issue with no close event; +- a linked item that is missing, of an unknown type, or in a malformed response; +- a GitHub error or invalid JSON; +- the whole check running past its single deadline (12 seconds by default). For the check the runner waits 1 second after SIGTERM before SIGKILL, and half a second for inherited pipes, so the whole check settles within 13.5 seconds, below the 15-second request budget. Reaching the deadline stops the running `gh` call. + +A cancelled check throws. It does not return `unknown`. + +## Publishing + +Only one publish runs per task at a time; a second one is refused. A publish whose signal is already aborted changes nothing. Publish runs these steps in order: + +1. **Recover.** First, an update whose confirmation was lost is settled: what GitHub shows for its PR (draft flag and head), in any base, is recorded, and the update is dropped, to be repeated after a new check. A pending update recorded for another repository is refused loudly, not cleared unseen. A marker is editable text, so another PR may carry it too (a copied description). A lost update's PR is the one with the recorded number, which cannot be edited; a copy is left to the main path and the draft step. A lost opening has no recorded number, so two PRs carrying its marker make recovery refuse with `PullRequestMisplaced` and keep the opening owned until a person closes the one that is not the task's; the draft step does not run meanwhile, and the refusal says the task's PR may still be ready. Then, if an opening is still `opening`, the task's own open PRs from its branch are listed in any base (with the markers of all the task's openings for that branch). + - **Its PR is there.** It is recorded as opened, wherever it is. If this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, it is turned back into a draft first: a failure keeps the opening owned, except that drafts being unsupported is definite, so the PR is recorded as it is and publishing continues: the main path returns `draft unsupported`, or refuses a PR it would not accept. The recovered opening ends this publish only when it is this publish's own work (same task state version, same review version, same draft mode) and the main path would accept its PR (in the configured base, the task's only open PR). Otherwise publishing continues: the main path pushes the current head and brings the PR into the current mode, or, for a PR it would not accept, makes all the task's PRs drafts and refuses (step 3). + - **Another of the task's PRs is open into the base this opening asked for.** This opening created nothing (GitHub allows one open PR per branch and base), so it is marked `abandoned`. If that other PR's opening was abandoned too, the main path (a running task) or the draft step (a stopped one) adopts it next. + - **Nothing.** The request may still be in flight or not yet visible, so publish stops with `OpeningUnsettled` until the opening is 10 minutes old (`settleMs`). After that, it marks the opening `abandoned` and continues. + **Draft what the task cannot keep ready.** Then, if the task is not in review, approved but merge blocked, or merged, and cannot be published as ready now (cancelled, needs human, possibly already fixed, an attempt active, requeue pending, rebase or merge in progress), every branch with a PR recorded as ready, or with an abandoned opening, is looked up. An abandoned opening's PR that has appeared since is adopted, because the main path, which also adopts it, may refuse first. A task in review, approved or merged has no such step, and publish refuses it on status: recovery still records its lost opening's own PR, but a late PR of another abandoned opening is not adopted or drafted for it. A ready PR is then made a draft, after re-reading the task: one approved meanwhile keeps its PR ready. An approved task's PR is left alone, because GitHub does not merge a draft. A draft flag that GitHub already shows is only recorded. The record saying "ready" is what makes the draft owed. So an earlier draft change that failed (`leftReady`), and a PR that recovery has just recorded, are both drafted by the next publish of that task, even though step 2 then refuses. Only a publish fulfils a draft owed: until something publishes the task again, its PR stays ready. Nothing in the runner calls publish for a cancelled task yet; closing the PR on cancel is a later slice (see "What this slice does not do"). A GitHub failure here does not replace step 2's refusal. The refusal names the PR that may still be ready, and the next publish tries again. A PR that has since closed keeps its "ready" record, and an abandoned opening stays abandoned, so each later publish of that task looks its branch up again. +2. **Status.** Before any other GitHub call, refuse unless the task is running (or in needs human, for a draft), with no attempt active, no merge active, no interrupted work waiting to be requeued, and no rebase in progress. +3. **Find the branch PR.** Ask GitHub for the branch's open PR every time, with the markers of the task's opened and abandoned openings for that branch, whatever their base (none if the task has no PR yet). GitHub allows one open PR per branch and base, so every lookup lists all open PRs from the branch. The list is one page of up to 100; a full page fails closed, because a later page could hide the task's PR, and every entry is validated before any is set aside as someone else's. Every draft change in recovery, the draft step and for a moved PR re-reads the task right before it, with no await between: a task in review, approved or merged keeps its PR ready, because GitHub does not merge a draft. (The head settle after an open or update is the one exception: it makes the PR a draft when GitHub shows another head than the one pushed, even for a task that has just moved to in review, because that head was not reviewed.) Two rules follow. **Observing and drafting** (recovery and the draft step in step 1) look for the task's own PRs, those carrying its markers, in any base (`findOwned`): what GitHub shows is recorded, and a PR is made a draft, wherever it is, because both are safe anywhere. So a lost update or opening settles, and a stopped task's PRs are all drafted, even after a base change or a retarget. **Changing content** (push, description update, ready change, opening) happens only on the main path and only in the configured base (`findOpened`): it refuses when the task's own PR is in another base (retargeted by a person, or left by a change of the base setting; retarget it or close it) or when two of the task's PRs are open (close all but one), instead of opening a second PR from the same branch. Anyone else's PR from the branch into another base (a backport, say) is ignored. A PR into the configured base without one of the task's markers is refused as not codeboost's, unless its number is one of the task's recorded PRs: then a person removed its marker, and it is refused as misplaced (restore its first line or close it). GitHub's PR list can lag behind a PR, so when the list shows no PR but the record has an opened one, that PR is read directly (one request per recorded PR, closed ones included): if it is still open on the task branch, into the configured base, with its marker, only the list is behind, and publish stops with `OpeningUnsettled` (retry later) and pushes nothing, because a push would move the open PR's head with no update recorded as in flight. If it is open but a person moved it (renamed its branch, removed its marker, retargeted it), a retry would never see it, so publish refuses with `PullRequestMisplaced`: close it, or restore its branch, base and first line. Before refusing, it makes that PR a draft where it is, while its first line still identifies it; otherwise the refusal says it may still be ready. A marker is read from the description's first line with surrounding white space removed, because GitHub returns a description edited on github.com with CRLF line endings. A record's base is only where the PR was opened. A lost opening counts as having created nothing only when another of the task's PRs is open into the base that opening asked for. Moving the task to in review is a change too: recovery does it only for a PR in the configured base with no other of the task's PRs open. Any other recovered PR is recorded, and the main path's misplaced draft step adopts the task's other open PRs, makes them all drafts and refuses; the task stays as it was. When the main path refuses the task's PRs for their place (another base, or two open), it first makes every one of them a draft, so none stays ready while a person decides. An open PR that carries none of them was not opened by codeboost: publish refuses before anything is pushed. A PR with an opened record's marker but another number is refused too. An abandoned opening whose PR is now visible is adopted here (number, URL and draft state recorded, task status unchanged), whatever the check then says, so the record names every PR the task has on GitHub. When the PR's draft state on GitHub differs from the record, the record is corrected here (for example after a draft change whose record was lost), under the task's state-version guard. This comes before the check: an abandoned opening's PR links the issue and has no recorded number, so only its marker shows it is the task's own, and its number is added to the own PRs for the check. The PR-number mismatch is refused here, before any GitHub change on this branch other than the draft step in step 1, which skips a mismatched PR and only makes PRs safer. If the task head is its base, nothing is published: after the full publish guard (versions, status, no requeue or rebase), an earlier ready PR is turned into a draft (reported as `leftReady` if the repository has no drafts), and a running task moves to needs human. +4. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. +5. **Push.** When the task already has an open PR, the refresh is recorded first (the push moves that PR's head), then the push runs; otherwise the push runs straight after the check's transaction with no await in between. Push the task head to `codeboost/issue---`. The slug is readable but can collide. The hash is 16 hex characters of SHA-256 over the exact task identity, so two tasks never share a branch. After the push's await the update re-reads the task, its review and its status before any other change to the PR, and again after the description update's await, right before a ready or draft change (`beforeReady`); a change during the push leaves the update in flight and the PR's description and draft state as they were. +6. **Re-read.** Right before the GitHub call, the Store confirms that the latest check is clear and that nothing changed since that check: same task state version, same review version (approvals, choices and notes), same snapshot, same head. +7. **Open or reuse.** Open a new PR (a draft opening that GitHub creates as ready fails, keeping the opening owned; recovery then turns it into a draft. A validation refusal (HTTP 422, for example no commits between base and head, or a PR already open for the branch) is definite: GitHub created nothing, so the opening is abandoned at once and publish fails with `PullRequestRefused`, carrying GitHub's reason, which `gh` prints on stdout. Refusals are recognised from `gh`'s stderr (`gh: (HTTP 422)`) and from GitHub's `errors[].message` fields in the JSON body on stdout, where GitHub puts a validation reason such as drafts being unsupported, never from the raw stdout, which could echo the PR body. A refusal that repeats, such as no commits between base and head when the base branch already contains the task head, fails every publish in the same way until a person acts; each attempt leaves one abandoned opening), or update the open earlier PR (found in step 3 with the markers of every earlier opening, abandoned ones included; an abandoned opening's PR was already adopted in step 3, and an update only ever starts on an opened record) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish first records what GitHub shows for it (draft flag and head), then drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. + +Each opening or refresh owns the task state version and the plan's review version at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has both (review input such as approvals, choices and notes changes only the review version). Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. + +| Task during the GitHub call | PR | Status after | +|---|---|---| +| Unchanged, running | Open, head as pushed | in review | +| Unchanged, running | Open, head on GitHub differs from the pushed head (GitHub has not caught up, or someone else pushed) | running; the PR is made a draft and the record keeps the head GitHub reports; the next publish reconciles it | +| Unchanged, needs human | Draft opened, or the earlier PR updated and turned back into a draft | needs human | +| Changed (cancelled, reassigned, reviewed, new attempt, new head) | Opened | Unchanged; the PR is recorded, and made a draft, so a task that is not in review never keeps a ready PR | + +The open or update itself has succeeded by then, so if making the PR a draft fails, publish still reports the PR as opened, with `leftReady`, rather than failing. An abort during that draft change is not a failure of it: publish rejects, and the next publish reconciles the PR. + +After an update, the read-back polls up to 5 times, half a second apart, until GitHub shows the pushed head, because GitHub updates a PR's head a moment after a push. + +When the check matches (or is unknown) and the task's earlier PR is open and ready for review, publish turns it back into a draft. A task that is not being published as ready never leaves its PR ready for review. This happens before the check result is recorded, after re-reading the task, its review and its head: if the task changed during the check, nothing is drafted. If the draft change fails, or GitHub does not show the PR as a draft afterwards, the task is still running, and a retry checks again and repeats it. A refresh likewise fails when GitHub does not show the requested draft or ready state. A PR-number mismatch is refused before any GitHub change. + +The adapter refuses any answer for a PR that is not open. A PR closed between the lookup and the update is never recorded as the task's review PR. + +**Deadlines.** Each PR operation (open, lookup, refresh, draft change) has one deadline for all of its commands and poll waits together: 60 seconds by default (`operationMs`), combined with the caller's signal. Stopping `gh` at the deadline can take up to 6 seconds more (see Transport), so one operation settles within 66 seconds. A whole publish has no deadline of its own. Its caller bounds it with the signal. A caller that runs publish inside an HTTP request must give it a deadline below the request budget. + +**Shutdown.** The publisher reads the coordinator's `closing` flag (the `closing` dependency) and its own flag right before each push, opening, description update and ready change, with no await in between (runner-lifecycle.md, "Irreversible actions"). Either flag refuses with `ShuttingDownError`. An update refused this way stays in flight for the next publish to settle. A draft change on its own is not refused: it only makes a PR safer, and `close()` stops it through the abort. The draft change inside an update comes after `beforeReady`, so it is refused with the update. `close()` sets the publisher's flag, aborts every publish in progress, and awaits their settlement. The server must await `close()` before the Store's write gate closes (shutdown step 3), so a publish's last records still land. Nothing calls the publisher yet; the change that wires it in adds `close()` to the shutdown order. + +**Repository.** The publisher refuses a check or PR gateway configured for another repository than its own, because the Store records each PR under the publisher's repository. + +**Transport.** The PR title and description go to `gh api --input -` as a JSON body on stdin (`github/run-with-input.ts`), never as arguments: Linux limits one argument to 128 KiB, and a 60,000-character description of multibyte text is larger. The runner settles only after `gh` has exited, including on a timeout or abort: it sends SIGTERM, then SIGKILL after 5 seconds if `gh` is still running, and if a process `gh` started keeps the output pipes open after `gh` exits, it closes them after 1 second. The already-fixed check uses the same runner, so its 12-second deadline always holds. The already-fixed check also waits for both of its reads to settle before it returns. + +**Environment.** Each `gh` process gets only an allowlist of variables: the path, home and locale; GitHub tokens, host and configuration directories; the D-Bus session bus that Linux keyring sign-in uses; the Windows system and temporary directories; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. + +**Token scopes.** The check and the PR calls need only the `repo` scope that a default `gh auth login` grants. The timeline query asks for no field that needs more: a Projects closer is read by its type name alone, because any field on `ProjectV2` needs `read:project`, and without that scope GitHub refuses the whole query, which would make every check `unknown`. A test checks the query text for this, and the query was run against GitHub with a default token. + +## The PR description + +The description starts with the marker and `Fixes #`. The plan follows, inside a fenced code block. A draft also lists its open problems inside a fenced code block. + +GitHub ignores closing keywords and @-mentions inside code. So plan text or agent output cannot notify people from the description, and cannot end the block: the fence is longer than any run of backticks in the text. + +Fences do not protect commit messages. A squash or merge commit can carry the PR title and description, and GitHub acts on closing keywords in default-branch commit messages. So every issue reference in the title's summary, the plan and the problems is neutralised: `#7` becomes `#7`, `GH-7` gets a non-breaking hyphen, and `/issues/7` or `/pull/7` gets a division slash. Only the task's own `Fixes #` line and the title's `(#)` remain real references. The title is not fenced, so an @-mention in it would notify: `@name` becomes `@name` there. + +Inside the fences, control characters other than newline and tab, and lone UTF-16 surrogates (also in the title), become U+FFFD, because GitHub may refuse them and that refusal would repeat on every publish. Titles and problems are cut by UTF-16 length, never inside a surrogate pair. An empty summary becomes `codeboost plan`. + +The description stays under 60,000 characters. If the full plan is too long, only item IDs and titles are listed. At most 20 open problems are shown, each cut to 2,000 characters. + +## Repositories without draft PRs + +Some repositories do not support draft PRs (for example private repositories on GitHub Free). GitHub's refusal is a definite outcome, so nothing is left in flight: + +| Case | Result | +|---|---| +| A needs-human task has no PR yet | No PR is opened; the opening is marked `abandoned`; publish returns `draft unsupported`. A ready PR is never opened instead, because it would invite review of work that needs a person. | +| A needs-human task has an open ready PR | After a clear check, turning it into a draft comes before the push or any description change, so the refusal leaves the PR exactly as it was; publish returns `draft unsupported` with its number. (A matching check gives `draft skipped` with `leftReady`, and no changes gives `no changes` with `leftReady`.) | +| The check matches and the earlier PR is ready | The result is recorded as usual; publish reports the PR it could not make a draft as `leftReady`. | +| A task that cannot publish has a ready PR | The publish guard's refusal also says which PR stays ready for review. | + +## What this slice does not do + +- **Repository renames and transfers.** Every PR record is kept under the configured repository name. After a rename or transfer, publish fails closed (the task's PRs are refused or reported as unsettled) until the records are moved; nothing here moves them. + +- **Push.** `BranchPusher` is injected. The real push needs D's commit export (#66) and a runner-owned host repository. +- **Continue from possibly already fixed.** The Continue and Cancel actions are user actions for a later slice. +- **Close the draft on cancel.** The design closes the draft PR when a person cancels a needs-human task. The PR record is kept for that. +- **The pre-merge check.** `GhMergeGateway` keeps its own check for now. F6 moves it onto this module. The merge check treats a PR in another repository as `unknown`; this check treats it as a match. + +## Tests + +`test/already-fixed.test.ts` and `test/publish.test.ts` cover each row above. Each guard was also broken on purpose, and a test failed each time: own-PR and own-commit exclusion, repository-qualified exclusion, every bound, the ancestor check, the fail-closed catch, the issue-number boundary, the state-version and head re-read, the status check before the no-changes shortcut, the version-owned status change, the open-state check, the settle time, the branch hash, the environment allowlist, reuse of the earlier PR, lost-opening recovery, adoption on the main path and counting that PR as the task's own, drafting a PR that a task which cannot publish still has ready, adopting an abandoned opening's PR that appears after the task stopped, the abort checks on the no-changes path and in that draft step (a lookup that fails, one that answers, a draft change that fails and one that lands), recording a late PR by adoption alone, an abort during the head-mismatch draft change, the `closing` checks before a push and before a ready change, `close()` aborting a publish in progress, the repository check, the definite 422 refusal, a retargeted own PR and another base's PR from the branch, refusals matched on stderr and GitHub's `errors[].message` only, the configured base in recovery and the head settle, the draft step finding the task's PR in any base, closing-only cross-references, both sides of a manual link, the query text, the fence length, and the description bounds. + +## Test this document with a reader + +Ask someone new to lane F to answer these questions from this page alone. Then fix any section they could not use. + +1. Why does a check that cannot be completed open no PR? +2. What happens to a PR that opens after the task was cancelled or reassigned? +3. Why is the second run's PR not a new PR? diff --git a/github/already-fixed.ts b/github/already-fixed.ts new file mode 100644 index 00000000..35ec68b6 --- /dev/null +++ b/github/already-fixed.ts @@ -0,0 +1,259 @@ +import type { RunGh } from './merge.ts'; +import { ghEnvironment } from './gh-env.ts'; +import { runWithInput } from './run-with-input.ts'; +import { BRANCH, REPOSITORY, SHA } from './validate.ts'; +import { cutText } from '../core/text.ts'; + +/** + * The pre-PR "already fixed" check (design, "Checking whether the issue is already fixed"). It reports a match when the + * issue is closed (by anything, including this task's own merged PR or own commit, which mean the fix is already in), + * when another open PR or any merged PR links to the issue (this task's own merged PR included), or when a new commit + * on the base branch mentions it. Only this task's own open PRs (by repository and number) and its own commits in the + * base comparison (by SHA) are excluded. Every read is bounded; a response past a bound, or one that cannot be read, + * is `unknown`, never clear. + */ +export type AlreadyFixedMatch = + | { kind: 'closed'; by: string } + | { kind: 'pull request'; repository: string; number: number; state: 'OPEN' | 'MERGED'; draft: boolean } + | { kind: 'commit'; sha: string; subject: string }; +export type AlreadyFixedResult = + | { outcome: 'clear'; baseHead: string } + | { outcome: 'found'; baseHead: string; matches: AlreadyFixedMatch[] } + | { outcome: 'unknown'; reason: string }; +export interface AlreadyFixedInput { + issue: number; + /** The base commit the task started from. Commits after it on the base branch are scanned. */ + taskBase: string; + baseBranch: string; + /** This task's PRs in the configured repository (the open PR and any earlier drafts). */ + ownPullRequests: readonly number[]; + /** This task's own commits (runner-owned ledger entries). */ + ownCommits: ReadonlySet; +} +export interface AlreadyFixedGateway { + /** The repository it checks, when fixed; the publisher refuses one that differs from its own. */ + readonly repository?: string; + check(input: AlreadyFixedInput, signal?: AbortSignal): Promise; +} + +export const MAX_TIMELINE_ITEMS = 100; +export const MAX_BASE_COMMITS = 250; +/** One deadline for the whole check, below the 15-second serving request budget (`web/server.ts`). */ +export const DEFAULT_CHECK_DEADLINE_MS = 12_000; +/** + * After the deadline aborts a `gh` call, the runner may wait this long for SIGTERM, then this long for inherited pipes. + * The deadline plus both stays below the 15-second serving request budget (12 + 1 + 0.5 = 13.5 s). + */ +export const CHECK_KILL_GRACE_MS = 1_000, CHECK_PIPE_GRACE_MS = 500; +const PAGE = 100; + +const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + nameWithOwner + defaultBranchRef { name } + issue(number: $number) { + state + timelineItems(first: ${MAX_TIMELINE_ITEMS}, itemTypes: [CLOSED_EVENT, CROSS_REFERENCED_EVENT, CONNECTED_EVENT, DISCONNECTED_EVENT]) { + totalCount + pageInfo { hasNextPage } + nodes { + __typename + ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } + ... on CrossReferencedEvent { willCloseTarget source { __typename ... on PullRequest { number state isDraft baseRefName repository { nameWithOwner } } } } + ... on ConnectedEvent { source { ...Linked } subject { ...Linked } } + ... on DisconnectedEvent { source { ...Linked } subject { ...Linked } } + } + } + } + } +} +fragment Linked on ReferencedSubject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } }`; + +class Unknown extends Error {} +const object = (value: unknown, label: string): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Unknown(`GitHub returned an invalid ${label}.`); + return value as Record; +}; +const positive = (value: unknown, label: string): number => { + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Unknown(`GitHub returned an invalid ${label}.`); + return value as number; +}; +const repositoryName = (value: unknown): string => { + const name = object(value, 'repository').nameWithOwner; + if (typeof name !== 'string' || !REPOSITORY.test(name)) throw new Unknown('GitHub returned an invalid repository name.'); + return name; +}; +const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Whether a commit message refers to the issue: `#N`, `GH-N`, `owner/name#N`, or the issue URL. A reference qualified + * with another repository (`other/repo#N`) is not this issue, and `#N` never matches a longer number. No form may start + * inside a URL path or a longer path-like token (after a word character, `/`, `.` or `-`): `example.com/GH-12` and + * `x.com/github.com/owner/repo/issues/12` are not mentions. The rare real mentions this drops (`Done.GH-12`, an archive + * link to the issue URL) let a duplicate PR open; a person sees it in review. + */ +export function mentionsIssue(message: string, repository: string, issue: number): boolean { + const n = String(issue), repo = escape(repository); + return new RegExp(`(? runWithInput('gh', args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment(), killGraceMs: CHECK_KILL_GRACE_MS, pipeGraceMs: CHECK_PIPE_GRACE_MS })); + } + + async #json(args: readonly string[], signal?: AbortSignal): Promise { + const output = await this.run(args, { signal }); + try { return JSON.parse(output); } + catch { throw new Unknown('GitHub returned invalid JSON.'); } + } + + async check(input: AlreadyFixedInput, signal?: AbortSignal): Promise { + if (!Number.isSafeInteger(input.issue) || input.issue < 1) throw new Error('Invalid issue number.'); + if (!SHA.test(input.taskBase)) throw new Error('Invalid task base commit.'); + if (!BRANCH.test(input.baseBranch)) throw new Error('Invalid base branch name.'); + if (input.ownPullRequests.some(number => !Number.isSafeInteger(number) || number < 1)) throw new Error('Invalid pull request number.'); + // Every stage shares one deadline; reaching it aborts the running `gh` call and makes the check unknown. + // The two stages are independent and run together. The first failure stops the other, and the check still waits for + // both to settle, so it never returns while a `gh` process it started is running. + const deadline = AbortSignal.timeout(this.deadlineMs), failed = new AbortController(); + const stages = AbortSignal.any([...(signal ? [signal] : []), deadline, failed.signal]); + let first: { error: unknown } | null = null; + const stop = (error: unknown) => { first ??= { error }; failed.abort(); throw error; }; + try { + const settled = await Promise.allSettled([this.#timeline(input, stages).catch(stop), this.#baseCommits(input, stages).catch(stop)]); + if (first) throw (first as { error: unknown }).error; + const [matches, { baseHead, commits }] = settled.map(result => (result as PromiseFulfilledResult).value) as [AlreadyFixedMatch[], { baseHead: string; commits: { sha: string; message: string }[] }]; + for (const commit of commits) { + if (input.ownCommits.has(commit.sha) || !mentionsIssue(commit.message, this.repository, input.issue)) continue; + matches.push({ kind: 'commit', sha: commit.sha, subject: cutText(commit.message.split('\n', 1)[0]!, 200) }); + } + return matches.length ? { outcome: 'found', baseHead, matches } : { outcome: 'clear', baseHead }; + } catch (error) { + if (signal?.aborted) throw error; + if (deadline.aborted) return { outcome: 'unknown', reason: `The check did not finish within ${Math.ceil(this.deadlineMs / 1000)} s.` }; + return { outcome: 'unknown', reason: error instanceof Unknown ? error.message : 'GitHub could not be read.' }; + } + } + + async #timeline(input: AlreadyFixedInput, signal?: AbortSignal): Promise { + const [owner, name] = this.repository.split('/') as [string, string]; + const response = object(await this.#json(['api', 'graphql', '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${input.issue}`, '-f', `query=${TIMELINE_QUERY}`], signal), 'response'); + if (Object.hasOwn(response, 'errors') && (!Array.isArray(response.errors) || response.errors.length > 0)) throw new Unknown('GitHub reported errors reading the issue.'); + const repository = object(object(response.data, 'response').repository, 'repository'); + const self = repositoryName(repository).toLowerCase(); + if (self !== this.repository.toLowerCase()) throw new Unknown('GitHub returned a different repository.'); + const defaultBranch = object(repository.defaultBranchRef, 'default branch').name; + if (typeof defaultBranch !== 'string' || !defaultBranch) throw new Unknown('GitHub returned an invalid default branch.'); + // GitHub closes issues only from PRs into the default branch, so willCloseTarget is false for every PR into another + // branch. With such a base, a PR in this repository into that same base that references the issue counts too (a user + // decision): it is most likely the same fix, and a mention elsewhere still is not. + const intoOtherBase = defaultBranch !== input.baseBranch; + const issue = object(repository.issue, 'issue'); + if (issue.state !== 'OPEN' && issue.state !== 'CLOSED') throw new Unknown('GitHub returned an invalid issue state.'); + const timeline = object(issue.timelineItems, 'timeline'); + const nodes = timeline.nodes; + if (!Array.isArray(nodes) || !Number.isSafeInteger(timeline.totalCount)) throw new Unknown('GitHub returned an invalid timeline.'); + if ((timeline.totalCount as number) > MAX_TIMELINE_ITEMS || object(timeline.pageInfo, 'timeline page').hasNextPage !== false || nodes.length !== timeline.totalCount) + throw new Unknown(`The issue has more than ${MAX_TIMELINE_ITEMS} linking events; the check cannot read them all.`); + const own = new Set(input.ownPullRequests); + const isOwn = (repo: string, number: number) => repo.toLowerCase() === self && own.has(number); + // Cross-references are permanent. A manual connection counts only while its latest event is a connect, so the + // events are replayed in timeline order and a later disconnect removes the link. + const referenced = new Map(), connected = new Map(); + let lastCloser: string | null | undefined; + for (const raw of nodes) { + const node = object(raw, 'timeline event'); + if (node.__typename === 'ClosedEvent') { + if (node.closer === null) { lastCloser = 'a person, without a linked PR or commit'; continue; } + const closer = object(node.closer, 'closer'); + if (closer.__typename === 'PullRequest') { + // A close always counts, even by this task's own PR or commit: that PR merged, so the issue is fixed. + const repo = repositoryName(closer.repository), number = positive(closer.number, 'pull request number'); + lastCloser = isOwn(repo, number) ? `${repo}#${number} (this task's own PR, already merged)` : `${repo}#${number}`; + } else if (closer.__typename === 'Commit') { + if (typeof closer.oid !== 'string' || !SHA.test(closer.oid)) throw new Unknown('GitHub returned an invalid closing commit.'); + lastCloser = input.ownCommits.has(closer.oid) ? `commit ${closer.oid} (this task's own commit, already on the default branch)` : `commit ${closer.oid}`; + } else if (closer.__typename === 'ProjectV2') lastCloser = 'a project workflow'; + else throw new Unknown('GitHub returned an unknown closer.'); + continue; + } + const manual = node.__typename === 'ConnectedEvent' || node.__typename === 'DisconnectedEvent'; + if (node.__typename !== 'CrossReferencedEvent' && !manual) throw new Unknown('GitHub returned an unexpected timeline event.'); + // A cross-reference links the issue only when it would close it (a closing keyword). A PR that merely mentions the + // issue, often in an unrelated repository, is not a fix (a user decision; about a third of busy repositories' + // open issues have such mentions). The exception for a non-default base is applied below, once the PR is read. + if (!manual && typeof node.willCloseTarget !== 'boolean') throw new Unknown('GitHub returned an invalid cross-reference.'); + // A manual link has two sides, the issue and what it is linked to, and which side GitHub reports as the subject + // depends on where the link was made. So both are read: the linked PR is the side that is a PR. + const sides = (manual ? [node.source, node.subject] : [node.source]).map(side => object(side, 'linked item')); + if (sides.some(side => side.__typename !== 'Issue' && side.__typename !== 'PullRequest')) throw new Unknown('GitHub returned an unknown linked item.'); + const pulls = sides.filter(side => side.__typename === 'PullRequest'); + if (!pulls.length) continue; + if (pulls.length > 1) throw new Unknown('GitHub returned a link between two pull requests on the issue timeline.'); + const source = pulls[0]!; + const repo = repositoryName(source.repository), number = positive(source.number, 'pull request number'); + if (!['OPEN', 'CLOSED', 'MERGED'].includes(source.state as string) || typeof source.isDraft !== 'boolean') throw new Unknown('GitHub returned an invalid pull request state.'); + if (!manual && !node.willCloseTarget) { + if (typeof source.baseRefName !== 'string') throw new Unknown('GitHub returned an invalid pull request base.'); + if (!(intoOtherBase && repo.toLowerCase() === self && source.baseRefName === input.baseBranch)) continue; + } + // The task's own open PR is not a match; its own merged PR is: the fix is already in. + if (isOwn(repo, number) && source.state !== 'MERGED') continue; + const key = `${repo.toLowerCase()}#${number}`; + const match: AlreadyFixedMatch | null = source.state === 'CLOSED' ? null : { kind: 'pull request', repository: repo, number, state: source.state as 'OPEN' | 'MERGED', draft: source.isDraft }; + if (node.__typename === 'DisconnectedEvent') connected.set(key, null); + else if (node.__typename === 'ConnectedEvent') connected.set(key, match); + else if (match && !referenced.has(key)) referenced.set(key, match); + } + const matches: AlreadyFixedMatch[] = [...referenced.values()]; + for (const [key, match] of connected) if (match && !referenced.has(key)) matches.push(match); + if (issue.state === 'CLOSED') { + if (lastCloser === undefined) throw new Unknown('The issue is closed, but GitHub did not say what closed it.'); + if (lastCloser !== null) matches.unshift({ kind: 'closed', by: lastCloser }); + } + return matches; + } + + async #baseCommits(input: AlreadyFixedInput, signal?: AbortSignal): Promise<{ baseHead: string; commits: { sha: string; message: string }[] }> { + const ref = object(await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/git/ref/heads/${input.baseBranch}`], signal), 'branch'); + if (ref.ref !== `refs/heads/${input.baseBranch}`) throw new Unknown('GitHub returned a different base branch.'); + const baseHead = object(ref.object, 'branch head').sha; + if (typeof baseHead !== 'string' || !SHA.test(baseHead)) throw new Unknown('GitHub returned an invalid base branch head.'); + const commits: { sha: string; message: string }[] = []; + let total = 0; + for (let page = 1; page === 1 || commits.length < total; page++) { + const response = object(await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/compare/${input.taskBase}...${baseHead}?per_page=${PAGE}&page=${page}`], signal), 'comparison'); + if (response.status !== 'identical' && response.status !== 'ahead') throw new Unknown('The task base is not an ancestor of the base branch.'); + if (!Number.isSafeInteger(response.total_commits) || (response.total_commits as number) < 0) throw new Unknown('GitHub returned an invalid commit count.'); + if (page === 1) total = response.total_commits as number; + else if (response.total_commits !== total) throw new Unknown('The base branch changed during the check.'); + if (total > MAX_BASE_COMMITS) throw new Unknown(`The base branch has more than ${MAX_BASE_COMMITS} new commits; the check cannot read them all.`); + if (!Array.isArray(response.commits) || (total > 0 && response.commits.length === 0) || commits.length + response.commits.length > total) throw new Unknown('GitHub returned an incomplete commit list.'); + for (const raw of response.commits) { + const entry = object(raw, 'commit'); + const message = object(entry.commit, 'commit').message; + if (typeof entry.sha !== 'string' || !SHA.test(entry.sha) || typeof message !== 'string') throw new Unknown('GitHub returned an invalid commit.'); + // Each commit once: a repeated SHA means another commit is missing, so the list cannot be trusted as complete. + if (commits.some(commit => commit.sha === entry.sha)) throw new Unknown('GitHub returned a commit twice in the comparison.'); + commits.push({ sha: entry.sha, message }); + } + if (total === 0) break; + } + return { baseHead, commits }; + } +} diff --git a/github/gh-env.ts b/github/gh-env.ts new file mode 100644 index 00000000..eed4b2b5 --- /dev/null +++ b/github/gh-env.ts @@ -0,0 +1,22 @@ +/** + * The environment a `gh` subprocess gets: what it needs to find itself, authenticate, reach GitHub (including through a + * proxy or custom CA) and read its own configuration. Nothing else from the server's environment is passed on. + */ +export const GH_ENV_ALLOWLIST = [ + 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'LANG', 'LC_ALL', + 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_HOST', 'GH_CONFIG_DIR', + 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', + // On Linux, gh reads a token kept in the system keyring over the D-Bus session bus. + 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR', + 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy', 'SSL_CERT_FILE', 'SSL_CERT_DIR', + // Windows: process creation, gh's config and credential store, and executable lookup. + 'SYSTEMROOT', 'SystemRoot', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'PATHEXT', 'COMSPEC', 'TEMP', 'TMP', +] as const; + +export function ghEnvironment(source: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const name of GH_ENV_ALLOWLIST) if (source[name] !== undefined) env[name] = source[name]; + // Never prompt, open a pager or check for updates inside a server. + // `cat` does not exist on Windows; there an empty GH_PAGER turns the pager off. + return { ...env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: platform === 'win32' ? '' : 'cat', NO_COLOR: '1' }; +} diff --git a/github/merge.ts b/github/merge.ts index 9881e231..0a0596dd 100644 --- a/github/merge.ts +++ b/github/merge.ts @@ -54,7 +54,7 @@ export interface GhMergeConfig { method?: 'merge' | 'squash' | 'rebase'; } -type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise; +export type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise; function confirmedMergeRefusal(message: string): boolean { return /required (?:approving )?review|required status check|branch protection|merge conflict|not mergeable|head (?:branch |commit )?(?:was )?(?:modified|changed)|does not match.*head|pull request.*(?:closed|draft)|merge method.*not allowed/i.test(message); diff --git a/github/pull-requests.ts b/github/pull-requests.ts new file mode 100644 index 00000000..20a38ec0 --- /dev/null +++ b/github/pull-requests.ts @@ -0,0 +1,289 @@ +import { ghEnvironment } from './gh-env.ts'; +import { CommandFailed, runWithInput } from './run-with-input.ts'; +import { BRANCH, REPOSITORY, SHA } from './validate.ts'; + +/** A `gh` runner that can also write a request body to stdin (`gh api --input -`). */ +export type RunGhWithInput = (args: readonly string[], options?: { signal?: AbortSignal; input?: string }) => Promise; + +export interface OpenPullRequestInput { + base: string; + headBranch: string; + title: string; + /** Must contain `marker`, so a PR whose opening outcome was lost can be found again. */ + body: string; + draft: boolean; + marker: string; +} +export interface OpenedPullRequest { number: number; url: string; headSha: string; draft: boolean } +export interface PullRequestGateway { + /** The repository it calls, when fixed; the publisher refuses one that differs from its own. */ + readonly repository?: string; + open(input: OpenPullRequestInput, signal?: AbortSignal): Promise; + /** + * The open PR from `headBranch` into `base`, with the one of `markers` its description carries, or null when there is + * no open PR. An open PR that carries none of them (always the case with no markers) was not opened by codeboost, and + * is refused, unless its number is one of `numbers` (the task's recorded PRs): then a person removed its marker, and it + * is refused as PullRequestMisplaced. The task's own PRs (those carrying one of `markers`) must be at most one, and into `base`: its own PR in + * another base (retargeted by a person, or left by a base change) is refused, and so are two of its own PRs. Anyone + * else's PR from the branch into another base (a backport, say) is ignored. For the calls that change a PR's content. + */ + findOpened(input: { base: string; headBranch: string; markers: readonly string[]; numbers?: readonly number[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; + /** + * Every open PR from `headBranch` that carries one of `markers`, in whatever base, with that base. Nothing is refused + * for being in another base: for recording what GitHub shows and for making PRs drafts, both safe in any base. + */ + findOwned(input: { headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string })[]>; + /** + * PR `number` read from the PR itself, not from the list: GitHub's PR list can lag behind it, so a recorded PR missing + * from the list is confirmed closed here before anything is pushed as if it were gone. Its branch, base and marker + * tell list lag apart from a PR a person moved (branch renamed, marker removed). + */ + readPull(number: number, signal?: AbortSignal): Promise<{ open: boolean; headBranch: string; base: string; marker: string }>; + /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ + /** `beforeReady` runs after the description update's await and before any ready or draft change; if it throws, no such change is made. */ + refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string; beforeReady?: () => void }, signal?: AbortSignal): Promise; + /** Turns an open PR codeboost opened back into a draft; a no-op for a draft. */ + markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise; +} +/** + * GitHub refused a draft because the repository does not support draft PRs (for example a private repository on the + * Free plan). A definite refusal: nothing was created or changed. + */ +export class DraftsUnsupported extends Error {} +/** + * GitHub refused to open the PR with a validation error (HTTP 422: no commits between base and head, a PR already open + * for the branch, and so on). A definite refusal: GitHub created nothing, so the opening is not left in flight. + */ +export class PullRequestRefused extends Error {} +/** + * The task's own PRs are not where the main path can publish: one is open into another base than the configured one, + * two are open, or a person moved one (renamed its branch, removed its first-line marker). A person has to retarget, + * restore or close them. The publisher makes them drafts first, where it can still identify them, and changes nothing + * else about them. + */ +export class PullRequestMisplaced extends Error {} +const DRAFTS_UNSUPPORTED = /draft pull requests? (?:are|is) not supported/i; +/** + * What a refusal is matched against: `gh`'s stderr (`gh: (HTTP 422)`) and GitHub's own error fields in the + * response body `gh api` prints on stdout (`message`, `errors[].message`, where a validation reason such as "A pull + * request already exists" is). Never the raw stdout, which could echo text codeboost sent, and nothing from a failure + * that is not a finished `gh` run. + */ +function refusalText(error: unknown): string { + if (!(error instanceof CommandFailed)) return ''; + const reasons = [error.stderr]; + try { + const body = JSON.parse(error.stdout) as { errors?: unknown } | null; + // The top-level message is already on stderr (`gh: (HTTP 422)`). + if (Array.isArray(body?.errors)) for (const item of body.errors) if (typeof item?.message === 'string') reasons.push(item.message); + } catch { /* no JSON body: stderr alone */ } + return reasons.join('\n'); +} +/** Runs a GitHub call that asks for a draft, turning GitHub's "not supported" refusal into DraftsUnsupported. */ +async function draftCall(call: () => Promise): Promise { + try { return await call(); } + catch (error) { + if (DRAFTS_UNSUPPORTED.test(refusalText(error))) throw new DraftsUnsupported('This repository does not support draft pull requests.'); + throw error; + } +} +/** + * A PR's marker is its description's first line and nothing else. Plan text and problems later in the description are + * agent-controlled, so a marker-shaped string there never identifies a PR. + */ +export const markerOf = (body: string): string => body.split('\n', 1)[0]!.trim(); +/** + * One deadline for a whole open, lookup, refresh or draft change, whatever the caller's signal. Stopping `gh` at the + * deadline can take up to 6 seconds more (SIGTERM grace and pipe drain), so an operation settles within 66 seconds. + */ +export const PR_OPERATION_DEADLINE_MS = 60_000; +/** GitHub updates a PR's head a moment after a push; the read-back waits up to this many polls for the pushed head. */ +export const HEAD_POLLS = 5, HEAD_POLL_MS = 500; + + +/** + * GitHub CLI adapter for opening a task's PR. All arguments are literal argv; no shell is involved. The title and + * description go as a JSON request body on stdin, never as arguments. + */ +export class GhPullRequestGateway implements PullRequestGateway { + readonly repository: string; + readonly run: RunGhWithInput; + /** One deadline for a whole operation (every command and poll wait in it), not a fresh allowance per command. */ + readonly operationMs: number; + constructor(config: { repository: string; operationMs?: number }, run?: RunGhWithInput) { + if (!REPOSITORY.test(config.repository)) throw new Error('A GitHub repository is required to open pull requests.'); + if (config.operationMs !== undefined && (!Number.isSafeInteger(config.operationMs) || config.operationMs < 1)) throw new Error('Invalid operation deadline.'); + this.repository = config.repository; + this.operationMs = config.operationMs ?? PR_OPERATION_DEADLINE_MS; + this.run = run ?? ((args, options) => runWithInput('gh', args, { input: options?.input, timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })); + } + + /** The caller's signal combined with this operation's single deadline; every command and wait in it uses the result. */ + #bounded(signal?: AbortSignal): AbortSignal { + const deadline = AbortSignal.timeout(this.operationMs); + return signal ? AbortSignal.any([signal, deadline]) : deadline; + } + + async #json(args: readonly string[], signal?: AbortSignal, body?: Record): Promise { + const output = await this.run(body ? [...args, '--input', '-'] : args, { signal, ...(body ? { input: JSON.stringify(body) } : {}) }); + try { return JSON.parse(output); } + catch { throw new Error('GitHub returned invalid JSON.'); } + } + + #pull(value: unknown, input: { base: string; headBranch: string }): OpenedPullRequest & { body: string } { + const pr = value as { number?: unknown; html_url?: unknown; draft?: unknown; state?: unknown; body?: unknown; head?: { sha?: unknown; ref?: unknown; repo?: { full_name?: unknown } | null }; base?: { ref?: unknown; repo?: { full_name?: unknown } } }; + if (!pr || typeof pr !== 'object' || !Number.isSafeInteger(pr.number) || (pr.number as number) < 1 || typeof pr.html_url !== 'string' || !pr.html_url.startsWith('https://') + || typeof pr.draft !== 'boolean' || (pr.body !== null && typeof pr.body !== 'string') || typeof pr.head?.sha !== 'string' || !SHA.test(pr.head.sha)) + throw new Error('GitHub returned an invalid pull request.'); + const same = (name: unknown) => typeof name === 'string' && name.toLowerCase() === this.repository.toLowerCase(); + if (pr.head.ref !== input.headBranch || !same(pr.head.repo?.full_name) || pr.base?.ref !== input.base || !same(pr.base.repo?.full_name)) + throw new Error('GitHub returned a pull request for a different branch.'); + // A PR can be closed between any two calls; codeboost only records and reports an open one. + if (pr.state !== 'open') throw new Error(`Pull request #${pr.number} is not open.`); + return { number: pr.number as number, url: pr.html_url, headSha: pr.head.sha, draft: pr.draft, body: pr.body ?? '' }; + } + + #validate(input: { base?: string; headBranch: string; marker?: string; markers?: readonly string[] }): void { + if ((input.base !== undefined && !BRANCH.test(input.base)) || !BRANCH.test(input.headBranch)) throw new Error('Invalid branch name.'); + // A lookup may carry no markers (the task has no PR yet); every other call names the PR's own marker. + const markers = input.markers ?? [input.marker]; + if ((input.markers === undefined && !markers.length) || markers.some(marker => typeof marker !== 'string' || !/^$/.test(marker))) throw new Error('Invalid pull request marker.'); + } + + async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { + signal = this.#bounded(signal); + this.#validate(input); + if (markerOf(input.body) !== input.marker) throw new Error('The pull request description must start with its marker.'); + const post = () => this.#json(['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls`], signal, + { title: input.title, body: input.body, head: input.headBranch, base: input.base, draft: input.draft }); + let response; + try { response = input.draft ? await draftCall(post) : await post(); } + catch (error) { + if (error instanceof DraftsUnsupported || !/\(HTTP 422\)/.test(refusalText(error))) throw error; + throw new PullRequestRefused(`GitHub refused to open the pull request: ${(error as Error).message}`); + } + const { body, ...pr } = this.#pull(response, input); + if (markerOf(body) !== input.marker) throw new Error('GitHub returned a pull request without its marker.'); + // The PR exists, but not in the requested state: failing keeps the opening owned, and recovery turns it into a draft. + if (input.draft && !pr.draft) throw new Error('GitHub opened the pull request as ready, not as a draft.'); + return pr; + } + + /** + * Every open PR from the branch, in any base. Not filtered by base on GitHub's side: GitHub allows one open PR per head + * and base, so the task's own PR that a person retargeted would be missed, and a second PR opened from the same branch. + */ + async #branchPulls(headBranch: string, signal: AbortSignal): Promise { + const owner = this.repository.split('/')[0]!; + const query = new URLSearchParams({ state: 'open', head: `${owner}:${headBranch}`, per_page: '100' }); + const response = await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls?${query}`], signal); + if (!Array.isArray(response)) throw new Error('GitHub returned an invalid pull request list.'); + // One page is read. A full page may hide more PRs from the branch on the next one, so it fails closed: this lookup + // decides whether the branch is clear to push to. + if (response.length >= 100) throw new Error(`GitHub lists 100 or more open pull requests from ${headBranch}; codeboost cannot read them all. Close the ones that are not needed.`); + // Every entry is validated before any is set aside as someone else's: a partial answer must not look like a clear branch. + for (const entry of response) { + const pr = entry as { number?: unknown; body?: unknown; base?: { ref?: unknown } } | null; + if (!pr || typeof pr !== 'object' || !Number.isSafeInteger(pr.number) || (pr.number as number) < 1 || (pr.body !== null && typeof pr.body !== 'string') || typeof pr.base?.ref !== 'string') + throw new Error('GitHub returned an invalid pull request list.'); + } + return response; + } + + /** The task's own PRs among `pulls`: those whose description's first line is one of `markers`, each with its base. */ + #owned(pulls: readonly unknown[], headBranch: string, markers: readonly string[]): (OpenedPullRequest & { marker: string; base: string })[] { + return pulls.flatMap(value => { + const body = (value as { body?: unknown } | null)?.body; + if (typeof body !== 'string' || !markers.includes(markerOf(body))) return []; + const base = (value as { base?: { ref?: unknown } }).base?.ref; + if (typeof base !== 'string' || !BRANCH.test(base)) throw new Error('GitHub returned an invalid pull request.'); + const { body: _, ...pr } = this.#pull(value, { base, headBranch }); + return [{ ...pr, marker: markerOf(body), base }]; + }); + } + + async findOwned(input: { headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string })[]> { + signal = this.#bounded(signal); + this.#validate(input); + return this.#owned(await this.#branchPulls(input.headBranch, signal), input.headBranch, input.markers); + } + + async readPull(number: number, signal?: AbortSignal): Promise<{ open: boolean; headBranch: string; base: string; marker: string }> { + signal = this.#bounded(signal); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); + const pr = await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`], signal) as + { number?: unknown; state?: unknown; body?: unknown; head?: { ref?: unknown }; base?: { ref?: unknown } } | null; + if (!pr || pr.number !== number || (pr.state !== 'open' && pr.state !== 'closed') || typeof pr.head?.ref !== 'string' || typeof pr.base?.ref !== 'string' + || (pr.body !== null && typeof pr.body !== 'string')) throw new Error('GitHub returned an invalid pull request.'); + return { open: pr.state === 'open', headBranch: pr.head.ref, base: pr.base.ref, marker: markerOf(pr.body ?? '') }; + } + + async findOpened(input: { base: string; headBranch: string; markers: readonly string[]; numbers?: readonly number[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { + signal = this.#bounded(signal); + this.#validate(input); + const pulls = await this.#branchPulls(input.headBranch, signal); + const own = this.#owned(pulls, input.headBranch, input.markers); + if (own.length > 1) throw new PullRequestMisplaced(`More than one of the task's pull requests is open from ${input.headBranch} (${own.map(pr => `#${pr.number} into ${pr.base}`).join(', ')}). Close all but one.`); + if (own.length === 1 && own[0]!.base !== input.base) + throw new PullRequestMisplaced(`The task's pull request #${own[0]!.number} from ${input.headBranch} now targets ${own[0]!.base}, not ${input.base}. Retarget it to ${input.base} or close it.`); + const here = pulls.filter(pr => (pr as { base?: { ref?: unknown } } | null)?.base?.ref === input.base); + if (here.length > 1) throw new Error('GitHub returned an invalid pull request list.'); + if (!here.length) return null; + const { body, ...pr } = this.#pull(here[0], input); + const found = input.markers.filter(marker => markerOf(body) === marker); + // `numbers`: the task's recorded PRs. One of them without its marker is the task's own PR, edited by a person. + if (found.length !== 1 && input.numbers?.includes(pr.number)) + throw new PullRequestMisplaced(`The task's pull request #${pr.number} no longer starts with its marker. Restore its first line or close it. It may still be ready for review: its first line no longer identifies it.`); + if (found.length !== 1) throw new Error(`An open pull request from ${input.headBranch} exists that codeboost did not open.`); + return { ...pr, marker: found[0]! }; + } + + async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string; beforeReady?: () => void }, signal?: AbortSignal): Promise { + signal = this.#bounded(signal); + this.#validate(input); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); + if (markerOf(input.body) !== input.marker) throw new Error('The pull request description must start with its marker.'); + const patched = this.#pull(await this.#json(['api', '-X', 'PATCH', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`], signal, + { title: input.title, body: input.body }), input); + if (patched.number !== number || markerOf(patched.body) !== input.marker) throw new Error('GitHub returned a different pull request.'); + // The caller's re-check after the PATCH's await: a task change during it must not lead to a ready change. + input.beforeReady?.(); + // A ready PR whose task went back to needs human becomes a draft again; a draft whose task is ready leaves draft. + if (input.ready && patched.draft) await this.run(['pr', 'ready', String(number), '--repo', this.repository], { signal }); + else if (input.draft && !patched.draft) await draftCall(() => this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal })); + const wantDraft = input.ready ? false : input.draft ? true : undefined; + const pr = await this.#readBack(number, input, found => (input.headSha === undefined || found.headSha === input.headSha) && (wantDraft === undefined || found.draft === wantDraft), signal); + // A draft change GitHub has not applied fails the refresh, so it stays unconfirmed and the next publish repeats it. + if (wantDraft !== undefined && pr.draft !== wantDraft) throw new Error(`GitHub did not ${wantDraft ? 'turn the pull request into a draft' : 'mark the pull request ready'}.`); + return pr; + } + + /** The caller has just read the PR as ready, so this changes it straight away and reads the result back once. */ + async markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise { + signal = this.#bounded(signal); + this.#validate(input); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); + let refused: unknown = null; + try { await draftCall(() => this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal })); } + catch (error) { if (error instanceof DraftsUnsupported || signal?.aborted) throw error; refused = error; } + const pr = await this.#readBack(number, input, found => found.draft, signal); + // A refusal because the PR had meanwhile become a draft is success; otherwise the change did not apply. + if (!pr.draft) throw refused ?? new Error('GitHub did not turn the pull request into a draft.'); + return pr; + } + + /** Reads the PR back, polling briefly until `done` holds (GitHub applies pushes and draft changes a moment later); returns the last answer. */ + async #readBack(number: number, input: { base: string; headBranch: string; marker: string }, done: (pr: OpenedPullRequest) => boolean, signal?: AbortSignal): Promise { + for (let poll = 1; ; poll++) { + const { body, ...pr } = this.#pull(await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`], signal), input); + if (pr.number !== number || markerOf(body) !== input.marker) throw new Error('GitHub returned a different pull request.'); + if (done(pr) || poll >= HEAD_POLLS) return pr; + await new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const onAbort = () => { clearTimeout(timer); reject(signal!.reason); }; + const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, HEAD_POLL_MS); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + } +} diff --git a/github/run-with-input.ts b/github/run-with-input.ts new file mode 100644 index 00000000..f4f04666 --- /dev/null +++ b/github/run-with-input.ts @@ -0,0 +1,67 @@ +import { spawn } from 'node:child_process'; + +/** + * A command that exited with a failure. The message carries stderr and the start of stdout, where `gh api` prints the + * response body with GitHub's reason; both are also kept whole, for callers that recognise a refusal. + */ +export class CommandFailed extends Error { + readonly stderr: string; readonly stdout: string; + constructor(message: string, stderr: string, stdout: string) { super(message); this.stderr = stderr; this.stdout = stdout; } +} + +/** + * Runs a command with literal argv and writes `input` to its stdin, so large text never travels as an OS argument + * (Linux limits one argument to 128 KiB, and a NUL cannot be passed at all). Settles only after the process closed: + * a timeout, an abort or an output past `maxBuffer` sends SIGTERM, then SIGKILL after `killGraceMs` if the process is + * still running, and the promise rejects once it has exited. If a process it started keeps the output pipes open after + * it exits, the pipes are closed after `pipeGraceMs` so the promise still settles. + */ +export function runWithInput(command: string, args: readonly string[], options: { + input?: string; signal?: AbortSignal; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: number; killGraceMs?: number; pipeGraceMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { reject(options.signal.reason ?? new Error('Aborted.')); return; } + const child = spawn(command, [...args], { env: options.env, stdio: ['pipe', 'pipe', 'pipe'] }); + const out: Buffer[] = [], err: Buffer[] = []; + let size = 0, failure: Error | null = null; + let escalation: NodeJS.Timeout | null = null; + const stop = (error: Error) => { + failure ??= error; + child.kill('SIGTERM'); + escalation ??= setTimeout(() => child.kill('SIGKILL'), options.killGraceMs ?? 5_000); + }; + const timer = options.timeout ? setTimeout(() => stop(new Error(`${command} timed out.`)), options.timeout) : null; + const onAbort = () => stop(options.signal?.reason instanceof Error ? options.signal.reason : new Error('Aborted.')); + options.signal?.addEventListener('abort', onAbort, { once: true }); + const collect = (chunks: Buffer[]) => (chunk: Buffer) => { + size += chunk.length; + if (options.maxBuffer !== undefined && size > options.maxBuffer) stop(new Error(`${command} output exceeded its limit.`)); + else chunks.push(chunk); + }; + child.stdout.on('data', collect(out)); + child.stderr.on('data', collect(err)); + child.stdin.on('error', () => { /* the exit status reports a process that stopped reading */ }); + child.stdin.end(options.input ?? ''); + child.on('error', error => stop(error)); + let settled = false, pipes: NodeJS.Timeout | null = null; + const finish = (code: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (escalation) clearTimeout(escalation); + if (pipes) clearTimeout(pipes); + options.signal?.removeEventListener('abort', onAbort); + const stderr = Buffer.concat(err).toString('utf8').trim(); + // `gh api` prints only the error's summary on stderr and the response body, with GitHub's reason, on stdout. + const stdout = code !== 0 ? Buffer.concat(out).toString('utf8').trim() : ''; + if (failure) reject(failure); + else if (code !== 0) reject(new CommandFailed(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}${stdout ? `\n${stdout.slice(0, 2000)}` : ''}`, stderr, stdout)); + else resolve(Buffer.concat(out).toString('utf8')); + }; + child.on('close', finish); + // 'close' also waits for the pipes; a grandchild that inherited them can hold them open after this process exits. + child.on('exit', (code, signal) => { + pipes = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(code, signal); }, options.pipeGraceMs ?? 1_000); + }); + }); +} diff --git a/github/validate.ts b/github/validate.ts new file mode 100644 index 00000000..c3095020 --- /dev/null +++ b/github/validate.ts @@ -0,0 +1,5 @@ +/** Shapes the GitHub adapters accept, kept in one place so the adapters cannot drift apart. */ +export const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +export const SHA = /^[a-f0-9]{40}$/; +/** A conservative branch name: no leading dash, no `..` or `//`, no trailing `.` or `/`. */ +export const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(?` on GitHub point at `head`. Settles only when the push finished or failed. */ + push(identity: PlanIdentity, input: { head: string; branch: string }, signal?: AbortSignal): Promise; +} +export interface PublishConfig { + repository: string; baseBranch: string; + /** How long an opening whose outcome was lost stays owned before an empty lookup may abandon it. Default 10 minutes. */ + settleMs?: number; now?: () => number; +} +/** An earlier opening's outcome is still unknown; nothing new is opened until it settles. Retry later. */ +export class OpeningUnsettled extends Error {} +export const DEFAULT_SETTLE_MS = 10 * 60_000; +export type PublishOutcome = + /** `leftReady`: the PR opened or was updated, but could not be made a draft although its task is not in review. */ + | { kind: 'opened'; number: number; url: string; draft: boolean; status: string; leftReady?: number } + /** `leftReady`: the task's earlier PR could not be made a draft because the repository does not support drafts. */ + | { kind: 'possibly already fixed'; result: AlreadyFixedResult; leftReady?: number } + /** A needs-human task whose check matched: no draft PR is opened, and the task stays in needs human. */ + | { kind: 'draft skipped'; result: AlreadyFixedResult; leftReady?: number } + /** + * The repository does not support draft PRs, so a needs-human task gets none (a ready PR would invite review of work + * that needs a person). `number` is the task's existing PR, left as it was, if there is one. Nothing is left in flight. + */ + | { kind: 'draft unsupported'; number: number | null } + /** + * The task head is its base: there is nothing to open a PR for. A running task moves to needs human. `leftReady`: the + * task's earlier PR could not be made a draft because the repository does not support drafts. + */ + | { kind: 'no changes'; leftReady?: number }; + +const marker = (openingId: string) => ``; +const pullRequestList = (prs: readonly { number: number }[]) => `Pull requests ${prs.map(pr => `#${pr.number}`).join(' and ')}`; +/** + * Tasks with a publish in progress, per Store, shared by every publisher over that Store. One publish per task at a + * time, so an update is never cleared or overtaken while its GitHub calls run. The runner lock rules out a second process. + */ +const publishing = new WeakMap>(); + +export class PullRequestPublisher { + #store: Store; #checks: AlreadyFixedGateway; #pulls: PullRequestGateway; #pusher: BranchPusher; #config: PublishConfig; + /** The coordinator's `closing` flag (shutdown step 1), read before each push, opening and ready change. */ + #coordinatorClosing: () => boolean; + #closing = false; + /** Publishes in progress, so shutdown can abort them and await their settlement. */ + #running = new Set<{ abort: AbortController; done: Promise }>(); + constructor(store: Store, deps: { checks: AlreadyFixedGateway; pulls: PullRequestGateway; pusher: BranchPusher; closing?: () => boolean }, config: PublishConfig) { + // The Store records PRs under config.repository; a gateway that calls another repository would open them elsewhere. + for (const gateway of [deps.checks, deps.pulls]) { + if (gateway.repository !== undefined && gateway.repository.toLowerCase() !== config.repository.toLowerCase()) + throw new Error('The publisher and its GitHub gateways must use the same repository.'); + } + this.#store = store; this.#checks = deps.checks; this.#pulls = deps.pulls; this.#pusher = deps.pusher; this.#config = config; + this.#coordinatorClosing = deps.closing ?? (() => false); + } + + /** + * Shutdown: refuse new publishes and every later push, opening or ready change, abort the publishes in progress and + * await their settlement, so nothing is left running when the Store closes (AGENTS.md: in-flight irreversible work). + */ + async close(): Promise { + this.#closing = true; + const running = [...this.#running]; + for (const publish of running) publish.abort.abort(new ShuttingDownError()); + await Promise.allSettled(running.map(publish => publish.done)); + } + + /** Right before an irreversible GitHub change, with no await since (runner-lifecycle.md, "Irreversible actions"). */ + #assertOpen(): void { + if (this.#closing || this.#coordinatorClosing()) throw new ShuttingDownError(); + } + + /** + * The task's branch. The readable slug may collide (`Task_42` and `task-42`); the suffix, a hash of the exact task + * identity, keeps branches of different tasks apart. + */ + branch(identity: PlanIdentity): string { + const plan = this.#store.getPlan(identity); + const slug = identity.taskId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '') || 'task'; + const suffix = createHash('sha256').update(identityKey(identity)).digest('hex').slice(0, 16); + return `codeboost/issue-${plan.issue}-${slug}-${suffix}`; + } + + /** + * Opens the task's PR, or a draft PR with the open problems when `problems` is given (the task is in needs human). + * Order: recover a lost opening; check; push; record the opening; open. A match or an unreadable check opens nothing. + */ + async publish(identity: PlanIdentity, input: { problems?: readonly string[] } = {}, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + this.#assertOpen(); + const key = identityKey(identity); + let inflight = publishing.get(this.#store); + if (!inflight) publishing.set(this.#store, inflight = new Set()); + if (inflight.has(key)) throw new GuardRefusal('A pull request is already being published for this task.'); + inflight.add(key); + const abort = new AbortController(); + const publish = { abort, done: this.#publish(identity, input, signal ? AbortSignal.any([signal, abort.signal]) : abort.signal) }; + this.#running.add(publish); + try { return await publish.done; } + finally { inflight.delete(key); this.#running.delete(publish); } + } + + async #publish(identity: PlanIdentity, input: { problems?: readonly string[] }, signal?: AbortSignal): Promise { + const draft = input.problems !== undefined; + const recovered = await this.#recover(identity, draft, signal); + if (recovered) return recovered; + const notes = await this.#draftStranded(identity, signal); + const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); + const reviewVersion = this.#store.reviewVersion(identity); + // The full publish guard (status, no attempt, merge, requeue or rebase) before any GitHub call. + try { this.#store.assertPublishableNow(identity, draft); } + catch (error) { + if (!(error instanceof GuardRefusal) || !notes.length) throw error; + throw new GuardRefusal(`${error.message} ${notes.join(' ')}`); + } + const prs = this.#store.taskPullRequests(identity), branch = this.branch(identity); + // The task's earlier PR (a needs-human draft, or an abandoned opening's PR that became visible later) is reused while + // it is still open: the branch has at most one open PR (GitHub allows one per base, and the lookup refuses one into + // another base). It is looked up before the check, because an abandoned + // opening's PR links the issue too and has no recorded number; the marker proves it is the task's own. + const candidates = this.#branchRows(prs, branch).filter(pr => pr.state !== 'opening'); + // Always asked, even with no known markers: an open PR on this branch that codeboost did not open is refused here, + // before the push could move it. + let live; + try { + live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)), + numbers: candidates.flatMap(pr => pr.number === null ? [] : [pr.number]) }, signal); + } + catch (error) { + if (!(error instanceof PullRequestMisplaced)) throw error; + // The task's PRs are not where it can publish, and a person has to decide: none of them stays ready meanwhile, + // although the task itself could otherwise be published as ready. + // Only this run's notes: it looks every PR up again, so an earlier run's failure it has since fixed is not reported. + const latest = await this.#draftStranded(identity, signal, true); + throw new PullRequestMisplaced(latest.length ? `${error.message} ${latest.join(' ')}` : error.message); + } + signal?.throwIfAborted(); + if (!live) { + // GitHub's PR list can lag behind a PR: a PR recorded as opened that the list does not show is read directly, so a + // push never moves an open PR's head with no update recorded as in flight. + for (const row of candidates) { + if (row.state !== 'opened' || row.number === null) continue; + const pr = await this.#pulls.readPull(row.number, signal); + signal?.throwIfAborted(); + if (!pr.open) continue; + // Still on the branch, into the configured base, with its marker: only the list is behind, so a retry will do. + if (pr.headBranch === branch && pr.base === this.#config.baseBranch && pr.marker === marker(row.openingId)) + throw new OpeningUnsettled(`Pull request #${row.number} is open, but GitHub's pull request list does not show it yet. Try again later.`); + // Moved by a person (branch renamed, marker removed, retargeted): a retry would never see it, so a person decides. + // Like every misplaced PR, it does not stay ready meanwhile: it is made a draft where it is, while its marker + // still identifies it. + let state = 'It may still be ready for review: its first line no longer identifies it.'; + // Re-read after the direct read's await: a task approved meanwhile keeps its PR ready (GitHub merges no draft). + if (this.#mayKeepReady(identity)) state = 'It is left as it is: the task is now in review, approved or merged.'; + else if (pr.marker === marker(row.openingId)) { + let drafted; + try { drafted = await this.#pulls.markDraft(row.number, { base: pr.base, headBranch: pr.headBranch, marker: pr.marker }, signal); } + catch (error) { + if (signal?.aborted) throw error; + state = error instanceof DraftsUnsupported ? 'It stays ready for review: this repository does not support draft pull requests.' + : `It could not be made a draft and may still be ready for review (${error instanceof Error ? error.message : String(error)}).`; + } + // Only the GitHub call's failure is reported as "may still be ready"; a Store failure after a draft that landed + // propagates. The flag is recorded only where it differs, so a retry does not move the task's version. + if (drafted) { + if (drafted.draft !== row.draft) this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, + { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + state = 'It is now a draft.'; + } + } + throw new PullRequestMisplaced(`The task's pull request #${row.number} is open, but no longer from ${branch} into ${this.#config.baseBranch} with its marker. Close it, or restore its branch, base and first line. ${state}`); + } + } + let earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; + // What GitHub shows is the truth for the draft flag: a draft change whose record was lost is repaired here. + let stateVersion = task.stateVersion; + // An abandoned opening's PR that is now visible is adopted at once, whatever the check says next, so the record + // always names every PR the task has on GitHub (for reuse, cancel or cleanup). It does not change the task status. + if (earlier && live && earlier.state === 'abandoned') { + stateVersion = this.#store.adoptOpening(identity, earlier.openingId, live, { stateVersion, reviewVersion }); + earlier = { ...earlier, state: 'opened', number: live.number, url: live.url, draft: live.draft }; + } + if (earlier && live && earlier.state === 'opened' && earlier.number === live.number && earlier.draft !== live.draft) + stateVersion = this.#store.recordPullRequestDraft(identity, earlier.openingId, live.number, live.draft, { stateVersion, reviewVersion }); + // Checked before any GitHub change on every path: a refused publish must neither draft the PR nor move the branch. + if (earlier && live && earlier.state === 'opened' && live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); + // Nothing to publish: the task's PR, if it is open and ready, must not stay ready for review. + if (snapshot.head === snapshot.base) { + // The full publish guard (versions, snapshot, status, no attempt, merge, requeue or rebase), after the lookup's + // await: interrupted work waiting to be requeued must not be sent to a person as "no changes". + this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + let leftReady: number | undefined; + if (earlier && live && !live.draft) { + try { + const drafted = await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal); + stateVersion = this.#store.recordPullRequestDraft(identity, earlier.openingId, drafted.number, drafted.draft, { stateVersion, reviewVersion }); + } catch (error) { + if (!(error instanceof DraftsUnsupported)) throw error; + leftReady = live.number; + // Re-read after the refused call's await, as a successful one is by recordPullRequestDraft. + this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + } + } + // A cancelled publish still records a draft change that landed (above), but moves no task. + signal?.throwIfAborted(); + if (!draft) this.#store.transitionTask(identity, stateVersion, 'needs human'); + return leftReady === undefined ? { kind: 'no changes' } : { kind: 'no changes', leftReady }; + } + const own = new Set(prs.filter(pr => pr.number !== null && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).map(pr => pr.number!)); + if (live) own.add(live.number); + const result = await this.#checks.check({ + issue: plan.issue, taskBase: snapshot.base, baseBranch: this.#config.baseBranch, + ownPullRequests: [...own], + ownCommits: new Set(this.#store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha)), + }, signal); + signal?.throwIfAborted(); + // A task that is not published as ready never leaves its PR ready for review: on a match its earlier ready PR becomes + // a draft. This comes before the result is recorded, so if it fails the task is still running and a retry repeats it. + // It is still a GitHub change for this task, so the task is re-read first: a reassignment or review during the check + // means this publish is stale and must not touch the current generation's PR. + const needsDraft = result.outcome !== 'clear' && earlier && live && !live.draft; + if (needsDraft) this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + let drafted = null, leftReady: number | undefined; + if (needsDraft && earlier && live) { + try { drafted = await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal); } + catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; leftReady = live.number; } + } + signal?.throwIfAborted(); + const check = this.#store.recordAlreadyFixed(identity, stateVersion, { snapshotId: snapshot.id, reviewVersion, draft, result }); + if (drafted && earlier!.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier!.openingId, drafted.number, drafted.draft, { stateVersion: check.stateVersion, reviewVersion: check.reviewVersion }); + const ready = leftReady === undefined ? {} : { leftReady }; + if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result, ...ready } : { kind: 'possibly already fixed', result, ...ready }; + if (earlier && live) { + // A needs-human task's ready PR becomes a draft first, before anything else about it changes: if the repository + // has no drafts, the refusal comes while the PR is still exactly as it was (no push, no new description). + if (draft && !live.draft) { + try { await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal); } + catch (error) { if (error instanceof DraftsUnsupported) return { kind: 'draft unsupported', number: live.number }; throw error; } + signal?.throwIfAborted(); + } + // The push is a refresh's first content write (it moves the open PR's head), so the refresh is recorded before it; + // beginRefresh re-reads the task after the draft change's await. A task change during the push cannot strand it. + this.#assertOpen(); + const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); + await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); + signal?.throwIfAborted(); + // Re-read after the push's await, before anything else about the PR changes (description, ready or draft): a + // cancel, reassignment or review during the push leaves the update in flight and the PR as it was. + this.#assertOpen(); + this.#store.assertRefreshCurrent(identity, earlier.openingId, draft); + // Any failure here, including a draft refusal after the PR was made ready again meanwhile, leaves the update + // recorded as in flight; the next publish settles it and starts again from the draft step above. + const pr = await this.#pulls.refresh(live.number, { + // The PR's base on GitHub: the lookup only returns a PR into the configured base. + base: this.#config.baseBranch, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), + beforeReady: () => { this.#assertOpen(); this.#store.assertRefreshCurrent(identity, earlier.openingId, draft); }, + title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), + }, signal); + const status = this.#store.recordRefreshConfirmed(identity, earlier.openingId, pr, { head: snapshot.head, stateVersion }); + return this.#settleHead(identity, earlier.openingId, pr, snapshot.head, draft, status, branch, signal); + } + // No PR exists yet, so moving the branch changes nothing a reviewer sees. + this.#assertOpen(); + await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); + signal?.throwIfAborted(); + // The last await before the irreversible call is behind us: beginPullRequest re-reads the task state in its transaction. + this.#assertOpen(); + const opening = this.#store.beginPullRequest(identity, { + checkId: check.id, repository: this.#config.repository, base: this.#config.baseBranch, headBranch: branch, headSha: snapshot.head, draft, + }); + let pr; + try { + pr = await this.#pulls.open({ + base: opening.base, headBranch: branch, draft, marker: marker(opening.openingId), + title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(opening.openingId), problems: input.problems }), + }, signal); + } catch (error) { + // Definite refusals: GitHub created nothing, so the opening is dropped rather than left to settle for 10 minutes. + if (error instanceof PullRequestRefused) { this.#store.abandonPullRequestOpening(identity, opening.openingId); throw error; } + if (!(error instanceof DraftsUnsupported)) throw error; + this.#store.abandonPullRequestOpening(identity, opening.openingId); + return { kind: 'draft unsupported', number: null }; + } + const status = this.#store.recordPullRequestOpened(identity, opening.openingId, pr); + return this.#settleHead(identity, opening.openingId, pr, snapshot.head, draft, status, branch, signal); + } + + /** + * A ready PR is left ready only for a task now in review. Otherwise (GitHub shows another head than the one pushed, + * or the task or its review changed during the call) it becomes a draft until a later publish reconciles it. The + * open or refresh already succeeded and is recorded, so a failure here is reported as `leftReady`, not as a failure + * of the whole publish (AGENTS.md: a later step's failure must not turn a succeeded irreversible action into one). + */ + async #settleHead(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, head: string, + draft: boolean, status: string, branch: string, signal?: AbortSignal): Promise { + const base = this.#config.baseBranch; + const opened = { kind: 'opened' as const, number: pr.number, url: pr.url, draft: pr.draft, status }; + // Left ready only for a ready publish whose task is now in review at the pushed head; a draft publish's PR is always + // a draft, whatever GitHub returned. + if (pr.draft || (!draft && status === 'in review' && pr.headSha === head)) return opened; + let drafted; + // Only the GitHub call's failure becomes leftReady (the PR stays ready; the next publish reconciles it). A Store + // failure after a draft change that landed propagates, so it is not misreported as a ready PR. + try { drafted = await this.#pulls.markDraft(pr.number, { base, headBranch: branch, marker: marker(openingId) }, signal); } + catch (error) { if (signal?.aborted) throw error; return { ...opened, leftReady: pr.number }; } + // A fact about the PR, recorded against the versions read right now (no await since). + this.#store.recordPullRequestDraft(identity, openingId, drafted.number, drafted.draft, + { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + return { kind: 'opened', number: drafted.number, url: drafted.url, draft: drafted.draft, status: this.#store.getTask(identity).status }; + } + + /** The task may keep a ready PR: it is in review, approved (GitHub merges no draft) or merged. Read with no await since. */ + #mayKeepReady(identity: PlanIdentity): boolean { + const status = this.#store.getTask(identity).status; + return MERGEABLE_STATUSES.includes(status) || status === 'merged'; + } + + /** Whether a lost opening is the current publish's own: neither the task nor its review changed since it began, and the mode matches. */ + #isCurrent(identity: PlanIdentity, lost: TaskPullRequest, draft: boolean): boolean { + return this.#store.getTask(identity).stateVersion === lost.ownerVersion && this.#store.reviewVersion(identity) === lost.ownerReviewVersion && lost.draft === draft; + } + + /** + * The task's PR records for one branch, whatever their base, in the configured repository: their markers go with every + * lookup, so the task's own PR is recognised even after the base setting changed or a person retargeted it. + */ + #branchRows(prs: readonly TaskPullRequest[], branch: string): TaskPullRequest[] { + return prs.filter(pr => pr.headBranch === branch && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()); + } + + /** + * An opening whose GitHub outcome was lost (a crash or a timeout): adopt the PR if GitHub has it. An empty lookup + * does not prove the request was refused while GitHub may still apply or show it, so the opening stays owned until + * the settle time has passed; only then is it abandoned. The caller retries after OpeningUnsettled. + */ + async #recover(identity: PlanIdentity, draft: boolean, signal?: AbortSignal): Promise { + // An update whose confirmation was lost is repeated, not adopted: its description may or may not have landed. What + // GitHub shows now (draft flag, head) is recorded first, so a change that did land is not forgotten. + for (const refreshing of this.#store.taskPullRequests(identity).filter(pr => pr.refresh !== null)) { + if (refreshing.repository.toLowerCase() !== this.#config.repository.toLowerCase()) throw new GuardRefusal('A pull request update is in flight in another repository.'); + // An observation in any base: recording what GitHub shows is safe wherever the PR is, and a refusal here would keep + // the update in flight and stop the draft step below from running. A marker is editable text, so another PR may + // carry it too (a copied description); the update's PR is the one with the recorded number, which cannot be edited, + // whatever order GitHub lists them in. A copy is left to the main path and the draft step. + const found = await this.#pulls.findOwned({ headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); + signal?.throwIfAborted(); + this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, found.find(pr => pr.number === refreshing.number) ?? null); + } + // Read once: the settlements above are the only writes before this point. + const prs = this.#store.taskPullRequests(identity); + const lost = prs.find((pr: TaskPullRequest) => pr.state === 'opening'); + if (!lost) return null; + if (lost.repository.toLowerCase() !== this.#config.repository.toLowerCase()) throw new GuardRefusal('A pull request was being opened in another repository.'); + const rows = this.#branchRows(prs, lost.headBranch); + // The task's own PRs in any base: the lost opening's PR is recorded wherever it is now (a person may have moved it). + const owned = await this.#pulls.findOwned({ headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); + signal?.throwIfAborted(); + const mine = owned.filter(candidate => candidate.marker === marker(lost.openingId)); + // Two PRs carrying the lost opening's marker (a copied description): no number was recorded, so which one it opened is + // unknown, and it stays owned. Neither can be drafted safely, since one is someone else's. + if (mine.length > 1) throw new PullRequestMisplaced(`${pullRequestList(mine)} carry the first line of a pull request the task was opening, so codeboost cannot tell which is its own. Close the one that is not (compare their authors and creation times). The task's pull request may still be ready for review.`); + const pr = mine[0]; + const blocking = pr ? undefined : owned.find(candidate => candidate.base === lost.base); + if (blocking) { + // Another of the task's openings has the open PR from this branch into the base this opening asked for, so this + // opening's request created nothing (GitHub allows one per head and base). Drop it. If that other opening was + // abandoned, the main path (a running task) or the draft step (a stopped one) adopts its PR next. + this.#store.abandonPullRequestOpening(identity, lost.openingId); + return null; + } + if (!pr) { + const age = (this.#config.now ?? Date.now)() - Date.parse(lost.createdAt); + if (!(age >= (this.#config.settleMs ?? DEFAULT_SETTLE_MS))) throw new OpeningUnsettled('An earlier pull request opening has not settled yet. Try again later.'); + this.#store.abandonPullRequestOpening(identity, lost.openingId); + return null; + } + // A lost draft opening must end as a draft: if the PR was made ready meanwhile, turn it back before confirming. Any + // failure keeps the opening owned (the next publish retries); drafts being unsupported is definite, so the PR is + // recorded as it is and reported. + let found: { number: number; url: string; headSha: string; draft: boolean } = pr; + // Only when this publish is itself a draft publish; a ready publish's main path marks the PR ready anyway. + // Re-read after the lookup's await, as before every other draft change: an approved task keeps its PR ready. + if (lost.draft && draft && !pr.draft && !this.#mayKeepReady(identity)) { + try { found = await this.#pulls.markDraft(pr.number, { base: pr.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } + catch (error) { + if (!(error instanceof DraftsUnsupported)) throw error; + // Definite: the PR is recorded as it is, and the main path reports it (`draft unsupported`, or the misplaced + // refusal for a PR it would not accept). + this.#store.recordPullRequestOpened(identity, lost.openingId, pr, false); + return null; + } + } + // The recovered opening finishes this publish only if it is this publish's own work: same task version and same + // draft mode. Otherwise (the task was rerun, or moved between ready and needs human) the PR is recorded and this + // publish continues, so the main path pushes the current head and refreshes the PR into the current mode. + const current = this.#isCurrent(identity, lost, draft); + // Moving the task to in review is a change, not an observation: only for a PR the main path would accept, in the + // configured base with no other of the task's PRs open. Any other is recorded; the main path drafts and refuses it. + const mayReview = pr.base === this.#config.baseBranch && owned.length === 1; + const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found, mayReview); + // It ends the publish only for a PR the main path would accept. Any other goes on to the main path, which makes all of + // the task's PRs drafts and refuses with what a person has to do, as it does for every misplaced PR. + if (current && mayReview) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal); + return null; + } + + /** + * A task that is not in review, approved, merged, or publishable as ready (cancelled, needs human, possibly already + * fixed, an attempt active, and so on) never keeps a ready PR: the main path may refuse on status, so nothing else + * would draft it. Its record saying "ready" is what makes the draft owed, so an earlier draft change that failed + * (`leftReady`) is repeated by the next publish, and a PR that recovery has just recorded is covered too. An abandoned + * opening's PR that has appeared since is adopted here too (AGENTS.md: a late result is adopted), because the main path, + * which also adopts, may refuse first. A draft flag GitHub already shows is only recorded. A GitHub failure does not + * replace the status refusal that follows: it is returned as a note for it, and the next publish tries again. + */ + async #draftStranded(identity: PlanIdentity, signal?: AbortSignal, misplaced = false): Promise { + // `misplaced`: the main path found the task's PRs where it cannot publish, so being publishable keeps nothing ready. + const keepsReady = () => this.#mayKeepReady(identity) || (!misplaced && this.#store.canPublish(identity, false)); + if (keepsReady()) return []; + const prs = this.#store.taskPullRequests(identity), notes: string[] = []; + const reason = (error: unknown) => error instanceof Error ? error.message : String(error); + // One lookup per branch with a PR recorded as ready, or with an abandoned opening whose PR may have appeared since. + // A record's base is only where the PR was opened; the lookup finds the task's own PRs in any base. + const branches = new Set(); + for (const pr of prs) { + // Misplaced PRs are all looked up: one recorded as a draft may have been made ready by the person who moved it. + if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && (!pr.draft || misplaced)) || pr.state === 'abandoned')) + branches.add(pr.headBranch); + } + for (const headBranch of branches) { + const rows = this.#branchRows(prs, headBranch).filter(pr => pr.state !== 'opening'); + let owned; + // In any base: a stopped task's PRs are drafted wherever they are (a draft is safe anywhere), all of them. + try { owned = await this.#pulls.findOwned({ headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); } + catch (error) { + if (signal?.aborted) throw error; + notes.push(`The open pull requests from ${headBranch} could not be looked up, so one of this task's pull requests may still be ready for review; the next publish tries again (${reason(error)}).`); + continue; + } + signal?.throwIfAborted(); + for (const live of owned) { + const row = rows.find(candidate => marker(candidate.openingId) === live.marker); + // Not the one recorded for its opening: nothing known is ready for review here. + if (!row || (row.state === 'opened' && row.number !== live.number)) continue; + // Versions read right now, with no await since: recording what GitHub shows is a fact, even after a cancel. + const current = () => ({ stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + if (row.state === 'abandoned') this.#store.adoptOpening(identity, row.openingId, live, current()); + let drafted: { number: number; draft: boolean } = live; + // Re-read after the lookup's await: a task that was approved meanwhile keeps its PR ready. + if (!live.draft && !keepsReady()) { + try { drafted = await this.#pulls.markDraft(live.number, { base: live.base, headBranch, marker: live.marker }, signal); } + catch (error) { + if (signal?.aborted) throw error; + notes.push(error instanceof DraftsUnsupported + ? `Pull request #${live.number} stays ready for review: this repository does not support draft pull requests.` + : `Pull request #${live.number} could not be made a draft and may still be ready for review; the next publish tries again (${reason(error)}).`); + continue; + } + } + // The record is corrected only where it differs: the draft change just made, or one GitHub already shows. + const recorded = row.state === 'abandoned' ? live.draft : row.draft; + if (drafted.draft !== recorded) this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, current()); + signal?.throwIfAborted(); + } + } + return notes; + } +} diff --git a/runner/store.ts b/runner/store.ts index 3e51b418..0ab45fc1 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -5,6 +5,7 @@ import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { importPlan, applySuggestion, assertEditReply, type Plan, type PlanContext, type EditReply } from '../core/plan.ts'; import type { Approval, SegmentChoice } from '../core/approvals.ts'; import type { InvocationContext, StopReason } from '../agents/contract.ts'; +import type { AlreadyFixedResult } from '../github/already-fixed.ts'; import { ATTEMPT_PHASES, BadRequest, CLOSED_STATUSES, MERGEABLE_STATUSES, ShuttingDownError, type ShutdownCapability, DEFAULT_TASK_BUDGET_MS, FIRST_REASONS, GuardRefusal, ActionIdReused, RefusalWithEffect, MAX_RESULT_BYTES, TASK_STATUSES, TERMINAL_STATES, assertUuidV4, bounded, classifySettlement, requestHash, sameContext, @@ -23,6 +24,18 @@ export interface SuggestionRequest { state: SuggestionState; revision: number; s export interface SnippetReference { key: string; path: string; side: 'old' | 'new'; start: number; end: number; text: string; head: string; base: string } export interface QuestionAnswer { provider?: 'claude' | 'codex'; attempt: string; contextId?: string; status: 'pending' | 'complete' | 'failed'; expiresAt: number; text?: string; error?: string } export interface ReviewNote { id: string; item: string; kind: 'question' | 'change'; text: string; reference?: SnippetReference; answer?: QuestionAnswer; createdAt: string; revision: number; snapshotId: string } +/** A PR codeboost opened (or is opening) for a task. `opening` means the outcome of the GitHub call is not yet known. */ +export interface TaskPullRequest { + openingId: string; repository: string; base: string; headBranch: string; headSha: string; draft: boolean; + state: 'opening' | 'opened' | 'abandoned'; number: number | null; url: string | null; createdAt: string; + /** The task state version this opening owns; only a response for that exact version may change the task status. */ + ownerVersion: number; + /** The plan's review version this opening owns: review input (approvals, choices, notes) since then makes it stale. */ + ownerReviewVersion: number; + /** An update of this open PR that started and has not been confirmed; the PR may already show it. */ + refresh: { head: string; draft: boolean; stateVersion: number } | null; +} +export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; reviewVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; export interface MergeAttempt { id: string; kind: 'queue' | 'direct'; state: MergeAttemptState; revision: number; snapshotId: string; reviewVersion: number; reviewedHead: string; @@ -78,8 +91,8 @@ export class Store { this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;'); this.#transaction(() => { const version = this.#get('PRAGMA user_version')!.user_version as number; - if (![0, 1, 2, 3, 4, 5, 6].includes(version)) throw new Error('Unsupported store schema version.'); - if (version === 6) return; + if (![0, 1, 2, 3, 4, 5, 6, 7].includes(version)) throw new Error('Unsupported store schema version.'); + if (version === 7) return; if (version === 0) this.#db.exec(` CREATE TABLE plans (key TEXT PRIMARY KEY, issue INTEGER NOT NULL, revision INTEGER NOT NULL, snapshot_id TEXT); CREATE TABLE revisions (key TEXT NOT NULL REFERENCES plans(key), revision INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,revision)); @@ -108,6 +121,7 @@ export class Store { ); PRAGMA user_version=5;`); if (version < 6) this.#migrateV6(); + if (version < 7) this.#migrateV7(); }); } catch (error) { this.#db.close(); throw error; } } @@ -892,6 +906,264 @@ export class Store { })); } + // ---- F2d: the pre-PR already-fixed check and PR opening ---- + #migrateV7(): void { + this.#db.exec(` + CREATE TABLE IF NOT EXISTS already_fixed_checks ( + id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES tasks(plan_key), snapshot_id TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('clear','found','unknown')), result TEXT NOT NULL, + state_version INTEGER NOT NULL, review_version INTEGER NOT NULL, checked_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS task_pull_requests ( + opening_id TEXT PRIMARY KEY, plan_key TEXT NOT NULL REFERENCES tasks(plan_key), repository TEXT NOT NULL, base TEXT NOT NULL, + head_branch TEXT NOT NULL, head_sha TEXT NOT NULL, draft INTEGER NOT NULL, owner_version INTEGER NOT NULL, owner_review_version INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('opening','opened','abandoned')), number INTEGER, url TEXT, + refresh_head TEXT, refresh_draft INTEGER, refresh_version INTEGER, refresh_review_version INTEGER, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + CHECK ((state = 'opened') = (number IS NOT NULL AND url IS NOT NULL))); + CREATE UNIQUE INDEX IF NOT EXISTS task_pull_requests_number ON task_pull_requests (lower(repository), number) WHERE number IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS task_pull_requests_opening ON task_pull_requests (plan_key) WHERE state = 'opening'; + CREATE INDEX IF NOT EXISTS already_fixed_checks_task ON already_fixed_checks (plan_key); + CREATE INDEX IF NOT EXISTS task_pull_requests_task ON task_pull_requests (plan_key); + PRAGMA user_version=7;`); + } + #pullRequestRecord(row: Record): TaskPullRequest { + return { + openingId: row.opening_id as string, repository: row.repository as string, base: row.base as string, headBranch: row.head_branch as string, + headSha: row.head_sha as string, draft: row.draft === 1, state: row.state as TaskPullRequest['state'], + number: row.number as number | null, url: row.url as string | null, createdAt: row.created_at as string, + ownerVersion: row.owner_version as number, ownerReviewVersion: row.owner_review_version as number, + refresh: row.refresh_head === null ? null : { head: row.refresh_head as string, draft: row.refresh_draft === 1, stateVersion: row.refresh_version as number }, + }; + } + /** Every PR codeboost opened or started to open for the task, oldest first. */ + taskPullRequests(identity: PlanIdentity): TaskPullRequest[] { + const key = identityKey(identity); this.#task(key); + return this.#db.prepare('SELECT * FROM task_pull_requests WHERE plan_key=? ORDER BY rowid').all(key).map(row => this.#pullRequestRecord(row)); + } + latestAlreadyFixed(identity: PlanIdentity): AlreadyFixedCheck | null { + const key = identityKey(identity); this.#task(key); + const row = this.#get('SELECT * FROM already_fixed_checks WHERE plan_key=? ORDER BY rowid DESC LIMIT 1', key); + return row ? { id: row.id as string, snapshotId: row.snapshot_id as string, result: decode(row.result), stateVersion: row.state_version as number, reviewVersion: row.review_version as number, checkedAt: row.checked_at as string } : null; + } + /** Publishing runs after the task's last attempt settled, while the task is running, or in needs human for a draft PR. */ + #assertPublishable(key: string, task: Record, expectedStateVersion: number, draft: boolean): void { + if (task.state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + if (task.status !== (draft ? 'needs human' : 'running')) throw new GuardRefusal(`A ${draft ? 'draft ' : ''}pull request cannot be opened while the task is ${task.status}.`); + if (this.#activeAttempt(key)) throw new GuardRefusal('An attempt is still active for this task.'); + if (this.#activeMerge(key)) throw new GuardRefusal('A merge is in progress; wait for its outcome.'); + // Interrupted work waiting to be requeued, or a rebase in progress, is not finished work to publish. + if (task.requeue_pending === 1) throw new GuardRefusal('The task has interrupted work waiting to be requeued.'); + if (task.rebase_in_progress !== null) throw new GuardRefusal('A rebase is in progress for this task.'); + } + /** + * Records a pre-PR check. A match, or a check that could not be completed, moves a running task to possibly already + * fixed in the same transaction. A needs-human task keeps its status; its draft PR is not opened. + */ + recordAlreadyFixed(identity: PlanIdentity, expectedStateVersion: number, input: { snapshotId: string; reviewVersion: number; draft: boolean; result: AlreadyFixedResult }): AlreadyFixedCheck { + if (!['clear', 'found', 'unknown'].includes(input.result?.outcome)) throw new Error('Invalid already-fixed result.'); + const key = identityKey(identity); + return this.#transaction(() => { + const task = this.#task(key); + this.#assertPublishable(key, task, expectedStateVersion, input.draft); + if (this.#current(key).snapshot_id !== input.snapshotId) throw new GuardRefusal('The task head changed during the check.'); + if (input.result.outcome !== 'clear' && !input.draft) this.#run("UPDATE tasks SET status='possibly already fixed' WHERE plan_key=?", key); + this.#touch(key); + const id = randomUUID(), checkedAt = new Date().toISOString(), stateVersion = this.#task(key).state_version as number; + // Review input (approvals, choices, notes) advances review_version without touching the task; bind the check to both, + // and refuse a result the publish obtained before a review change (it would move the task on stale review state). + const reviewVersion = this.#current(key).review_version as number; + if (reviewVersion !== input.reviewVersion) throw new GuardRefusal('The review changed during the check. Reload before writing.'); + this.#run('INSERT INTO already_fixed_checks (id,plan_key,snapshot_id,outcome,result,state_version,review_version,checked_at) VALUES (?,?,?,?,?,?,?,?)', + id, key, input.snapshotId, input.result.outcome, encode(input.result), stateVersion, reviewVersion, checkedAt); + return { id, snapshotId: input.snapshotId, result: input.result, stateVersion, reviewVersion, checkedAt }; + }); + } + /** + * Records the intent to open a PR, immediately before the GitHub call. It requires a clear check with no task change + * since it was recorded, so the check and the PR bind to the same head (AGENTS.md: re-read before an irreversible action). + */ + beginPullRequest(identity: PlanIdentity, input: { checkId: string; repository: string; base: string; headBranch: string; headSha: string; draft: boolean }): TaskPullRequest { + const key = identityKey(identity); + return this.#transaction(() => { + this.#assertCheckedHead(identity, input); + if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); + const openingId = randomUUID(), now = new Date().toISOString(); + this.#touch(key); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,'opening',NULL,NULL,?,?)`, openingId, key, input.repository, input.base, input.headBranch, input.headSha, input.draft ? 1 : 0, + this.#task(key).state_version as number, this.#current(key).review_version as number, now, now); + return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; + }); + } + /** + * Records an update of the task's open PR before it starts, under the same guard as an opening. The PATCH and the + * draft change may land even if their confirmation is lost; the record keeps that visible until the update is + * confirmed or abandoned. Returns the state version the update owns. + */ + beginRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): number { + const key = identityKey(identity); + return this.#transaction(() => { + this.#assertCheckedHead(identity, input); + // Only an opened PR is refreshed; an abandoned opening's PR is adopted first, by adoptOpening, when publish sees it. + const row = this.#get('SELECT state FROM task_pull_requests WHERE plan_key=? AND opening_id=?', key, input.openingId); + if (row?.state !== 'opened') throw new GuardRefusal('Unknown pull request.'); + this.#touch(key); + const version = this.#task(key).state_version as number; + this.#run('UPDATE task_pull_requests SET refresh_head=?, refresh_draft=?, refresh_version=?, refresh_review_version=?, updated_at=? WHERE opening_id=?', + input.headSha, input.draft ? 1 : 0, version, this.#current(key).review_version as number, new Date().toISOString(), input.openingId); + return version; + }); + } + /** + * Settles an update whose confirmation was lost. What GitHub shows now (`observed`, or null when the PR is no longer + * open) replaces the recorded draft flag and head, so a change that landed is not forgotten; then the in-flight record + * is cleared. The next publish checks again and repeats the update (it is idempotent). + */ + settleUnconfirmedRefresh(identity: PlanIdentity, openingId: string, observed: { number: number; draft: boolean; headSha: string } | null): void { + const key = identityKey(identity); + this.#transaction(() => { + const row = this.#get("SELECT number FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", key, openingId); + if (!row) throw new GuardRefusal('No update of this pull request is in flight.'); + if (observed && observed.number === row.number) + this.#run('UPDATE task_pull_requests SET draft=?, head_sha=? WHERE opening_id=?', observed.draft ? 1 : 0, observed.headSha, openingId); + this.#run("UPDATE task_pull_requests SET refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, refresh_review_version=NULL, updated_at=? WHERE opening_id=?", + new Date().toISOString(), openingId); + this.#touch(key); + }); + } + /** + * Right before an update's GitHub calls, after the push's await: the task and its review are still exactly as the + * update recorded them, and the task is still publishable. A change during the push leaves the update in flight. + */ + assertRefreshCurrent(identity: PlanIdentity, openingId: string, draft: boolean): void { + const key = identityKey(identity); + this.#transaction(() => { + const row = this.#get("SELECT refresh_version, refresh_review_version FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", key, openingId); + if (!row) throw new GuardRefusal('No update of this pull request is in flight.'); + this.#assertPublishable(key, this.#task(key), row.refresh_version as number, draft); + if (this.#current(key).review_version !== row.refresh_review_version) throw new GuardRefusal('The review changed after the check. Reload before writing.'); + }); + } + #assertVersions(key: string, expected: { stateVersion: number; reviewVersion: number }): void { + if (this.#task(key).state_version !== expected.stateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + if (this.#current(key).review_version !== expected.reviewVersion) throw new GuardRefusal('The review changed. Reload before writing.'); + } + #assertCheckedHead(identity: PlanIdentity, input: { checkId: string; headSha: string; draft: boolean }): void { + const key = identityKey(identity), task = this.#task(key), check = this.latestAlreadyFixed(identity); + if (!check || check.id !== input.checkId || check.result.outcome !== 'clear') throw new GuardRefusal('A clear already-fixed check must come right before opening a pull request.'); + this.#assertPublishable(key, task, check.stateVersion, input.draft); + if (this.#current(key).review_version !== check.reviewVersion) throw new GuardRefusal('The review changed after the check. Reload before writing.'); + const snapshot = this.getSnapshot(identity); + if (snapshot.id !== check.snapshotId || snapshot.head !== input.headSha) throw new GuardRefusal('The task head changed after the check.'); + } + /** + * An opening's PR exists. The record is kept whatever happened to the task meanwhile, so the PR can still be found and + * closed. The task status changes only when the task is unchanged since the opening began (its owned state version), + * and only when `mayReview`: recovery passes false for a PR the main path would refuse (in another base than the + * configured one, or with another of the task's PRs open), so such a PR is recorded but never puts the task in review. + */ + recordPullRequestOpened(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, mayReview = true): TaskStatus { + const key = identityKey(identity); + return this.#confirmPullRequest(key, pr, mayReview, () => + this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opening'", key, openingId), + row => ({ head: row.head_sha as string, owned: row.owner_version as number, ownedReview: row.owner_review_version as number }), openingId, 'No pull request is being opened with this ID.'); + } + /** A refresh of the task's open PR landed, for the head and state version beginRefresh recorded. Same status rule. */ + recordRefreshConfirmed(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, + refresh: { head: string; stateVersion: number }): TaskStatus { + const key = identityKey(identity); + return this.#confirmPullRequest(key, pr, true, () => + this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened' AND number=? AND refresh_head=? AND refresh_version=?", + key, openingId, pr.number, refresh.head, refresh.stateVersion), + row => ({ head: refresh.head, owned: refresh.stateVersion, ownedReview: row.refresh_review_version as number }), openingId, 'No update of this pull request is in flight.'); + } + #confirmPullRequest(key: string, pr: { number: number; url: string; headSha: string; draft: boolean }, mayReview: boolean, find: () => Record | undefined, + expected: (row: Record) => { head: string; owned: number; ownedReview: number }, openingId: string, missing: string): TaskStatus { + if (!Number.isSafeInteger(pr.number) || pr.number < 1 || typeof pr.url !== 'string') throw new Error('Invalid pull request.'); + return this.#transaction(() => { + const row = find(); + if (!row) throw new GuardRefusal(missing); + const { head, owned, ownedReview } = expected(row); + // The record keeps the head GitHub reports, which may differ from the head that was pushed. + this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, refresh_review_version=NULL, updated_at=? WHERE opening_id=?", + pr.number, pr.url, pr.draft ? 1 : 0, pr.headSha, new Date().toISOString(), openingId); + const task = this.#task(key); + // Every status change and every admission increases the state version, so an unchanged version means the task is + // still in the status the opening or refresh was guarded for with no attempt active. Review input advances only the + // review version, so that must be unchanged too. A needs-human task stays there whatever the PR looks like; only a + // running task can move to in review, and only with a ready PR at the head it pushed. A PR showing another head + // (GitHub has not caught up, or someone else pushed) leaves the task running; the publisher makes it a draft and + // the next publish reconciles it. + if (mayReview && task.state_version === owned && this.#current(key).review_version === ownedReview && task.status === 'running' + && pr.headSha === head && !pr.draft) { + this.#run("UPDATE tasks SET status='in review' WHERE plan_key=?", key); + } + this.#touch(key); + return this.#task(key).status as TaskStatus; + }); + } + /** + * An abandoned opening whose PR became visible is the task's PR after all: record its number, URL and draft state so + * it can be reused, closed or cleaned up. The task status does not change. Guarded by the state version the caller + * read; returns the new state version. + */ + adoptOpening(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; draft: boolean }, expected: { stateVersion: number; reviewVersion: number }): number { + if (!Number.isSafeInteger(pr.number) || pr.number < 1 || typeof pr.url !== 'string') throw new Error('Invalid pull request.'); + const key = identityKey(identity); + return this.#transaction(() => { + this.#assertVersions(key, expected); + if (this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, updated_at=? WHERE plan_key=? AND opening_id=? AND state='abandoned'", + pr.number, pr.url, pr.draft ? 1 : 0, new Date().toISOString(), key, openingId).changes !== 1) throw new GuardRefusal('No abandoned opening with this ID.'); + this.#touch(key); + return this.#task(key).state_version as number; + }); + } + /** Whether the task can be published in this mode right now (status, no attempt, merge, requeue or rebase). */ + canPublish(identity: PlanIdentity, draft: boolean): boolean { + const key = identityKey(identity), task = this.#task(key); + try { this.#assertPublishable(key, task, task.state_version as number, draft); return true; } + catch (error) { if (error instanceof GuardRefusal) return false; throw error; } + } + /** The full publish guard at the current state version, before any GitHub call: refuse early, with its reason. */ + assertPublishableNow(identity: PlanIdentity, draft: boolean): void { + const key = identityKey(identity), task = this.#task(key); + this.#assertPublishable(key, task, task.state_version as number, draft); + } + /** The task, its review and its head are exactly as a publish read them before its last await. */ + assertUnchangedSince(identity: PlanIdentity, input: { stateVersion: number; reviewVersion: number; snapshotId: string; draft: boolean }): void { + const key = identityKey(identity); + this.#transaction(() => { + this.#assertPublishable(key, this.#task(key), input.stateVersion, input.draft); + const plan = this.#current(key); + if (plan.review_version !== input.reviewVersion) throw new GuardRefusal('The review changed after the check. Reload before writing.'); + if (plan.snapshot_id !== input.snapshotId) throw new GuardRefusal('The task head changed during the check.'); + }); + } + /** + * Records the draft state GitHub shows for the task's open PR. It also repairs a record whose draft change landed on + * GitHub but was never recorded (a crash or cancel right after the call). Guarded by the state version the caller + * read, so a task change is still noticed; returns the new state version. + */ + recordPullRequestDraft(identity: PlanIdentity, openingId: string, number: number, draft: boolean, expected: { stateVersion: number; reviewVersion: number }): number { + const key = identityKey(identity); + return this.#transaction(() => { + this.#assertVersions(key, expected); + if (this.#run("UPDATE task_pull_requests SET draft=?, updated_at=? WHERE plan_key=? AND opening_id=? AND state='opened' AND number=?", + draft ? 1 : 0, new Date().toISOString(), key, openingId, number).changes !== 1) throw new GuardRefusal('Unknown pull request.'); + this.#touch(key); + return this.#task(key).state_version as number; + }); + } + /** Recovery found no PR for an opening whose outcome was lost; a new check and opening follow. */ + abandonPullRequestOpening(identity: PlanIdentity, openingId: string): void { + const key = identityKey(identity); + this.#transaction(() => { + if (this.#run("UPDATE task_pull_requests SET state='abandoned', updated_at=? WHERE plan_key=? AND opening_id=? AND state='opening'", new Date().toISOString(), key, openingId).changes !== 1) + throw new GuardRefusal('No pull request is being opened with this ID.'); + this.#touch(key); + }); + } + // ---- F1d: startup recovery (runner-lifecycle.md, "Startup recovery") ---- /** * The per-database runner owner token, tied to the database file's device and inode. diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts new file mode 100644 index 00000000..c70ebc5f --- /dev/null +++ b/test/already-fixed.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { CHECK_KILL_GRACE_MS, CHECK_PIPE_GRACE_MS, DEFAULT_CHECK_DEADLINE_MS, GhAlreadyFixedGateway, MAX_BASE_COMMITS, mentionsIssue, type AlreadyFixedInput } from '../github/already-fixed.ts'; + +const sha = (n: number) => n.toString(16).padStart(40, '0'); +const repo = 'Owner/Repo'; +const pr = (number: number, state = 'OPEN', extra: Record = {}) => + ({ __typename: 'PullRequest', number, state, isDraft: false, baseRefName: 'main', repository: { nameWithOwner: repo }, ...extra }); +const cross = (source: unknown, willCloseTarget: unknown = true) => ({ __typename: 'CrossReferencedEvent', willCloseTarget, source }); +const issue = { __typename: 'Issue' }; +const connected = (subject: unknown, source: unknown = issue) => ({ __typename: 'ConnectedEvent', source, subject }); +const disconnected = (subject: unknown, source: unknown = issue) => ({ __typename: 'DisconnectedEvent', source, subject }); +const closed = (closer: unknown) => ({ __typename: 'ClosedEvent', closer }); + +interface Fake { defaultBranch?: unknown; state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; + commits?: { sha: string; message: string }[]; status?: string; totalCommits?: number; totalCommitsLater?: number; baseSha?: string; baseRef?: string; fail?: RegExp } +function gateway(fake: Fake = {}) { + const calls: string[][] = []; + const nodes = fake.nodes ?? []; + const commits = fake.commits ?? []; + const run = async (args: readonly string[]) => { + calls.push([...args]); + const joined = args.join(' '); + if (fake.fail?.test(joined)) throw new Error('HTTP 502'); + if (args[1] === 'graphql') return JSON.stringify({ + ...(fake.errors !== undefined ? { errors: fake.errors } : {}), + data: { repository: { nameWithOwner: fake.nameWithOwner ?? 'owner/repo', defaultBranchRef: fake.defaultBranch === undefined ? { name: 'main' } : fake.defaultBranch, issue: { state: fake.state ?? 'OPEN', + timelineItems: { totalCount: fake.totalCount ?? nodes.length, pageInfo: { hasNextPage: fake.hasNextPage ?? false }, nodes } } } }, + }); + if (joined.includes('/git/ref/heads/')) return JSON.stringify({ ref: `refs/heads/${fake.baseRef ?? 'main'}`, object: { sha: fake.baseSha ?? sha(99) } }); + const page = Number(/[?&]page=(\d+)/.exec(joined)![1]); + return JSON.stringify({ status: fake.status ?? 'ahead', total_commits: page > 1 && fake.totalCommitsLater !== undefined ? fake.totalCommitsLater : fake.totalCommits ?? commits.length, + commits: commits.slice((page - 1) * 100, page * 100).map(c => ({ sha: c.sha, commit: { message: c.message } })) }); + }; + return { calls, gh: new GhAlreadyFixedGateway({ repository: repo }, run) }; +} +const input = (over: Partial = {}): AlreadyFixedInput => + ({ issue: 12, taskBase: sha(1), baseBranch: 'main', ownPullRequests: [], ownCommits: new Set(), ...over }); + +describe('the timeline query', () => { + // The fake above answers any query, so the query text itself is checked here. Verified against GitHub with a default + // `gh auth login` token (scopes gist, read:org, repo, workflow). + it('reads both sides of a manual link and asks for no field that needs a scope beyond repo', async () => { + const { gh, calls } = gateway(); + await gh.check(input()); + const query = calls.find(args => args[1] === 'graphql')!.find(arg => arg.startsWith('query='))!.slice('query='.length); + for (const event of ['ConnectedEvent', 'DisconnectedEvent']) expect(query).toContain(`... on ${event} { source { ...Linked } subject { ...Linked } }`); + expect(query).toContain('fragment Linked on ReferencedSubject'); + expect(query).toContain('... on CrossReferencedEvent { willCloseTarget source {'); + expect(query).toContain('defaultBranchRef { name }'); + expect(query).toMatch(/on CrossReferencedEvent \{ willCloseTarget source \{[^}]*baseRefName/); + // A ProjectV2 closer is read by its type name only: any field on ProjectV2 needs the read:project scope, and GitHub + // then refuses the whole query. + expect(query).not.toMatch(/on ProjectV2/); + }); +}); + +describe('issue mentions in commit messages', () => { + it('matches this issue by number, GH- form, qualified name or URL, and nothing else', () => { + for (const message of ['Fix #12', 'fixes #12.', '(#12)', 'Resolve GH-12', 'fixes gh-12', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context', '', '(GH-12)']) + expect(mentionsIssue(message, repo, 12), message).toBe(true); + for (const message of ['Fix #123', 'Fix #1', 'other/repo#12', 'x#12', 'issue 12', 'https://github.com/owner/repo/issues/120', 'https://github.com/other/repo/issues/12', 'GH-120', 'owner/repo2#12', 'https://example.com/#12', 'XGH-12', 'foo-GH-12', '##12', + // Issue-like tokens inside URL paths or longer path-like tokens. + 'https://example.com/GH-12', 'https://example.com/github.com/Owner/Repo/issues/12', 'mirror/owner/repo#12', 'x.github.com/owner/repo/issues/12', 'example.com.GH-12']) + expect(mentionsIssue(message, repo, 12), message).toBe(false); + }); +}); + +describe('the pre-PR already-fixed check', () => { + it('is clear when nothing links, closes or mentions the issue, and reports the base head it read', async () => { + const { gh, calls } = gateway({ nodes: [cross({ __typename: 'Issue' })], commits: [{ sha: sha(5), message: 'Unrelated' }] }); + expect(await gh.check(input())).toEqual({ outcome: 'clear', baseHead: sha(99) }); + expect(calls.map(call => call.find(arg => arg.startsWith('repos/')) ?? call[1])).toEqual(['graphql', 'repos/Owner/Repo/git/ref/heads/main', `repos/Owner/Repo/compare/${sha(1)}...${sha(99)}?per_page=100&page=1`]); + }); + it('finds other open or merged PRs that link the issue, but not closed ones', async () => { + // A PR that only mentions the issue, here or in another repository, is not a link. + expect(await gateway({ nodes: [cross(pr(401), false), cross(pr(402, 'MERGED', { repository: { nameWithOwner: 'someone/else' } }), false)] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); + expect(await gateway({ nodes: [cross(pr(401), 'yes')] }).gh.check(input())).toMatchObject({ outcome: 'unknown' }); + }); + it('with a base that is not the default branch, also counts a PR here into that base that references the issue', async () => { + const develop = (number: number, over: Record = {}) => cross(pr(number, 'OPEN', { baseRefName: 'develop', ...over }), false); + const onDevelop = input({ baseBranch: 'develop' }); + // GitHub reports willCloseTarget false for any PR into a non-default branch, even with "Fixes #12". + expect(await gateway({ nodes: [develop(401)], baseRef: 'develop' }).gh.check(onDevelop)).toMatchObject({ outcome: 'found', matches: [{ number: 401 }] }); + expect(await gateway({ nodes: [develop(401, { state: 'MERGED' })], baseRef: 'develop' }).gh.check(onDevelop)).toMatchObject({ outcome: 'found', matches: [{ number: 401, state: 'MERGED' }] }); + // Not into that base, not in this repository, or the base is the default branch: a mention still is not a link. + expect(await gateway({ nodes: [develop(401, { baseRefName: 'main' })], baseRef: 'develop' }).gh.check(onDevelop)).toMatchObject({ outcome: 'clear' }); + expect(await gateway({ nodes: [develop(401, { repository: { nameWithOwner: 'someone/else' } })], baseRef: 'develop' }).gh.check(onDevelop)).toMatchObject({ outcome: 'clear' }); + expect(await gateway({ nodes: [develop(401)], baseRef: 'develop', defaultBranch: { name: 'develop' } }).gh.check(onDevelop)).toMatchObject({ outcome: 'clear' }); + // Fails closed without a readable default branch or PR base. + expect(await gateway({ nodes: [], baseRef: 'develop', defaultBranch: null }).gh.check(onDevelop)).toMatchObject({ outcome: 'unknown' }); + expect(await gateway({ nodes: [], baseRef: 'develop', defaultBranch: { name: '' } }).gh.check(onDevelop)).toMatchObject({ outcome: 'unknown' }); + expect(await gateway({ nodes: [develop(401, { baseRefName: 7 })], baseRef: 'develop' }).gh.check(onDevelop)).toMatchObject({ outcome: 'unknown' }); + const { gh } = gateway({ nodes: [cross(pr(401)), connected(pr(402, 'MERGED', { isDraft: false })), cross(pr(403, 'CLOSED')), cross(pr(401))] }); + expect(await gh.check(input())).toMatchObject({ outcome: 'found', matches: [ + { kind: 'pull request', repository: repo, number: 401, state: 'OPEN' }, { kind: 'pull request', number: 402, state: 'MERGED' }] }); + }); + it('replays manual links: a later disconnect removes a connected PR, a later connect restores it, a cross-reference stays', async () => { + expect(await gateway({ nodes: [connected(pr(401)), disconnected(pr(401))] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); + // A link made from the PR's side reports the PR as the source and the issue as the subject; it counts the same way. + expect(await gateway({ nodes: [connected(issue, pr(401))] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ number: 401 }] }); + expect(await gateway({ nodes: [connected(pr(401)), disconnected(issue, pr(401))] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); + // Two PRs, or a side of an unknown type, cannot be read as a link to this issue. + expect(await gateway({ nodes: [connected(pr(401), pr(402))] }).gh.check(input())).toMatchObject({ outcome: 'unknown' }); + expect(await gateway({ nodes: [connected(pr(401), { __typename: 'Discussion' })] }).gh.check(input())).toMatchObject({ outcome: 'unknown' }); + expect(await gateway({ nodes: [connected(pr(401)), disconnected(pr(401)), connected(pr(401))] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ number: 401 }] }); + expect(await gateway({ nodes: [cross(pr(401)), connected(pr(401)), disconnected(pr(401))] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ number: 401 }] }); + }); + it("ignores the task's own PR and earlier drafts by repository and number", async () => { + const { gh } = gateway({ nodes: [cross(pr(7)), cross(pr(8, 'OPEN', { isDraft: true }))] }); + expect(await gh.check(input({ ownPullRequests: [7, 8] }))).toMatchObject({ outcome: 'clear' }); + }); + it('does not exclude a PR in another repository that has the same number as an own PR', async () => { + const { gh } = gateway({ nodes: [cross(pr(7, 'OPEN', { repository: { nameWithOwner: 'fork/repo' } }))] }); + expect(await gh.check(input({ ownPullRequests: [7] }))).toMatchObject({ outcome: 'found', matches: [{ repository: 'fork/repo', number: 7 }] }); + }); + it("reports an issue closed by anything, including the task's own merged PR or its own commit on the default branch", async () => { + expect(await gateway({ state: 'CLOSED', nodes: [closed(null)] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed' }] }); + expect(await gateway({ state: 'CLOSED', nodes: [closed(pr(9))] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed', by: `${repo}#9` }] }); + // A close by the task's own PR means that PR merged: the issue is fixed, so it is a match, labelled as own. + expect(await gateway({ state: 'CLOSED', nodes: [closed(pr(9))] }).gh.check(input({ ownPullRequests: [9] }))).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed', by: `${repo}#9 (this task's own PR, already merged)` }] }); + expect(await gateway({ state: 'CLOSED', nodes: [closed({ __typename: 'Commit', oid: sha(3) })] }).gh.check(input({ ownCommits: new Set([sha(3)]) }))).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed', by: expect.stringContaining("this task's own commit") }] }); + // Only the latest close counts. + expect(await gateway({ state: 'CLOSED', nodes: [closed(null), closed(pr(9))] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed', by: `${repo}#9` }] }); + // A reopened issue does not count as closed. + expect(await gateway({ state: 'OPEN', nodes: [closed(null)] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); + }); + it("counts the task's own merged PR as a match, while its own open PR is still excluded", async () => { + expect(await gateway({ nodes: [cross(pr(7, 'MERGED'))] }).gh.check(input({ ownPullRequests: [7] }))).toMatchObject({ outcome: 'found', matches: [{ number: 7, state: 'MERGED' }] }); + expect(await gateway({ nodes: [cross(pr(7, 'OPEN'))] }).gh.check(input({ ownPullRequests: [7] }))).toMatchObject({ outcome: 'clear' }); + }); + it('cuts a long commit subject without splitting a surrogate pair', async () => { + // The second emoji straddles unit 200 (units 199–200), so a plain slice would split it. + const subject = `Fix #12 ${'a'.repeat(191)}😀😀😀`; + const result = await gateway({ commits: [{ sha: sha(5), message: subject }] }).gh.check(input()); + const cut = (result as { matches: { subject: string }[] }).matches[0]!.subject; + expect(cut.length).toBeLessThanOrEqual(200); + expect(cut).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/); + }); + it('treats an issue closed by a Projects workflow as closed by someone else, not as unreadable', async () => { + expect(await gateway({ state: 'CLOSED', nodes: [closed({ __typename: 'ProjectV2', number: 3 })] }).gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ kind: 'closed', by: 'a project workflow' }] }); + expect(await gateway({ state: 'CLOSED', nodes: [closed({ __typename: 'Mystery' })] }).gh.check(input())).toMatchObject({ outcome: 'unknown', reason: expect.stringMatching(/unknown closer/) }); + }); + it('runs the timeline and base-commit reads together, and a failure in one stops the other', async () => { + let timelineAborted = false; + const gh = new GhAlreadyFixedGateway({ repository: repo, deadlineMs: 10_000 }, async (args, options) => { + if (args[1] === 'graphql') return new Promise((_, reject) => options?.signal?.addEventListener('abort', () => { timelineAborted = true; reject(new Error('killed')); })); + throw new Error('HTTP 502'); + }); + const started = Date.now(); + expect(await gh.check(input())).toMatchObject({ outcome: 'unknown', reason: 'GitHub could not be read.' }); + expect(timelineAborted).toBe(true); + expect(Date.now() - started).toBeLessThan(5_000); + }); + it('finds new base-branch commits that mention the issue, except its own commits', async () => { + const commits = [{ sha: sha(5), message: 'Fix crash (#12)\n\nlong body' }, { sha: sha(6), message: 'P1: own change, refs #12' }, { sha: sha(7), message: 'Fix #123' }]; + expect(await gateway({ commits }).gh.check(input({ ownCommits: new Set([sha(6)]) }))).toMatchObject({ outcome: 'found', matches: [{ kind: 'commit', sha: sha(5), subject: 'Fix crash (#12)' }] }); + }); + it('reads every page of base commits', async () => { + const commits = Array.from({ length: 230 }, (_, i) => ({ sha: sha(1000 + i), message: i === 229 ? 'Late fix for #12' : 'Other' })); + const { gh, calls } = gateway({ commits }); + expect(await gh.check(input())).toMatchObject({ outcome: 'found', matches: [{ kind: 'commit', sha: sha(1229) }] }); + expect(calls.filter(call => call.some(arg => arg.includes('/compare/')))).toHaveLength(3); + }); + it('fails closed at once on an empty comparison page that should hold commits, without asking for more', async () => { + const { gh, calls } = gateway({ commits: [], totalCommits: 5 }); + expect(await gh.check(input())).toMatchObject({ outcome: 'unknown', reason: expect.stringMatching(/incomplete commit list/) }); + expect(calls.filter(call => call.some(arg => arg.includes('/compare/')))).toHaveLength(1); + }); + it('fails closed past every bound and on unreadable or inconsistent answers', async () => { + const cases: Fake[] = [ + { totalCount: 101 }, { hasNextPage: true }, { nodes: [cross(pr(1))], totalCount: 2 }, + { commits: Array.from({ length: MAX_BASE_COMMITS + 1 }, (_, i) => ({ sha: sha(2000 + i), message: 'x' })) }, { status: 'diverged' }, { status: 'behind' }, + { commits: [{ sha: sha(5), message: 'x' }], totalCommits: 2 }, + // A commit without a message, an invalid base head, and a non-integer commit count. + { commits: [{ sha: sha(5), message: undefined as unknown as string }] }, { baseSha: 'HEAD' }, { totalCommits: 1.5, commits: [{ sha: sha(5), message: 'x' }] }, + // Validation of the issue state, a linked PR's state and draft flag, and a closing commit's SHA. + { state: 'WEIRD' }, { nodes: [cross(pr(1, 'UNKNOWN'))] }, { nodes: [cross(pr(1, 'OPEN', { isDraft: 'no' }))] }, + { state: 'CLOSED', nodes: [closed({ __typename: 'Commit', oid: 'short' })] }, + // The base branch moved between pages (the count changed), and a page longer than the reported total. + { commits: Array.from({ length: 150 }, (_, i) => ({ sha: sha(3000 + i), message: 'x' })), totalCommitsLater: 151 }, + { commits: Array.from({ length: 160 }, (_, i) => ({ sha: sha(3000 + i), message: 'x' })), totalCommits: 120 }, + { commits: [{ sha: sha(5), message: 'x' }, { sha: sha(5), message: 'x' }] }, + { errors: [{ message: 'rate limited' }] }, { errors: { message: 'not a list' } }, { nameWithOwner: 'other/repo' }, { baseRef: 'other' }, + { state: 'CLOSED' }, { nodes: [cross(null)] }, { nodes: [cross({ __typename: 'Discussion' })] }, + { nodes: [cross(pr(1, 'OPEN', { repository: null }))] }, { nodes: [{ __typename: 'LabeledEvent' }] }, + { fail: /graphql/ }, { fail: /compare/ }, + ]; + for (const fake of cases) expect(await gateway(fake).gh.check(input()), JSON.stringify(fake)).toMatchObject({ outcome: 'unknown' }); + }); + it('gives the whole check one deadline and aborts the running call when it passes', async () => { + let aborted = false; + const gh = new GhAlreadyFixedGateway({ repository: repo, deadlineMs: 50 }, (_args, options) => new Promise((_, reject) => { + options?.signal?.addEventListener('abort', () => { aborted = true; reject(new Error('killed')); }); + })); + expect(await gh.check(input())).toEqual({ outcome: 'unknown', reason: 'The check did not finish within 1 s.' }); + expect(aborted).toBe(true); + }); + it('defaults to a deadline below the 15-second serving request budget', () => { + // The deadline plus the runner's SIGTERM and pipe grace periods stays below the budget. + expect(DEFAULT_CHECK_DEADLINE_MS + CHECK_KILL_GRACE_MS + CHECK_PIPE_GRACE_MS).toBeLessThan(15_000); + expect(new GhAlreadyFixedGateway({ repository: repo }).deadlineMs).toBe(DEFAULT_CHECK_DEADLINE_MS); + }); + it('returns only after the stopped stage has settled', async () => { + let timelineSettled = false; + const gh = new GhAlreadyFixedGateway({ repository: repo, deadlineMs: 10_000 }, async (args, options) => { + if (args[1] === 'graphql') return new Promise((_, reject) => options?.signal?.addEventListener('abort', () => setTimeout(() => { timelineSettled = true; reject(new Error('killed')); }, 100))); + throw new Error('HTTP 502'); + }); + expect(await gh.check(input())).toMatchObject({ outcome: 'unknown' }); + expect(timelineSettled).toBe(true); + }); + it('passes cancellation through instead of reporting it as unknown', async () => { + const controller = new AbortController(); + const gh = new GhAlreadyFixedGateway({ repository: repo }, async () => { controller.abort(); throw new Error('aborted'); }); + await expect(gh.check(input(), controller.signal)).rejects.toThrow('aborted'); + }); + it('refuses invalid input before calling GitHub', async () => { + const { gh, calls } = gateway(); + for (const bad of [{ issue: 0 }, { taskBase: 'HEAD' }, { baseBranch: '-x' }, { baseBranch: 'a..b' }, { ownPullRequests: [0] }]) + await expect(gh.check(input(bad as Partial))).rejects.toThrow(); + expect(calls).toEqual([]); + }); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts new file mode 100644 index 00000000..21e5018c --- /dev/null +++ b/test/publish.test.ts @@ -0,0 +1,2232 @@ +import { existsSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Store } from '../runner/store.ts'; +import { GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; +import { OpeningUnsettled, PullRequestPublisher, type BranchPusher, type PublishConfig } from '../runner/publish.ts'; +import { GH_ENV_ALLOWLIST, ghEnvironment } from '../github/gh-env.ts'; +import { CommandFailed, runWithInput } from '../github/run-with-input.ts'; +import { DraftsUnsupported, GhPullRequestGateway, PullRequestMisplaced, PullRequestRefused, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; +import type { AlreadyFixedGateway, AlreadyFixedInput, AlreadyFixedResult } from '../github/already-fixed.ts'; +import { fenced, neutralizeReferences, pullRequestBody, pullRequestTitle, MAX_BODY } from '../core/pull-request-body.ts'; +import type { Plan, PlanContext } from '../core/plan.ts'; + +const oid = (n: number) => n.toString(16).padStart(40, '0'); +const identity = { repositoryId: 'repo', taskId: 'Task_42', planId: 'plan' }; +const plan: Plan = { schema_version: 1, issue: 12, revision: 1, summary: 'Stop the crash', questions: [], items: [ + { id: 'P1', title: 'Guard input', intent: 'Reject empty input', files: [{ path: 'a.ts', kind: 'edit', renamed_from: null, change: 'Check it' }], acceptance: [{ type: 'cmd', text: 'npm test' }], depends_on: [] }] }; +const context: PlanContext = { identity, issue: 12, baseEntries: [{ path: 'a.ts', kind: 'file' }], pathKey: p => p, allowedCommands: [['npm', 'test']] }; +const config: PublishConfig = { repository: 'owner/repo', baseBranch: 'main' }; +const BRANCH = /^codeboost\/issue-12-task-42-[0-9a-f]{16}$/; + +/** A task whose last attempt settled while it runs, with head `oid(2)` over base `oid(1)` and one owned commit. */ +const stores: Store[] = []; +afterEach(() => { for (const store of stores.splice(0)) store.close(); }); +function runningTask(options: { head?: string } = {}) { + const store = new Store(':memory:'); + stores.push(store); + const head = options.head ?? oid(2); + store.createPlan(JSON.stringify(plan), 'json', context, oid(1), head); + if (head !== oid(1)) store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), head, [{ sha: head, owner: 'P1', origin: 'owned', sourceSha: null }]); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + expect(store.getTask(identity).status).toBe('running'); + return store; +} + +const baseOf = new WeakMap, Map>(); +/** `live` is GitHub's set of open PRs by marker; share it between harnesses to model later runs of the same task. */ +function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: (input: OpenPullRequestInput) => Promise; found?: OpenedPullRequest | null; + push?: BranchPusher['push']; live?: Map; next?: { value: number }; config?: Partial; draftAfterRefresh?: boolean; + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean; onDraft?: () => void; onRefresh?: () => void; closing?: () => boolean; onCheck?: () => void; moved?: Set; unmarked?: Set; onRead?: () => void; copies?: (OpenedPullRequest & { marker: string })[] } = {}) { + const publishConfig = { ...config, ...options.config }; + // The task's branch, as the publisher names it (one task per harness). + let publishBranch = ''; + const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); + const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; + // `copies`: PRs someone else opened from the task branch with a description starting with one of the task's markers. + // GitHub allows one open PR per branch and base, so a copy is into another base. + const copiesOf = (markers: readonly string[]) => (options.copies ?? []) + .filter(copy => markers.includes(copy.marker) && !(options.closed ?? new Set()).has(copy.number)).map(copy => ({ ...copy, base: 'copy-base' })); + // Each PR's base, by marker (like GitHub, a PR opened into a base stays there); unset means the configured base. + const bases = baseOf.get(live) ?? new Map(); baseOf.set(live, bases); + const results = options.results ? [...options.results] : []; + // Like GitHub, the default check reports every visible open PR on the branch (it links the issue) unless it is listed as own. + const gate: AlreadyFixedGateway = { async check(input) { + log.push('check'); checks.push(input); options.onCheck?.(); + if (options.results) return results.shift() ?? { outcome: 'clear', baseHead: oid(9) }; + const foreign = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && !input.ownPullRequests.includes(pr.number)); + return foreign.length ? { outcome: 'found', baseHead: oid(9), matches: foreign.map(([, pr]) => ({ kind: 'pull request' as const, repository: 'owner/repo', number: pr.number, state: 'OPEN' as const, draft: pr.draft })) } + : { outcome: 'clear', baseHead: oid(9) }; + } }; + const pulls: PullRequestGateway = { + async open(input) { + log.push(`open ${input.draft ? 'draft' : 'ready'}`); opened.push(input); + if (options.open) return options.open(input); + if (options.draftsUnsupported && input.draft) throw new DraftsUnsupported('no drafts'); + // Like GitHub: one open PR per branch and base. + if ([...live].some(([m, pr]) => !closed.has(pr.number) && (bases.get(m) ?? publishConfig.baseBranch) === input.base)) throw new PullRequestRefused('GitHub refused to open the pull request: A pull request already exists. (HTTP 422)'); + const pr = { number: counter.value++, url: 'https://github.com/owner/repo/pull/1', headSha: store.getSnapshot(identity).head, draft: input.draft }; + live.set(input.marker, pr); bases.set(input.marker, input.base); + if (options.openTimesOut) throw new Error('timeout'); + return pr; + }, + async findOpened(input) { + log.push(`find ${input.markers.join(' ')}`); options.onFind?.(); + if (options.found !== undefined) return options.found && { ...options.found, marker: input.markers.at(-1)! }; + // Like the adapter: at most one of the task's own PRs, and into this base; the branch's open PR into this base must + // be the task's; anyone else's PR into another base is ignored. + // A PR whose branch a person renamed (`moved`) is no longer listed under this branch. + const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && !options.moved?.has(pr.number)); + const baseOfPr = (m: string) => bases.get(m) ?? publishConfig.baseBranch; + // `unmarked`: a person removed the PR's first-line marker. + const carries = (m: string, pr: OpenedPullRequest) => !options.unmarked?.has(pr.number) && input.markers.includes(m); + const own = visible.filter(([m, pr]) => carries(m, pr)).map(([, pr]) => pr).concat(copiesOf(input.markers)); + if (own.length > 1) throw new PullRequestMisplaced(`More than one of the task's pull requests is open (${own.map(pr => `#${pr.number}`).join(', ')}).`); + const ownTuple = visible.find(([m, pr]) => carries(m, pr)); + if (own.length === 1 && ownTuple && baseOfPr(ownTuple[0]) !== input.base) throw new PullRequestMisplaced(`The task's pull request #${ownTuple[1].number} now targets ${baseOfPr(ownTuple[0])}, not ${input.base}.`); + const open = visible.find(([m]) => baseOfPr(m) === input.base); + if (!open) return null; + if (!carries(open[0], open[1]) && input.numbers?.includes(open[1].number)) throw new PullRequestMisplaced(`The task's pull request #${open[1].number} no longer starts with its marker. It may still be ready for review.`); + if (!carries(open[0], open[1])) throw new Error('An open pull request exists that codeboost did not open.'); + return { ...open[1], marker: open[0] }; + }, + async readPull(number) { + // The PR itself, whatever the list shows (a hidden PR models a list that lags behind); `moved` models a person + // renaming its branch. + log.push(`read ${number}`); options.onRead?.(); + // A PR a test removed from `live` was closed (GitHub keeps every PR it ever had). + const [m] = [...live].find(([, pr]) => pr.number === number) ?? [undefined]; + if (m === undefined) return { open: false, headBranch: publishBranch, base: publishConfig.baseBranch, marker: '' }; + return { open: !closed.has(number), headBranch: options.moved?.has(number) ? 'renamed-by-a-person' : publishBranch, base: bases.get(m) ?? publishConfig.baseBranch, marker: options.unmarked?.has(number) ? '' : m }; + }, + async findOwned(input) { + log.push(`owned ${input.markers.join(' ')}`); options.onFind?.(); + if (options.found !== undefined) return options.found ? [{ ...options.found, marker: input.markers.at(-1)!, base: publishConfig.baseBranch }] : []; + // Copies first: GitHub's order is not defined, and a copy listed first must not be taken for the task's PR. + return copiesOf(input.markers).concat([...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && !options.moved?.has(pr.number) && !options.unmarked?.has(pr.number) && input.markers.includes(m)) + .map(([m, pr]) => ({ ...pr, marker: m, base: bases.get(m) ?? publishConfig.baseBranch }))); + }, + async markDraft(number, input) { + log.push(`draft ${number}`); + // Like the adapter's read-back: the PR must be from the branch and into the base asked for. + if ((bases.get(input.marker) ?? input.base) !== input.base) throw new Error('GitHub returned a pull request for a different branch.'); + const current = live.get(input.marker); + // The read-back needs the marker on the first line: an unmarked PR fails it. + if (current && options.unmarked?.has(current.number)) throw new Error('GitHub returned a different pull request.'); + if (current && input.headBranch !== (options.moved?.has(current.number) ? 'renamed-by-a-person' : publishBranch)) throw new Error('GitHub returned a pull request for a different branch.'); + options.onDraft?.(); + if (options.draftsUnsupported) throw new DraftsUnsupported('no drafts'); + if (options.draftFails) throw new Error('timeout marking the PR a draft'); + const pr = { ...live.get(input.marker)!, draft: true }; live.set(input.marker, pr); return pr; + }, + async refresh(number, input) { + log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); + if ((bases.get(input.marker) ?? input.base) !== input.base) throw new Error('GitHub returned a pull request for a different branch.'); + options.onRefresh?.(); + input.beforeReady?.(); + if (options.refreshFails) throw new Error('timeout reading the PR back'); + if (options.draftsUnsupported && input.draft) throw new DraftsUnsupported('no drafts'); + if (closed.has(number)) throw new Error(`Pull request #${number} is not open.`); + const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; + live.set(input.marker, pr); + // Like the adapter's read-back: a draft or ready change GitHub has not applied fails the refresh. + if (input.draft && !pr.draft) throw new Error('GitHub did not turn the pull request into a draft.'); + if (input.ready && pr.draft) throw new Error('GitHub did not mark the pull request ready.'); + return pr; + }, + }; + const pusher: BranchPusher = { async push(id, input, signal) { log.push(`push ${input.branch.replace(/-[0-9a-f]{16}$/, '')} ${input.head.slice(-3)}`); await options.push?.(id, input, signal); } }; + const publisher = new PullRequestPublisher(store, { checks: gate, pulls, pusher, closing: options.closing }, publishConfig); + publishBranch = publisher.branch(identity); + return { log, checks, opened, pulls, publisher }; +} + +describe('opening the task PR', () => { + it('checks, pushes, then opens the PR and moves the task to in review', async () => { + const store = runningTask(); + const { publisher, log, checks, opened } = harness(store); + const outcome = await publisher.publish(identity); + expect(outcome).toMatchObject({ kind: 'opened', number: 100, draft: false, status: 'in review' }); + expect(log).toEqual(['find ', 'check', 'push codeboost/issue-12-task-42 002', 'open ready']); + expect(checks[0]).toMatchObject({ issue: 12, taskBase: oid(1), baseBranch: 'main', ownPullRequests: [] }); + expect([...checks[0]!.ownCommits]).toEqual([oid(2)]); + expect(opened[0]).toMatchObject({ base: 'main', headBranch: expect.stringMatching(BRANCH), title: 'Stop the crash (#12)' }); + expect(opened[0]!.body).toContain(opened[0]!.marker); + expect(store.getTask(identity).status).toBe('in review'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, headSha: oid(2), draft: false }]); + expect(store.latestAlreadyFixed(identity)).toMatchObject({ result: { outcome: 'clear' } }); + }); + it('opens nothing and moves to possibly already fixed on a match', async () => { + const store = runningTask(); + const result: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'pull request', repository: 'owner/repo', number: 401, state: 'OPEN', draft: false }] }; + const { publisher, log } = harness(store, { results: [result] }); + expect(await publisher.publish(identity)).toEqual({ kind: 'possibly already fixed', result }); + expect(log).toEqual(['find ', 'check']); + expect(store.getTask(identity).status).toBe('possibly already fixed'); + expect(store.taskPullRequests(identity)).toEqual([]); + expect(store.latestAlreadyFixed(identity)!.result).toEqual(result); + }); + it('fails closed: a check that could not be completed also opens nothing', async () => { + const store = runningTask(); + const { publisher, log } = harness(store, { results: [{ outcome: 'unknown', reason: 'GitHub could not be read.' }] }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + expect(log).toEqual(['find ', 'check']); + expect(store.getTask(identity).status).toBe('possibly already fixed'); + }); + it('opens a draft PR with the open problems for a needs-human task, and keeps it in needs human', async () => { + const store = runningTask(); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const { publisher, opened } = harness(store); + expect(await publisher.publish(identity, { problems: ['Review round 3: @someone Fixes #99 still fails'] })).toMatchObject({ kind: 'opened', draft: true, status: 'needs human' }); + expect(opened[0]).toMatchObject({ draft: true }); + expect(opened[0]!.body).toMatch(/```text\nReview round 3: @someone Fixes #99 still fails\n```/); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('skips the draft on a match and leaves the task in needs human', async () => { + const store = runningTask(); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const { publisher, log } = harness(store, { results: [{ outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }] }); + expect(await publisher.publish(identity, { problems: ['x'] })).toMatchObject({ kind: 'draft skipped' }); + expect(log).toEqual(['find ', 'check']); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('opens no PR when the task changed nothing, and moves it to needs human', async () => { + const store = runningTask({ head: oid(1) }); + const { publisher, log } = harness(store); + expect(await publisher.publish(identity)).toEqual({ kind: 'no changes' }); + // The branch is looked up first (a foreign PR there would be refused); nothing is checked, pushed or opened. + expect(log).toEqual(['find ']); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('turns the earlier ready PR into a draft when a rerun changes nothing', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + // The task is sent back, reruns, and its head goes back to its base. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toEqual({ kind: 'no changes' }); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('refuses to open when the task changed after the check (a cancel during the push)', async () => { + const store = runningTask(); + const { publisher, log } = harness(store, { push: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); } }); + await expect(publisher.publish(identity)).rejects.toThrow(GuardRefusal); + expect(log).not.toContain('open ready'); + expect(store.taskPullRequests(identity)).toEqual([]); + }); + it('refuses to open when anything else about the task changed after the check, such as its assignment', async () => { + const store = runningTask(); + const { publisher, log } = harness(store, { push: async () => { store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); } }); + await expect(publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(log).not.toContain('open ready'); + expect(store.getTask(identity).status).toBe('running'); + }); + it('refuses to open the PR when a review note is added during the push', async () => { + const store = runningTask(); + const { publisher, log } = harness(store, { push: async () => { + store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + } }); + await expect(publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(log.some(line => line.startsWith('open'))).toBe(false); + expect(store.taskPullRequests(identity)).toEqual([]); + }); + it('refuses a PR on the branch that codeboost did not open before pushing, even when the task has no PR records', async () => { + const store = runningTask(), live = new Map(); + live.set('', { number: 77, url: 'https://github.com/owner/repo/pull/77', headSha: oid(8), draft: false }); + const { publisher, log } = harness(store, { live }); + await expect(publisher.publish(identity)).rejects.toThrow(/did not open/); + expect(log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + }); + it('refuses to open when the head moved during the check', async () => { + const store = runningTask(); + const gate: AlreadyFixedGateway = { async check() { + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(3), [{ sha: oid(3), owner: 'P1', origin: 'owned', sourceSha: null }]); + return { outcome: 'clear', baseHead: oid(9) }; + } }; + const opened: string[] = []; + const publisher = new PullRequestPublisher(store, { checks: gate, pusher: { async push() {} }, + pulls: { async open() { opened.push('open'); throw new Error('unreachable'); }, async findOpened() { return null; }, async findOwned() { return []; }, async readPull() { throw new Error('unreachable'); }, async refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); + await expect(publisher.publish(identity)).rejects.toThrow(GuardRefusal); + expect(opened).toEqual([]); + }); + it('refuses to publish a task that is not running', async () => { + const store = runningTask(); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + await expect(harness(store).publisher.publish(identity)).rejects.toThrow(/cannot be opened while the task is queued/); + }); + it('keeps the branches of tasks whose IDs normalize alike apart', () => { + const store = runningTask(); + const other = { ...identity, taskId: 'task-42' }; + store.createPlan(JSON.stringify(plan), 'json', { ...context, identity: other }, oid(1), oid(2)); + const publisher = harness(store).publisher; + expect(publisher.branch(identity)).toMatch(BRANCH); + expect(publisher.branch(other)).not.toBe(publisher.branch(identity)); + expect(publisher.branch(other)).toMatch(BRANCH); + }); + it('checks the task status before the no-changes shortcut', async () => { + const queuedTask = runningTask({ head: oid(1) }); + queuedTask.transitionTask(identity, queuedTask.getTask(identity).stateVersion, 'queued'); + await expect(harness(queuedTask).publisher.publish(identity)).rejects.toThrow(/while the task is queued/); + expect(queuedTask.getTask(identity).status).toBe('queued'); + const cancelled = runningTask({ head: oid(1) }); + cancelled.cancelTask(identity, cancelled.getTask(identity).stateVersion, crypto.randomUUID()); + await expect(harness(cancelled).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(/while the task is cancelled/); + }); + it('records a PR whose response arrives after the task changed, without letting it move the task', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, open: async input => { + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); + const pr = { number: 6, url: 'https://github.com/owner/repo/pull/6', headSha: oid(2), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + // Recorded, the task not moved, and the PR made a draft: the task is not in review. + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 6, status: 'running', draft: true }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 6, draft: true }]); + }); + it('keeps the task running and makes the PR a draft when GitHub shows another head than the one pushed', async () => { + const store = runningTask(), live = new Map(); + const { publisher, log } = harness(store, { live, open: async input => { + const pr = { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }; + live.set(input.marker, pr); return pr; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 5, draft: true, status: 'running' }); + expect(log.at(-1)).toBe('draft 5'); + // The record keeps the head GitHub reported, not the head that was pushed. + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5, draft: true, headSha: oid(77) }]); + expect(store.getTask(identity).status).toBe('running'); + }); +}); + +describe('schema v7', () => { + it('upgrades a populated v6 database: adds the check and PR tables, keeps the tasks', async () => { + const { mkdtempSync, rmSync } = await import('node:fs'), { tmpdir } = await import('node:os'), { join } = await import('node:path'); + const { DatabaseSync } = await import('node:sqlite'); + const dir = mkdtempSync(join(tmpdir(), 'codeboost-v7-')), path = join(dir, 'state.sqlite'); + try { + const first = new Store(path); + first.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + first.transitionTask(identity, first.getTask(identity).stateVersion, 'queued'); + const before = first.getTask(identity); + first.close(); + const legacy = new DatabaseSync(path); + legacy.exec('DROP TABLE task_pull_requests; DROP TABLE already_fixed_checks; PRAGMA user_version=6;'); + legacy.close(); + const upgraded = new Store(path); + stores.push(upgraded); + expect(upgraded.getTask(identity)).toMatchObject({ status: 'queued', stateVersion: before.stateVersion }); + expect(upgraded.taskPullRequests(identity)).toEqual([]); + expect(upgraded.latestAlreadyFixed(identity)).toBeNull(); + const db = new DatabaseSync(path, { readOnly: true }); + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 7 }); + const names = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index') AND (name LIKE '%pull_requests%' OR name LIKE 'already_fixed_checks%') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); + expect(names).toEqual(['already_fixed_checks', 'already_fixed_checks_task', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening', 'task_pull_requests_task']); + db.close(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); + +describe('review changes during GitHub calls', () => { + const note = (store: Store) => store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + it('refuses to record a matching check when the review changed during the draft change', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + await expect(harness(store, { live, next, results: [found], onDraft: () => note(store) }).publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(store.getTask(identity).status).toBe('running'); + }); + it('records a PR opened while the review changed, but does not move the task to in review', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, open: async input => { + note(store); const pr = { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(2), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + // The task is not in review, so its PR is not left ready for review either. + const outcome = await publisher.publish(identity); + expect(outcome).toMatchObject({ kind: 'opened', number: 5, status: 'running', draft: true }); + expect(outcome).not.toHaveProperty('leftReady'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5, draft: true }]); + }); + it('makes no ready change when the review changed during the description update, and keeps the update in flight', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, onRefresh: () => note(store) }).publisher.publish(identity)).rejects.toThrow(/review changed/); + expect([...live.values()][0]!.draft).toBe(true); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: { head: oid(3) } }]); + expect(store.getTask(identity).status).toBe('running'); + }); + it('does not end a publish with a recovered opening whose review has changed since', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + note(store); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + // The recovered opening was recorded, then publishing continued: a new check and a refresh under current review state. + expect(again.log).toContain('check'); + expect(again.log.at(-1)).toBe('refresh 100 ready'); + }); +}); + +describe('guards found by the independent review', () => { + const note = (store: Store) => store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + it('does not draft the PR when the review changed during the check', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + const gate = harness(store, { live, next }); + const publisher = new PullRequestPublisher(store, { checks: { async check() { note(store); return found; } }, pulls: gate.pulls, pusher: { async push() {} } }, config); + await expect(publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(gate.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('does not update the PR when the review changed during the push', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + const again = harness(store, { live, next, push: async () => { note(store); } }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(again.log.some(line => line.startsWith('refresh'))).toBe(false); + }); + it('does not adopt or repair records when the review changed during the lookup', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + rerun(store); + await expect(harness(store, { live, next, onFind: () => note(store) }).publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false }]); + }); + it('records what GitHub shows for an update whose confirmation was lost, then settles it', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + // The ready update lands on GitHub, but its read-back fails. + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false, headSha: oid(3) }); + // Then the task is cancelled: the next publish refuses on status, but first records what GitHub shows. + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const settled = harness(store, { live, next }); + await expect(settled.publisher.publish(identity)).rejects.toThrow(/cancelled/); + // The landed ready update is recorded, then the PR is made a draft: the task is cancelled, so it must not stay ready. + expect(settled.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, headSha: oid(3), refresh: null }]); + }); + it('refuses to publish while an attempt is active', async () => { + const store = runningTask(); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + expect(attempt).toBeTruthy(); + const { publisher, log } = harness(store); + await expect(publisher.publish(identity)).rejects.toThrow(/attempt is still active/); + expect(log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + }); + it('refuses to publish a task with interrupted work waiting to be requeued, or with a rebase in progress', async () => { + const { mkdtempSync, rmSync } = await import('node:fs'), { tmpdir } = await import('node:os'), { join } = await import('node:path'); + const { DatabaseSync } = await import('node:sqlite'); + for (const column of ["requeue_pending=1", "rebase_in_progress='{}'"]) { + const dir = mkdtempSync(join(tmpdir(), 'codeboost-guard-')), path = join(dir, 'state.sqlite'); + try { + const first = new Store(path); + first.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + first.recordHistory(identity, { revision: 1, snapshotId: first.getSnapshot(identity).id }, oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]); + first.close(); + const db = new DatabaseSync(path); db.exec(`UPDATE tasks SET status='running', ${column}`); db.close(); + const store = new Store(path); stores.push(store); + const { publisher, log } = harness(store); + await expect(publisher.publish(identity)).rejects.toThrow(/requeued|rebase is in progress/); + expect(log.some(line => line.startsWith('push'))).toBe(false); + } finally { rmSync(dir, { recursive: true, force: true }); } + } + }); + it('refuses the no-changes path for interrupted work waiting to be requeued, instead of sending it to a person', async () => { + const { mkdtempSync, rmSync } = await import('node:fs'), { tmpdir } = await import('node:os'), { join } = await import('node:path'); + const { DatabaseSync } = await import('node:sqlite'); + const dir = mkdtempSync(join(tmpdir(), 'codeboost-guard-')), path = join(dir, 'state.sqlite'); + try { + const first = new Store(path); + first.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(1)); + first.close(); + const db = new DatabaseSync(path); db.exec("UPDATE tasks SET status='running', requeue_pending=1"); db.close(); + const store = new Store(path); stores.push(store); + await expect(harness(store).publisher.publish(identity)).rejects.toThrow(/requeued/); + expect(store.getTask(identity).status).toBe('running'); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + it('moves a no-changes task to needs human and reports a ready PR it could not draft', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + expect(await harness(store, { live, next, draftsUnsupported: true }).publisher.publish(identity)).toEqual({ kind: 'no changes', leftReady: 100 }); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('refuses a PR-number mismatch before the no-changes draft change', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + for (const [m, pr] of live) live.set(m, { ...pr, number: 999 }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/different pull request/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('reports a PR it could not draft after a head mismatch, without failing the publish that opened it', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, draftFails: true, open: async input => { + const pr = { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 5, draft: false, status: 'running', leftReady: 5 }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5, headSha: oid(77) }]); + }); + it('does not take another PR carrying the marker as what GitHub shows for an unconfirmed update', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + // The PR was closed and a person opened another with the same description: nothing about #100 is taken from it. + const [m, pr] = [...live][0]!; + const copy = { ...pr, number: 999, draft: false, headSha: oid(8), marker: m }; + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + await expect(harness(store, { live, next, copies: [copy], closed: new Set([pr.number]) }).publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, headSha: oid(2), refresh: null }]); + }); + it('reports drafts unsupported when a lost draft opening cannot be turned back into a draft', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + expect(await harness(store, { live, next, draftsUnsupported: true }).publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: 100 }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }]); + }); + it('refuses to recover an opening recorded for another repository', async () => { + const store = runningTask(); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + store.beginPullRequest(identity, { checkId: check.id, repository: 'other/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + const { publisher, log } = harness(store); + await expect(publisher.publish(identity)).rejects.toThrow(/another repository/); + expect(log).toEqual([]); + }); +}); + +describe('independent review round 3', () => { + const note = (store: Store) => store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + const toBase = (store: Store) => { + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + }; + it('makes a recovered ready PR a draft when its task was cancelled meanwhile', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + }); + it('re-reads the task after a refused draft change on the no-changes path', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + toBase(store); + await expect(harness(store, { live, next, draftsUnsupported: true, onDraft: () => note(store) }).publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(store.getTask(identity).status).toBe('running'); + }); + it('propagates a Store failure after a head-mismatch draft change that landed, instead of reporting a ready PR', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, onDraft: () => store.closeWrites(), open: async input => { + const pr = { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + await expect(publisher.publish(identity)).rejects.toThrow(); + }); + it('does not end a publish with a lost opening of the other mode, even with unchanged versions', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + // A draft publish now: the lost ready opening is recorded (its own versions are unchanged, so the task moves to in + // review), but it does not end this publish, which continues and refuses on status. + await expect(harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(/draft pull request cannot be opened while the task is in review/); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }]); + expect(store.getTask(identity).status).toBe('in review'); + // A task in review keeps its ready PR. + expect(store.taskPullRequests(identity)).toMatchObject([{ draft: false }]); + expect([...live.values()][0]!.draft).toBe(false); + }); + it('does not move the task to in review when a recovered ready opening is now a draft', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + expect(await harness(store, { live, next }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); + }); + it('stops after an abort during the refresh push, before any change to the PR', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + const controller = new AbortController(); + const again = harness(store, { live, next, push: async () => { controller.abort(new Error('stop')); } }); + await expect(again.publisher.publish(identity, {}, controller.signal)).rejects.toThrow('stop'); + expect(again.log.some(line => line.startsWith('refresh'))).toBe(false); + }); +}); + +describe('Store guards the review listed as untested', () => { + const clear = (store: Store) => store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + it('allows only one opening in flight per task', () => { + const store = runningTask(); + store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + // A fresh clear check, so only the one-opening rule can refuse. + expect(() => store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false })).toThrow(/already being opened/); + }); + it('confirms a refresh only for the head and version it recorded', () => { + const store = runningTask(); + const opening = store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: false }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'running' === 'running' ? 'queued' : 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const version = store.beginRefresh(identity, { checkId: clear(store).id, openingId: opening.openingId, headSha: oid(2), draft: false }); + const pr = { number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: false }; + expect(() => store.recordRefreshConfirmed(identity, opening.openingId, pr, { head: oid(3), stateVersion: version })).toThrow(/No update/); + expect(() => store.recordRefreshConfirmed(identity, opening.openingId, pr, { head: oid(2), stateVersion: version + 1 })).toThrow(/No update/); + expect(store.recordRefreshConfirmed(identity, opening.openingId, pr, { head: oid(2), stateVersion: version })).toBe('in review'); + }); +}); + +describe('independent review round 4', () => { + const clear = (store: Store, draft = false) => store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft, result: { outcome: 'clear', baseHead: oid(9) } }); + it('makes a recovered ready PR a draft when its running task has an attempt active', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false, headSha: oid(3) }); + store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/attempt is still active/); + expect(again.log).toContain('draft 100'); + // The full guard refuses before the check runs. + expect(again.log).not.toContain('check'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: null }]); + }); + it('refuses a pending update recorded for another repository, instead of clearing it unseen', async () => { + const store = runningTask(); + const opening = store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'other/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/other/repo/pull/7', headSha: oid(2), draft: false }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.beginRefresh(identity, { checkId: clear(store).id, openingId: opening.openingId, headSha: oid(2), draft: false }); + const { publisher, log } = harness(store); + await expect(publisher.publish(identity)).rejects.toThrow(/in flight in another repository/); + expect(log).toEqual([]); + expect(store.taskPullRequests(identity)[0]!.refresh).not.toBeNull(); + }); + it("does not pass another repository's PR numbers to the check as the task's own", async () => { + const store = runningTask(); + const opening = store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'other/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/other/repo/pull/7', headSha: oid(2), draft: false }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const { publisher, checks } = harness(store); + await publisher.publish(identity); + expect(checks[0]!.ownPullRequests).toEqual([]); + }); + it('does not take a record for another base as the branch PR', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next, config: { baseBranch: 'develop' } }).publisher.publish(identity); + rerun(store); + // Now publishing into main: the task's own PR still targets develop, so a person has to retarget or close it; no + // second PR is opened from the same branch. + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/#100 now targets develop, not main/); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + }); + it('stops after an abort during the branch lookup, before the check', async () => { + const store = runningTask(), controller = new AbortController(); + const { publisher, log } = harness(store, { onFind: () => controller.abort(new Error('stop')) }); + await expect(publisher.publish(identity, {}, controller.signal)).rejects.toThrow('stop'); + expect(log).not.toContain('check'); + }); + it('requires the latest check to be clear before an opening', () => { + const store = runningTask(); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: true, result: { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'x' }] } }); + expect(() => store.beginPullRequest(identity, { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: true })).toThrow(/clear already-fixed check/); + }); + it('matches the PR number when confirming a refresh or recording a draft flag', () => { + const store = runningTask(); + const opening = store.beginPullRequest(identity, { checkId: clear(store).id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + store.recordPullRequestOpened(identity, opening.openingId, { number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: false }); + const versions = () => ({ stateVersion: store.getTask(identity).stateVersion, reviewVersion: store.reviewVersion(identity) }); + expect(() => store.recordPullRequestDraft(identity, opening.openingId, 8, true, versions())).toThrow(/Unknown pull request/); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const version = store.beginRefresh(identity, { checkId: clear(store).id, openingId: opening.openingId, headSha: oid(2), draft: false }); + expect(() => store.recordRefreshConfirmed(identity, opening.openingId, { number: 8, url: 'u', headSha: oid(2), draft: false }, { head: oid(2), stateVersion: version })).toThrow(/No update/); + }); +}); + +describe('independent review round 5', () => { + const note = (store: Store) => store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + it('adopts and drafts an abandoned opening\'s PR found during recovery of a cancelled task', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); + hidden.clear(); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, next, config: later }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }, { state: 'abandoned' }]); + }); + it('makes no ready change when the task changes during the description update', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, onRefresh: () => store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()) }).publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect([...live.values()][0]!.draft).toBe(true); + }); + it('calls beforeReady between the PATCH and any ready change, and makes none if it throws', async () => { + const calls: string[] = []; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + calls.push(args[0] === 'pr' ? 'ready' : args.includes('PATCH') ? 'patch' : 'get'); + return args[0] === 'pr' ? '' : JSON.stringify({ number: 7, html_url: 'https://github.com/owner/repo/pull/7', state: 'open', draft: true, + body: '\nplan', head: { sha: oid(2), ref: 'codeboost/issue-12-task', repo: { full_name: 'owner/repo' } }, base: { ref: 'main', repo: { full_name: 'owner/repo' } } }); + }); + const marker = ''; + await expect(gh.refresh(7, { base: 'main', headBranch: 'codeboost/issue-12-task', title: 'T', body: `${marker}\nplan`, draft: false, ready: true, marker, + beforeReady: () => { throw new GuardRefusal('Stale task state.'); } })).rejects.toThrow(/Stale/); + expect(calls).toEqual(['patch']); + }); + it('refuses the no-changes path when the review changed during the lookup', async () => { + const store = runningTask({ head: oid(1) }); + await expect(harness(store, { onFind: () => note(store) }).publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(store.getTask(identity).status).toBe('running'); + }); + it('does not draft a recovered ready PR whose running task the main path will publish', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false, headSha: oid(3) }); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + }); +}); + +describe('PR records', () => { + it('keeps the record of a PR that opened after the task was cancelled, without reopening the task', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, open: async input => { + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const pr = { number: 9, url: 'https://github.com/owner/repo/pull/9', headSha: oid(2), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 9, status: 'cancelled', draft: true }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 9, draft: true }]); + }); + it('refreshes only an opened PR: an abandoned opening must be adopted first', async () => { + const store = runningTask(); + const clear = () => store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + const opening = store.beginPullRequest(identity, { checkId: clear().id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }); + store.abandonPullRequestOpening(identity, opening.openingId); + expect(() => store.beginRefresh(identity, { checkId: clear().id, openingId: opening.openingId, headSha: oid(2), draft: false })).toThrow(/Unknown pull request/); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned', number: null, refresh: null }]); + }); + it('binds an opening to the checked head, with no task change since the check', async () => { + const store = runningTask(); + const check = store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + const pr = { checkId: check.id, repository: 'owner/repo', base: 'main', headBranch: 'b', headSha: oid(2), draft: false }; + expect(() => store.beginPullRequest(identity, { ...pr, headSha: oid(3) })).toThrow(/head changed/); + expect(() => store.recordAlreadyFixed(identity, store.getTask(identity).stateVersion, { snapshotId: 'old', reviewVersion: store.reviewVersion(identity), draft: false, result: { outcome: 'clear', baseHead: oid(9) } })).toThrow(/head changed/); + store.beginPullRequest(identity, pr); + expect(() => store.beginPullRequest(identity, pr)).toThrow(GuardRefusal); + }); +}); + +describe('a repository without draft PRs', () => { + it('opens no PR for a needs-human task, drops the opening, and says why', async () => { + const store = runningTask(); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const { publisher } = harness(store, { draftsUnsupported: true }); + expect(await publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: null }); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned']); + expect(store.getTask(identity).status).toBe('needs human'); + // Nothing is left in flight: the next publish does not wait for a settle time. + expect(await harness(store, { draftsUnsupported: true }).publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: null }); + }); + it('leaves the existing ready PR exactly as it is: the draft refusal comes before any push or description change', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const again = harness(store, { live, next, draftsUnsupported: true }); + expect(await again.publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: 100 }); + // The draft step comes first, so the refusal leaves the PR exactly as it was: no push, no new description. + expect(again.log.some(line => line.startsWith('push') || line.startsWith('refresh'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(2) }]); + }); + it('reports a ready PR it could not make a draft when the check matches', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + expect(await harness(store, { live, next, results: [found], draftsUnsupported: true }).publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed', leftReady: 100 }); + expect(store.getTask(identity).status).toBe('possibly already fixed'); + }); +}); + +describe('recovering a lost opening', () => { + it('adopts the PR GitHub has for the recorded marker, without opening another', async () => { + const store = runningTask(); + const first = harness(store, { open: async () => { throw new Error('timeout'); } }); + await expect(first.publisher.publish(identity)).rejects.toThrow('timeout'); + const [lost] = store.taskPullRequests(identity); + expect(lost).toMatchObject({ state: 'opening' }); + const second = harness(store, { found: { number: 55, url: 'https://github.com/owner/repo/pull/55', headSha: oid(2), draft: false } }); + expect(await second.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 55, status: 'in review' }); + expect(second.log).toEqual([`owned `]); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 55 }]); + }); + it('turns a lost draft opening back into a draft when the PR it finds was made ready meanwhile', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ kind: 'opened', number: 100, draft: true }); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('records a recovered opening from an older run, then continues the current publish with the new head and mode', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + // A needs-human draft POST lands, but its answer is lost. + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + // The person sends the task back; it reruns (new head oid3) and now publishes as ready. + rerun(store); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, draft: false, status: 'in review' }); + expect(again.log.filter(line => !line.startsWith('find') && !line.startsWith('owned'))).toEqual(['check', 'push codeboost/issue-12-task-42 003', 'refresh 100 ready']); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: false, headSha: oid(3) }]); + }); + it('keeps an unconfirmed opening owned until it settles, then abandons it, checks again and opens a new PR', async () => { + const store = runningTask(); + await expect(harness(store, { open: async () => { throw new Error('timeout'); } }).publisher.publish(identity)).rejects.toThrow('timeout'); + // Within the settle time an empty lookup proves nothing: the opening stays owned and nothing is posted. + const early = harness(store); + await expect(early.publisher.publish(identity)).rejects.toThrow(OpeningUnsettled); + expect(early.log).toEqual([expect.stringMatching(/^owned /)]); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['opening']); + const second = harness(store, { config: { now: () => Date.now() + 10 * 60_000 } }); + expect(await second.publisher.publish(identity)).toMatchObject({ kind: 'opened', status: 'in review' }); + // The abandoned opening is still looked up by its marker, in case its PR appears later. + expect(second.log).toEqual([expect.stringMatching(/^owned /), expect.stringMatching(/^find /), 'check', 'push codeboost/issue-12-task-42 002', 'open ready']); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'opened']); + }); + it('reuses the still-open draft for the next run: updates it and marks it ready instead of opening a second PR', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, draft: false, status: 'in review' }); + expect(again.checks[0]!.ownPullRequests).toEqual([100]); + const [draft] = store.taskPullRequests(identity); + expect(again.log).toEqual([`find `, 'check', 'push codeboost/issue-12-task-42 003', 'refresh 100 ready']); + expect(again.opened[0]!.body).not.toContain('Needs human'); + // The refresh waits for GitHub to show the pushed head. + expect(again.opened[0]).toMatchObject({ headSha: oid(3) }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, headSha: oid(3), state: 'opened' }]); + }); + it('keeps a needs-human task in needs human when it reuses its earlier ready PR, and turns that PR back into a draft', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + expect(store.getTask(identity).status).toBe('in review'); + rerun(store); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity, { problems: ['still failing'] })).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'needs human' }); + expect(again.log.at(-1)).toBe('refresh 100 draft'); + expect(store.getTask(identity).status).toBe('needs human'); + // If GitHub does not apply the draft change, the refresh fails and stays in flight; the task never moves to in + // review, and the next publish settles the update and makes the PR a draft. + rerun(store); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, draftAfterRefresh: false }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(/did not turn the pull request into a draft/); + expect(store.getTask(identity).status).toBe('needs human'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); + const last = harness(store, { live, next }); + expect(await last.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ number: 100, draft: true, status: 'needs human' }); + expect(last.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: null }]); + }); + it('does not push when the task changed while the earlier PR was looked up (the check refuses to record)', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + const again = harness(store, { live, next, onFind: () => store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code') }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(again.log.some(line => line.startsWith('push'))).toBe(false); + }); + it('records an update of the open PR before it starts, and repeats it after its confirmation was lost', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: { head: oid(3), draft: false } }]); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(again.log.at(-1)).toBe('refresh 100 ready'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3) }]); + }); + it('drops an unconfirmed update when the next check matches, so no stale update stays pending', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + const again = harness(store, { live, next, results: [{ outcome: 'unknown', reason: 'GitHub could not be read.' }] }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); + }); + it('records the refresh before the push, and changes nothing else about the PR when the task changed during the push', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + rerun(store); + let refreshRecordedAtPush: unknown = undefined; + const again = harness(store, { live, next, push: async () => { + refreshRecordedAtPush = store.taskPullRequests(identity)[0]!.refresh; + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); + } }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(refreshRecordedAtPush).toMatchObject({ head: oid(3), draft: false }); + // The draft is not marked ready and its description is not replaced; the update stays in flight for the next publish. + expect(again.log.some(line => line.startsWith('refresh'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: { head: oid(3) } }]); + }); + it('runs one publish per task at a time, across publishers over the same Store', async () => { + const store = runningTask(); + let release!: () => void; + const pushed = new Promise(resolve => { release = resolve; }); + const { publisher } = harness(store, { push: () => pushed }); + const first = publisher.publish(identity); + await new Promise(resolve => setTimeout(resolve, 0)); + await expect(publisher.publish(identity)).rejects.toThrow(/already being published/); + // A second publisher over the same Store is refused too. + await expect(harness(store).publisher.publish(identity)).rejects.toThrow(/already being published/); + release(); + expect(await first).toMatchObject({ kind: 'opened', status: 'in review' }); + // The guard is released afterwards. + await expect(publisher.publish(identity)).rejects.toThrow(/while the task is in review/); + }); + it('changes nothing when the signal is already aborted', async () => { + const store = runningTask({ head: oid(1) }); + const controller = new AbortController(); controller.abort(); + const { publisher, log } = harness(store); + await expect(publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(log).toEqual([]); + expect(store.getTask(identity).status).toBe('running'); + }); + it('turns the earlier ready PR back into a draft when the check matches, so it is never left ready for review', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + expect(store.getTask(identity).status).toBe('in review'); + rerun(store); + const again = harness(store, { live, next, results: [{ outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }] }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + expect(again.log).toEqual([expect.stringMatching(/^find /), 'check', 'draft 100']); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + expect(store.getTask(identity).status).toBe('possibly already fixed'); + }); + it('does not draft the PR when the task was reassigned during the check', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + const gate = harness(store, { live, next, results: [found] }); + const publisher = new PullRequestPublisher(store, { checks: { async check() { + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); return found; + } }, pulls: { ...gate.pulls }, pusher: { async push() {} } }, config); + await expect(publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(gate.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('keeps the task running when marking the earlier PR a draft fails, so a retry repeats it', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + rerun(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + await expect(harness(store, { live, next, results: [found], draftFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + expect(store.getTask(identity).status).toBe('running'); + const retry = harness(store, { live, next, results: [found] }); + expect(await retry.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + expect(retry.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('refuses a PR-number mismatch before drafting anything', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + for (const [m, pr] of live) live.set(m, { ...pr, number: 999 }); + rerun(store); + const again = harness(store, { live, next, results: [{ outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }] }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/different pull request/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('repairs a draft flag whose change landed on GitHub but was never recorded', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + // The PR became a draft on GitHub (a draft change whose record was lost to a crash). + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + rerun(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + expect(await harness(store, { live, next, results: [found] }).publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('still notices a task change during the lookup when it repairs the draft flag', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + rerun(store); + const again = harness(store, { live, next, onFind: () => store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code') }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(again.log).not.toContain('check'); + }); + it('opens a new PR when the earlier draft was closed, and still excludes the old draft from the check', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + live.clear(); + rerun(store); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 101, status: 'in review' }); + expect(again.checks[0]!.ownPullRequests).toEqual([100]); + expect(store.taskPullRequests(identity).map(pr => pr.number)).toEqual([100, 101]); + }); +}); + +/** The person sends a needs-human task back; one more item runs and commits `oid(3)`. */ +function rerun(store: Store) { + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(3), [{ sha: oid(3), owner: 'P1', origin: 'owned', sourceSha: null }]); +} + +describe('recovering from an abandoned opening whose PR appears later', () => { + it('adopts that PR instead of getting stuck behind GitHub refusing a second PR for the branch', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + // The POST lands, but its answer is lost and GitHub does not show the PR yet. + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + const [first] = store.taskPullRequests(identity); + for (const m of live.keys()) hidden.add(m); + // After the settle time the opening is abandoned; the new POST is refused because the first PR now exists. That + // refusal is definite, so the second opening is abandoned at once instead of being left to settle. + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'abandoned']); + // The first PR becomes visible: the main path adopts it, counts it as the task's own in the check, and updates it. + hidden.clear(); + const third = harness(store, { live, next, config: later }); + expect(await third.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(third.checks[0]!.ownPullRequests).toEqual([100]); + expect(store.taskPullRequests(identity)).toMatchObject([{ openingId: first!.openingId, state: 'opened', number: 100 }, { state: 'abandoned' }]); + expect(third.log.filter(line => line.startsWith('open'))).toEqual([]); + }); + it('adopts an abandoned opening\'s PR as soon as it is seen, even when the check then matches', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + // The opening is abandoned after its settle time; a new POST is refused (the first PR exists), leaving one opening. + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); + hidden.clear(); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + expect(await harness(store, { live, next, results: [found], config: later }).publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); + // The first opening's PR is now recorded with its number and URL, so it can be found and closed later. + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, url: expect.stringContaining('github.com') }, { state: 'abandoned' }]); + }); + it('refuses the main path when the task changed during the lookup that would adopt the PR', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); + hidden.clear(); + // The task changes while the branch lookup runs; adoption is guarded by the version read before it. + const again = harness(store, { live, next, config: later, onFind: () => store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code') }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/Stale task state/); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'abandoned']); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + }); + it('refuses before pushing when the branch PR has the earlier marker but another number', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await harness(store, { live, next }).publisher.publish(identity, { problems: ['x'] }); + for (const [m, pr] of live) live.set(m, { ...pr, number: 999 }); + rerun(store); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/different pull request/); + expect(again.log.some(line => line.startsWith('push'))).toBe(false); + }); +}); + +describe('the PR description', () => { + it('fences plan text so closing keywords and mentions in it do nothing, even with backticks in the text', () => { + const hostile: Plan = { ...plan, items: [{ ...plan.items[0]!, intent: 'Closes #1 @admin ```\n# injected' }] }; + const body = pullRequestBody({ plan: hostile, marker: '' }); + expect(body.split('\n').slice(0, 2)).toEqual(['', 'Fixes #12']); + expect(body).toContain('````text\nP1: Guard input\n Intent: Closes #1 @admin ```\n# injected'); + expect(fenced('a ```` b')).toMatch(/^`````text\n/); + }); + it('keeps newlines and tabs in fenced text and replaces other control characters and lone surrogates', () => { + expect(fenced('a\u0000b\u0007c\td\r\ne\u007f')).toBe('```text\na\ufffdb\ufffdc\td\ne\ufffd\n```'); + expect(fenced('x\ud800y😀')).toBe('```text\nx\ufffdy😀\n```'); + expect(pullRequestTitle({ ...plan, summary: 'Fix \udc00 crash' })).toBe('Fix \ufffd crash (#12)'); + }); + it('handles text with very many backtick runs without overflowing the stack', () => { + expect(fenced('`a'.repeat(300_000)).startsWith('```text\n')).toBe(true); + }); + it('lists only item titles when the full plan is too long, and refuses when even that is too long', () => { + const long: Plan = { ...plan, items: [{ ...plan.items[0]!, intent: 'x'.repeat(MAX_BODY) }] }; + const body = pullRequestBody({ plan: long, marker: 'm' }); + expect(body).toContain('items only'); + expect(body).not.toContain('Intent:'); + const huge: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'y'.repeat(MAX_BODY) }] }; + expect(() => pullRequestBody({ plan: huge, marker: 'm' })).toThrow(/too long/); + }); + it('keeps the description bound with problems made of astral characters', () => { + const body = pullRequestBody({ plan, marker: 'm', problems: Array.from({ length: 20 }, () => '😀'.repeat(2500)) }); + expect(body.length).toBeLessThan(MAX_BODY); + }); + it('cuts each shown problem to exactly the documented 2,000 characters', () => { + const body = pullRequestBody({ plan, marker: 'm', problems: ['a'.repeat(3000)] }); + expect(body).toContain(`${'a'.repeat(1999)}…\n`); + expect(body).not.toContain('a'.repeat(2000)); + }); + it('bounds the open problems it shows', () => { + const body = pullRequestBody({ plan, marker: 'm', problems: Array.from({ length: 25 }, (_, i) => `problem ${i} ${'z'.repeat(3000)}`) }); + expect(body).toContain('(5 more in codeboost)'); + expect(body).not.toContain('problem 20 '); + expect(body.length).toBeLessThan(MAX_BODY); + }); + it('neutralises every issue reference in the title, plan and problems, since commit messages ignore fences', () => { + const hostile: Plan = { ...plan, summary: 'Fix #7 crash', items: [{ ...plan.items[0]!, intent: 'fixes #5, closes GH-6, resolves https://github.com/owner/repo/issues/8 and owner/repo#9' }] }; + const title = pullRequestTitle(hostile); + expect(title).toBe('Fix #7 crash (#12)'); + const body = pullRequestBody({ plan: hostile, marker: 'm', problems: ['Fixes #10'] }); + expect(body).toContain('Fixes #12'); + expect(body.replace('Fixes #12', '').replace('(#12)', '')).not.toMatch(/#\d|GH-\d|\/issues\/\d/i); + expect(neutralizeReferences('owner/repo#9 and #x and GH-a')).toBe('owner/repo#9 and #x and GH-a'); + // Lowercase and pull-request URL forms are references too. + expect(neutralizeReferences('gh-7 and https://github.com/owner/repo/pull/8')).toBe('gh‑7 and https://github.com/owner/repo/pull∕8'); + }); + it('cuts titles and problems by code point, and never leaves an empty summary', () => { + const emoji = '😀'.repeat(300); + const title = pullRequestTitle({ ...plan, summary: emoji }); + expect(title.endsWith('… (#12)')).toBe(true); + expect(title).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])|(? { + expect(pullRequestTitle({ ...plan, summary: 'Investigate @admin and @org/team, not a@b' })).toBe('Investigate @admin and @org/team, not a@b (#12)'); + }); + it('makes a one-line, bounded title', () => { + expect(pullRequestTitle({ ...plan, summary: 'a\nb\u0007c' })).toBe('a b c (#12)'); + expect(pullRequestTitle({ ...plan, summary: 'w'.repeat(500) })).toHaveLength(200); + }); +}); + +describe('running gh with a request body on stdin', () => { + it('passes a body far past the per-argument limit, including a NUL, through stdin', async () => { + const body = `${'€'.repeat(60_000)}\u0000end`; + const out = await runWithInput(process.execPath, ['-e', 'let n=0;process.stdin.on("data",c=>n+=c.length).on("end",()=>process.stdout.write(String(n)))'], { input: body }); + expect(Number(out)).toBe(Buffer.byteLength(body)); + }); + it('rejects on abort only after the process has exited', async () => { + const controller = new AbortController(); + const started = runWithInput(process.execPath, ['-e', 'process.on("SIGTERM",()=>setTimeout(()=>process.exit(1),150));setInterval(()=>{},1000)'], { signal: controller.signal }); + await new Promise(resolve => setTimeout(resolve, 200)); + const aborted = Date.now(); controller.abort(new Error('stop')); + await expect(started).rejects.toThrow('stop'); + expect(Date.now() - aborted).toBeGreaterThanOrEqual(100); + }); + it('kills a process that ignores SIGTERM after the grace period, and still settles', async () => { + const controller = new AbortController(); + const started = runWithInput(process.execPath, ['-e', 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000)'], { signal: controller.signal, killGraceMs: 100 }); + await new Promise(resolve => setTimeout(resolve, 200)); + controller.abort(new Error('stop')); + await expect(started).rejects.toThrow('stop'); + }); + it('settles after the process exits even when a child it started keeps the pipes open', async () => { + const started = Date.now(); + const script = 'require("child_process").spawn(process.execPath,["-e","setTimeout(()=>{},3000)"],{stdio:["ignore","inherit","inherit"]}).unref();process.exit(0)'; + await expect(runWithInput(process.execPath, ['-e', script], { pipeGraceMs: 100 })).resolves.toBe(''); + expect(Date.now() - started).toBeLessThan(2_000); + }); + it('starts nothing when the signal is already aborted', async () => { + const controller = new AbortController(); controller.abort(new Error('cancelled first')); + const marker = `${process.env.TMPDIR ?? '/tmp'}/codeboost-started-${process.pid}-${Date.now()}`; + await expect(runWithInput(process.execPath, ['-e', `require('fs').writeFileSync(${JSON.stringify(marker)}, 'x')`], { signal: controller.signal })).rejects.toThrow('cancelled first'); + await new Promise(resolve => setTimeout(resolve, 300)); + expect(existsSync(marker)).toBe(false); + }); + it('reports a failing exit with its stderr', async () => { + await expect(runWithInput(process.execPath, ['-e', 'console.error("HTTP 422");process.exit(1)'], {})).rejects.toThrow(/exit 1\): HTTP 422/); + }); + it('reports a process that exits without reading a large body by its exit status, without crashing on the broken pipe', async () => { + await expect(runWithInput(process.execPath, ['-e', 'process.exit(3)'], { input: 'x'.repeat(10 * 1024 * 1024) })).rejects.toThrow(/exit 3/); + }); + it('stops a process whose output passes the limit', async () => { + await expect(runWithInput(process.execPath, ['-e', 'process.stdout.write("x".repeat(1 << 20));setInterval(()=>{},1000)'], { maxBuffer: 1024, killGraceMs: 100 })).rejects.toThrow(/exceeded its limit/); + }); +}); + +describe('gh subprocess environment', () => { + it('passes only the allowlisted variables, and turns prompts off', () => { + const env = ghEnvironment({ PATH: '/bin', GH_TOKEN: 't', AWS_SECRET_ACCESS_KEY: 'x', ANTHROPIC_API_KEY: 'y', HOME: '/h' }); + expect(env).toEqual({ PATH: '/bin', GH_TOKEN: 't', HOME: '/h', GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: 'cat', NO_COLOR: '1' }); + expect(GH_ENV_ALLOWLIST).not.toContain('ANTHROPIC_API_KEY' as never); + // Windows needs its system and profile directories, and has no `cat` for a pager. + expect(ghEnvironment({ SYSTEMROOT: 'C:\\Windows', APPDATA: 'A', LOCALAPPDATA: 'L', USERPROFILE: 'U', PATHEXT: '.EXE' }, 'win32')) + .toMatchObject({ SYSTEMROOT: 'C:\\Windows', APPDATA: 'A', LOCALAPPDATA: 'L', USERPROFILE: 'U', PATHEXT: '.EXE', GH_PAGER: '' }); + // Every allowlisted variable is passed on as it is: GitHub hosts, tokens and configuration, proxies and CAs. + const every = Object.fromEntries(GH_ENV_ALLOWLIST.map(name => [name, `value of ${name}`])); + expect(ghEnvironment(every)).toMatchObject(every); + for (const name of ['GH_HOST', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_CONFIG_DIR', 'XDG_CONFIG_HOME', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'TEMP', 'TMP']) + expect(GH_ENV_ALLOWLIST, name).toContain(name as never); + // Linux keyring sign-in needs the session bus. + expect(ghEnvironment({ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1/bus', XDG_RUNTIME_DIR: '/run/user/1' })).toMatchObject({ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1/bus', XDG_RUNTIME_DIR: '/run/user/1' }); + }); +}); + +describe('GitHub PR adapter', () => { + const marker = ''; + const response = (over: Record = {}) => ({ number: 7, html_url: 'https://github.com/owner/repo/pull/7', state: 'open', draft: true, body: `${marker}\nplan`, + head: { sha: oid(2), ref: 'codeboost/issue-12-task', repo: { full_name: 'Owner/Repo' } }, base: { ref: 'main', repo: { full_name: 'owner/repo' } }, ...over }); + const input = { base: 'main', headBranch: 'codeboost/issue-12-task', title: 'T', body: `${marker}\nplan`, draft: true, marker }; + it('opens with literal argv, sends the title and description as a JSON body on stdin, and validates the answer', async () => { + const calls: string[][] = [], inputs: (string | undefined)[] = []; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async (args, options) => { calls.push([...args]); inputs.push(options?.input); return JSON.stringify(response()); }); + expect(await gh.open(input)).toEqual({ number: 7, url: 'https://github.com/owner/repo/pull/7', headSha: oid(2), draft: true }); + expect(calls[0]).toEqual(['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', 'repos/owner/repo/pulls', '--input', '-']); + expect(JSON.parse(inputs[0]!)).toEqual({ title: 'T', body: `${marker}\nplan`, head: 'codeboost/issue-12-task', base: 'main', draft: true }); + expect(calls[0]!.join(' ')).not.toContain('plan'); + }); + it('refuses answers for another branch or repository, and bodies without the marker', async () => { + for (const over of [{ head: { sha: oid(2), ref: 'other', repo: { full_name: 'owner/repo' } } }, { head: { sha: oid(2), ref: 'codeboost/issue-12-task', repo: { full_name: 'fork/repo' } } }, + { base: { ref: 'dev', repo: { full_name: 'owner/repo' } } }, { body: 'no marker' }, { state: 'closed' }, { number: 0 }, { head: { sha: 'x', ref: 'codeboost/issue-12-task', repo: { full_name: 'owner/repo' } } }]) { + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response(over))); + await expect(gh.open(input), JSON.stringify(over)).rejects.toThrow(); + } + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '{}').open({ ...input, body: 'no marker' })).rejects.toThrow(/marker/); + }); + it('refreshes the description, then marks a draft ready, then reads the PR back', async () => { + const calls: string[][] = []; + let draft = true; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + calls.push([...args]); + if (args[0] === 'pr') { draft = false; return ''; } + return JSON.stringify(response({ draft })); + }); + expect(await gh.refresh(7, { ...input, draft: false, ready: true })).toMatchObject({ number: 7, draft: false }); + expect(calls.map(call => call.slice(0, 3))).toEqual([['api', '-X', 'PATCH'], ['pr', 'ready', '7'], ['api', '-H', 'Accept: application/vnd.github+json']]); + expect(calls[1]).toEqual(['pr', 'ready', '7', '--repo', 'owner/repo']); + const undo: string[][] = []; + let undone = false; + await new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { undo.push([...args]); if (args[0] === 'pr') { undone = true; return ''; } return JSON.stringify(response({ draft: undone })); }) + .refresh(7, { ...input, draft: true, ready: false }); + expect(undo[1]).toEqual(['pr', 'ready', '7', '--undo', '--repo', 'owner/repo']); + // GitHub never applying the draft change fails the refresh instead of reporting success. + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async args => args[0] === 'pr' ? '' : JSON.stringify(response({ draft: false }))) + .refresh(7, { ...input, draft: true, ready: false })).rejects.toThrow(/did not turn the pull request into a draft/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ number: 8 }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/different/); + // The PATCH answer itself is checked, before any ready change: another PR there stops the refresh even if a read-back would pass. + const patched: string[][] = []; + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + patched.push([...args]); if (args[0] === 'pr') return ''; return JSON.stringify(response(args.includes('PATCH') ? { number: 8 } : { draft: false })); + }).refresh(7, { ...input, draft: false, ready: true })).rejects.toThrow(/different/); + expect(patched.some(args => args[0] === 'pr')).toBe(false); + // Closed between the lookup and the refresh: refused, so the task never moves to in review without an open PR. + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ state: 'closed' }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/not open/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ state: 'closed' })])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/not open/); + }); + it('waits for GitHub to show the pushed head before returning a refresh', async () => { + let gets = 0; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + if (args[0] === 'pr') return ''; + if (args.includes('GET') || !args.includes('-X')) { gets++; return JSON.stringify(response({ draft: false, head: { sha: gets < 3 ? oid(2) : oid(3), ref: 'codeboost/issue-12-task', repo: { full_name: 'owner/repo' } } })); } + return JSON.stringify(response({ draft: false })); + }); + const controller = new AbortController(); + let listeners = 0; + const add = controller.signal.addEventListener.bind(controller.signal), remove = controller.signal.removeEventListener.bind(controller.signal); + controller.signal.addEventListener = ((...a: Parameters) => { if (a[0] === 'abort') listeners++; return add(...a); }) as typeof add; + controller.signal.removeEventListener = ((...a: Parameters) => { if (a[0] === 'abort') listeners--; return remove(...a); }) as typeof remove; + expect(await gh.refresh(7, { ...input, draft: false, ready: true, headSha: oid(3) }, controller.signal)).toMatchObject({ headSha: oid(3) }); + expect(gets).toBe(3); + // Each wait removes its abort listener, so a long-lived signal does not collect them. + expect(listeners).toBe(0); + }); + it('marks an open ready PR as a draft, and leaves a draft alone', async () => { + const calls: string[][] = []; + let draft = false; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + calls.push([...args]); if (args[0] === 'pr') { draft = true; return ''; } return JSON.stringify(response({ draft })); + }); + expect(await gh.markDraft(7, input)).toMatchObject({ draft: true }); + // No read before the change: the caller has just read the PR as ready. + expect(calls.map(call => call[0] === 'pr' ? 'undo' : 'get')).toEqual(['undo', 'get']); + expect(calls[0]).toEqual(['pr', 'ready', '7', '--undo', '--repo', 'owner/repo']); + // A refusal because the PR already became a draft meanwhile is success. + const already = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { if (args[0] === 'pr') throw new Error('pull request #7 is already a draft'); return JSON.stringify(response({ draft: true })); }); + expect(await already.markDraft(7, input)).toMatchObject({ draft: true }); + // A real refusal is reported. + const refused = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { if (args[0] === 'pr') throw new Error('HTTP 403'); return JSON.stringify(response({ draft: false })); }); + await expect(refused.markDraft(7, input)).rejects.toThrow('HTTP 403'); + }); + it('turns GitHub refusing drafts into DraftsUnsupported on open, refresh and markDraft', async () => { + // As gh reports it: `gh api` prints only the summary on stderr and GitHub's reason in the JSON body on stdout; + // `gh pr ready --undo` prints the GraphQL error on stderr. + const unsupported = new CommandFailed('gh failed (exit 1): gh: Validation Failed (HTTP 422)', 'gh: Validation Failed (HTTP 422)', + JSON.stringify({ message: 'Validation Failed', errors: [{ resource: 'PullRequest', code: 'custom', message: 'Draft pull requests are not supported in this repository.' }] })); + const undo = new CommandFailed('gh failed (exit 1): GraphQL: Draft pull requests are not supported in this repository.', 'GraphQL: Draft pull requests are not supported in this repository. (convertPullRequestToDraft)', ''); + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + if (args[0] === 'pr') throw undo; if (args.includes('POST')) throw unsupported; return JSON.stringify(response({ draft: false })); + }); + await expect(gh.open({ ...input, draft: true })).rejects.toBeInstanceOf(DraftsUnsupported); + await expect(gh.refresh(7, { ...input, draft: true, ready: false })).rejects.toBeInstanceOf(DraftsUnsupported); + await expect(gh.markDraft(7, input)).rejects.toBeInstanceOf(DraftsUnsupported); + // A ready PR is not a draft request, so the same text is not reinterpreted. + const readyOpen = await gh.open({ ...input, draft: false }).catch(error => error); + expect(readyOpen).toBeInstanceOf(PullRequestRefused); + expect(readyOpen).not.toBeInstanceOf(DraftsUnsupported); + }); + it('fails markDraft when GitHub never shows the PR as a draft', async () => { + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => args[0] === 'pr' ? '' : JSON.stringify(response({ draft: false }))); + await expect(gh.markDraft(7, input)).rejects.toThrow(/did not turn the pull request into a draft/); + }); + it('looks up the branch with no markers: an open PR there is refused, no PR is null', async () => { + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response()])).findOpened({ ...input, markers: [] })).rejects.toThrow(/did not open/); + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '[]').findOpened({ ...input, markers: [] })).toBeNull(); + // Every other call still needs its marker. + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '{}').markDraft(7, { ...input, marker: '' })).rejects.toThrow(/marker/); + }); + it('reads the marker only from the first line, so a marker quoted in plan text identifies nothing', async () => { + const other = ''; + // A foreign PR that quotes our marker in its text is not ours. + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: `Someone's PR\n${marker}` })])) + .findOpened({ ...input, markers: [marker] })).rejects.toThrow(/did not open/); + // Our PR whose plan text quotes an older marker still matches exactly one: its own first line. + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: `${marker}\nplan quoting ${other}` })])) + .findOpened({ ...input, markers: [other, marker] })).toMatchObject({ marker }); + }); + it('fails a draft opening that GitHub created as ready, so the opening stays owned for recovery', async () => { + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ draft: false }))); + await expect(gh.open({ ...input, draft: true })).rejects.toThrow(/as ready, not as a draft/); + expect(await gh.open({ ...input, draft: false })).toMatchObject({ draft: false }); + }); + it('bounds a whole refresh by one deadline, not a fresh allowance per command or poll', async () => { + let calls = 0, lastSignal: AbortSignal | undefined; + const gh = new GhPullRequestGateway({ repository: 'owner/repo', operationMs: 300 }, async (args, options) => { + calls++; lastSignal = options?.signal; + await new Promise(resolve => setTimeout(resolve, 40)); + // GitHub never shows the pushed head, so the refresh would poll 5 times at 500 ms without the deadline. + return args[0] === 'pr' ? '' : JSON.stringify(response({ draft: false, head: { sha: oid(2), ref: 'codeboost/issue-12-task', repo: { full_name: 'owner/repo' } } })); + }); + const started = Date.now(); + await expect(gh.refresh(7, { ...input, draft: false, ready: true, headSha: oid(3) })).rejects.toThrow(); + expect(Date.now() - started).toBeLessThan(1_500); + expect(lastSignal?.aborted).toBe(true); + expect(calls).toBeLessThan(5); + }); + it('refuses a read-back of another PR, and rethrows an abort from the draft change', async () => { + const other = ''; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + if (args[0] === 'pr') return ''; + return args.includes('PATCH') ? JSON.stringify(response()) : JSON.stringify(response({ body: `${other}\nplan` })); + }); + await expect(gh.refresh(7, { ...input, ready: false })).rejects.toThrow(/different pull request/); + const controller = new AbortController(); let gets = 0; + const aborting = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + if (args[0] === 'pr') { controller.abort(new Error('stop')); throw new Error('killed'); } + gets++; return JSON.stringify(response({ draft: true })); + }); + await expect(aborting.markDraft(7, input, controller.signal)).rejects.toThrow(); + expect(gets).toBe(0); + }); + it('finds a lost PR only by its marker, and refuses a PR on the branch that codeboost did not open', async () => { + const calls: string[][] = []; + const found = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { calls.push([...args]); return JSON.stringify([response()]); }); + expect(await found.findOpened({ ...input, markers: [marker] })).toMatchObject({ number: 7, marker }); + // It reports which of several markers the PR carries. + const other = ''; + expect(await found.findOpened({ ...input, markers: [other, marker] })).toMatchObject({ marker }); + expect(calls[0]!.at(-1)).toBe('repos/owner/repo/pulls?state=open&head=owner%3Acodeboost%2Fissue-12-task&per_page=100'); + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '[]').findOpened({ ...input, markers: [marker] })).toBeNull(); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: 'someone else' })])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/did not open/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/More than one of the task's pull requests/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: 'a' }), response({ number: 8, body: 'b' })])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/invalid pull request list/); + }); + it('recognises its marker in a description GitHub returns with CRLF line endings (an edit on github.com)', async () => { + const crlf = response({ body: `${marker}\r\nplan` }); + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([crlf])).findOpened({ ...input, markers: [marker] })).toMatchObject({ number: 7, marker }); + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([crlf])).findOwned({ headBranch: input.headBranch, markers: [marker] })).toMatchObject([{ number: 7 }]); + }); + it('fails closed on a full page of PRs from the branch, and on any malformed entry', async () => { + const list = (entries: unknown[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(entries)).findOpened({ ...input, markers: [marker] }); + const others = Array.from({ length: 100 }, (_, i) => ({ ...response({ number: 1000 + i, body: 'backport' }), base: { ...response().base, ref: `release-${i}` } })); + await expect(list(others)).rejects.toThrow(/100 or more open pull requests/); + expect(await list(others.slice(0, 99))).toBeNull(); + // Missing base or a non-string body: refused, not set aside as someone else's PR. + await expect(list([{ ...response({ body: 'backport' }), base: {} }])).rejects.toThrow(/invalid pull request list/); + await expect(list([response({ body: 7 })])).rejects.toThrow(/invalid pull request list/); + // A missing or impossible number, even on someone else's PR into another base. + const elsewhere = { ...response({ body: 'backport' }), base: { ...response().base, ref: 'release' } }; + await expect(list([{ ...elsewhere, number: undefined }])).rejects.toThrow(/invalid pull request list/); + await expect(list([{ ...elsewhere, number: 0 }])).rejects.toThrow(/invalid pull request list/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([{ ...response(), base: {} }])).findOwned({ headBranch: input.headBranch, markers: [marker] })).rejects.toThrow(/invalid pull request list/); + }); + it('reads a PR from the PR itself: state, branch, base and marker', async () => { + const read = (value: unknown) => new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { expect(args.at(-1)).toBe('repos/owner/repo/pulls/7'); return JSON.stringify(value); }).readPull(7); + expect(await read(response({ state: 'open' }))).toEqual({ open: true, headBranch: 'codeboost/issue-12-task', base: 'main', marker }); + expect(await read(response({ state: 'closed', body: null }))).toMatchObject({ open: false, marker: '' }); + await expect(read(response({ number: 8 }))).rejects.toThrow(/invalid pull request/); + await expect(read(response({ state: 'weird' }))).rejects.toThrow(/invalid pull request/); + await expect(read({ ...response(), head: {} })).rejects.toThrow(/invalid pull request/); + }); + it('refuses the task\'s own PR without its marker as misplaced, not as a PR codeboost did not open', async () => { + const lookup = (numbers?: number[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: 'edited by a person' })])) + .findOpened({ ...input, markers: [marker], numbers }); + const own = await lookup([7]).catch(e => e); + expect(own).toBeInstanceOf(PullRequestMisplaced); + expect(own.message).toMatch(/#7 no longer starts with its marker.*may still be ready for review/); + await expect(lookup([8])).rejects.toThrow(/did not open/); + }); + it('validates every PR it reads: an https URL, and head and base in this repository', async () => { + const lookup = (pr: unknown) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([pr])).findOpened({ ...input, markers: [marker] }); + await expect(lookup(response({ html_url: 'http://github.com/owner/repo/pull/7' }))).rejects.toThrow(/invalid pull request/); + await expect(lookup({ ...response(), base: { ref: 'main', repo: { full_name: 'other/repo' } } })).rejects.toThrow(/different branch/); + // A marked PR without a readable base is refused, not taken as being in no base. + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([{ ...response(), base: { repo: { full_name: 'owner/repo' } } }])) + .findOwned({ headBranch: input.headBranch, markers: [marker] })).rejects.toThrow(/invalid pull request/); + }); + it('refuses the task\'s own PR retargeted to another base, and ignores anyone else\'s PR from the branch into another base', async () => { + const elsewhere = (over: Record = {}) => ({ ...response(over), base: { ...response().base, ref: 'release' } }); + const lookup = (list: unknown[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(list)).findOpened({ ...input, markers: [marker] }); + await expect(lookup([elsewhere()])).rejects.toThrow(/#7 .* now targets release, not main\. Retarget it to main or close it/); + // A backport someone opened from the branch: not the task's, and not in the way. + expect(await lookup([elsewhere({ number: 8, body: 'backport' })])).toBeNull(); + expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response()])).toMatchObject({ number: 7, marker }); + }); + it('lists the task\'s own PRs in every base with their bases, and nobody else\'s', async () => { + const elsewhere = (over: Record = {}) => ({ ...response(over), base: { ...response().base, ref: 'release' } }); + const other = ''; + const owned = (list: unknown[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(list)).findOwned({ headBranch: input.headBranch, markers: [marker, other] }); + expect(await owned([elsewhere()])).toMatchObject([{ number: 7, marker, base: 'release' }]); + expect(await owned([elsewhere({ number: 8, body: 'backport' }), response({ number: 9, body: 'someone else' })])).toEqual([]); + // Two of the task's PRs, one per base: both are listed, so both can be made drafts. + expect(await owned([elsewhere(), response({ number: 9, body: `${other}\nplan` })])).toMatchObject([{ number: 7, base: 'release' }, { number: 9, marker: other, base: 'main' }]); + }); + it('refuses two of the task\'s PRs, and its own PR in another base even when the configured base has one too', async () => { + const elsewhere = (over: Record = {}) => ({ ...response(over), base: { ...response().base, ref: 'release' } }); + const other = ''; + const lookup = (list: unknown[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(list)).findOpened({ ...input, markers: [marker, other] }); + await expect(lookup([elsewhere(), response({ number: 9, body: `${other}\nplan` })])).rejects.toThrow(/More than one of the task's pull requests .*#7 into release, #9 into main.*Close all but one/); + // Both are PullRequestMisplaced, so the publisher can draft the task's PRs before it refuses. + expect(await lookup([elsewhere(), response({ number: 9, body: `${other}\nplan` })]).catch(e => e)).toBeInstanceOf(PullRequestMisplaced); + expect(await lookup([elsewhere()]).catch(e => e)).toBeInstanceOf(PullRequestMisplaced); + }); + it('matches refusals against gh\'s stderr only, not the response body echoed on stdout', async () => { + const echoed = new CommandFailed('gh failed (exit 1): gh: Server Error (HTTP 502)', 'gh: Server Error (HTTP 502)', '{"body":"Draft pull requests are not supported (HTTP 422)"}'); + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { throw echoed; }); + const error = await gh.open({ ...input, draft: true }).catch(e => e); + expect(error).toBe(echoed); + // A body that is not JSON (a proxy's HTML error page, say) is not read either. + const page = new CommandFailed('gh failed (exit 1): gh: Bad Gateway (HTTP 502)', 'gh: Bad Gateway (HTTP 502)', 'Draft pull requests are not supported (HTTP 422)'); + const proxied = await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { throw page; }).open({ ...input, draft: true }).catch(e => e); + expect(proxied).toBe(page); + }); + it('turns a validation refusal of the opening into PullRequestRefused, with the reason GitHub gave', async () => { + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { + throw new CommandFailed('gh failed (exit 1): gh: Validation Failed (HTTP 422)\n{"message":"Validation Failed","errors":[{"message":"No commits between main and codeboost/x"}]}', + 'gh: Validation Failed (HTTP 422)', '{"message":"Validation Failed","errors":[{"message":"No commits between main and codeboost/x"}]}'); + }); + const error = await gh.open({ ...input, draft: false }).catch(e => e); + expect(error).toBeInstanceOf(PullRequestRefused); + expect(error.message).toMatch(/No commits between/); + // Other failures (a timeout, a 5xx) stay ambiguous: the opening stays owned. + const flaky = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { throw new CommandFailed('gh failed (exit 1): gh: Server Error (HTTP 502)', 'gh: Server Error (HTTP 502)', ''); }); + expect(await flaky.open({ ...input, draft: false }).catch(e => e)).not.toBeInstanceOf(PullRequestRefused); + // Only a finished gh run is read: any other error, whatever its text, is not a refusal. + const other = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { throw new Error('gh: Validation Failed (HTTP 422)'); }); + expect(await other.open({ ...input, draft: false }).catch(e => e)).not.toBeInstanceOf(PullRequestRefused); + }); + it('keeps the response body gh prints on stdout in the failure', async () => { + const error = await runWithInput(process.execPath, ['-e', 'console.error("gh: Validation Failed (HTTP 422)");console.log(JSON.stringify({errors:[{message:"No commits between"}]}));process.exit(1)'], {}).catch(e => e); + expect(error).toBeInstanceOf(CommandFailed); + expect(error.message).toMatch(/HTTP 422\)\n\{"errors":\[\{"message":"No commits between"/); + expect(error).toMatchObject({ stderr: 'gh: Validation Failed (HTTP 422)', stdout: '{"errors":[{"message":"No commits between"}]}' }); + }); +}); + +describe('shutdown and PRs left ready', () => { + /** A task that ran, got its PR, was sent back, reran and settled running again with the same head. */ + async function rerun(store: Store, live: Map, next: { value: number }) { + await harness(store, { live, next }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + } + it("pushes and opens nothing once the coordinator is closing, even when it closes during the check", async () => { + const store = runningTask(); + let closing = false; + const { publisher, log } = harness(store, { closing: () => closing, results: [], onFind: () => { closing = true; } }); + await expect(publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(log).toEqual(['find ', 'check']); + expect(store.taskPullRequests(identity)).toEqual([]); + await expect(harness(store, { closing: () => true }).publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + }); + it('makes no ready change when the coordinator starts closing during the description update', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await rerun(store, live, next); + let closing = false; + const again = harness(store, { live, next, closing: () => closing, onRefresh: () => { closing = true; } }); + await expect(again.publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(again.log.at(-1)).toBe('refresh 100 ready'); + // The update stays in flight for the next publish to settle; the task has not moved. + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); + expect(store.getTask(identity).status).toBe('running'); + }); + it('close() aborts a publish in progress, awaits it, and refuses new ones', async () => { + const store = runningTask(); + const { publisher, log } = harness(store, { push: (_id, _input, signal) => new Promise((_, reject) => signal!.addEventListener('abort', () => reject(signal!.reason), { once: true })) }); + const first = publisher.publish(identity); + first.catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + let settled = false; + first.finally(() => { settled = true; }).catch(() => {}); + await publisher.close(); + expect(settled).toBe(true); + await expect(first).rejects.toThrow(ShuttingDownError); + expect(log.some(line => line.startsWith('open'))).toBe(false); + await expect(publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + }); + it('repeats a failed draft change on the next publish of a task that cannot publish, so its PR does not stay ready', async () => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, draftFails: true, open: async input => { + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const pr = { number: 9, url: 'https://github.com/owner/repo/pull/9', headSha: oid(2), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 9, status: 'cancelled', leftReady: 9 }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 9, draft: false }]); + const again = harness(store, { live }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toEqual([expect.stringMatching(/^owned /), 'draft 9']); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 9, draft: true }]); + // Nothing is owed any more: a further publish looks nothing up. + const third = harness(store, { live }); + await expect(third.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(third.log).toEqual([]); + }); + it('names a PR left ready because drafts are unsupported when it refuses a task that cannot publish', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, draftsUnsupported: true }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled.*#100 stays ready for review/); + expect(again.log).toContain('draft 100'); + }); + it('records a draft flag GitHub already shows for a task that cannot publish, without changing the PR', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + const again = harness(store, { live }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('keeps a no-changes draft publish in needs human without writing a status change', async () => { + const store = runningTask({ head: oid(1) }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const version = store.getTask(identity).stateVersion; + expect(await harness(store).publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'no changes' }); + expect(store.getTask(identity)).toMatchObject({ status: 'needs human', stateVersion: version }); + }); + it('moves no task on the no-changes path when the publish is aborted during a refused draft change', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await rerun(store, live, next); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + const controller = new AbortController(); + const again = harness(store, { live, next, draftsUnsupported: true, onDraft: () => controller.abort() }); + await expect(again.publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(again.log).toContain('draft 100'); + expect(store.getTask(identity).status).toBe('running'); + }); + it('records a draft change that landed but moves no task when the no-changes publish is aborted during it', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await rerun(store, live, next); + store.recordHistory(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, oid(1), oid(1), []); + const controller = new AbortController(); + const again = harness(store, { live, next, onDraft: () => controller.abort() }); + await expect(again.publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + expect(store.getTask(identity).status).toBe('running'); + }); + it('leaves the ready PR of an approved, in-review or merged task alone, without asking GitHub', async () => { + for (const status of ['in review', 'approved but merge blocked', 'merged'] as const) { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + // A real merge needs the whole merge flow; the publisher only reads the status, so it is reported as merged here. + if (status === 'merged') { const real = store.getTask.bind(store); vi.spyOn(store, 'getTask').mockImplementation(id => ({ ...real(id), status: 'merged' })); } + else if (status !== 'in review') store.transitionTask(identity, store.getTask(identity).stateVersion, status); + const again = harness(store, { live }); + await expect(again.publisher.publish(identity)).rejects.toThrow(GuardRefusal); + expect(again.log).toEqual([]); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false }]); + } + }); + it('keeps the status refusal when the draft change fails, notes the PR, and tries again next time', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + await expect(harness(store, { live, draftFails: true }).publisher.publish(identity)).rejects.toThrow(/cancelled.*#100 could not be made a draft.*timeout/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false }]); + const again = harness(store, { live }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('drafts nothing when the branch PR GitHub shows has another number than the ready record', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + for (const [m, pr] of live) live.set(m, { ...pr, number: 999 }); + const again = harness(store, { live }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('checks closing at entry, before the refresh push, before the description update and before the opening', async () => { + // At entry: nothing is asked. + const entry = harness(runningTask(), { closing: () => true }); + await expect(entry.publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(entry.log).toEqual([]); + // Closing during the push of a new PR's branch: nothing is opened. + let closing = false; + const fresh = runningTask(); + const opening = harness(fresh, { closing: () => closing, push: async () => { closing = true; } }); + await expect(opening.publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(opening.log.some(line => line.startsWith('open'))).toBe(false); + expect(fresh.taskPullRequests(identity)).toEqual([]); + // Closing during the check before an update: nothing is pushed. + const store = runningTask(), live = new Map(), next = { value: 100 }; + await rerun(store, live, next); + closing = false; + const gate = harness(store, { live, next, closing: () => closing, onFind: () => { closing = true; } }); + await expect(gate.publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(gate.log).toContain('check'); + expect(gate.log.some(line => line.startsWith('push'))).toBe(false); + // Closing during the update's push: the description is not updated. + closing = false; + const pushed = harness(store, { live, next, closing: () => closing, push: async () => { closing = true; } }); + await expect(pushed.publisher.publish(identity)).rejects.toThrow(ShuttingDownError); + expect(pushed.log.some(line => line.startsWith('push'))).toBe(true); + expect(pushed.log.some(line => line.startsWith('refresh'))).toBe(false); + }); + it("adopts and drafts an abandoned opening's PR that appears only after its task was cancelled", async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + // Recovery sees nothing after the settle time and abandons the opening; the status refusal follows. + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned' }]); + hidden.clear(); + const again = harness(store, { live, next, config: later }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + }); + /** A needs-human draft opening whose outcome was lost, then a cancel, then recovery abandoning it: its PR is hidden until `hidden` clears. */ + async function abandonedDraft() { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned', draft: true }]); + hidden.clear(); + return { store, live, next, later }; + } + it("records a late draft PR by adoption alone, and drafts one a person has made ready since", async () => { + // Still a draft on GitHub: adoption is the only write. + let { store, live, next, later } = await abandonedDraft(); + const version = store.getTask(identity).stateVersion; + const quiet = harness(store, { live, next, config: later }); + await expect(quiet.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(quiet.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + expect(store.getTask(identity).stateVersion).toBe(version + 1); + // Made ready on GitHub meanwhile: adopted as ready, then drafted, and the record says so. + ({ store, live, next, later } = await abandonedDraft()); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + const readied = harness(store, { live, next, config: later }); + await expect(readied.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(readied.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + }); + it('adopts nothing when the draft step is aborted during a lookup that still answers', async () => { + const { store, live, next, later } = await abandonedDraft(); + const controller = new AbortController(); + const again = harness(store, { live, next, config: later, onFind: () => controller.abort() }); + const error = await again.publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(error).not.toBeInstanceOf(GuardRefusal); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned' }]); + }); + it('leaves the PR ready when the task is approved during the draft step lookup', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const again = harness(store, { live, onFind: () => store.transitionTask(identity, store.getTask(identity).stateVersion, 'approved but merge blocked') }); + await expect(again.publisher.publish(identity)).rejects.toThrow(GuardRefusal); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false }]); + }); + it('rejects with the abort, not a note, when the draft step is aborted', async () => { + const cancelled = async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + return { store, live }; + }; + // The lookup fails because of the abort. + let controller = new AbortController(); + let { store, live } = await cancelled(); + let error = await harness(store, { live, onFind: () => { controller.abort(); throw new Error('aborted lookup'); } }).publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(error).not.toBeInstanceOf(GuardRefusal); + // The draft change fails because of the abort. + controller = new AbortController(); + ({ store, live } = await cancelled()); + error = await harness(store, { live, draftFails: true, onDraft: () => controller.abort() }).publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(error).not.toBeInstanceOf(GuardRefusal); + // The draft change lands, then the abort: the change is recorded and the publish rejects. + controller = new AbortController(); + ({ store, live } = await cancelled()); + error = await harness(store, { live, onDraft: () => controller.abort() }).publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(error).not.toBeInstanceOf(GuardRefusal); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('drafts nothing in another repository than its own', async () => { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const other = harness(store, { live, config: { repository: 'owner/other' } }); + await expect(other.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(other.log).toEqual([]); + }); + it('rejects with the abort during the head-mismatch draft change instead of reporting leftReady', async () => { + const store = runningTask(), live = new Map(), controller = new AbortController(); + const { publisher } = harness(store, { live, draftFails: true, onDraft: () => controller.abort(), open: async input => { + const pr = { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }; live.set(input.marker, pr); return pr; + } }); + await expect(publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5 }]); + }); + /** A needs-human draft opening whose outcome was lost; its PR was then made ready on GitHub. */ + async function lostDraftMadeReady() { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + return { store, live, next }; + } + it('does not draft a recovered draft opening for a ready publish: the main path marks it ready', async () => { + const { store, live, next } = await lostDraftMadeReady(); + // The task went back and ran again; it is now published as ready. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + }); + it('continues to the main path when a recovered draft opening is not this publish\'s own and drafts are unsupported', async () => { + const { store, live, next } = await lostDraftMadeReady(); + // The task changed since the opening (same status, new version), so the opening is not this publish's own. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + const again = harness(store, { live, next, draftsUnsupported: true }); + expect(await again.publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: 100 }); + expect(again.log).toContain('check'); + }); + it('converges after a base change once the person retargets the PR, even when the update is then lost', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next, config: { baseBranch: 'develop' } }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + // The base setting is now main: the task's own PR into develop is refused until a person moves it. + await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/now targets develop, not main/); + const bases = baseOf.get(live)!; + for (const m of live.keys()) bases.set(m, 'main'); + // The update into main is recorded, then its confirmation is lost. + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow(/timeout/); + // Recovery looks the PR up in the configured base too, settles the update, and the publish finishes. + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(again.log.some(line => line.startsWith('open'))).toBe(false); + }); + it('recovers a lost opening in the configured base after a base change and a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity)).rejects.toThrow('timeout'); + const bases = baseOf.get(live)!; + for (const m of live.keys()) bases.set(m, 'main'); + const again = harness(store, { live, next }); + await again.publisher.publish(identity); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }]); + expect(again.log.some(line => line.startsWith('open'))).toBe(false); + }); + /** Requeues a task in review and settles its rerun, so it can publish again. */ + const requeue = (store: Store) => { + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const attempt = store.admitAttempt(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + store.markRunning(identity, attempt.id); + store.settleAttempt(identity, attempt.id, { firstReason: null, exitCode: 0, valid: true }); + }; + it('settles a lost update and drafts the PR of a cancelled task after a base change, without a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + const develop = { baseBranch: 'develop' }; + await harness(store, { live, next, config: develop }).publisher.publish(identity); + requeue(store); + await expect(harness(store, { live, next, config: develop, refreshFails: true }).publisher.publish(identity)).rejects.toThrow(/timeout/); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + // The base setting is now main. Recovery records what GitHub shows in any base, so the draft step still runs. + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled\.$/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: null }]); + }); + /** A task with two of its own PRs open: #100 into develop (closed, later reopened by a person) and #101 into main. */ + async function twoOwnPullRequests() { + const store = runningTask(), live = new Map(), next = { value: 100 }, closed = new Set(); + await harness(store, { live, next, closed, config: { baseBranch: 'develop' } }).publisher.publish(identity); + closed.add(100); + requeue(store); + expect(await harness(store, { live, next, closed }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 101 }); + closed.delete(100); + return { store, live, next, closed }; + } + it('turns a lost draft opening back into a draft in its own base when the base setting changed', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + // Recovery turns it back into a draft where it is, records it, and leaves the refusal to the main path. + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(PullRequestMisplaced); + expect(again.log).toContain('draft 100'); + expect(live.get([...live.keys()][0]!)).toMatchObject({ draft: true }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + }); + it('drafts a recovered PR whose head differs in its own base when the base setting changed', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, headSha: oid(77) }); + await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(PullRequestMisplaced); + expect(store.getTask(identity).status).toBe('running'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + }); + it('never puts the task in review for a recovered PR outside the configured base: it records it and drafts it', async () => { + for (const setup of ['retarget', 'base change'] as const) { + const store = runningTask(), live = new Map(), next = { value: 100 }; + const first = setup === 'retarget' ? {} : { baseBranch: 'develop' }; + await expect(harness(store, { live, next, openTimesOut: true, config: first }).publisher.publish(identity)).rejects.toThrow('timeout'); + if (setup === 'retarget') for (const m of live.keys()) baseOf.get(live)!.set(m, 'develop'); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity), setup).rejects.toThrow(/now targets develop, not main/); + expect(store.getTask(identity).status, setup).toBe('running'); + expect(store.taskPullRequests(identity), setup).toMatchObject([{ state: 'opened', number: 100, draft: true }]); + } + }); + it('never puts the task in review while another of its PRs is open, and records that PR too', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + // Opening A into develop is lost and its PR stays hidden past the settle time, so it is abandoned. + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + // Opening B into main is lost too; then A's PR shows up. + await expect(harness(store, { live, next, hidden, openTimesOut: true, config: later }).publisher.publish(identity)).rejects.toThrow('timeout'); + hidden.clear(); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/More than one of the task's pull requests/); + expect(store.getTask(identity).status).toBe('running'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: true }, { state: 'opened', number: 101, draft: true }]); + }); + it('drafts a running task\'s PR that a person retargeted, before refusing, however it got there', async () => { + // A lost opening that is not this publish's own (the task changed since), then a retarget. + let store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) baseOf.get(live)!.set(m, 'develop'); + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); + let again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/now targets develop, not main/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + // A current lost opening whose draft change fails once: the refusal says so, and the next publish drafts it. + store = runningTask(); live = new Map(); next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) baseOf.get(live)!.set(m, 'develop'); + await expect(harness(store, { live, next, draftFails: true }).publisher.publish(identity)).rejects.toThrow(/now targets develop.*#100 could not be made a draft/); + again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/now targets develop, not main/); + expect(again.log).toContain('draft 100'); + // A PR in review, sent back to run again, then retargeted. + store = runningTask(); live = new Map(); next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + for (const m of live.keys()) baseOf.get(live)!.set(m, 'develop'); + again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(PullRequestMisplaced); + expect(again.log).toContain('draft 100'); + expect(again.log.some(line => line.startsWith('push'))).toBe(false); + }); + it('drafts a misplaced PR recorded as a draft that a person made ready', async () => { + // A needs-human task has its draft PR; it is then sent back and runs again. + const task = runningTask(), pulls = new Map(), next = { value: 100 }; + task.transitionTask(identity, task.getTask(identity).stateVersion, 'needs human'); + await harness(task, { live: pulls, next }).publisher.publish(identity, { problems: ['x'] }); + expect(task.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + requeue(task); + // A person marks it ready and moves it to develop. + for (const [m, pr] of pulls) { pulls.set(m, { ...pr, draft: false }); baseOf.get(pulls)!.set(m, 'develop'); } + const again = harness(task, { live: pulls, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(PullRequestMisplaced); + expect(again.log).toContain('draft 100'); + expect(task.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('refuses a lost draft opening moved to another base in a repository without drafts, like any misplaced PR', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) { live.set(m, { ...pr, draft: false }); baseOf.get(live)!.set(m, 'develop'); } + const again = harness(store, { live, next, draftsUnsupported: true }); + await expect(again.publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(/now targets develop, not main.*#100 stays ready for review: this repository does not support draft/); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100, draft: false }]); + }); + it('reports only what is still true in the misplaced refusal: a draft change that failed and then landed is not reported', async () => { + // A ready PR in review; the task then needs a person, and someone moves the PR to develop. + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + for (const m of live.keys()) baseOf.get(live)!.set(m, 'develop'); + // The first draft step's change fails; the misplaced run's lands. + let drafts = 0; + const again = harness(store, { live, next, onDraft: () => { if (++drafts === 1) throw new Error('timeout marking the PR a draft'); } }); + const error = await again.publisher.publish(identity, { problems: ['x'] }).catch(e => e); + expect(error).toBeInstanceOf(PullRequestMisplaced); + expect(drafts).toBe(2); + expect(error.message).not.toMatch(/could not be made a draft/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('records nothing after an abort during the check, the push, or the needs-human draft change', async () => { + // During the check: no result is recorded and the task stays running. + let store = runningTask(), controller = new AbortController(); + await expect(harness(store, { onCheck: () => controller.abort() }).publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(store.latestAlreadyFixed(identity)).toBeNull(); + expect(store.getTask(identity).status).toBe('running'); + // During the push of a new branch: no opening is recorded, so nothing is left to settle. + store = runningTask(); controller = new AbortController(); + const pushed = harness(store, { push: async () => { controller.abort(); } }); + await expect(pushed.publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); + expect(store.taskPullRequests(identity)).toEqual([]); + expect(pushed.log.some(line => line.startsWith('open'))).toBe(false); + // During the draft change before a needs-human update: no update is recorded and nothing is pushed. + store = runningTask(); controller = new AbortController(); + const live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + let drafts = 0; + // The draft step's change fails once (a note); the main path's lands, then the abort. + const again = harness(store, { live, next, onDraft: () => { if (++drafts === 1) throw new Error('timeout marking the PR a draft'); controller.abort(); } }); + await expect(again.publisher.publish(identity, { problems: ['x'] }, controller.signal)).rejects.toThrow(); + expect(drafts).toBe(2); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('refresh'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); + }); + it('drops a lost opening at once when another of the task\'s PRs is open into the same base', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + // Opening A into main is lost and its PR hidden; after the settle time it is abandoned. + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + // Opening B into main is lost too (its POST is refused while A's PR exists, but GitHub's answer is lost). + await expect(harness(store, { live, next, hidden, config: later, open: async () => { throw new Error('timeout'); } }).publisher.publish(identity)).rejects.toThrow('timeout'); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'opening']); + // A's PR shows up in main: B created nothing, so it is dropped at once, well inside its settle time. + hidden.clear(); + const again = harness(store, { live, next }); + await again.publisher.publish(identity); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { state: 'abandoned' }]); + }); + it('pushes nothing while GitHub\'s list does not show a PR that is still open', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + for (const m of live.keys()) hidden.add(m); + const lagging = harness(store, { live, next, hidden }); + await expect(lagging.publisher.publish(identity)).rejects.toThrow(/#100 is open, but GitHub's pull request list does not show it yet/); + expect(lagging.log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + expect(store.taskPullRequests(identity)).toHaveLength(1); + // A PR a person moved is not list lag: a retry would never see it, so the refusal says what to do, and the PR is + // made a draft where it is while its marker still identifies it. + const moved = harness(store, { live, next, hidden, moved: new Set([100]) }); + await expect(moved.publisher.publish(identity)).rejects.toThrow(/#100 is open, but no longer from .* Close it, or restore.*It is now a draft\./); + expect(moved.log.some(line => line.startsWith('push') || line.startsWith('open'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + // Moved again, the draft already recorded: a retry does not move the task's version. + const version = store.getTask(identity).stateVersion; + await expect(harness(store, { live, next, hidden, moved: new Set([100]) }).publisher.publish(identity)).rejects.toThrow(/It is now a draft\./); + expect(store.getTask(identity).stateVersion).toBe(version); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + // An abort during that draft change rejects with the abort, not the refusal. + const controller = new AbortController(); + const aborted = await harness(store, { live, next, hidden, moved: new Set([100]), draftFails: true, onDraft: () => controller.abort() }).publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(aborted).not.toBeInstanceOf(PullRequestMisplaced); + // Retargeted while the list lags: also moved, not lag, and drafted in its new base. + baseOf.get(live)!.set([...live.keys()][0]!, 'develop'); + await expect(harness(store, { live, next, hidden }).publisher.publish(identity)).rejects.toThrow(/It is now a draft\./); + baseOf.get(live)!.delete([...live.keys()][0]!); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + // Its marker removed: refused, and said to be possibly still ready, since nothing identifies it any more. + const unmarked = harness(store, { live, next, hidden, unmarked: new Set([100]) }); + await expect(unmarked.publisher.publish(identity)).rejects.toThrow(/first line no longer identifies it/); + expect(unmarked.log.some(line => line.startsWith('draft'))).toBe(false); + // Once the list shows it, the update goes ahead. + hidden.clear(); + expect(await harness(store, { live, next }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + }); + it('refuses the task\'s own PR whose marker a person removed as misplaced, and pushes nothing', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + const again = harness(store, { live, next, unmarked: new Set([100]) }); + // The gateway's message is tested at the gateway; here, the class and what the publisher does with it. + await expect(again.publisher.publish(identity)).rejects.toThrow(PullRequestMisplaced); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('refresh'))).toBe(false); + // Nothing identifies it any more, so it cannot be drafted: the record still says ready, as GitHub does. + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false }]); + }); + it('records no match after an abort during the draft change a match makes', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, controller = new AbortController(); + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + const found: AlreadyFixedResult = { outcome: 'found', baseHead: oid(9), matches: [{ kind: 'closed', by: 'owner/repo#5' }] }; + const again = harness(store, { live, next, results: [found], onDraft: () => controller.abort(new Error('cancelled')) }); + const error = await again.publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(error.message).toBe('cancelled'); + expect(again.log).toContain('draft 100'); + expect(store.getTask(identity).status).toBe('running'); + }); + it('leaves the PR ready when the task is approved during the direct read or recovery\'s lookup', async () => { + // The direct read of a moved PR. + let store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + const approve = (task: Store) => () => task.transitionTask(identity, task.getTask(identity).stateVersion, 'approved but merge blocked'); + let again = harness(store, { live, next, hidden: new Set(live.keys()), moved: new Set([100]), onRead: approve(store) }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/left as it is: the task is now in review, approved or merged/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + // Recovery of a lost draft opening made ready meanwhile. + store = runningTask(); live = new Map(); next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + for (const [m, pr] of live) live.set(m, { ...pr, draft: false }); + again = harness(store, { live, next, onFind: approve(store) }); + await expect(again.publisher.publish(identity, { problems: ['x'] })).rejects.toThrow(GuardRefusal); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(live.get([...live.keys()][0]!)).toMatchObject({ draft: false }); + }); + it('writes nothing after an abort during recovery\'s lookup or the direct read of a PR the list does not show', async () => { + // Recovery: the lost opening stays owned, nothing is recorded or abandoned. + let store = runningTask(), controller = new AbortController(); + let live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + const before = store.getTask(identity).stateVersion; + const recovering = await harness(store, { live, next, config: { now: () => Date.now() + 10 * 60_000 }, onFind: () => controller.abort(new Error('cancelled')) }) + .publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(recovering.message).toBe('cancelled'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opening' }]); + expect(store.getTask(identity).stateVersion).toBe(before); + // The direct read: the abort, not "try again later", and no draft change. + store = runningTask(); controller = new AbortController(); live = new Map(); next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + const hidden = new Set(live.keys()); + const reading = harness(store, { live, next, hidden, onRead: () => controller.abort(new Error('cancelled')) }); + const read = await reading.publisher.publish(identity, {}, controller.signal).catch(e => e); + expect(read.message).toBe('cancelled'); + expect(reading.log.some(line => line.startsWith('draft') || line.startsWith('push'))).toBe(false); + }); + /** A lost update of the task's PR #100, then a person cancels the task. */ + async function lostUpdateThenCancel() { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next }).publisher.publish(identity); + requeue(store); + await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow(/timeout/); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const copy = { ...live.get([...live.keys()][0]!)!, number: 999, marker: [...live.keys()][0]! }; + return { store, live, next, copy }; + } + it('settles a lost update by its recorded number when a copy carries its marker, and still drafts the stopped task\'s PR', async () => { + const { store, live, next, copy } = await lostUpdateThenCancel(); + const again = harness(store, { live, next, copies: [copy] }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(again.log).not.toContain('draft 999'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: null }]); + }); + it('records what GitHub shows for the update\'s own PR even when a copy is listed first', async () => { + const { store, live, next, copy } = await lostUpdateThenCancel(); + // A person made #100 a draft meanwhile; the copy is still ready. + for (const [m, pr] of live) live.set(m, { ...pr, draft: true }); + const again = harness(store, { live, next, copies: [{ ...copy, draft: false }] }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + // The draft flag GitHub shows for #100 is recorded, so no draft change is owed. + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, refresh: null }]); + }); + it('records nothing from a copy when the update\'s own PR was closed', async () => { + const { store, live, next, copy } = await lostUpdateThenCancel(); + const again = harness(store, { live, next, copies: [copy], closed: new Set([100]) }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log.some(line => line.startsWith('draft'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, refresh: null }]); + }); + it('keeps a lost opening owned when two PRs carry its marker, and says its PR may still be ready', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + const copy = { ...live.get([...live.keys()][0]!)!, number: 999, marker: [...live.keys()][0]! }; + const again = harness(store, { live, next, copies: [copy] }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/#(100|999) and #(100|999) carry the first line .* cannot tell which is its own.*may still be ready/); + expect(again.log.some(line => line.startsWith('draft') || line.startsWith('push'))).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opening' }]); + }); + it('keeps a young lost opening owned when the task\'s only visible PR is in another base', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }, hidden = new Set(); + const later = { now: () => Date.now() + 10 * 60_000 }; + // Opening A into develop is lost and its PR hidden; after the settle time it is abandoned. + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity)).rejects.toThrow('timeout'); + for (const m of live.keys()) hidden.add(m); + // Opening B into main is lost too. + await expect(harness(store, { live, next, hidden, openTimesOut: true, config: later }).publisher.publish(identity)).rejects.toThrow('timeout'); + const b = [...live.keys()][1]!; + hidden.clear(); hidden.add(b); + // A's PR shows up, in develop. It cannot have blocked B's POST into main, so B may still be in flight: it stays owned. + await expect(harness(store, { live, next, hidden }).publisher.publish(identity)).rejects.toThrow(OpeningUnsettled); + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'opening']); + }); + it('drafts every ready PR of a stopped task, one per base', async () => { + const { store, live, next, closed } = await twoOwnPullRequests(); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, next, closed }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled\.$/); + expect(again.log).toEqual(expect.arrayContaining(['draft 100', 'draft 101'])); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }, { number: 101, draft: true }]); + }); + it('refuses to push or update while two of the task\'s PRs are open', async () => { + const { store, live, next, closed } = await twoOwnPullRequests(); + requeue(store); + const again = harness(store, { live, next, closed }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/More than one of the task's pull requests/); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('refresh') || line.startsWith('open'))).toBe(false); + // Neither stays ready while a person decides which one to keep. + expect(again.log).toEqual(expect.arrayContaining(['draft 100', 'draft 101'])); + }); + it('drafts a stopped task\'s PR left in its old base after a base change, without a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next, config: { baseBranch: 'develop' } }).publisher.publish(identity); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled\.$/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('turns a lost draft opening back into a draft in the configured base after a base change and a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity, { problems: ['x'] })).rejects.toThrow('timeout'); + const bases = baseOf.get(live)!; + for (const [m, pr] of live) { live.set(m, { ...pr, draft: false }); bases.set(m, 'main'); } + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ kind: 'opened', number: 100, draft: true }); + expect(again.log).toContain('draft 100'); + }); + it('drafts a recovered ready PR whose head differs in the configured base after a base change and a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true, config: { baseBranch: 'develop' } }).publisher.publish(identity)).rejects.toThrow('timeout'); + const bases = baseOf.get(live)!; + for (const [m, pr] of live) { live.set(m, { ...pr, headSha: oid(77) }); bases.set(m, 'main'); } + const again = harness(store, { live, next }); + const outcome = await again.publisher.publish(identity); + expect(outcome).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); + expect(outcome).not.toHaveProperty('leftReady'); + }); + it('drafts a stopped task\'s PR in the configured base after a base change and a retarget', async () => { + const store = runningTask(), live = new Map(), next = { value: 100 }; + await harness(store, { live, next, config: { baseBranch: 'develop' } }).publisher.publish(identity); + const bases = baseOf.get(live)!; + for (const m of live.keys()) bases.set(m, 'main'); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + const again = harness(store, { live, next }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(again.log).toContain('draft 100'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); + }); + it('refuses gateways configured for another repository', () => { + const store = runningTask(); + const { pulls } = harness(store); + const pusher: BranchPusher = { async push() {} }; + const checks: AlreadyFixedGateway = { repository: 'other/repo', async check() { return { outcome: 'clear', baseHead: oid(9) }; } }; + expect(() => new PullRequestPublisher(store, { checks, pulls, pusher }, config)).toThrow(/same repository/); + expect(() => new PullRequestPublisher(store, { checks: { ...checks, repository: 'Owner/Repo' }, pulls: { ...pulls, repository: 'other/repo' }, pusher }, config)).toThrow(/same repository/); + expect(() => new PullRequestPublisher(store, { checks: { ...checks, repository: 'Owner/Repo' }, pulls: { ...pulls, repository: 'owner/repo' }, pusher }, config)).not.toThrow(); + }); +});