Skip to content
Open
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
71 changes: 67 additions & 4 deletions docs/node-maintenance-operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ service. Repository maintainers install the shared App; they do not host it.
The receiver verifies signed webhook deliveries and reads canonical repository
metadata with a token restricted to one repository. For managed PRs it also
reads the canonical PR and displays the selected package versions in the App's
**Dependency repair** Check. A maintainer can request one harmless pilot run
through its native action. D1 retains the active request and returned Actions run
**Dependency repair** Check. A maintainer can select fixes in the PR description
and post `/repair selected` in the Conversation tab. The App replies there with
the request status and exact Actions link. The native Check action remains an
optional secondary entry point. D1 retains the active request and returned Actions run
identity. Dependency repairs and completion handling belong to later slices.

## Check the selected batch
Expand All @@ -34,13 +36,43 @@ only Contents read, Pull requests read and Checks write. Action-event tokens
also grant Actions write, restricted to the same repository. The service never
updates another App's Check.

## Request the selected batch
## Request the selected batch from the PR conversation

The report includes the head and base revisions in one hidden line outside the
selection block (replace these illustrative hashes with the exact PR revisions):

```markdown
<!-- node-repair:revision:v1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb -->
```

Tick the allowed fixes in the PR description, then post a new comment containing
only `/repair selected`. Use GitHub's normal **Comment** button to submit it.
Neither ticking a checkbox nor editing an existing comment starts an attempt.
There is no need to visit Checks. Request status appears in an App-owned reply;
supporting Check details remain available separately.

The receiver accepts only a newly created human comment on a managed PR. It
re-fetches the comment and verifies its author, exact body, PR association and
unchanged creation/update timestamps. It checks current maintainer permission,
the selections captured in the signed comment event, the report's head/base
binding, and the current canonical PR/Check fingerprint. A stale report or
selection starts no run and produces a reply asking for a fresh command.
Unrelated comments and edits are ignored. Missing or edited/deleted commands
and unmanaged PRs do not authorize an attempt.

For this unreleased integration, reports/pilot descriptions must include the
revision line. Existing reports without it cannot authorize comment dispatch.
The production report renderer is still a later slice; it is not deployed.

### Existing Check action

Review the exact versions in the Check summary, then click **Run selected fixes**.
The service reads current write, maintain or admin permission and verifies the
canonical PR and Check again. A stale selection or head refreshes the Check and
requires another click; it starts no run.

### Shared admission and recovery

The request UUID is the signed GitHub delivery ID. A single D1 insert reserves
one active request per installation, repository and PR before dispatch. Replayed
deliveries and competing clicks cannot admit another active request. The Check
Expand All @@ -54,6 +86,37 @@ in the Check. The fixture in `repair-app/pilot/` validates those inputs without
checking out code or changing dependency files. A successful pilot does not mean
a dependency repair was published.

Comment requests additionally retain the source comment ID, reply ID and exact
selections. A unique source-comment index prevents the same comment from
authorizing a second attempt, even under a different delivery UUID. Only the
winning reservation creates a status reply. Its ID is saved before dispatch;
a lost reply POST or failed save retains the lock and starts no run. Replays
update only the recorded reply owned by this App on this PR. They never retry
an ambiguous reply POST. PR refreshes use read-only PR tokens and do not edit
conversation replies.

The control Check stays completed/neutral while its text changes. Real GitHub
did not reopen the previously completed Check when asked for `in_progress`.
The Actions link, and later canonical completion handling, supply run state.

## Enable comment delivery after review

This change is local and unreleased. Before testing the reviewed revision live:

1. Add **Issues: read** and change **Pull requests** to **read/write** on the App.
Subscribe to **Issue comment** events. Existing installations must accept the
new permissions. Keep Contents read, Checks write and Actions write.
2. For an existing slice-3 database, apply
`repair-app/migrations/0001_comment_requests.sql` exactly once. It preserves
all existing request rows and active locks. New databases use `schema.sql`
instead; do not apply both initial schema and migration to one database.
3. Deploy the reviewed Worker. Put the correct revision marker and selections
in the pilot description, then verify a real command, exact inputs, reply
link, replay and stale-command rejection against the harmless fixture.

No live permission, database, deployment or repository change is performed by
these instructions. The local/native tests cannot replace that acceptance.

## Check the receiver locally

Use Node 24 and run these commands from `repair-app/`:
Expand Down Expand Up @@ -97,7 +160,7 @@ with the exact request inputs, repository, workflow and actor.

Completion updates and releasing finished requests arrive in slice 4. Until then,
even a finished harmless pilot keeps its active lock. Do not describe its
in-progress Check as proof that Actions is still running.
neutral Check as proof that Actions is still running.

Keep App credentials in operator storage and Worker secrets. Keep verification
receipts outside the repository, without credentials or webhook payloads. A
Expand Down
14 changes: 14 additions & 0 deletions docs/node-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@ It also checks security and still obeys the cooldown and upgrade scope. It does
not enable automatic upgrades. Nothing merges automatically. See
[reports and PR behavior](../node/maintenance.md#reports-and-prs) for details.

## Interactive repairs (unreleased)

The repair integration under development keeps selection and submission in the
PR's **Conversation** tab. Tick the fixes you allow in the PR description, then
post a new comment containing only `/repair selected` using **Comment**. The App
replies with the request status and the exact Actions link. Checkbox edits alone
start no work. The Checks tab is optional for this flow.

This requires an installed repair App, a current report that supports comment
requests, and the fixed repair workflow. The current pilot only validates inputs;
it changes no dependencies. Completion reporting and the production repair
renderer are still later delivery slices. Nothing merges automatically.
See the [service reference](node-maintenance-operator.md) for current limits.

## Toolchain selection

The basic setup follows project declarations. Explicit or inherited constraints
Expand Down
8 changes: 8 additions & 0 deletions repair-app/migrations/0001_comment_requests.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Apply once to an existing slice-3 database before deploying comment support.
-- New databases use schema.sql, which already includes these columns/index.
ALTER TABLE requests ADD COLUMN comment_id INTEGER CHECK (comment_id > 0);
ALTER TABLE requests ADD COLUMN status_comment_id INTEGER CHECK (status_comment_id > 0);
ALTER TABLE requests ADD COLUMN selections_json TEXT;
CREATE UNIQUE INDEX one_comment_request
ON requests (installation_id, repository_id, comment_id)
WHERE comment_id IS NOT NULL;
38 changes: 37 additions & 1 deletion repair-app/runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ const nativeCheck = {id: 77, app: {id: 42}, name: 'Dependency repair', head_sha:
external_id: `node-repair:12:${nativeFingerprint}`, status: 'completed', conclusion: 'neutral'};
const documentedCheck = value => { const response = {...value}; delete response.actions; return response; };
const nativeCalls = [];
const commandComment = {id: 501, body: '/repair selected', user: {id: 9, type: 'User', login: 'maintainer'},
issue_url: 'https://api.github.com/repos/fixture/repo/issues/12',
created_at: '2026-10-24T05:00:00Z', updated_at: '2026-10-24T05:00:00Z'};
let statusComment;
const admissionService = new Miniflare(convertV4MiniflareOptions({
modules: true, scriptPath: new URL('./worker.mjs', import.meta.url).pathname,
compatibilityDate: '2026-10-19', d1Databases: ['DB'],
Expand All @@ -108,6 +112,15 @@ const admissionService = new Miniflare(convertV4MiniflareOptions({
if (path === '/repos/fixture/repo') return Response.json({id: 7, full_name: 'fixture/repo', default_branch: 'main'});
if (path === '/repos/fixture/repo/pulls/12') return Response.json(nativePR);
if (path === '/repos/fixture/repo/collaborators/maintainer/permission') return Response.json({permission: 'maintain', user: {login: 'maintainer'}});
if (path === '/repos/fixture/repo/issues/comments/501') return Response.json(commandComment);
if (path === '/repos/fixture/repo/issues/12/comments' && request.method === 'POST') {
statusComment = {id: 600, ...body, performed_via_github_app: {id: 42}, issue_url: commandComment.issue_url};
return Response.json(statusComment);
}
if (path === '/repos/fixture/repo/issues/comments/600') {
if (request.method === 'PATCH') Object.assign(statusComment, body);
return Response.json(statusComment);
}
if (path === '/repos/fixture/repo/check-runs/77') {
if (request.method === 'PATCH') Object.assign(nativeCheck, body);
return Response.json(request.method === 'GET' ? documentedCheck(nativeCheck) : nativeCheck);
Expand Down Expand Up @@ -153,7 +166,7 @@ try {
assert.equal(rows.results.length, 1);assert.equal(rows.results[0].state, 'dispatched');assert.equal(rows.results[0].run_id, 900);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 1);
assert.equal(nativeCheck.external_id, `node-repair-request:${rows.results[0].id}`);
assert.equal(nativeCheck.status, 'in_progress');assert.deepEqual(nativeCheck.actions, []);
assert.equal(nativeCheck.status, 'completed');assert.equal(nativeCheck.conclusion, 'neutral');assert.deepEqual(nativeCheck.actions, []);

// A queued A click must not admit the refreshed B selection or a changed base.
await db.exec('DELETE FROM requests');
Expand Down Expand Up @@ -190,6 +203,29 @@ try {
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 2);
assert.equal(JSON.parse(nativeCalls.findLast(c => c.path.endsWith('/dispatches')).body.inputs.selections)[0].to, '2.14.3');
console.log('Native D1 passed: concurrent atomic admission, UUID replay, scoped active locks, ambiguous-lock retention, complete clicked Check identity binding across stale selection/base changes, missing identity rejection, and one fresh exact dispatch.');

await db.exec('DELETE FROM requests');
Object.assign(nativeCheck, {external_id: `node-repair:12:${nativeFingerprint}`, status: 'completed', conclusion: 'neutral'});
nativePR.body += `\n<!-- node-repair:revision:v1 ${nativePR.head.sha} ${nativePR.base.sha} -->`;
const commentEvent = {action: 'created', installation: {id: 3}, repository: {id: 7, full_name: 'fixture/repo'},
issue: {number: 12, body: nativePR.body, pull_request: {url: 'https://api.github.com/repos/fixture/repo/pulls/12'}},
comment: commandComment, sender: commandComment.user};
const submit = id => {
const input = signed(commentEvent, 'issue_comment');input.headers['x-github-delivery'] = id;
return admissionService.dispatchFetch('https://worker.test/webhook', input);
};
const commandID = randomUUID();
assert.deepEqual((await Promise.all([submit(commandID), submit(randomUUID())])).map(r => r.status), [200, 200]);
assert.equal((await submit(commandID)).status, 200);
const commentRows = (await db.prepare('SELECT * FROM requests').all()).results;
assert.equal(commentRows.length, 1);assert.equal(commentRows[0].comment_id, 501);
assert.equal(commentRows[0].status_comment_id, 600);assert.equal(commentRows[0].run_id, 900);
assert.match(statusComment.body, /Workflow started/);assert.match(statusComment.body, /launch-editor/);
assert.match(statusComment.body, /actions\/runs\/900/);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 3);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/12/comments') && c.method === 'POST').length, 1);
assert.ok(nativeCalls.every(c => c.body?.status !== 'in_progress'));
console.log('Native conversation command passed: real local D1 deduplicates concurrent/replayed comments, retains one reply, and links the exact dispatched run without requiring Check navigation.');
} finally {
await admissionService.dispose();
}
8 changes: 7 additions & 1 deletion repair-app/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ CREATE TABLE IF NOT EXISTS requests (
run_id INTEGER CHECK (run_id > 0),
run_url TEXT,
html_url TEXT,
conclusion TEXT
conclusion TEXT,
comment_id INTEGER CHECK (comment_id > 0),
status_comment_id INTEGER CHECK (status_comment_id > 0),
selections_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_request
ON requests (installation_id, repository_id, pr)
WHERE state IN ('reserved', 'dispatched', 'unknown');
CREATE UNIQUE INDEX IF NOT EXISTS one_comment_request
ON requests (installation_id, repository_id, comment_id)
WHERE comment_id IS NOT NULL;
Loading
Loading