From cba0a9b2f2eda49ad02ab333cfaf64019143ba51 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 08:49:46 -0700 Subject: [PATCH 01/65] F2d: check whether the issue is already fixed, then open the task's PR Before opening a PR, check the issue's close, links from other open or merged PRs, and new base commits that mention it. Exclude the task's own PRs by repository and number and its own commits by SHA. Fail closed past every bound. A match or an incomplete check opens nothing and moves a running task to possibly already fixed. Otherwise push the task head, re-read the task right before the GitHub call, and open the PR (a draft with open problems for needs human). Record each opening first, with a marker in the description, so a lost outcome is recovered instead of opening twice. A later run reuses the task's still-open PR. Plan text and problems are fenced so closing keywords and mentions in them do nothing. Co-Authored-By: Claude Opus 5.5 --- core/pull-request-body.ts | 64 +++++ docs/implementation/pull-request-opening.md | 93 ++++++ github/already-fixed.ts | 199 +++++++++++++ github/merge.ts | 2 +- github/pull-requests.ts | 95 +++++++ runner/publish.ts | 103 +++++++ runner/store.ts | 136 ++++++++- test/already-fixed.test.ts | 108 +++++++ test/publish.test.ts | 296 ++++++++++++++++++++ 9 files changed, 1093 insertions(+), 3 deletions(-) create mode 100644 core/pull-request-body.ts create mode 100644 docs/implementation/pull-request-opening.md create mode 100644 github/already-fixed.ts create mode 100644 github/pull-requests.ts create mode 100644 runner/publish.ts create mode 100644 test/already-fixed.test.ts create mode 100644 test/publish.test.ts diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts new file mode 100644 index 00000000..ce21d152 --- /dev/null +++ b/core/pull-request-body.ts @@ -0,0 +1,64 @@ +import type { Plan } from './plan.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. + */ +export function fenced(text: string): string { + const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length)); + const fence = '`'.repeat(Math.max(3, longest + 1)); + return `${fence}text\n${text.replace(/\r\n?/g, '\n')}\n${fence}`; +} + +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'); +} + +/** Single line, no control characters, bounded. */ +export function pullRequestTitle(plan: Plan): string { + const summary = plan.summary.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const suffix = ` (#${plan.issue})`; + return (summary.length + suffix.length > MAX_TITLE ? `${summary.slice(0, MAX_TITLE - suffix.length - 1)}…` : summary) + 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 => problem.length > MAX_PROBLEM ? `${problem.slice(0, MAX_PROBLEM)}…` : 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(planText(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/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md new file mode 100644 index 00000000..b326896d --- /dev/null +++ b/docs/implementation/pull-request-opening.md @@ -0,0 +1,93 @@ +# 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. + +## 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 `` at the top of the PR description. | +| 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 | +|---|---|---| +| Something other than this task closed the issue. | The issue state and its latest close event (GraphQL). | Closed by an own PR or an own commit. A reopened issue. | +| Another open or merged PR links to the issue. | Cross-reference and "connected" timeline events. | Own PRs, matched by repository and number. Closed, unmerged PRs. | +| 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 commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that links the issue counts as a match. It is not excluded by number, because its number belongs to another repository. + +**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; +- 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. + +A cancelled check throws. It does not return `unknown`. + +## Publishing + +Publish runs these steps in order: + +1. **Recover.** If an opening is still `opening`, look for an open PR from the task branch whose description has its marker. If one exists, record it as opened. If none exists, mark the opening `abandoned` and continue. +2. **No changes.** If the task head is its base, open nothing. A running task moves to needs human. +3. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. +4. **Find the earlier PR.** If the task has an opened PR on the same branch, ask GitHub whether it is still open. +5. **Push.** Push the task head to `codeboost/issue--`. +6. **Re-read.** Right before the GitHub call, the Store confirms that the latest check is clear and that the task is unchanged since that check: same state version, same snapshot, same head. +7. **Open or reuse.** Open a new PR, or update the open earlier PR and mark it ready. Record the result. + +After step 7, the task moves as follows: + +| Task status before publish | PR | Status after | +|---|---|---| +| running | Opened or reused, head as pushed | in review | +| running | Head on GitHub differs from the pushed head | needs human | +| needs human | Draft opened or updated | needs human | +| cancelled (during the call) | Opened | cancelled; the PR is recorded so it can be closed later | + +## 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 close other issues or notify people. The fence is longer than any run of backticks in the text, so the text cannot end the block. + +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. + +## What this slice does not do + +- **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, reuse of the earlier PR, lost-opening recovery, 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? +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..a8941e59 --- /dev/null +++ b/github/already-fixed.ts @@ -0,0 +1,199 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { RunGh } from './merge.ts'; + +const runFile = promisify(execFile); + +/** + * The pre-PR "already fixed" check (design, "Checking whether the issue is already fixed"). It reports a match when + * something other than this task closed the issue, when another open or merged PR links to the issue, or when a new + * commit on the base branch mentions it. The task's own PRs and commits are excluded by repository and number or by + * SHA only. 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 { check(input: AlreadyFixedInput, signal?: AbortSignal): Promise } + +export const MAX_TIMELINE_ITEMS = 100; +export const MAX_BASE_COMMITS = 250; +const PAGE = 100; + +const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + nameWithOwner + issue(number: $number) { + state + timelineItems(first: ${MAX_TIMELINE_ITEMS}, itemTypes: [CLOSED_EVENT, CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + totalCount + pageInfo { hasNextPage } + nodes { + __typename + ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } + ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } + ... on ConnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } + } + } + } + } +}`; + +class Unknown extends Error {} +const SHA = /^[a-f0-9]{40}$/; +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' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.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. + */ +export function mentionsIssue(message: string, repository: string, issue: number): boolean { + const n = String(issue), repo = escape(repository); + return new RegExp(`(? (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal })).stdout); + } + + 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 (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? !Number.isSafeInteger(number) || number < 1)) throw new Error('Invalid pull request number.'); + try { + const matches = await this.#timeline(input, signal); + const { baseHead, commits } = await this.#baseCommits(input, signal); + 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: commit.message.split('\n', 1)[0]!.slice(0, 200) }); + } + return matches.length ? { outcome: 'found', baseHead, matches } : { outcome: 'clear', baseHead }; + } catch (error) { + if (signal?.aborted) throw error; + 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 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); + const matches: AlreadyFixedMatch[] = [], seen = new Set(); + 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') { + const repo = repositoryName(closer.repository), number = positive(closer.number, 'pull request number'); + lastCloser = isOwn(repo, number) ? null : `${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) ? null : `commit ${closer.oid}`; + } else throw new Unknown('GitHub returned an unknown closer.'); + continue; + } + const field = node.__typename === 'CrossReferencedEvent' ? 'source' : node.__typename === 'ConnectedEvent' ? 'subject' : null; + if (!field) throw new Unknown('GitHub returned an unexpected timeline event.'); + const source = object(node[field], 'linked item'); + if (source.__typename === 'Issue') continue; + if (source.__typename !== 'PullRequest') throw new Unknown('GitHub returned an unknown linked item.'); + 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 (isOwn(repo, number) || source.state === 'CLOSED') continue; + const key = `${repo.toLowerCase()}#${number}`; + if (seen.has(key)) continue; + seen.add(key); + matches.push({ kind: 'pull request', repository: repo, number, state: source.state as 'OPEN' | 'MERGED', draft: source.isDraft }); + } + 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.'); + commits.push({ sha: entry.sha, message }); + } + if (total === 0) break; + } + return { baseHead, commits }; + } +} 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..1e886cc0 --- /dev/null +++ b/github/pull-requests.ts @@ -0,0 +1,95 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { RunGh } from './merge.ts'; + +const runFile = promisify(execFile); + +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 { + open(input: OpenPullRequestInput, signal?: AbortSignal): Promise; + /** The open PR from `headBranch` into `base` whose description carries `marker`, or null when there is none. */ + findOpened(input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise; + /** Replaces the title and description of an open PR codeboost opened, and marks it ready for review when `ready`. */ + refresh(number: number, input: OpenPullRequestInput & { ready: boolean }, signal?: AbortSignal): Promise; +} + +const SHA = /^[a-f0-9]{40}$/; +const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal })).stdout); + } + + async #json(args: readonly string[], signal?: AbortSignal): Promise { + const output = await this.run(args, { signal }); + 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; 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.'); + 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 }): void { + if (!BRANCH.test(input.base) || !BRANCH.test(input.headBranch)) throw new Error('Invalid branch name.'); + if (!/^$/.test(input.marker)) throw new Error('Invalid pull request marker.'); + } + + async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { + this.#validate(input); + if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); + const response = await this.#json(['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls`, + '-f', `title=${input.title}`, '-f', `body=${input.body}`, '-f', `head=${input.headBranch}`, '-f', `base=${input.base}`, '-F', `draft=${input.draft}`], signal); + const { body, ...pr } = this.#pull(response, input); + if (!body.includes(input.marker)) throw new Error('GitHub returned a pull request without its marker.'); + return pr; + } + + async findOpened(input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise { + this.#validate(input); + const owner = this.repository.split('/')[0]!; + const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, base: input.base, per_page: '100' }); + const response = await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls?${query}`], signal); + // GitHub allows one open PR per head and base, so more than one result is a malformed response. + if (!Array.isArray(response) || response.length > 1) throw new Error('GitHub returned an invalid pull request list.'); + if (!response.length) return null; + const { body, ...pr } = this.#pull(response[0], input); + if (!body.includes(input.marker)) throw new Error(`An open pull request from ${input.headBranch} exists that codeboost did not open.`); + return pr; + } + + async refresh(number: number, input: OpenPullRequestInput & { ready: boolean }, signal?: AbortSignal): Promise { + this.#validate(input); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); + if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); + const patched = this.#pull(await this.#json(['api', '-X', 'PATCH', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`, + '-f', `title=${input.title}`, '-f', `body=${input.body}`], signal), input); + if (patched.number !== number || !patched.body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + if (input.ready && patched.draft) await this.run(['pr', 'ready', String(number), '--repo', this.repository], { signal }); + 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + return pr; + } +} diff --git a/runner/publish.ts b/runner/publish.ts new file mode 100644 index 00000000..90564aa3 --- /dev/null +++ b/runner/publish.ts @@ -0,0 +1,103 @@ +import type { PlanIdentity } from '../core/identity.ts'; +import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; +import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; +import type { PullRequestGateway } from '../github/pull-requests.ts'; +import { GuardRefusal } from './lifecycle.ts'; +import type { Store, TaskPullRequest } from './store.ts'; + +/** + * F2d: the pre-PR "already fixed" check and PR opening (design, "Checking whether the issue is already fixed" and + * "Needs human"). Pushing the task head to its branch is D's export plus a runner push; until that exists it is injected. + */ +export interface BranchPusher { + /** Makes `refs/heads/` 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 } +export type PublishOutcome = + | { kind: 'opened'; number: number; url: string; draft: boolean; status: string } + | { kind: 'possibly already fixed'; result: AlreadyFixedResult } + /** A needs-human task whose check matched: no draft PR is opened, and the task stays in needs human. */ + | { kind: 'draft skipped'; result: AlreadyFixedResult } + /** The task head is its base: there is nothing to open a PR for. A running task moves to needs human. */ + | { kind: 'no changes' }; + +const marker = (openingId: string) => ``; + +export class PullRequestPublisher { + #store: Store; #checks: AlreadyFixedGateway; #pulls: PullRequestGateway; #pusher: BranchPusher; #config: PublishConfig; + constructor(store: Store, deps: { checks: AlreadyFixedGateway; pulls: PullRequestGateway; pusher: BranchPusher }, config: PublishConfig) { + this.#store = store; this.#checks = deps.checks; this.#pulls = deps.pulls; this.#pusher = deps.pusher; this.#config = config; + } + + /** The task's branch. The task ID keeps branches of different tasks for the same issue apart. */ + branch(identity: PlanIdentity): string { + const plan = this.#store.getPlan(identity); + const task = identity.taskId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'task'; + return `codeboost/issue-${plan.issue}-${task}`; + } + + /** + * 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 { + const draft = input.problems !== undefined; + const recovered = await this.#recover(identity, signal); + if (recovered) return recovered; + const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); + if (snapshot.head === snapshot.base) { + if (!draft) this.#store.transitionTask(identity, task.stateVersion, 'needs human'); + return { kind: 'no changes' }; + } + const prs = this.#store.taskPullRequests(identity); + const result = await this.#checks.check({ + issue: plan.issue, taskBase: snapshot.base, baseBranch: this.#config.baseBranch, + ownPullRequests: prs.filter(pr => pr.number !== null && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).map(pr => pr.number!), + ownCommits: new Set(this.#store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha)), + }, signal); + signal?.throwIfAborted(); + const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); + if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; + const branch = this.branch(identity); + // The task's earlier PR (a needs-human draft) is reused while it is still open: GitHub allows one open PR per branch. + const earlier = prs.filter(pr => pr.state === 'opened' && pr.headBranch === branch && pr.base === this.#config.baseBranch + && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).at(-1); + const live = earlier ? await this.#pulls.findOpened({ base: earlier.base, headBranch: branch, marker: marker(earlier.openingId) }, signal) : null; + signal?.throwIfAborted(); + await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); + signal?.throwIfAborted(); + if (earlier && live) { + if (live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); + this.#store.assertReadyToRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); + const pr = await this.#pulls.refresh(live.number, { + base: earlier.base, headBranch: branch, draft, ready: !draft, marker: marker(earlier.openingId), + title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), + }, signal); + const status = this.#store.recordPullRequestOpened(identity, earlier.openingId, pr, snapshot.head); + return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + } + // The last await before the irreversible call is behind us: beginPullRequest re-reads the task state in its transaction. + const opening = this.#store.beginPullRequest(identity, { + checkId: check.id, repository: this.#config.repository, base: this.#config.baseBranch, headBranch: branch, headSha: snapshot.head, draft, + }); + const 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); + const status = this.#store.recordPullRequestOpened(identity, opening.openingId, pr); + return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + } + + /** An opening whose GitHub outcome was lost (a crash or a timeout): adopt the PR if GitHub has it, else abandon it. */ + async #recover(identity: PlanIdentity, signal?: AbortSignal): Promise { + const lost = this.#store.taskPullRequests(identity).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 pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); + signal?.throwIfAborted(); + if (!pr) { this.#store.abandonPullRequestOpening(identity, lost.openingId); return null; } + const status = this.#store.recordPullRequestOpened(identity, lost.openingId, pr); + return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + } +} diff --git a/runner/store.ts b/runner/store.ts index f7d1506a..c424dc95 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, CLOSED_STATUSES, MERGEABLE_STATUSES, DEFAULT_TASK_BUDGET_MS, FIRST_REASONS, GuardRefusal, ActionIdReused, RefusalWithEffect, MAX_RESULT_BYTES, TASK_STATUSES, TERMINAL_STATES, assertUuidV4, bounded, classifySettlement, requestHash, sameContext, @@ -23,6 +24,12 @@ 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; +} +export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: 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 +85,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 +115,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; } } @@ -870,4 +878,128 @@ 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, 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, + state TEXT NOT NULL CHECK (state IN ('opening','opened','abandoned')), number INTEGER, url TEXT, + 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'; + 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, + }; + } + /** 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, 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.'); + } + /** + * 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; 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; + this.#run('INSERT INTO already_fixed_checks (id,plan_key,snapshot_id,outcome,result,state_version,checked_at) VALUES (?,?,?,?,?,?,?)', + id, key, input.snapshotId, input.result.outcome, encode(input.result), stateVersion, checkedAt); + return { id, snapshotId: input.snapshotId, result: input.result, stateVersion, 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.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,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, now, now); + this.#touch(key); + return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; + }); + } + /** The same guard for reusing the task's open PR: nothing changed since a clear check of this head. */ + assertReadyToRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): void { + const key = identityKey(identity); + this.#transaction(() => { + this.#assertCheckedHead(identity, input); + if (!this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened'", key, input.openingId)) throw new GuardRefusal('Unknown pull request.'); + }); + } + #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); + const snapshot = this.getSnapshot(identity); + if (snapshot.id !== check.snapshotId || snapshot.head !== input.headSha) throw new GuardRefusal('The task head changed after the check.'); + } + /** + * The PR exists. The record is kept even if the task closed meanwhile, so the PR can still be found and closed; only + * a running task moves to in review (or needs human, when the pushed head moved before GitHub read it). + */ + recordPullRequestOpened(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, refreshedHead?: string): TaskStatus { + 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(() => { + // Opening completes an `opening` row; a refresh updates the task's existing PR to the head it was checked at. + const row = refreshedHead === undefined + ? this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opening'", key, openingId) + : this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened' AND number=?", key, openingId, pr.number); + if (!row) throw new GuardRefusal('No pull request is being opened with this ID.'); + const expectedHead = refreshedHead ?? row.head_sha as string; + this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, updated_at=? WHERE opening_id=?", + pr.number, pr.url, pr.draft ? 1 : 0, expectedHead, new Date().toISOString(), openingId); + const task = this.#task(key); + if (task.status === 'running' && !this.#activeAttempt(key)) { + this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === expectedHead && !pr.draft ? 'in review' : 'needs human', key); + } + this.#touch(key); + return this.#task(key).status as TaskStatus; + }); + } + /** 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); + }); + } } diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts new file mode 100644 index 00000000..913a9dce --- /dev/null +++ b/test/already-fixed.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { 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, repository: { nameWithOwner: repo }, ...extra }); +const cross = (source: unknown) => ({ __typename: 'CrossReferencedEvent', source }); +const connected = (subject: unknown) => ({ __typename: 'ConnectedEvent', subject }); +const closed = (closer: unknown) => ({ __typename: 'ClosedEvent', closer }); + +interface Fake { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; + commits?: { sha: string; message: string }[]; status?: string; totalCommits?: number; 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', 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: sha(99) } }); + const page = Number(/[?&]page=(\d+)/.exec(joined)![1]); + return JSON.stringify({ status: fake.status ?? 'ahead', total_commits: 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('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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) + 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']) + 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 () => { + 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("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 someone else, and ignores one closed by an own PR or commit', 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` }] }); + expect(await gateway({ state: 'CLOSED', nodes: [closed(pr(9))] }).gh.check(input({ ownPullRequests: [9] }))).toMatchObject({ outcome: 'clear' }); + expect(await gateway({ state: 'CLOSED', nodes: [closed({ __typename: 'Commit', oid: sha(3) })] }).gh.check(input({ ownCommits: new Set([sha(3)]) }))).toMatchObject({ outcome: 'clear' }); + // Only the latest close counts: an earlier close by someone else was followed by a reopen and an own close. + expect(await gateway({ state: 'CLOSED', nodes: [closed(null), closed(pr(9))] }).gh.check(input({ ownPullRequests: [9] }))).toMatchObject({ outcome: 'clear' }); + // A reopened issue does not count as closed. + expect(await gateway({ state: 'OPEN', nodes: [closed(null)] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); + }); + 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 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 }, + { errors: [{ message: 'rate limited' }] }, { 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('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..4188d66e --- /dev/null +++ b/test/publish.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import { Store } from '../runner/store.ts'; +import { GuardRefusal } from '../runner/lifecycle.ts'; +import { PullRequestPublisher, type BranchPusher } from '../runner/publish.ts'; +import { GhPullRequestGateway, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; +import type { AlreadyFixedGateway, AlreadyFixedInput, AlreadyFixedResult } from '../github/already-fixed.ts'; +import { fenced, 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 = { repository: 'owner/repo', baseBranch: 'main' }; + +/** A task whose last attempt settled while it runs, with head `oid(2)` over base `oid(1)` and one owned commit. */ +function runningTask(options: { head?: string } = {}) { + const store = new Store(':memory:'); + 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; +} + +/** `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 } } = {}) { + const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }; + const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; + const results = options.results ?? [{ outcome: 'clear', baseHead: oid(9) }]; + const gate: AlreadyFixedGateway = { async check(input) { log.push('check'); checks.push(input); return results.shift() ?? { 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); + 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); return pr; + }, + async findOpened(input) { log.push(`find ${input.marker}`); return options.found !== undefined ? options.found : live.get(input.marker) ?? null; }, + async refresh(number, input) { + log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); + const pr = { ...live.get(input.marker)!, draft: input.draft, headSha: store.getSnapshot(identity).head }; + live.set(input.marker, pr); return pr; + }, + }; + const pusher: BranchPusher = { async push(id, input, signal) { log.push(`push ${input.branch} ${input.head.slice(-3)}`); await options.push?.(id, input, signal); } }; + return { log, checks, opened, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, config) }; +} + +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(['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: 'codeboost/issue-12-task-42', 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(['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(['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(['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' }); + expect(log).toEqual([]); + 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 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 refresh() { 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('moves to needs human when the branch head moved before GitHub read it', async () => { + const store = runningTask(); + const { publisher } = harness(store, { open: async input => ({ number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }) }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', status: 'needs human' }); + }); +}); + +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(); + const { publisher } = harness(store, { open: async input => { + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + return { number: 9, url: 'https://github.com/owner/repo/pull/9', headSha: oid(2), draft: input.draft }; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 9, status: 'cancelled' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 9 }]); + }); + 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, 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', draft: false, result: { outcome: 'clear', baseHead: oid(9) } })).toThrow(/head changed/); + store.beginPullRequest(identity, pr); + expect(() => store.beginPullRequest(identity, pr)).toThrow(GuardRefusal); + }); +}); + +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([`find `]); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 55 }]); + }); + it('abandons an opening GitHub never saw, then 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'); + const second = harness(store); + expect(await second.publisher.publish(identity)).toMatchObject({ kind: 'opened', status: 'in review' }); + expect(second.log).toEqual([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(['check', `find `, 'push codeboost/issue-12-task-42 003', 'refresh 100 ready']); + expect(again.opened[0]!.body).not.toContain('Needs human'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, headSha: oid(3), state: 'opened' }]); + }); + 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('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('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('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('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('GitHub PR adapter', () => { + const marker = ''; + const response = (over: Record = {}) => ({ number: 7, html_url: 'https://github.com/owner/repo/pull/7', 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 and validates the answer', async () => { + const calls: string[][] = []; + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { calls.push([...args]); 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', + '-f', 'title=T', '-f', `body=${marker}\nplan`, '-f', 'head=codeboost/issue-12-task', '-f', 'base=main', '-F', 'draft=true']); + }); + 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' }, { 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']); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ number: 8 }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/different/); + }); + 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)).toMatchObject({ number: 7 }); + expect(calls[0]!.at(-1)).toBe('repos/owner/repo/pulls?state=open&head=owner%3Acodeboost%2Fissue-12-task&base=main&per_page=100'); + expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '[]').findOpened(input)).toBeNull(); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: 'someone else' })])).findOpened(input)).rejects.toThrow(/did not open/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened(input)).rejects.toThrow(); + }); +}); From 7e3830bf5e7b7e07cdcb4ee2b7ceb01713926e38 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 09:11:59 -0700 Subject: [PATCH 02/65] Address review round 1 on F2d: ownership, lost openings, stale responses - Branch names end in a hash of the exact task identity, so tasks whose IDs normalize alike never share a branch or PR. - gh runs with an allowlisted environment only. - The PR adapter refuses answers for a PR that is not open. - Publishing checks the task status before the no-changes shortcut. - An opening GitHub does not show yet stays owned until its settle time has passed; publish reports OpeningUnsettled instead of posting again. - Each opening or refresh owns the task state version it was guarded at; a response for an older version is recorded but never moves the task. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 29 +++++---- github/already-fixed.ts | 3 +- github/gh-env.ts | 17 ++++++ github/pull-requests.ts | 7 ++- runner/publish.ts | 40 ++++++++++--- runner/store.ts | 35 +++++++---- test/publish.test.ts | 65 +++++++++++++++++---- 7 files changed, 151 insertions(+), 45 deletions(-) create mode 100644 github/gh-env.ts diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index b326896d..6a74f1e7 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -9,7 +9,8 @@ - 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. +- 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 @@ -48,22 +49,26 @@ A cancelled check throws. It does not return `unknown`. Publish runs these steps in order: -1. **Recover.** If an opening is still `opening`, look for an open PR from the task branch whose description has its marker. If one exists, record it as opened. If none exists, mark the opening `abandoned` and continue. -2. **No changes.** If the task head is its base, open nothing. A running task moves to needs human. +1. **Recover.** If an opening is still `opening`, look for an open PR from the task branch whose description has its marker. If one exists, record it as opened. If none exists, 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. +2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. 3. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. 4. **Find the earlier PR.** If the task has an opened PR on the same branch, ask GitHub whether it is still open. -5. **Push.** Push the task head to `codeboost/issue--`. +5. **Push.** 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. 6. **Re-read.** Right before the GitHub call, the Store confirms that the latest check is clear and that the task is unchanged since that check: same state version, same snapshot, same head. 7. **Open or reuse.** Open a new PR, or update the open earlier PR and mark it ready. Record the result. -After step 7, the task moves as follows: +Each opening or refresh owns the task state version at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. -| Task status before publish | PR | Status after | +| Task during the GitHub call | PR | Status after | |---|---|---| -| running | Opened or reused, head as pushed | in review | -| running | Head on GitHub differs from the pushed head | needs human | -| needs human | Draft opened or updated | needs human | -| cancelled (during the call) | Opened | cancelled; the PR is recorded so it can be closed later | +| Unchanged, running | Open, head as pushed | in review | +| Unchanged, running | Open, head on GitHub differs from the pushed head | needs human | +| Unchanged, needs human | Draft opened or updated | needs human | +| Changed (cancelled, reassigned, new attempt, new head) | Opened | Unchanged; the PR is recorded so it can be reused or closed later | + +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. + +**Environment.** Each `gh` process gets only an allowlist of variables: the path, home and locale; GitHub tokens, host and configuration directories; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. ## The PR description @@ -82,12 +87,12 @@ The description stays under 60,000 characters. If the full plan is too long, onl ## 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, reuse of the earlier PR, lost-opening recovery, the fence length, and the description bounds. +`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, 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? +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 index a8941e59..0ed0d47d 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { RunGh } from './merge.ts'; +import { ghEnvironment } from './gh-env.ts'; const runFile = promisify(execFile); @@ -91,7 +92,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { constructor(config: GhAlreadyFixedConfig, run?: RunGh) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repository)) throw new Error('A GitHub repository is required for the already-fixed check.'); this.repository = config.repository; - this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal })).stdout); + this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })).stdout); } async #json(args: readonly string[], signal?: AbortSignal): Promise { diff --git a/github/gh-env.ts b/github/gh-env.ts new file mode 100644 index 00000000..22e68b10 --- /dev/null +++ b/github/gh-env.ts @@ -0,0 +1,17 @@ +/** + * 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', + 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy', 'SSL_CERT_FILE', 'SSL_CERT_DIR', +] as const; + +export function ghEnvironment(source: NodeJS.ProcessEnv = process.env): 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. + return { ...env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: 'cat', NO_COLOR: '1' }; +} diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 1e886cc0..a16205a8 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { RunGh } from './merge.ts'; +import { ghEnvironment } from './gh-env.ts'; const runFile = promisify(execFile); @@ -32,7 +33,7 @@ export class GhPullRequestGateway implements PullRequestGateway { constructor(config: { repository: string }, run?: RunGh) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repository)) throw new Error('A GitHub repository is required to open pull requests.'); this.repository = config.repository; - this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal })).stdout); + this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })).stdout); } async #json(args: readonly string[], signal?: AbortSignal): Promise { @@ -42,13 +43,15 @@ export class GhPullRequestGateway implements PullRequestGateway { } #pull(value: unknown, input: { base: string; headBranch: string }): OpenedPullRequest & { body: string } { - const pr = value as { number?: unknown; html_url?: unknown; draft?: unknown; body?: unknown; head?: { sha?: unknown; ref?: unknown; repo?: { full_name?: unknown } | null }; base?: { ref?: unknown; repo?: { full_name?: unknown } } }; + 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 ?? '' }; } diff --git a/runner/publish.ts b/runner/publish.ts index 90564aa3..01942b96 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -1,4 +1,5 @@ -import type { PlanIdentity } from '../core/identity.ts'; +import { createHash } from 'node:crypto'; +import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; import type { PullRequestGateway } from '../github/pull-requests.ts'; @@ -13,7 +14,14 @@ export interface BranchPusher { /** Makes `refs/heads/` 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 } +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 = | { kind: 'opened'; number: number; url: string; draft: boolean; status: string } | { kind: 'possibly already fixed'; result: AlreadyFixedResult } @@ -30,11 +38,15 @@ export class PullRequestPublisher { this.#store = store; this.#checks = deps.checks; this.#pulls = deps.pulls; this.#pusher = deps.pusher; this.#config = config; } - /** The task's branch. The task ID keeps branches of different tasks for the same issue apart. */ + /** + * 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 task = identity.taskId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'task'; - return `codeboost/issue-${plan.issue}-${task}`; + 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}`; } /** @@ -46,6 +58,7 @@ export class PullRequestPublisher { const recovered = await this.#recover(identity, signal); if (recovered) return recovered; const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); + 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 (snapshot.head === snapshot.base) { if (!draft) this.#store.transitionTask(identity, task.stateVersion, 'needs human'); return { kind: 'no changes' }; @@ -69,12 +82,12 @@ export class PullRequestPublisher { signal?.throwIfAborted(); if (earlier && live) { if (live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); - this.#store.assertReadyToRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); + const stateVersion = this.#store.assertReadyToRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); const pr = await this.#pulls.refresh(live.number, { base: earlier.base, headBranch: branch, draft, ready: !draft, marker: marker(earlier.openingId), title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), }, signal); - const status = this.#store.recordPullRequestOpened(identity, earlier.openingId, pr, snapshot.head); + const status = this.#store.recordPullRequestOpened(identity, earlier.openingId, pr, { head: snapshot.head, stateVersion }); return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } // The last await before the irreversible call is behind us: beginPullRequest re-reads the task state in its transaction. @@ -89,14 +102,23 @@ export class PullRequestPublisher { return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } - /** An opening whose GitHub outcome was lost (a crash or a timeout): adopt the PR if GitHub has it, else abandon it. */ + /** + * 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, signal?: AbortSignal): Promise { const lost = this.#store.taskPullRequests(identity).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 pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); signal?.throwIfAborted(); - if (!pr) { 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; + } const status = this.#store.recordPullRequestOpened(identity, lost.openingId, pr); return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } diff --git a/runner/store.ts b/runner/store.ts index c424dc95..9deecf5a 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -28,6 +28,8 @@ export interface ReviewNote { id: string; item: string; kind: 'question' | 'chan 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; } export interface AlreadyFixedCheck { id: string; snapshotId: string; result: AlreadyFixedResult; stateVersion: number; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -887,7 +889,7 @@ export class Store { state_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, + head_branch TEXT NOT NULL, head_sha TEXT NOT NULL, draft INTEGER NOT NULL, owner_version INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('opening','opened','abandoned')), number INTEGER, url TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK ((state = 'opened') = (number IS NOT NULL AND url IS NOT NULL))); @@ -900,6 +902,7 @@ export class Store { 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, }; } /** Every PR codeboost opened or started to open for the task, oldest first. */ @@ -948,18 +951,23 @@ export class Store { 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.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,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, now, now); this.#touch(key); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_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, now, now); return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } - /** The same guard for reusing the task's open PR: nothing changed since a clear check of this head. */ - assertReadyToRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): void { + /** + * The same guard for reusing the task's open PR: nothing changed since a clear check of this head. Returns the state + * version the refresh owns. + */ + assertReadyToRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): number { const key = identityKey(identity); - this.#transaction(() => { + return this.#transaction(() => { this.#assertCheckedHead(identity, input); if (!this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened'", key, input.openingId)) throw new GuardRefusal('Unknown pull request.'); + return this.#task(key).state_version as number; }); } #assertCheckedHead(identity: PlanIdentity, input: { checkId: string; headSha: string; draft: boolean }): void { @@ -970,10 +978,13 @@ export class Store { if (snapshot.id !== check.snapshotId || snapshot.head !== input.headSha) throw new GuardRefusal('The task head changed after the check.'); } /** - * The PR exists. The record is kept even if the task closed meanwhile, so the PR can still be found and closed; only - * a running task moves to in review (or needs human, when the pushed head moved before GitHub read it). + * The 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 or refresh began (its owned state + * version): then a running task moves to in review, or to needs human when the pushed head moved before GitHub read it. */ - recordPullRequestOpened(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, refreshedHead?: string): TaskStatus { + recordPullRequestOpened(identity: PlanIdentity, openingId: string, pr: { number: number; url: string; headSha: string; draft: boolean }, + refreshed?: { head: string; stateVersion: number }): TaskStatus { + const refreshedHead = refreshed?.head; 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(() => { @@ -985,8 +996,10 @@ export class Store { const expectedHead = refreshedHead ?? row.head_sha as string; this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, updated_at=? WHERE opening_id=?", pr.number, pr.url, pr.draft ? 1 : 0, expectedHead, new Date().toISOString(), openingId); - const task = this.#task(key); - if (task.status === 'running' && !this.#activeAttempt(key)) { + const task = this.#task(key), owned = refreshed?.stateVersion ?? row.owner_version as number; + // Every status change and every admission increases the state version, so an unchanged version means the task is + // still in the status the opening was guarded for (running, or needs human for a draft) with no attempt active. + if (task.state_version === owned) { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === expectedHead && !pr.draft ? 'in review' : 'needs human', key); } this.#touch(key); diff --git a/test/publish.test.ts b/test/publish.test.ts index 4188d66e..0326a5cf 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -import { PullRequestPublisher, type BranchPusher } from '../runner/publish.ts'; +import { OpeningUnsettled, PullRequestPublisher, type BranchPusher, type PublishConfig } from '../runner/publish.ts'; +import { GH_ENV_ALLOWLIST, ghEnvironment } from '../github/gh-env.ts'; import { GhPullRequestGateway, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; import type { AlreadyFixedGateway, AlreadyFixedInput, AlreadyFixedResult } from '../github/already-fixed.ts'; import { fenced, pullRequestBody, pullRequestTitle, MAX_BODY } from '../core/pull-request-body.ts'; @@ -12,7 +13,8 @@ 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 = { repository: 'owner/repo', baseBranch: 'main' }; +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. */ function runningTask(options: { head?: string } = {}) { @@ -30,7 +32,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } } = {}) { + push?: BranchPusher['push']; live?: Map; next?: { value: number }; config?: Partial } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }; const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ?? [{ outcome: 'clear', baseHead: oid(9) }]; @@ -49,8 +51,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: live.set(input.marker, pr); return pr; }, }; - const pusher: BranchPusher = { async push(id, input, signal) { log.push(`push ${input.branch} ${input.head.slice(-3)}`); await options.push?.(id, input, signal); } }; - return { log, checks, opened, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, config) }; + 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); } }; + return { log, checks, opened, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, { ...config, ...options.config }) }; } describe('opening the task PR', () => { @@ -62,7 +64,7 @@ describe('opening the task PR', () => { expect(log).toEqual(['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: 'codeboost/issue-12-task-42', title: 'Stop the crash (#12)' }); + 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 }]); @@ -140,6 +142,33 @@ describe('opening the task PR', () => { 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(); + const { publisher } = harness(store, { open: async input => { + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); + return { number: 6, url: 'https://github.com/owner/repo/pull/6', headSha: oid(2), draft: input.draft }; + } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 6, status: 'running' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 6 }]); + }); it('moves to needs human when the branch head moved before GitHub read it', async () => { const store = runningTask(); const { publisher } = harness(store, { open: async input => ({ number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }) }); @@ -180,10 +209,15 @@ describe('recovering a lost opening', () => { expect(second.log).toEqual([`find `]); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 55 }]); }); - it('abandons an opening GitHub never saw, then checks again and opens a new PR', async () => { + 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'); - const second = harness(store); + // 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(/^find /)]); + 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' }); expect(second.log).toEqual([expect.stringMatching(/^find /), 'check', 'push codeboost/issue-12-task-42 002', 'open ready']); expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['abandoned', 'opened']); @@ -251,9 +285,17 @@ describe('the PR description', () => { }); }); +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); + }); +}); + describe('GitHub PR adapter', () => { const marker = ''; - const response = (over: Record = {}) => ({ number: 7, html_url: 'https://github.com/owner/repo/pull/7', draft: true, body: `${marker}\nplan`, + 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 and validates the answer', async () => { @@ -265,7 +307,7 @@ describe('GitHub PR adapter', () => { }); 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' }, { number: 0 }, { head: { sha: 'x', ref: 'codeboost/issue-12-task', repo: { full_name: 'owner/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(); } @@ -283,6 +325,9 @@ describe('GitHub PR adapter', () => { 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']); await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ number: 8 }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/different/); + // 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)).rejects.toThrow(/not open/); }); 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[][] = []; From 1dcd29e11d187b7db635bcad746b147f7df05bf3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 09:23:28 -0700 Subject: [PATCH 03/65] Keep a needs-human task in needs human when it reuses a ready PR; avoid spreading backtick runs A refresh for a needs-human task turns the earlier PR back into a draft, and the task status changes only from running. fenced() finds the longest backtick run in a loop, so unbounded plan text cannot overflow the stack. Co-Authored-By: Claude Opus 5.5 --- core/pull-request-body.ts | 4 +++- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 4 +++- runner/store.ts | 3 ++- test/publish.test.ts | 26 +++++++++++++++++++-- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index ce21d152..196898ac 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -11,7 +11,9 @@ const MAX_PROBLEMS = 20, MAX_PROBLEM = 2000; * The fence is longer than any backtick run in the text, so the text cannot end the block. */ export function fenced(text: string): string { - const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length)); + // 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.replace(/\r\n?/g, '\n')}\n${fence}`; } diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 6a74f1e7..1de93861 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -63,7 +63,7 @@ Each opening or refresh owns the task state version at the moment it passed step |---|---|---| | Unchanged, running | Open, head as pushed | in review | | Unchanged, running | Open, head on GitHub differs from the pushed head | needs human | -| Unchanged, needs human | Draft opened or updated | needs human | +| Unchanged, needs human | Draft opened, or the earlier PR updated and turned back into a draft | needs human | | Changed (cancelled, reassigned, new attempt, new head) | Opened | Unchanged; the PR is recorded so it can be reused or closed later | 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index a16205a8..0f984e86 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -19,7 +19,7 @@ export interface PullRequestGateway { open(input: OpenPullRequestInput, signal?: AbortSignal): Promise; /** The open PR from `headBranch` into `base` whose description carries `marker`, or null when there is none. */ findOpened(input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise; - /** Replaces the title and description of an open PR codeboost opened, and marks it ready for review when `ready`. */ + /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ refresh(number: number, input: OpenPullRequestInput & { ready: boolean }, signal?: AbortSignal): Promise; } @@ -90,7 +90,9 @@ export class GhPullRequestGateway implements PullRequestGateway { const patched = this.#pull(await this.#json(['api', '-X', 'PATCH', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`, '-f', `title=${input.title}`, '-f', `body=${input.body}`], signal), input); if (patched.number !== number || !patched.body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + // 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 this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); return pr; diff --git a/runner/store.ts b/runner/store.ts index 9deecf5a..06a03572 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -999,7 +999,8 @@ export class Store { const task = this.#task(key), owned = refreshed?.stateVersion ?? row.owner_version as number; // Every status change and every admission increases the state version, so an unchanged version means the task is // still in the status the opening was guarded for (running, or needs human for a draft) with no attempt active. - if (task.state_version === owned) { + // A needs-human task stays there whatever the PR looks like; only a running task can move to in review. + if (task.state_version === owned && task.status === 'running') { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === expectedHead && !pr.draft ? 'in review' : 'needs human', key); } this.#touch(key); diff --git a/test/publish.test.ts b/test/publish.test.ts index 0326a5cf..a1cce167 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -32,7 +32,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + push?: BranchPusher['push']; live?: Map; next?: { value: number }; config?: Partial; draftAfterRefresh?: boolean } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }; const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ?? [{ outcome: 'clear', baseHead: oid(9) }]; @@ -47,7 +47,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: async findOpened(input) { log.push(`find ${input.marker}`); return options.found !== undefined ? options.found : live.get(input.marker) ?? null; }, async refresh(number, input) { log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); - const pr = { ...live.get(input.marker)!, draft: input.draft, headSha: store.getSnapshot(identity).head }; + const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; live.set(input.marker, pr); return pr; }, }; @@ -235,6 +235,21 @@ describe('recovering a lost opening', () => { expect(again.opened[0]!.body).not.toContain('Needs human'); 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'); + // Even if GitHub still reports the PR as ready, a needs-human task never moves to in review. + rerun(store); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); + expect(await harness(store, { live, next, draftAfterRefresh: false }).publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: false, status: 'needs human' }); + }); 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'); @@ -265,6 +280,9 @@ describe('the PR description', () => { expect(body).toContain('````text\nP1: Guard input\n Intent: Closes #1 @admin ```\n# injected'); expect(fenced('a ```` b')).toMatch(/^`````text\n/); }); + 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' }); @@ -324,6 +342,10 @@ describe('GitHub PR adapter', () => { 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[][] = []; + await new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { undo.push([...args]); return args[0] === 'pr' ? '' : JSON.stringify(response({ draft: false })); }) + .refresh(7, { ...input, draft: true, ready: false }); + expect(undo[1]).toEqual(['pr', 'ready', '7', '--undo', '--repo', 'owner/repo']); await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ number: 8 }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/different/); // 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/); From 7f3e5febdf0e96055aae455dfd9dee6860ac000f Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 09:24:25 -0700 Subject: [PATCH 04/65] Pass the D-Bus session bus to gh, so Linux keyring sign-in works Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/gh-env.ts | 2 ++ test/publish.test.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 1de93861..beebeed0 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -68,7 +68,7 @@ Each opening or refresh owns the task state version at the moment it passed step 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. -**Environment.** Each `gh` process gets only an allowlist of variables: the path, home and locale; GitHub tokens, host and configuration directories; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. +**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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. ## The PR description diff --git a/github/gh-env.ts b/github/gh-env.ts index 22e68b10..854d83b7 100644 --- a/github/gh-env.ts +++ b/github/gh-env.ts @@ -6,6 +6,8 @@ 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', ] as const; diff --git a/test/publish.test.ts b/test/publish.test.ts index a1cce167..d9ae5158 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -308,6 +308,8 @@ describe('gh subprocess environment', () => { 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); + // 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' }); }); }); From ea96836d0c51c3cdcc58a5a010079bf21dfa5897 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 09:32:32 -0700 Subject: [PATCH 05/65] AGENTS.md: rules from the F2d review rounds Guarded status transitions, preconditions before shortcuts, platform credential channels in subprocess allowlists, and no spreading of unbounded collections. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 13c7cc4e..32602b1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ 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. +- 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. @@ -67,6 +68,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. - Preserve repository identity with pull request numbers in cross-reference scans. Never resolve or exclude a repository-qualified reference by number alone. - 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. - Batch and briefly cache read-only status probes, and give the combined operation an overall deadline below the serving request timeout. - 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. @@ -94,6 +96,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 +104,7 @@ 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. +- 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. ## Blinded experiments From 0f750f15bd9b9377e7402c9588b48dc9a7c80774 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 10:25:00 -0700 Subject: [PATCH 06/65] Address Copilot round 2 on F2d: pre-push re-read, disconnects, deadline, refresh record - Re-read the task right before the push, after the last await. - Replay connected and disconnected timeline events; a disconnected PR no longer counts as linked. Cross-references stay. - One deadline for the whole already-fixed check; it aborts the running gh call and makes the check unknown. - Record an update of the open PR before it starts; if its confirmation is lost, the next publish drops the record and repeats the update. - AGENTS.md: replay add and remove events for removable relations. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + docs/implementation/pull-request-opening.md | 9 ++--- github/already-fixed.ts | 34 +++++++++++++------ runner/publish.ts | 7 +++- runner/store.ts | 35 +++++++++++++++---- test/already-fixed.test.ts | 14 ++++++++ test/publish.test.ts | 37 +++++++++++++++++++-- 7 files changed, 114 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 32602b1f..c4cdcb2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. - 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. - 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. - Batch and briefly cache read-only status probes, and give the combined operation an overall deadline below the serving request timeout. diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index beebeed0..b41e4e1f 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -30,7 +30,7 @@ The check matches when any of these is true: | Signal | Source | Not a match | |---|---|---| | Something other than this task closed the issue. | The issue state and its latest close event (GraphQL). | Closed by an own PR or an own commit. A reopened issue. | -| Another open or merged PR links to the issue. | Cross-reference and "connected" timeline events. | Own PRs, matched by repository and number. Closed, unmerged PRs. | +| Another open or merged PR links to the issue. | Cross-reference events, and manual links: "connected" and "disconnected" events replayed in order. | Own PRs, matched by repository and number. Closed, unmerged PRs. A manual link whose latest event is a disconnect. | | 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 commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that links the issue counts as a match. It is not excluded by number, because its number belongs to another repository. @@ -41,7 +41,8 @@ A commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue U - a task base that is not an ancestor of the base branch; - 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. +- a GitHub error or invalid JSON; +- the whole check running past its single deadline (45 seconds by default). Reaching the deadline stops the running `gh` call. A cancelled check throws. It does not return `unknown`. @@ -53,9 +54,9 @@ Publish runs these steps in order: 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. 3. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. 4. **Find the earlier PR.** If the task has an opened PR on the same branch, ask GitHub whether it is still open. -5. **Push.** 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. +5. **Push.** Re-read the task as in step 6, then 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. 6. **Re-read.** Right before the GitHub call, the Store confirms that the latest check is clear and that the task is unchanged since that check: same state version, same snapshot, same head. -7. **Open or reuse.** Open a new PR, or update the open earlier PR and mark it ready. Record the result. +7. **Open or reuse.** Open a new PR, or update the open earlier PR and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish 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 at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 0ed0d47d..2952d4a2 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -33,6 +33,8 @@ export interface AlreadyFixedGateway { check(input: AlreadyFixedInput, signal?: export const MAX_TIMELINE_ITEMS = 100; export const MAX_BASE_COMMITS = 250; +/** One deadline for the whole check, below the default serving request budget. Each `gh` call no longer gets its own. */ +export const DEFAULT_CHECK_DEADLINE_MS = 45_000; const PAGE = 100; const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { @@ -40,7 +42,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { nameWithOwner issue(number: $number) { state - timelineItems(first: ${MAX_TIMELINE_ITEMS}, itemTypes: [CLOSED_EVENT, CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + timelineItems(first: ${MAX_TIMELINE_ITEMS}, itemTypes: [CLOSED_EVENT, CROSS_REFERENCED_EVENT, CONNECTED_EVENT, DISCONNECTED_EVENT]) { totalCount pageInfo { hasNextPage } nodes { @@ -48,6 +50,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } ... on ConnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } + ... on DisconnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } } } } @@ -83,15 +86,18 @@ export function mentionsIssue(message: string, repository: string, issue: number || new RegExp(`(? (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })).stdout); } @@ -106,9 +112,11 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { if (!SHA.test(input.taskBase)) throw new Error('Invalid task base commit.'); if (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? !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. + const deadline = AbortSignal.timeout(this.deadlineMs), stages = signal ? AbortSignal.any([signal, deadline]) : deadline; try { - const matches = await this.#timeline(input, signal); - const { baseHead, commits } = await this.#baseCommits(input, signal); + const matches = await this.#timeline(input, stages); + const { baseHead, commits } = await this.#baseCommits(input, stages); 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: commit.message.split('\n', 1)[0]!.slice(0, 200) }); @@ -116,6 +124,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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.' }; } } @@ -136,7 +145,9 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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); - const matches: AlreadyFixedMatch[] = [], seen = new Set(); + // 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'); @@ -152,19 +163,22 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { } else throw new Unknown('GitHub returned an unknown closer.'); continue; } - const field = node.__typename === 'CrossReferencedEvent' ? 'source' : node.__typename === 'ConnectedEvent' ? 'subject' : null; + const field = node.__typename === 'CrossReferencedEvent' ? 'source' : node.__typename === 'ConnectedEvent' || node.__typename === 'DisconnectedEvent' ? 'subject' : null; if (!field) throw new Unknown('GitHub returned an unexpected timeline event.'); const source = object(node[field], 'linked item'); if (source.__typename === 'Issue') continue; if (source.__typename !== 'PullRequest') throw new Unknown('GitHub returned an unknown linked item.'); 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 (isOwn(repo, number) || source.state === 'CLOSED') continue; + if (isOwn(repo, number)) continue; const key = `${repo.toLowerCase()}#${number}`; - if (seen.has(key)) continue; - seen.add(key); - matches.push({ kind: 'pull request', repository: repo, number, state: source.state as 'OPEN' | 'MERGED', draft: source.isDraft }); + 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 }); diff --git a/runner/publish.ts b/runner/publish.ts index 01942b96..9b5abe75 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -78,11 +78,13 @@ export class PullRequestPublisher { && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).at(-1); const live = earlier ? await this.#pulls.findOpened({ base: earlier.base, headBranch: branch, marker: marker(earlier.openingId) }, signal) : null; signal?.throwIfAborted(); + // The push changes GitHub too: re-read the task after the last await before it, as before open and refresh. + this.#store.assertCheckCurrent(identity, { checkId: check.id, headSha: snapshot.head, draft }); await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); signal?.throwIfAborted(); if (earlier && live) { if (live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); - const stateVersion = this.#store.assertReadyToRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); + const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); const pr = await this.#pulls.refresh(live.number, { base: earlier.base, headBranch: branch, draft, ready: !draft, marker: marker(earlier.openingId), title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), @@ -108,6 +110,9 @@ export class PullRequestPublisher { * the settle time has passed; only then is it abandoned. The caller retries after OpeningUnsettled. */ async #recover(identity: PlanIdentity, signal?: AbortSignal): Promise { + // An update whose confirmation was lost is repeated, not adopted: its description may or may not have landed. + const refreshing = this.#store.taskPullRequests(identity).find(pr => pr.refresh !== null); + if (refreshing) this.#store.abandonRefresh(identity, refreshing.openingId); const lost = this.#store.taskPullRequests(identity).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.'); diff --git a/runner/store.ts b/runner/store.ts index 06a03572..7d6d6a6a 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -30,6 +30,8 @@ export interface TaskPullRequest { 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; + /** 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; checkedAt: string } export type MergeAttemptState = 'submitting' | 'queued' | 'merged' | 'removed' | 'failed'; @@ -891,6 +893,7 @@ export class Store { 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, state TEXT NOT NULL CHECK (state IN ('opening','opened','abandoned')), number INTEGER, url TEXT, + refresh_head TEXT, refresh_draft INTEGER, refresh_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; @@ -903,6 +906,7 @@ export class Store { 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, + 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. */ @@ -958,16 +962,34 @@ export class Store { return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } + /** Nothing changed since a clear check of this head. Called right before each external write (push, open, refresh). */ + assertCheckCurrent(identity: PlanIdentity, input: { checkId: string; headSha: string; draft: boolean }): void { + this.#transaction(() => this.#assertCheckedHead(identity, input)); + } /** - * The same guard for reusing the task's open PR: nothing changed since a clear check of this head. Returns the state - * version the refresh owns. + * 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. */ - assertReadyToRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): number { + beginRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean }): number { const key = identityKey(identity); return this.#transaction(() => { this.#assertCheckedHead(identity, input); if (!this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened'", key, input.openingId)) throw new GuardRefusal('Unknown pull request.'); - return this.#task(key).state_version as number; + 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=?, updated_at=? WHERE opening_id=?', + input.headSha, input.draft ? 1 : 0, version, new Date().toISOString(), input.openingId); + return version; + }); + } + /** An unconfirmed update is dropped; the next publish checks again and repeats it (the update is idempotent). */ + abandonRefresh(identity: PlanIdentity, openingId: string): void { + const key = identityKey(identity); + this.#transaction(() => { + if (this.#run("UPDATE task_pull_requests SET refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, updated_at=? WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", + new Date().toISOString(), key, openingId).changes !== 1) throw new GuardRefusal('No update of this pull request is in flight.'); + this.#touch(key); }); } #assertCheckedHead(identity: PlanIdentity, input: { checkId: string; headSha: string; draft: boolean }): void { @@ -991,10 +1013,11 @@ export class Store { // Opening completes an `opening` row; a refresh updates the task's existing PR to the head it was checked at. const row = refreshedHead === undefined ? this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opening'", key, openingId) - : this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened' AND number=?", key, openingId, pr.number); + : 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, refreshed!.head, refreshed!.stateVersion); if (!row) throw new GuardRefusal('No pull request is being opened with this ID.'); const expectedHead = refreshedHead ?? row.head_sha as string; - this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, updated_at=? WHERE opening_id=?", + this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, updated_at=? WHERE opening_id=?", pr.number, pr.url, pr.draft ? 1 : 0, expectedHead, new Date().toISOString(), openingId); const task = this.#task(key), owned = refreshed?.stateVersion ?? row.owner_version as number; // Every status change and every admission increases the state version, so an unchanged version means the task is diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 913a9dce..989153f3 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -7,6 +7,7 @@ const pr = (number: number, state = 'OPEN', extra: Record = {}) ({ __typename: 'PullRequest', number, state, isDraft: false, repository: { nameWithOwner: repo }, ...extra }); const cross = (source: unknown) => ({ __typename: 'CrossReferencedEvent', source }); const connected = (subject: unknown) => ({ __typename: 'ConnectedEvent', subject }); +const disconnected = (subject: unknown) => ({ __typename: 'DisconnectedEvent', subject }); const closed = (closer: unknown) => ({ __typename: 'ClosedEvent', closer }); interface Fake { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; @@ -54,6 +55,11 @@ describe('the pre-PR already-fixed check', () => { 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' }); + 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' }); @@ -94,6 +100,14 @@ describe('the pre-PR already-fixed check', () => { ]; 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('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'); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index d9ae5158..16fc97c0 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -32,7 +32,8 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + push?: BranchPusher['push']; live?: Map; next?: { value: number }; config?: Partial; draftAfterRefresh?: boolean; + onFind?: () => void; refreshFails?: boolean } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }; const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ?? [{ outcome: 'clear', baseHead: oid(9) }]; @@ -44,9 +45,10 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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); return pr; }, - async findOpened(input) { log.push(`find ${input.marker}`); return options.found !== undefined ? options.found : live.get(input.marker) ?? null; }, + async findOpened(input) { log.push(`find ${input.marker}`); options.onFind?.(); return options.found !== undefined ? options.found : live.get(input.marker) ?? null; }, async refresh(number, input) { log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); + if (options.refreshFails) throw new Error('timeout reading the PR back'); const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; live.set(input.marker, pr); return pr; }, @@ -250,6 +252,37 @@ describe('recovering a lost opening', () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); expect(await harness(store, { live, next, draftAfterRefresh: false }).publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: false, status: 'needs human' }); }); + it('does not push when the task changed while the earlier PR was looked up', 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('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'); From 89621e9490267327d78898a7aff4413c560953de Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 13:59:14 -0700 Subject: [PATCH 07/65] Address Copilot round 3 on F2d: review version, one publish per task, abort, deadline - Bind each check to the plan's review_version and refuse every push, open and refresh if approvals, choices or notes changed since. - Run one publish per task at a time, so an in-flight update is never cleared or overtaken. - Check the signal before any durable write. - Default the check deadline to 12 s, below the 15 s request budget. - AGENTS.md: honour an aborted signal before the first durable write. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + docs/implementation/pull-request-opening.md | 6 ++-- github/already-fixed.ts | 4 +-- runner/publish.ts | 11 +++++++ runner/store.ts | 15 ++++++---- test/already-fixed.test.ts | 6 +++- test/publish.test.ts | 32 +++++++++++++++++++++ 7 files changed, 63 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c4cdcb2e..03f1a6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. - 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. diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index b41e4e1f..6a384568 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -42,20 +42,20 @@ A commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue U - 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 (45 seconds by default). Reaching the deadline stops the running `gh` call. +- the whole check running past its single deadline (12 seconds by default, below the 15-second request budget). Reaching the deadline stops the running `gh` call. A cancelled check throws. It does not return `unknown`. ## Publishing -Publish runs these steps in order: +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.** If an opening is still `opening`, look for an open PR from the task branch whose description has its marker. If one exists, record it as opened. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. 3. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. 4. **Find the earlier PR.** If the task has an opened PR on the same branch, ask GitHub whether it is still open. 5. **Push.** Re-read the task as in step 6, then 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. -6. **Re-read.** Right before the GitHub call, the Store confirms that the latest check is clear and that the task is unchanged since that check: same state version, same snapshot, same head. +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, or update the open earlier PR and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish 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 at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 2952d4a2..6a22aa32 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -33,8 +33,8 @@ export interface AlreadyFixedGateway { check(input: AlreadyFixedInput, signal?: export const MAX_TIMELINE_ITEMS = 100; export const MAX_BASE_COMMITS = 250; -/** One deadline for the whole check, below the default serving request budget. Each `gh` call no longer gets its own. */ -export const DEFAULT_CHECK_DEADLINE_MS = 45_000; +/** One deadline for the whole check, below the 15-second serving request budget (`web/server.ts`). */ +export const DEFAULT_CHECK_DEADLINE_MS = 12_000; const PAGE = 100; const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { diff --git a/runner/publish.ts b/runner/publish.ts index 9b5abe75..b39b9540 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -34,6 +34,8 @@ const marker = (openingId: string) => ``; export class PullRequestPublisher { #store: Store; #checks: AlreadyFixedGateway; #pulls: PullRequestGateway; #pusher: BranchPusher; #config: PublishConfig; + /** Tasks with a publish in progress. One publish per task at a time, so an update is never cleared or overtaken while its GitHub calls run. */ + #inflight = new Set(); constructor(store: Store, deps: { checks: AlreadyFixedGateway; pulls: PullRequestGateway; pusher: BranchPusher }, config: PublishConfig) { this.#store = store; this.#checks = deps.checks; this.#pulls = deps.pulls; this.#pusher = deps.pusher; this.#config = config; } @@ -54,6 +56,15 @@ export class PullRequestPublisher { * 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(); + const key = identityKey(identity); + if (this.#inflight.has(key)) throw new GuardRefusal('A pull request is already being published for this task.'); + this.#inflight.add(key); + try { return await this.#publish(identity, input, signal); } + finally { this.#inflight.delete(key); } + } + + async #publish(identity: PlanIdentity, input: { problems?: readonly string[] }, signal?: AbortSignal): Promise { const draft = input.problems !== undefined; const recovered = await this.#recover(identity, signal); if (recovered) return recovered; diff --git a/runner/store.ts b/runner/store.ts index 7d6d6a6a..6edf1853 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -33,7 +33,7 @@ export interface TaskPullRequest { /** 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; checkedAt: string } +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; @@ -888,7 +888,7 @@ export class Store { 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, checked_at 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, @@ -917,7 +917,7 @@ export class Store { 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, checkedAt: row.checked_at as string } : null; + 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 { @@ -940,9 +940,11 @@ export class Store { 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; - this.#run('INSERT INTO already_fixed_checks (id,plan_key,snapshot_id,outcome,result,state_version,checked_at) VALUES (?,?,?,?,?,?,?)', - id, key, input.snapshotId, input.result.outcome, encode(input.result), stateVersion, checkedAt); - return { id, snapshotId: input.snapshotId, result: input.result, stateVersion, checkedAt }; + // Review input (approvals, choices, notes) advances review_version without touching the task; bind the check to both. + const reviewVersion = this.#current(key).review_version as number; + 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 }; }); } /** @@ -996,6 +998,7 @@ export class Store { 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.'); } diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 989153f3..aea66468 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { GhAlreadyFixedGateway, MAX_BASE_COMMITS, mentionsIssue, type AlreadyFixedInput } from '../github/already-fixed.ts'; +import { 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'; @@ -108,6 +108,10 @@ describe('the pre-PR already-fixed check', () => { 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', () => { + expect(DEFAULT_CHECK_DEADLINE_MS).toBeLessThan(15_000); + expect(new GhAlreadyFixedGateway({ repository: repo }).deadlineMs).toBe(DEFAULT_CHECK_DEADLINE_MS); + }); 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'); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 16fc97c0..81ef9b21 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -283,6 +283,38 @@ describe('recovering a lost opening', () => { expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); }); + it('does not push when a review note is added while the earlier PR is looked up', 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.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + } }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/review changed/); + expect(again.log.some(line => line.startsWith('push'))).toBe(false); + }); + it('runs one publish per task at a time', 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/); + 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('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'); From 6b26e6566af9139d200a7c05adea856ee5560680 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 14:01:04 -0700 Subject: [PATCH 08/65] Share the one-publish-per-task guard across publishers over the same Store Co-Authored-By: Claude Opus 5.5 --- runner/publish.ts | 15 ++++++++++----- test/publish.test.ts | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/runner/publish.ts b/runner/publish.ts index b39b9540..14ee4892 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -31,11 +31,14 @@ export type PublishOutcome = | { kind: 'no changes' }; const marker = (openingId: string) => ``; +/** + * 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; - /** Tasks with a publish in progress. One publish per task at a time, so an update is never cleared or overtaken while its GitHub calls run. */ - #inflight = new Set(); constructor(store: Store, deps: { checks: AlreadyFixedGateway; pulls: PullRequestGateway; pusher: BranchPusher }, config: PublishConfig) { this.#store = store; this.#checks = deps.checks; this.#pulls = deps.pulls; this.#pusher = deps.pusher; this.#config = config; } @@ -58,10 +61,12 @@ export class PullRequestPublisher { async publish(identity: PlanIdentity, input: { problems?: readonly string[] } = {}, signal?: AbortSignal): Promise { signal?.throwIfAborted(); const key = identityKey(identity); - if (this.#inflight.has(key)) throw new GuardRefusal('A pull request is already being published for this task.'); - this.#inflight.add(key); + 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); try { return await this.#publish(identity, input, signal); } - finally { this.#inflight.delete(key); } + finally { inflight.delete(key); } } async #publish(identity: PlanIdentity, input: { problems?: readonly string[] }, signal?: AbortSignal): Promise { diff --git a/test/publish.test.ts b/test/publish.test.ts index 81ef9b21..51588acb 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -294,7 +294,7 @@ describe('recovering a lost opening', () => { await expect(again.publisher.publish(identity)).rejects.toThrow(/review changed/); expect(again.log.some(line => line.startsWith('push'))).toBe(false); }); - it('runs one publish per task at a time', async () => { + 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; }); @@ -302,6 +302,8 @@ describe('recovering a lost opening', () => { 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. From 9980445baceaeda91de371a0222730d50c8fddaa Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 14:16:20 -0700 Subject: [PATCH 09/65] Close handed-off review findings on F2d: references, adoption, ProjectV2, order - Neutralise issue references in the PR title, plan and problems, since GitHub closes issues from keywords in default-branch commit messages, which fences do not protect. - Look up the branch PR with the markers of every earlier opening, abandoned ones included, before the check; adopt an abandoned opening's PR (and count it as own) instead of getting stuck behind GitHub's one-open-PR-per-branch refusal. - Treat a ProjectV2 closer as a closing by someone else, not unknown. - Refuse a PR-number mismatch before the push. - Run the timeline and base-commit reads together; the first failure aborts the other. - Cut titles and problems by UTF-16 unit without splitting a surrogate pair; an empty summary becomes "codeboost plan". - AGENTS.md: three rules from these findings. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 + core/pull-request-body.ts | 28 ++++- docs/implementation/pull-request-opening.md | 18 +-- github/already-fixed.ts | 13 ++- github/pull-requests.ts | 19 ++-- runner/publish.ts | 42 ++++--- runner/store.ts | 13 ++- test/already-fixed.test.ts | 14 +++ test/publish.test.ts | 115 ++++++++++++++++---- 9 files changed, 202 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 03f1a6e5..cbedfca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. @@ -91,6 +92,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. - 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. - 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. @@ -106,6 +108,7 @@ 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. 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. diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index 196898ac..a9b6846a 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -18,6 +18,23 @@ export function fenced(text: string): string { return `${fence}text\n${text.replace(/\r\n?/g, '\n')}\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∕'); +} +/** Cut to at most `max` UTF-16 units (the unit every length bound here counts), never inside a surrogate pair. */ +function cut(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)}…`; +} + function planText(plan: Plan, full: boolean): string { return plan.items.map(item => { const lines = [`${item.id}: ${item.title}`]; @@ -30,12 +47,13 @@ function planText(plan: Plan, full: boolean): string { return lines.join('\n'); }).join('\n\n'); } +const planTextSafe = (plan: Plan, full: boolean) => neutralizeReferences(planText(plan, full)); -/** Single line, no control characters, bounded. */ +/** Single line, no control characters, no issue references except its own, bounded in code points. */ export function pullRequestTitle(plan: Plan): string { - const summary = plan.summary.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const summary = neutralizeReferences(plan.summary.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()) || 'codeboost plan'; const suffix = ` (#${plan.issue})`; - return (summary.length + suffix.length > MAX_TITLE ? `${summary.slice(0, MAX_TITLE - suffix.length - 1)}…` : summary) + suffix; + return cut(summary, MAX_TITLE - suffix.length) + suffix; } /** @@ -45,7 +63,7 @@ export function pullRequestTitle(plan: Plan): string { 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 => problem.length > MAX_PROBLEM ? `${problem.slice(0, MAX_PROBLEM)}…` : problem); + const all = input.problems ?? [], shown = all.slice(0, MAX_PROBLEMS).map(problem => neutralizeReferences(cut(problem, MAX_PROBLEM + 1))); const problems = all.length > shown.length ? [...shown, `(${all.length - shown.length} more in codeboost)`] : shown; const build = (full: boolean) => [ marker, @@ -56,7 +74,7 @@ export function pullRequestBody(input: { plan: Plan; marker: string; problems?: '', full ? '**Plan**' : '**Plan** (items only; the full plan is too long for this description)', '', - fenced(planText(plan, full)), + fenced(planTextSafe(plan, full)), ].join('\n'); const body = build(true); if (body.length <= MAX_BODY) return body; diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 6a384568..09225845 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -29,7 +29,7 @@ The check matches when any of these is true: | Signal | Source | Not a match | |---|---|---| -| Something other than this task closed the issue. | The issue state and its latest close event (GraphQL). | Closed by an own PR or an own commit. A reopened issue. | +| Something other than this task closed the issue. | The issue state and its latest close event (GraphQL). The closer is a PR, a commit, or a Projects workflow (closed by the project, so a match). | Closed by an own PR or an own commit. A reopened issue. | | Another open or merged PR links to the issue. | Cross-reference events, and manual links: "connected" and "disconnected" events replayed in order. | Own PRs, matched by repository and number. Closed, unmerged PRs. A manual link whose latest event is a disconnect. | | 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`. | @@ -50,13 +50,13 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for an open PR from the task branch whose description has its marker. If one exists, record it as opened. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. -3. **Check.** Run the check. Record the result, and any status change, in one transaction. That transaction refuses if the task changed during the check. -4. **Find the earlier PR.** If the task has an opened PR on the same branch, ask GitHub whether it is still open. -5. **Push.** Re-read the task as in step 6, then 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. +3. **Find the earlier PR.** If the task has an opened or abandoned opening on the same branch, ask GitHub for the branch's open PR and which opening's marker it carries. A PR with an opened record's marker but another number is refused here, before the push. 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. +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.** Push, straight after the check's transaction with no await in between, 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. 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, or update the open earlier PR and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. +7. **Open or reuse.** Open a new PR, 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 is adopted in the same transaction that records the update) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish 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 at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. @@ -75,7 +75,11 @@ The adapter refuses any answer for a PR that is not open. A PR closed between th 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 close other issues or notify people. The fence is longer than any run of backticks in the text, so the text cannot end the 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. + +Titles and problems are cut by code point, 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. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 6a22aa32..5dbf4fed 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -47,7 +47,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { pageInfo { hasNextPage } nodes { __typename - ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } + ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } ... on ProjectV2 { number } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } ... on ConnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } ... on DisconnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } @@ -113,10 +113,12 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { if (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? !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. - const deadline = AbortSignal.timeout(this.deadlineMs), stages = signal ? AbortSignal.any([signal, deadline]) : deadline; + // The two stages are independent and run together; the first failure stops the other. + const deadline = AbortSignal.timeout(this.deadlineMs), failed = new AbortController(); + const stages = AbortSignal.any([...(signal ? [signal] : []), deadline, failed.signal]); + const stop = (error: unknown) => { failed.abort(); throw error; }; try { - const matches = await this.#timeline(input, stages); - const { baseHead, commits } = await this.#baseCommits(input, stages); + const [matches, { baseHead, commits }] = await Promise.all([this.#timeline(input, stages).catch(stop), this.#baseCommits(input, stages).catch(stop)]); 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: commit.message.split('\n', 1)[0]!.slice(0, 200) }); @@ -160,7 +162,8 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { } 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) ? null : `commit ${closer.oid}`; - } else throw new Unknown('GitHub returned an unknown closer.'); + } else if (closer.__typename === 'ProjectV2') lastCloser = 'a project workflow'; + else throw new Unknown('GitHub returned an unknown closer.'); continue; } const field = node.__typename === 'CrossReferencedEvent' ? 'source' : node.__typename === 'ConnectedEvent' || node.__typename === 'DisconnectedEvent' ? 'subject' : null; diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 0f984e86..7c4d34a6 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -17,8 +17,11 @@ export interface OpenPullRequestInput { export interface OpenedPullRequest { number: number; url: string; headSha: string; draft: boolean } export interface PullRequestGateway { open(input: OpenPullRequestInput, signal?: AbortSignal): Promise; - /** The open PR from `headBranch` into `base` whose description carries `marker`, or null when there is none. */ - findOpened(input: { base: string; headBranch: string; marker: string }, 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 was not opened by codeboost, and is refused. + */ + findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ refresh(number: number, input: OpenPullRequestInput & { ready: boolean }, signal?: AbortSignal): Promise; } @@ -55,9 +58,10 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 }): void { + #validate(input: { base: string; headBranch: string; marker?: string; markers?: readonly string[] }): void { if (!BRANCH.test(input.base) || !BRANCH.test(input.headBranch)) throw new Error('Invalid branch name.'); - if (!/^$/.test(input.marker)) throw new Error('Invalid pull request marker.'); + const markers = input.markers ?? [input.marker]; + if (!markers.length || markers.some(marker => typeof marker !== 'string' || !/^$/.test(marker))) throw new Error('Invalid pull request marker.'); } async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { @@ -70,7 +74,7 @@ export class GhPullRequestGateway implements PullRequestGateway { return pr; } - async findOpened(input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise { + async findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { this.#validate(input); const owner = this.repository.split('/')[0]!; const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, base: input.base, per_page: '100' }); @@ -79,8 +83,9 @@ export class GhPullRequestGateway implements PullRequestGateway { if (!Array.isArray(response) || response.length > 1) throw new Error('GitHub returned an invalid pull request list.'); if (!response.length) return null; const { body, ...pr } = this.#pull(response[0], input); - if (!body.includes(input.marker)) throw new Error(`An open pull request from ${input.headBranch} exists that codeboost did not open.`); - return pr; + const found = input.markers.filter(marker => body.includes(marker)); + 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 }, signal?: AbortSignal): Promise { diff --git a/runner/publish.ts b/runner/publish.ts index 14ee4892..16027f26 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -79,28 +79,32 @@ export class PullRequestPublisher { if (!draft) this.#store.transitionTask(identity, task.stateVersion, 'needs human'); return { kind: 'no changes' }; } - const prs = this.#store.taskPullRequests(identity); + 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: GitHub allows one open PR per branch. 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, this.#config.baseBranch).filter(pr => pr.state !== 'opening'); + const live = candidates.length ? await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal) : null; + signal?.throwIfAborted(); + const earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; + 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: prs.filter(pr => pr.number !== null && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).map(pr => pr.number!), + ownPullRequests: [...own], ownCommits: new Set(this.#store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha)), }, signal); signal?.throwIfAborted(); const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; - const branch = this.branch(identity); - // The task's earlier PR (a needs-human draft) is reused while it is still open: GitHub allows one open PR per branch. - const earlier = prs.filter(pr => pr.state === 'opened' && pr.headBranch === branch && pr.base === this.#config.baseBranch - && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).at(-1); - const live = earlier ? await this.#pulls.findOpened({ base: earlier.base, headBranch: branch, marker: marker(earlier.openingId) }, signal) : null; - signal?.throwIfAborted(); - // The push changes GitHub too: re-read the task after the last await before it, as before open and refresh. - this.#store.assertCheckCurrent(identity, { checkId: check.id, headSha: snapshot.head, draft }); + // Checked before the push: a refused publish must not 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.'); + // No await since recordAlreadyFixed, whose transaction re-read the task: the push follows it directly. await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); signal?.throwIfAborted(); if (earlier && live) { - if (live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); - const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft }); + const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft, + ...(earlier.state === 'abandoned' ? { adopt: { number: live.number, url: live.url } } : {}) }); const pr = await this.#pulls.refresh(live.number, { base: earlier.base, headBranch: branch, draft, ready: !draft, marker: marker(earlier.openingId), title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), @@ -120,6 +124,11 @@ export class PullRequestPublisher { return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } + /** The task's PR records for one branch into one base, in the configured repository. */ + #branchRows(prs: readonly TaskPullRequest[], branch: string, base: string): TaskPullRequest[] { + return prs.filter(pr => pr.headBranch === branch && pr.base === base && 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 @@ -132,8 +141,15 @@ export class PullRequestPublisher { const lost = this.#store.taskPullRequests(identity).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 pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); + const rows = this.#branchRows(this.#store.taskPullRequests(identity), lost.headBranch, lost.base); + const pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); signal?.throwIfAborted(); + if (pr && pr.marker !== marker(lost.openingId)) { + // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing + // (GitHub allows one open PR per branch). Drop it; the main path reuses, or adopts, the PR that is there. + 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.'); diff --git a/runner/store.ts b/runner/store.ts index 6edf1853..366bbe33 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -964,20 +964,21 @@ export class Store { return this.taskPullRequests(identity).find(pr => pr.openingId === openingId)!; }); } - /** Nothing changed since a clear check of this head. Called right before each external write (push, open, refresh). */ - assertCheckCurrent(identity: PlanIdentity, input: { checkId: string; headSha: string; draft: boolean }): void { - this.#transaction(() => this.#assertCheckedHead(identity, input)); - } /** * 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 { + beginRefresh(identity: PlanIdentity, input: { checkId: string; openingId: string; headSha: string; draft: boolean; adopt?: { number: number; url: string } }): number { const key = identityKey(identity); return this.#transaction(() => { this.#assertCheckedHead(identity, input); - if (!this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opened'", key, input.openingId)) throw new GuardRefusal('Unknown pull request.'); + const row = this.#get('SELECT state FROM task_pull_requests WHERE plan_key=? AND opening_id=?', key, input.openingId); + // An abandoned opening whose PR became visible later is adopted here, under the same guard as the update. + if (row?.state === 'abandoned' && input.adopt) { + if (!Number.isSafeInteger(input.adopt.number) || input.adopt.number < 1 || typeof input.adopt.url !== 'string') throw new Error('Invalid pull request.'); + this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=? WHERE opening_id=?", input.adopt.number, input.adopt.url, input.openingId); + } else 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=?, updated_at=? WHERE opening_id=?', diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index aea66468..b0e91721 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -78,6 +78,20 @@ describe('the pre-PR already-fixed check', () => { // A reopened issue does not count as closed. expect(await gateway({ state: 'OPEN', nodes: [closed(null)] }).gh.check(input())).toMatchObject({ outcome: 'clear' }); }); + 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' }] }); + }); + 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)' }] }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 51588acb..48fce1f8 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -5,7 +5,7 @@ import { OpeningUnsettled, PullRequestPublisher, type BranchPusher, type Publish import { GH_ENV_ALLOWLIST, ghEnvironment } from '../github/gh-env.ts'; import { GhPullRequestGateway, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; import type { AlreadyFixedGateway, AlreadyFixedInput, AlreadyFixedResult } from '../github/already-fixed.ts'; -import { fenced, pullRequestBody, pullRequestTitle, MAX_BODY } from '../core/pull-request-body.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'); @@ -33,19 +33,38 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { - const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }; + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean } = {}) { + const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; - const results = options.results ?? [{ outcome: 'clear', baseHead: oid(9) }]; - const gate: AlreadyFixedGateway = { async check(input) { log.push('check'); checks.push(input); return results.shift() ?? { outcome: 'clear', baseHead: oid(9) }; } }; + 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); + 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); + // Like GitHub: one open PR per branch. + if ([...live].some(([, pr]) => !closed.has(pr.number))) throw new Error('HTTP 422: A pull request already exists.'); 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); return pr; + live.set(input.marker, pr); + 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)! }; + // GitHub shows at most one open PR per branch; find it among every live PR, then match its marker. + const open = [...live].find(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); + if (!open) return null; + if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); + return { ...open[1], marker: open[0] }; }, - async findOpened(input) { log.push(`find ${input.marker}`); options.onFind?.(); return options.found !== undefined ? options.found : live.get(input.marker) ?? null; }, async refresh(number, input) { log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); if (options.refreshFails) throw new Error('timeout reading the PR back'); @@ -95,7 +114,7 @@ describe('opening the task PR', () => { 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(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 () => { @@ -221,7 +240,8 @@ describe('recovering a lost opening', () => { 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' }); - expect(second.log).toEqual([expect.stringMatching(/^find /), 'check', 'push codeboost/issue-12-task-42 002', 'open ready']); + // The abandoned opening is still looked up by its marker, in case its PR appears later. + expect(second.log).toEqual([expect.stringMatching(/^find /), 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 () => { @@ -233,7 +253,7 @@ describe('recovering a lost opening', () => { 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(['check', `find `, 'push codeboost/issue-12-task-42 003', 'refresh 100 ready']); + 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'); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, headSha: oid(3), state: 'opened' }]); }); @@ -252,7 +272,7 @@ describe('recovering a lost opening', () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); expect(await harness(store, { live, next, draftAfterRefresh: false }).publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: false, status: 'needs human' }); }); - it('does not push when the task changed while the earlier PR was looked up', async () => { + 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'] }); @@ -283,16 +303,16 @@ describe('recovering a lost opening', () => { expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); }); - it('does not push when a review note is added while the earlier PR is looked up', async () => { + it('does not update the PR when a review note is added 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, onFind: () => { + const again = harness(store, { live, next, push: async () => { store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); } }); await expect(again.publisher.publish(identity)).rejects.toThrow(/review changed/); - expect(again.log.some(line => line.startsWith('push'))).toBe(false); + expect(again.log.some(line => line.startsWith('refresh'))).toBe(false); }); it('runs one publish per task at a time, across publishers over the same Store', async () => { const store = runningTask(); @@ -339,12 +359,42 @@ function rerun(store: Store) { 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. + 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', 'opening']); + // The first PR becomes visible: it is adopted, the second opening is dropped, and publishing finishes. + 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(store.taskPullRequests(identity)).toMatchObject([{ openingId: first!.openingId, state: 'opened', number: 100 }, { state: 'abandoned' }]); + expect(third.log.filter(line => line.startsWith('open'))).toEqual([]); + }); + 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(body).toContain('````text\nP1: Guard input\n Intent: Closes #1 @admin ```\n# injected'); expect(fenced('a ```` b')).toMatch(/^`````text\n/); }); it('handles text with very many backtick runs without overflowing the stack', () => { @@ -358,12 +408,34 @@ describe('the PR description', () => { 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('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'); + }); + 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: 'a\nb\u0007c' })).toBe('a b c (#12)'); expect(pullRequestTitle({ ...plan, summary: 'w'.repeat(500) })).toHaveLength(200); @@ -418,15 +490,18 @@ describe('GitHub PR adapter', () => { await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(response({ number: 8 }))).refresh(7, { ...input, ready: false })).rejects.toThrow(/different/); // 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)).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('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)).toMatchObject({ number: 7 }); + 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&base=main&per_page=100'); - expect(await new GhPullRequestGateway({ repository: 'owner/repo' }, async () => '[]').findOpened(input)).toBeNull(); - await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response({ body: 'someone else' })])).findOpened(input)).rejects.toThrow(/did not open/); - await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened(input)).rejects.toThrow(); + 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(); }); }); From fc5887b242898f70901d21d4bbbda9dd98b50a3e Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 14:31:36 -0700 Subject: [PATCH 10/65] Address Copilot round 4 on F2d: stdin transport, exact problem cut, settle both reads - Send the PR title and description as a JSON body on stdin (gh api --input -), so a large multibyte description or a NUL never hits the per-argument limit. The runner settles after gh exits. - Cut each shown problem to exactly 2,000 characters. - The already-fixed check aborts the other read on the first failure but returns only after both have settled. - AGENTS.md: pass input-sized text on stdin, not as an argument. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + core/pull-request-body.ts | 2 +- docs/implementation/pull-request-opening.md | 2 ++ github/already-fixed.ts | 10 ++++-- github/pull-requests.ts | 30 ++++++++-------- github/run-with-input.ts | 39 +++++++++++++++++++++ test/already-fixed.test.ts | 9 +++++ test/publish.test.ts | 36 ++++++++++++++++--- 8 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 github/run-with-input.ts diff --git a/AGENTS.md b/AGENTS.md index cbedfca9..b6612206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ 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. - 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. diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index a9b6846a..f3c5f50c 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -63,7 +63,7 @@ export function pullRequestTitle(plan: Plan): string { 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 + 1))); + 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, diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 09225845..f6d496c8 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -69,6 +69,8 @@ Each opening or refresh owns the task state version at the moment it passed step 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. +**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. 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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. ## The PR description diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 5dbf4fed..13be8707 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -113,12 +113,16 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { if (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? !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. + // 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]); - const stop = (error: unknown) => { failed.abort(); throw error; }; + let first: { error: unknown } | null = null; + const stop = (error: unknown) => { first ??= { error }; failed.abort(); throw error; }; try { - const [matches, { baseHead, commits }] = await Promise.all([this.#timeline(input, stages).catch(stop), this.#baseCommits(input, stages).catch(stop)]); + 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: commit.message.split('\n', 1)[0]!.slice(0, 200) }); diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 7c4d34a6..34187fff 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -1,9 +1,8 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import type { RunGh } from './merge.ts'; import { ghEnvironment } from './gh-env.ts'; +import { runWithInput } from './run-with-input.ts'; -const runFile = promisify(execFile); +/** 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; @@ -29,18 +28,21 @@ export interface PullRequestGateway { const SHA = /^[a-f0-9]{40}$/; const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })).stdout); + this.run = run ?? ((args, options) => runWithInput('gh', args, { input: options?.input, timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })); } - async #json(args: readonly string[], signal?: AbortSignal): Promise { - const output = await this.run(args, { signal }); + 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.'); } } @@ -67,8 +69,8 @@ export class GhPullRequestGateway implements PullRequestGateway { async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { this.#validate(input); if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); - const response = await this.#json(['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls`, - '-f', `title=${input.title}`, '-f', `body=${input.body}`, '-f', `head=${input.headBranch}`, '-f', `base=${input.base}`, '-F', `draft=${input.draft}`], signal); + const response = await 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 }); const { body, ...pr } = this.#pull(response, input); if (!body.includes(input.marker)) throw new Error('GitHub returned a pull request without its marker.'); return pr; @@ -92,8 +94,8 @@ export class GhPullRequestGateway implements PullRequestGateway { this.#validate(input); if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); - const patched = this.#pull(await this.#json(['api', '-X', 'PATCH', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls/${number}`, - '-f', `title=${input.title}`, '-f', `body=${input.body}`], signal), input); + 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 || !patched.body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); // 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 }); diff --git a/github/run-with-input.ts b/github/run-with-input.ts new file mode 100644 index 00000000..336fe62d --- /dev/null +++ b/github/run-with-input.ts @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process'; + +/** + * 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` kills it, and the promise rejects once it has exited. + */ +export function runWithInput(command: string, args: readonly string[], options: { + input?: string; signal?: AbortSignal; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: 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; + const stop = (error: Error) => { failure ??= error; child.kill('SIGTERM'); }; + 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)); + child.on('close', (code, signal) => { + if (timer) clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + const stderr = Buffer.concat(err).toString('utf8').trim(); + if (failure) reject(failure); + else if (code !== 0) reject(new Error(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}`)); + else resolve(Buffer.concat(out).toString('utf8')); + }); + }); +} diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index b0e91721..13466486 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -126,6 +126,15 @@ describe('the pre-PR already-fixed check', () => { expect(DEFAULT_CHECK_DEADLINE_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'); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 48fce1f8..64bb1364 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -3,6 +3,7 @@ import { Store } from '../runner/store.ts'; import { GuardRefusal } 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 { runWithInput } from '../github/run-with-input.ts'; import { GhPullRequestGateway, 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'; @@ -412,6 +413,11 @@ describe('the PR description', () => { 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)'); @@ -442,6 +448,25 @@ describe('the PR description', () => { }); }); +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('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/); + }); +}); + 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' }); @@ -457,12 +482,13 @@ describe('GitHub PR adapter', () => { 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 and validates the answer', async () => { - const calls: string[][] = []; - const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { calls.push([...args]); return JSON.stringify(response()); }); + 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', - '-f', 'title=T', '-f', `body=${marker}\nplan`, '-f', 'head=codeboost/issue-12-task', '-f', 'base=main', '-F', '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' } } }, From b32a33099329ddd932c8f9762704d8359fbb3e46 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 14:32:51 -0700 Subject: [PATCH 11/65] Escalate to SIGKILL when gh ignores SIGTERM, so a timeout or abort always settles Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/run-with-input.ts | 13 ++++++++++--- test/publish.test.ts | 7 +++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index f6d496c8..91206de5 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -69,7 +69,7 @@ Each opening or refresh owns the task state version at the moment it passed step 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. -**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. The already-fixed check also waits for both of its reads to settle before it returns. +**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. 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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. diff --git a/github/run-with-input.ts b/github/run-with-input.ts index 336fe62d..1e62c6db 100644 --- a/github/run-with-input.ts +++ b/github/run-with-input.ts @@ -3,17 +3,23 @@ import { spawn } from 'node:child_process'; /** * 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` kills it, and the promise rejects once it has exited. + * 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. */ export function runWithInput(command: string, args: readonly string[], options: { - input?: string; signal?: AbortSignal; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: number; + input?: string; signal?: AbortSignal; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: number; killGraceMs?: 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; - const stop = (error: Error) => { failure ??= error; child.kill('SIGTERM'); }; + 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 }); @@ -29,6 +35,7 @@ export function runWithInput(command: string, args: readonly string[], options: child.on('error', error => stop(error)); child.on('close', (code, signal) => { if (timer) clearTimeout(timer); + if (escalation) clearTimeout(escalation); options.signal?.removeEventListener('abort', onAbort); const stderr = Buffer.concat(err).toString('utf8').trim(); if (failure) reject(failure); diff --git a/test/publish.test.ts b/test/publish.test.ts index 64bb1364..b7888616 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -462,6 +462,13 @@ describe('running gh with a request body on stdin', () => { 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('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/); }); From 07c66303ac15384ae531663ca461c9a0c7c4c591 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 16:31:35 -0700 Subject: [PATCH 12/65] Close high-effort review findings on F2d: settle, head read-back, drafts - The already-fixed check's default runner reuses runWithInput, so an aborted stage escalates to SIGKILL and the 12 s deadline holds. - runWithInput settles after exit even if a process gh started keeps the pipes open, closing them after a grace period. - A refresh read-back polls briefly until GitHub shows the pushed head, so a correct publish is not sent to needs human by a stale head. - When the check matches, the task's earlier ready PR becomes a draft, so it is never left ready for review. - #recover reads the task's PR records once. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 ++- github/already-fixed.ts | 8 ++-- github/pull-requests.ts | 33 ++++++++++++--- github/run-with-input.ts | 16 +++++-- runner/publish.ts | 18 ++++++-- runner/store.ts | 9 ++++ test/publish.test.ts | 46 ++++++++++++++++++++- 7 files changed, 117 insertions(+), 19 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 91206de5..ddc0b680 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -67,9 +67,13 @@ Each opening or refresh owns the task state version at the moment it passed step | Unchanged, needs human | Draft opened, or the earlier PR updated and turned back into a draft | needs human | | Changed (cancelled, reassigned, new attempt, new head) | Opened | Unchanged; the PR is recorded so it can be reused or closed later | +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. + 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. -**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. The already-fixed check also waits for both of its reads to settle before it returns. +**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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 13be8707..5b926f38 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -1,9 +1,6 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; import type { RunGh } from './merge.ts'; import { ghEnvironment } from './gh-env.ts'; - -const runFile = promisify(execFile); +import { runWithInput } from './run-with-input.ts'; /** * The pre-PR "already fixed" check (design, "Checking whether the issue is already fixed"). It reports a match when @@ -98,7 +95,8 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { if (config.deadlineMs !== undefined && (!Number.isSafeInteger(config.deadlineMs) || config.deadlineMs < 1)) throw new Error('Invalid check deadline.'); this.repository = config.repository; this.deadlineMs = config.deadlineMs ?? DEFAULT_CHECK_DEADLINE_MS; - this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })).stdout); + // runWithInput escalates to SIGKILL, so an aborted stage always settles and the check's single deadline holds. + this.run = run ?? ((args, options) => runWithInput('gh', args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })); } async #json(args: readonly string[], signal?: AbortSignal): Promise { diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 34187fff..c1ebe528 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -22,8 +22,12 @@ export interface PullRequestGateway { */ findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ - refresh(number: number, input: OpenPullRequestInput & { ready: boolean }, signal?: AbortSignal): Promise; + refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, 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 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; const SHA = /^[a-f0-9]{40}$/; const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? { + async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, signal?: AbortSignal): Promise { this.#validate(input); if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); @@ -100,8 +104,27 @@ export class GhPullRequestGateway implements PullRequestGateway { // 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 this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); - 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); - return pr; + return this.#readBack(number, input, input.headSha, signal); + } + + async markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise { + this.#validate(input); + if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); + const current = await this.#readBack(number, input, undefined, signal); + if (!current.draft) await this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); + return current.draft ? current : this.#readBack(number, input, undefined, signal); + } + + /** Reads the PR back; when `headSha` is given, polls briefly until GitHub shows it, then returns the last answer. */ + async #readBack(number: number, input: { base: string; headBranch: string; marker: string }, headSha: string | undefined, 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + if (headSha === undefined || pr.headSha === headSha || poll >= HEAD_POLLS) return pr; + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, HEAD_POLL_MS); + signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + }); + } } } diff --git a/github/run-with-input.ts b/github/run-with-input.ts index 1e62c6db..e3f991b8 100644 --- a/github/run-with-input.ts +++ b/github/run-with-input.ts @@ -4,10 +4,11 @@ import { spawn } from 'node:child_process'; * 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. + * 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; + 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; } @@ -33,14 +34,23 @@ export function runWithInput(command: string, args: readonly string[], options: child.stdin.on('error', () => { /* the exit status reports a process that stopped reading */ }); child.stdin.end(options.input ?? ''); child.on('error', error => stop(error)); - child.on('close', (code, signal) => { + 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(); if (failure) reject(failure); else if (code !== 0) reject(new Error(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}`)); 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/runner/publish.ts b/runner/publish.ts index 16027f26..c7bdbaab 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -96,7 +96,15 @@ export class PullRequestPublisher { }, signal); signal?.throwIfAborted(); const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); - if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; + if (result.outcome !== 'clear') { + // A task that is not published as ready never leaves its PR ready for review: its earlier ready PR becomes a draft. + // No await since recordAlreadyFixed, which re-read the task; the change only lowers what the PR offers. + if (earlier && live && !live.draft) { + const pr = await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal); + if (earlier.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier.openingId, pr.number, pr.draft); + } + return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; + } // Checked before the push: a refused publish must not 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.'); // No await since recordAlreadyFixed, whose transaction re-read the task: the push follows it directly. @@ -106,7 +114,7 @@ export class PullRequestPublisher { const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft, ...(earlier.state === 'abandoned' ? { adopt: { number: live.number, url: live.url } } : {}) }); const pr = await this.#pulls.refresh(live.number, { - base: earlier.base, headBranch: branch, draft, ready: !draft, marker: marker(earlier.openingId), + base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), }, signal); const status = this.#store.recordPullRequestOpened(identity, earlier.openingId, pr, { head: snapshot.head, stateVersion }); @@ -138,10 +146,12 @@ export class PullRequestPublisher { // An update whose confirmation was lost is repeated, not adopted: its description may or may not have landed. const refreshing = this.#store.taskPullRequests(identity).find(pr => pr.refresh !== null); if (refreshing) this.#store.abandonRefresh(identity, refreshing.openingId); - const lost = this.#store.taskPullRequests(identity).find((pr: TaskPullRequest) => pr.state === 'opening'); + // Read once: abandonRefresh above is the only write 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(this.#store.taskPullRequests(identity), lost.headBranch, lost.base); + const rows = this.#branchRows(prs, lost.headBranch, lost.base); const pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); signal?.throwIfAborted(); if (pr && pr.marker !== marker(lost.openingId)) { diff --git a/runner/store.ts b/runner/store.ts index 366bbe33..5745194e 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1034,6 +1034,15 @@ export class Store { return this.#task(key).status as TaskStatus; }); } + /** Records that the task's open PR is now a draft (after a check matched). The task status does not change. */ + recordPullRequestDraft(identity: PlanIdentity, openingId: string, number: number, draft: boolean): void { + const key = identityKey(identity); + this.#transaction(() => { + 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); + }); + } /** 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); diff --git a/test/publish.test.ts b/test/publish.test.ts index b7888616..4e15cc8d 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -66,6 +66,10 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); return { ...open[1], marker: open[0] }; }, + async markDraft(number, input) { + log.push(`draft ${number}`); + 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 (options.refreshFails) throw new Error('timeout reading the PR back'); @@ -155,7 +159,7 @@ describe('opening the task PR', () => { } }; 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 refresh() { throw new Error('unreachable'); } } }, config); + pulls: { async open() { opened.push('open'); throw new Error('unreachable'); }, async findOpened() { return null; }, async refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); await expect(publisher.publish(identity)).rejects.toThrow(GuardRefusal); expect(opened).toEqual([]); }); @@ -256,6 +260,8 @@ describe('recovering a lost opening', () => { 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 () => { @@ -338,6 +344,17 @@ describe('recovering a lost opening', () => { 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('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'); @@ -469,6 +486,12 @@ describe('running gh with a request body on stdin', () => { 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('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/); }); @@ -525,6 +548,27 @@ describe('GitHub PR adapter', () => { 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 })); + }); + expect(await gh.refresh(7, { ...input, draft: false, ready: true, headSha: oid(3) })).toMatchObject({ headSha: oid(3) }); + expect(gets).toBe(3); + }); + 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 }); + expect(calls.filter(call => call[0] === 'pr')).toEqual([['pr', 'ready', '7', '--undo', '--repo', 'owner/repo']]); + await gh.markDraft(7, input); + expect(calls.filter(call => call[0] === 'pr')).toHaveLength(1); + }); 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()]); }); From dcc2b0c65785763d26774e85cec473582760a25a Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 16:40:06 -0700 Subject: [PATCH 13/65] Draft the earlier PR before recording a matching check, so a failure stays retryable Also refuse a PR-number mismatch before any GitHub change, and remove each head-poll abort listener when its wait ends. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 8 +++-- runner/publish.ts | 21 +++++++------ test/publish.test.ts | 33 +++++++++++++++++++-- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index ddc0b680..b518fbb4 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -69,7 +69,7 @@ Each opening or refresh owns the task state version at the moment it passed step 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. +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: if it fails, the task is still running, and a retry checks again and repeats it. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index c1ebe528..0879cb54 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -121,9 +121,11 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); if (headSha === undefined || pr.headSha === headSha || poll >= HEAD_POLLS) return pr; - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, HEAD_POLL_MS); - signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + 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/runner/publish.ts b/runner/publish.ts index c7bdbaab..9565908d 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -95,18 +95,17 @@ export class PullRequestPublisher { ownCommits: new Set(this.#store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha)), }, signal); signal?.throwIfAborted(); - const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); - if (result.outcome !== 'clear') { - // A task that is not published as ready never leaves its PR ready for review: its earlier ready PR becomes a draft. - // No await since recordAlreadyFixed, which re-read the task; the change only lowers what the PR offers. - if (earlier && live && !live.draft) { - const pr = await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal); - if (earlier.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier.openingId, pr.number, pr.draft); - } - return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; - } - // Checked before the push: a refused publish must not move the branch. + // Checked before any GitHub change: 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.'); + // 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. + // Making a PR a draft only lowers what it offers, so it needs no re-read of the task first. + const drafted = result.outcome !== 'clear' && earlier && live && !live.draft + ? await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal) : null; + signal?.throwIfAborted(); + const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); + if (drafted && earlier!.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier!.openingId, drafted.number, drafted.draft); + if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result } : { kind: 'possibly already fixed', result }; // No await since recordAlreadyFixed, whose transaction re-read the task: the push follows it directly. await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); signal?.throwIfAborted(); diff --git a/test/publish.test.ts b/test/publish.test.ts index 4e15cc8d..ee770615 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -34,7 +34,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ? [...options.results] : []; @@ -68,6 +68,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, async markDraft(number, input) { log.push(`draft ${number}`); + 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) { @@ -355,6 +356,27 @@ describe('recovering a lost opening', () => { expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); expect(store.getTask(identity).status).toBe('possibly already fixed'); }); + 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('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'); @@ -555,8 +577,15 @@ describe('GitHub PR adapter', () => { 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 })); }); - expect(await gh.refresh(7, { ...input, draft: false, ready: true, headSha: oid(3) })).toMatchObject({ headSha: oid(3) }); + 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[][] = []; From 0be714791508defcd06b283a2876fe26f2f836cb Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 17:30:00 -0700 Subject: [PATCH 14/65] Address Copilot round 5 on F2d: verified draft state, re-read before drafting, title mentions, deadline budget - markDraft and refresh poll the read-back for the requested draft or ready state and fail if GitHub never shows it, so a publish stays retryable instead of recording a change that did not apply. - Re-read the task, its review and its head right before drafting the earlier PR; a stale publish no longer touches the current PR. - Neutralise @-mentions in the unfenced PR title. - The check's runner waits 1 s before SIGKILL and 0.5 s for pipes, so the whole check settles in 13.5 s, below the 15 s request budget. - AGENTS.md: grace periods count inside deadlines; read back external state changes; neutralise mentions in unfenced titles. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++- core/pull-request-body.ts | 9 +++++-- docs/implementation/pull-request-opening.md | 6 ++--- github/already-fixed.ts | 7 +++++- github/pull-requests.ts | 21 ++++++++++------ runner/publish.ts | 8 ++++-- runner/store.ts | 10 ++++++++ test/already-fixed.test.ts | 5 ++-- test/publish.test.ts | 27 +++++++++++++++++++-- 9 files changed, 77 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b6612206..aa05fada 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. @@ -109,7 +111,7 @@ 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. Code fences do not protect commit messages, and GitHub closes issues from closing keywords in default-branch commits. +- 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. diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index f3c5f50c..11c34a2b 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -49,9 +49,14 @@ function planText(plan: Plan, full: boolean): string { } const planTextSafe = (plan: Plan, full: boolean) => neutralizeReferences(planText(plan, full)); -/** Single line, no control characters, no issue references except its own, bounded in code points. */ +/** 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 = neutralizeReferences(plan.summary.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()) || 'codeboost plan'; + const summary = neutralizeMentions(neutralizeReferences(plan.summary.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim())) || 'codeboost plan'; const suffix = ` (#${plan.issue})`; return cut(summary, MAX_TITLE - suffix.length) + suffix; } diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index b518fbb4..355425c0 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -42,7 +42,7 @@ A commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue U - 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, below the 15-second request budget). Reaching the deadline stops the running `gh` call. +- 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`. @@ -69,7 +69,7 @@ Each opening or refresh owns the task state version at the moment it passed step 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: if it fails, the task is still running, and a retry checks again and repeats it. A PR-number mismatch is refused before any GitHub change. +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. @@ -83,7 +83,7 @@ The description starts with the marker and `Fixes #`. The plan follows, i 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. +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. Titles and problems are cut by code point, never inside a surrogate pair. An empty summary becomes `codeboost plan`. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 5b926f38..d60b3234 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -32,6 +32,11 @@ 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!) { @@ -96,7 +101,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { this.repository = config.repository; this.deadlineMs = config.deadlineMs ?? DEFAULT_CHECK_DEADLINE_MS; // runWithInput escalates to SIGKILL, so an aborted stage always settles and the check's single deadline holds. - this.run = run ?? ((args, options) => runWithInput('gh', args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })); + this.run = run ?? ((args, options) => 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 { diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 0879cb54..3ade04d0 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -104,23 +104,30 @@ export class GhPullRequestGateway implements PullRequestGateway { // 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 this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); - return this.#readBack(number, input, input.headSha, 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; } async markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise { this.#validate(input); if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); - const current = await this.#readBack(number, input, undefined, signal); - if (!current.draft) await this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); - return current.draft ? current : this.#readBack(number, input, undefined, signal); + const current = await this.#readBack(number, input, () => true, signal); + if (current.draft) return current; + await this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); + const pr = await this.#readBack(number, input, found => found.draft, signal); + if (!pr.draft) throw new Error('GitHub did not turn the pull request into a draft.'); + return pr; } - /** Reads the PR back; when `headSha` is given, polls briefly until GitHub shows it, then returns the last answer. */ - async #readBack(number: number, input: { base: string; headBranch: string; marker: string }, headSha: string | undefined, signal?: AbortSignal): Promise { + /** 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); - if (headSha === undefined || pr.headSha === headSha || poll >= HEAD_POLLS) return pr; + if (done(pr) || poll >= HEAD_POLLS) return pr; await new Promise((resolve, reject) => { signal?.throwIfAborted(); const onAbort = () => { clearTimeout(timer); reject(signal!.reason); }; diff --git a/runner/publish.ts b/runner/publish.ts index 9565908d..1ec3eb12 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -74,6 +74,7 @@ export class PullRequestPublisher { const recovered = await this.#recover(identity, signal); if (recovered) return recovered; const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); + const reviewVersion = this.#store.reviewVersion(identity); 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 (snapshot.head === snapshot.base) { if (!draft) this.#store.transitionTask(identity, task.stateVersion, 'needs human'); @@ -99,8 +100,11 @@ export class PullRequestPublisher { if (earlier && live && earlier.state === 'opened' && live.number !== earlier.number) throw new GuardRefusal('GitHub returned a different pull request for this branch.'); // 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. - // Making a PR a draft only lowers what it offers, so it needs no re-read of the task first. - const drafted = result.outcome !== 'clear' && earlier && live && !live.draft + // 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: task.stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + const drafted = needsDraft ? await this.#pulls.markDraft(live.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(earlier.openingId) }, signal) : null; signal?.throwIfAborted(); const check = this.#store.recordAlreadyFixed(identity, task.stateVersion, { snapshotId: snapshot.id, draft, result }); diff --git a/runner/store.ts b/runner/store.ts index 5745194e..56dae108 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1034,6 +1034,16 @@ export class Store { return this.#task(key).status as TaskStatus; }); } + /** 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 that the task's open PR is now a draft (after a check matched). The task status does not change. */ recordPullRequestDraft(identity: PlanIdentity, openingId: string, number: number, draft: boolean): void { const key = identityKey(identity); diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 13466486..78330a97 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_CHECK_DEADLINE_MS, GhAlreadyFixedGateway, MAX_BASE_COMMITS, mentionsIssue, type AlreadyFixedInput } from '../github/already-fixed.ts'; +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'; @@ -123,7 +123,8 @@ describe('the pre-PR already-fixed check', () => { expect(aborted).toBe(true); }); it('defaults to a deadline below the 15-second serving request budget', () => { - expect(DEFAULT_CHECK_DEADLINE_MS).toBeLessThan(15_000); + // 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 () => { diff --git a/test/publish.test.ts b/test/publish.test.ts index ee770615..476c1005 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -79,7 +79,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, }; 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); } }; - return { log, checks, opened, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, { ...config, ...options.config }) }; + return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, { ...config, ...options.config }) }; } describe('opening the task PR', () => { @@ -356,6 +356,18 @@ describe('recovering a lost opening', () => { 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); @@ -481,6 +493,9 @@ describe('the PR description', () => { const body = pullRequestBody({ plan, marker: 'm', problems: ['😀'.repeat(3000)] }); expect(body).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); @@ -562,9 +577,13 @@ describe('GitHub PR adapter', () => { 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[][] = []; - await new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { undo.push([...args]); return args[0] === 'pr' ? '' : JSON.stringify(response({ draft: false })); }) + 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/); // 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/); @@ -598,6 +617,10 @@ describe('GitHub PR adapter', () => { await gh.markDraft(7, input); expect(calls.filter(call => call[0] === 'pr')).toHaveLength(1); }); + 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('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()]); }); From f5859f4284622d56281943a90d8eae16a18221a2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 17:56:35 -0700 Subject: [PATCH 15/65] Close the xhigh review findings on F2d: own merged PR, drafts unsupported, refresh ordering - A close by the task's own PR or commit, and the task's own merged PR, now count as already fixed; only its own open PR is excluded. - A repository without draft PRs: GitHub's refusal becomes DraftsUnsupported, a definite outcome. Opening drops the opening, refresh drops the pending update, and a matching check reports the ready PR it could not draft (leftReady). No loops, no ready PR for a needs-human task. - The refresh is recorded before the push that moves the open PR's head, so a task change during the push cannot strand a half-updated PR; the new-PR path keeps the guard after the push. - gh gets the Windows variables it needs; GH_PAGER is empty there. - Commit subjects are cut without splitting a surrogate pair, through a shared core/text.ts; the GitHub adapters share github/validate.ts. - markDraft changes the PR straight away and reads it back once. - recordPullRequestOpened is split from recordRefreshConfirmed. - Test Stores are closed after each test. - AGENTS.md: exclusions only in the states they are for; record in-flight ownership before an operation's first external write. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + core/pull-request-body.ts | 8 +- core/text.ts | 7 ++ docs/implementation/pull-request-opening.md | 16 +++- github/already-fixed.ts | 19 +++-- github/gh-env.ts | 7 +- github/pull-requests.ts | 34 +++++--- github/validate.ts | 5 ++ runner/publish.ts | 65 ++++++++++----- runner/store.ts | 46 ++++++----- test/already-fixed.test.ts | 23 ++++-- test/publish.test.ts | 88 ++++++++++++++++++--- 12 files changed, 239 insertions(+), 81 deletions(-) create mode 100644 core/text.ts create mode 100644 github/validate.ts diff --git a/AGENTS.md b/AGENTS.md index aa05fada..dff3af04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. @@ -94,6 +95,7 @@ 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. - 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. - 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. diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index 11c34a2b..36943b1b 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -1,4 +1,5 @@ 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; @@ -27,13 +28,6 @@ export function fenced(text: string): string { export function neutralizeReferences(text: string): string { return text.replace(/#(?=\d)/g, '#').replace(/\b(GH)-(?=\d)/gi, '$1‑').replace(/\/(issues|pull)\/(?=\d)/gi, '/$1∕'); } -/** Cut to at most `max` UTF-16 units (the unit every length bound here counts), never inside a surrogate pair. */ -function cut(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)}…`; -} function planText(plan: Plan, full: boolean): string { return plan.items.map(item => { 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 index 355425c0..a80167c9 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -29,8 +29,8 @@ The check matches when any of these is true: | Signal | Source | Not a match | |---|---|---| -| Something other than this task closed the issue. | The issue state and its latest close event (GraphQL). The closer is a PR, a commit, or a Projects workflow (closed by the project, so a match). | Closed by an own PR or an own commit. A reopened issue. | -| Another open or merged PR links to the issue. | Cross-reference events, and manual links: "connected" and "disconnected" events replayed in order. | Own PRs, matched by repository and number. Closed, unmerged PRs. A manual link whose latest event is a disconnect. | +| 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, and manual links: "connected" and "disconnected" events replayed in order. | 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 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 commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that links the issue counts as a match. It is not excluded by number, because its number belongs to another repository. @@ -54,7 +54,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. 3. **Find the earlier PR.** If the task has an opened or abandoned opening on the same branch, ask GitHub for the branch's open PR and which opening's marker it carries. A PR with an opened record's marker but another number is refused here, before the push. 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. 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.** Push, straight after the check's transaction with no await in between, 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. +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. 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, 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 is adopted in the same transaction that records the update) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. @@ -89,6 +89,16 @@ Titles and problems are cut by code point, never inside a surrogate pair. An emp 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 | The update is dropped; the PR is left as it was; publish returns `draft unsupported` with its number. | +| 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`. | + ## What this slice does not do - **Push.** `BranchPusher` is injected. The real push needs D's commit export (#66) and a runner-owned host repository. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index d60b3234..ad4e525c 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -1,6 +1,8 @@ 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 @@ -60,7 +62,6 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { }`; class Unknown extends Error {} -const SHA = /^[a-f0-9]{40}$/; 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; @@ -71,7 +72,7 @@ const positive = (value: unknown, label: string): number => { }; const repositoryName = (value: unknown): string => { const name = object(value, 'repository').nameWithOwner; - if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name)) throw new Unknown('GitHub returned an invalid repository name.'); + 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, '\\$&'); @@ -96,7 +97,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { readonly run: RunGh; readonly deadlineMs: number; constructor(config: GhAlreadyFixedConfig, run?: RunGh) { - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repository)) throw new Error('A GitHub repository is required for the already-fixed check.'); + if (!REPOSITORY.test(config.repository)) throw new Error('A GitHub repository is required for the already-fixed check.'); if (config.deadlineMs !== undefined && (!Number.isSafeInteger(config.deadlineMs) || config.deadlineMs < 1)) throw new Error('Invalid check deadline.'); this.repository = config.repository; this.deadlineMs = config.deadlineMs ?? DEFAULT_CHECK_DEADLINE_MS; @@ -113,7 +114,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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 (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? !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 @@ -128,7 +129,7 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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: commit.message.split('\n', 1)[0]!.slice(0, 200) }); + 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) { @@ -164,11 +165,12 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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) ? null : `${repo}#${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) ? null : `commit ${closer.oid}`; + 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; @@ -180,7 +182,8 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { if (source.__typename !== 'PullRequest') throw new Unknown('GitHub returned an unknown linked item.'); 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 (isOwn(repo, number)) 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); diff --git a/github/gh-env.ts b/github/gh-env.ts index 854d83b7..e3952d76 100644 --- a/github/gh-env.ts +++ b/github/gh-env.ts @@ -9,11 +9,14 @@ export const GH_ENV_ALLOWLIST = [ // 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', ] as const; -export function ghEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +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. - return { ...env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: 'cat', NO_COLOR: '1' }; + // `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/pull-requests.ts b/github/pull-requests.ts index 3ade04d0..ad0a57e8 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -1,5 +1,6 @@ import { ghEnvironment } from './gh-env.ts'; import { 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; @@ -26,11 +27,23 @@ export interface PullRequestGateway { /** 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 {} +const DRAFTS_UNSUPPORTED = /draft pull requests? (?:are|is) not supported/i; +/** 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 (error instanceof Error && DRAFTS_UNSUPPORTED.test(error.message)) throw new DraftsUnsupported('This repository does not support draft pull requests.'); + throw error; + } +} /** 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; -const SHA = /^[a-f0-9]{40}$/; -const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]+(? runWithInput('gh', args, { input: options?.input, timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment() })); } @@ -73,8 +86,9 @@ export class GhPullRequestGateway implements PullRequestGateway { async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { this.#validate(input); if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); - const response = await this.#json(['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls`], signal, + 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 }); + const response = input.draft ? await draftCall(post) : await post(); const { body, ...pr } = this.#pull(response, input); if (!body.includes(input.marker)) throw new Error('GitHub returned a pull request without its marker.'); return pr; @@ -103,7 +117,7 @@ export class GhPullRequestGateway implements PullRequestGateway { if (patched.number !== number || !patched.body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); // 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 this.run(['pr', 'ready', String(number), '--undo', '--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. @@ -111,14 +125,16 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 { this.#validate(input); if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); - const current = await this.#readBack(number, input, () => true, signal); - if (current.draft) return current; - await this.run(['pr', 'ready', String(number), '--undo', '--repo', this.repository], { signal }); + 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); - if (!pr.draft) throw new Error('GitHub did not turn the pull request into a draft.'); + // 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; } 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._/-]+(? + 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 }), 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, () => + 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), + () => ({ head: refresh.head, owned: refresh.stateVersion }), openingId, 'No update of this pull request is in flight.'); + } + #confirmPullRequest(key: string, pr: { number: number; url: string; headSha: string; draft: boolean }, find: () => Record | undefined, + expected: (row: Record) => { head: string; owned: 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(() => { - // Opening completes an `opening` row; a refresh updates the task's existing PR to the head it was checked at. - const row = refreshedHead === undefined - ? this.#get("SELECT * FROM task_pull_requests WHERE plan_key=? AND opening_id=? AND state='opening'", key, openingId) - : 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, refreshed!.head, refreshed!.stateVersion); - if (!row) throw new GuardRefusal('No pull request is being opened with this ID.'); - const expectedHead = refreshedHead ?? row.head_sha as string; + const row = find(); + if (!row) throw new GuardRefusal(missing); + const { head, owned } = expected(row); this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, updated_at=? WHERE opening_id=?", - pr.number, pr.url, pr.draft ? 1 : 0, expectedHead, new Date().toISOString(), openingId); - const task = this.#task(key), owned = refreshed?.stateVersion ?? row.owner_version as number; + pr.number, pr.url, pr.draft ? 1 : 0, head, 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 was guarded for (running, or needs human for a draft) with no attempt active. - // A needs-human task stays there whatever the PR looks like; only a running task can move to in review. + // still in the status the opening or refresh was guarded for with no attempt active. A needs-human task stays + // there whatever the PR looks like; only a running task can move to in review. if (task.state_version === owned && task.status === 'running') { - this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === expectedHead && !pr.draft ? 'in review' : 'needs human', key); + this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === head && !pr.draft ? 'in review' : 'needs human', key); } this.#touch(key); return this.#task(key).status as TaskStatus; diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 78330a97..bb160795 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -68,16 +68,29 @@ describe('the pre-PR already-fixed check', () => { 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 someone else, and ignores one closed by an own PR or commit', async () => { + 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` }] }); - expect(await gateway({ state: 'CLOSED', nodes: [closed(pr(9))] }).gh.check(input({ ownPullRequests: [9] }))).toMatchObject({ outcome: 'clear' }); - expect(await gateway({ state: 'CLOSED', nodes: [closed({ __typename: 'Commit', oid: sha(3) })] }).gh.check(input({ ownCommits: new Set([sha(3)]) }))).toMatchObject({ outcome: 'clear' }); - // Only the latest close counts: an earlier close by someone else was followed by a reopen and an own close. - expect(await gateway({ state: 'CLOSED', nodes: [closed(null), closed(pr(9))] }).gh.check(input({ ownPullRequests: [9] }))).toMatchObject({ outcome: 'clear' }); + // 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' }] }); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 476c1005..0980da6a 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { GuardRefusal } 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 { runWithInput } from '../github/run-with-input.ts'; -import { GhPullRequestGateway, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; +import { DraftsUnsupported, GhPullRequestGateway, 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'; @@ -18,8 +18,11 @@ 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 }]); @@ -34,7 +37,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ? [...options.results] : []; @@ -50,6 +53,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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. if ([...live].some(([, pr]) => !closed.has(pr.number))) throw new Error('HTTP 422: A pull request already exists.'); const pr = { number: counter.value++, url: 'https://github.com/owner/repo/pull/1', headSha: store.getSnapshot(identity).head, draft: input.draft }; @@ -68,12 +72,14 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, async markDraft(number, input) { log.push(`draft ${number}`); + 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 (options.refreshFails) throw new Error('timeout reading the PR back'); + if (options.draftsUnsupported && input.draft) throw new DraftsUnsupported('no drafts'); const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; live.set(input.marker, pr); return pr; }, @@ -152,6 +158,15 @@ describe('opening the task PR', () => { 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 to open when the head moved during the check', async () => { const store = runningTask(); const gate: AlreadyFixedGateway = { async check() { @@ -224,6 +239,35 @@ describe('PR records', () => { }); }); +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 as it is, and drops the unconfirmed refresh', 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'); + expect(await harness(store, { live, next, draftsUnsupported: true }).publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: 100 }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); + }); + 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(); @@ -311,16 +355,20 @@ describe('recovering a lost opening', () => { expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'possibly already fixed' }); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); }); - it('does not update the PR when a review note is added during the push', async () => { + it('records the refresh before the push, so a task change during the push still completes the PR update without moving the task', 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 () => { - store.addReviewNote(identity, { revision: 1, snapshotId: store.getSnapshot(identity).id }, 'P1', 'question', 'Why this file?'); + refreshRecordedAtPush = store.taskPullRequests(identity)[0]!.refresh; + store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); } }); - await expect(again.publisher.publish(identity)).rejects.toThrow(/review changed/); - expect(again.log.some(line => line.startsWith('refresh'))).toBe(false); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running' }); + expect(refreshRecordedAtPush).toMatchObject({ head: oid(3), draft: false }); + expect(again.log.at(-1)).toBe('refresh 100 ready'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3) }]); }); it('runs one publish per task at a time, across publishers over the same Store', async () => { const store = runningTask(); @@ -539,6 +587,9 @@ describe('gh subprocess environment', () => { 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: '' }); // 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' }); }); @@ -613,9 +664,26 @@ describe('GitHub PR adapter', () => { calls.push([...args]); if (args[0] === 'pr') { draft = true; return ''; } return JSON.stringify(response({ draft })); }); expect(await gh.markDraft(7, input)).toMatchObject({ draft: true }); - expect(calls.filter(call => call[0] === 'pr')).toEqual([['pr', 'ready', '7', '--undo', '--repo', 'owner/repo']]); - await gh.markDraft(7, input); - expect(calls.filter(call => call[0] === 'pr')).toHaveLength(1); + // 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 () => { + const unsupported = new Error('gh: Draft pull requests are not supported in this repository. (HTTP 422)'); + const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async args => { + if (args[0] === 'pr' || 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. + await expect(gh.open({ ...input, draft: false })).rejects.toBe(unsupported); }); 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 }))); From a0b5206236a40c3edfc9a0e475d9173d67397aa0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 19:59:11 -0700 Subject: [PATCH 16/65] =?UTF-8?q?Address=20Copilot=20round=206=20on=20F2d:?= =?UTF-8?q?=20branch=20lookup=20always,=20draft=20first,=20docs,=20v6?= =?UTF-8?q?=E2=86=92v7=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Look up the branch's open PR on every publish, even with no known markers, so a PR codeboost did not open is refused before the push can move it. - For a needs-human task with a ready PR, make it a draft before the push or any description change; a "drafts unsupported" refusal now leaves the PR exactly as it was. A failure later in the refresh stays recorded in flight and is redone by the next publish. - Correct the already-fixed module summary to the implemented policy. - Add a v6-to-v7 upgrade test on a populated file database. - AGENTS.md: refusable steps come before writes that cannot be undone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + docs/implementation/pull-request-opening.md | 4 +- github/already-fixed.ts | 10 ++-- github/pull-requests.ts | 6 ++- runner/publish.ts | 32 +++++++----- test/publish.test.ts | 58 ++++++++++++++++++--- 6 files changed, 82 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dff3af04..bfa9982e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. - 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. diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index a80167c9..bd85c9db 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -52,7 +52,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. -3. **Find the earlier PR.** If the task has an opened or abandoned opening on the same branch, ask GitHub for the branch's open PR and which opening's marker it carries. A PR with an opened record's marker but another number is refused here, before the push. 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. +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 (none if the task has no PR yet). 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. 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. 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. 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. @@ -96,7 +96,7 @@ Some repositories do not support draft PRs (for example private repositories on | 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 | The update is dropped; the PR is left as it was; publish returns `draft unsupported` with its number. | +| A needs-human task has an open ready PR | Turning it into a draft is the first step, before the push or any description change, so the refusal leaves the PR exactly as it was; publish returns `draft unsupported` with its number. | | 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`. | ## What this slice does not do diff --git a/github/already-fixed.ts b/github/already-fixed.ts index ad4e525c..540a01d1 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -5,10 +5,12 @@ 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 - * something other than this task closed the issue, when another open or merged PR links to the issue, or when a new - * commit on the base branch mentions it. The task's own PRs and commits are excluded by repository and number or by - * SHA only. Every read is bounded; a response past a bound, or one that cannot be read, is `unknown`, never clear. + * 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 } diff --git a/github/pull-requests.ts b/github/pull-requests.ts index ad0a57e8..df63ba6a 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -19,7 +19,8 @@ export interface PullRequestGateway { 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 was not opened by codeboost, and is refused. + * 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. */ findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ @@ -79,8 +80,9 @@ export class GhPullRequestGateway implements PullRequestGateway { #validate(input: { base: string; headBranch: string; marker?: string; markers?: readonly string[] }): void { if (!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 (!markers.length || markers.some(marker => typeof marker !== 'string' || !/^$/.test(marker))) throw new Error('Invalid pull request 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 { diff --git a/runner/publish.ts b/runner/publish.ts index f2bae348..41cadda1 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -91,7 +91,9 @@ export class PullRequestPublisher { // it is still open: GitHub allows one open PR per branch. 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, this.#config.baseBranch).filter(pr => pr.state !== 'opening'); - const live = candidates.length ? await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal) : null; + // 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. + const live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); signal?.throwIfAborted(); const earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; const own = new Set(prs.filter(pr => pr.number !== null && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).map(pr => pr.number!)); @@ -121,23 +123,25 @@ export class PullRequestPublisher { 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) { - // The push is a refresh's first GitHub write (it moves the open PR's head), so the refresh is recorded before it, - // with no await since recordAlreadyFixed. A task change during the push then cannot strand a half-updated PR. + // 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. const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft, ...(earlier.state === 'abandoned' ? { adopt: { number: live.number, url: live.url } } : {}) }); await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); signal?.throwIfAborted(); - let pr; - try { - pr = await this.#pulls.refresh(live.number, { - base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), - title: pullRequestTitle(plan), body: pullRequestBody({ plan, marker: marker(earlier.openingId), problems: input.problems }), - }, signal); - } catch (error) { - if (!(error instanceof DraftsUnsupported)) throw error; - this.#store.abandonRefresh(identity, earlier.openingId); - return { kind: 'draft unsupported', number: live.number }; - } + // 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 drops it and starts again from the draft step above. + const pr = await this.#pulls.refresh(live.number, { + base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), + 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 { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } diff --git a/test/publish.test.ts b/test/publish.test.ts index 0980da6a..454b56d0 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -94,7 +94,7 @@ describe('opening the task PR', () => { 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(['check', 'push codeboost/issue-12-task-42 002', 'open ready']); + 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)' }); @@ -108,7 +108,7 @@ describe('opening the task PR', () => { 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(['check']); + 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); @@ -117,7 +117,7 @@ describe('opening the task PR', () => { 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(['check']); + 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 () => { @@ -134,7 +134,7 @@ describe('opening the task PR', () => { 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(['check']); + 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 () => { @@ -167,6 +167,13 @@ describe('opening the task PR', () => { 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() { @@ -218,6 +225,34 @@ describe('opening the task PR', () => { }); }); +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='already_fixed_checks') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); + expect(names).toEqual(['already_fixed_checks', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening']); + db.close(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); + 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(); @@ -250,13 +285,16 @@ describe('a repository without draft PRs', () => { // 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 as it is, and drops the unconfirmed refresh', async () => { + 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'); - expect(await harness(store, { live, next, draftsUnsupported: true }).publisher.publish(identity, { problems: ['x'] })).toEqual({ kind: 'draft unsupported', number: 100 }); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null }]); + 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 }; @@ -689,6 +727,12 @@ describe('GitHub PR adapter', () => { 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('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()]); }); From 46bbebafcbeac4d33db6ab2310c44a3cee410a10 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 20:16:38 -0700 Subject: [PATCH 17/65] Address Copilot round 7 on F2d: marker line only, draft flag repair, recovery draft reconcile - A PR's marker is read only from its description's first line; a marker-shaped string in plan text or problems no longer identifies, or blocks, anything. - When publish sees the task's PR, it corrects the recorded draft flag from GitHub under the state-version guard, so a draft change whose record was lost is repaired. - Recovering a lost draft opening turns a PR that was made ready meanwhile back into a draft before confirming it; a failure keeps the opening owned. - AGENTS.md: markers only from their reserved position; correct owned fields from the observed external record. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 ++ docs/implementation/pull-request-opening.md | 6 ++-- github/pull-requests.ts | 17 +++++---- runner/publish.ts | 26 +++++++++++--- runner/store.ts | 11 ++++-- test/publish.test.ts | 39 +++++++++++++++++++++ 6 files changed, 85 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f215f03d..eca6a2cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,8 @@ Every reproduced race requires a failing-before and passing-after regression. As - 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. diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index bd85c9db..30cac9c6 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -20,7 +20,7 @@ | 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 `` at the top of the PR description. | +| 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 @@ -50,9 +50,9 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. -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 (none if the task has no PR yet). 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. 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. +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 (none if the task has no PR yet). 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. 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. 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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index df63ba6a..1381b7ac 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -42,6 +42,11 @@ async function draftCall(call: () => Promise): Promise { 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(); /** 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; @@ -87,12 +92,12 @@ export class GhPullRequestGateway implements PullRequestGateway { async open(input: OpenPullRequestInput, signal?: AbortSignal): Promise { this.#validate(input); - if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); + 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 }); const response = input.draft ? await draftCall(post) : await post(); const { body, ...pr } = this.#pull(response, input); - if (!body.includes(input.marker)) throw new Error('GitHub returned a pull request without its marker.'); + if (markerOf(body) !== input.marker) throw new Error('GitHub returned a pull request without its marker.'); return pr; } @@ -105,7 +110,7 @@ export class GhPullRequestGateway implements PullRequestGateway { if (!Array.isArray(response) || response.length > 1) throw new Error('GitHub returned an invalid pull request list.'); if (!response.length) return null; const { body, ...pr } = this.#pull(response[0], input); - const found = input.markers.filter(marker => body.includes(marker)); + const found = input.markers.filter(marker => markerOf(body) === marker); 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]! }; } @@ -113,10 +118,10 @@ export class GhPullRequestGateway implements PullRequestGateway { async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, signal?: AbortSignal): Promise { this.#validate(input); if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.'); - if (!input.body.includes(input.marker)) throw new Error('The pull request description must carry its marker.'); + 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 || !patched.body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + if (patched.number !== number || markerOf(patched.body) !== input.marker) throw new Error('GitHub returned a different pull request.'); // 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 })); @@ -144,7 +149,7 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 || !body.includes(input.marker)) throw new Error('GitHub returned a different pull request.'); + 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(); diff --git a/runner/publish.ts b/runner/publish.ts index 41cadda1..d1d1aa16 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -96,6 +96,10 @@ export class PullRequestPublisher { const live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); signal?.throwIfAborted(); const 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; + 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); 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({ @@ -111,15 +115,15 @@ export class PullRequestPublisher { // 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: task.stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + if (needsDraft) this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); let drafted = null, leftReady: number | undefined; if (needsDraft) { 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, task.stateVersion, { snapshotId: snapshot.id, draft, result }); - if (drafted && earlier!.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier!.openingId, drafted.number, drafted.draft); + const check = this.#store.recordAlreadyFixed(identity, stateVersion, { snapshotId: snapshot.id, draft, result }); + if (drafted && earlier!.state === 'opened') this.#store.recordPullRequestDraft(identity, earlier!.openingId, drafted.number, drafted.draft, check.stateVersion); 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) { @@ -202,7 +206,19 @@ export class PullRequestPublisher { this.#store.abandonPullRequestOpening(identity, lost.openingId); return null; } - const status = this.#store.recordPullRequestOpened(identity, lost.openingId, pr); - return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + // 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; + if (lost.draft && !pr.draft) { + try { found = await this.#pulls.markDraft(pr.number, { base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } + catch (error) { + if (!(error instanceof DraftsUnsupported)) throw error; + this.#store.recordPullRequestOpened(identity, lost.openingId, pr); + return { kind: 'draft unsupported', number: pr.number }; + } + } + const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); + return { kind: 'opened', number: found.number, url: found.url, draft: found.draft, status }; } } diff --git a/runner/store.ts b/runner/store.ts index f112f5a7..fe5c0979 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1075,12 +1075,19 @@ export class Store { }); } /** Records that the task's open PR is now a draft (after a check matched). The task status does not change. */ - recordPullRequestDraft(identity: PlanIdentity, openingId: string, number: number, draft: boolean): void { + /** + * 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, expectedStateVersion: number): number { const key = identityKey(identity); - this.#transaction(() => { + return this.#transaction(() => { + if (this.#task(key).state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); 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. */ diff --git a/test/publish.test.ts b/test/publish.test.ts index 454b56d0..246c495b 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -318,6 +318,17 @@ describe('recovering a lost opening', () => { expect(second.log).toEqual([`find `]); 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('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'); @@ -475,6 +486,25 @@ describe('recovering a lost opening', () => { 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'); @@ -733,6 +763,15 @@ describe('GitHub PR adapter', () => { // 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('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()]); }); From f09485324f865c1249a0d3aac736b2b2200460f7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 20:52:47 -0700 Subject: [PATCH 18/65] Address Copilot round 8 on F2d: duplicate commits, stale recovery, verified draft opening - The base comparison fails closed when a commit appears twice, since another commit must then be missing. - A recovered opening ends the publish only when it is this publish's own work (same task version and draft mode); otherwise the PR is recorded and publishing continues with the current head and mode. Draft reconciliation in recovery runs only for draft publishes. - A draft opening that GitHub creates as ready fails, so the opening stays owned and recovery turns the PR into a draft. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 +++--- github/already-fixed.ts | 2 ++ github/pull-requests.ts | 2 ++ runner/publish.ts | 21 ++++++++++++++++----- test/already-fixed.test.ts | 1 + test/publish.test.ts | 17 +++++++++++++++++ 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 30cac9c6..2d1adf2a 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -38,7 +38,7 @@ A commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue U **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; +- 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; @@ -50,13 +50,13 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. 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 (none if the task has no PR yet). 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. 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. 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. 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, 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 is adopted in the same transaction that records the update) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. +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), 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 is adopted in the same transaction that records the update) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish 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 at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 540a01d1..e91f189b 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -220,6 +220,8 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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; diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 1381b7ac..ffac817e 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -98,6 +98,8 @@ export class GhPullRequestGateway implements PullRequestGateway { const response = input.draft ? await draftCall(post) : await post(); 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; } diff --git a/runner/publish.ts b/runner/publish.ts index d1d1aa16..02d8f738 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -77,7 +77,7 @@ export class PullRequestPublisher { async #publish(identity: PlanIdentity, input: { problems?: readonly string[] }, signal?: AbortSignal): Promise { const draft = input.problems !== undefined; - const recovered = await this.#recover(identity, signal); + const recovered = await this.#recover(identity, draft, signal); if (recovered) return recovered; const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); const reviewVersion = this.#store.reviewVersion(identity); @@ -172,6 +172,11 @@ export class PullRequestPublisher { return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } + /** Whether a lost opening is the current publish's own: the task has not changed since it began, and the mode matches. */ + #isCurrent(identity: PlanIdentity, lost: TaskPullRequest, draft: boolean): boolean { + return this.#store.getTask(identity).stateVersion === lost.ownerVersion && lost.draft === draft; + } + /** The task's PR records for one branch into one base, in the configured repository. */ #branchRows(prs: readonly TaskPullRequest[], branch: string, base: string): TaskPullRequest[] { return prs.filter(pr => pr.headBranch === branch && pr.base === base && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()); @@ -182,7 +187,7 @@ export class PullRequestPublisher { * 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, signal?: AbortSignal): Promise { + 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. const refreshing = this.#store.taskPullRequests(identity).find(pr => pr.refresh !== null); if (refreshing) this.#store.abandonRefresh(identity, refreshing.openingId); @@ -210,15 +215,21 @@ export class PullRequestPublisher { // 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; - if (lost.draft && !pr.draft) { + // Only when this publish is itself a draft publish; a ready publish's main path marks the PR ready anyway. + if (lost.draft && draft && !pr.draft) { try { found = await this.#pulls.markDraft(pr.number, { base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; + const current = this.#isCurrent(identity, lost, draft); this.#store.recordPullRequestOpened(identity, lost.openingId, pr); - return { kind: 'draft unsupported', number: pr.number }; + return current ? { kind: 'draft unsupported', number: pr.number } : 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - return { kind: 'opened', number: found.number, url: found.url, draft: found.draft, status }; + return current ? { kind: 'opened', number: found.number, url: found.url, draft: found.draft, status } : null; } } diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index bb160795..79a93119 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -120,6 +120,7 @@ describe('the pre-PR already-fixed check', () => { { 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 }, + { commits: [{ sha: sha(5), message: 'x' }, { sha: sha(5), message: 'x' }] }, { errors: [{ message: 'rate limited' }] }, { nameWithOwner: 'other/repo' }, { baseRef: 'other' }, { state: 'CLOSED' }, { nodes: [cross(null)] }, { nodes: [cross({ __typename: 'Discussion' })] }, { nodes: [cross(pr(1, 'OPEN', { repository: null }))] }, { nodes: [{ __typename: 'LabeledEvent' }] }, diff --git a/test/publish.test.ts b/test/publish.test.ts index 246c495b..9d64a381 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -329,6 +329,18 @@ describe('recovering a lost opening', () => { 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'))).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'); @@ -772,6 +784,11 @@ describe('GitHub PR adapter', () => { 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('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()]); }); From 8a4e5eade78f50cf18004d7c9b42d3e32c32475e Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 21:38:21 -0700 Subject: [PATCH 19/65] Address Copilot round 9 on F2d: one deadline per PR operation, adopt on sight - Each PR operation (open, lookup, refresh, draft change) runs under one deadline for all of its commands and poll waits (60 s by default), combined with the caller's signal, instead of 30 s per command. - An abandoned opening's PR is adopted as soon as publish sees it, under the state-version guard and without changing the task status, so a non-clear check no longer leaves the PR unrecorded. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 4 ++- github/pull-requests.ts | 18 +++++++++- runner/publish.ts | 13 ++++--- runner/store.ts | 16 +++++++++ test/publish.test.ts | 40 +++++++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 2d1adf2a..af331b27 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -52,7 +52,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. -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 (none if the task has no PR yet). 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. 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. +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 (none if the task has no PR yet). 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. 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. 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. @@ -73,6 +73,8 @@ When the check matches (or is unknown) and the task's earlier PR is open and rea 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. + **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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index ffac817e..eb4cc5f1 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -47,6 +47,8 @@ async function draftCall(call: () => Promise): Promise { * agent-controlled, so a marker-shaped string there never identifies a PR. */ export const markerOf = (body: string): string => body.split('\n', 1)[0]!.trim(); +/** The longest one open, lookup, refresh or draft change may take in total, whatever the caller's signal. */ +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; @@ -58,12 +60,22 @@ export const HEAD_POLLS = 5, HEAD_POLL_MS = 500; export class GhPullRequestGateway implements PullRequestGateway { readonly repository: string; readonly run: RunGhWithInput; - constructor(config: { repository: string }, 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); } @@ -91,6 +103,7 @@ export class GhPullRequestGateway implements PullRequestGateway { } 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, @@ -104,6 +117,7 @@ export class GhPullRequestGateway implements PullRequestGateway { } async findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { + signal = this.#bounded(signal); this.#validate(input); const owner = this.repository.split('/')[0]!; const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, base: input.base, per_page: '100' }); @@ -118,6 +132,7 @@ export class GhPullRequestGateway implements PullRequestGateway { } async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, 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.'); @@ -136,6 +151,7 @@ export class GhPullRequestGateway implements PullRequestGateway { /** 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; diff --git a/runner/publish.ts b/runner/publish.ts index 02d8f738..2f846777 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -95,9 +95,15 @@ export class PullRequestPublisher { // before the push could move it. const live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); signal?.throwIfAborted(); - const earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; + 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); + 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); const own = new Set(prs.filter(pr => pr.number !== null && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()).map(pr => pr.number!)); @@ -117,7 +123,7 @@ export class PullRequestPublisher { 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) { + 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; } } @@ -136,8 +142,7 @@ export class PullRequestPublisher { } // 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. - const stateVersion = this.#store.beginRefresh(identity, { checkId: check.id, openingId: earlier.openingId, headSha: snapshot.head, draft, - ...(earlier.state === 'abandoned' ? { adopt: { number: live.number, url: live.url } } : {}) }); + 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(); // Any failure here, including a draft refusal after the PR was made ready again meanwhile, leaves the update diff --git a/runner/store.ts b/runner/store.ts index fe5c0979..2086a5cb 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1064,6 +1064,22 @@ export class Store { 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 }, expectedStateVersion: 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(() => { + if (this.#task(key).state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + 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; + }); + } /** 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); diff --git a/test/publish.test.ts b/test/publish.test.ts index 9d64a381..920fd38c 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -557,6 +557,32 @@ describe('recovering from an abandoned opening whose PR appears later', () => { 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('does not adopt when the task changed during the lookup', 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(); + // Recovery drops the second opening; then the task changes while the branch lookup runs. + let finds = 0; + const again = harness(store, { live, next, config: later, onFind: () => { if (++finds === 2) 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']); + }); 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'); @@ -789,6 +815,20 @@ describe('GitHub PR adapter', () => { 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('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()]); }); From af561002f978ae73dda45bf007aba3540e228038 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 22:04:08 -0700 Subject: [PATCH 20/65] Refresh only an opened PR: drop beginRefresh's unused adopt path Adoption of an abandoned opening's PR happens in adoptOpening, under the state-version guard, when publish first sees the PR. beginRefresh now requires an opened row, leaving one adoption path. Co-Authored-By: Claude Opus 5.5 --- runner/store.ts | 9 +++------ test/publish.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/runner/store.ts b/runner/store.ts index 2086a5cb..43d4ed93 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -991,16 +991,13 @@ export class Store { * 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; adopt?: { number: number; url: string } }): number { + 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); - // An abandoned opening whose PR became visible later is adopted here, under the same guard as the update. - if (row?.state === 'abandoned' && input.adopt) { - if (!Number.isSafeInteger(input.adopt.number) || input.adopt.number < 1 || typeof input.adopt.url !== 'string') throw new Error('Invalid pull request.'); - this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=? WHERE opening_id=?", input.adopt.number, input.adopt.url, input.openingId); - } else if (row?.state !== 'opened') throw new GuardRefusal('Unknown pull request.'); + 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=?, updated_at=? WHERE opening_id=?', diff --git a/test/publish.test.ts b/test/publish.test.ts index 920fd38c..c170c615 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -263,6 +263,14 @@ describe('PR records', () => { expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 9, status: 'cancelled' }); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 9 }]); }); + 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, 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, draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); From 6c15c41bcd4122b2be596448f410e5893cf3af74 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 23:54:06 -0700 Subject: [PATCH 21/65] Address Copilot round 10 on F2d: own the review version too - recordAlreadyFixed takes the review version the publish read and refuses a result obtained before a review change. - Each opening and refresh stores the plan's review version it owns; its confirmation still records the PR but moves the task only when both the state version and the review version are unchanged. Recovery treats a lost opening as current only under both. - Design doc: adoption happens in step 3, not in the update; the owned versions are the state and review versions. - AGENTS.md: an in-flight action owns every version counter. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + docs/implementation/pull-request-opening.md | 4 +- runner/publish.ts | 6 +-- runner/store.ts | 43 ++++++++++--------- test/publish.test.ts | 46 +++++++++++++++++++-- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eca6a2cd..654d1f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ 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. diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index af331b27..7ee85b66 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -56,9 +56,9 @@ Only one publish runs per task at a time; a second one is refused. A publish who 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. 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), 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 is adopted in the same transaction that records the update) and mark it ready (or a draft). An update is recorded before it starts. If its confirmation is lost, the next publish drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. +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), 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 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 at the moment it passed step 6. The PR is always recorded. The status changes only when the task still has that version. Every status change, admission and context change increases the version. So a response that arrives after the task changed never moves it. +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 | |---|---|---| diff --git a/runner/publish.ts b/runner/publish.ts index 2f846777..0dc8bca1 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -128,7 +128,7 @@ export class PullRequestPublisher { catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; leftReady = live.number; } } signal?.throwIfAborted(); - const check = this.#store.recordAlreadyFixed(identity, stateVersion, { snapshotId: snapshot.id, draft, result }); + 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, check.stateVersion); const ready = leftReady === undefined ? {} : { leftReady }; if (result.outcome !== 'clear') return draft ? { kind: 'draft skipped', result, ...ready } : { kind: 'possibly already fixed', result, ...ready }; @@ -177,9 +177,9 @@ export class PullRequestPublisher { return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; } - /** Whether a lost opening is the current publish's own: the task has not changed since it began, and the mode matches. */ + /** 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 && lost.draft === draft; + 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 into one base, in the configured repository. */ diff --git a/runner/store.ts b/runner/store.ts index 43d4ed93..8690f513 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -30,6 +30,8 @@ export interface TaskPullRequest { 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; } @@ -913,9 +915,9 @@ export class Store { 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, + 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_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; @@ -927,7 +929,7 @@ export class Store { 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, + 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 }, }; } @@ -952,7 +954,7 @@ export class Store { * 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; draft: boolean; result: AlreadyFixedResult }): AlreadyFixedCheck { + 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(() => { @@ -962,8 +964,10 @@ export class Store { 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. + // 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 }; @@ -980,9 +984,9 @@ export class Store { if (this.#get("SELECT 1 FROM task_pull_requests WHERE plan_key=? AND state='opening'", key)) throw new GuardRefusal('A pull request is already being opened; recover it first.'); const openingId = randomUUID(), now = new Date().toISOString(); this.#touch(key); - this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,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, now, now); + this.#run(`INSERT INTO task_pull_requests (opening_id,plan_key,repository,base,head_branch,head_sha,draft,owner_version,owner_review_version,state,number,url,created_at,updated_at) + 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)!; }); } @@ -1000,8 +1004,8 @@ export class Store { 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=?, updated_at=? WHERE opening_id=?', - input.headSha, input.draft ? 1 : 0, version, new Date().toISOString(), input.openingId); + 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; }); } @@ -1009,7 +1013,7 @@ export class Store { abandonRefresh(identity: PlanIdentity, openingId: string): void { const key = identityKey(identity); this.#transaction(() => { - if (this.#run("UPDATE task_pull_requests SET refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, updated_at=? WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", + if (this.#run("UPDATE task_pull_requests SET refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, refresh_review_version=NULL, updated_at=? WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", new Date().toISOString(), key, openingId).changes !== 1) throw new GuardRefusal('No update of this pull request is in flight.'); this.#touch(key); }); @@ -1030,7 +1034,7 @@ export class Store { const key = identityKey(identity); return this.#confirmPullRequest(key, pr, () => 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 }), openingId, 'No pull request is being opened with this ID.'); + 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 }, @@ -1039,22 +1043,23 @@ export class Store { return this.#confirmPullRequest(key, pr, () => 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), - () => ({ head: refresh.head, owned: refresh.stateVersion }), openingId, 'No update of this pull request is in flight.'); + 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 }, find: () => Record | undefined, - expected: (row: Record) => { head: string; owned: number }, openingId: string, missing: string): TaskStatus { + 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 } = expected(row); - this.#run("UPDATE task_pull_requests SET state='opened', number=?, url=?, draft=?, head_sha=?, refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, updated_at=? WHERE opening_id=?", + const { head, owned, ownedReview } = expected(row); + 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, head, 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. A needs-human task stays - // there whatever the PR looks like; only a running task can move to in review. - if (task.state_version === owned && task.status === 'running') { + // 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. + if (task.state_version === owned && this.#current(key).review_version === ownedReview && task.status === 'running') { this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === head && !pr.draft ? 'in review' : 'needs human', key); } this.#touch(key); diff --git a/test/publish.test.ts b/test/publish.test.ts index c170c615..1d402fbe 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -37,7 +37,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean; onDraft?: () => void; onRefresh?: () => void } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ? [...options.results] : []; @@ -72,12 +72,14 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, async markDraft(number, input) { log.push(`draft ${number}`); + 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); + options.onRefresh?.(); if (options.refreshFails) throw new Error('timeout reading the PR back'); if (options.draftsUnsupported && input.draft) throw new DraftsUnsupported('no drafts'); const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; @@ -253,6 +255,42 @@ describe('schema v7', () => { }); }); +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(); + const { publisher } = harness(store, { open: async input => { note(store); return { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(2), draft: input.draft }; } }); + expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 5, status: 'running' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5 }]); + }); + it('records a refresh that landed while the review changed, but does not move the task to in review', 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); + expect(await harness(store, { live, next, onRefresh: () => note(store) }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3) }]); + }); + 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('PR records', () => { it('keeps the record of a PR that opened after the task was cancelled, without reopening the task', async () => { const store = runningTask(); @@ -265,7 +303,7 @@ describe('PR records', () => { }); 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, draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + 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/); @@ -273,10 +311,10 @@ describe('PR records', () => { }); 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, draft: false, result: { outcome: 'clear', baseHead: oid(9) } }); + 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', draft: false, result: { outcome: 'clear', baseHead: oid(9) } })).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); }); From 94fa810c07760688ce4ce36d45147d06ac71ab3a Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 00:59:55 -0700 Subject: [PATCH 22/65] Close the independent review's findings on F2d: guard after push, head mismatch, no-changes, settle - Refresh path: re-read the task, its review and its status after the push's await, before the PATCH or ready/draft change. - A PR that GitHub shows at another head than the one pushed no longer moves the task to needs human with a ready PR: the task stays running, the PR is made a draft, and the record keeps the reported head. - The "no changes" path looks up the branch first and turns an earlier ready PR into a draft before moving the task to needs human. - An update whose confirmation was lost is settled from what GitHub shows (draft flag, head) before its record is dropped, for every pending row. - adoptOpening and the draft-flag repair also require the review version to be unchanged. - Publishing refuses a task with interrupted work waiting to be requeued or a rebase in progress. - Tests: the two tests that asserted the unguarded behaviour now assert the guarded one; added tests for every guard the review listed as untested (review during the check, active attempt, recovery's drafts-unsupported and other-repository branches, paging changes). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 10 +- runner/publish.ts | 57 ++++++--- runner/store.ts | 56 +++++++-- test/already-fixed.test.ts | 7 +- test/publish.test.ts | 128 ++++++++++++++++++-- 5 files changed, 215 insertions(+), 43 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 7ee85b66..19765db3 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -51,19 +51,19 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. -2. **Status and no changes.** Refuse unless the task is running (or in needs human, for a draft). If the task head is its base, open nothing. A running task moves to needs human. -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 (none if the task has no PR yet). 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. +2. **Status.** 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 (none if the task has no PR yet). 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. If the task head is its base, nothing is published: an earlier ready PR is turned into a draft (after re-reading the task), 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. +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; 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), 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 drops the record in step 1 and repeats the update after a new check; the update is idempotent. Record the result. +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), 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 | needs human | +| 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, so it is not left ready for review; 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, new attempt, new head) | Opened | Unchanged; the PR is recorded so it can be reused or closed later | diff --git a/runner/publish.ts b/runner/publish.ts index 0dc8bca1..dfd1010a 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -82,10 +82,6 @@ export class PullRequestPublisher { const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); const reviewVersion = this.#store.reviewVersion(identity); 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 (snapshot.head === snapshot.base) { - if (!draft) this.#store.transitionTask(identity, task.stateVersion, 'needs human'); - return { kind: 'no changes' }; - } 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: GitHub allows one open PR per branch. It is looked up before the check, because an abandoned @@ -101,11 +97,21 @@ export class PullRequestPublisher { // 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); + 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); + stateVersion = this.#store.recordPullRequestDraft(identity, earlier.openingId, live.number, live.draft, { stateVersion, reviewVersion }); + // Nothing to publish: the task's PR, if it is open and ready, must not stay ready for review. + if (snapshot.head === snapshot.base) { + if (earlier && live && !live.draft) { + this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); + 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 }); + } + if (!draft) this.#store.transitionTask(identity, stateVersion, 'needs human'); + return { kind: 'no changes' }; + } 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({ @@ -129,7 +135,7 @@ export class PullRequestPublisher { } 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, check.stateVersion); + 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) { @@ -145,14 +151,17 @@ export class PullRequestPublisher { 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.#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 drops it and starts again from the draft step above. + // 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, { base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), 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 { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + 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. await this.#pusher.push(identity, { head: snapshot.head, branch }, signal); @@ -174,7 +183,21 @@ export class PullRequestPublisher { return { kind: 'draft unsupported', number: null }; } const status = this.#store.recordPullRequestOpened(identity, opening.openingId, pr); - return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + return this.#settleHead(identity, opening.openingId, pr, snapshot.head, draft, status, branch, signal); + } + + /** + * A ready PR that GitHub shows at another head than the one pushed (GitHub has not caught up, or someone else pushed) + * is not ready for review: the task stays running and the PR becomes a draft until a later publish reconciles it. + */ + 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 { + if (draft || pr.draft || pr.headSha === head) return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; + const drafted = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); + // 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 }; } /** Whether a lost opening is the current publish's own: neither the task nor its review changed since it began, and the mode matches. */ @@ -193,10 +216,14 @@ export class PullRequestPublisher { * 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. - const refreshing = this.#store.taskPullRequests(identity).find(pr => pr.refresh !== null); - if (refreshing) this.#store.abandonRefresh(identity, refreshing.openingId); - // Read once: abandonRefresh above is the only write before this point. + // 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)) { + const observed = await this.#pulls.findOpened({ base: refreshing.base, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); + signal?.throwIfAborted(); + this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); + } + // 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; @@ -235,6 +262,6 @@ export class PullRequestPublisher { // 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - return current ? { kind: 'opened', number: found.number, url: found.url, draft: found.draft, status } : null; + return current ? this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal) : null; } } diff --git a/runner/store.ts b/runner/store.ts index 8690f513..bccd827b 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -949,6 +949,9 @@ export class Store { 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 @@ -1009,15 +1012,40 @@ export class Store { return version; }); } - /** An unconfirmed update is dropped; the next publish checks again and repeats it (the update is idempotent). */ - abandonRefresh(identity: PlanIdentity, openingId: string): void { + /** + * 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(() => { - if (this.#run("UPDATE task_pull_requests SET refresh_head=NULL, refresh_draft=NULL, refresh_version=NULL, refresh_review_version=NULL, updated_at=? WHERE plan_key=? AND opening_id=? AND refresh_head IS NOT NULL", - new Date().toISOString(), key, openingId).changes !== 1) throw new GuardRefusal('No update of this pull request is in flight.'); + 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.'); @@ -1052,15 +1080,19 @@ export class Store { 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, head, new Date().toISOString(), openingId); + 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. - if (task.state_version === owned && this.#current(key).review_version === ownedReview && task.status === 'running') { - this.#run('UPDATE tasks SET status=? WHERE plan_key=?', pr.headSha === head && !pr.draft ? 'in review' : 'needs human', key); + // 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 (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; @@ -1071,11 +1103,11 @@ export class Store { * 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 }, expectedStateVersion: number): number { + 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(() => { - if (this.#task(key).state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + 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); @@ -1098,10 +1130,10 @@ export class Store { * 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, expectedStateVersion: number): number { + recordPullRequestDraft(identity: PlanIdentity, openingId: string, number: number, draft: boolean, expected: { stateVersion: number; reviewVersion: number }): number { const key = identityKey(identity); return this.#transaction(() => { - if (this.#task(key).state_version !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.'); + 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); diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 79a93119..b8abbeb1 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -11,7 +11,7 @@ const disconnected = (subject: unknown) => ({ __typename: 'DisconnectedEvent', s const closed = (closer: unknown) => ({ __typename: 'ClosedEvent', closer }); interface Fake { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; - commits?: { sha: string; message: string }[]; status?: string; totalCommits?: number; baseRef?: string; fail?: RegExp } + commits?: { sha: string; message: string }[]; status?: string; totalCommits?: number; totalCommitsLater?: number; baseRef?: string; fail?: RegExp } function gateway(fake: Fake = {}) { const calls: string[][] = []; const nodes = fake.nodes ?? []; @@ -27,7 +27,7 @@ function gateway(fake: Fake = {}) { }); if (joined.includes('/git/ref/heads/')) return JSON.stringify({ ref: `refs/heads/${fake.baseRef ?? 'main'}`, object: { sha: sha(99) } }); const page = Number(/[?&]page=(\d+)/.exec(joined)![1]); - return JSON.stringify({ status: fake.status ?? 'ahead', total_commits: fake.totalCommits ?? commits.length, + 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) }; @@ -120,6 +120,9 @@ describe('the pre-PR already-fixed check', () => { { 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 }, + // 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' }] }, { nameWithOwner: 'other/repo' }, { baseRef: 'other' }, { state: 'CLOSED' }, { nodes: [cross(null)] }, { nodes: [cross({ __typename: 'Discussion' })] }, diff --git a/test/publish.test.ts b/test/publish.test.ts index 1d402fbe..ad608ded 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -143,7 +143,23 @@ describe('opening the task PR', () => { const store = runningTask({ head: oid(1) }); const { publisher, log } = harness(store); expect(await publisher.publish(identity)).toEqual({ kind: 'no changes' }); - expect(log).toEqual([]); + // 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 () => { @@ -220,10 +236,17 @@ describe('opening the task PR', () => { expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 6, status: 'running' }); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 6 }]); }); - it('moves to needs human when the branch head moved before GitHub read it', async () => { - const store = runningTask(); - const { publisher } = harness(store, { open: async input => ({ number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(77), draft: input.draft }) }); - expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', status: 'needs human' }); + 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'); }); }); @@ -291,6 +314,92 @@ describe('review changes during GitHub calls', () => { }); }); +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()); + await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/cancelled/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, 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('reports drafts unsupported when recovery cannot turn a lost draft opening 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('PR records', () => { it('keeps the record of a PR that opened after the task was cancelled, without reopening the task', async () => { const store = runningTask(); @@ -462,7 +571,7 @@ describe('recovering a lost opening', () => { 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, so a task change during the push still completes the PR update without moving the task', async () => { + 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'] }); @@ -472,10 +581,11 @@ describe('recovering a lost opening', () => { refreshRecordedAtPush = store.taskPullRequests(identity)[0]!.refresh; store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); } }); - expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running' }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/Stale task state/); expect(refreshRecordedAtPush).toMatchObject({ head: oid(3), draft: false }); - expect(again.log.at(-1)).toBe('refresh 100 ready'); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3) }]); + // 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(); From 0022b1828e80b78ad7597731e12bbdc6454737f9 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 01:08:02 -0700 Subject: [PATCH 23/65] Close independent review round 2 on F2d: no-changes guards, leftReady, mismatch order - The no-changes path runs the full publish guard (including requeue and rebase) after the lookup, handles drafts being unsupported (leftReady), and moves the task to needs human only then. - The PR-number mismatch is refused before any GitHub change on every path, including no-changes. - After an open or refresh, a ready PR is made a draft unless its task is now in review at the pushed head; a failure there is reported as leftReady instead of failing the publish that succeeded. - Tests: the two review-change tests now assert the PR ends as a draft; new tests for each fix and for settleUnconfirmedRefresh's number match. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 8 ++- runner/publish.ts | 51 +++++++++----- test/publish.test.ts | 76 +++++++++++++++++++-- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 19765db3..1e37aa58 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -52,7 +52,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status.** 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 (none if the task has no PR yet). 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. If the task head is its base, nothing is published: an earlier ready PR is turned into a draft (after re-reading the task), and a running task moves to needs human. +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 (none if the task has no PR yet). 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 any path. 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; 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. @@ -63,9 +63,11 @@ Each opening or refresh owns the task state version and the plan's review versio | 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, so it is not left ready for review; the next publish reconciles it | +| 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 | + +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. | Unchanged, needs human | Draft opened, or the earlier PR updated and turned back into a draft | needs human | -| Changed (cancelled, reassigned, new attempt, new head) | Opened | Unchanged; the PR is recorded so it can be reused or closed later | +| 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 | 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. diff --git a/runner/publish.ts b/runner/publish.ts index dfd1010a..e5d125c1 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -23,7 +23,8 @@ export interface PublishConfig { export class OpeningUnsettled extends Error {} export const DEFAULT_SETTLE_MS = 10 * 60_000; export type PublishOutcome = - | { kind: 'opened'; number: number; url: string; draft: boolean; status: string } + /** `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. */ @@ -33,8 +34,11 @@ export type PublishOutcome = * 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. */ - | { kind: 'no changes' }; + /** + * 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) => ``; /** @@ -102,15 +106,22 @@ export class PullRequestPublisher { } 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) { - this.#store.assertUnchangedSince(identity, { stateVersion, reviewVersion, snapshotId: snapshot.id, draft }); - 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 }); + 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; } } if (!draft) this.#store.transitionTask(identity, stateVersion, 'needs human'); - return { kind: 'no changes' }; + 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); @@ -120,8 +131,6 @@ export class PullRequestPublisher { ownCommits: new Set(this.#store.getLedger(identity).filter(entry => entry.origin === 'owned').map(entry => entry.sha)), }, signal); signal?.throwIfAborted(); - // Checked before any GitHub change: 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.'); // 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 @@ -187,17 +196,25 @@ export class PullRequestPublisher { } /** - * A ready PR that GitHub shows at another head than the one pushed (GitHub has not caught up, or someone else pushed) - * is not ready for review: the task stays running and the PR becomes a draft until a later publish reconciles it. + * 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 { - if (draft || pr.draft || pr.headSha === head) return { kind: 'opened', number: pr.number, url: pr.url, draft: pr.draft, status }; - const drafted = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); - // 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 }; + const opened = { kind: 'opened' as const, number: pr.number, url: pr.url, draft: pr.draft, status }; + if (draft || pr.draft || (status === 'in review' && pr.headSha === head)) return opened; + try { + const drafted = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); + // 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 }; + } catch { + // The PR stays ready; the next publish sees it (live, not a draft) and reconciles it. + return { ...opened, leftReady: pr.number }; + } } /** Whether a lost opening is the current publish's own: neither the task nor its review changed since it began, and the mode matches. */ diff --git a/test/publish.test.ts b/test/publish.test.ts index ad608ded..e74a11a2 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -289,18 +289,25 @@ describe('review changes during GitHub calls', () => { 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(); - const { publisher } = harness(store, { open: async input => { note(store); return { number: 5, url: 'https://github.com/owner/repo/pull/5', headSha: oid(2), draft: input.draft }; } }); - expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 5, status: 'running' }); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5 }]); + 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('records a refresh that landed while the review changed, but does not move the task to in review', 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); - expect(await harness(store, { live, next, onRefresh: () => note(store) }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running' }); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3) }]); + const outcome = await harness(store, { live, next, onRefresh: () => note(store) }).publisher.publish(identity); + expect(outcome).toMatchObject({ kind: 'opened', number: 100, status: 'running', draft: true }); + expect(outcome).not.toHaveProperty('leftReady'); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3), draft: true }]); }); 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 }; @@ -382,6 +389,63 @@ describe('guards found by the independent review', () => { } 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 a different PR number 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'); + for (const [m, pr] of live) live.set(m, { ...pr, number: 999, draft: false, headSha: oid(8) }); + store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); + await expect(harness(store, { live, next }).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 recovery cannot turn a lost draft opening back into a draft', async () => { const store = runningTask(), live = new Map(), next = { value: 100 }; store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); From 3c8d31b5f780575296d3206fced4e2fe2ae9a975 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 01:30:32 -0700 Subject: [PATCH 24/65] Close independent review round 3 on F2d: recovery drafts, no-changes re-read, settle errors - Recovery makes a recovered ready PR (lost opening or lost update) a draft when its task can no longer be published as ready, since the main path will refuse on status. - The no-changes path re-reads the task after a refused draft change, as it does after a successful one. - #settleHead reports only the GitHub call's failure as leftReady; a Store failure after a draft change that landed propagates. - Tests for every guard the review found untested: the lost opening's mode check, a recovered opening that is now a draft, one opening in flight, the refresh confirmation filter, an abort during the refresh push, issue/PR state and closer validation, the read-back identity check and markDraft's abort rethrow. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- runner/publish.ts | 46 +++++-- test/already-fixed.test.ts | 3 + test/publish.test.ts | 126 ++++++++++++++++++-- 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 1e37aa58..8c3a1b5c 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,7 +50,7 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse on status. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. 2. **Status.** 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 (none if the task has no PR yet). 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 any path. 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. diff --git a/runner/publish.ts b/runner/publish.ts index e5d125c1..80d03217 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -118,7 +118,12 @@ export class PullRequestPublisher { 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; } + } 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 }); + } } if (!draft) this.#store.transitionTask(identity, stateVersion, 'needs human'); return leftReady === undefined ? { kind: 'no changes' } : { kind: 'no changes', leftReady }; @@ -205,16 +210,15 @@ export class PullRequestPublisher { draft: boolean, status: string, branch: string, signal?: AbortSignal): Promise { const opened = { kind: 'opened' as const, number: pr.number, url: pr.url, draft: pr.draft, status }; if (draft || pr.draft || (status === 'in review' && pr.headSha === head)) return opened; - try { - const drafted = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); - // 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 }; - } catch { - // The PR stays ready; the next publish sees it (live, not a draft) and reconciles it. - return { ...opened, leftReady: pr.number }; - } + 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: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); } + catch { 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 }; } /** Whether a lost opening is the current publish's own: neither the task nor its review changed since it began, and the mode matches. */ @@ -239,6 +243,7 @@ export class PullRequestPublisher { const observed = await this.#pulls.findOpened({ base: refreshing.base, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); + if (observed && observed.number === refreshing.number) await this.#draftIfNotPublishable(identity, refreshing, observed, signal); } // Read once: the settlements above are the only writes before this point. const prs = this.#store.taskPullRequests(identity); @@ -279,6 +284,23 @@ export class PullRequestPublisher { // 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - return current ? this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal) : null; + if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal); + await this.#draftIfNotPublishable(identity, lost, found, signal); + return null; + } + + /** + * After recovery records a PR (a lost opening, or an update whose confirmation was lost), a ready PR whose task can no + * longer be published as ready (cancelled, needs human, possibly already fixed, and so on) is made a draft at once: + * the main path may refuse on status, and nothing else would. A running task's main path reconciles the PR itself. + */ + async #draftIfNotPublishable(identity: PlanIdentity, row: TaskPullRequest, pr: { number: number; draft: boolean }, signal?: AbortSignal): Promise { + const status = this.#store.getTask(identity).status; + if (pr.draft || status === 'running' || status === 'in review') return; + let drafted; + try { drafted = await this.#pulls.markDraft(pr.number, { base: row.base, headBranch: row.headBranch, marker: marker(row.openingId) }, signal); } + catch (error) { if (error instanceof DraftsUnsupported) return; throw error; } + this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, + { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); } } diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index b8abbeb1..810ad8a3 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -120,6 +120,9 @@ describe('the pre-PR already-fixed check', () => { { 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 }, + // 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 }, diff --git a/test/publish.test.ts b/test/publish.test.ts index e74a11a2..aa9dbda5 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -228,13 +228,14 @@ describe('opening the task PR', () => { 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(); - const { publisher } = harness(store, { open: async input => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, open: async input => { store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); - return { number: 6, url: 'https://github.com/owner/repo/pull/6', headSha: oid(2), draft: input.draft }; + 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; } }); - expect(await publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 6, status: 'running' }); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 6 }]); + // 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(); @@ -360,8 +361,11 @@ describe('guards found by the independent review', () => { 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()); - await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/cancelled/); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: false, headSha: oid(3), refresh: null }]); + 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(); @@ -464,15 +468,98 @@ describe('guards found by the independent review', () => { }); }); +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'); + }); + 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('PR records', () => { it('keeps the record of a PR that opened after the task was cancelled, without reopening the task', async () => { - const store = runningTask(); - const { publisher } = harness(store, { open: async input => { + const store = runningTask(), live = new Map(); + const { publisher } = harness(store, { live, open: async input => { store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); - return { number: 9, url: 'https://github.com/owner/repo/pull/9', headSha: oid(2), draft: input.draft }; + 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' }); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 9 }]); + 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(); @@ -1049,6 +1136,21 @@ describe('GitHub PR adapter', () => { 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()]); }); From 3c20a0c752f97b955204014cdf24d1adddc9c0f9 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 01:52:43 -0700 Subject: [PATCH 25/65] Close independent review round 4 on F2d: busy tasks, early guard, wrong-repo updates - Recovery makes a recovered ready PR a draft whenever the task cannot be published as ready right now, including a running task with an attempt active, requeue pending, rebase or merge in progress. - The full publish guard runs before any GitHub call, so a busy task is refused before the check. - A pending update recorded for another repository is refused loudly instead of being cleared after a lookup in the wrong repository. - #settleHead drafts a draft publish's PR whatever GitHub returned, and uses the recovered opening's base; the test that locked in a ready PR for a needs-human task now asserts the draft. - Tests for the guards the review found untested: own-PR repository filter, branch-record base filter, abort during the lookup, the clear check before an opening, PR-number matches, commit message, base head and commit-count validation. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 4 +- runner/publish.ts | 19 +++-- runner/store.ts | 11 +++ test/already-fixed.test.ts | 6 +- test/publish.test.ts | 80 ++++++++++++++++++++- 5 files changed, 107 insertions(+), 13 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 8c3a1b5c..1632117a 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,8 +50,8 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse on status. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. -2. **Status.** 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse. This includes a running task that cannot be published now (an attempt active, requeue pending, rebase or merge in progress). A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. +2. **Status.** Before any 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 (none if the task has no PR yet). 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 any path. 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; a change during the push leaves the update in flight and the PR's description and draft state as they were. diff --git a/runner/publish.ts b/runner/publish.ts index 80d03217..038d4b17 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -85,7 +85,8 @@ export class PullRequestPublisher { if (recovered) return recovered; const task = this.#store.getTask(identity), snapshot = this.#store.getSnapshot(identity), plan = this.#store.getPlan(identity); const reviewVersion = this.#store.reviewVersion(identity); - if (task.status !== (draft ? 'needs human' : 'running')) throw new GuardRefusal(`A ${draft ? 'draft ' : ''}pull request cannot be opened while the task is ${task.status}.`); + // The full publish guard (status, no attempt, merge, requeue or rebase) before any GitHub call. + this.#store.assertPublishableNow(identity, draft); 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: GitHub allows one open PR per branch. It is looked up before the check, because an abandoned @@ -207,13 +208,15 @@ export class PullRequestPublisher { * 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 { + draft: boolean, status: string, branch: string, signal?: AbortSignal, base = this.#config.baseBranch): Promise { const opened = { kind: 'opened' as const, number: pr.number, url: pr.url, draft: pr.draft, status }; - if (draft || pr.draft || (status === 'in review' && pr.headSha === head)) return opened; + // 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: this.#config.baseBranch, headBranch: branch, marker: marker(openingId) }, signal); } + try { drafted = await this.#pulls.markDraft(pr.number, { base, headBranch: branch, marker: marker(openingId) }, signal); } catch { 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, @@ -240,6 +243,7 @@ export class PullRequestPublisher { // 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.'); const observed = await this.#pulls.findOpened({ base: refreshing.base, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); @@ -284,7 +288,7 @@ export class PullRequestPublisher { // 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal); + if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, lost.base); await this.#draftIfNotPublishable(identity, lost, found, signal); return null; } @@ -295,8 +299,9 @@ export class PullRequestPublisher { * the main path may refuse on status, and nothing else would. A running task's main path reconciles the PR itself. */ async #draftIfNotPublishable(identity: PlanIdentity, row: TaskPullRequest, pr: { number: number; draft: boolean }, signal?: AbortSignal): Promise { - const status = this.#store.getTask(identity).status; - if (pr.draft || status === 'running' || status === 'in review') return; + // A task in review keeps a ready PR; one the main path will go on to publish as ready reconciles it there. Any other + // task (including a running one with an active attempt, pending requeue, rebase or merge) gets a draft now. + if (pr.draft || this.#store.getTask(identity).status === 'in review' || this.#store.canPublish(identity, false)) return; let drafted; try { drafted = await this.#pulls.markDraft(pr.number, { base: row.base, headBranch: row.headBranch, marker: marker(row.openingId) }, signal); } catch (error) { if (error instanceof DraftsUnsupported) return; throw error; } diff --git a/runner/store.ts b/runner/store.ts index bccd827b..8d3350f0 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1114,6 +1114,17 @@ export class Store { 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); diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 810ad8a3..840d9b26 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -11,7 +11,7 @@ const disconnected = (subject: unknown) => ({ __typename: 'DisconnectedEvent', s const closed = (closer: unknown) => ({ __typename: 'ClosedEvent', closer }); interface Fake { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; - commits?: { sha: string; message: string }[]; status?: string; totalCommits?: number; totalCommitsLater?: number; baseRef?: string; fail?: RegExp } + 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 ?? []; @@ -25,7 +25,7 @@ function gateway(fake: Fake = {}) { data: { repository: { nameWithOwner: fake.nameWithOwner ?? 'owner/repo', 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: sha(99) } }); + 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 } })) }); @@ -120,6 +120,8 @@ describe('the pre-PR already-fixed check', () => { { 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' })] }, diff --git a/test/publish.test.ts b/test/publish.test.ts index aa9dbda5..aa7d04c0 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -551,6 +551,80 @@ describe('Store guards the review listed as untested', () => { }); }); +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 develop record is not a candidate, so the branch PR is not codeboost's for main. + await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/did not open/); + }); + 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('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(); @@ -686,10 +760,12 @@ describe('recovering a lost opening', () => { 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'); - // Even if GitHub still reports the PR as ready, a needs-human task never moves to in review. + // Even if GitHub still reports the PR as ready, a needs-human task never moves to in review, and its PR is made a draft. rerun(store); store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); - expect(await harness(store, { live, next, draftAfterRefresh: false }).publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: false, status: 'needs human' }); + const last = harness(store, { live, next, draftAfterRefresh: false }); + expect(await last.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: true, status: 'needs human' }); + expect(last.log.at(-1)).toBe('draft 100'); }); 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 }; From e36bcbb0d275a04a0a45d227914d64da703e331b Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 02:05:37 -0700 Subject: [PATCH 26/65] Close independent review round 5 on F2d: re-check before ready, adopt in recovery - refresh() calls a beforeReady check after the PATCH's await and before any ready or draft change; the publisher passes assertRefreshCurrent, so a cancel or review during the description update never marks the PR ready (and never sends review requests). - Recovery adopts an abandoned opening's PR when the branch PR carries its marker, and makes it a draft if the task cannot be published as ready; a task that left running no longer keeps an unrecorded ready PR. - Tests: the no-changes re-read, the in-review and publishable exemptions, beforeReady at the gateway and in the publisher, and the recovery adoption. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 4 +- github/pull-requests.ts | 7 +- runner/publish.ts | 11 ++- test/publish.test.ts | 74 +++++++++++++++++++-- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 1632117a..f1e14d50 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,11 +50,11 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse. This includes a running task that cannot be published now (an attempt active, requeue pending, rebase or merge in progress). A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned` and continue. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse. This includes a running task that cannot be published now (an attempt active, requeue pending, rebase or merge in progress). A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here and make it a draft if the task cannot be published as ready, then continue. If none exists, 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. 2. **Status.** Before any 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 (none if the task has no PR yet). 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 any path. 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; a change during the push leaves the update in flight and the PR's description and draft state as they were. +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), 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index eb4cc5f1..e710d729 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -24,7 +24,8 @@ export interface PullRequestGateway { */ findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ - refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, signal?: AbortSignal): Promise; + /** `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; } @@ -131,7 +132,7 @@ export class GhPullRequestGateway implements PullRequestGateway { return { ...pr, marker: found[0]! }; } - async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string }, signal?: AbortSignal): Promise { + 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.'); @@ -139,6 +140,8 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 })); diff --git a/runner/publish.ts b/runner/publish.ts index 038d4b17..260141ba 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -173,6 +173,7 @@ export class PullRequestPublisher { // 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, { base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), + beforeReady: () => 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 }); @@ -259,8 +260,16 @@ export class PullRequestPublisher { signal?.throwIfAborted(); if (pr && pr.marker !== marker(lost.openingId)) { // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing - // (GitHub allows one open PR per branch). Drop it; the main path reuses, or adopts, the PR that is there. + // (GitHub allows one open PR per branch). Drop it. If that other opening was abandoned, its PR is adopted here, + // not only on the main path, which a task that can no longer publish never reaches; then it is made a draft if + // the task cannot be published as ready. this.#store.abandonPullRequestOpening(identity, lost.openingId); + const owner = rows.find(row => marker(row.openingId) === pr.marker); + if (owner?.state === 'abandoned') { + this.#store.adoptOpening(identity, owner.openingId, pr, + { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + await this.#draftIfNotPublishable(identity, owner, pr, signal); + } return null; } if (!pr) { diff --git a/test/publish.test.ts b/test/publish.test.ts index aa7d04c0..e8583241 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -80,6 +80,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: async refresh(number, input) { log.push(`refresh ${number} ${input.ready ? 'ready' : 'draft'}`); opened.push(input); 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'); const pr = { ...live.get(input.marker)!, draft: options.draftAfterRefresh ?? input.draft, headSha: store.getSnapshot(identity).head }; @@ -300,15 +301,15 @@ describe('review changes during GitHub calls', () => { expect(outcome).not.toHaveProperty('leftReady'); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5, draft: true }]); }); - it('records a refresh that landed while the review changed, but does not move the task to in review', async () => { + 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); - const outcome = await harness(store, { live, next, onRefresh: () => note(store) }).publisher.publish(identity); - expect(outcome).toMatchObject({ kind: 'opened', number: 100, status: 'running', draft: true }); - expect(outcome).not.toHaveProperty('leftReady'); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: null, headSha: oid(3), draft: true }]); + 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 }; @@ -508,6 +509,9 @@ describe('independent review round 3', () => { 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 }; @@ -625,6 +629,59 @@ describe('independent review round 4', () => { }); }); +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(); @@ -953,7 +1010,7 @@ describe('recovering from an abandoned opening whose PR appears later', () => { // 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('does not adopt when the task changed during the lookup', async () => { + it('refuses the main path when the task changed during its lookup, after recovery adopted 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'); @@ -964,7 +1021,10 @@ describe('recovering from an abandoned opening whose PR appears later', () => { let finds = 0; const again = harness(store, { live, next, config: later, onFind: () => { if (++finds === 2) 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']); + // Recovery adopted the first opening's PR before the change (a fact about the PR); the main path, after the change, + // refused when recording the check's result, so nothing was pushed or opened. + expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['opened', '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 }; From 3399abf05b988cc9163c49d551b124a88051318d Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:41:37 -0700 Subject: [PATCH 27/65] Close independent review round 6 on F2d: shutdown guard, owed drafts - The publisher reads the coordinator's closing flag and its own right before each push, opening, description update and ready change, and close() aborts and awaits every publish in progress (runner-lifecycle.md, "Irreversible actions"; AGENTS.md in-flight work). - A task that cannot be published in the requested mode has every PR its record shows as ready looked up and made a draft before the status refusal. The "ready" record is the durable signal, so a failed draft change (leftReady) is repeated by the next publish, and recovery no longer drafts in three places of its own. Drafts being unsupported is named in the refusal instead of dropped. Tasks in review, approved or merged are left alone (GitHub does not merge a draft), and a GitHub failure in this step is noted in the status refusal, not raised instead. - The no-changes path checks the abort signal before moving the task. - Gateways configured for another repository are refused; the gh environment allows TEMP and TMP. - Tests: main-path adoption and its own-PR exclusion, draft-mode no changes, the shutdown checks, owed drafts, abort on no changes, the repository check. Doc: the new step, shutdown, the 66 s bound. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 16 +- github/already-fixed.ts | 6 +- github/gh-env.ts | 2 +- github/pull-requests.ts | 2 + runner/publish.ts | 105 ++++++++--- test/publish.test.ts | 194 +++++++++++++++++++- 6 files changed, 292 insertions(+), 33 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index f1e14d50..4ab45cc7 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,8 +50,9 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. If the task can no longer be published as ready (cancelled, needs human, possibly already fixed), a recovered ready PR, whether from a lost opening or a lost update, is made a draft right away, because the main path will refuse. This includes a running task that cannot be published now (an attempt active, requeue pending, rebase or merge in progress). A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here and make it a draft if the task cannot be published as ready, then continue. If none exists, 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. -2. **Status.** Before any 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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 PR its record shows as open and ready is looked up and made a draft. 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. 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, so each later publish of that task looks it 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 (none if the task has no PR yet). 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 any path. 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. @@ -75,11 +76,15 @@ When the check matches (or is unknown) and the task's earlier PR is open and rea 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. +**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 is not refused: it only makes a PR safer, and `close()` stops it through the abort. `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; and proxy and CA settings (`github/gh-env.ts`). Prompts, the pager and update checks are turned off. +**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. ## The PR description @@ -102,6 +107,7 @@ Some repositories do not support draft PRs (for example private repositories on | 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 | Turning it into a draft is the first step, before the push or any description change, so the refusal leaves the PR exactly as it was; publish returns `draft unsupported` with its number. | | 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 @@ -112,7 +118,7 @@ Some repositories do not support draft PRs (for example private repositories on ## 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, the fence length, and the description bounds. +`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, the abort checks on the no-changes path, the `closing` checks before a push and before a ready change, `close()` aborting a publish in progress, the repository check, the fence length, and the description bounds. ## Test this document with a reader diff --git a/github/already-fixed.ts b/github/already-fixed.ts index e91f189b..7377ffd6 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -30,7 +30,11 @@ export interface AlreadyFixedInput { /** This task's own commits (runner-owned ledger entries). */ ownCommits: ReadonlySet; } -export interface AlreadyFixedGateway { check(input: AlreadyFixedInput, signal?: AbortSignal): Promise } +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; diff --git a/github/gh-env.ts b/github/gh-env.ts index e3952d76..eed4b2b5 100644 --- a/github/gh-env.ts +++ b/github/gh-env.ts @@ -10,7 +10,7 @@ export const GH_ENV_ALLOWLIST = [ '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', + '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 { diff --git a/github/pull-requests.ts b/github/pull-requests.ts index e710d729..c9a83256 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -16,6 +16,8 @@ export interface OpenPullRequestInput { } 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 diff --git a/runner/publish.ts b/runner/publish.ts index 260141ba..fd10fd5c 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -3,7 +3,7 @@ import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; import { DraftsUnsupported, type PullRequestGateway } from '../github/pull-requests.ts'; -import { GuardRefusal } from './lifecycle.ts'; +import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError } from './lifecycle.ts'; import type { Store, TaskPullRequest } from './store.ts'; /** @@ -49,8 +49,35 @@ const publishing = new WeakMap>(); export class PullRequestPublisher { #store: Store; #checks: AlreadyFixedGateway; #pulls: PullRequestGateway; #pusher: BranchPusher; #config: PublishConfig; - constructor(store: Store, deps: { 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(); } /** @@ -70,23 +97,32 @@ export class PullRequestPublisher { */ 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); - try { return await this.#publish(identity, input, signal); } - finally { inflight.delete(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. - this.#store.assertPublishableNow(identity, draft); + 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: GitHub allows one open PR per branch. It is looked up before the check, because an abandoned @@ -126,6 +162,8 @@ export class PullRequestPublisher { 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 }; } @@ -163,26 +201,30 @@ export class PullRequestPublisher { } // 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, { base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), - beforeReady: () => this.#store.assertRefreshCurrent(identity, earlier.openingId, draft), + 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, }); @@ -248,7 +290,6 @@ export class PullRequestPublisher { const observed = await this.#pulls.findOpened({ base: refreshing.base, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); - if (observed && observed.number === refreshing.number) await this.#draftIfNotPublishable(identity, refreshing, observed, signal); } // Read once: the settlements above are the only writes before this point. const prs = this.#store.taskPullRequests(identity); @@ -261,14 +302,12 @@ export class PullRequestPublisher { if (pr && pr.marker !== marker(lost.openingId)) { // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing // (GitHub allows one open PR per branch). Drop it. If that other opening was abandoned, its PR is adopted here, - // not only on the main path, which a task that can no longer publish never reaches; then it is made a draft if - // the task cannot be published as ready. + // not only on the main path, which a task that can no longer publish never reaches. this.#store.abandonPullRequestOpening(identity, lost.openingId); const owner = rows.find(row => marker(row.openingId) === pr.marker); if (owner?.state === 'abandoned') { this.#store.adoptOpening(identity, owner.openingId, pr, { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); - await this.#draftIfNotPublishable(identity, owner, pr, signal); } return null; } @@ -298,23 +337,43 @@ export class PullRequestPublisher { const current = this.#isCurrent(identity, lost, draft); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, lost.base); - await this.#draftIfNotPublishable(identity, lost, found, signal); return null; } /** - * After recovery records a PR (a lost opening, or an update whose confirmation was lost), a ready PR whose task can no - * longer be published as ready (cancelled, needs human, possibly already fixed, and so on) is made a draft at once: - * the main path may refuse on status, and nothing else would. A running task's main path reconciles the PR itself. + * 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. 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 record stays "ready" so the next publish tries again. */ - async #draftIfNotPublishable(identity: PlanIdentity, row: TaskPullRequest, pr: { number: number; draft: boolean }, signal?: AbortSignal): Promise { - // A task in review keeps a ready PR; one the main path will go on to publish as ready reconciles it there. Any other - // task (including a running one with an active attempt, pending requeue, rebase or merge) gets a draft now. - if (pr.draft || this.#store.getTask(identity).status === 'in review' || this.#store.canPublish(identity, false)) return; - let drafted; - try { drafted = await this.#pulls.markDraft(pr.number, { base: row.base, headBranch: row.headBranch, marker: marker(row.openingId) }, signal); } - catch (error) { if (error instanceof DraftsUnsupported) return; throw error; } - this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, - { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + async #draftStranded(identity: PlanIdentity, signal?: AbortSignal): Promise { + const status = this.#store.getTask(identity).status; + // An approved task's PR must stay ready: GitHub does not merge a draft. + if (MERGEABLE_STATUSES.includes(status) || status === 'merged' || this.#store.canPublish(identity, false)) return []; + const prs = this.#store.taskPullRequests(identity), notes: string[] = []; + const ready = prs.filter(pr => pr.state === 'opened' && !pr.draft && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()); + for (const row of ready) { + const markers = this.#branchRows(prs, row.headBranch, row.base).filter(pr => pr.state !== 'opening').map(pr => marker(pr.openingId)); + let drafted; + try { + const live = await this.#pulls.findOpened({ base: row.base, headBranch: row.headBranch, markers }, signal); + // Closed or merged, or the branch's open PR is another opening's: this PR is not ready for review. + if (!live || live.number !== row.number) continue; + drafted = live.draft ? live : await this.#pulls.markDraft(live.number, { base: row.base, headBranch: row.headBranch, marker: marker(row.openingId) }, signal); + } catch (error) { + if (signal?.aborted) throw error; + notes.push(error instanceof DraftsUnsupported + ? `Pull request #${row.number} stays ready for review: this repository does not support draft pull requests.` + : `Pull request #${row.number} could not be made a draft and may still be ready for review; the next publish tries again (${error instanceof Error ? error.message : String(error)}).`); + continue; + } + // A fact about the PR, recorded even after a cancel, against the versions read right now (no await since). + this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, + { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + signal?.throwIfAborted(); + } + return notes; } } diff --git a/test/publish.test.ts b/test/publish.test.ts index e8583241..54581e10 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; -import { GuardRefusal } from '../runner/lifecycle.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 { runWithInput } from '../github/run-with-input.ts'; @@ -37,7 +37,7 @@ function runningTask(options: { head?: string } = {}) { /** `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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean; onDraft?: () => void; onRefresh?: () => void; closing?: () => boolean } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; const results = options.results ? [...options.results] : []; @@ -88,7 +88,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, }; 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); } }; - return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher }, { ...config, ...options.config }) }; + return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher, closing: options.closing }, { ...config, ...options.config }) }; } describe('opening the task PR', () => { @@ -1300,3 +1300,191 @@ describe('GitHub PR adapter', () => { await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(); }); }); + +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(/^find /), '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("adopts on the main path an abandoned opening's PR that becomes visible after recovery, and counts it as the task's own", 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 first opening is abandoned; the second POST is refused (the first PR exists), so the second opening stays owned. + await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); + // Recovery's lookup still sees nothing and abandons the second opening; the PR shows up for the main path's lookup. + const again = harness(store, { live, next, hidden, config: later, onFind: () => { if (again.log.length > 1) hidden.clear(); } }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); + expect(again.checks[0]!.ownPullRequests).toEqual([100]); + expect(again.log).toContain('refresh 100 ready'); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { state: 'abandoned' }]); + }); + 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 or in-review task alone, without asking GitHub', async () => { + for (const status of ['in review', 'approved but merge blocked'] as const) { + const store = runningTask(), live = new Map(); + await harness(store, { live }).publisher.publish(identity); + 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('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(); + }); +}); From 63aaadc1e29de6a3b5059f41441bb8f8e6f1fd40 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:07:17 -0700 Subject: [PATCH 28/65] Close independent review round 7 on F2d: adopt late PRs after a stop - The draft step looks up every branch with a PR recorded as ready or an abandoned opening, so an abandoned opening's PR that appears after its task stopped is adopted and drafted; before, no path reached it. - It re-reads the task after the lookup, so a task approved meanwhile keeps its PR ready, and corrects the draft flag only where it differs. - A failed lookup gets its own note; an abort in the step, or during the head-mismatch draft change, rejects instead of becoming a note or leftReady. - Doc: the results table is whole again; the draft step and shutdown wording match the code. - Tests: late adoption, approval during the lookup, the three abort paths, the repository filter, abort during the head-mismatch draft. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 10 +-- runner/publish.ts | 69 ++++++++++++++------- test/publish.test.ts | 64 +++++++++++++++++++ 3 files changed, 115 insertions(+), 28 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 4ab45cc7..3c5eee08 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -51,7 +51,7 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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 PR its record shows as open and ready is looked up and made a draft. 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. 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, so each later publish of that task looks it up again. + **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, since the main path that also adopts it is never reached. 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. 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 (none if the task has no PR yet). 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 any path. 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. @@ -65,11 +65,11 @@ Each opening or refresh owns the task state version and the plan's review versio |---|---|---| | 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 | - -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. | 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. @@ -78,7 +78,7 @@ The adapter refuses any answer for a PR that is not open. A PR closed between th **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 is not refused: it only makes a PR safer, and `close()` stops it through the abort. `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. +**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. @@ -118,7 +118,7 @@ Some repositories do not support draft PRs (for example private repositories on ## 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, the abort checks on the no-changes path, the `closing` checks before a push and before a ready change, `close()` aborting a publish in progress, the repository check, the fence length, and the description bounds. +`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, 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 fence length, and the description bounds. ## Test this document with a reader diff --git a/runner/publish.ts b/runner/publish.ts index fd10fd5c..97728ca0 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -260,7 +260,7 @@ export class PullRequestPublisher { // 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 { return { ...opened, leftReady: pr.number }; } + 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) }); @@ -344,34 +344,57 @@ export class PullRequestPublisher { * 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. 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 record stays "ready" so the next publish tries again. + * (`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, is never reached. 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): Promise { - const status = this.#store.getTask(identity).status; - // An approved task's PR must stay ready: GitHub does not merge a draft. - if (MERGEABLE_STATUSES.includes(status) || status === 'merged' || this.#store.canPublish(identity, false)) return []; + const keepsReady = () => { + const status = this.#store.getTask(identity).status; + // An approved task's PR must stay ready: GitHub does not merge a draft. + return MERGEABLE_STATUSES.includes(status) || status === 'merged' || this.#store.canPublish(identity, false); + }; + if (keepsReady()) return []; const prs = this.#store.taskPullRequests(identity), notes: string[] = []; - const ready = prs.filter(pr => pr.state === 'opened' && !pr.draft && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()); - for (const row of ready) { - const markers = this.#branchRows(prs, row.headBranch, row.base).filter(pr => pr.state !== 'opening').map(pr => marker(pr.openingId)); - let drafted; - try { - const live = await this.#pulls.findOpened({ base: row.base, headBranch: row.headBranch, markers }, signal); - // Closed or merged, or the branch's open PR is another opening's: this PR is not ready for review. - if (!live || live.number !== row.number) continue; - drafted = live.draft ? live : await this.#pulls.markDraft(live.number, { base: row.base, headBranch: row.headBranch, marker: marker(row.openingId) }, signal); - } catch (error) { + 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. + const branches = new Map(); + for (const pr of prs) { + if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && !pr.draft) || pr.state === 'abandoned')) + branches.set(`${pr.base}\n${pr.headBranch}`, { base: pr.base, headBranch: pr.headBranch }); + } + for (const { base, headBranch } of branches.values()) { + const rows = this.#branchRows(prs, headBranch, base).filter(pr => pr.state !== 'opening'); + let live; + try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); } + catch (error) { if (signal?.aborted) throw error; - notes.push(error instanceof DraftsUnsupported - ? `Pull request #${row.number} stays ready for review: this repository does not support draft pull requests.` - : `Pull request #${row.number} could not be made a draft and may still be ready for review; the next publish tries again (${error instanceof Error ? error.message : String(error)}).`); + notes.push(`The open pull request 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; } - // A fact about the PR, recorded even after a cancel, against the versions read right now (no await since). - this.#store.recordPullRequestDraft(identity, row.openingId, drafted.number, drafted.draft, - { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + signal?.throwIfAborted(); + const row = live && rows.find(candidate => marker(candidate.openingId) === live.marker); + // No open PR (closed or merged), or it is not the one recorded for its opening: nothing is ready for review here. + if (!live || !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 = 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, headBranch, marker: live.marker }, signal), marker: live.marker }; } + 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/test/publish.test.ts b/test/publish.test.ts index 54581e10..fd89a0ba 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1478,6 +1478,70 @@ describe('shutdown and PRs left ready', () => { 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 }]); + }); + 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 }]); + }); it('refuses gateways configured for another repository', () => { const store = runningTask(); const { pulls } = harness(store); From 153050b59040c86ff317eba6ec9b4f8bc3264568 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:13:14 -0700 Subject: [PATCH 29/65] Test the draft step's late-adoption record and abort after a lookup - Tests: a late draft PR is recorded by adoption alone (one state version bump); one a person made ready is adopted as ready, drafted and recorded as a draft; an abort during a lookup that still answers adopts nothing. - Doc and comment: the main path may refuse before it adopts; tasks in review, approved or merged have no draft step; the PR-number wording names the draft step. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 ++-- runner/publish.ts | 2 +- test/publish.test.ts | 38 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 3c5eee08..7e8ac96f 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -51,9 +51,9 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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, since the main path that also adopts it is never reached. 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. 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. + **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: its PRs are adopted only by recovery and the main path. 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. 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 (none if the task has no PR yet). 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 any path. 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. +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 (none if the task has no PR yet). 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. @@ -118,7 +118,7 @@ Some repositories do not support draft PRs (for example private repositories on ## 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, 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 fence length, and the description bounds. +`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 fence length, and the description bounds. ## Test this document with a reader diff --git a/runner/publish.ts b/runner/publish.ts index 97728ca0..32e8efc0 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -346,7 +346,7 @@ export class PullRequestPublisher { * 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, is never reached. A draft flag GitHub already shows is only recorded. A GitHub failure does not + * 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): Promise { diff --git a/test/publish.test.ts b/test/publish.test.ts index fd89a0ba..35d0741f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1493,6 +1493,44 @@ describe('shutdown and PRs left ready', () => { 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); From e0f7976f02a58ab4b40b153d93361687c1fc2317 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:30:51 -0700 Subject: [PATCH 30/65] Close independent review round 8 on F2d: tests for untested guards - Tests: adoption's version guard (a task change during the lookup that adopts refuses the main path), recovery drafting a lost draft opening only for a draft publish, a non-current lost draft opening continuing to the main path when drafts are unsupported, and a URL fragment (`/#12`) not counting as an issue mention. - Doc: a draft owed is fulfilled only by a later publish; nothing calls publish for a cancelled task yet. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- test/already-fixed.test.ts | 2 +- test/publish.test.ts | 41 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 7e8ac96f..cf5cdf13 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -51,7 +51,7 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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. 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. + **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: its PRs are adopted only by recovery and the main path. 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 (none if the task has no PR yet). 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. diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 840d9b26..e7ebfbd5 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -39,7 +39,7 @@ 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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) 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']) + 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']) expect(mentionsIssue(message, repo, 12), message).toBe(false); }); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 35d0741f..57f88847 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1580,6 +1580,47 @@ describe('shutdown and PRs left ready', () => { await expect(publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5 }]); }); + it('refuses the main path when the task changes during the lookup that adopts an abandoned opening', 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/); + // Recovery abandons the second opening; during the main path's lookup the PR appears and the assignment changes. + const again = harness(store, { live, next, hidden, config: later, onFind: () => { + if (again.log.length > 1) { hidden.clear(); 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') || line === 'check')).toBe(false); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned' }, { state: 'abandoned' }]); + }); + /** 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('refuses gateways configured for another repository', () => { const store = runningTask(); const { pulls } = harness(store); From f6a90ecc2741d1468b2192aafe8dd8dcb7c72b66 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:56:35 -0700 Subject: [PATCH 31/65] Close independent review round 9 on F2d: both sides of a manual link - The already-fixed check reads both the source and the subject of a connect or disconnect event and takes the side that is a PR, so a link made from the PR's side is not missed (fail-open). Two PRs, or a side of an unknown type, make the check unknown. - Index the new check and PR tables by task; publish reads both by task on every call. - Tests: links from either side, two-PR and unknown-type links, an unknown closer, a process that exits without reading a large body (no crash on the broken pipe), and output past the limit. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/already-fixed.ts | 22 +++++++++++++-------- runner/store.ts | 2 ++ test/already-fixed.test.ts | 12 +++++++++-- test/publish.test.ts | 10 ++++++++-- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index cf5cdf13..30ccde9a 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -30,7 +30,7 @@ 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, and manual links: "connected" and "disconnected" events replayed in order. | 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. | +| Another open or merged PR links to the issue. | Cross-reference events, and 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 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 commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that links the issue counts as a match. It is not excluded by number, because its number belongs to another repository. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 7377ffd6..b67f31e0 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -59,13 +59,14 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { __typename ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } ... on ProjectV2 { number } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } - ... on ConnectedEvent { subject { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } - ... on DisconnectedEvent { subject { __typename ... on PullRequest { number state isDraft 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 => { @@ -181,11 +182,16 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { else throw new Unknown('GitHub returned an unknown closer.'); continue; } - const field = node.__typename === 'CrossReferencedEvent' ? 'source' : node.__typename === 'ConnectedEvent' || node.__typename === 'DisconnectedEvent' ? 'subject' : null; - if (!field) throw new Unknown('GitHub returned an unexpected timeline event.'); - const source = object(node[field], 'linked item'); - if (source.__typename === 'Issue') continue; - if (source.__typename !== 'PullRequest') throw new Unknown('GitHub returned an unknown linked item.'); + const manual = node.__typename === 'ConnectedEvent' || node.__typename === 'DisconnectedEvent'; + if (node.__typename !== 'CrossReferencedEvent' && !manual) throw new Unknown('GitHub returned an unexpected timeline event.'); + // 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.'); // The task's own open PR is not a match; its own merged PR is: the fix is already in. diff --git a/runner/store.ts b/runner/store.ts index 8d3350f0..9e05516e 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -922,6 +922,8 @@ export class Store { 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 { diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index e7ebfbd5..56c49774 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -6,8 +6,9 @@ const repo = 'Owner/Repo'; const pr = (number: number, state = 'OPEN', extra: Record = {}) => ({ __typename: 'PullRequest', number, state, isDraft: false, repository: { nameWithOwner: repo }, ...extra }); const cross = (source: unknown) => ({ __typename: 'CrossReferencedEvent', source }); -const connected = (subject: unknown) => ({ __typename: 'ConnectedEvent', subject }); -const disconnected = (subject: unknown) => ({ __typename: 'DisconnectedEvent', subject }); +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 { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; @@ -57,6 +58,12 @@ describe('the pre-PR already-fixed check', () => { }); 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 }] }); }); @@ -93,6 +100,7 @@ describe('the pre-PR already-fixed check', () => { }); 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index 57f88847..ba9c0caf 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -273,8 +273,8 @@ describe('schema v7', () => { 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='already_fixed_checks') AND name NOT LIKE 'sqlite_autoindex%' ORDER BY name").all().map(row => row.name); - expect(names).toEqual(['already_fixed_checks', 'task_pull_requests', 'task_pull_requests_number', 'task_pull_requests_opening']); + 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 }); } }); @@ -1129,6 +1129,12 @@ describe('running gh with a request body on stdin', () => { 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', () => { From 4f964fd38f5ee0bd22191daeeaffab0fa3704ca8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:00:08 -0700 Subject: [PATCH 32/65] Close independent review round 10 on F2d: no read:project field - The timeline query asked for `number` on a ProjectV2 closer. That field needs the read:project scope, which a default `gh auth login` token lacks, and GitHub then refuses the whole query: every check was `unknown` and no PR was ever opened. The closer is now read by its type name alone, which is all the code uses. Checked against GitHub with a default token: closed issues, a PR-side link, and an open issue with no links (clear). - Test: the query text reads both sides of manual links and selects no field on ProjectV2, since the fake answers any query. - Doc: the scopes the check and the PR calls need. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 ++ github/already-fixed.ts | 2 +- test/already-fixed.test.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 30ccde9a..0c1fe31e 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -86,6 +86,8 @@ The adapter refuses any answer for a PR that is not open. A PR closed between th **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. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index b67f31e0..a1f9555d 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -57,7 +57,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { pageInfo { hasNextPage } nodes { __typename - ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } ... on ProjectV2 { number } } } + ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } ... on ConnectedEvent { source { ...Linked } subject { ...Linked } } ... on DisconnectedEvent { source { ...Linked } subject { ...Linked } } diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 56c49774..9d94fc19 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -36,6 +36,21 @@ function gateway(fake: Fake = {}) { 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'); + // 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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) From eed847787204eefd4a26d3ada922d984cf97f6bc Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:13:16 -0700 Subject: [PATCH 33/65] Close independent review round 11 on F2d: definite 422, retargeted PRs - A validation refusal of the POST (HTTP 422: no commits between base and head, a PR already open, and so on) is definite: the adapter throws PullRequestRefused and the publisher abandons the opening at once, instead of leaving it to settle for 10 minutes and repeating the same refusal forever. - The failure of a gh run keeps the response body gh prints on stdout, so GitHub's reason is no longer lost. - The branch lookup is no longer filtered by base: a PR a person retargeted to another base is refused, instead of being missed and a second PR opened from the same branch. More than one open PR from the branch is refused by name. - Tests follow: the refused POST now leaves no opening in flight, so the late-PR adoption tests go through the main path (two duplicates removed); new adapter and runner tests. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 +- github/pull-requests.ts | 22 ++++-- github/run-with-input.ts | 4 +- runner/publish.ts | 10 +-- test/publish.test.ts | 79 ++++++++++----------- 5 files changed, 66 insertions(+), 55 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 0c1fe31e..4ce6de23 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,14 +50,14 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (one open PR per branch): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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. +1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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 (none if the task has no PR yet). 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. +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 (none if the task has no PR yet). The lookup is not filtered by base. GitHub allows one open PR per branch and base, so a PR that a person retargeted to another base would otherwise be missed, and a second PR opened from the same branch. An open PR into another base is refused instead, and so is more than one open PR from the branch. 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), 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. +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), 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index c9a83256..fe05a754 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -36,6 +36,11 @@ export interface PullRequestGateway { * 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 {} const DRAFTS_UNSUPPORTED = /draft pull requests? (?:are|is) not supported/i; /** Runs a GitHub call that asks for a draft, turning GitHub's "not supported" refusal into DraftsUnsupported. */ async function draftCall(call: () => Promise): Promise { @@ -111,7 +116,12 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 }); - const response = input.draft ? await draftCall(post) : await post(); + let response; + try { response = input.draft ? await draftCall(post) : await post(); } + catch (error) { + if (error instanceof DraftsUnsupported || signal.aborted || !(error instanceof Error) || !/\(HTTP 422\)/.test(error.message)) throw error; + throw new PullRequestRefused(`GitHub refused to open the pull request: ${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. @@ -123,11 +133,15 @@ export class GhPullRequestGateway implements PullRequestGateway { signal = this.#bounded(signal); this.#validate(input); const owner = this.repository.split('/')[0]!; - const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, base: input.base, per_page: '100' }); + // Not filtered by base: GitHub allows one open PR per head and base, so a PR that a person retargeted to another base + // would not be found, and a second PR would be opened from the same branch. It is refused instead. + const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, per_page: '100' }); const response = await this.#json(['api', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/pulls?${query}`], signal); - // GitHub allows one open PR per head and base, so more than one result is a malformed response. - if (!Array.isArray(response) || response.length > 1) throw new Error('GitHub returned an invalid pull request list.'); + if (!Array.isArray(response)) throw new Error('GitHub returned an invalid pull request list.'); + if (response.length > 1) throw new Error(`More than one open pull request comes from ${input.headBranch}.`); if (!response.length) return null; + const base = (response[0] as { base?: { ref?: unknown } } | null)?.base?.ref; + if (typeof base === 'string' && base !== input.base) throw new Error(`The open pull request from ${input.headBranch} targets ${base}, not ${input.base}; codeboost will not open a second one.`); const { body, ...pr } = this.#pull(response[0], input); const found = input.markers.filter(marker => markerOf(body) === marker); if (found.length !== 1) throw new Error(`An open pull request from ${input.headBranch} exists that codeboost did not open.`); diff --git a/github/run-with-input.ts b/github/run-with-input.ts index e3f991b8..242266d8 100644 --- a/github/run-with-input.ts +++ b/github/run-with-input.ts @@ -43,8 +43,10 @@ export function runWithInput(command: string, args: readonly string[], options: 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 Error(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}`)); + else if (code !== 0) reject(new Error(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}${stdout ? `\n${stdout.slice(0, 2000)}` : ''}`)); else resolve(Buffer.concat(out).toString('utf8')); }; child.on('close', finish); diff --git a/runner/publish.ts b/runner/publish.ts index 32e8efc0..5c74b3a6 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; -import { DraftsUnsupported, type PullRequestGateway } from '../github/pull-requests.ts'; +import { DraftsUnsupported, PullRequestRefused, type PullRequestGateway } from '../github/pull-requests.ts'; import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError } from './lifecycle.ts'; import type { Store, TaskPullRequest } from './store.ts'; @@ -125,7 +125,8 @@ export class PullRequestPublisher { } 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: GitHub allows one open PR per branch. It is looked up before the check, because an abandoned + // 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, this.#config.baseBranch).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, @@ -235,8 +236,9 @@ export class PullRequestPublisher { 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; - // A definite refusal: GitHub created nothing, so the opening is dropped rather than left to settle. this.#store.abandonPullRequestOpening(identity, opening.openingId); return { kind: 'draft unsupported', number: null }; } @@ -301,7 +303,7 @@ export class PullRequestPublisher { signal?.throwIfAborted(); if (pr && pr.marker !== marker(lost.openingId)) { // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing - // (GitHub allows one open PR per branch). Drop it. If that other opening was abandoned, its PR is adopted here, + // (the branch has at most one open PR). Drop it. If that other opening was abandoned, its PR is adopted here, // not only on the main path, which a task that can no longer publish never reaches. this.#store.abandonPullRequestOpening(identity, lost.openingId); const owner = rows.find(row => marker(row.openingId) === pr.marker); diff --git a/test/publish.test.ts b/test/publish.test.ts index ba9c0caf..e8f76f7e 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -4,7 +4,7 @@ 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 { runWithInput } from '../github/run-with-input.ts'; -import { DraftsUnsupported, GhPullRequestGateway, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; +import { DraftsUnsupported, GhPullRequestGateway, 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'; @@ -55,7 +55,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: if (options.open) return options.open(input); if (options.draftsUnsupported && input.draft) throw new DraftsUnsupported('no drafts'); // Like GitHub: one open PR per branch. - if ([...live].some(([, pr]) => !closed.has(pr.number))) throw new Error('HTTP 422: A pull request already exists.'); + if ([...live].some(([, pr]) => !closed.has(pr.number))) 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); if (options.openTimesOut) throw new Error('timeout'); @@ -987,13 +987,15 @@ describe('recovering from an abandoned opening whose PR appears later', () => { 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. + // 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', 'opening']); - // The first PR becomes visible: it is adopted, the second opening is dropped, and publishing finishes. + 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([]); }); @@ -1010,20 +1012,17 @@ describe('recovering from an abandoned opening whose PR appears later', () => { // 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 its lookup, after recovery adopted the PR', async () => { + 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(); - // Recovery drops the second opening; then the task changes while the branch lookup runs. - let finds = 0; - const again = harness(store, { live, next, config: later, onFind: () => { if (++finds === 2) store.setAssignment(identity, store.getTask(identity).stateVersion, 'someone-else', 'code'); } }); + // 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/); - // Recovery adopted the first opening's PR before the change (a fact about the PR); the main path, after the change, - // refused when recording the check's result, so nothing was pushed or opened. - expect(store.taskPullRequests(identity).map(pr => pr.state)).toEqual(['opened', 'abandoned']); + 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 () => { @@ -1238,7 +1237,9 @@ describe('GitHub PR adapter', () => { 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. - await expect(gh.open({ ...input, draft: false })).rejects.toBe(unsupported); + 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 }))); @@ -1300,10 +1301,30 @@ describe('GitHub PR adapter', () => { // 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&base=main&per_page=100'); + 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(); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/More than one/); + }); + it('refuses the branch PR a person retargeted to another base, instead of missing it and opening a second one', async () => { + const retargeted = { ...response(), base: { ...response().base, ref: 'release' } }; + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([retargeted])).findOpened({ ...input, markers: [marker] })) + .rejects.toThrow(/targets release, not main/); + }); + 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 Error('gh failed (exit 1): gh: Validation Failed (HTTP 422)\n{"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 Error('gh failed (exit 1): gh: Server Error (HTTP 502)'); }); + expect(await flaky.open({ ...input, draft: false }).catch(e => e)).not.toBeInstanceOf(PullRequestRefused); + }); + it('keeps the response body gh prints on stdout in the failure', async () => { + await expect(runWithInput(process.execPath, ['-e', 'console.error("gh: Validation Failed (HTTP 422)");console.log(JSON.stringify({errors:[{message:"No commits between"}]}));process.exit(1)'], {})) + .rejects.toThrow(/HTTP 422\)\n\{"errors":\[\{"message":"No commits between"/); }); }); @@ -1385,20 +1406,6 @@ describe('shutdown and PRs left ready', () => { expect(again.log.some(line => line.startsWith('draft'))).toBe(false); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); }); - it("adopts on the main path an abandoned opening's PR that becomes visible after recovery, and counts it as the task's own", 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 first opening is abandoned; the second POST is refused (the first PR exists), so the second opening stays owned. - await expect(harness(store, { live, next, hidden, config: later }).publisher.publish(identity)).rejects.toThrow(/already exists/); - // Recovery's lookup still sees nothing and abandons the second opening; the PR shows up for the main path's lookup. - const again = harness(store, { live, next, hidden, config: later, onFind: () => { if (again.log.length > 1) hidden.clear(); } }); - expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'in review' }); - expect(again.checks[0]!.ownPullRequests).toEqual([100]); - expect(again.log).toContain('refresh 100 ready'); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { state: 'abandoned' }]); - }); 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'); @@ -1586,20 +1593,6 @@ describe('shutdown and PRs left ready', () => { await expect(publisher.publish(identity, {}, controller.signal)).rejects.toThrow(); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 5 }]); }); - it('refuses the main path when the task changes during the lookup that adopts an abandoned opening', 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/); - // Recovery abandons the second opening; during the main path's lookup the PR appears and the assignment changes. - const again = harness(store, { live, next, hidden, config: later, onFind: () => { - if (again.log.length > 1) { hidden.clear(); 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') || line === 'check')).toBe(false); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'abandoned' }, { state: 'abandoned' }]); - }); /** 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 }; From 9ad2d165e0b6890849152596ede98955cdda229c Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:15:07 -0700 Subject: [PATCH 34/65] Count only closing cross-references in the already-fixed check A cross-reference is a mention: a PR anywhere on GitHub whose text names the issue. Run against cli/cli, the check flagged a third of the 100 newest open issues, mostly for merged PRs in unrelated repositories, so those tasks would never get a PR. Decided with the user: a cross-reference counts only when it would close the issue (willCloseTarget, a closing keyword). Manual links, the closed state and base-branch commits are unchanged. Checked live (read-only): the mention-only issue is now clear, a closed issue is still found. - Tests: mention-only PRs here and in another repository are clear; a non-boolean willCloseTarget makes the check unknown; the query asks for willCloseTarget. - Doc: the rule, the decision, and why a merged closing PR shows up as the closed state. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 4 ++-- github/already-fixed.ts | 9 ++++++++- test/already-fixed.test.ts | 6 +++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 4ce6de23..a48b802b 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -30,10 +30,10 @@ 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, and 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. | +| 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`), and 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 commit mentions the issue with `#12`, `GH-12`, `owner/repo#12`, or the issue URL. A PR in another repository that links the issue counts as a match. It is not excluded by number, because its number belongs to another repository. +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. **The check fails closed.** It returns `unknown` in each of these cases, and `unknown` is handled like a match: diff --git a/github/already-fixed.ts b/github/already-fixed.ts index a1f9555d..a92b219a 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -58,7 +58,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { nodes { __typename ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } - ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } + ... on CrossReferencedEvent { willCloseTarget source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } ... on ConnectedEvent { source { ...Linked } subject { ...Linked } } ... on DisconnectedEvent { source { ...Linked } subject { ...Linked } } } @@ -184,6 +184,13 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { } 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). + if (!manual) { + if (typeof node.willCloseTarget !== 'boolean') throw new Unknown('GitHub returned an invalid cross-reference.'); + if (!node.willCloseTarget) continue; + } // 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')); diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 9d94fc19..e6a61045 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -5,7 +5,7 @@ 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, repository: { nameWithOwner: repo }, ...extra }); -const cross = (source: unknown) => ({ __typename: 'CrossReferencedEvent', source }); +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 }); @@ -45,6 +45,7 @@ describe('the timeline query', () => { 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 {'); // 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/); @@ -67,6 +68,9 @@ describe('the pre-PR already-fixed check', () => { 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' }); 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' }] }); From 8ef6bedde9a1bad745ca0960dc89c68928cc0955 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:28:05 -0700 Subject: [PATCH 35/65] Close independent review round 12 on F2d: branch lookup across bases - The branch lookup lists every open PR from the branch and uses the one into the configured base. A PR into another base is ignored (a backport someone opened), unless it carries one of the task's markers: then the task's own PR was retargeted or the base setting changed, and publish refuses with a message that says what to do. Round 11's version refused any other-base PR and any second PR, which could block recovery and the draft step for good, and sent markers for the configured base only, so a retargeted own PR read as foreign. - Every lookup sends the markers of all the branch's records, whatever their base; an update uses the configured base the PR was found in. - Refusals (HTTP 422, drafts unsupported) are matched against gh's stderr only (CommandFailed.stderr): the stdout now kept in the message can echo the PR body. - Tests: the harness models PR bases; a retargeted own PR, a backport from the branch, stderr-only matching. Doc: lookup rule, repeated refusals, test list. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 +-- github/pull-requests.ts | 36 ++++++++++------ github/run-with-input.ts | 11 ++++- runner/publish.ts | 18 +++++--- test/publish.test.ts | 47 +++++++++++++++------ 5 files changed, 82 insertions(+), 36 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index a48b802b..58feef5d 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,11 +53,11 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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 (none if the task has no PR yet). The lookup is not filtered by base. GitHub allows one open PR per branch and base, so a PR that a person retargeted to another base would otherwise be missed, and a second PR opened from the same branch. An open PR into another base is refused instead, and so is more than one open PR from the branch. 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. +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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. 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), 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. +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 only, because stdout can 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. @@ -120,7 +120,7 @@ Some repositories do not support draft PRs (for example private repositories on ## 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 fence length, and the description bounds. +`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 only, 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 diff --git a/github/pull-requests.ts b/github/pull-requests.ts index fe05a754..6e6c5a01 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -1,5 +1,5 @@ import { ghEnvironment } from './gh-env.ts'; -import { runWithInput } from './run-with-input.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 -`). */ @@ -22,7 +22,8 @@ export interface PullRequestGateway { /** * 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. + * is refused. A PR from the branch into another base is ignored, unless it carries one of `markers` (the task's own PR, + * retargeted by a person): that is refused. */ findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; /** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */ @@ -42,11 +43,13 @@ export class DraftsUnsupported extends Error {} */ export class PullRequestRefused extends Error {} const DRAFTS_UNSUPPORTED = /draft pull requests? (?:are|is) not supported/i; +/** What a refusal is matched against: `gh`'s own stderr, never the stdout body that can echo text codeboost sent. */ +const refusalText = (error: unknown): string => error instanceof CommandFailed ? error.stderr : error instanceof Error ? error.message : ''; /** 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 (error instanceof Error && DRAFTS_UNSUPPORTED.test(error.message)) throw new DraftsUnsupported('This repository does not support draft pull requests.'); + if (DRAFTS_UNSUPPORTED.test(refusalText(error))) throw new DraftsUnsupported('This repository does not support draft pull requests.'); throw error; } } @@ -119,8 +122,8 @@ export class GhPullRequestGateway implements PullRequestGateway { let response; try { response = input.draft ? await draftCall(post) : await post(); } catch (error) { - if (error instanceof DraftsUnsupported || signal.aborted || !(error instanceof Error) || !/\(HTTP 422\)/.test(error.message)) throw error; - throw new PullRequestRefused(`GitHub refused to open the pull request: ${error.message}`); + 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.'); @@ -133,16 +136,25 @@ export class GhPullRequestGateway implements PullRequestGateway { signal = this.#bounded(signal); this.#validate(input); const owner = this.repository.split('/')[0]!; - // Not filtered by base: GitHub allows one open PR per head and base, so a PR that a person retargeted to another base - // would not be found, and a second PR would be opened from the same branch. It is refused instead. + // 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 to another base would be missed, and a second PR opened from the same branch. const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.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.'); - if (response.length > 1) throw new Error(`More than one open pull request comes from ${input.headBranch}.`); - if (!response.length) return null; - const base = (response[0] as { base?: { ref?: unknown } } | null)?.base?.ref; - if (typeof base === 'string' && base !== input.base) throw new Error(`The open pull request from ${input.headBranch} targets ${base}, not ${input.base}; codeboost will not open a second one.`); - const { body, ...pr } = this.#pull(response[0], input); + const baseOf = (pr: unknown) => (pr as { base?: { ref?: unknown } } | null)?.base?.ref; + const here = response.filter(pr => baseOf(pr) === input.base); + if (here.length > 1) throw new Error('GitHub returned an invalid pull request list.'); + if (!here.length) { + // A PR into another base is someone else's (a backport from this branch, say) and is left alone, unless it carries + // one of the task's markers: then it is the task's own PR, retargeted, and a person has to decide. + for (const other of response) { + const { number, body } = other as { number?: unknown; body?: unknown }; + if (typeof body === 'string' && input.markers.includes(markerOf(body))) + throw new Error(`The task's pull request #${String(number)} from ${input.headBranch} now targets ${String(baseOf(other))}, not ${input.base}. Retarget it to ${input.base} or close it.`); + } + return null; + } + const { body, ...pr } = this.#pull(here[0], input); const found = input.markers.filter(marker => markerOf(body) === marker); 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]! }; diff --git a/github/run-with-input.ts b/github/run-with-input.ts index 242266d8..9aa5c464 100644 --- a/github/run-with-input.ts +++ b/github/run-with-input.ts @@ -1,5 +1,14 @@ import { spawn } from 'node:child_process'; +/** + * A command that exited with a failure. The message carries stderr and the response body `gh api` prints on stdout; + * `stderr` alone is what callers match refusals against, because stdout can echo text codeboost sent (a PR body). + */ +export class CommandFailed extends Error { + readonly stderr: string; + constructor(message: string, stderr: string) { super(message); this.stderr = stderr; } +} + /** * 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: @@ -46,7 +55,7 @@ export function runWithInput(command: string, args: readonly string[], options: // `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 Error(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}${stdout ? `\n${stdout.slice(0, 2000)}` : ''}`)); + else if (code !== 0) reject(new CommandFailed(`${command} failed (${signal ?? `exit ${code}`}): ${stderr.slice(0, 2000)}${stdout ? `\n${stdout.slice(0, 2000)}` : ''}`, stderr)); else resolve(Buffer.concat(out).toString('utf8')); }; child.on('close', finish); diff --git a/runner/publish.ts b/runner/publish.ts index 5c74b3a6..55703383 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -128,7 +128,7 @@ export class PullRequestPublisher { // 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, this.#config.baseBranch).filter(pr => pr.state !== 'opening'); + 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. const live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); @@ -213,7 +213,8 @@ export class PullRequestPublisher { // 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, { - base: earlier.base, headBranch: branch, draft, ready: !draft, headSha: snapshot.head, marker: marker(earlier.openingId), + // 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); @@ -274,9 +275,12 @@ export class PullRequestPublisher { 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 into one base, in the configured repository. */ - #branchRows(prs: readonly TaskPullRequest[], branch: string, base: string): TaskPullRequest[] { - return prs.filter(pr => pr.headBranch === branch && pr.base === base && pr.repository.toLowerCase() === this.#config.repository.toLowerCase()); + /** + * 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()); } /** @@ -298,7 +302,7 @@ export class PullRequestPublisher { 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, lost.base); + const rows = this.#branchRows(prs, lost.headBranch); const pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); signal?.throwIfAborted(); if (pr && pr.marker !== marker(lost.openingId)) { @@ -367,7 +371,7 @@ export class PullRequestPublisher { branches.set(`${pr.base}\n${pr.headBranch}`, { base: pr.base, headBranch: pr.headBranch }); } for (const { base, headBranch } of branches.values()) { - const rows = this.#branchRows(prs, headBranch, base).filter(pr => pr.state !== 'opening'); + const rows = this.#branchRows(prs, headBranch).filter(pr => pr.state !== 'opening'); let live; try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); } catch (error) { diff --git a/test/publish.test.ts b/test/publish.test.ts index e8f76f7e..4f313009 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -3,7 +3,7 @@ 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 { runWithInput } from '../github/run-with-input.ts'; +import { CommandFailed, runWithInput } from '../github/run-with-input.ts'; import { DraftsUnsupported, GhPullRequestGateway, 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'; @@ -34,12 +34,15 @@ function runningTask(options: { head?: string } = {}) { 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 } = {}) { const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; + // 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) { @@ -57,16 +60,22 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: // Like GitHub: one open PR per branch. if ([...live].some(([, pr]) => !closed.has(pr.number))) 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); + 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)! }; - // GitHub shows at most one open PR per branch; find it among every live PR, then match its marker. - const open = [...live].find(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); - if (!open) return null; + // Like the adapter: the branch's open PR into this base, matched by marker; a PR into another base is ignored unless + // it is the task's own (retargeted), which is refused. + const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); + const open = visible.find(([m]) => (bases.get(m) ?? input.base) === input.base); + if (!open) { + const moved = visible.find(([m]) => input.markers.includes(m)); + if (moved) throw new Error(`The task's pull request #${moved[1].number} now targets ${bases.get(moved[0])}, not ${input.base}.`); + return null; + } if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); return { ...open[1], marker: open[0] }; }, @@ -600,8 +609,11 @@ describe('independent review round 4', () => { 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 develop record is not a candidate, so the branch PR is not codeboost's for main. - await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/did not open/); + // 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(); @@ -1304,12 +1316,21 @@ describe('GitHub PR adapter', () => { 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/); - }); - it('refuses the branch PR a person retargeted to another base, instead of missing it and opening a second one', async () => { - const retargeted = { ...response(), base: { ...response().base, ref: 'release' } }; - await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([retargeted])).findOpened({ ...input, markers: [marker] })) - .rejects.toThrow(/targets release, not main/); + await expect(new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify([response(), response()])).findOpened({ ...input, markers: [marker] })).rejects.toThrow(/invalid pull request list/); + }); + 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('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)\n{"body":"Draft pull requests are not supported (HTTP 422)"}', 'gh: Server Error (HTTP 502)'); + 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); }); it('turns a validation refusal of the opening into PullRequestRefused, with the reason GitHub gave', async () => { const gh = new GhPullRequestGateway({ repository: 'owner/repo' }, async () => { From dc2af66b0384503bcb072ae74ea78815e709069e Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:30:14 -0700 Subject: [PATCH 36/65] Count same-repo PRs into a non-default base in the already-fixed check GitHub closes issues only from PRs into the default branch, so it reports willCloseTarget false for every PR into another branch, even with "Fixes #12" (seen live on PRs into staging). With the closing-only rule, a task whose base is develop or staging would miss a competing fix into that branch and could open a duplicate PR. Decided with the user: when the task's base is not the repository's default branch, a PR in this repository into that same base that references the issue counts too. A missing default branch or PR base fails closed. - Query: the repository's default branch and each cross-referencing PR's base (repo scope only; the query still runs with a default token, checked live read-only). - Tests: the rule for a develop base, not for another base, another repository or a default-branch base; fail closed on a missing default branch or base; the query text. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/already-fixed.ts | 20 ++++++++++++------ test/already-fixed.test.ts | 23 ++++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 58feef5d..0cef6b45 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -30,7 +30,7 @@ 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`), and 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). | +| 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 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. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index a92b219a..8dd0de30 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -50,6 +50,7 @@ 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]) { @@ -58,7 +59,7 @@ const TIMELINE_QUERY = `query($owner: String!, $name: String!, $number: Int!) { nodes { __typename ... on ClosedEvent { closer { __typename ... on PullRequest { number repository { nameWithOwner } } ... on Commit { oid } } } - ... on CrossReferencedEvent { willCloseTarget source { __typename ... on PullRequest { number state isDraft repository { nameWithOwner } } } } + ... 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 } } } @@ -153,6 +154,12 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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'); @@ -186,11 +193,8 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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). - if (!manual) { - if (typeof node.willCloseTarget !== 'boolean') throw new Unknown('GitHub returned an invalid cross-reference.'); - if (!node.willCloseTarget) continue; - } + // 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')); @@ -201,6 +205,10 @@ export class GhAlreadyFixedGateway implements AlreadyFixedGateway { 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}`; diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index e6a61045..1be7edf0 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -4,14 +4,14 @@ import { CHECK_KILL_GRACE_MS, CHECK_PIPE_GRACE_MS, DEFAULT_CHECK_DEADLINE_MS, Gh 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, repository: { nameWithOwner: repo }, ...extra }); + ({ __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 { state?: string; nodes?: unknown[]; totalCount?: number; hasNextPage?: boolean; errors?: unknown; nameWithOwner?: string; +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[][] = []; @@ -23,7 +23,7 @@ function gateway(fake: Fake = {}) { 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', issue: { state: fake.state ?? 'OPEN', + 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) } }); @@ -46,6 +46,8 @@ describe('the timeline query', () => { 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/); @@ -71,6 +73,21 @@ describe('the pre-PR already-fixed check', () => { // 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' }] }); From ba6f8f6ae40a407cf2c3ccc522606bff4d9cb0f9 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:40:18 -0700 Subject: [PATCH 37/65] Close independent review round 13 on F2d: configured base everywhere - Recovery (lost openings and lost updates), the draft step and the head settle now look PRs up and change them in the configured base, like the main path. Before, they used the base stored on each record: after a base change, a retargeted PR was asked to go back to the old base by recovery and to the new one by the main path, so only closing it ended the loop, and a stopped task's PR could stay ready. - Refusals are matched against gh's stderr and GitHub's own error fields in the JSON body on stdout (message, errors[].message). Round 12 matched stderr only, but gh api prints only "Validation Failed (HTTP 422)" there, so a drafts-unsupported refusal of the POST would have become PullRequestRefused. Only a finished gh run (CommandFailed, now carrying stdout too) is read. - Tests: adapter tests use failures shaped as gh reports them; the harness checks PR bases like the adapter; base change + retarget converges through a lost update, a lost opening, and the draft step. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 4 +- github/pull-requests.ts | 18 +++++- github/run-with-input.ts | 10 ++-- runner/publish.ts | 19 +++--- test/publish.test.ts | 66 ++++++++++++++++++--- 5 files changed, 93 insertions(+), 24 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 0cef6b45..5be3a412 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,11 +53,11 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. 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. +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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. Every lookup and draft change (recovery, the draft step, the main path) uses the configured base; a record's base is only where the PR was opened. So after a base change, retargeting the PR to the configured base is enough for every step to find it again. 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 only, because stdout can 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. +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 and from GitHub's own error fields in the JSON body on stdout (`message`, `errors[].message`, 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 6e6c5a01..59d2da45 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -43,8 +43,22 @@ export class DraftsUnsupported extends Error {} */ export class PullRequestRefused extends Error {} const DRAFTS_UNSUPPORTED = /draft pull requests? (?:are|is) not supported/i; -/** What a refusal is matched against: `gh`'s own stderr, never the stdout body that can echo text codeboost sent. */ -const refusalText = (error: unknown): string => error instanceof CommandFailed ? error.stderr : error instanceof Error ? error.message : ''; +/** + * 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 { message?: unknown; errors?: unknown } | null; + if (typeof body?.message === 'string') reasons.push(body.message); + 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(); } diff --git a/github/run-with-input.ts b/github/run-with-input.ts index 9aa5c464..f4f04666 100644 --- a/github/run-with-input.ts +++ b/github/run-with-input.ts @@ -1,12 +1,12 @@ import { spawn } from 'node:child_process'; /** - * A command that exited with a failure. The message carries stderr and the response body `gh api` prints on stdout; - * `stderr` alone is what callers match refusals against, because stdout can echo text codeboost sent (a PR body). + * 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; - constructor(message: string, stderr: string) { super(message); this.stderr = stderr; } + readonly stderr: string; readonly stdout: string; + constructor(message: string, stderr: string, stdout: string) { super(message); this.stderr = stderr; this.stdout = stdout; } } /** @@ -55,7 +55,7 @@ export function runWithInput(command: string, args: readonly string[], options: // `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)); + 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); diff --git a/runner/publish.ts b/runner/publish.ts index 55703383..88d23c4c 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -254,7 +254,8 @@ export class PullRequestPublisher { * 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, base = this.#config.baseBranch): Promise { + 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. @@ -293,7 +294,7 @@ export class PullRequestPublisher { // 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.'); - const observed = await this.#pulls.findOpened({ base: refreshing.base, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); + const observed = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); } @@ -303,7 +304,7 @@ export class PullRequestPublisher { 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); - const pr = await this.#pulls.findOpened({ base: lost.base, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); + const pr = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); signal?.throwIfAborted(); if (pr && pr.marker !== marker(lost.openingId)) { // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing @@ -329,7 +330,7 @@ export class PullRequestPublisher { 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. if (lost.draft && draft && !pr.draft) { - try { found = await this.#pulls.markDraft(pr.number, { base: lost.base, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } + try { found = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; const current = this.#isCurrent(identity, lost, draft); @@ -342,7 +343,7 @@ export class PullRequestPublisher { // 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, lost.base); + if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal); return null; } @@ -365,12 +366,14 @@ export class PullRequestPublisher { 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. - const branches = new Map(); + // Looked up into the configured base, like every lookup: a record's base is where it was opened, and the gateway + // refuses the task's own PR found in another base, so a PR moved by a person or a base change is reported, not missed. + const base = this.#config.baseBranch, branches = new Set(); for (const pr of prs) { if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && !pr.draft) || pr.state === 'abandoned')) - branches.set(`${pr.base}\n${pr.headBranch}`, { base: pr.base, headBranch: pr.headBranch }); + branches.add(pr.headBranch); } - for (const { base, headBranch } of branches.values()) { + for (const headBranch of branches) { const rows = this.#branchRows(prs, headBranch).filter(pr => pr.state !== 'opening'); let live; try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); } diff --git a/test/publish.test.ts b/test/publish.test.ts index 4f313009..26fb92a1 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -81,6 +81,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, async markDraft(number, input) { log.push(`draft ${number}`); + // Like the adapter's read-back: the PR must be 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.'); options.onDraft?.(); if (options.draftsUnsupported) throw new DraftsUnsupported('no drafts'); if (options.draftFails) throw new Error('timeout marking the PR a draft'); @@ -88,6 +90,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, 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'); @@ -1241,9 +1244,13 @@ describe('GitHub PR adapter', () => { await expect(refused.markDraft(7, input)).rejects.toThrow('HTTP 403'); }); it('turns GitHub refusing drafts into DraftsUnsupported on open, refresh and markDraft', async () => { - const unsupported = new Error('gh: Draft pull requests are not supported in this repository. (HTTP 422)'); + // 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' || args.includes('POST')) throw unsupported; return JSON.stringify(response({ draft: false })); + 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); @@ -1327,25 +1334,31 @@ describe('GitHub PR adapter', () => { expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response()])).toMatchObject({ number: 7, marker }); }); 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)\n{"body":"Draft pull requests are not supported (HTTP 422)"}', 'gh: Server Error (HTTP 502)'); + 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); }); 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 Error('gh failed (exit 1): gh: Validation Failed (HTTP 422)\n{"message":"Validation Failed","errors":[{"message":"No commits between main and codeboost/x"}]}'); + 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 Error('gh failed (exit 1): gh: Server Error (HTTP 502)'); }); + 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 () => { - await expect(runWithInput(process.execPath, ['-e', 'console.error("gh: Validation Failed (HTTP 422)");console.log(JSON.stringify({errors:[{message:"No commits between"}]}));process.exit(1)'], {})) - .rejects.toThrow(/HTTP 422\)\n\{"errors":\[\{"message":"No commits between"/); + 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"}]}' }); }); }); @@ -1641,6 +1654,45 @@ describe('shutdown and PRs left ready', () => { 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); + }); + 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); From c8119c62087d967db37d96ddc6520dc55d6ecdad Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:47:43 -0700 Subject: [PATCH 38/65] Close independent review round 14 on F2d: draft a stopped task's PR in any base - The draft step finds the task's own PR in whatever base it is (findOpened anyBase: only PRs carrying the task's markers, with their base) and makes it a draft there. Round 13's configured-base lookup got the retarget refusal for a PR left in an old base, so a stopped task's PR stayed ready. Recovery and the main path keep the configured base. - Refusals: the top-level message is already on stderr, so only errors[].message is read from the JSON body. - Tests: a stopped task's PR in its old base is drafted; recovery's draft change and the head settle use the configured base after a base change; the any-base lookup finds own PRs only and refuses two. Doc: the base rules, the refusal sources, the test list. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 +-- github/pull-requests.ts | 22 +++++++--- runner/publish.ts | 8 ++-- test/publish.test.ts | 45 ++++++++++++++++++++- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 5be3a412..f5b13495 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,11 +53,11 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. Every lookup and draft change (recovery, the draft step, the main path) uses the configured base; a record's base is only where the PR was opened. So after a base change, retargeting the PR to the configured base is enough for every step to find it again. 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. +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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. Recovery and the main path look up and change PRs in the configured base; a record's base is only where the PR was opened. So after a base change, retargeting the PR to the configured base is enough for every step to find it again. The draft step in step 1 finds the task's own PR in any base, because a draft is safe anywhere: a stopped task's PR left in an old base is still made a draft. 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 and from GitHub's own error fields in the JSON body on stdout (`message`, `errors[].message`, 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. +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. @@ -120,7 +120,7 @@ Some repositories do not support draft PRs (for example private repositories on ## 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 only, closing-only cross-references, both sides of a manual link, the query text, the fence length, and the description bounds. +`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 diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 59d2da45..650b3130 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -23,9 +23,10 @@ export interface PullRequestGateway { * 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. A PR from the branch into another base is ignored, unless it carries one of `markers` (the task's own PR, - * retargeted by a person): that is refused. + * retargeted by a person): that is refused. With `anyBase`, only the task's own PR is looked for, in whatever base it + * is (for making it a draft, which is safe anywhere), and anyone else's PR from the branch is ignored. */ - findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; + findOpened(input: { base: string; headBranch: string; markers: readonly string[]; anyBase?: boolean }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string }) | null>; /** 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; @@ -53,8 +54,8 @@ function refusalText(error: unknown): string { if (!(error instanceof CommandFailed)) return ''; const reasons = [error.stderr]; try { - const body = JSON.parse(error.stdout) as { message?: unknown; errors?: unknown } | null; - if (typeof body?.message === 'string') reasons.push(body.message); + 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'); @@ -146,7 +147,7 @@ export class GhPullRequestGateway implements PullRequestGateway { return pr; } - async findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { + async findOpened(input: { base: string; headBranch: string; markers: readonly string[]; anyBase?: boolean }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string }) | null> { signal = this.#bounded(signal); this.#validate(input); const owner = this.repository.split('/')[0]!; @@ -156,6 +157,15 @@ export class GhPullRequestGateway implements PullRequestGateway { 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.'); const baseOf = (pr: unknown) => (pr as { base?: { ref?: unknown } } | null)?.base?.ref; + if (input.anyBase) { + const own = response.filter(pr => { const body = (pr as { body?: unknown } | null)?.body; return typeof body === 'string' && input.markers.includes(markerOf(body)); }); + if (own.length > 1) throw new Error(`More than one of the task's pull requests is open from ${input.headBranch}.`); + if (!own.length) return null; + const base = baseOf(own[0]); + if (typeof base !== 'string' || !BRANCH.test(base)) throw new Error('GitHub returned an invalid pull request.'); + const { body, ...pr } = this.#pull(own[0], { base, headBranch: input.headBranch }); + return { ...pr, marker: markerOf(body), base }; + } const here = response.filter(pr => baseOf(pr) === input.base); if (here.length > 1) throw new Error('GitHub returned an invalid pull request list.'); if (!here.length) { @@ -171,7 +181,7 @@ export class GhPullRequestGateway implements PullRequestGateway { const { body, ...pr } = this.#pull(here[0], input); const found = input.markers.filter(marker => markerOf(body) === marker); 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]! }; + return { ...pr, marker: found[0]!, base: input.base }; } async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string; beforeReady?: () => void }, signal?: AbortSignal): Promise { diff --git a/runner/publish.ts b/runner/publish.ts index 88d23c4c..04242bdf 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -366,8 +366,7 @@ export class PullRequestPublisher { 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. - // Looked up into the configured base, like every lookup: a record's base is where it was opened, and the gateway - // refuses the task's own PR found in another base, so a PR moved by a person or a base change is reported, not missed. + // A record's base is only where the PR was opened; the lookup finds the task's own PR in any base. const base = this.#config.baseBranch, branches = new Set(); for (const pr of prs) { if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && !pr.draft) || pr.state === 'abandoned')) @@ -376,7 +375,8 @@ export class PullRequestPublisher { for (const headBranch of branches) { const rows = this.#branchRows(prs, headBranch).filter(pr => pr.state !== 'opening'); let live; - try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); } + // In any base: a stopped task's PR is drafted wherever it is (a draft is safe anywhere), even one left in an old base. + try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)), anyBase: true }, signal); } catch (error) { if (signal?.aborted) throw error; notes.push(`The open pull request 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)}).`); @@ -392,7 +392,7 @@ export class PullRequestPublisher { let drafted = 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, headBranch, marker: live.marker }, signal), marker: live.marker }; } + try { drafted = { ...await this.#pulls.markDraft(live.number, { base: live.base, headBranch, marker: live.marker }, signal), marker: live.marker, base: live.base }; } catch (error) { if (signal?.aborted) throw error; notes.push(error instanceof DraftsUnsupported diff --git a/test/publish.test.ts b/test/publish.test.ts index 26fb92a1..92005b75 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -66,10 +66,14 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, 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)! }; + if (options.found !== undefined) return options.found && { ...options.found, marker: input.markers.at(-1)!, base: input.base }; // Like the adapter: the branch's open PR into this base, matched by marker; a PR into another base is ignored unless // it is the task's own (retargeted), which is refused. const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); + if (input.anyBase) { + const own = visible.find(([m]) => input.markers.includes(m)); + return own ? { ...own[1], marker: own[0], base: bases.get(own[0]) ?? input.base } : null; + } const open = visible.find(([m]) => (bases.get(m) ?? input.base) === input.base); if (!open) { const moved = visible.find(([m]) => input.markers.includes(m)); @@ -77,7 +81,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: return null; } if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); - return { ...open[1], marker: open[0] }; + return { ...open[1], marker: open[0], base: input.base }; }, async markDraft(number, input) { log.push(`draft ${number}`); @@ -1333,6 +1337,14 @@ describe('GitHub PR adapter', () => { expect(await lookup([elsewhere({ number: 8, body: 'backport' })])).toBeNull(); expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response()])).toMatchObject({ number: 7, marker }); }); + it('finds the task\'s own PR in any base for a draft change, and ignores anyone else\'s', 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], anyBase: true }); + expect(await lookup([elsewhere()])).toMatchObject({ number: 7, marker, base: 'release' }); + expect(await lookup([response()])).toMatchObject({ number: 7, marker, base: 'main' }); + expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response({ number: 9, body: 'someone else' })])).toBeNull(); + await expect(lookup([elsewhere(), response()])).rejects.toThrow(/More than one of the task's pull requests/); + }); 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; }); @@ -1682,6 +1694,35 @@ describe('shutdown and PRs left ready', () => { expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }]); expect(again.log.some(line => line.startsWith('open'))).toBe(false); }); + 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); From e2f7bad2ef46bac10bca51d560126f8e1db9772e Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:57:20 -0700 Subject: [PATCH 39/65] Close independent review round 15 on F2d: observe in any base, write in one Rounds 12 to 15 each found a case where two lookups disagreed about which PR is the task's after a base change or a retarget, or where one lookup's refusal stopped another step's safe action. One rule now: - Observing and drafting (recovery of lost updates and openings, the draft step) use findOwned: every open PR from the branch that carries one of the task's markers, in any base, with its base. Records and draft changes happen where the PR is. A stopped task's PRs are all drafted, and a lost update settles, whatever the base setting says. - Changing content (push, update, ready change, opening) stays on the main path in the configured base (findOpened), which now refuses two of the task's PRs, and its own PR in another base even when the configured base has one too. - A lost opening is dropped only when another of the task's PRs is open into the base that opening asked for. - Tests: the harness mirrors both lookups and one PR per branch and base; a lost update + base change on a cancelled task, two own PRs (drafted when stopped, refused when running), lost draft opening and head settle in their own base, a young lost opening not dropped for an own PR in another base. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 81 ++++++----- runner/publish.ts | 82 ++++++----- test/publish.test.ts | 147 ++++++++++++++++---- 4 files changed, 210 insertions(+), 102 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index f5b13495..e5c8e5dc 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,7 +53,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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 the lookup lists every open PR from the branch and uses the one into the configured base. A PR from the branch into another base is ignored (a backport someone opened, say), unless it carries one of the task's markers: then it is the task's own PR, retargeted by a person or left behind by a change of the base setting, and publish refuses with a message that says to retarget it or close it, instead of opening a second PR from the same branch. Recovery and the main path look up and change PRs in the configured base; a record's base is only where the PR was opened. So after a base change, retargeting the PR to the configured base is enough for every step to find it again. The draft step in step 1 finds the task's own PR in any base, because a draft is safe anywhere: a stopped task's PR left in an old base is still made a draft. 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. +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. 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 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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 650b3130..6703a521 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -22,11 +22,16 @@ export interface PullRequestGateway { /** * 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. A PR from the branch into another base is ignored, unless it carries one of `markers` (the task's own PR, - * retargeted by a person): that is refused. With `anyBase`, only the task's own PR is looked for, in whatever base it - * is (for making it a draft, which is safe anywhere), and anyone else's PR from the branch is ignored. + * is refused. 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[]; anyBase?: boolean }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string }) | null>; + findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, 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 })[]>; /** 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; @@ -121,8 +126,8 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 (!BRANCH.test(input.base) || !BRANCH.test(input.headBranch)) throw new Error('Invalid branch name.'); + #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.'); @@ -147,41 +152,51 @@ export class GhPullRequestGateway implements PullRequestGateway { return pr; } - async findOpened(input: { base: string; headBranch: string; markers: readonly string[]; anyBase?: boolean }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string }) | null> { - signal = this.#bounded(signal); - this.#validate(input); + /** + * 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]!; - // 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 to another base would be missed, and a second PR opened from the same branch. - const query = new URLSearchParams({ state: 'open', head: `${owner}:${input.headBranch}`, per_page: '100' }); + 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.'); - const baseOf = (pr: unknown) => (pr as { base?: { ref?: unknown } } | null)?.base?.ref; - if (input.anyBase) { - const own = response.filter(pr => { const body = (pr as { body?: unknown } | null)?.body; return typeof body === 'string' && input.markers.includes(markerOf(body)); }); - if (own.length > 1) throw new Error(`More than one of the task's pull requests is open from ${input.headBranch}.`); - if (!own.length) return null; - const base = baseOf(own[0]); + 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(own[0], { base, headBranch: input.headBranch }); - return { ...pr, marker: markerOf(body), base }; - } - const here = response.filter(pr => baseOf(pr) === input.base); + 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 findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, 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 Error(`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 Error(`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) { - // A PR into another base is someone else's (a backport from this branch, say) and is left alone, unless it carries - // one of the task's markers: then it is the task's own PR, retargeted, and a person has to decide. - for (const other of response) { - const { number, body } = other as { number?: unknown; body?: unknown }; - if (typeof body === 'string' && input.markers.includes(markerOf(body))) - throw new Error(`The task's pull request #${String(number)} from ${input.headBranch} now targets ${String(baseOf(other))}, not ${input.base}. Retarget it to ${input.base} or close it.`); - } - return null; - } + if (!here.length) return null; const { body, ...pr } = this.#pull(here[0], input); const found = input.markers.filter(marker => markerOf(body) === marker); 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]!, base: input.base }; + return { ...pr, marker: found[0]! }; } async refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string; beforeReady?: () => void }, signal?: AbortSignal): Promise { diff --git a/runner/publish.ts b/runner/publish.ts index 04242bdf..6307d41c 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -254,8 +254,7 @@ export class PullRequestPublisher { * 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; + draft: boolean, status: string, branch: string, signal?: AbortSignal, base = this.#config.baseBranch): Promise { 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. @@ -294,7 +293,9 @@ export class PullRequestPublisher { // 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.'); - const observed = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); + // 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. + const [observed = null] = await this.#pulls.findOwned({ headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); } @@ -304,16 +305,19 @@ export class PullRequestPublisher { 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); - const pr = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: lost.headBranch, markers: rows.map(row => marker(row.openingId)) }, signal); + // 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(); - if (pr && pr.marker !== marker(lost.openingId)) { - // The branch's open PR belongs to another of the task's openings, so this opening's request created nothing - // (the branch has at most one open PR). Drop it. If that other opening was abandoned, its PR is adopted here, - // not only on the main path, which a task that can no longer publish never reaches. + const pr = owned.find(candidate => candidate.marker === marker(lost.openingId)); + 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, its PR is adopted here, not only on the main path, which a task that can no longer publish never reaches. this.#store.abandonPullRequestOpening(identity, lost.openingId); - const owner = rows.find(row => marker(row.openingId) === pr.marker); + const owner = rows.find(row => marker(row.openingId) === blocking.marker); if (owner?.state === 'abandoned') { - this.#store.adoptOpening(identity, owner.openingId, pr, + this.#store.adoptOpening(identity, owner.openingId, blocking, { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); } return null; @@ -330,7 +334,7 @@ export class PullRequestPublisher { 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. if (lost.draft && draft && !pr.draft) { - try { found = await this.#pulls.markDraft(pr.number, { base: this.#config.baseBranch, headBranch: lost.headBranch, marker: marker(lost.openingId) }, signal); } + 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; const current = this.#isCurrent(identity, lost, draft); @@ -343,7 +347,7 @@ export class PullRequestPublisher { // 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); const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); - if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal); + if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, pr.base); return null; } @@ -366,45 +370,47 @@ export class PullRequestPublisher { 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 PR in any base. - const base = this.#config.baseBranch, branches = new Set(); + // 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) { if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && !pr.draft) || pr.state === 'abandoned')) branches.add(pr.headBranch); } for (const headBranch of branches) { const rows = this.#branchRows(prs, headBranch).filter(pr => pr.state !== 'opening'); - let live; - // In any base: a stopped task's PR is drafted wherever it is (a draft is safe anywhere), even one left in an old base. - try { live = await this.#pulls.findOpened({ base, headBranch, markers: rows.map(row => marker(row.openingId)), anyBase: true }, signal); } + 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 request 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)}).`); + 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(); - const row = live && rows.find(candidate => marker(candidate.openingId) === live.marker); - // No open PR (closed or merged), or it is not the one recorded for its opening: nothing is ready for review here. - if (!live || !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 = 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), marker: live.marker, base: live.base }; } - 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; + 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(); } - // 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/test/publish.test.ts b/test/publish.test.ts index 92005b75..ae3eb22a 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,6 +39,7 @@ const baseOf = new WeakMap, Map>( 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 } = {}) { + const publishConfig = { ...config, ...options.config }; const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; // Each PR's base, by marker (like GitHub, a PR opened into a base stays there); unset means the configured base. @@ -57,8 +58,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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. - if ([...live].some(([, pr]) => !closed.has(pr.number))) throw new PullRequestRefused('GitHub refused to open the pull request: A pull request already exists. (HTTP 422)'); + // 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'); @@ -66,22 +67,24 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, 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)!, base: input.base }; - // Like the adapter: the branch's open PR into this base, matched by marker; a PR into another base is ignored unless - // it is the task's own (retargeted), which is refused. + 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. const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); - if (input.anyBase) { - const own = visible.find(([m]) => input.markers.includes(m)); - return own ? { ...own[1], marker: own[0], base: bases.get(own[0]) ?? input.base } : null; - } - const open = visible.find(([m]) => (bases.get(m) ?? input.base) === input.base); - if (!open) { - const moved = visible.find(([m]) => input.markers.includes(m)); - if (moved) throw new Error(`The task's pull request #${moved[1].number} now targets ${bases.get(moved[0])}, not ${input.base}.`); - return null; - } + const baseOfPr = (m: string) => bases.get(m) ?? publishConfig.baseBranch; + const own = visible.filter(([m]) => input.markers.includes(m)); + if (own.length > 1) throw new Error(`More than one of the task's pull requests is open (${own.map(([, pr]) => `#${pr.number}`).join(', ')}).`); + if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new Error(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![0])}, not ${input.base}.`); + const open = visible.find(([m]) => baseOfPr(m) === input.base); + if (!open) return null; if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); - return { ...open[1], marker: open[0], base: input.base }; + return { ...open[1], marker: open[0] }; + }, + 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 }] : []; + return [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && input.markers.includes(m)) + .map(([m, pr]) => ({ ...pr, marker: m, base: bases.get(m) ?? publishConfig.baseBranch })); }, async markDraft(number, input) { log.push(`draft ${number}`); @@ -104,7 +107,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, }; 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); } }; - return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher, closing: options.closing }, { ...config, ...options.config }) }; + return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher, closing: options.closing }, publishConfig) }; } describe('opening the task PR', () => { @@ -217,7 +220,7 @@ describe('opening the task PR', () => { } }; 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 refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); + pulls: { async open() { opened.push('open'); throw new Error('unreachable'); }, async findOpened() { return null; }, async findOwned() { return []; }, async refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); await expect(publisher.publish(identity)).rejects.toThrow(GuardRefusal); expect(opened).toEqual([]); }); @@ -771,7 +774,7 @@ describe('recovering a lost opening', () => { 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([`find `]); + 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 () => { @@ -794,7 +797,7 @@ describe('recovering a lost opening', () => { 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'))).toEqual(['check', 'push codeboost/issue-12-task-42 003', 'refresh 100 ready']); + 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 () => { @@ -803,12 +806,12 @@ describe('recovering a lost opening', () => { // 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(/^find /)]); + 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(/^find /), expect.stringMatching(/^find /), 'check', 'push codeboost/issue-12-task-42 002', 'open ready']); + 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 () => { @@ -1327,7 +1330,8 @@ describe('GitHub PR adapter', () => { 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(/invalid pull request list/); + 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('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' } }); @@ -1337,13 +1341,20 @@ describe('GitHub PR adapter', () => { expect(await lookup([elsewhere({ number: 8, body: 'backport' })])).toBeNull(); expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response()])).toMatchObject({ number: 7, marker }); }); - it('finds the task\'s own PR in any base for a draft change, and ignores anyone else\'s', async () => { + 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 lookup = (list: unknown[]) => new GhPullRequestGateway({ repository: 'owner/repo' }, async () => JSON.stringify(list)).findOpened({ ...input, markers: [marker], anyBase: true }); - expect(await lookup([elsewhere()])).toMatchObject({ number: 7, marker, base: 'release' }); - expect(await lookup([response()])).toMatchObject({ number: 7, marker, base: 'main' }); - expect(await lookup([elsewhere({ number: 8, body: 'backport' }), response({ number: 9, body: 'someone else' })])).toBeNull(); - await expect(lookup([elsewhere(), response()])).rejects.toThrow(/More than one of the task's pull requests/); + 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/); }); 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)"}'); @@ -1427,7 +1438,7 @@ describe('shutdown and PRs left ready', () => { 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(/^find /), 'draft 9']); + 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 }); @@ -1694,6 +1705,82 @@ describe('shutdown and PRs left ready', () => { 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 }); + const again = harness(store, { live, next }); + expect(await again.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ kind: 'opened', number: 100, draft: true }); + expect(live.get([...live.keys()][0]!)).toMatchObject({ 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) }); + const outcome = await harness(store, { live, next }).publisher.publish(identity); + expect(outcome).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); + expect(outcome).not.toHaveProperty('leftReady'); + }); + 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); + }); 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); From b286a294614d2c1aba4a44c298e8054f24d6d41f Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:41:01 -0700 Subject: [PATCH 40/65] Close independent review round 16 on F2d: in review only from the configured base - Recovery confirms a lost opening's PR found in any base, and that confirmation could move the task to in review for a PR the main path would refuse: one retargeted or left in an old base, or one with another of the task's PRs open. Once in review nothing reconciled it, and a merge would target the wrong base. recordPullRequestOpened now takes mayReview; recovery passes it only for a PR in the configured base that is the task's only open PR. Any other is recorded and the head settle makes it a draft. - Recovery also adopts the task's other open PRs (abandoned openings) on that path, which ends the publish before the main path or the draft step could. The adoption on the "created nothing" path is removed: the main path or the draft step adopts next. - Tests: retarget and base change after a lost opening, two own PRs. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- runner/publish.ts | 27 ++++++++++++++------- runner/store.ts | 14 ++++++----- test/publish.test.ts | 24 ++++++++++++++++++ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index e5c8e5dc..89695b9d 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,7 +53,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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. 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 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. 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. +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. 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 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, together with the task's other open PRs, and made a draft; the task stays as it was. 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. diff --git a/runner/publish.ts b/runner/publish.ts index 6307d41c..008bfe86 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; -import { DraftsUnsupported, PullRequestRefused, type PullRequestGateway } from '../github/pull-requests.ts'; +import { DraftsUnsupported, PullRequestRefused, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError } from './lifecycle.ts'; import type { Store, TaskPullRequest } from './store.ts'; @@ -270,6 +270,15 @@ export class PullRequestPublisher { return { kind: 'opened', number: drafted.number, url: drafted.url, draft: drafted.draft, status: this.#store.getTask(identity).status }; } + /** Adopts, as facts with the versions read right now, every PR among `owned` whose opening is recorded as abandoned. */ + #adoptOwned(identity: PlanIdentity, rows: readonly TaskPullRequest[], owned: readonly (OpenedPullRequest & { marker: string })[]): void { + for (const pr of owned) { + const row = rows.find(candidate => marker(candidate.openingId) === pr.marker); + if (row?.state === 'abandoned') + this.#store.adoptOpening(identity, row.openingId, pr, { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); + } + } + /** 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; @@ -313,13 +322,8 @@ export class PullRequestPublisher { 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, its PR is adopted here, not only on the main path, which a task that can no longer publish never reaches. + // abandoned, the main path (a running task) or the draft step (a stopped one) adopts its PR next. this.#store.abandonPullRequestOpening(identity, lost.openingId); - const owner = rows.find(row => marker(row.openingId) === blocking.marker); - if (owner?.state === 'abandoned') { - this.#store.adoptOpening(identity, owner.openingId, blocking, - { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); - } return null; } if (!pr) { @@ -338,7 +342,7 @@ export class PullRequestPublisher { catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; const current = this.#isCurrent(identity, lost, draft); - this.#store.recordPullRequestOpened(identity, lost.openingId, pr); + this.#store.recordPullRequestOpened(identity, lost.openingId, pr, false); return current ? { kind: 'draft unsupported', number: pr.number } : null; } } @@ -346,7 +350,12 @@ export class PullRequestPublisher { // 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); - const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found); + // 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, and made a draft by the head settle. + const mayReview = pr.base === this.#config.baseBranch && owned.length === 1; + const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found, mayReview); + // The task's other open PRs are facts too: an abandoned opening's PR among them is adopted, so it can be found. + this.#adoptOwned(identity, rows, owned.filter(other => other !== pr)); if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, pr.base); return null; } diff --git a/runner/store.ts b/runner/store.ts index 9e05516e..fabc1cc8 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1058,11 +1058,13 @@ export class Store { } /** * 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). + * 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 }): TaskStatus { + 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, () => + 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.'); } @@ -1070,12 +1072,12 @@ export class Store { 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, () => + 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 }, find: () => Record | undefined, + #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(() => { @@ -1092,7 +1094,7 @@ export class Store { // 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 (task.state_version === owned && this.#current(key).review_version === ownedReview && task.status === 'running' + 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); } diff --git a/test/publish.test.ts b/test/publish.test.ts index ae3eb22a..7e641fe2 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1752,6 +1752,30 @@ describe('shutdown and PRs left ready', () => { expect(outcome).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); expect(outcome).not.toHaveProperty('leftReady'); }); + 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 }); + expect(await again.publisher.publish(identity), setup).toMatchObject({ kind: 'opened', number: 100, draft: true, status: '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 }); + expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 101, draft: true, status: 'running' }); + expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { state: 'opened', number: 101, draft: true }]); + }); 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 }; From eb46d949f30a7adae254c0db181f5f13d1ff1858 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:49:24 -0700 Subject: [PATCH 41/65] Close independent review round 17 on F2d: draft misplaced PRs before refusing - The main path's refusal for the task's PRs being where it cannot publish (its own PR in another base, or two of its PRs open) is now PullRequestMisplaced. Before refusing, the publisher runs the draft step with the task treated as not keeping ready, so none of its PRs stays ready while a person retargets or closes them. Before, a running task counted as "will be published as ready", so a PR that recovery recorded (not current, or its draft change failed), or an in-review PR sent back and then retargeted, stayed ready for good. - The drafts-unsupported branch of recovery leaves adopting the task's other PRs to the next publish's draft step (documented). - Tests: the three routes to a misplaced ready PR; both refusals are PullRequestMisplaced; two own PRs are drafted before the refusal. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 9 +++-- runner/publish.ts | 18 +++++++--- test/publish.test.ts | 39 +++++++++++++++++++-- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 89695b9d..198980d8 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -53,7 +53,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who 1. **Recover.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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: its PRs are adopted only by recovery and the main path. 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. 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 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, together with the task's other open PRs, and made a draft; the task stays as it was. 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. +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. 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 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 made a draft (the task's other open PRs are adopted with it, or by the next publish when drafts are unsupported); 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 6703a521..7c7f1d6c 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -48,6 +48,11 @@ export class DraftsUnsupported extends Error {} * 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, + * or two are open. A person has to retarget or close them; nothing about them is changed. + */ +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 @@ -187,9 +192,9 @@ export class GhPullRequestGateway implements PullRequestGateway { 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 Error(`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) 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 Error(`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.`); + 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; diff --git a/runner/publish.ts b/runner/publish.ts index 008bfe86..12090d46 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { pullRequestBody, pullRequestTitle } from '../core/pull-request-body.ts'; import type { AlreadyFixedGateway, AlreadyFixedResult } from '../github/already-fixed.ts'; -import { DraftsUnsupported, PullRequestRefused, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; +import { DraftsUnsupported, PullRequestMisplaced, PullRequestRefused, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.ts'; import { GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError } from './lifecycle.ts'; import type { Store, TaskPullRequest } from './store.ts'; @@ -131,7 +131,15 @@ export class PullRequestPublisher { 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. - const live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); + let live; + try { live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); } + catch (error) { + if (!(error instanceof PullRequestMisplaced) || signal?.aborted) 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. + const notes = await this.#draftStranded(identity, signal, true); + throw new PullRequestMisplaced(notes.length ? `${error.message} ${notes.join(' ')}` : error.message); + } signal?.throwIfAborted(); 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. @@ -342,6 +350,7 @@ export class PullRequestPublisher { catch (error) { if (!(error instanceof DraftsUnsupported)) throw error; const current = this.#isCurrent(identity, lost, draft); + // The task is in needs human: the next publish's draft step adopts its other open PRs. this.#store.recordPullRequestOpened(identity, lost.openingId, pr, false); return current ? { kind: 'draft unsupported', number: pr.number } : null; } @@ -369,11 +378,12 @@ export class PullRequestPublisher { * 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): Promise { + 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 = () => { const status = this.#store.getTask(identity).status; // An approved task's PR must stay ready: GitHub does not merge a draft. - return MERGEABLE_STATUSES.includes(status) || status === 'merged' || this.#store.canPublish(identity, false); + return MERGEABLE_STATUSES.includes(status) || status === 'merged' || (!misplaced && this.#store.canPublish(identity, false)); }; if (keepsReady()) return []; const prs = this.#store.taskPullRequests(identity), notes: string[] = []; diff --git a/test/publish.test.ts b/test/publish.test.ts index 7e641fe2..93185886 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -4,7 +4,7 @@ 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, PullRequestRefused, type OpenPullRequestInput, type OpenedPullRequest, type PullRequestGateway } from '../github/pull-requests.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'; @@ -73,8 +73,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); const baseOfPr = (m: string) => bases.get(m) ?? publishConfig.baseBranch; const own = visible.filter(([m]) => input.markers.includes(m)); - if (own.length > 1) throw new Error(`More than one of the task's pull requests is open (${own.map(([, pr]) => `#${pr.number}`).join(', ')}).`); - if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new Error(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![0])}, not ${input.base}.`); + if (own.length > 1) throw new PullRequestMisplaced(`More than one of the task's pull requests is open (${own.map(([, pr]) => `#${pr.number}`).join(', ')}).`); + if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new PullRequestMisplaced(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![0])}, not ${input.base}.`); const open = visible.find(([m]) => baseOfPr(m) === input.base); if (!open) return null; if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); @@ -1355,6 +1355,9 @@ describe('GitHub PR adapter', () => { 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)"}'); @@ -1776,6 +1779,34 @@ describe('shutdown and PRs left ready', () => { expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 101, draft: true, status: 'running' }); expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { 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 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'); + expect(await harness(store, { live, next, draftFails: true }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running', leftReady: 100 }); + 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('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 }; @@ -1804,6 +1835,8 @@ describe('shutdown and PRs left ready', () => { 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 }; From 808915247c2f69e07f1bc9f249b62b8b31552863 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:55:07 -0700 Subject: [PATCH 42/65] Close independent review round 18 on F2d: look up every misplaced PR - Before refusing misplaced PRs, the draft step now looks up every branch with an opened record, not only ones recorded as ready: a PR recorded as a draft may have been made ready by the person who moved it. Test: a needs-human draft PR made ready and retargeted is drafted. - The notes of both draft-step runs are kept in the refusal, instead of the second shadowing the first. - The abort check in the misplaced catch is removed: the draft step rethrows on an aborted signal itself. Co-Authored-By: Claude Opus 5.5 --- runner/publish.ts | 9 +++++---- test/publish.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/runner/publish.ts b/runner/publish.ts index 12090d46..46740e21 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -134,11 +134,11 @@ export class PullRequestPublisher { let live; try { live = await this.#pulls.findOpened({ base: this.#config.baseBranch, headBranch: branch, markers: candidates.map(pr => marker(pr.openingId)) }, signal); } catch (error) { - if (!(error instanceof PullRequestMisplaced) || signal?.aborted) throw 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. - const notes = await this.#draftStranded(identity, signal, true); - throw new PullRequestMisplaced(notes.length ? `${error.message} ${notes.join(' ')}` : error.message); + const all = [...new Set([...notes, ...await this.#draftStranded(identity, signal, true)])]; + throw new PullRequestMisplaced(all.length ? `${error.message} ${all.join(' ')}` : error.message); } signal?.throwIfAborted(); let earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; @@ -392,7 +392,8 @@ export class PullRequestPublisher { // 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) { - if (pr.repository.toLowerCase() === this.#config.repository.toLowerCase() && ((pr.state === 'opened' && !pr.draft) || pr.state === 'abandoned')) + // 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) { diff --git a/test/publish.test.ts b/test/publish.test.ts index 93185886..cab71b7e 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1807,6 +1807,20 @@ describe('shutdown and PRs left ready', () => { 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('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 }; From 9dbccdb23bdeb945e83fbc8ae8e0923c17ab598a Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:03:18 -0700 Subject: [PATCH 43/65] Close independent review round 19 on F2d: one path for misplaced PRs - Recovery ends a publish only for a PR the main path would accept (in the configured base, the task's only open PR). Any other recovered PR is recorded and publishing continues to the main path, which drafts all the task's PRs and refuses with what a person has to do. Before, a current recovered opening ended as "opened" and drafted only its own PR, leaving another of the task's PRs ready. The head settle is therefore always in the configured base again. - The misplaced refusal carries only the second draft-step run's notes: it looks every PR up again, so a failure it fixed is not reported. - Doc: step 1 rewritten against the code (the settled update, the review version in "own work", drafts unsupported, "created nothing" only for the same base, adoption left to the main path or draft step). - Tests: recovered misplaced PRs now end in the refusal, drafted. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 7 +++++-- runner/publish.ts | 12 +++++++---- test/publish.test.ts | 23 +++++++++++++-------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 198980d8..ac34e1bb 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -50,10 +50,13 @@ A cancelled check throws. It does not return `unknown`. 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.** If an opening is still `opening`, look for the open PR from the task branch, with the markers of all the task's openings for that branch. If it has this opening's marker, record it as opened; if this was a draft opening, this publish is a draft publish too, and the PR has since been made ready, turn it back into a draft first (a failure keeps the opening owned). The recovered opening ends this publish only when it is this publish's own work: same task state version and same draft mode. Otherwise the PR is recorded and publishing continues, so the main path pushes the current head and brings the PR into the current mode. A pending update recorded for another repository is refused loudly, not cleared unseen. If it has another opening's marker, this opening created nothing (the branch has at most one open PR): mark it `abandoned`; if that other opening was abandoned, adopt its PR here, then continue. If none exists, 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. +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. 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 and publish returns `draft unsupported`. 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: its PRs are adopted only by recovery and the main path. 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. 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 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 made a draft (the task's other open PRs are adopted with it, or by the next publish when drafts are unsupported); 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. +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. 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 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, with the task's other open PRs adopted, and the main path 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. diff --git a/runner/publish.ts b/runner/publish.ts index 46740e21..e1165a61 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -137,8 +137,9 @@ export class PullRequestPublisher { 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. - const all = [...new Set([...notes, ...await this.#draftStranded(identity, signal, true)])]; - throw new PullRequestMisplaced(all.length ? `${error.message} ${all.join(' ')}` : error.message); + // 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(); let earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; @@ -262,7 +263,8 @@ export class PullRequestPublisher { * 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, base = this.#config.baseBranch): Promise { + 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. @@ -365,7 +367,9 @@ export class PullRequestPublisher { const status = this.#store.recordPullRequestOpened(identity, lost.openingId, found, mayReview); // The task's other open PRs are facts too: an abandoned opening's PR among them is adopted, so it can be found. this.#adoptOwned(identity, rows, owned.filter(other => other !== pr)); - if (current) return this.#settleHead(identity, lost.openingId, found, lost.headSha, draft, status, lost.headBranch, signal, pr.base); + // 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; } diff --git a/test/publish.test.ts b/test/publish.test.ts index cab71b7e..b07661f9 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1743,17 +1743,20 @@ describe('shutdown and PRs left ready', () => { 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 }); - expect(await again.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ kind: 'opened', number: 100, draft: true }); + 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) }); - const outcome = await harness(store, { live, next }).publisher.publish(identity); - expect(outcome).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); - expect(outcome).not.toHaveProperty('leftReady'); + 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) { @@ -1762,7 +1765,8 @@ describe('shutdown and PRs left ready', () => { 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 }); - expect(await again.publisher.publish(identity), setup).toMatchObject({ kind: 'opened', number: 100, draft: true, status: 'running' }); + 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 }]); } }); @@ -1776,8 +1780,9 @@ describe('shutdown and PRs left ready', () => { 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 }); - expect(await again.publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 101, draft: true, status: 'running' }); - expect(store.taskPullRequests(identity)).toMatchObject([{ state: 'opened', number: 100 }, { state: 'opened', number: 101, draft: true }]); + 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. @@ -1789,11 +1794,11 @@ describe('shutdown and PRs left ready', () => { 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 next publish drafts it. + // 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'); - expect(await harness(store, { live, next, draftFails: true }).publisher.publish(identity)).toMatchObject({ kind: 'opened', number: 100, status: 'running', leftReady: 100 }); + 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'); From 09ee8b216769cda708b4ccdc5b4f236caf7581b7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:14:13 -0700 Subject: [PATCH 44/65] Close independent review round 20 on F2d: no early ends in recovery - Recovery's drafts-unsupported branch no longer returns early: the PR is recorded as it is and publishing continues, so the main path returns `draft unsupported` in the configured base, or the misplaced refusal for a PR moved elsewhere (before, a moved lost draft opening was reported as `draft unsupported` with no word about its base). - Recovery's adoption of the task's other PRs is removed: since round 19 any such case continues to the main path, whose misplaced draft step adopts them. - Tests: a moved lost draft opening without drafts support is refused as misplaced; the misplaced refusal leaves out a draft failure that the second run fixed. Doc: step 1's drafts-unsupported sentence. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- runner/publish.ts | 19 ++++------------ test/publish.test.ts | 24 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index ac34e1bb..44ee1bda 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -51,7 +51,7 @@ A cancelled check throws. It does not return `unknown`. 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. 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 and publish returns `draft unsupported`. 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). + - **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: its PRs are adopted only by recovery and the main path. 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. diff --git a/runner/publish.ts b/runner/publish.ts index e1165a61..3ac64e11 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -280,15 +280,6 @@ export class PullRequestPublisher { return { kind: 'opened', number: drafted.number, url: drafted.url, draft: drafted.draft, status: this.#store.getTask(identity).status }; } - /** Adopts, as facts with the versions read right now, every PR among `owned` whose opening is recorded as abandoned. */ - #adoptOwned(identity: PlanIdentity, rows: readonly TaskPullRequest[], owned: readonly (OpenedPullRequest & { marker: string })[]): void { - for (const pr of owned) { - const row = rows.find(candidate => marker(candidate.openingId) === pr.marker); - if (row?.state === 'abandoned') - this.#store.adoptOpening(identity, row.openingId, pr, { stateVersion: this.#store.getTask(identity).stateVersion, reviewVersion: this.#store.reviewVersion(identity) }); - } - } - /** 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; @@ -351,10 +342,10 @@ export class PullRequestPublisher { 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; - const current = this.#isCurrent(identity, lost, draft); - // The task is in needs human: the next publish's draft step adopts its other open PRs. + // 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 current ? { kind: 'draft unsupported', number: pr.number } : null; + return null; } } // The recovered opening finishes this publish only if it is this publish's own work: same task version and same @@ -362,11 +353,9 @@ export class PullRequestPublisher { // 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, and made a draft by the head settle. + // 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); - // The task's other open PRs are facts too: an abandoned opening's PR among them is adopted, so it can be found. - this.#adoptOwned(identity, rows, owned.filter(other => other !== pr)); // 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); diff --git a/test/publish.test.ts b/test/publish.test.ts index b07661f9..4574aa12 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1826,6 +1826,30 @@ describe('shutdown and PRs left ready', () => { 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('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 }; From 14656061a0c429e0c933ee19107f061e758fa3b4 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:19:35 -0700 Subject: [PATCH 45/65] Close independent review round 21 on F2d: doc follows round 20 Round 21 found no code defects; three doc statements lagged behind round 20's removal of recovery's early return and adoption: - step 3: the main path's misplaced draft step adopts the task's other PRs, not recovery; - step 1: a task in review, approved or merged is refused on status, so a late PR of another abandoned opening is not adopted for it; - the drafts table: the draft change comes after a clear check, and a match or no changes report the ready PR as leftReady. A test title no longer names recovery as the one reporting it. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 6 +++--- test/publish.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 44ee1bda..23f80553 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -54,9 +54,9 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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: its PRs are adopted only by recovery and the main path. 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. + **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. 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 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, with the task's other open PRs adopted, and the main path 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. +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. 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 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. @@ -110,7 +110,7 @@ Some repositories do not support draft PRs (for example private repositories on | 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 | Turning it into a draft is the first step, 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 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. | diff --git a/test/publish.test.ts b/test/publish.test.ts index 4574aa12..3cf4b05a 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -470,7 +470,7 @@ describe('guards found by the independent review', () => { await expect(harness(store, { live, next }).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 recovery cannot turn a lost draft opening back into a draft', async () => { + 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'); From 188b022057e8e9d78e598ed91f90b8165829a354 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:39:33 -0700 Subject: [PATCH 46/65] Close independent review round 22 on F2d: tests for untested guards Round 22 found no code defects. It found guards no test would miss: - runWithInput starts nothing for an already-aborted signal (a listener added to an aborted signal never fires); - every gh environment allowlist variable is passed on, and the proxy, CA, host and enterprise-token names are pinned; - an empty comparison page that should hold commits fails closed at once, with one compare call; - GH-N needs a boundary before it (XGH-12, foo-GH-12 are not mentions). Doc: titles and problems are cut by UTF-16 length, not by code point. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- test/already-fixed.test.ts | 7 ++++++- test/publish.test.ts | 13 +++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 23f80553..291a8e0b 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -99,7 +99,7 @@ GitHub ignores closing keywords and @-mentions inside code. So plan text or agen 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. -Titles and problems are cut by code point, never inside a surrogate pair. An empty summary becomes `codeboost plan`. +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. diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 1be7edf0..22006479 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -58,7 +58,7 @@ 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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) 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']) + 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']) expect(mentionsIssue(message, repo, 12), message).toBe(false); }); }); @@ -159,6 +159,11 @@ describe('the pre-PR already-fixed check', () => { 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 }, diff --git a/test/publish.test.ts b/test/publish.test.ts index 3cf4b05a..2ea2dc13 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { GuardRefusal, ShuttingDownError } from '../runner/lifecycle.ts'; @@ -1147,6 +1148,13 @@ describe('running gh with a request body on stdin', () => { 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/); }); @@ -1166,6 +1174,11 @@ describe('gh subprocess environment', () => { // 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' }); }); From d82cc234ac2c5f5f979efa9a0d10552cba377377 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 15:00:50 -0700 Subject: [PATCH 47/65] Close independent review round 23 on F2d: tests for the remaining guards Round 23 found no code defects. Tests now fail when these break: - the abort checks after the check (no result recorded), after the new branch's push (no opening left to settle) and after the needs-human draft change (no update recorded or pushed); - the PATCH answer's own check in refresh, before any ready change; - PR validation: an https URL, the base repository, and a marked PR without a readable base in findOwned; - neutralising lowercase gh-N and /pull/N, and ##N not being a mention. A stale comment on recordPullRequestDraft is removed. Co-Authored-By: Claude Opus 5.5 --- runner/store.ts | 1 - test/already-fixed.test.ts | 2 +- test/publish.test.ts | 45 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/runner/store.ts b/runner/store.ts index fabc1cc8..0ab45fc1 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -1139,7 +1139,6 @@ export class Store { if (plan.snapshot_id !== input.snapshotId) throw new GuardRefusal('The task head changed during the check.'); }); } - /** Records that the task's open PR is now a draft (after a check matched). The task status does not change. */ /** * 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 diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 22006479..4ca9e244 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -58,7 +58,7 @@ 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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) 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']) + 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']) expect(mentionsIssue(message, repo, 12), message).toBe(false); }); }); diff --git a/test/publish.test.ts b/test/publish.test.ts index 2ea2dc13..21434f1e 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,7 +39,7 @@ 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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean; onDraft?: () => void; onRefresh?: () => void; closing?: () => boolean; onCheck?: () => void } = {}) { const publishConfig = { ...config, ...options.config }; const live = options.live ?? new Map(), counter = options.next ?? { value: 100 }, closed = options.closed ?? new Set(); const log: string[] = [], checks: AlreadyFixedInput[] = [], opened: OpenPullRequestInput[] = []; @@ -48,7 +48,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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); + 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 })) } @@ -1102,6 +1102,8 @@ describe('the PR description', () => { 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); @@ -1225,6 +1227,12 @@ describe('GitHub PR adapter', () => { 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/); @@ -1346,6 +1354,14 @@ describe('GitHub PR adapter', () => { 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('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] }); @@ -1863,6 +1879,31 @@ describe('shutdown and PRs left ready', () => { 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('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 }; From 553135cb1f3561d2d0481fa07674106ad5f876f4 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 18:29:37 -0700 Subject: [PATCH 48/65] Close independent review round 24 on F2d: control characters, blocking test Round 24 found no code defects. - Fenced plan text and problems replace control characters other than newline and tab with U+FFFD: a NUL from agent output could make GitHub refuse the description, and that refusal would repeat after every push. - Test: a lost opening is dropped at once when another of the task's PRs is open into the same base (only the other-base case was tested). - The PR operation deadline comment states the 66-second settle bound the doc gives. Co-Authored-By: Claude Opus 5.5 --- core/pull-request-body.ts | 6 ++++-- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 5 ++++- test/publish.test.ts | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index 36943b1b..60333560 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -9,14 +9,16 @@ 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. + * 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) 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.replace(/\r\n?/g, '\n')}\n${fence}`; + return `${fence}text\n${text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '\ufffd')}\n${fence}`; } /** diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 291a8e0b..ad85d87d 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -99,7 +99,7 @@ GitHub ignores closing keywords and @-mentions inside code. So plan text or agen 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. -Titles and problems are cut by UTF-16 length, never inside a surrogate pair. An empty summary becomes `codeboost plan`. +Inside the fences, control characters other than newline and tab 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 7c7f1d6c..bbe6becd 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -83,7 +83,10 @@ async function draftCall(call: () => Promise): Promise { * agent-controlled, so a marker-shaped string there never identifies a PR. */ export const markerOf = (body: string): string => body.split('\n', 1)[0]!.trim(); -/** The longest one open, lookup, refresh or draft change may take in total, whatever the caller's signal. */ +/** + * 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index 21434f1e..39019369 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1068,6 +1068,9 @@ describe('the PR description', () => { 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', () => { + expect(fenced('a\u0000b\u0007c\td\r\ne\u007f')).toBe('```text\na\ufffdb\ufffdc\td\ne\ufffd\n```'); + }); it('handles text with very many backtick runs without overflowing the stack', () => { expect(fenced('`a'.repeat(300_000)).startsWith('```text\n')).toBe(true); }); @@ -1904,6 +1907,21 @@ describe('shutdown and PRs left ready', () => { 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('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 }; From b84afe77abba76391b6427cec1c03e1b63eee2bf Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 18:52:01 -0700 Subject: [PATCH 49/65] Close independent review round 25 on F2d: non-JSON bodies are not read Round 25 found no code defects. Test: a gh failure whose stdout is not JSON (a proxy's HTML error page) is not matched for refusals, even when it contains the drafts-unsupported or HTTP 422 text. Co-Authored-By: Claude Opus 5.5 --- test/publish.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/publish.test.ts b/test/publish.test.ts index 39019369..48b07c24 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1396,6 +1396,10 @@ describe('GitHub PR adapter', () => { 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 () => { From f83c260c21ca8858f16133fe2f33d5ebbf96c71b Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:12:34 -0700 Subject: [PATCH 50/65] Close independent review round 26 on F2d: list lag, CRLF markers, surrogates Round 26 found no high or medium defects. - A PR recorded as opened that GitHub's list does not show is read directly (isOpen) before the main path acts as if it were gone; if it is still open, publish stops with OpeningUnsettled and pushes nothing, so a lagging list never lets a push move an open PR's head with no update recorded as in flight. - Test: a marker in a description with CRLF line endings (GitHub returns these after an edit on github.com, seen live) is recognised. - Lone UTF-16 surrogates in the title and fenced text become U+FFFD. Co-Authored-By: Claude Opus 5.5 --- core/pull-request-body.ts | 8 ++--- docs/implementation/pull-request-opening.md | 4 +-- github/pull-requests.ts | 13 ++++++++ runner/publish.ts | 10 ++++++ test/publish.test.ts | 36 +++++++++++++++++++-- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/core/pull-request-body.ts b/core/pull-request-body.ts index 60333560..bc2a75a1 100644 --- a/core/pull-request-body.ts +++ b/core/pull-request-body.ts @@ -10,15 +10,15 @@ 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) become U+FFFD: GitHub may refuse them, and a refusal would repeat - * on every publish. + * 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.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '\ufffd')}\n${fence}`; + return `${fence}text\n${text.toWellFormed().replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '\ufffd')}\n${fence}`; } /** @@ -52,7 +52,7 @@ export function neutralizeMentions(text: string): string { /** 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.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim())) || 'codeboost plan'; + 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; } diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index ad85d87d..deb40c2e 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -56,7 +56,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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. 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 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. +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. 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. 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: if it is still open, publish stops with `OpeningUnsettled` and pushes nothing, because a push would move the open PR's head with no update recorded as in flight. 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. @@ -99,7 +99,7 @@ GitHub ignores closing keywords and @-mentions inside code. So plan text or agen 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 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`. +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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index bbe6becd..b7ddf7e8 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -32,6 +32,11 @@ export interface PullRequestGateway { * 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 })[]>; + /** + * Whether PR `number` is open, read from the PR itself: 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. + */ + isOpen(number: number, signal?: AbortSignal): Promise; /** 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; @@ -190,6 +195,14 @@ export class GhPullRequestGateway implements PullRequestGateway { return this.#owned(await this.#branchPulls(input.headBranch, signal), input.headBranch, input.markers); } + async isOpen(number: number, signal?: AbortSignal): Promise { + 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 } | null; + if (!pr || pr.number !== number || (pr.state !== 'open' && pr.state !== 'closed')) throw new Error('GitHub returned an invalid pull request.'); + return pr.state === 'open'; + } + async findOpened(input: { base: string; headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { signal = this.#bounded(signal); this.#validate(input); diff --git a/runner/publish.ts b/runner/publish.ts index 3ac64e11..13e44ceb 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -142,6 +142,16 @@ export class PullRequestPublisher { 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 open = await this.#pulls.isOpen(row.number, signal); + signal?.throwIfAborted(); + if (open) throw new OpeningUnsettled(`Pull request #${row.number} is open, but GitHub's pull request list does not show it yet. Try again later.`); + } + } 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index 48b07c24..0cef7167 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -81,6 +81,11 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); return { ...open[1], marker: open[0] }; }, + async isOpen(number) { + // The PR itself: open unless closed, whatever the list shows (a hidden PR models a list that lags behind). + log.push(`is open ${number}`); + return [...live.values()].some(pr => pr.number === number) && !closed.has(number); + }, 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 }] : []; @@ -221,7 +226,7 @@ describe('opening the task PR', () => { } }; 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 refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); + pulls: { async open() { opened.push('open'); throw new Error('unreachable'); }, async findOpened() { return null; }, async findOwned() { return []; }, async isOpen() { return false; }, async refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); await expect(publisher.publish(identity)).rejects.toThrow(GuardRefusal); expect(opened).toEqual([]); }); @@ -1068,8 +1073,10 @@ describe('the PR description', () => { 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', () => { + 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); @@ -1357,6 +1364,18 @@ describe('GitHub PR adapter', () => { 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('reads whether a PR is open from the PR itself', 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); }).isOpen(7); + expect(await read({ number: 7, state: 'open' })).toBe(true); + expect(await read({ number: 7, state: 'closed' })).toBe(false); + await expect(read({ number: 8, state: 'open' })).rejects.toThrow(/invalid pull request/); + await expect(read({ number: 7, state: 'weird' })).rejects.toThrow(/invalid pull request/); + }); 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/); @@ -1926,6 +1945,19 @@ describe('shutdown and PRs left ready', () => { 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); + // 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('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 }; From 13bdfada7f58d1dc3fffb3055b4ac4cd2f905413 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:16:35 -0700 Subject: [PATCH 51/65] Close independent review round 27 on F2d: tell list lag from a moved PR Round 27 found no high or medium defects. The direct read added in round 26 treated every open recorded PR missing from the branch's list as list lag ("try again later"). A PR whose branch a person renamed, or whose marker was removed and base changed, never comes back, so that retry looped forever. readPull now returns the PR's branch, base and marker: only a PR still on the task branch, into the configured base, with its marker is lag (OpeningUnsettled); any other open PR is refused as PullRequestMisplaced with what a person has to do. Tests: the moved-branch refusal; the adapter's readPull validation. Doc: the two outcomes and one request per recorded PR. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 17 +++++---- runner/publish.ts | 9 +++-- test/publish.test.ts | 39 ++++++++++++++------- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index deb40c2e..d0e278c3 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -56,7 +56,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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. 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. 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: if it is still open, publish stops with `OpeningUnsettled` and pushes nothing, because a push would move the open PR's head with no update recorded as in flight. 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. +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. 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. 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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index b7ddf7e8..4558a59f 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -33,10 +33,11 @@ export interface PullRequestGateway { */ findOwned(input: { headBranch: string; markers: readonly string[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string; base: string })[]>; /** - * Whether PR `number` is open, read from the PR itself: 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. + * 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). */ - isOpen(number: number, signal?: AbortSignal): Promise; + 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; @@ -195,12 +196,14 @@ export class GhPullRequestGateway implements PullRequestGateway { return this.#owned(await this.#branchPulls(input.headBranch, signal), input.headBranch, input.markers); } - async isOpen(number: number, signal?: AbortSignal): Promise { + 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 } | null; - if (!pr || pr.number !== number || (pr.state !== 'open' && pr.state !== 'closed')) throw new Error('GitHub returned an invalid pull request.'); - return pr.state === 'open'; + 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[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { diff --git a/runner/publish.ts b/runner/publish.ts index 13e44ceb..b210d1d8 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -147,9 +147,14 @@ export class PullRequestPublisher { // 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 open = await this.#pulls.isOpen(row.number, signal); + const pr = await this.#pulls.readPull(row.number, signal); signal?.throwIfAborted(); - if (open) throw new OpeningUnsettled(`Pull request #${row.number} is open, but GitHub's pull request list does not show it yet. Try again later.`); + 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. + 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.`); } } let earlier = live ? candidates.find(pr => marker(pr.openingId) === live.marker)! : undefined; diff --git a/test/publish.test.ts b/test/publish.test.ts index 0cef7167..88ef68bd 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,8 +39,10 @@ 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 } = {}) { + onFind?: () => void; refreshFails?: boolean; closed?: Set; hidden?: Set; openTimesOut?: boolean; draftFails?: boolean; draftsUnsupported?: boolean; onDraft?: () => void; onRefresh?: () => void; closing?: () => boolean; onCheck?: () => void; moved?: Set } = {}) { 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[] = []; // Each PR's base, by marker (like GitHub, a PR opened into a base stays there); unset means the configured base. @@ -81,10 +83,14 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); return { ...open[1], marker: open[0] }; }, - async isOpen(number) { - // The PR itself: open unless closed, whatever the list shows (a hidden PR models a list that lags behind). - log.push(`is open ${number}`); - return [...live.values()].some(pr => pr.number === number) && !closed.has(number); + 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}`); + // 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: m }; }, async findOwned(input) { log.push(`owned ${input.markers.join(' ')}`); options.onFind?.(); @@ -113,7 +119,9 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, }; 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); } }; - return { log, checks, opened, pulls, publisher: new PullRequestPublisher(store, { checks: gate, pulls, pusher, closing: options.closing }, publishConfig) }; + 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', () => { @@ -226,7 +234,7 @@ describe('opening the task PR', () => { } }; 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 isOpen() { return false; }, async refresh() { throw new Error('unreachable'); }, async markDraft() { throw new Error('unreachable'); } } }, config); + 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([]); }); @@ -1369,12 +1377,13 @@ describe('GitHub PR adapter', () => { 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('reads whether a PR is open from the PR itself', 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); }).isOpen(7); - expect(await read({ number: 7, state: 'open' })).toBe(true); - expect(await read({ number: 7, state: 'closed' })).toBe(false); - await expect(read({ number: 8, state: 'open' })).rejects.toThrow(/invalid pull request/); - await expect(read({ number: 7, state: 'weird' })).rejects.toThrow(/invalid pull request/); + 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('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] }); @@ -1954,6 +1963,10 @@ describe('shutdown and PRs left ready', () => { 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 whose branch a person renamed is not list lag: a retry would never see it, so the refusal says what to do. + 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/); + expect(moved.log.some(line => line.startsWith('push') || line.startsWith('open'))).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' }); From 61c0a9ff385b50549429a08883a43f547b7a4807 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:22:03 -0700 Subject: [PATCH 52/65] Close independent review round 28 on F2d: draft a moved PR before refusing Round 28 found no high or medium defects. - A PR the direct read finds moved (branch renamed, retargeted while the list lags) is made a draft where it is before the misplaced refusal, while its first line still identifies it; a PR whose marker was removed is reported as possibly still ready. Before, this refusal skipped the drafting every other misplaced refusal does. - Tests: retargeted and unmarked PRs during list lag; the base and marker conditions of the lag test now fail when removed. - PullRequestMisplaced's comment covers moved PRs and the drafting. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 4 +++- runner/publish.ts | 17 ++++++++++++++++- test/publish.test.ts | 19 +++++++++++++++---- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index d0e278c3..78795b9d 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -56,7 +56,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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. 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. 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. 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. +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. 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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 4558a59f..1dbc27a6 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -56,7 +56,9 @@ export class DraftsUnsupported extends Error {} 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, - * or two are open. A person has to retarget or close them; nothing about them is changed. + * 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; diff --git a/runner/publish.ts b/runner/publish.ts index b210d1d8..4a49420b 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -154,7 +154,22 @@ export class PullRequestPublisher { 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. - 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.`); + // 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.'; + if (pr.marker === marker(row.openingId)) { + try { + const drafted = await this.#pulls.markDraft(row.number, { base: pr.base, headBranch: pr.headBranch, marker: pr.marker }, signal); + 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.'; + } 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)}).`; + } + } + 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index 88ef68bd..f508cb46 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,7 +39,7 @@ 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 } = {}) { + 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 } = {}) { const publishConfig = { ...config, ...options.config }; // The task's branch, as the publisher names it (one task per harness). let publishBranch = ''; @@ -90,7 +90,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: // 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: m }; + 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?.(); @@ -1963,10 +1963,21 @@ describe('shutdown and PRs left ready', () => { 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 whose branch a person renamed is not list lag: a retry would never see it, so the refusal says what to do. + // 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/); + 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 }); + // Retargeted while the list lags: also moved, not lag. + baseOf.get(live)!.set([...live.keys()][0]!, 'develop'); + await expect(harness(store, { live, next, hidden }).publisher.publish(identity)).rejects.toThrow(PullRequestMisplaced); + baseOf.get(live)!.delete([...live.keys()][0]!); + // 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' }); From 6a56491a502b61a0144df2946384171e11bea850 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:26:36 -0700 Subject: [PATCH 53/65] Close independent review round 29 on F2d: moved-PR draft record Round 29 found no high or medium defects; all items were in round 28's drafting of a moved PR: - The record write is outside the draft call's catch, so a Store failure after a draft that landed propagates instead of being told as "may still be ready". - The draft flag is recorded only when it differs, so retries of a refused publish do not move the task's version. - Tests: the fake draft change checks the head branch, a repeat does not move the version, an abort during the change rejects with the abort, and a retargeted PR is drafted in its new base. Co-Authored-By: Claude Opus 5.5 --- runner/publish.ts | 16 ++++++++++------ test/publish.test.ts | 18 +++++++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/runner/publish.ts b/runner/publish.ts index 4a49420b..c22e4517 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -158,16 +158,20 @@ export class PullRequestPublisher { // still identifies it. let state = 'It may still be ready for review: its first line no longer identifies it.'; if (pr.marker === marker(row.openingId)) { - try { - const drafted = await this.#pulls.markDraft(row.number, { base: pr.base, headBranch: pr.headBranch, marker: pr.marker }, signal); - 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.'; - } catch (error) { + 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}`); } diff --git a/test/publish.test.ts b/test/publish.test.ts index f508cb46..ae5c190c 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -100,8 +100,10 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: }, async markDraft(number, input) { log.push(`draft ${number}`); - // Like the adapter's read-back: the PR must be into the base asked for. + // 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); + 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'); @@ -1970,10 +1972,20 @@ describe('shutdown and PRs left ready', () => { 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 }); - // Retargeted while the list lags: also moved, not lag. + // 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(PullRequestMisplaced); + 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/); From 719224f7c86cc4ca2437ed3737ca3bfc226cf697 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:47:45 -0700 Subject: [PATCH 54/65] Close independent review round 30 on F2d: own PR without its marker Round 30 found no high or medium defects. A recorded PR whose first-line marker a person removed was refused as "not opened by codeboost" when the list showed it, but as misplaced (with what to do) when the list lagged. The main path now passes its recorded PR numbers to findOpened, which refuses such a PR as PullRequestMisplaced: restore its first line or close it. Tests at the gateway and the publisher; doc step 3. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 7 +++++-- runner/publish.ts | 5 ++++- test/publish.test.ts | 23 +++++++++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 78795b9d..89491103 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -56,7 +56,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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. 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. 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. +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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 1dbc27a6..a9a46605 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -26,7 +26,7 @@ export interface PullRequestGateway { * 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[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null>; + 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. @@ -208,7 +208,7 @@ export class GhPullRequestGateway implements PullRequestGateway { 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[] }, signal?: AbortSignal): Promise<(OpenedPullRequest & { marker: string }) | null> { + 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); @@ -221,6 +221,9 @@ export class GhPullRequestGateway implements PullRequestGateway { 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.`); 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]! }; } diff --git a/runner/publish.ts b/runner/publish.ts index c22e4517..e2035da6 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -132,7 +132,10 @@ export class PullRequestPublisher { // 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)) }, signal); } + 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, diff --git a/test/publish.test.ts b/test/publish.test.ts index ae5c190c..19112704 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -75,12 +75,15 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: // be the task's; anyone else's PR into another base is ignored. const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); const baseOfPr = (m: string) => bases.get(m) ?? publishConfig.baseBranch; - const own = visible.filter(([m]) => input.markers.includes(m)); + // `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)); if (own.length > 1) throw new PullRequestMisplaced(`More than one of the task's pull requests is open (${own.map(([, pr]) => `#${pr.number}`).join(', ')}).`); if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new PullRequestMisplaced(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![0])}, not ${input.base}.`); const open = visible.find(([m]) => baseOfPr(m) === input.base); if (!open) return null; - if (!input.markers.includes(open[0])) throw new Error('An open pull request exists that codeboost did not open.'); + 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.`); + 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) { @@ -1387,6 +1390,14 @@ describe('GitHub PR adapter', () => { 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/); + 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/); @@ -1994,6 +2005,14 @@ describe('shutdown and PRs left ready', () => { 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]) }); + await expect(again.publisher.publish(identity)).rejects.toThrow(/#100 no longer starts with its marker/); + expect(again.log.some(line => line.startsWith('push') || line.startsWith('refresh'))).toBe(false); + }); 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 }; From e22ec2a1753421eb1d2ab9d7f7bb9e4d6080c108 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:51:43 -0700 Subject: [PATCH 55/65] Close independent review round 31 on F2d: unmarked PRs, faithfully Round 31 found no high or medium defects. - The test harness let the draft step find and draft a PR whose marker a person removed, which the real gateway cannot: findOwned matches by first-line marker and markDraft's read-back needs it. The fake now does the same, and the test asserts what really happens: no draft, the record still ready. - The refusal for such a PR says it may still be ready for review, as the list-lag path already did. - findOpened's interface comment describes `numbers`. Co-Authored-By: Claude Opus 5.5 --- github/pull-requests.ts | 5 +++-- test/publish.test.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/github/pull-requests.ts b/github/pull-requests.ts index a9a46605..85a61ada 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -22,7 +22,8 @@ export interface PullRequestGateway { /** * 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. The task's own PRs (those carrying one of `markers`) must be at most one, and into `base`: its own PR in + * 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. */ @@ -223,7 +224,7 @@ export class GhPullRequestGateway implements PullRequestGateway { 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.`); + 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]! }; } diff --git a/test/publish.test.ts b/test/publish.test.ts index 19112704..a9576c91 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -98,7 +98,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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 }] : []; - return [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && input.markers.includes(m)) + return [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && !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) { @@ -106,6 +106,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: // 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'); @@ -1395,7 +1397,7 @@ describe('GitHub PR adapter', () => { .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/); + 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 () => { @@ -2010,8 +2012,11 @@ describe('shutdown and PRs left ready', () => { await harness(store, { live, next }).publisher.publish(identity); requeue(store); const again = harness(store, { live, next, unmarked: new Set([100]) }); - await expect(again.publisher.publish(identity)).rejects.toThrow(/#100 no longer starts with its marker/); + await expect(again.publisher.publish(identity)).rejects.toThrow(/#100 no longer starts with its marker.*may still be ready for review/); 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('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(); From 08a840211925f35b5ca549078388d64621e0cf1e Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 19:52:15 -0700 Subject: [PATCH 56/65] Fix the unmarked-PR publisher test pushed failing in e22ec2a e22ec2a asserted the gateway's new message text through the test harness, whose fake lookup does not produce it, so the test failed. The publisher test now checks the error class and what the publisher does (no push, no draft, the record still ready); the message is tested at the gateway. The fake mirrors the message anyway. Co-Authored-By: Claude Opus 5.5 --- test/publish.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/publish.test.ts b/test/publish.test.ts index a9576c91..c42c1ab6 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -82,7 +82,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new PullRequestMisplaced(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![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.`); + 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] }; }, @@ -2012,7 +2012,8 @@ describe('shutdown and PRs left ready', () => { await harness(store, { live, next }).publisher.publish(identity); requeue(store); const again = harness(store, { live, next, unmarked: new Set([100]) }); - await expect(again.publisher.publish(identity)).rejects.toThrow(/#100 no longer starts with its marker.*may still be ready for review/); + // 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); From d49d7137c5be09096a2d9d384e0cd707769fde55 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 20:05:11 -0700 Subject: [PATCH 57/65] Close independent review round 32 on F2d: a more faithful fake GitHub Round 32 found no code defects; it found places where the test harness was more forgiving than GitHub: - The fake refresh now fails, like the adapter's read-back, when GitHub does not apply the requested draft or ready change, and refuses a closed PR. The test that relied on a refresh returning a ready PR for a draft request now asserts what really happens: the publish fails, the update stays in flight, and the next publish settles it and makes the PR a draft. - A PR whose branch a person renamed is no longer listed under the task's branch by the fake lookups. Doc: the check counting the task's own abandoned PR when GitHub's list lags its timeline past the settle time (fails closed). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 ++ test/publish.test.ts | 25 +++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 89491103..4218b1a4 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -35,6 +35,8 @@ The check matches when any of these is true: 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index c42c1ab6..41e5c148 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -73,7 +73,8 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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. - const visible = [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m)); + // 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); @@ -98,7 +99,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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 }] : []; - return [...live].filter(([m, pr]) => !closed.has(pr.number) && !options.hidden?.has(m) && !options.unmarked?.has(pr.number) && input.markers.includes(m)) + return [...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) { @@ -121,8 +122,13 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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); return pr; + 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); } }; @@ -860,12 +866,17 @@ describe('recovering a lost opening', () => { 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'); - // Even if GitHub still reports the PR as ready, a needs-human task never moves to in review, and its PR is made a draft. + // 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'); - const last = harness(store, { live, next, draftAfterRefresh: false }); - expect(await last.publisher.publish(identity, { problems: ['x'] })).toMatchObject({ draft: true, status: 'needs human' }); - expect(last.log.at(-1)).toBe('draft 100'); + 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 }; From 2ab52a099fcd77381bf0f55eac54fbe46de901fe Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 20:26:34 -0700 Subject: [PATCH 58/65] Close independent review round 33 on F2d: abort tests for two lookups Round 33 found no defects. Tests now fail if the abort checks after recovery's lookup or after the direct read of a PR the list does not show are removed: an abort there leaves the lost opening owned with the task's version unchanged, and draws no draft change or push. Co-Authored-By: Claude Opus 5.5 --- test/publish.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/test/publish.test.ts b/test/publish.test.ts index 41e5c148..a3986653 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,7 +39,7 @@ 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 } = {}) { + 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 } = {}) { const publishConfig = { ...config, ...options.config }; // The task's branch, as the publisher names it (one task per harness). let publishBranch = ''; @@ -90,7 +90,7 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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}`); + 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: '' }; @@ -2030,6 +2030,27 @@ describe('shutdown and PRs left ready', () => { expect(again.log.some(line => line.startsWith('draft'))).toBe(false); expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, 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); + }); 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 }; From d97da73c8c47a4789aaacca736f559516bb474d8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 20:54:10 -0700 Subject: [PATCH 59/65] Close independent review round 34 on F2d: three tests, one scope note Round 34 found no defects. Tests now fail if these break: the abort check after the draft change a match makes (no match recorded after a cancel), a non-array GraphQL `errors` field failing closed, and lowercase gh-N mentions in base commits. Doc: repository renames and transfers are out of this slice's scope (publish fails closed). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 ++ test/already-fixed.test.ts | 4 ++-- test/publish.test.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 4218b1a4..cfe9bb02 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -118,6 +118,8 @@ Some repositories do not support draft PRs (for example private repositories on ## 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. diff --git a/test/already-fixed.test.ts b/test/already-fixed.test.ts index 4ca9e244..92f15d0c 100644 --- a/test/already-fixed.test.ts +++ b/test/already-fixed.test.ts @@ -56,7 +56,7 @@ describe('the timeline query', () => { 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', 'owner/repo#12', 'See https://github.com/Owner/Repo/issues/12 for context']) + 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']) 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']) expect(mentionsIssue(message, repo, 12), message).toBe(false); @@ -178,7 +178,7 @@ describe('the pre-PR already-fixed check', () => { { 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' }] }, { nameWithOwner: 'other/repo' }, { baseRef: 'other' }, + { 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/ }, diff --git a/test/publish.test.ts b/test/publish.test.ts index a3986653..7f219f9f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -2030,6 +2030,17 @@ describe('shutdown and PRs left ready', () => { 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('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(); From 7c5ca43453261db7a7fa60345955a40c734d59f6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 21:56:18 -0700 Subject: [PATCH 60/65] Address Copilot round 11 on F2d: full pages, malformed entries, approvals - The branch PR list fails closed on a full page of 100: a later page could hide the task's PR, and this lookup decides whether the branch is clear to push to. - Every list entry is validated (number, body null or string, base ref) before any is set aside as someone else's, so a partial answer cannot look like a clear branch. - The draft change for a moved PR found by the direct read, and recovery's draft change for a lost draft opening, re-read the task right before the call: a task approved meanwhile keeps its PR ready, since GitHub does not merge a draft. The draft step uses the same check (#mayKeepReady). - Tests for each; doc step 3. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 9 +++++++ runner/publish.ts | 19 +++++++++----- test/publish.test.ts | 29 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index cfe9bb02..af20e2a8 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -58,7 +58,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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. 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. +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 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. 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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index 85a61ada..b68e5f1b 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -178,6 +178,15 @@ export class GhPullRequestGateway implements PullRequestGateway { 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.`); + // 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.body !== null && typeof pr.body !== 'string') || typeof pr.base?.ref !== 'string') + throw new Error('GitHub returned an invalid pull request list.'); + } return response; } diff --git a/runner/publish.ts b/runner/publish.ts index e2035da6..5e28833b 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -160,7 +160,9 @@ export class PullRequestPublisher { // 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.'; - if (pr.marker === marker(row.openingId)) { + // 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) { @@ -317,6 +319,12 @@ export class PullRequestPublisher { 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; @@ -375,7 +383,8 @@ export class PullRequestPublisher { // 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. - if (lost.draft && draft && !pr.draft) { + // 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; @@ -410,11 +419,7 @@ export class PullRequestPublisher { */ 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 = () => { - const status = this.#store.getTask(identity).status; - // An approved task's PR must stay ready: GitHub does not merge a draft. - return MERGEABLE_STATUSES.includes(status) || status === 'merged' || (!misplaced && this.#store.canPublish(identity, false)); - }; + 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); diff --git a/test/publish.test.ts b/test/publish.test.ts index 7f219f9f..2d17b96f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1395,6 +1395,16 @@ describe('GitHub PR adapter', () => { 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/); + 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 }); @@ -2041,6 +2051,25 @@ describe('shutdown and PRs left ready', () => { 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(); From 3c91d8be4c63a7f8aabbb18028131eae558dbca1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 22:03:14 -0700 Subject: [PATCH 61/65] Follow the code review of 7c5ca43 on F2d - The list entry check requires a PR number of at least 1, as #pull does, and a test covers a missing or zero number (the check was untested). - A merged task keeps its PR ready in the draft step; the test that covered in review and approved covers merged too. - The full-page error says to close the PRs that are not needed. - Doc: the re-read before draft changes applies to recovery, the draft step and moved PRs; the head settle is the stated exception (it drafts a PR whose head on GitHub is not the one reviewed). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/pull-requests.ts | 4 ++-- test/publish.test.ts | 14 ++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index af20e2a8..5206f553 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -58,7 +58,7 @@ Only one publish runs per task at a time; a second one is refused. A publish who - **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 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. 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. +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. diff --git a/github/pull-requests.ts b/github/pull-requests.ts index b68e5f1b..20a38ec0 100644 --- a/github/pull-requests.ts +++ b/github/pull-requests.ts @@ -180,11 +180,11 @@ export class GhPullRequestGateway implements PullRequestGateway { 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.`); + 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.body !== null && typeof pr.body !== 'string') || typeof pr.base?.ref !== 'string') + 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; diff --git a/test/publish.test.ts b/test/publish.test.ts index 2d17b96f..0c313111 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { afterEach, describe, expect, it } from 'vitest'; +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'; @@ -1403,6 +1403,10 @@ describe('GitHub PR adapter', () => { // 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 () => { @@ -1593,11 +1597,13 @@ describe('shutdown and PRs left ready', () => { expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true }]); expect(store.getTask(identity).status).toBe('running'); }); - it('leaves the ready PR of an approved or in-review task alone, without asking GitHub', async () => { - for (const status of ['in review', 'approved but merge blocked'] as const) { + 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); - if (status !== 'in review') store.transitionTask(identity, store.getTask(identity).stateVersion, status); + // 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([]); From 683f5380b1d3e64372885b5c0d2a6e6a6ae411a8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 22:14:39 -0700 Subject: [PATCH 62/65] Address Copilot round 12 on F2d: fail closed on ambiguous markers Recovery picked the first PR GitHub listed among those carrying a marker. A marker is editable text, so a copied description could make recovery record the wrong PR, or silently clear an unconfirmed update whose only matching PR had another number. Recovery now refuses with PullRequestMisplaced, keeping the update or opening in flight, when more than one open PR carries the update's or the lost opening's marker, or when the one carrying an update's marker is not the recorded PR. Tests for each; the earlier test that expected the mismatched number to be ignored now expects the refusal. Doc step 1. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- runner/publish.ts | 14 +++++++--- test/publish.test.ts | 30 +++++++++++++++++---- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 5206f553..cb7f3744 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -52,7 +52,7 @@ A cancelled check throws. It does not return `unknown`. 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. 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). +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 recovery fails closed with `PullRequestMisplaced` when it is ambiguous: more than one open PR carrying the update's or the lost opening's marker (a copied description), or the only one carrying an update's marker having another number than the recorded PR. The update or opening stays in flight until a person closes the copies or restores their first lines. 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. diff --git a/runner/publish.ts b/runner/publish.ts index 5e28833b..3f894e7f 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -41,6 +41,7 @@ export type PublishOutcome = | { kind: 'no changes'; leftReady?: number }; const marker = (openingId: string) => ``; +const ambiguous = (prs: readonly { number: number }[]) => `Pull requests ${prs.map(pr => `#${pr.number}`).join(', ')}`; /** * 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. @@ -350,9 +351,13 @@ export class PullRequestPublisher { 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. - const [observed = null] = await this.#pulls.findOwned({ headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); + const found = await this.#pulls.findOwned({ headBranch: refreshing.headBranch, markers: [marker(refreshing.openingId)] }, signal); signal?.throwIfAborted(); - this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, observed); + // A marker is editable text: another PR carrying it (a copied description) makes the observation ambiguous, so the + // update stays in flight and a person decides, rather than GitHub's list order picking which PR is recorded. + if (found.length > 1 || (found.length === 1 && found[0]!.number !== refreshing.number)) + throw new PullRequestMisplaced(`${ambiguous(found)} carry the first line of the task's pull request #${refreshing.number}. Close the copies or restore their first lines.`); + this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, found[0] ?? null); } // Read once: the settlements above are the only writes before this point. const prs = this.#store.taskPullRequests(identity); @@ -363,7 +368,10 @@ export class PullRequestPublisher { // 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 pr = owned.find(candidate => candidate.marker === marker(lost.openingId)); + const mine = owned.filter(candidate => candidate.marker === marker(lost.openingId)); + // Two PRs carrying the lost opening's marker (a copied description): which one it opened is unknown, so it stays owned. + if (mine.length > 1) throw new PullRequestMisplaced(`${ambiguous(mine)} carry the first line of a pull request the task was opening. Close the copies or restore their first lines.`); + 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 diff --git a/test/publish.test.ts b/test/publish.test.ts index 0c313111..fd6723f4 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -39,7 +39,7 @@ 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 } = {}) { + 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 = ''; @@ -100,7 +100,9 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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 }] : []; return [...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 })); + .map(([m, pr]) => ({ ...pr, marker: m, base: bases.get(m) ?? publishConfig.baseBranch })) + // `copies`: other PRs whose description a person started with one of the task's markers. + .concat((options.copies ?? []).filter(copy => input.markers.includes(copy.marker)).map(copy => ({ ...copy, base: publishConfig.baseBranch }))); }, async markDraft(number, input) { log.push(`draft ${number}`); @@ -486,7 +488,7 @@ describe('guards found by the independent review', () => { 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 a different PR number as what GitHub shows for an unconfirmed update', async () => { + it('does not take a different PR number as what GitHub shows for an unconfirmed update: it refuses and keeps it 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'] }); @@ -494,8 +496,8 @@ describe('guards found by the independent review', () => { await expect(harness(store, { live, next, refreshFails: true }).publisher.publish(identity)).rejects.toThrow('timeout'); for (const [m, pr] of live) live.set(m, { ...pr, number: 999, draft: false, headSha: oid(8) }); store.cancelTask(identity, store.getTask(identity).stateVersion, crypto.randomUUID()); - await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/cancelled/); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, headSha: oid(2), refresh: null }]); + await expect(harness(store, { live, next }).publisher.publish(identity)).rejects.toThrow(/#999 carry the first line of the task's pull request #100/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, headSha: oid(2), refresh: expect.anything() }]); }); 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 }; @@ -2097,6 +2099,24 @@ describe('shutdown and PRs left ready', () => { expect(read.message).toBe('cancelled'); expect(reading.log.some(line => line.startsWith('draft') || line.startsWith('push'))).toBe(false); }); + it('fails closed when a copied marker makes recovery ambiguous, keeping the update or opening in flight', async () => { + const copy = (live: Map) => { const [m, pr] = [...live][0]!; return { ...pr, number: 999, marker: m }; }; + // A lost update, with a copy of its PR's first line on another PR: refused, the update stays in flight. + let 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/); + await expect(harness(store, { live, next, copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#100, #999 carry the first line of the task's pull request #100/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); + // Only the copy is visible: its number is not the recorded PR's, so nothing is settled from it. + await expect(harness(store, { live, next, hidden: new Set(live.keys()), copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#999 carry/); + expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); + // A lost opening with two PRs carrying its marker: it stays owned. + store = runningTask(); live = new Map(); next = { value: 100 }; + await expect(harness(store, { live, next, openTimesOut: true }).publisher.publish(identity)).rejects.toThrow('timeout'); + await expect(harness(store, { live, next, copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#100, #999 carry the first line of a pull request the task was opening/); + 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 }; From 4cc74ca15ea5934ba43e3b5d449f13b8dfd39d06 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 23:35:07 -0700 Subject: [PATCH 63/65] Follow the code review of 683f538 on F2d: settle updates by number The review of 683f538 found that refusing a lost update when a copy carries its marker was a regression: the refusal ran before the draft step, so a stopped task's ready PR stayed ready for as long as anyone kept a copy open. An update's PR has a recorded number, which cannot be edited, so recovery now settles it from the PR with that number, whatever order GitHub lists them in, and leaves copies to the main path (two own PRs: drafted, refused) and the draft step. The Store's own number check already kept a copy from being recorded; this makes the publisher's choice explicit too. A lost opening has no recorded number, so two PRs carrying its marker still make recovery refuse; the message now says which to close and that the task's PR may still be ready. Tests: copies are into another base and listed first by the fake (one open PR per branch and base; GitHub's order is undefined); the combined test is split; a stopped task's PR is drafted with a copy present; the update's own PR's draft flag is recorded with a copy listed first; the older test models a closed PR replaced by a copy. Doc step 1. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- runner/publish.ts | 17 +++-- test/publish.test.ts | 78 +++++++++++++++------ 3 files changed, 64 insertions(+), 33 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index cb7f3744..9ae8e8c6 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -52,7 +52,7 @@ A cancelled check throws. It does not return `unknown`. 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 recovery fails closed with `PullRequestMisplaced` when it is ambiguous: more than one open PR carrying the update's or the lost opening's marker (a copied description), or the only one carrying an update's marker having another number than the recorded PR. The update or opening stays in flight until a person closes the copies or restores their first lines. 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). +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. diff --git a/runner/publish.ts b/runner/publish.ts index 3f894e7f..83aa9ff3 100644 --- a/runner/publish.ts +++ b/runner/publish.ts @@ -41,7 +41,7 @@ export type PublishOutcome = | { kind: 'no changes'; leftReady?: number }; const marker = (openingId: string) => ``; -const ambiguous = (prs: readonly { number: number }[]) => `Pull requests ${prs.map(pr => `#${pr.number}`).join(', ')}`; +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. @@ -350,14 +350,12 @@ export class PullRequestPublisher { 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. + // 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(); - // A marker is editable text: another PR carrying it (a copied description) makes the observation ambiguous, so the - // update stays in flight and a person decides, rather than GitHub's list order picking which PR is recorded. - if (found.length > 1 || (found.length === 1 && found[0]!.number !== refreshing.number)) - throw new PullRequestMisplaced(`${ambiguous(found)} carry the first line of the task's pull request #${refreshing.number}. Close the copies or restore their first lines.`); - this.#store.settleUnconfirmedRefresh(identity, refreshing.openingId, found[0] ?? null); + 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); @@ -369,8 +367,9 @@ export class PullRequestPublisher { 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): which one it opened is unknown, so it stays owned. - if (mine.length > 1) throw new PullRequestMisplaced(`${ambiguous(mine)} carry the first line of a pull request the task was opening. Close the copies or restore their first lines.`); + // 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) { diff --git a/test/publish.test.ts b/test/publish.test.ts index fd6723f4..21e5018c 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -45,6 +45,10 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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] : []; @@ -78,9 +82,10 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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)); - if (own.length > 1) throw new PullRequestMisplaced(`More than one of the task's pull requests is open (${own.map(([, pr]) => `#${pr.number}`).join(', ')}).`); - if (own.length === 1 && baseOfPr(own[0]![0]) !== input.base) throw new PullRequestMisplaced(`The task's pull request #${own[0]![1].number} now targets ${baseOfPr(own[0]![0])}, not ${input.base}.`); + 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.`); @@ -99,10 +104,9 @@ function harness(store: Store, options: { results?: AlreadyFixedResult[]; open?: 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 }] : []; - return [...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 })) - // `copies`: other PRs whose description a person started with one of the task's markers. - .concat((options.copies ?? []).filter(copy => input.markers.includes(copy.marker)).map(copy => ({ ...copy, 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}`); @@ -488,16 +492,18 @@ describe('guards found by the independent review', () => { 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 a different PR number as what GitHub shows for an unconfirmed update: it refuses and keeps it in flight', async () => { + 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'); - for (const [m, pr] of live) live.set(m, { ...pr, number: 999, draft: false, headSha: oid(8) }); + // 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 }).publisher.publish(identity)).rejects.toThrow(/#999 carry the first line of the task's pull request #100/); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, draft: true, headSha: oid(2), refresh: expect.anything() }]); + 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 }; @@ -2099,22 +2105,48 @@ describe('shutdown and PRs left ready', () => { expect(read.message).toBe('cancelled'); expect(reading.log.some(line => line.startsWith('draft') || line.startsWith('push'))).toBe(false); }); - it('fails closed when a copied marker makes recovery ambiguous, keeping the update or opening in flight', async () => { - const copy = (live: Map) => { const [m, pr] = [...live][0]!; return { ...pr, number: 999, marker: m }; }; - // A lost update, with a copy of its PR's first line on another PR: refused, the update stays in flight. - let store = runningTask(), live = new Map(), next = { value: 100 }; + /** 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/); - await expect(harness(store, { live, next, copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#100, #999 carry the first line of the task's pull request #100/); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); - // Only the copy is visible: its number is not the recorded PR's, so nothing is settled from it. - await expect(harness(store, { live, next, hidden: new Set(live.keys()), copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#999 carry/); - expect(store.taskPullRequests(identity)).toMatchObject([{ number: 100, refresh: expect.anything() }]); - // A lost opening with two PRs carrying its marker: it stays owned. - store = runningTask(); live = new Map(); next = { value: 100 }; + 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'); - await expect(harness(store, { live, next, copies: [copy(live)] }).publisher.publish(identity)).rejects.toThrow(/#100, #999 carry the first line of a pull request the task was opening/); + 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 () => { From 2aaa4fd519dc4d41ff71ad44af564d35a1e90fa1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 1 Oct 2026 00:35:43 -0700 Subject: [PATCH 64/65] Address Copilot round 13 on F2d: no issue tokens inside URL paths The GH-N, owner/repo#N and issue-URL forms in mentionsIssue could start inside a URL path or a longer path-like token, so a base commit with `https://example.com/GH-12` or `example.com/github.com/owner/repo/ issues/12` counted as mentioning issue 12 and moved the task to possibly already fixed. Like `#N` already did, the three forms now need a start that is not a word character, `/`, `.` or `-`. Tests: four embedded forms are not mentions; a bracketed URL and `(GH-12)` still are. Co-Authored-By: Claude Opus 5.5 --- github/already-fixed.ts | 7 ++++--- test/already-fixed.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 8dd0de30..7e65df32 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -92,9 +92,10 @@ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export function mentionsIssue(message: string, repository: string, issue: number): boolean { const n = String(issue), repo = escape(repository); return new RegExp(`(? { 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']) + 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']) + 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']) expect(mentionsIssue(message, repo, 12), message).toBe(false); }); }); From bf8d2837b0075ef89b96673fc144b66cf3f65215 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 1 Oct 2026 00:38:46 -0700 Subject: [PATCH 65/65] Follow the code review of 2aaa4fd on F2d - Test: `example.com.GH-12` is not a mention (the `.` in the GH-N rule was untested). - The boundary rule moves into mentionsIssue's JSDoc, which covers all four forms, with the rare real mentions it drops. - Doc: the check table lists path-embedded tokens as not a match. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/pull-request-opening.md | 2 +- github/already-fixed.ts | 6 ++++-- test/already-fixed.test.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/implementation/pull-request-opening.md b/docs/implementation/pull-request-opening.md index 9ae8e8c6..8c729123 100644 --- a/docs/implementation/pull-request-opening.md +++ b/docs/implementation/pull-request-opening.md @@ -31,7 +31,7 @@ The check matches when any of these is true: |---|---|---| | 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 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. diff --git a/github/already-fixed.ts b/github/already-fixed.ts index 7e65df32..35ec68b6 100644 --- a/github/already-fixed.ts +++ b/github/already-fixed.ts @@ -87,12 +87,14 @@ 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. + * 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(`(? { 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']) + '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); }); });