Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/skills/neutral-reconcile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,10 @@ which replies are new.
human merge:** next tick the design LLP on `origin/<DEFAULT>` /
`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 <N>` if still a draft, then
`gh pr merge <N> --squash --match-head-commit <headSha>`. 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 <N>` if still a draft, re-observe next tick, then
`neutral enqueue <N> <headSha>`. 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
Expand Down
3 changes: 3 additions & 0 deletions bin/neutral.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -26,6 +27,7 @@ usage:
neutral implementable [--json] Accepted designs merged to target, owed an implementation (LLP 0003)
neutral ready <slug> [--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 <pr> <sha> 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 <number> [--json] inspect one LLP: metadata, role, coverage
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions llp/0060-admission-control-and-merge-queue.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ precedence. At the reviewed-clean terminal, `enqueue` flips a draft ready and ru
`gh pr merge <N> --squash --match-head-commit <headSha>`. 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
Expand Down
46 changes: 46 additions & 0 deletions llp/0061-explicit-merge-queue-enqueue.decision.md
Original file line number Diff line number Diff line change
@@ -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 <pr-number> <expected-head-sha>`. 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`.
54 changes: 54 additions & 0 deletions src/commands/enqueue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @ts-check
// `neutral enqueue <pr-number> <expected-head-sha>` — 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<number>}
*/
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 <pr-number> <expected-head-sha>\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
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
43 changes: 43 additions & 0 deletions test/enqueue-command.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading