From d872599c221764ed07f1a4de00d9048b7219f4b6 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Wed, 19 Aug 2026 16:50:10 -0700 Subject: [PATCH] Enqueue PRs through the merge queue API --- .claude/skills/neutral-reconcile/SKILL.md | 8 +-- bin/neutral.js | 3 ++ ...ission-control-and-merge-queue.decision.md | 4 ++ ...1-explicit-merge-queue-enqueue.decision.md | 46 ++++++++++++++++ src/commands/enqueue.js | 54 +++++++++++++++++++ src/index.js | 1 + test/enqueue-command.test.js | 43 +++++++++++++++ 7 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 llp/0061-explicit-merge-queue-enqueue.decision.md create mode 100644 src/commands/enqueue.js create mode 100644 test/enqueue-command.test.js diff --git a/.claude/skills/neutral-reconcile/SKILL.md b/.claude/skills/neutral-reconcile/SKILL.md index 314d882..58738ce 100644 --- a/.claude/skills/neutral-reconcile/SKILL.md +++ b/.claude/skills/neutral-reconcile/SKILL.md @@ -565,10 +565,10 @@ which replies are new. human merge:** next tick the design LLP on `origin/` / `gh pr view --json state` = `MERGED` is the ground truth, not gh's exit code. - **`enqueue`** (terminal, only when both `automerge: true` and `mergeQueue: true` - — LLP 0060): `gh pr ready ` if still a draft, then - `gh pr merge --squash --match-head-commit `. The head guard prevents - a changed, unreviewed head from entering between observation and action. On a - queue-required target this adds the PR to GitHub's queue; it does not bypass it. + — LLP 0060/0061): `gh pr ready ` if still a draft, re-observe next tick, then + `neutral enqueue `. The command verifies the current head, passes it + again as GraphQL `expectedHeadOid`, and requires a returned queue entry. This adds + the PR to GitHub's queue; it does not use or require repository auto-merge. Next tick, a live GraphQL `mergeQueueEntry` makes the CLI return `wait` with `approved: true`. Do not merge the target into the branch, re-review, or re-enqueue while that entry exists. If GitHub removes the entry, the CLI re-opens the proper diff --git a/bin/neutral.js b/bin/neutral.js index 82c4565..4c43a89 100755 --- a/bin/neutral.js +++ b/bin/neutral.js @@ -13,6 +13,7 @@ import { issuesCommand } from '../src/commands/issues.js' import { idleCommand } from '../src/commands/idle.js' import { startCommand } from '../src/commands/start.js' import { observeCommand } from '../src/commands/observe.js' +import { enqueueCommand } from '../src/commands/enqueue.js' const USAGE = `neutral — declarative reconcilers for the LLP -> PR pipeline @@ -26,6 +27,7 @@ usage: neutral implementable [--json] Accepted designs merged to target, owed an implementation (LLP 0003) neutral ready [--json] the unblocked-open task queue for a change set neutral prs [--json] in-scope open PRs with the reconcilePR rung to act on + neutral enqueue add an approved exact head to GitHub's merge queue neutral issues [--json] open neutral:fix issues with their fix-attempt state neutral idle [--json] is the tick idle, and should it recycle context (LLP 0013) neutral llp [--json] inspect one LLP: metadata, role, coverage @@ -50,6 +52,7 @@ async function main(argv) { case 'implementable': return implementableCommand(repo, rest) case 'ready': return readyCommand(repo, rest) case 'prs': return prsCommand(repo, rest) + case 'enqueue': return enqueueCommand(repo, rest) case 'issues': return issuesCommand(repo, rest) case 'idle': return idleCommand(repo, rest) case 'llp': return llpCommand(repo, rest) diff --git a/llp/0060-admission-control-and-merge-queue.decision.md b/llp/0060-admission-control-and-merge-queue.decision.md index a56c153..6b7156b 100644 --- a/llp/0060-admission-control-and-merge-queue.decision.md +++ b/llp/0060-admission-control-and-merge-queue.decision.md @@ -71,6 +71,10 @@ precedence. At the reviewed-clean terminal, `enqueue` flips a draft ready and ru `gh pr merge --squash --match-head-commit `. On a queue-required target, GitHub adds the exact head to the queue and validates the synthesized merge group. +> **Extended-by [LLP 0061](0061-explicit-merge-queue-enqueue.decision.md):** a live +> queue exposed `gh pr merge` falling back to disabled auto-merge. The executor is +> now `neutral enqueue`, backed by GraphQL `enqueuePullRequest` with `expectedHeadOid`. + Queue membership is read from the Pull Request GraphQL `mergeQueueEntry` field. While an entry exists, the classifier returns `wait` with `approved: true`; it does not re-enqueue, merge the target into the branch, or repeat review. If GitHub drops diff --git a/llp/0061-explicit-merge-queue-enqueue.decision.md b/llp/0061-explicit-merge-queue-enqueue.decision.md new file mode 100644 index 0000000..01b17a8 --- /dev/null +++ b/llp/0061-explicit-merge-queue-enqueue.decision.md @@ -0,0 +1,46 @@ +# LLP 0061: Enqueue through the explicit merge-queue mutation + +**Type:** Decision +**Status:** Accepted +**Systems:** Engine, Engineer +**Author:** Phil / Codex +**Date:** 2026-08-19 +**Related:** 0002, 0019, 0060 + +## Context + +LLP 0060 specified `gh pr merge` as the executor for an `enqueue` action, following +the GitHub CLI manual. A live queue-required repository exposed a narrower behavior: +`gh pr merge` rejected an explicit merge strategy, then without the strategy tried +to call `enablePullRequestAutoMerge`. The repository intentionally had auto-merge +disabled because the merge queue, not auto-merge, owns landing. Both forms failed; +GitHub's dedicated GraphQL `enqueuePullRequest` mutation succeeded immediately and +returned the queue entry. + +Putting that mutation inline in agent prose would leave quoting, node-id lookup and +head validation to each run. Queue entry is a deterministic side effect and belongs +behind the deterministic CLI boundary. + +## Decision + +Add `neutral enqueue `. The command: + +1. reads the PR's GraphQL node id and current head SHA from GitHub; +2. refuses to act when the current head differs from the reviewed head supplied by + `neutral prs`; +3. calls `enqueuePullRequest` with both `pullRequestId` and `expectedHeadOid`; +4. requires GitHub to return a real `mergeQueueEntry`, then reports its id, position + and state. + +The reconcile skill executes this command only for the deterministic `enqueue` +action. A later observation still reads `mergeQueueEntry` independently as LLP 0060 +requires; the enqueue command's success output is not persisted or trusted as a +queue-membership ledger. + +## Consequences + +- Queue landing does not depend on the repository-level auto-merge switch. +- The reviewed head is protected both before the mutation and by GitHub's + `expectedHeadOid` check at mutation time. +- GraphQL quoting and response validation are unit-tested in the engine, while the + skill remains a small relay from `action=enqueue` to `neutral enqueue`. diff --git a/src/commands/enqueue.js b/src/commands/enqueue.js new file mode 100644 index 0000000..3b7de96 --- /dev/null +++ b/src/commands/enqueue.js @@ -0,0 +1,54 @@ +// @ts-check +// `neutral enqueue ` — execute one already-decided +// merge-queue admission with a double head guard. The rung decision remains in +// prhealth.js; this command only makes its GitHub side effect deterministic. +// @ref LLP 0061 [implements] +import { run } from '../git.js' + +/** + * @param {string} repo + * @param {number} number + * @param {string} expectedHeadOid + * @param {typeof run} [exec] + * @returns {Promise<{number: number, headSha: string, id: string, position: number, state: string}>} + */ +export async function enqueuePR(repo, number, expectedHeadOid, exec = run) { + const raw = await exec('gh', ['pr', 'view', String(number), '--json', 'id,headRefOid'], repo) + const pr = JSON.parse(raw) + const actual = String(pr.headRefOid || '') + if (!pr.id) throw new Error(`PR #${number} has no GraphQL node id`) + if (!expectedHeadOid || actual !== expectedHeadOid) { + throw new Error(`PR #${number} head changed: expected ${expectedHeadOid || '(missing)'}, observed ${actual || '(missing)'}`) + } + + const query = 'mutation($id:ID!,$oid:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$oid}){mergeQueueEntry{id position state}}}' + const queued = await exec('gh', ['api', 'graphql', '-f', `query=${query}`, '-F', `id=${pr.id}`, '-F', `oid=${expectedHeadOid}`], repo) + const entry = JSON.parse(queued)?.data?.enqueuePullRequest?.mergeQueueEntry + if (!entry?.id) throw new Error(`GitHub did not return a merge queue entry for PR #${number}`) + return { + number, + headSha: expectedHeadOid, + id: String(entry.id), + position: Number(entry.position || 0), + state: String(entry.state || 'UNKNOWN') + } +} + +/** + * @param {string} repo + * @param {string[]} args + * @param {typeof run} [exec] + * @returns {Promise} + */ +export async function enqueueCommand(repo, args, exec = run) { + const number = Number(args[0]) + const headSha = args[1] || '' + if (!Number.isInteger(number) || number <= 0 || !headSha) { + process.stderr.write('usage: neutral enqueue \n') + return 2 + } + const entry = await enqueuePR(repo, number, headSha, exec) + if (args.includes('--json')) process.stdout.write(JSON.stringify(entry, null, 2) + '\n') + else process.stdout.write(`enqueued PR #${entry.number} at position ${entry.position} state=${entry.state}\n`) + return 0 +} diff --git a/src/index.js b/src/index.js index 38d4499..1efae98 100644 --- a/src/index.js +++ b/src/index.js @@ -26,5 +26,6 @@ export { export { fixBranchName, fixedIssueNumbers, classifyIssue } from './issuefix.js' export { idleState } from './idle.js' export { admissionState } from './admission.js' +export { enqueuePR } from './commands/enqueue.js' export { contextSizeFromTranscript, usageOf, projectSlug, transcriptPath, readContextSize } from './context.js' export { newestUsageMs, classifySilence, incidentKey, sentinelStep, formatAlert, formatNag, formatRecovery } from './silence.js' diff --git a/test/enqueue-command.test.js b/test/enqueue-command.test.js new file mode 100644 index 0000000..ee7829a --- /dev/null +++ b/test/enqueue-command.test.js @@ -0,0 +1,43 @@ +// @ts-check +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { enqueuePR } from '../src/commands/enqueue.js' + +test('enqueuePR guards the reviewed head and calls the dedicated queue mutation (LLP 0061)', async () => { + /** @type {Array<{cmd: string, args: string[]}>} */ + const calls = [] + /** @type {import('../src/git.js').run} */ + const exec = async (cmd, args) => { + calls.push({ cmd, args }) + if (args[0] === 'pr') return JSON.stringify({ id: 'PR_node', headRefOid: 'abc1234' }) + return JSON.stringify({ data: { enqueuePullRequest: { mergeQueueEntry: { id: 'MQE_node', position: 2, state: 'QUEUED' } } } }) + } + + const got = await enqueuePR('/r', 7, 'abc1234', exec) + assert.deepEqual(got, { number: 7, headSha: 'abc1234', id: 'MQE_node', position: 2, state: 'QUEUED' }) + assert.deepEqual(calls[0], { cmd: 'gh', args: ['pr', 'view', '7', '--json', 'id,headRefOid'] }) + assert.equal(calls[1].cmd, 'gh') + assert.deepEqual(calls[1].args.slice(0, 2), ['api', 'graphql']) + assert.ok(calls[1].args.includes('id=PR_node')) + assert.ok(calls[1].args.includes('oid=abc1234')) + assert.match(calls[1].args.find(a => a.startsWith('query=')) || '', /expectedHeadOid:\$oid/) +}) + +test('enqueuePR refuses a moved head before mutating GitHub', async () => { + let calls = 0 + /** @type {import('../src/git.js').run} */ + const exec = async () => { + calls++ + return JSON.stringify({ id: 'PR_node', headRefOid: 'newhead' }) + } + await assert.rejects(() => enqueuePR('/r', 7, 'reviewedhead', exec), /head changed/) + assert.equal(calls, 1) +}) + +test('enqueuePR requires GitHub to return a queue entry', async () => { + /** @type {import('../src/git.js').run} */ + const exec = async (_cmd, args) => args[0] === 'pr' + ? JSON.stringify({ id: 'PR_node', headRefOid: 'abc1234' }) + : JSON.stringify({ data: { enqueuePullRequest: { mergeQueueEntry: null } } }) + await assert.rejects(() => enqueuePR('/r', 7, 'abc1234', exec), /did not return/) +})