@volter/supercode-orchestrator 0.5.228 → 0.5.229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/ci.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Review candidates are observed before a board mutation. No network or Git
|
|
2
2
|
// process runs inside the optimistic transaction. Deployment is not a completion gate.
|
|
3
|
-
import {
|
|
3
|
+
import { runtimeDoor } from './runtime-door.mjs';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
5
|
import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
|
5
6
|
import { dirname, join, relative } from 'node:path';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
8
10
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace, workspaceTask } from './workspace.mjs';
|
|
9
11
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
|
-
import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryUsed, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
|
|
12
|
+
import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryObservedAt, githubDiscoveryUsed, githubSourceFact, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
|
|
11
13
|
|
|
12
14
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
13
15
|
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
@@ -127,12 +129,20 @@ const actionReads = new AsyncLocalStorage();
|
|
|
127
129
|
/** Fresh observations belong to one action only; callers and later actions never share this snapshot. */
|
|
128
130
|
export const withFreshReviewReads = (fn) => actionReads.getStore() ? fn() : actionReads.run(new Map(), fn);
|
|
129
131
|
async function github(path, all = false, { purpose = 'validation' } = {}) {
|
|
130
|
-
const reads =
|
|
132
|
+
const reads = actionReads.getStore();
|
|
131
133
|
if (!reads) return githubUncached(path, all, { purpose });
|
|
132
134
|
const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${all}\0${path}`;
|
|
133
135
|
if (!reads.has(key)) reads.set(key, githubUncached(path, all, { purpose }));
|
|
134
136
|
return reads.get(key);
|
|
135
137
|
}
|
|
138
|
+
// Retain the provider's observation clock beside the returned object. Durable history
|
|
139
|
+
// watermarks must share GitHub updated_at's clock, including when this action coalesces a read.
|
|
140
|
+
const githubObservationTimes = new WeakMap();
|
|
141
|
+
function historyObservedAt(list) {
|
|
142
|
+
const at = githubObservationTimes.get(list);
|
|
143
|
+
if (!Number.isFinite(at)) throw new Error('GitHub history has no provider Date observation; its completeness is unknown');
|
|
144
|
+
return at;
|
|
145
|
+
}
|
|
136
146
|
async function wireRead(fn) {
|
|
137
147
|
const release = await admitGithubRead();
|
|
138
148
|
try { return await fn(); } finally { release(); }
|
|
@@ -146,7 +156,7 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
|
|
|
146
156
|
const context = admitGithubContext(rateKey);
|
|
147
157
|
if (!context) return { code: 'github_read_admission_full', error: 'eight GitHub origin/credential contexts are active; not requested' };
|
|
148
158
|
try {
|
|
149
|
-
let url = base + path, out = [];
|
|
159
|
+
let url = base + path, out = [], observedAt = Infinity;
|
|
150
160
|
for (let page = 0; url && page < 10; page += 1) {
|
|
151
161
|
// No await separates admission from fetch. Every page observes a
|
|
152
162
|
// refusal retained while an earlier page or another read was in flight.
|
|
@@ -162,15 +172,17 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
|
|
|
162
172
|
}
|
|
163
173
|
// An older in-flight success is not recovery from a newer refusal.
|
|
164
174
|
if (context.rateLimit && Date.now() >= context.rateLimit.until) context.rateLimit = null;
|
|
165
|
-
return { body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
|
|
175
|
+
return { observedAt: Date.parse(r.headers.get('date')), body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
|
|
166
176
|
});
|
|
167
177
|
if (pageRead.result) return pageRead.result;
|
|
168
178
|
const body = pageRead.body;
|
|
169
|
-
|
|
179
|
+
observedAt = Math.min(observedAt, pageRead.observedAt);
|
|
180
|
+
if (!all) return { body, observedAt };
|
|
170
181
|
out = out.concat(body);
|
|
171
182
|
url = pageRead.next;
|
|
172
183
|
}
|
|
173
|
-
|
|
184
|
+
if (url) throw new Error('GitHub list exceeds ten pages; source set remains unknown');
|
|
185
|
+
return { body: out, observedAt };
|
|
174
186
|
} finally {
|
|
175
187
|
context.readers -= 1;
|
|
176
188
|
if (!context.readers && !context.rateLimit) githubReadContexts.delete(rateKey);
|
|
@@ -188,16 +200,17 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
|
|
|
188
200
|
throw Object.assign(new Error(`GitHub: ${result.error.trim().slice(-500)}${nativeCode ? ` (${nativeCode})` : ''}`, { cause: result.cause }), result.rateLimit
|
|
189
201
|
? { code: 'github_rate_limited', status: result.status, retry_at: result.rateLimit.retryAt, deferred: true }
|
|
190
202
|
: result.code ? { code: result.code, deferred: result.code === 'github_read_admission_full' }
|
|
191
|
-
: result.cause?.code ? { code: result.cause.code, deferred: result.cause.
|
|
203
|
+
: result.cause?.code ? { code: result.cause.code, deferred: Boolean(result.cause.deferred), retry_at: result.cause.retry_at }
|
|
192
204
|
: result.status ? { status: result.status } : {});
|
|
193
205
|
}
|
|
206
|
+
if (result.body && typeof result.body === 'object') githubObservationTimes.set(result.body, result.observedAt);
|
|
194
207
|
return result.body;
|
|
195
208
|
}
|
|
196
209
|
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
197
210
|
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
198
211
|
/** A PR as the board reads it (gh's field names). */
|
|
199
212
|
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
200
|
-
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
213
|
+
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft), updatedAt: p.updated_at });
|
|
201
214
|
const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
202
215
|
const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
|
|
203
216
|
const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
|
|
@@ -212,13 +225,44 @@ const MERGED_TTL = 7 * 86400_000, CLOSED_TTL = 6 * 3600_000;
|
|
|
212
225
|
/** A long retention lengthened by up to a quarter, fixed for each key: entries one scan filled together (a cold strict
|
|
213
226
|
* scan reads an arc's every declared PR and earlier branch at once) end over that quarter, not in the same round. */
|
|
214
227
|
const spread = (ttl, key) => ttl + Number.parseInt(createHash('sha256').update(key).digest('hex').slice(0, 8), 16) % Math.floor(ttl / 4);
|
|
228
|
+
const sourceFact = async (key, value) => githubSourceFact(await discoveryIdentity(key), value);
|
|
229
|
+
const mergedFact = async (url, value) => {
|
|
230
|
+
const key = `merged:${pullRequestKey(url)}`;
|
|
231
|
+
const held = await sourceFact(key);
|
|
232
|
+
if (value?.state === 'MERGED') {
|
|
233
|
+
if (held && held.headRefOid !== value.headRefOid) throw new Error(`Inconsistent merged head for ${url}; source remains unknown`);
|
|
234
|
+
return sourceFact(key, value);
|
|
235
|
+
}
|
|
236
|
+
return held;
|
|
237
|
+
};
|
|
215
238
|
export async function discoverPullRequest(url, { fresh = false } = {}) {
|
|
239
|
+
const immutable = await mergedFact(url);
|
|
240
|
+
if (immutable) return immutable;
|
|
216
241
|
const key = pullRequestKey(url);
|
|
217
|
-
|
|
242
|
+
const pr = await discoveryRead(key, 60_000, async () => pullRequest(repositoryOf(url),
|
|
218
243
|
await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
|
|
219
244
|
{ fresh, ttlOf: pr => pr.state === 'MERGED' ? spread(MERGED_TTL, key) : pr.state === 'CLOSED' ? spread(CLOSED_TTL, key) : 60_000 });
|
|
245
|
+
if (pr.state === 'MERGED') await mergedFact(url, pr);
|
|
246
|
+
return pr;
|
|
220
247
|
}
|
|
221
248
|
|
|
249
|
+
/** The repository list precedes retained closed observations, so a reopened PR
|
|
250
|
+
* is visible without polling every queued PR through fresh validation. */
|
|
251
|
+
export async function discoverQueuedPullRequest(url) {
|
|
252
|
+
const immutable = await mergedFact(url);
|
|
253
|
+
if (immutable) return immutable;
|
|
254
|
+
const list = await openPullRequests(repositoryOf(url));
|
|
255
|
+
return list.find(pr => pr.number === numberOf(url)) ?? discoverPullRequest(url);
|
|
256
|
+
}
|
|
257
|
+
/** When the shared open-PR list of `url`'s repository was last read (ms), or 0: what a queued request whose
|
|
258
|
+
* admission failed for no reason of its own waits to see move (ADR 0252). */
|
|
259
|
+
export async function openListObservedAt(url) {
|
|
260
|
+
return githubDiscoveryObservedAt(await discoveryIdentity(`open:${repositoryOf(url).toLowerCase()}`));
|
|
261
|
+
}
|
|
262
|
+
// Every discovery caller shares this complete, paged five-minute list.
|
|
263
|
+
const openPullRequests = (repository, { fresh = false } = {}) => discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
|
|
264
|
+
(await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
|
|
265
|
+
|
|
222
266
|
/**
|
|
223
267
|
* Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
|
|
224
268
|
* longer that commit (409), so a head pushed after the verdict is never merged unreviewed. Answers
|
|
@@ -296,6 +340,27 @@ export function withCompletionEvidence(board, id, state, fn) {
|
|
|
296
340
|
}
|
|
297
341
|
}
|
|
298
342
|
|
|
343
|
+
/** Unsettled requests in FIFO order. Explicit replacements inherit the named request's slot; a settlement can
|
|
344
|
+
* give an already queued replacement that slot without rewriting either immutable request. */
|
|
345
|
+
export function pendingReviewRequests(db, id) {
|
|
346
|
+
return db.prepare(`SELECT q.id,q.payload,
|
|
347
|
+
MIN(COALESCE(json_extract(CASE WHEN json_valid(q.payload) THEN q.payload END,'$.queue_order'),q.id),
|
|
348
|
+
COALESCE((SELECT MIN(json_extract(CASE WHEN json_valid(s.payload) THEN s.payload END,'$.queue_order'))
|
|
349
|
+
FROM task_events s WHERE s.task_id=q.task_id AND s.kind='review_queue_settled'
|
|
350
|
+
AND json_extract(CASE WHEN json_valid(s.payload) THEN s.payload END,'$.replacement_event')=q.id),q.id)) AS queue_order
|
|
351
|
+
FROM task_events q WHERE q.task_id=? AND q.kind='review_queued'
|
|
352
|
+
AND NOT EXISTS (SELECT 1 FROM task_events settled WHERE settled.task_id=q.task_id
|
|
353
|
+
AND settled.kind IN ('review_queue_started','review_queue_settled')
|
|
354
|
+
AND json_extract(CASE WHEN json_valid(settled.payload) THEN settled.payload END,'$.queued_event')=q.id)
|
|
355
|
+
ORDER BY queue_order,q.id`).all(id).map(row => ({ ...JSON.parse(row.payload ?? '{}'), id: row.id, queue_order: row.queue_order }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The oldest retained review request not yet applied. The existing card event stream is its queue. */
|
|
359
|
+
export const pendingReviewRequest = (db, id) => pendingReviewRequests(db, id)[0] ?? null;
|
|
360
|
+
|
|
361
|
+
/** A PR's identity across spellings of its URL (a trailing slash, letter case). */
|
|
362
|
+
export const prIdentity = url => String(url).replace(/\/$/, '').toLowerCase();
|
|
363
|
+
|
|
299
364
|
/** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
|
|
300
365
|
export function reviewCandidate(board, id) {
|
|
301
366
|
const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
@@ -303,6 +368,48 @@ export function reviewCandidate(board, id) {
|
|
|
303
368
|
return payload?.ci?.pr || payload?.ci?.no_code || payload?.ci?.pr_reviewed?.length ? payload.ci : null;
|
|
304
369
|
}
|
|
305
370
|
|
|
371
|
+
/** A source declaration, as SQL over its `commented` event row `c`: `comment --pr` or `--pr none` (`sources`), and
|
|
372
|
+
* `--source` or `--source none` (`branches`). A PR disposition or release readback comment carries its one `--pr` too
|
|
373
|
+
* (engine.comment) but declares nothing: it is the comment whose evidence event (`pr_disposition_recorded`,
|
|
374
|
+
* `pr_release_readback`) is recorded after it in the same write, before the card's next comment. */
|
|
375
|
+
const declaringSql = (c) => `${c}.kind='commented' AND json_valid(${c}.payload) AND (json_type(${c}.payload,'$.branches')='array'
|
|
376
|
+
OR (json_type(${c}.payload,'$.sources')='array' AND NOT EXISTS (SELECT 1 FROM task_events k WHERE k.task_id=${c}.task_id
|
|
377
|
+
AND k.kind IN ('pr_disposition_recorded','pr_release_readback') AND k.id>${c}.id
|
|
378
|
+
AND k.id<COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id=${c}.task_id AND n.kind='commented' AND n.id>${c}.id), 9223372036854775807))))`;
|
|
379
|
+
|
|
380
|
+
/** The first source declaration (declaringSql) the arc made after event `sinceId` (every declaration when there is none),
|
|
381
|
+
* or null. A review request's frozen candidate no longer states the arc's declared set once one follows it, so the next
|
|
382
|
+
* review is built from the declarations (VO-3775 t_1daf732e: its PRs #2596 and #2606 moved to another card and the arc
|
|
383
|
+
* was re-declared, and every later reviewer run stayed bound to request 121444 naming them). */
|
|
384
|
+
export function redeclaredSince(db, id, sinceId) {
|
|
385
|
+
return db.prepare(`SELECT c.id FROM task_events c WHERE c.task_id=? AND c.id>? AND ${declaringSql('c')} ORDER BY c.id LIMIT 1`).get(id, sinceId ?? 0)?.id ?? null;
|
|
386
|
+
}
|
|
387
|
+
/** What the arc cleared after its latest review request, as a test of one of that request's PRs (`{ url, repository,
|
|
388
|
+
* branch }`): when it cleared its PR declarations (`comment --pr none`), the PR URLs it had itself declared (`--pr`)
|
|
389
|
+
* before that clear; when it cleared its source declarations (`comment --source none`), the PRs found on the
|
|
390
|
+
* `--source` branches it had declared before that clear and has not declared again since. The declarations since are
|
|
391
|
+
* the complete declared set, so those are not carried into it from that request's candidate; the other PRs the board
|
|
392
|
+
* discovered (a merged `wt/<card>` PR in a sibling repository, which discovery can no longer list) are. */
|
|
393
|
+
function clearedDeclarations(db, id) {
|
|
394
|
+
const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='review_requested'").get(id)?.id ?? 0;
|
|
395
|
+
const clearedAt = (field) => db.prepare(`SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND id>? AND kind='commented' AND json_valid(payload)
|
|
396
|
+
AND json_type(payload,'$.${field}')='array' AND json_array_length(payload,'$.${field}')=0`).get(id, asked)?.id;
|
|
397
|
+
const before = (field, at) => db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='commented' AND json_valid(payload)
|
|
398
|
+
AND json_type(payload,'$.${field}')='array'`).all(id, at).flatMap((row) => JSON.parse(row.payload)[field]);
|
|
399
|
+
const branchKey = (repository, branch) => `${repository.toLowerCase()}:${branch}`;
|
|
400
|
+
const urls = new Set(), branches = new Set();
|
|
401
|
+
const prs = clearedAt('sources');
|
|
402
|
+
if (prs) for (const source of before('sources', prs)) if (typeof source?.url === 'string') urls.add(source.url);
|
|
403
|
+
const sources = clearedAt('branches');
|
|
404
|
+
if (sources) {
|
|
405
|
+
const kept = new Set(arcSources(db, id).map((b) => branchKey(b.repository, b.branch)));
|
|
406
|
+
for (const b of before('branches', sources)) {
|
|
407
|
+
if (typeof b?.repository === 'string' && typeof b?.branch === 'string' && !kept.has(branchKey(b.repository, b.branch))) branches.add(branchKey(b.repository, b.branch));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return (source) => urls.has(source.url) || (typeof source.branch === 'string' && branches.has(branchKey(source.repository, source.branch)));
|
|
411
|
+
}
|
|
412
|
+
|
|
306
413
|
/** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
|
|
307
414
|
export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
|
|
308
415
|
// The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
|
|
@@ -376,18 +483,52 @@ async function recreatedWorktree(workspace, resolved) {
|
|
|
376
483
|
try { const made = await resolveWorkspace(workspace, true); return made.path === resolved.path && checkoutPresent(made.path); } catch { return false; }
|
|
377
484
|
}
|
|
378
485
|
|
|
379
|
-
/**
|
|
380
|
-
*
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
486
|
+
/** Seed a branch once, then discover subsequent closed/merged work with one repository
|
|
487
|
+
* listing, sorted by update time. The durable watermark survives cache expiry/restart.
|
|
488
|
+
* Open-list discovery independently follows branch reuse, including drafts. */
|
|
489
|
+
async function branchHistories(repository, branches, { fresh = false, admit }) {
|
|
490
|
+
const unique = [...new Set(branches)];
|
|
491
|
+
const snapshots = await readTogether(unique, async branch => {
|
|
492
|
+
const key = `branch-history:${repository}:${branch}`;
|
|
493
|
+
let held = await sourceFact(key);
|
|
494
|
+
if (!held) {
|
|
495
|
+
if (!await admit(`history:${repository}:${branch}`)) return null;
|
|
496
|
+
const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
|
|
497
|
+
const path = `/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`;
|
|
498
|
+
const list = await github(path, true, { purpose: fresh ? 'validation' : 'discovery' });
|
|
499
|
+
const at = historyObservedAt(list), prs = list.filter(p => p.merged_at).map(p => pullRequest(repository, p));
|
|
500
|
+
held = { at, prs };
|
|
501
|
+
await sourceFact(key, held);
|
|
502
|
+
for (const pr of prs) await mergedFact(pr.url, pr);
|
|
503
|
+
}
|
|
504
|
+
return { key, branch, ...held };
|
|
505
|
+
});
|
|
506
|
+
const held = snapshots.filter(Boolean);
|
|
507
|
+
if (!held.length) return [];
|
|
508
|
+
const cutoff = Math.min(...held.map(h => h.at)) - 2000;
|
|
509
|
+
if (fresh || Date.now() - cutoff > 300_000) {
|
|
510
|
+
let began = Infinity;
|
|
511
|
+
const updates = [];
|
|
512
|
+
let complete = false;
|
|
513
|
+
for (let page = 1; page <= 10; page++) {
|
|
514
|
+
const path = `/repos/${repository}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`;
|
|
515
|
+
const list = await github(path, false,
|
|
516
|
+
{ purpose: fresh ? 'validation' : 'discovery' });
|
|
517
|
+
began = Math.min(began, historyObservedAt(list));
|
|
518
|
+
if (!Array.isArray(list) || list.some(p => !Number.isFinite(Date.parse(p.updated_at)))) throw new Error(`Unreadable closed-PR history for ${repository}`);
|
|
519
|
+
updates.push(...list.filter(p => p.merged_at && Date.parse(p.updated_at) >= cutoff).map(p => pullRequest(repository, p)));
|
|
520
|
+
if (list.length < 100 || list.some(p => Date.parse(p.updated_at) < cutoff)) { complete = true; break; }
|
|
521
|
+
}
|
|
522
|
+
if (!complete) throw new Error(`Closed-PR history for ${repository} exceeds ten pages since its watermark; source set remains unknown`);
|
|
523
|
+
for (const snapshot of held) {
|
|
524
|
+
const prs = new Map(snapshot.prs.map(pr => [pr.url, pr]));
|
|
525
|
+
for (const pr of updates) if (pr.headRefName === snapshot.branch) prs.set(pr.url, pr);
|
|
526
|
+
snapshot.prs = [...prs.values()];
|
|
527
|
+
await sourceFact(snapshot.key, { at: began, prs: snapshot.prs });
|
|
528
|
+
}
|
|
529
|
+
for (const pr of updates) await mergedFact(pr.url, pr);
|
|
530
|
+
}
|
|
531
|
+
return held.flatMap(h => h.prs);
|
|
391
532
|
}
|
|
392
533
|
|
|
393
534
|
async function branchCheckout(board, id, repository, branch) {
|
|
@@ -438,14 +579,13 @@ function declarationsOf(db, id, field, adding = null) {
|
|
|
438
579
|
return declarations.slice(from);
|
|
439
580
|
}
|
|
440
581
|
|
|
441
|
-
/**
|
|
582
|
+
/** Stable membership identity: an equivalent declaration never changes the member's first declaration. */
|
|
442
583
|
function declaredBranches(db, id, adding = null) {
|
|
443
584
|
const out = [];
|
|
444
585
|
for (const declaration of declarationsOf(db, id, 'branches', adding)) for (const b of declaration.items) {
|
|
445
586
|
if (typeof b?.repository !== 'string' || typeof b?.branch !== 'string') continue;
|
|
446
587
|
const known = out.find((r) => r.repository.toLowerCase() === b.repository.toLowerCase() && r.branch === b.branch);
|
|
447
|
-
if (known)
|
|
448
|
-
else out.push({ repository: b.repository, branch: b.branch, at: declaration.id });
|
|
588
|
+
if (!known) out.push({ repository: b.repository, branch: b.branch, at: declaration.id });
|
|
449
589
|
}
|
|
450
590
|
return out;
|
|
451
591
|
}
|
|
@@ -458,20 +598,7 @@ export function arcSources(db, id, adding = null) {
|
|
|
458
598
|
return declaredBranches(db, id, adding).map(({ repository, branch }) => ({ repository, branch }));
|
|
459
599
|
}
|
|
460
600
|
|
|
461
|
-
|
|
462
|
-
* one arc that it does not already hold (declared PRs and earlier branches' histories; the rest are read on later
|
|
463
|
-
* rounds), and the share of the home's discovery budget (GITHUB_DISCOVERY_ATTEMPTS an hour, every arc's) past which it
|
|
464
|
-
* starts none: an arc's declarations add up for as long as it runs, and several arcs filling their caches at once
|
|
465
|
-
* (three at four reads a round would start 720 an hour) leave the rest of that budget to every arc's routine reads. */
|
|
466
|
-
const EARLIER_BRANCH_TTL = 6 * 3600_000, NEW_READS_PER_ROUND = 4, FILL_SHARE = 0.7;
|
|
467
|
-
|
|
468
|
-
/** The declared branches (`repository:branch`, the repository lower-cased) the arc's latest `--source` declaration does
|
|
469
|
-
* not name: work it declared earlier. Their merged history is read on a long cache (EARLIER_BRANCH_TTL), never skipped. */
|
|
470
|
-
function earlierDeclaredBranches(db, id) {
|
|
471
|
-
const declared = declaredBranches(db, id);
|
|
472
|
-
const last = Math.max(0, ...declared.map((b) => b.at));
|
|
473
|
-
return new Set(declared.filter((b) => b.at !== last).map((b) => `${b.repository.toLowerCase()}:${b.branch}`));
|
|
474
|
-
}
|
|
601
|
+
const NEW_READS_PER_ROUND = 4, FILL_SHARE = 0.7;
|
|
475
602
|
|
|
476
603
|
/** The PRs the arc declared (`comment --pr URL --pr-head SHA`), each at its latest declaration: every declaration since
|
|
477
604
|
* the latest `--pr none` adds to the ones before it, in any of the arc's repositories, on any run. Discovery reads each
|
|
@@ -484,6 +611,12 @@ export function declaredPullRequests(db, id) {
|
|
|
484
611
|
latest.delete(source.url);
|
|
485
612
|
latest.set(source.url, { url: source.url, head: source.head });
|
|
486
613
|
}
|
|
614
|
+
for (const row of db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND kind='pr_disposition_recorded' AND json_valid(payload) ORDER BY id`).all(id)) {
|
|
615
|
+
const source = JSON.parse(row.payload);
|
|
616
|
+
// Only a release-only PR is kept once closed: its release readback settles it. A closed PR left to merge can never
|
|
617
|
+
// merge, so it leaves the set as any closed declared PR does.
|
|
618
|
+
latest.set(source.url, { url: source.url, head: source.head, retained: source.disposition === 'release-only' });
|
|
619
|
+
}
|
|
487
620
|
return [...latest.values()];
|
|
488
621
|
}
|
|
489
622
|
|
|
@@ -528,25 +661,26 @@ export function declaredWorkBranches(db, id) {
|
|
|
528
661
|
return [...new Set([...declared, ...reviewed].filter(Boolean))];
|
|
529
662
|
}
|
|
530
663
|
|
|
531
|
-
/** Every PR of the arc, open (not a draft) or merged, at its current head,
|
|
532
|
-
* branch, but for one merged at a head its PR review passed (`pr_reviewed`, settled): the arc's final review request
|
|
664
|
+
/** Every PR of the arc, open (not a draft) or merged, at its current head, whatever its base, but for one merged at a head its PR review passed (`pr_reviewed`, settled): the arc's final review request
|
|
533
665
|
* names them all, and whether a review is due depends only on the heads no verdict judged (reviewDue). A PR review
|
|
534
666
|
* names one ready PR of them (requireCurrentReviewSet). A PR of the arc is one it declared (declaredPullRequests) or one whose head is the card's
|
|
535
667
|
* work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
|
|
536
668
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
537
669
|
* checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
|
|
538
670
|
* answers `{ waits }` saying so. The final review covers them all; the caller decides the transition. */
|
|
539
|
-
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false,
|
|
540
|
-
if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces,
|
|
671
|
+
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
|
|
672
|
+
if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
|
|
541
673
|
// Every authorization entry acquires the broad advisory scope, not only
|
|
542
674
|
// dispatch. An older receipt and a checkout now on main still have to see
|
|
543
675
|
// implicit wt/<card> PRs. Discovery failures leave that scope unknown.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
676
|
+
return withFreshReviewReads(async () => {
|
|
677
|
+
const scope = new Set(repositories);
|
|
678
|
+
await discoverReviewSources(board, id, { repositories, strict: true }, scope);
|
|
679
|
+
return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
|
|
680
|
+
});
|
|
547
681
|
}
|
|
548
682
|
|
|
549
|
-
async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false,
|
|
683
|
+
async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
|
|
550
684
|
const { own, registered, ownRepository, repositories: arcScope } = await arcRepositories(board, id);
|
|
551
685
|
// A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
|
|
552
686
|
// discovery cannot see; it never hides what discovery found. Its PRs are bounded by the arc's repositories (the card's
|
|
@@ -558,7 +692,27 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
558
692
|
// (volter-ai/volter#134 left four t_ef2aa6ce review requests with no word). A PR the review already includes is never refused.
|
|
559
693
|
// Every PR the arc declared, on any run and in any repository, is one of them (declaredPullRequests), not only its latest declaration's.
|
|
560
694
|
const declared = declaredPullRequests(board.db, id);
|
|
561
|
-
|
|
695
|
+
// A sibling source can leave the open list by merging between reviews. Keep the
|
|
696
|
+
// exact previously observed URL, including settled sources whose pass can be voided. A `--pr none` or `--source none`
|
|
697
|
+
// since that request re-declared the complete set: a PR the arc had declared, or one found on a `--source` branch it
|
|
698
|
+
// cleared, is not carried into it from the frozen candidate (VO-3775 kept PRs moved to another card); another one the
|
|
699
|
+
// board discovered is (clearedDeclarations).
|
|
700
|
+
const previous = reviewCandidate(board, id);
|
|
701
|
+
const dropped = clearedDeclarations(board.db, id);
|
|
702
|
+
const priorSources = [
|
|
703
|
+
...candidatePullRequests(previous).map(p => ({ url: p.pr.url, head: p.commit, repository: p.repository ?? repositoryOf(p.pr.url), branch: p.branch })),
|
|
704
|
+
...(previous?.pr_reviewed ?? []).map(p => ({ url: p.url, head: p.head, repository: repositoryOf(p.url), branch: p.branch })),
|
|
705
|
+
].filter(source => !dropped(source));
|
|
706
|
+
// The PRs only the frozen candidate carries, not a `--pr` declaration: `--pr none` does not drop them.
|
|
707
|
+
const carried = new Map();
|
|
708
|
+
for (const source of priorSources) if (!declared.some(p => p.url === source.url)) { declared.push({ url: source.url, head: source.head }); carried.set(source.url, source); }
|
|
709
|
+
const scopeWords = `not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch)`;
|
|
710
|
+
const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => {
|
|
711
|
+
const found = carried.get(url);
|
|
712
|
+
return refused('undeclared', url, found
|
|
713
|
+
? `${url} is in ${id}'s last review request${found.branch ? `, found on ${repository}:${found.branch}` : ''}, but ${repository} is now ${scopeWords}, so the review cannot include it: declare the repository on the arc again: comment ${id} "<why>" --source ${repository}:${found.branch ?? '<branch>'}. To leave it out instead, declare that branch with --source and then clear the arc's source declarations with --source none, which drops the PRs the last review found on each --source branch it clears, and declare again the sources the arc keeps.`
|
|
714
|
+
: `${url} is declared as a source of ${id}, but ${repository} is ${scopeWords}, so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch>, or clear the arc's PR declarations with --pr none and declare its PRs again without it.`);
|
|
715
|
+
}));
|
|
562
716
|
// A card with no repository of its own and none registered (scratch) has no discovery: every declared PR is outside.
|
|
563
717
|
if (!ownRepository && !registered.length) {
|
|
564
718
|
const outside = outsideArcRepositories(declared.map(source => source.url), arcScope);
|
|
@@ -573,7 +727,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
573
727
|
// Broad discovery finds wt/<card> work in sibling repositories. Authorization
|
|
574
728
|
// refreshes only this arc's repositories, including siblings already found or
|
|
575
729
|
// requested; an unrelated checkout is never a validation-purpose PR scan.
|
|
576
|
-
const previous = reviewCandidate(board, id);
|
|
577
730
|
const requested = [...candidatePullRequests(previous).map(p => p.repository ?? repositoryOf(p.pr.url)),
|
|
578
731
|
...(previous?.review_repositories ?? [])];
|
|
579
732
|
const siblings = fresh ? [] : await knownRepositories({ strict });
|
|
@@ -581,11 +734,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
581
734
|
...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
|
|
582
735
|
const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
|
|
583
736
|
.filter(Boolean).map(r => r.toLowerCase()));
|
|
584
|
-
// A branch an earlier `--source` declaration named, and no task or the latest declaration names, keeps its merged
|
|
585
|
-
// history on a long cache: it is read, never skipped, but a lane card's weeks of branches stay a small share of the
|
|
586
|
-
// home's discovery budget. A task's branch and the latest declaration's are read as before.
|
|
587
|
-
const earlier = earlierDeclaredBranches(board.db, id);
|
|
588
|
-
if (earlier.size) for (const r of registeredBranches(board, id, [])) earlier.delete(`${r.repository.toLowerCase()}:${r.branch}`);
|
|
589
737
|
// An advisory discovery starts at most NEW_READS_PER_ROUND reads it does not already hold for the arc's earlier
|
|
590
738
|
// branches and declared PRs, and only one once the home has spent FILL_SHARE of its hour's discovery budget (every
|
|
591
739
|
// arc's reads), so an arc still advances through a long busy stretch (issue() still refuses past the budget itself);
|
|
@@ -603,9 +751,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
603
751
|
};
|
|
604
752
|
const openSeen = new Map(), openKey = (url) => `${repositoryOf(url).toLowerCase()}#${numberOf(url)}`;
|
|
605
753
|
// A repository's open PRs, every page, on the five-minute list every arc reading that repository shares.
|
|
606
|
-
const openList = (repository) =>
|
|
607
|
-
|
|
608
|
-
const stacked = [], workRepositories = new Set();
|
|
754
|
+
const openList = (repository) => openPullRequests(repository, { fresh });
|
|
755
|
+
const workRepositories = new Set();
|
|
609
756
|
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
610
757
|
const prs = [];
|
|
611
758
|
const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
|
|
@@ -624,7 +771,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
624
771
|
if (fresh || strict || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
625
772
|
return []; // An unread sibling stays unread: do not cache an empty successful result.
|
|
626
773
|
}
|
|
627
|
-
// Include excluded drafts
|
|
774
|
+
// Include excluded drafts in the fresh scope: becoming ready
|
|
628
775
|
// must not let a sibling source escape the complete-set check.
|
|
629
776
|
for (const pr of list.items) openSeen.set(openKey(pr.url), pr);
|
|
630
777
|
if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
|
|
@@ -634,34 +781,21 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
634
781
|
if (repository === ownRepository && branch) {
|
|
635
782
|
if (branch === list.defaultBranch) branch = null;
|
|
636
783
|
}
|
|
637
|
-
// a draft is not handed over yet: its review starts when it is marked ready; a PR
|
|
638
|
-
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
// and tells it until it is retargeted to the default branch. A PR from the default branch itself is a release.
|
|
642
|
-
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.defaultBranch && pr.headRefName !== pr.defaultBranch && pr.baseRefName !== pr.defaultBranch && mine(repository, pr.headRefName)) stacked.push(pr);
|
|
784
|
+
// a draft is not handed over yet: its review starts when it is marked ready; a ready PR on one of the arc's branches
|
|
785
|
+
// is the arc's work whatever its base, and is reviewed against that base; a PR from the default branch itself
|
|
786
|
+
// (main→prod) is a release, not the arc's work
|
|
787
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.headRefName !== list.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
643
788
|
// A manual review and merge during a dispatcher outage may predate any
|
|
644
789
|
// board request. The source-review door already accepts merged heads; make
|
|
645
790
|
// them discoverable so the ordinary independent verdict can be retained.
|
|
646
|
-
// An open PR (including a draft
|
|
791
|
+
// An open PR (including a draft) on that branch takes priority.
|
|
647
792
|
const historical = [...new Set([
|
|
648
793
|
...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
|
|
649
794
|
...registered.filter(r => r.repository === repository).map(r => r.branch),
|
|
650
795
|
])];
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
|
|
655
|
-
const older = earlier.has(`${repository}:${headBranch}`) && !(repository === ownRepository && headBranch === branch);
|
|
656
|
-
// An earlier branch's history already held is its answer, in a fresh scan too, until its long cache ends.
|
|
657
|
-
const held = older ? await heldRead(`history:${repository}:${headBranch}`) : null;
|
|
658
|
-
if (older && !held && !await admit(`history:${repository}:${headBranch}`)) return [];
|
|
659
|
-
return (held ?? await mergedBranchPullRequests(repository, headBranch, { fresh, ...(older ? { ttl: spread(EARLIER_BRANCH_TTL, `history:${repository}:${headBranch}`) } : {}) })).filter(pr =>
|
|
660
|
-
!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
661
|
-
&& pr.baseRefName === pr.defaultBranch);
|
|
662
|
-
});
|
|
663
|
-
for (const history of histories) for (const pr of history) {
|
|
664
|
-
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
796
|
+
const histories = await branchHistories(repository, historical.filter(head => head !== list.defaultBranch), { fresh, admit });
|
|
797
|
+
for (const pr of histories) {
|
|
798
|
+
if (!pr.isCrossRepository && !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
665
799
|
}
|
|
666
800
|
return prs;
|
|
667
801
|
});
|
|
@@ -687,6 +821,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
687
821
|
const read = await readTogether(sources, async source => {
|
|
688
822
|
const listed = openSeen.get(openKey(source.url));
|
|
689
823
|
if (listed) return listed;
|
|
824
|
+
const immutable = await mergedFact(source.url);
|
|
825
|
+
if (immutable) return immutable;
|
|
690
826
|
const held = await heldRead(pullRequestKey(source.url));
|
|
691
827
|
if (held && (!fresh || held.state === 'MERGED')) return held;
|
|
692
828
|
if (!held && !await admit(pullRequestKey(source.url))) return { deferred: true };
|
|
@@ -705,28 +841,31 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
705
841
|
if (unread) throw unread.unread;
|
|
706
842
|
// Reads this round did not start leave the set incomplete: the review waits for them, never runs without them.
|
|
707
843
|
if (deferred) throw Object.assign(new Error(`${deferred} of ${id}'s earlier-declared PRs and branches are not read yet (${spent ? `the home has spent ${Math.round(FILL_SHARE * 100)}% of its hour's ${GITHUB_DISCOVERY_ATTEMPTS} discovery reads, so an arc starts one new read a round` : `an advisory round starts at most ${NEW_READS_PER_ROUND} new reads for an arc`}); the rest are read on the next rounds`), { code: 'github_read_admission_full', deferred: true });
|
|
708
|
-
for (const pr of read) {
|
|
709
|
-
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
844
|
+
for (const [i, pr] of read.entries()) {
|
|
845
|
+
if (pr.isCrossRepository || (!['OPEN', 'MERGED'].includes(pr.state) && !sources[i].retained) || (pr.state === 'OPEN' && pr.isDraft && !sources[i].retained)) continue;
|
|
710
846
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
711
847
|
}
|
|
712
848
|
// A PR merged at a head a review passed (its PR review, or an earlier final review: passedHead) is settled: the arc's
|
|
713
849
|
// final review neither names nor owes it. It is kept apart as `pr_reviewed`, so an arc whose every PR is settled still
|
|
714
850
|
// gets its final review, of its Acceptance against them (VO-3766: PRs merged at the heads a final review passed, then
|
|
715
851
|
// PR-reviewed PRs merged, left no PR the final review could name, and its open Acceptance waited forever).
|
|
716
|
-
const settled = prs.filter(
|
|
852
|
+
const settled = prs.filter(p => prDisposition(board, id, p.url, p.head).disposition === 'release-only'
|
|
853
|
+
? Boolean(releaseReadback(board, id, p.url, p.head))
|
|
854
|
+
: p.observed?.state === 'MERGED' && passedHead(board, id, p.head));
|
|
717
855
|
for (const p of settled) prs.splice(prs.indexOf(p), 1);
|
|
718
|
-
const
|
|
719
|
-
//
|
|
856
|
+
const dispositions = [...prs, ...settled].map(p => ({ url: p.url, head: p.head, ...prDisposition(board, id, p.url, p.head) })).filter(p => p.event != null);
|
|
857
|
+
// Each settled PR keeps the branch it was found on, so a `--source none` clearing that branch drops it (clearedDeclarations).
|
|
858
|
+
const reviewed = { ...(settled.length ? { pr_reviewed: settled.map(p => ({ url: p.url, head: p.head, ...(p.observed?.headRefName ? { branch: p.observed.headRefName } : {}), ...prDisposition(board, id, p.url, p.head) })) } : {}),
|
|
859
|
+
...(dispositions.length ? { pr_dispositions: dispositions } : {}) };
|
|
860
|
+
// Retain scope even when all matching PRs are excluded drafts,
|
|
720
861
|
// so a fresh scan cannot inherit that exclusion.
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (stacked.length && (checkWorkspaces || refuseStacked)) throw refusalOf(stacked.map((pr) => refused('base', pr.url, `${pr.url} proposes ${pr.headRefName} to ${pr.baseRefName}, not ${pr.defaultBranch}: retarget it to ${pr.defaultBranch} so the arc's review includes it.`)));
|
|
726
|
-
if (!prs.length && (stacked.length || settled.length)) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url), ...reviewed };
|
|
862
|
+
// The fresh phase must inspect every enumerated sibling: an advisory empty list
|
|
863
|
+
// cannot prove it still has no matching ready source.
|
|
864
|
+
if (scope) for (const repository of repositoriesToRead) scope.add(repository);
|
|
865
|
+
if (!prs.length && settled.length) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), ...reviewed };
|
|
727
866
|
if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
728
867
|
// Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
|
|
729
|
-
// doing checkout work: a
|
|
868
|
+
// doing checkout work: a refused PR cannot be repaired by revalidating the whole arc every round. Discovery
|
|
730
869
|
// refreshes changed heads/bases/states; local readiness is read each round. Neither check authorizes a move:
|
|
731
870
|
// inspectCompletionEvidence repeats both, in the same order, against fresh metadata and the pushed head. Each
|
|
732
871
|
// collects every PR's refusal, so the same state always refuses with the same set.
|
|
@@ -734,7 +873,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
734
873
|
await refuseTogether(prs, pr => requireReviewPullRequestProperties(pr.url, pr.head, pr.observed));
|
|
735
874
|
await refuseTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
736
875
|
}
|
|
737
|
-
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(),
|
|
876
|
+
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), ...reviewed };
|
|
738
877
|
}
|
|
739
878
|
|
|
740
879
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
@@ -805,7 +944,8 @@ async function knownRepositories({ strict = false } = {}) {
|
|
|
805
944
|
* the current heads), and a PR that verdict's request did not name is due at whatever head it has: one that was a draft
|
|
806
945
|
* when the request was made. */
|
|
807
946
|
export async function reviewDue(board, id, prs) {
|
|
808
|
-
|
|
947
|
+
// Only a final review's verdict is answered here: a PR review requested since (a fix's own PR) must not hide it.
|
|
948
|
+
if (prs.length && answeredChanges(board, id, ['final'])) return true;
|
|
809
949
|
// A review the manager returned with no verdict is due again only for new work: a PR head its request did not name.
|
|
810
950
|
const returned = returnedReview(board, id);
|
|
811
951
|
if (returned) return prs.some((p) => !returned.heads.some((h) => h.pr.url === p.url && h.commit === p.head));
|
|
@@ -813,12 +953,15 @@ export async function reviewDue(board, id, prs) {
|
|
|
813
953
|
if (!unjudged.length) return false;
|
|
814
954
|
const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
|
|
815
955
|
if (candidatePullRequests(voided && JSON.parse(voided.payload)?.ci).some(j => unjudged.some(p => p.url === j.pr.url && p.head === j.commit))) return true;
|
|
956
|
+
// The last final review's own verdict, the one that answered it before the next request (as verdictOn reads it): a
|
|
957
|
+
// PR or plan review's verdict, or a later review's, never dates the arc's PRs for its final review.
|
|
958
|
+
const answered = `v.id > r.id AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)`;
|
|
816
959
|
const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
|
|
817
|
-
AND v.kind IN ('changes_requested', 'review_pass') AND
|
|
960
|
+
AND v.kind IN ('changes_requested', 'review_pass') AND ${answered}) AS verdict_at,
|
|
818
961
|
(SELECT v.kind FROM task_events v WHERE v.task_id = r.task_id
|
|
819
|
-
AND v.kind IN ('changes_requested', 'review_pass') AND
|
|
820
|
-
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')})
|
|
821
|
-
AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND
|
|
962
|
+
AND v.kind IN ('changes_requested', 'review_pass') AND ${answered} ORDER BY v.id LIMIT 1) AS verdict
|
|
963
|
+
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) = 'final'
|
|
964
|
+
AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND ${answered})
|
|
822
965
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
823
966
|
if (!last) return true;
|
|
824
967
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
@@ -835,61 +978,139 @@ export async function reviewDue(board, id, prs) {
|
|
|
835
978
|
}
|
|
836
979
|
|
|
837
980
|
/** Whether a PR review is due for one ready PR at `head` (`[{ url, head }]` as reviewDue takes them): no PR review
|
|
838
|
-
* passed at that head, and no verdict judged it or the last verdict
|
|
839
|
-
*
|
|
981
|
+
* passed at that head, and no verdict judged it or the last PR or final review verdict on that head has its findings
|
|
982
|
+
* answered (answeredHead; a plan review's findings neither hold it nor admit it). The
|
|
983
|
+
* arc's other PRs and their verdicts neither hold it nor make it due: each is reviewed when its coder marks it ready. */
|
|
840
984
|
export function prReviewDue(board, id, { head }) {
|
|
841
985
|
if (prReviewedHead(board, id, head)) return false;
|
|
842
986
|
// a PR review's or the final review's verdict on this head stands in its way; a plan review's never does (requestKindSql)
|
|
843
|
-
return !judgedHead(board, id, head) ||
|
|
987
|
+
return !judgedHead(board, id, head) || answeredHead(board, id, head, ['pr', 'final']);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** Whether a plan review is due for the proposed ADR's PR at `head`: no plan review passed at that head, and no plan
|
|
991
|
+
* review's verdict judged it or the last plan verdict's findings are answered (answeredChanges). Only plan verdicts count:
|
|
992
|
+
* a PR review's or the final review's verdict, on this PR or any other of the arc, neither holds it nor makes it due
|
|
993
|
+
* (an ADR amendment pushed before an unrelated PR's findings is still a plan never reviewed at that head). */
|
|
994
|
+
export function planReviewDue(board, id, { head }) {
|
|
995
|
+
if (verdictOn(board, id, head, ['plan_review_pass'], ['plan'])) return false;
|
|
996
|
+
return !verdictOn(board, id, head, ['changes_requested'], ['plan']) || answeredChanges(board, id, ['plan']);
|
|
844
997
|
}
|
|
845
998
|
|
|
846
999
|
/** Whether an arc whose every PR is settled (merged at the head its PR review passed: `pr_reviewed`) is due its final
|
|
847
|
-
* review, of its Acceptance against them: its last review event is a PR review's request (that review is over, and the
|
|
848
|
-
* arc is back with its implementer), a voided verdict, or changes requested whose tasks are all closed.
|
|
849
|
-
*
|
|
850
|
-
|
|
1000
|
+
* review, of its Acceptance against them: its last review event is a PR or plan review's request (that review is over, and the
|
|
1001
|
+
* arc is back with its implementer), a voided verdict, or changes requested whose tasks are all closed. A review the
|
|
1002
|
+
* manager returned gave no verdict, and its arc can reach none by a push once every PR is merged: it is due again once
|
|
1003
|
+
* the settled set (`current`, the round's `pr_reviewed`) is not the one the returned request named, or the arc
|
|
1004
|
+
* re-declared its sources after the return (returnedReview). After a pass nothing is new until another PR review is requested. */
|
|
1005
|
+
export function settledReviewDue(board, id, current = []) {
|
|
851
1006
|
const last = board.db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
|
|
852
1007
|
if (!last || last.kind === 'review_voided') return true;
|
|
853
1008
|
if (last.kind === 'changes_requested') return answeredChanges(board, id);
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
return
|
|
1009
|
+
if (last.kind === 'review_returned') {
|
|
1010
|
+
const returned = returnedReview(board, id);
|
|
1011
|
+
if (!returned) return true;
|
|
1012
|
+
const named = new Set([...returned.heads.map((h) => `${h.pr.url}@${h.commit}`), ...returned.settled.map((p) => `${p.url}@${p.head}`)]);
|
|
1013
|
+
const held = new Set(current.map((p) => `${p.url}@${p.head}`));
|
|
1014
|
+
return named.size !== held.size || [...held].some((k) => !named.has(k));
|
|
1015
|
+
}
|
|
1016
|
+
return last.kind === 'review_requested' && Boolean(board.db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind IN ('pr_review_requested', 'plan_review_requested') AND id > ? LIMIT 1").get(id, last.id));
|
|
862
1017
|
}
|
|
863
1018
|
|
|
864
1019
|
/** A PR review's request: a session's request-review naming its PR (engine.requestReview's `review`), as the
|
|
865
1020
|
* workflow's PR review clause selects it. The board's own final review request never carries one. */
|
|
866
1021
|
const asksPrReview = (payload) => payload?.review === 'pr';
|
|
1022
|
+
/** A plan review's request: a session's `request-review --plan` naming its proposed ADR's PR (engine.requestReview). */
|
|
1023
|
+
const asksPlanReview = (payload) => payload?.review === 'plan';
|
|
867
1024
|
|
|
868
1025
|
/** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
|
|
869
1026
|
* owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
|
|
870
1027
|
* finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
|
|
871
1028
|
* answered this way: only a new head is. Task state is read as every board reader reads it (statusOf: the card's
|
|
872
|
-
* workflow stage over its stored status).
|
|
873
|
-
|
|
1029
|
+
* workflow stage over its stored status).
|
|
1030
|
+
* With `of` (request kinds, as requestKindSql names them) only those requests' verdicts count: the latest verdict on a
|
|
1031
|
+
* request of those kinds, neither voided nor followed by another such request, must be changes requested, so a finding
|
|
1032
|
+
* on one kind of review neither holds back nor re-admits another. */
|
|
1033
|
+
export function answeredChanges(board, id, of = null) {
|
|
874
1034
|
const db = board.db;
|
|
875
|
-
|
|
1035
|
+
let verdict, asked;
|
|
1036
|
+
if (of) {
|
|
1037
|
+
verdict = db.prepare(`SELECT v.id, v.kind, r.id AS asked FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
|
|
1038
|
+
AND v.kind IN ('changes_requested', 'review_pass', 'pr_review_pass', 'plan_review_pass') AND v.id > r.id
|
|
1039
|
+
AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
|
|
1040
|
+
WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')})
|
|
1041
|
+
ORDER BY v.id DESC LIMIT 1`).get(id);
|
|
1042
|
+
if (verdict?.kind !== 'changes_requested') return false;
|
|
1043
|
+
// a newer request of these kinds is a review in progress, not findings to answer
|
|
1044
|
+
if (db.prepare(`SELECT 1 FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND r.id > ?
|
|
1045
|
+
AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) LIMIT 1`).get(id, verdict.id)) return false;
|
|
1046
|
+
if (db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'review_voided' AND id > ? AND json_extract(payload, '$.review_request') = ? LIMIT 1").get(id, verdict.id, verdict.asked)) return false;
|
|
1047
|
+
asked = verdict.asked;
|
|
1048
|
+
} else {
|
|
1049
|
+
verdict = db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
1050
|
+
if (verdict?.kind !== 'changes_requested') return false;
|
|
1051
|
+
asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ?").get(id, verdict.id)?.id ?? 0;
|
|
1052
|
+
}
|
|
1053
|
+
return findingsClosed(db, id, asked, verdict.id);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Whether the findings on one PR `head` are answered: the latest verdict on a request of the kinds `of` that named that
|
|
1057
|
+
* head (verdictOn, so not a voided one) is changes requested, no later such request naming the head is still without a
|
|
1058
|
+
* verdict (a review in progress or returned, not findings to answer), and every task the verdict owns is closed, as
|
|
1059
|
+
* answeredChanges reads them. Only the head's own verdicts count: a later verdict on another PR of the arc neither hides
|
|
1060
|
+
* nor answers its findings (t_c1e92c46: #2680's pass at 7cc79ffc1 hid the answered findings on #2679 at 364911e9c, whose
|
|
1061
|
+
* PR review could then never be requested again). */
|
|
1062
|
+
export function answeredHead(board, id, head, of) {
|
|
1063
|
+
const db = board.db;
|
|
1064
|
+
const verdict = verdictOn(board, id, head, ['changes_requested', 'review_pass', 'pr_review_pass'], of, null, { latest: true });
|
|
876
1065
|
if (verdict?.kind !== 'changes_requested') return false;
|
|
877
|
-
|
|
1066
|
+
if (db.prepare(`SELECT 1 FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND r.id > ? AND ${namesHeadSql('r')}
|
|
1067
|
+
AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')})
|
|
1068
|
+
AND NOT EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass', 'pr_review_pass') AND v.id > r.id
|
|
1069
|
+
AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807))
|
|
1070
|
+
LIMIT 1`).get(id, verdict.id, head, head)) return false;
|
|
1071
|
+
return findingsClosed(db, id, verdict.asked, verdict.id);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Whether the verdict `verdictId`, answering the request `asked`, owns a task and every one is closed now: the tasks
|
|
1075
|
+
* filed or reopened between the two (answeredChanges). */
|
|
1076
|
+
/** This PR's open findings, across its review rounds: the arc's open tasks filed or reopened between a request naming
|
|
1077
|
+
* the PR and the changes_requested verdict that answered it (the ownership findingsClosed reads). */
|
|
1078
|
+
export function openPrReviewFindings(db, id, request) {
|
|
1079
|
+
const urls = new Set(candidatePullRequests(JSON.parse(request.payload ?? '{}').ci).map(p => prIdentity(p.pr.url)));
|
|
1080
|
+
const asked = db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND id<=? AND kind='review_requested'").all(id, request.id)
|
|
1081
|
+
.filter(r => candidatePullRequests(JSON.parse(r.payload ?? '{}').ci).some(p => urls.has(prIdentity(p.pr.url)))).map(r => r.id);
|
|
1082
|
+
if (!asked.length) return [];
|
|
1083
|
+
return db.prepare(`SELECT t.id,t.title,t.body,COALESCE(c.stage,t.status) AS status FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
1084
|
+
WHERE c.subtask_of=? AND COALESCE(c.stage,t.status) NOT IN ('done','cancelled','archived') AND EXISTS (
|
|
1085
|
+
SELECT 1 FROM task_events r JOIN task_events v ON v.task_id=r.task_id AND v.kind='changes_requested' AND v.id>r.id
|
|
1086
|
+
AND v.id<COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id=r.task_id AND n.kind='review_requested' AND n.id>r.id),9223372036854775807)
|
|
1087
|
+
JOIN task_events e ON e.task_id=t.id AND e.kind IN ('created','reopened') AND e.id>r.id AND e.id<v.id
|
|
1088
|
+
WHERE r.task_id=? AND r.id IN (SELECT value FROM json_each(?))) ORDER BY t.created_at`).all(id, id, JSON.stringify(asked));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function findingsClosed(db, id, asked, verdictId) {
|
|
878
1092
|
const owned = db.prepare(`SELECT COALESCE(c.stage, t.status) AS status FROM supercode_cards c JOIN tasks t ON t.id = c.task_id
|
|
879
|
-
WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked,
|
|
1093
|
+
WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked, verdictId);
|
|
880
1094
|
return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
|
|
881
1095
|
}
|
|
882
1096
|
|
|
883
1097
|
/** The manager's return of the arc's review (`review_returned`, no verdict) while it is the arc's latest review event, with
|
|
884
|
-
* the PR heads its review request named (`heads`, as candidatePullRequests answers them); else null.
|
|
1098
|
+
* the PR heads its review request named (`heads`, as candidatePullRequests answers them); else null. A return holds
|
|
1099
|
+
* nothing when it was made because the arc re-declared its sources after the review request (reopenReview's
|
|
1100
|
+
* `redeclared`), or once the arc re-declares them after the return (redeclaredSince): its next review is built from the
|
|
1101
|
+
* declarations by the ordinary rules. A declaration made while the review ran, before the manager returned it, is
|
|
1102
|
+
* part of what the return answered and does not undo it. */
|
|
885
1103
|
export function returnedReview(board, id) {
|
|
886
1104
|
const db = board.db;
|
|
887
1105
|
const last = db.prepare("SELECT id, kind, payload FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
|
|
888
1106
|
if (last?.kind !== 'review_returned') return null;
|
|
889
|
-
const
|
|
1107
|
+
const returned = JSON.parse(last.payload ?? '{}') ?? {};
|
|
1108
|
+
if (returned.redeclared || redeclaredSince(db, id, last.id)) return null;
|
|
1109
|
+
const asked = returned.review_request;
|
|
890
1110
|
const request = asked ? db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND id = ? AND kind = 'review_requested'").get(id, asked)
|
|
891
1111
|
: db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ? ORDER BY id DESC LIMIT 1").get(id, last.id);
|
|
892
|
-
|
|
1112
|
+
const ci = request ? JSON.parse(request.payload)?.ci : null;
|
|
1113
|
+
return { id: last.id, heads: candidatePullRequests(ci), settled: ci?.pr_reviewed ?? [] };
|
|
893
1114
|
}
|
|
894
1115
|
|
|
895
1116
|
/** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
|
|
@@ -906,28 +1127,36 @@ const commitTime = async (p) => (await commitRead(p)).at;
|
|
|
906
1127
|
/** Whether the PR's head carries the same source tree as `commit` of the same PR. */
|
|
907
1128
|
const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
|
|
908
1129
|
|
|
909
|
-
/** A PR read for
|
|
1130
|
+
/** A PR read for review admission or acceptance: a merged one already held is its answer (its head, base and state
|
|
910
1131
|
* never change again), any other is read fresh. */
|
|
911
1132
|
const readSource = async (url) => {
|
|
912
|
-
|
|
913
|
-
return held?.state === 'MERGED' ? held : readPullRequest(url);
|
|
1133
|
+
return await mergedFact(url) ?? readPullRequest(url);
|
|
914
1134
|
};
|
|
915
1135
|
|
|
916
|
-
/** A cached exclusion must never hide a newly ready/reopened source from
|
|
1136
|
+
/** A cached exclusion must never hide a newly ready/reopened source from review admission or acceptance. The final
|
|
917
1137
|
* review's request names the whole current set, which leaves out each PR merged at the head its PR review passed
|
|
918
|
-
* (openReviewPullRequests: `pr_reviewed`).
|
|
1138
|
+
* (openReviewPullRequests: `pr_reviewed`). Acceptance of a request also stands beside a PR the request
|
|
919
1139
|
* did not name when that PR is merged at a head an earlier verdict of the arc judged (`judged`): it was reviewed at the
|
|
920
1140
|
* head it merged at, and no review is due for it (reviewDue). A PR review (`single`) names one ready PR of the set, and
|
|
921
|
-
* its request
|
|
1141
|
+
* its request stands beside every other PR of the arc, open or merged. */
|
|
922
1142
|
async function requireCurrentReviewSet(board, id, urls, repositories = [], { judged = false, single = false } = {}) {
|
|
1143
|
+
if (single) {
|
|
1144
|
+
// A PR review needs its named PR's repository to belong to the arc's workspace, tasks or declared sources,
|
|
1145
|
+
// read from the card without GitHub. Its repositories are the ones already known plus that PR's repository;
|
|
1146
|
+
// requireReviewPullRequestProperties separately reads the named PR at its current head.
|
|
1147
|
+
const { repositories: arc } = await arcRepositories(board, id);
|
|
1148
|
+
const known = new Set([...arc].map((r) => r.toLowerCase()));
|
|
1149
|
+
const outside = urls.map(repositoryOf).filter((r) => !known.has(r.toLowerCase()));
|
|
1150
|
+
if (outside.length) throw new Error(`A PR review names a PR of the arc's repositories; ${[...new Set(outside)].join(', ')} is not one of them (declare it: comment ${id} --source owner/repo:branch).`);
|
|
1151
|
+
return [...new Set([...repositories, ...urls.map(repositoryOf)].map((r) => r.toLowerCase()))].sort();
|
|
1152
|
+
}
|
|
923
1153
|
const current = await openReviewPullRequests(board, id, { fresh: true,
|
|
924
1154
|
repositories: [...repositories, ...urls.map(repositoryOf)] });
|
|
925
|
-
if (single) return current?.review_repositories ?? repositories;
|
|
926
1155
|
const unnamed = (current?.pr ?? []).map((url, i) => ({ url, head: current.pr_head[i] })).filter(p => !urls.includes(p.url));
|
|
927
1156
|
const settled = judged ? await readTogether(unnamed, async p => judgedHead(board, id, p.head) !== null
|
|
928
1157
|
&& (await readSource(p.url)).state === 'MERGED') : unnamed.map(() => false);
|
|
929
1158
|
const omitted = unnamed.filter((_, i) => !settled[i]).map(p => p.url);
|
|
930
|
-
if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review
|
|
1159
|
+
if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review or accepting it`);
|
|
931
1160
|
return current?.review_repositories ?? repositories;
|
|
932
1161
|
}
|
|
933
1162
|
|
|
@@ -949,6 +1178,71 @@ export function passedHead(board, id, head) {
|
|
|
949
1178
|
return verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final']);
|
|
950
1179
|
}
|
|
951
1180
|
|
|
1181
|
+
/** Current exact-head disposition; a new declaration withdraws all older readbacks. */
|
|
1182
|
+
export function prDisposition(board, id, url, head) {
|
|
1183
|
+
const row = board.db.prepare(`SELECT id,payload FROM task_events WHERE task_id=? AND kind='pr_disposition_recorded'
|
|
1184
|
+
AND json_valid(payload) AND json_extract(payload,'$.url')=? ORDER BY id DESC LIMIT 1`).get(id, url);
|
|
1185
|
+
const value = row ? JSON.parse(row.payload) : null;
|
|
1186
|
+
return value?.head === head ? { ...value, event: row.id } : { disposition: 'merge', event: null };
|
|
1187
|
+
}
|
|
1188
|
+
export function releaseReadback(board, id, url, head) {
|
|
1189
|
+
const disposition = prDisposition(board, id, url, head);
|
|
1190
|
+
const pass = verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final'], url);
|
|
1191
|
+
if (disposition.disposition !== 'release-only' || !pass) return null;
|
|
1192
|
+
const row = board.db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND kind='pr_release_readback'
|
|
1193
|
+
AND json_valid(payload) AND json_extract(payload,'$.url')=? AND json_extract(payload,'$.head')=? ORDER BY id DESC LIMIT 1`).get(id, url, head);
|
|
1194
|
+
const value = row ? JSON.parse(row.payload) : null;
|
|
1195
|
+
if (value?.disposition_event !== disposition.event || value?.verdict !== pass.id
|
|
1196
|
+
|| value?.target !== disposition.target || value?.artifact !== head || value?.verification?.source !== head || value?.verification?.target !== value.target
|
|
1197
|
+
|| !['machine', 'manager-attestation'].includes(value?.verification?.kind)) return null;
|
|
1198
|
+
return value;
|
|
1199
|
+
}
|
|
1200
|
+
/** A retained readback becomes settlement only beside a fresh observation of its PR head. */
|
|
1201
|
+
export async function verifiedReleaseReadback(board, id, url, head) {
|
|
1202
|
+
const readback = releaseReadback(board, id, url, head);
|
|
1203
|
+
if (!readback) return null;
|
|
1204
|
+
const pr = await readPullRequest(url);
|
|
1205
|
+
return pr.headRefOid === head && !pr.isCrossRepository ? releaseReadback(board, id, url, head) : null;
|
|
1206
|
+
}
|
|
1207
|
+
/** Prepare evidence outside the write. The engine checks its event bindings again at commit. */
|
|
1208
|
+
export async function preparePrEvidence(board, id, source, options, { manager = false, actor } = {}) {
|
|
1209
|
+
const { url, head } = source;
|
|
1210
|
+
if (typeof actor !== 'string' || !actor.trim()) throw new Error('PR evidence needs the authenticated actor identity.');
|
|
1211
|
+
const pr = await readPullRequest(url);
|
|
1212
|
+
if (pr.headRefOid !== head || pr.isCrossRepository) throw new Error('PR evidence must name the current exact repository PR head.');
|
|
1213
|
+
const reason = String(options.reason ?? '').trim();
|
|
1214
|
+
if (!reason) throw new Error('PR disposition/readback needs a reason.');
|
|
1215
|
+
if (options.disposition != null) {
|
|
1216
|
+
if (!['merge', 'release-only'].includes(options.disposition)) throw new Error('PR disposition is merge or release-only.');
|
|
1217
|
+
const target = options.disposition === 'release-only' ? String(options.target ?? '').trim() : null;
|
|
1218
|
+
if (options.disposition === 'release-only' && !target) throw new Error('Release-only disposition names its intended --release-target.');
|
|
1219
|
+
return { kind: 'pr_disposition_recorded', url, head, disposition: options.disposition, target, actor, reason };
|
|
1220
|
+
}
|
|
1221
|
+
const disposition = prDisposition(board, id, url, head);
|
|
1222
|
+
const pass = verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final'], url);
|
|
1223
|
+
if (disposition.disposition !== 'release-only' || !pass) throw new Error('Release readback needs an active release-only disposition and a valid independent pass of this exact PR/head.');
|
|
1224
|
+
const { target, artifact, evidence } = options.readback ?? {};
|
|
1225
|
+
if (typeof target !== 'string' || target !== disposition.target || artifact !== head || typeof evidence !== 'string' || !evidence.trim()) throw new Error('Release readback needs its target, exact released head as artifact, and evidence pointer.');
|
|
1226
|
+
let verification;
|
|
1227
|
+
if (target.startsWith('machine:')) {
|
|
1228
|
+
if (options.readback.attest) throw new Error('A machine target uses its status verifier, not an attestation.');
|
|
1229
|
+
const machine = target.slice('machine:'.length);
|
|
1230
|
+
if (!machine) throw new Error('Name the released machine.');
|
|
1231
|
+
const answer = await runtimeDoor(machine, 'harness.v1.machine.describe', {}, { timeout: 9000 });
|
|
1232
|
+
if (answer.code !== 0) throw new Error(`Release target could not be verified: ${answer.stderr}`);
|
|
1233
|
+
const receipt = JSON.parse(answer.stdout), selected = receipt.runtime_release?.source;
|
|
1234
|
+
const source = selected?.binding === 'node' ? selected.node : selected?.native;
|
|
1235
|
+
if (![receipt.name, receipt.id, receipt.machineId].includes(machine) || selected?.binding_verdict !== 'matches'
|
|
1236
|
+
|| selected?.commit !== head || source?.commit !== head || source?.dirty !== false || source?.observation_error) throw new Error('Machine status does not confirm this exact reviewed source/artifact.');
|
|
1237
|
+
verification = { kind: 'machine', source: head, target, observed_at: Date.now(), receipt };
|
|
1238
|
+
} else {
|
|
1239
|
+
if (!manager || options.readback.attest !== true) throw new Error('This target has no machine verifier; an explicit manager attestation of the observed target and source is required.');
|
|
1240
|
+
verification = { kind: 'manager-attestation', source: head, target, observed_at: Date.now(), actor };
|
|
1241
|
+
}
|
|
1242
|
+
return { kind: 'pr_release_readback', url, head, actor, reason, target, artifact, evidence,
|
|
1243
|
+
disposition_event: disposition.event, verdict: pass.id, verification };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
952
1246
|
/** A review request's kind, as SQL over its `review_requested` row `r`: a PR review's and a plan review's request each
|
|
953
1247
|
* record their own marker right after it (pr_review_requested, plan_review_requested), before the next request; the
|
|
954
1248
|
* board's final review records none. Each kind keeps its own verdicts: a plan review, which grades no code head, never
|
|
@@ -960,17 +1254,30 @@ const requestKindSql = (r) => `CASE
|
|
|
960
1254
|
AND k.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = ${r}.task_id AND n.kind = 'review_requested' AND n.id > ${r}.id), 9223372036854775807)) THEN 'pr'
|
|
961
1255
|
ELSE 'final' END`;
|
|
962
1256
|
|
|
963
|
-
|
|
1257
|
+
/** The latest requested candidate and its kind, using the same marker ordering as verdict selection. */
|
|
1258
|
+
export function latestReviewRequest(db, id) {
|
|
1259
|
+
return db.prepare(`SELECT r.id,r.payload,${requestKindSql('r')} AS kind FROM task_events r
|
|
1260
|
+
WHERE r.task_id=? AND r.kind='review_requested' ORDER BY r.id DESC LIMIT 1`).get(id) ?? null;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Whether review request `r` named a head, as SQL binding that head twice: its one PR's commit or any of its PRs'. */
|
|
1264
|
+
const namesHeadSql = (r) => `(json_extract(${r}.payload, '$.ci.commit') = ?
|
|
1265
|
+
OR EXISTS (SELECT 1 FROM json_each(${r}.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?))`;
|
|
1266
|
+
|
|
1267
|
+
/** The first verdict of `kinds` (the latest with `latest`) that answered a request of the kinds `of` naming `head` (of
|
|
1268
|
+
* `url`, when given) and was not voided, with the request it answered (`asked`), or null. */
|
|
1269
|
+
function verdictOn(board, id, head, kinds, of, url = null, { latest = false } = {}) {
|
|
964
1270
|
if (!head) return null;
|
|
965
1271
|
// A request's JSON head list is checked once before visiting its verdicts. Without CROSS JOIN SQLite starts
|
|
966
1272
|
// from every verdict and reparses the same older requests for each of them (including heads never requested).
|
|
967
|
-
return board.db.prepare(`SELECT v.id, v.kind FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
|
|
1273
|
+
return board.db.prepare(`SELECT v.id, v.kind, r.id AS asked FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
|
|
968
1274
|
AND v.kind IN (${kinds.map((kind) => `'${kind}'`).join(', ')}) AND v.id > r.id
|
|
969
1275
|
AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
|
|
970
1276
|
AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
|
|
971
|
-
WHERE r.task_id = ? AND r.kind = 'review_requested' AND (
|
|
972
|
-
|
|
973
|
-
|
|
1277
|
+
WHERE r.task_id = ? AND r.kind = 'review_requested' AND ${namesHeadSql('r')}
|
|
1278
|
+
AND (? IS NULL OR (json_extract(r.payload,'$.ci.pr.url')=? AND json_extract(r.payload,'$.ci.commit')=?)
|
|
1279
|
+
OR EXISTS (SELECT 1 FROM json_each(r.payload,'$.ci.prs') p WHERE json_extract(p.value,'$.pr.url')=? AND json_extract(p.value,'$.commit')=?))
|
|
1280
|
+
AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) ORDER BY v.id ${latest ? 'DESC' : 'ASC'} LIMIT 1`).get(id, head, head, url, url, head, url, head) ?? null;
|
|
974
1281
|
}
|
|
975
1282
|
|
|
976
1283
|
export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
@@ -998,36 +1305,54 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
998
1305
|
if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
|
|
999
1306
|
await readTogether(wanted, async w => {
|
|
1000
1307
|
const pr = await readSource(w.pr.url);
|
|
1001
|
-
if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
|
|
1308
|
+
if ((!['OPEN', 'MERGED'].includes(pr.state) && !releaseReadback(board, id, w.pr.url, w.commit)) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
|
|
1002
1309
|
});
|
|
1003
1310
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
1004
1311
|
}
|
|
1005
1312
|
return state;
|
|
1006
1313
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1314
|
+
if (stage === 'review_verdict') {
|
|
1315
|
+
// The verdict judges the review run's frozen candidate: the arc's latest run is its reviewer's while it reviews.
|
|
1316
|
+
const asked = reviewCandidate(board, id);
|
|
1317
|
+
const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
1318
|
+
const bound = latest ? JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate : null;
|
|
1319
|
+
if (bound && !isDeepStrictEqual(bound, asked)) throw new Error('This reviewer run was launched for a different review candidate.');
|
|
1320
|
+
const requested = candidatePullRequests(asked);
|
|
1321
|
+
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? requested.map(p => p.pr.url));
|
|
1322
|
+
const named = [].concat(payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []);
|
|
1323
|
+
const sameSet = (left, right) => {
|
|
1324
|
+
const wanted = new Set(right);
|
|
1325
|
+
return new Set(left).size === wanted.size && left.every(value => wanted.has(value));
|
|
1326
|
+
};
|
|
1327
|
+
if (urls.some(url => typeof url !== 'string')
|
|
1328
|
+
|| !sameSet(urls.map(prIdentity), requested.map(p => prIdentity(p.pr.url)))) throw new Error('The verdict must cover exactly the PRs its review request named.');
|
|
1329
|
+
if (named.some(head => typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head))
|
|
1330
|
+
|| !sameSet(named, requested.map(p => p.commit))) throw new Error('Name exactly the requested PR heads with --reviewed-sha.');
|
|
1331
|
+
return { ...state, ...asked, ready: true, reason: null };
|
|
1332
|
+
}
|
|
1333
|
+
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? []).filter(Boolean);
|
|
1009
1334
|
if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
|
|
1010
1335
|
// Advisory heads can refuse a redundant request, never authorize one. Do
|
|
1011
1336
|
// this before a fresh source scan, including for callers outside dispatch.
|
|
1012
1337
|
const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
1013
|
-
// A PR review
|
|
1014
|
-
//
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1338
|
+
// A PR review and a plan review each name one PR, whatever other PRs the arc has, open or merged, and each is due
|
|
1339
|
+
// by its own kind's verdicts alone (prReviewDue, planReviewDue). Only the final review is due by the whole set (reviewDue).
|
|
1340
|
+
const plan = asksPlanReview(payload);
|
|
1341
|
+
const single = plan || asksPrReview(payload);
|
|
1342
|
+
if (single && urls.length !== 1) throw new Error(plan ? 'A plan review names its proposed ADR\'s PR: --plan --pr URL --pr-head SHA.' : 'A PR review names one PR: --pr URL --pr-head SHA.');
|
|
1343
|
+
const due = (prs) => plan ? prs.every((p) => planReviewDue(board, id, p)) : single ? prs.every((p) => prReviewDue(board, id, p)) : reviewDue(board, id, prs);
|
|
1344
|
+
const kind = plan ? 'plan review' : 'PR review';
|
|
1345
|
+
if (urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
|
|
1019
1346
|
&& !await due(urls.map((url, i) => ({ url, head: proposed[i] })))) {
|
|
1020
|
-
throw
|
|
1347
|
+
throw refused('judged', urls[0], single ? `The proposed PR head already has a ${kind}'s verdict (a pass, or findings not yet answered); no ${kind} is due.` : 'Every proposed PR head already has a verdict or carries no new source; no review is due.');
|
|
1021
1348
|
}
|
|
1022
|
-
|
|
1023
|
-
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
1349
|
+
state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? [], { single });
|
|
1024
1350
|
// The final review of an arc whose every PR is settled by its PR review names no PR: it reviews the arc's Acceptance
|
|
1025
1351
|
// against them (settledReviewDue).
|
|
1026
|
-
const settled =
|
|
1352
|
+
const settled = payload.pr_reviewed;
|
|
1027
1353
|
if (!urls.length && settled?.length && !single) return { ...state, pr_reviewed: settled };
|
|
1028
1354
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
1029
|
-
// unmerged candidate's checkout
|
|
1030
|
-
// prevents a verdict on already merged commits.
|
|
1355
|
+
// unmerged candidate's checkout before admitting its review.
|
|
1031
1356
|
const read = await mapGithubSources(urls, readSource);
|
|
1032
1357
|
const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
|
|
1033
1358
|
const own = mergedOnly ? null : await repositoryWorkspace(board, id);
|
|
@@ -1045,31 +1370,18 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
1045
1370
|
const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
|
|
1046
1371
|
if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName}`)));
|
|
1047
1372
|
}
|
|
1048
|
-
//
|
|
1049
|
-
// always names every opened PR it changed, independent of deployment.
|
|
1050
|
-
if (stage === 'review_verdict' && !asked && !urls.length) return state;
|
|
1373
|
+
// An arc's review always names every opened PR it changed, independent of deployment.
|
|
1051
1374
|
Object.assign(state, { required: true, ready: false });
|
|
1052
1375
|
if (!urls.length) throw new Error('Name each opened PR with --pr.');
|
|
1053
|
-
const named = [].concat(
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
|
|
1057
|
-
if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
|
|
1058
|
-
// Every PR's properties, then (for a review request) every PR's checkout at its pushed head, as the advisory pass
|
|
1376
|
+
const named = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
1377
|
+
if (named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
|
|
1378
|
+
// Every PR's properties, then every PR's checkout at its pushed head, as the advisory pass
|
|
1059
1379
|
// checks them; each collects every refusal (refuseTogether).
|
|
1060
|
-
const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
} else if (asked) {
|
|
1066
|
-
const requested = candidatePullRequests(asked);
|
|
1067
|
-
if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
|
|
1068
|
-
if (requested.some((r) => !named.includes(r.commit))) throw new Error('Name every reviewed PR head with --reviewed-sha.');
|
|
1069
|
-
const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
1070
|
-
const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
|
|
1071
|
-
if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
|
|
1072
|
-
}
|
|
1380
|
+
const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, named[index], read[index]));
|
|
1381
|
+
await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
|
|
1382
|
+
// A review is asked for only while some PR has a head no verdict has judged; a PR or plan review, while its one PR's
|
|
1383
|
+
// head has no verdict of its own kind.
|
|
1384
|
+
if (!await due(prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw refused('judged', prs[0].pr.url, single ? `That PR head already has a ${kind}'s verdict (a pass, or findings not yet answered): push a commit that changes the source, or close the tasks that own the last verdict's findings, before requesting its ${kind}.` : 'Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
|
|
1073
1385
|
Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}), ...(settled?.length && !single ? { pr_reviewed: settled } : {}) });
|
|
1074
1386
|
} catch (error) {
|
|
1075
1387
|
Object.assign(state, { required: true, ready: false, reason: String(error?.message ?? error) });
|
|
@@ -1081,6 +1393,36 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
1081
1393
|
return state;
|
|
1082
1394
|
}
|
|
1083
1395
|
|
|
1396
|
+
// Quiet tracked predicates produce no path list. Untracked detection streams only a bounded
|
|
1397
|
+
// sample and terminates that owned Git child once one dirty fact is known. A Git failure
|
|
1398
|
+
// before a dirty fact is an error, never a clean checkout or an uncommitted-state refusal.
|
|
1399
|
+
async function gitState(path, args, { stream = false } = {}) {
|
|
1400
|
+
return new Promise((resolve, reject) => {
|
|
1401
|
+
const child = spawn('git', args, { cwd: path, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1402
|
+
let sample = '', diagnostic = '', found = false, timedOut = false;
|
|
1403
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 9000);
|
|
1404
|
+
child.stdout.on('data', bytes => {
|
|
1405
|
+
if (!stream || found) return;
|
|
1406
|
+
sample = (sample + bytes.toString('utf8')).slice(0, 512);
|
|
1407
|
+
if (sample.length) { found = true; child.kill('SIGTERM'); }
|
|
1408
|
+
});
|
|
1409
|
+
child.stderr.on('data', bytes => { diagnostic = (diagnostic + bytes.toString('utf8')).slice(-500); });
|
|
1410
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
1411
|
+
child.once('close', (code) => {
|
|
1412
|
+
clearTimeout(timer);
|
|
1413
|
+
if (timedOut) return reject(new Error('git clean-state check exceeded 9 seconds'));
|
|
1414
|
+
if (found) return resolve(`untracked path(s): ${JSON.stringify(sample.split('\0')[0])}${sample.includes('\0') ? ' (sample; more may exist)' : '…'}`);
|
|
1415
|
+
if (code === 0) return resolve(null);
|
|
1416
|
+
if (!stream && code === 1) return resolve('tracked index/worktree changes (paths omitted)');
|
|
1417
|
+
reject(new Error(`git clean-state check failed (${code}): ${diagnostic}`));
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
export async function reviewWorkspaceDirty(path) {
|
|
1422
|
+
return await gitState(path, ['diff', '--quiet', '--no-ext-diff', '--ignore-submodules=none', 'HEAD', '--'])
|
|
1423
|
+
?? await gitState(path, ['ls-files', '--others', '--exclude-standard', '-z'], { stream: true });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1084
1426
|
/** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
|
|
1085
1427
|
async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
|
|
1086
1428
|
const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
|
|
@@ -1091,8 +1433,8 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
|
|
|
1091
1433
|
if (!path) return;
|
|
1092
1434
|
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
1093
1435
|
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw refused('head', url, `The head named for ${url} must be its worktree head.`);
|
|
1094
|
-
const
|
|
1095
|
-
if (
|
|
1436
|
+
const dirty = await reviewWorkspaceDirty(path);
|
|
1437
|
+
if (dirty) throw refused('uncommitted', url, `Commit the work in ${path} before requesting review: ${dirty}`);
|
|
1096
1438
|
if (pushed) {
|
|
1097
1439
|
const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
1098
1440
|
if (remote !== head) throw refused('unpushed', url, `Push the exact head of ${url} before requesting review.`);
|
|
@@ -1100,21 +1442,22 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
|
|
|
1100
1442
|
}
|
|
1101
1443
|
|
|
1102
1444
|
/** PR-only readiness, shared by advisory refusals and fresh source validation. */
|
|
1103
|
-
function requireReviewPullRequestProperties(url, supplied, observed) {
|
|
1445
|
+
export function requireReviewPullRequestProperties(url, supplied, observed) {
|
|
1104
1446
|
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
1105
1447
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
1106
1448
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
1107
1449
|
// verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
|
|
1108
1450
|
const merged = observed.state === 'MERGED';
|
|
1109
|
-
if (
|
|
1451
|
+
if (observed.isCrossRepository) throw refused('foreign', url, `Source review needs a PR from its own repository (${url}).`);
|
|
1452
|
+
if (observed.state !== 'OPEN' && !merged) throw refused('state', url, `Source review needs an open or merged PR (${url}).`);
|
|
1110
1453
|
if (!merged && observed.isDraft) throw refused('draft', url, `${url} is a draft; its review starts when it is marked ready for review.`);
|
|
1111
1454
|
const head = supplied ?? observed.headRefOid;
|
|
1112
1455
|
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
|
|
1113
|
-
if (observed.headRefOid !== head) throw
|
|
1456
|
+
if (observed.headRefOid !== head) throw refused('moved', url, `The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
|
|
1114
1457
|
const branch = observed.headRefName;
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1458
|
+
// A PR whose head is the default branch (main → prod) is a release, never an arc's work.
|
|
1459
|
+
if (branch === (observed.defaultBranch ?? 'main')) throw refused('release', url, `${url} proposes the default branch ${branch}; it is a release, not an arc's work.`);
|
|
1460
|
+
// Any base: the review reads the PR's whole diff against its own base (pr.base).
|
|
1118
1461
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
1119
1462
|
}
|
|
1120
1463
|
|