agent-dealer 1.3.3 → 1.3.5
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/bundle/server/dist/coordinator/commands.js +79 -1
- package/bundle/server/dist/coordinator/commands.test.js +2 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +20 -2
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +44 -4
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +129 -1
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
- package/bundle/server/dist/db/index.js +4 -2
- package/bundle/server/dist/db/schema.sql +1 -1
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
- package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
- package/bundle/server/dist/routes/human-actions.test.js +4 -4
- package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
- package/bundle/server/dist/routes/issues.js +32 -2
- package/bundle/server/dist/routes/issues.test.js +25 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
- package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/issues.d.ts +8 -5
- package/bundle/shared/dist/issues.js +7 -4
- package/bundle/shared/dist/issues.test.js +11 -1
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +1 -1
- package/dist/issue.d.ts +1 -0
- package/dist/issue.js +3 -2
- package/dist/issue.test.js +37 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B_pNUDRY.js +0 -64
- package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
|
@@ -32,7 +32,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
|
32
32
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
33
33
|
import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
34
34
|
import { AUTH_TRANSIENT_RETRY_PAYLOAD_KEY, RUNTIME_AUTH_PARK_EVIDENCE_KEY, authProbeConfirmsFailure, defaultRemediationForRuntime, gateRuntimeAuthParkResume, parseRuntimeAuthParkEvidence, remediationFromProbe, } from "./runtime-auth-park.js";
|
|
35
|
-
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
35
|
+
import { AUTO_MERGE_INTENT, OPERATOR_VERIFICATION_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
36
36
|
import { triggerLinearPostMerge } from "./linear-merge-verify.js";
|
|
37
37
|
import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
|
|
38
38
|
import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
|
|
@@ -261,6 +261,84 @@ export function applyParkedEdit(issueId, patch) {
|
|
|
261
261
|
throw err;
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
export function autoMergePolicyEditability(issue) {
|
|
265
|
+
switch (issue.status) {
|
|
266
|
+
case "ready":
|
|
267
|
+
return { editable: true, reason: null };
|
|
268
|
+
case "developing":
|
|
269
|
+
case "reviewing":
|
|
270
|
+
case "repairing":
|
|
271
|
+
return getActiveWorkflowInstance(issue.id)
|
|
272
|
+
? { editable: true, reason: null }
|
|
273
|
+
: { editable: false, reason: "No active workflow is running for this issue." };
|
|
274
|
+
case "needs_human": {
|
|
275
|
+
const open = listHumanActionsForIssue(issue.id).filter((a) => a.status === "open");
|
|
276
|
+
// The operator-verification gate runs after approval routing: resolving it continues
|
|
277
|
+
// the already-committed merge path, so the policy is locked while it is open.
|
|
278
|
+
if (issue.currentIntent === OPERATOR_VERIFICATION_INTENT ||
|
|
279
|
+
open.some((a) => a.actionType === "operator_verification")) {
|
|
280
|
+
return {
|
|
281
|
+
editable: false,
|
|
282
|
+
reason: "The reviewer approved and the merge decision is committed — it waits only on operator verification.",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Merge-failure parks, including the no-head operator-verification escalation.
|
|
286
|
+
if (open.some((a) => a.actionType === "policy_escalation" && isMergeFailureAction(a))) {
|
|
287
|
+
return {
|
|
288
|
+
editable: false,
|
|
289
|
+
reason: "The reviewer approved and the merge was already attempted — use the merge-failure action instead.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { editable: true, reason: null };
|
|
293
|
+
}
|
|
294
|
+
case "final_review":
|
|
295
|
+
return {
|
|
296
|
+
editable: false,
|
|
297
|
+
reason: issue.currentOwner === "system" && issue.currentIntent === AUTO_MERGE_INTENT
|
|
298
|
+
? "The reviewer approved and auto-merge is already in flight."
|
|
299
|
+
: "The reviewer approved and the merge decision is committed — use Merge on the final review action.",
|
|
300
|
+
};
|
|
301
|
+
case "done":
|
|
302
|
+
case "closed":
|
|
303
|
+
return { editable: false, reason: `The issue is ${issue.status}.` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* NOT-490: an `autoMerge`-only update — the one input that may change during a live
|
|
308
|
+
* round, because nothing reads it until approved-review routing. Guard re-check, row
|
|
309
|
+
* write and audit run in one SQLite transaction, which serializes against the
|
|
310
|
+
* reviewer-completion transaction: an update committed first is the value
|
|
311
|
+
* `applyReviewer` routes on; a reviewer decision committed first moves the issue to
|
|
312
|
+
* `final_review` and this answers 409, so one approval can never yield both an
|
|
313
|
+
* auto-merge and a `final_review` action. Queue entry, frozen snapshot, agents,
|
|
314
|
+
* budgets, branch and PR are untouched (`updateIssue` writes only the policy column
|
|
315
|
+
* here). A real change appends one `issue.reassigned` event with before/after values
|
|
316
|
+
* and the actor; a no-op appends nothing.
|
|
317
|
+
*/
|
|
318
|
+
export function applyAutoMergePolicyUpdate(issueId, autoMerge, actor) {
|
|
319
|
+
return getDb().transaction(() => {
|
|
320
|
+
const fresh = getIssue(issueId);
|
|
321
|
+
if (!fresh)
|
|
322
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
323
|
+
const editability = autoMergePolicyEditability(fresh);
|
|
324
|
+
if (!editability.editable) {
|
|
325
|
+
return { ok: false, code: 409, error: `Auto-merge can no longer be changed: ${editability.reason}` };
|
|
326
|
+
}
|
|
327
|
+
if (fresh.autoMerge === autoMerge)
|
|
328
|
+
return { ok: true, issue: fresh, changed: false };
|
|
329
|
+
const next = updateIssue(issueId, { autoMerge });
|
|
330
|
+
appendWorkflowEvent({
|
|
331
|
+
issueId,
|
|
332
|
+
workflowInstanceId: getActiveWorkflowInstance(issueId)?.id ?? null,
|
|
333
|
+
type: "issue.reassigned",
|
|
334
|
+
actorType: "human",
|
|
335
|
+
stage: next.status,
|
|
336
|
+
round: next.currentRound,
|
|
337
|
+
payload: { fromAutoMerge: fresh.autoMerge, toAutoMerge: next.autoMerge, actor },
|
|
338
|
+
});
|
|
339
|
+
return { ok: true, issue: next, changed: true };
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
264
342
|
/**
|
|
265
343
|
* Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
|
|
266
344
|
* excluded: it is content-addressed from the compared fields under an
|
|
@@ -42,6 +42,8 @@ function newIssue(opts = {}) {
|
|
|
42
42
|
baseBranch: "main",
|
|
43
43
|
maxReviewRounds: opts.maxReviewRounds ?? 3,
|
|
44
44
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
45
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
46
|
+
autoMerge: false,
|
|
45
47
|
source: "manual"
|
|
46
48
|
}).id;
|
|
47
49
|
}
|
|
@@ -89,6 +89,8 @@ async function makeIssue(opts = {}) {
|
|
|
89
89
|
reviewerAgentId: rev.id,
|
|
90
90
|
maxReviewRounds: 3,
|
|
91
91
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
92
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
93
|
+
autoMerge: false,
|
|
92
94
|
source: "manual"
|
|
93
95
|
}).id;
|
|
94
96
|
}
|
|
@@ -358,13 +358,15 @@ function recordVisualEvidence(issueId, opts) {
|
|
|
358
358
|
baseSha: "base1",
|
|
359
359
|
uiAffecting: true,
|
|
360
360
|
state: opts.state,
|
|
361
|
-
runId: opts.state === "
|
|
361
|
+
runId: opts.state === "available" || opts.state === "failed" ? 42 : null,
|
|
362
362
|
artifactNames: opts.state === "available" ? ["ui-screenshots", "ui-baseline", "ui-diff"] : [],
|
|
363
363
|
reason: opts.state === "available"
|
|
364
364
|
? null
|
|
365
365
|
: opts.state === "failed"
|
|
366
366
|
? "Visual run 42 for this head concluded failure — its captures are unusable"
|
|
367
|
-
:
|
|
367
|
+
: opts.state === "not_applicable"
|
|
368
|
+
? "repository has no Visual workflow (workflow visual.yml not found on the default branch) — nothing to capture"
|
|
369
|
+
: "no Visual workflow run found for head abc123",
|
|
368
370
|
},
|
|
369
371
|
author: "system",
|
|
370
372
|
});
|
|
@@ -420,6 +422,22 @@ test("NOT-384: available visual evidence on a UI head merges normally", async ()
|
|
|
420
422
|
assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
|
|
421
423
|
assert.equal(getIssue(issueId).status, "done");
|
|
422
424
|
});
|
|
425
|
+
test("NOT-538: not_applicable visual evidence (no Visual workflow) on a UI head merges normally", async () => {
|
|
426
|
+
const calls = [];
|
|
427
|
+
setMergePrForTests(async (opts) => {
|
|
428
|
+
calls.push(opts);
|
|
429
|
+
return { ok: true };
|
|
430
|
+
});
|
|
431
|
+
const issueId = newIssue("It works");
|
|
432
|
+
startWorkflow(issueId);
|
|
433
|
+
recordVisualEvidence(issueId, { headSha: "abc123", state: "not_applicable" });
|
|
434
|
+
const result = await approve(issueId);
|
|
435
|
+
assert.equal(result.applied, true);
|
|
436
|
+
if (result.applied)
|
|
437
|
+
assert.equal(result.issueStatus, "done");
|
|
438
|
+
assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
|
|
439
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
440
|
+
});
|
|
423
441
|
test("NOT-384: [operator] criteria plus a visual hold raise one action naming both", async () => {
|
|
424
442
|
let merges = 0;
|
|
425
443
|
setMergePrForTests(async () => {
|
|
@@ -104,6 +104,8 @@ async function makeIssue(opts = {}) {
|
|
|
104
104
|
reviewerAgentId: rev.id,
|
|
105
105
|
maxReviewRounds: 3,
|
|
106
106
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
107
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
108
|
+
autoMerge: false,
|
|
107
109
|
source: "manual"
|
|
108
110
|
}).id;
|
|
109
111
|
}
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
// Dealer's issue-artifact storage, stage a read-only copy inside the
|
|
23
23
|
// reviewer's worktree (outside tracked paths, ignored by `isWorktreeClean`),
|
|
24
24
|
// and record one `visual_evidence` artifact row. It never throws: every
|
|
25
|
-
// failure mode resolves to `missing` with a reason
|
|
26
|
-
//
|
|
25
|
+
// failure mode resolves to `missing` with a reason — except a lookup 404
|
|
26
|
+
// proving the repository has no `visual.yml`, which resolves to
|
|
27
|
+
// `not_applicable` (nothing to capture) — and a missing or failed capture
|
|
28
|
+
// never counts as a visual pass.
|
|
27
29
|
// 4. `readVisualEvidenceHold` — the merge-gate reader (auto-merge.ts): a
|
|
28
30
|
// UI-affecting head whose recorded evidence is `missing` or `failed` holds
|
|
29
31
|
// for `operator_verification` instead of merging or queuing a repair round.
|
|
@@ -334,6 +336,24 @@ function truncateReason(text, maxChars = 300) {
|
|
|
334
336
|
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
335
337
|
return oneLine.length > maxChars ? `${oneLine.slice(0, maxChars).trimEnd()}…` : oneLine;
|
|
336
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* NOT-538: true when a Visual run lookup failure means the repository's
|
|
341
|
+
* default branch has no `visual.yml` (`gh run list --workflow visual.yml`
|
|
342
|
+
* answers HTTP 404: "workflow visual.yml not found on the default branch"),
|
|
343
|
+
* rather than a transient or auth failure. Deliberately narrow — all three
|
|
344
|
+
* markers must be present — so auth, network, 5xx, and rate-limit failures
|
|
345
|
+
* keep failing closed to `missing`.
|
|
346
|
+
*/
|
|
347
|
+
export function isVisualWorkflowNotFoundError(message) {
|
|
348
|
+
const text = message.toLowerCase();
|
|
349
|
+
return text.includes("404") && text.includes("visual.yml") && text.includes("not found");
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* NOT-538: the recorded reason when the lookup proves the repository has no
|
|
353
|
+
* Visual workflow. The head still matches the UI paths (`uiAffecting: true`),
|
|
354
|
+
* but there is nothing to capture, so the merge gate must not hold.
|
|
355
|
+
*/
|
|
356
|
+
export const VISUAL_WORKFLOW_ABSENT_REASON = "repository has no Visual workflow (workflow visual.yml not found on the default branch) — nothing to capture";
|
|
337
357
|
function noEvidence(uiAffecting, base, reason) {
|
|
338
358
|
return {
|
|
339
359
|
...base,
|
|
@@ -357,7 +377,9 @@ function noEvidence(uiAffecting, base, reason) {
|
|
|
357
377
|
*
|
|
358
378
|
* Never throws and never returns usable evidence it does not have: unknown
|
|
359
379
|
* changed files fail closed to UI-affecting + `missing`, and every lookup,
|
|
360
|
-
* download, stage, or record failure degrades to `missing` with a reason
|
|
380
|
+
* download, stage, or record failure degrades to `missing` with a reason —
|
|
381
|
+
* except a lookup 404 proving the repository has no Visual workflow, which
|
|
382
|
+
* records `not_applicable` instead (NOT-538).
|
|
361
383
|
*/
|
|
362
384
|
export async function resolveReviewerVisualEvidence(opts) {
|
|
363
385
|
const base = { headSha: opts.headSha, baseSha: opts.baseSha };
|
|
@@ -411,7 +433,25 @@ export async function resolveReviewerVisualEvidence(opts) {
|
|
|
411
433
|
run = await fetcher.findRunForHead({ cwd: opts.worktreePath, headSha: opts.headSha });
|
|
412
434
|
}
|
|
413
435
|
catch (err) {
|
|
414
|
-
const
|
|
436
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
437
|
+
if (isVisualWorkflowNotFoundError(message)) {
|
|
438
|
+
const resolved = {
|
|
439
|
+
...base,
|
|
440
|
+
uiAffecting: true,
|
|
441
|
+
state: "not_applicable",
|
|
442
|
+
runId: null,
|
|
443
|
+
artifactNames: [],
|
|
444
|
+
stagedDir: null,
|
|
445
|
+
summaryChars: null,
|
|
446
|
+
reason: VISUAL_WORKFLOW_ABSENT_REASON,
|
|
447
|
+
staged: null,
|
|
448
|
+
summary: null,
|
|
449
|
+
files: null,
|
|
450
|
+
};
|
|
451
|
+
record(resolved, null);
|
|
452
|
+
return resolved;
|
|
453
|
+
}
|
|
454
|
+
const resolved = noEvidence(true, base, `Visual run lookup failed: ${truncateReason(message)}`);
|
|
415
455
|
record(resolved, null);
|
|
416
456
|
return resolved;
|
|
417
457
|
}
|
|
@@ -17,7 +17,7 @@ const { createIssue } = await import("../repository/issues.js");
|
|
|
17
17
|
const { createIssueArtifact } = await import("../repository/artifacts.js");
|
|
18
18
|
const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
|
|
19
19
|
const { isWorktreeClean, REVIEWER_VISUAL_STAGING_DIR_NAME } = await import("../adapters/git-worktree.js");
|
|
20
|
-
const { VISUAL_WORKFLOW_PATHS, VISUAL_ARTIFACT_NAMES, VISUAL_EVIDENCE_ARTIFACT_KIND, isUiAffectingChange, classifyVisualEvidence, githubVisualEvidenceFetcher, resolveReviewerVisualEvidence, stageVisualArtifacts, cleanupStagedVisualArtifacts, readVisualDiffSummary, reviewerVisualStagingDir, toReviewerVisualEvidenceInput, readVisualEvidenceHold, } = await import("./reviewer-visual-evidence.js");
|
|
20
|
+
const { VISUAL_WORKFLOW_PATHS, VISUAL_ARTIFACT_NAMES, VISUAL_EVIDENCE_ARTIFACT_KIND, VISUAL_WORKFLOW_ABSENT_REASON, isUiAffectingChange, classifyVisualEvidence, githubVisualEvidenceFetcher, resolveReviewerVisualEvidence, stageVisualArtifacts, cleanupStagedVisualArtifacts, readVisualDiffSummary, reviewerVisualStagingDir, toReviewerVisualEvidenceInput, readVisualEvidenceHold, } = await import("./reviewer-visual-evidence.js");
|
|
21
21
|
before(() => migrate());
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
getDb().exec(`
|
|
@@ -285,6 +285,8 @@ function fixtureFetcher(opts) {
|
|
|
285
285
|
return {
|
|
286
286
|
calls,
|
|
287
287
|
async findRunForHead() {
|
|
288
|
+
if (opts.failLookupError !== undefined)
|
|
289
|
+
throw new Error(opts.failLookupError);
|
|
288
290
|
if (opts.failLookup)
|
|
289
291
|
throw new Error("gh exploded");
|
|
290
292
|
return opts.run === undefined
|
|
@@ -494,3 +496,129 @@ test("NOT-384: gate reader holds only UI heads with missing/failed evidence at t
|
|
|
494
496
|
});
|
|
495
497
|
assert.equal(readVisualEvidenceHold(nonUiIssue, HEAD), null);
|
|
496
498
|
});
|
|
499
|
+
// NOT-538: a repository whose default branch has no visual.yml has nothing to
|
|
500
|
+
// capture — the lookup's "workflow not found" 404 resolves to not_applicable
|
|
501
|
+
// (the head still matches the UI paths) instead of holding the PR.
|
|
502
|
+
test("NOT-538: workflow-not-found 404 records not_applicable, never missing", async () => {
|
|
503
|
+
const issueId = makeIssue();
|
|
504
|
+
const worktree = initRepo();
|
|
505
|
+
try {
|
|
506
|
+
const resolved = await resolveReviewerVisualEvidence({
|
|
507
|
+
issueId,
|
|
508
|
+
worktreePath: worktree,
|
|
509
|
+
baseSha: "c".repeat(40),
|
|
510
|
+
headSha: HEAD,
|
|
511
|
+
changedFiles: ["packages/shared/src/types.ts"],
|
|
512
|
+
github: {},
|
|
513
|
+
fetcher: fixtureFetcher({
|
|
514
|
+
failLookupError: "gh run list failed: HTTP 404: workflow visual.yml not found on the default branch",
|
|
515
|
+
}),
|
|
516
|
+
});
|
|
517
|
+
assert.equal(resolved.state, "not_applicable");
|
|
518
|
+
assert.equal(resolved.uiAffecting, true);
|
|
519
|
+
assert.equal(resolved.runId, null);
|
|
520
|
+
assert.equal(resolved.staged, null);
|
|
521
|
+
assert.equal(resolved.reason, VISUAL_WORKFLOW_ABSENT_REASON);
|
|
522
|
+
assert.match(resolved.reason, /repository has no Visual workflow/);
|
|
523
|
+
assert.equal(toReviewerVisualEvidenceInput(resolved), undefined);
|
|
524
|
+
const rows = listArtifactsForIssueByKind(issueId, VISUAL_EVIDENCE_ARTIFACT_KIND);
|
|
525
|
+
assert.equal(rows.length, 1);
|
|
526
|
+
const content = JSON.parse(rows[0].contentJson);
|
|
527
|
+
assert.equal(content.state, "not_applicable");
|
|
528
|
+
assert.equal(content.uiAffecting, true);
|
|
529
|
+
// The recorded workflow-absent record releases the merge gate.
|
|
530
|
+
assert.equal(readVisualEvidenceHold(issueId, HEAD), null);
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
// NOT-538: every other lookup failure still fails closed to missing with the
|
|
537
|
+
// existing reason — only the workflow-not-found 404 skips the gate.
|
|
538
|
+
test("NOT-538: auth, network, 5xx, and rate-limit lookup failures still record missing", async () => {
|
|
539
|
+
const worktree = initRepo();
|
|
540
|
+
try {
|
|
541
|
+
const failures = {
|
|
542
|
+
auth: "gh run list failed: HTTP 401: Bad credentials",
|
|
543
|
+
network: "gh run list failed: Could not resolve host: api.github.com",
|
|
544
|
+
server: "gh run list failed: HTTP 502: Bad Gateway",
|
|
545
|
+
rateLimit: "gh run list failed: HTTP 403: API rate limit exceeded for user ID 123",
|
|
546
|
+
};
|
|
547
|
+
for (const [kind, message] of Object.entries(failures)) {
|
|
548
|
+
const resolved = await resolveReviewerVisualEvidence({
|
|
549
|
+
issueId: makeIssue(),
|
|
550
|
+
worktreePath: worktree,
|
|
551
|
+
baseSha: "c".repeat(40),
|
|
552
|
+
headSha: HEAD,
|
|
553
|
+
changedFiles: ["packages/shared/src/types.ts"],
|
|
554
|
+
github: {},
|
|
555
|
+
fetcher: fixtureFetcher({ failLookupError: message }),
|
|
556
|
+
});
|
|
557
|
+
assert.equal(resolved.state, "missing", `${kind} failure must stay missing`);
|
|
558
|
+
assert.equal(resolved.uiAffecting, true);
|
|
559
|
+
assert.match(resolved.reason, /^Visual run lookup failed: /, `${kind} failure keeps the lookup-failed reason`);
|
|
560
|
+
}
|
|
561
|
+
// Near-miss 404s that do not name the missing workflow also stay missing.
|
|
562
|
+
for (const message of [
|
|
563
|
+
"gh run list failed: HTTP 404: Not Found",
|
|
564
|
+
"gh run list failed: HTTP 404: repository not found",
|
|
565
|
+
]) {
|
|
566
|
+
const resolved = await resolveReviewerVisualEvidence({
|
|
567
|
+
issueId: makeIssue(),
|
|
568
|
+
worktreePath: worktree,
|
|
569
|
+
baseSha: "c".repeat(40),
|
|
570
|
+
headSha: HEAD,
|
|
571
|
+
changedFiles: ["packages/shared/src/types.ts"],
|
|
572
|
+
github: {},
|
|
573
|
+
fetcher: fixtureFetcher({ failLookupError: message }),
|
|
574
|
+
});
|
|
575
|
+
assert.equal(resolved.state, "missing", `${message} must stay missing`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
// NOT-538: the gate reader releases a UI-affecting head whose record is
|
|
583
|
+
// not_applicable because the repository has no Visual workflow, and still
|
|
584
|
+
// holds missing and failed.
|
|
585
|
+
test("NOT-538: gate reader releases workflow-absent not_applicable, holds missing and failed", () => {
|
|
586
|
+
const absentIssue = makeIssue();
|
|
587
|
+
createIssueArtifact({
|
|
588
|
+
issueId: absentIssue,
|
|
589
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
590
|
+
content: {
|
|
591
|
+
headSha: HEAD,
|
|
592
|
+
uiAffecting: true,
|
|
593
|
+
state: "not_applicable",
|
|
594
|
+
reason: VISUAL_WORKFLOW_ABSENT_REASON,
|
|
595
|
+
runId: null,
|
|
596
|
+
artifactNames: [],
|
|
597
|
+
},
|
|
598
|
+
author: "system",
|
|
599
|
+
});
|
|
600
|
+
assert.equal(readVisualEvidenceHold(absentIssue, HEAD), null);
|
|
601
|
+
const missingIssue = makeIssue();
|
|
602
|
+
createIssueArtifact({
|
|
603
|
+
issueId: missingIssue,
|
|
604
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
605
|
+
content: {
|
|
606
|
+
headSha: HEAD,
|
|
607
|
+
uiAffecting: true,
|
|
608
|
+
state: "missing",
|
|
609
|
+
reason: "Visual run lookup failed: HTTP 502: Bad Gateway",
|
|
610
|
+
runId: null,
|
|
611
|
+
artifactNames: [],
|
|
612
|
+
},
|
|
613
|
+
author: "system",
|
|
614
|
+
});
|
|
615
|
+
assert.equal(readVisualEvidenceHold(missingIssue, HEAD).state, "missing");
|
|
616
|
+
const failedIssue = makeIssue();
|
|
617
|
+
createIssueArtifact({
|
|
618
|
+
issueId: failedIssue,
|
|
619
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
620
|
+
content: { headSha: HEAD, uiAffecting: true, state: "failed", reason: "boom", runId: 9, artifactNames: [] },
|
|
621
|
+
author: "system",
|
|
622
|
+
});
|
|
623
|
+
assert.equal(readVisualEvidenceHold(failedIssue, HEAD).state, "failed");
|
|
624
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// NOT-489: a clean install's `issues.auto_merge` column defaults on, while an upgrade
|
|
2
|
+
// never rewrites an existing issue's stored choice — and new creates on the upgraded
|
|
3
|
+
// database still take the shared `CreateIssueInput` default (true), not the column's.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-auto-merge-migrate-"));
|
|
12
|
+
const { migrate, getDb } = await import("./index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const autoMergeDefault = (db) => db.prepare("PRAGMA table_info(issues)").all().find((c) => c.name === "auto_merge").dflt_value;
|
|
17
|
+
const storedAutoMerge = (id) => getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(id).auto_merge;
|
|
18
|
+
function newIssueId(autoMerge) {
|
|
19
|
+
return createIssue({
|
|
20
|
+
title: "T",
|
|
21
|
+
repo: "github.com/acme/app",
|
|
22
|
+
baseBranch: "main",
|
|
23
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
24
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
25
|
+
maxReviewRounds: 3,
|
|
26
|
+
maxInfraAttempts: 3,
|
|
27
|
+
source: "manual",
|
|
28
|
+
...(autoMerge === undefined ? {} : { autoMerge }),
|
|
29
|
+
}).id;
|
|
30
|
+
}
|
|
31
|
+
test("a clean schema defaults auto_merge on", () => {
|
|
32
|
+
const db = new Database(":memory:");
|
|
33
|
+
db.exec(fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8"));
|
|
34
|
+
assert.equal(autoMergeDefault(db), "1");
|
|
35
|
+
});
|
|
36
|
+
test("upgrading preserves every stored auto_merge value; post-upgrade omitted-field creates still get true", () => {
|
|
37
|
+
migrate();
|
|
38
|
+
const optedOut = newIssueId(false);
|
|
39
|
+
const optedIn = newIssueId(true);
|
|
40
|
+
// An upgraded database whose column predates NOT-489 (still DEFAULT 0).
|
|
41
|
+
getDb().exec("ALTER TABLE issues DROP COLUMN auto_merge");
|
|
42
|
+
getDb().exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
43
|
+
getDb().prepare("UPDATE issues SET auto_merge = 1 WHERE id = ?").run(optedIn);
|
|
44
|
+
assert.equal(autoMergeDefault(getDb()), "0");
|
|
45
|
+
migrate();
|
|
46
|
+
assert.equal(storedAutoMerge(optedOut), 0, "a pre-existing false row is not rewritten");
|
|
47
|
+
assert.equal(getIssue(optedOut).autoMerge, false);
|
|
48
|
+
assert.equal(storedAutoMerge(optedIn), 1);
|
|
49
|
+
const fresh = newIssueId();
|
|
50
|
+
assert.equal(storedAutoMerge(fresh), 1, "an omitted-field create on the upgraded DB stores true");
|
|
51
|
+
assert.equal(getIssue(fresh).autoMerge, true);
|
|
52
|
+
});
|
|
@@ -264,8 +264,10 @@ export function migrate() {
|
|
|
264
264
|
if (!issueColsForCi.some((c) => c.name === "ci_attempts")) {
|
|
265
265
|
db.exec("ALTER TABLE issues ADD COLUMN ci_attempts INTEGER NOT NULL DEFAULT 0");
|
|
266
266
|
}
|
|
267
|
-
// NOT-102: per-issue auto-merge after reviewer approve
|
|
268
|
-
//
|
|
267
|
+
// NOT-102: per-issue auto-merge after reviewer approve. Rows that predate the column
|
|
268
|
+
// backfill off so in-flight workflows keep the human final_review path. NOT-489: new
|
|
269
|
+
// creates write the shared `CreateIssueInput` default (true) explicitly, so this
|
|
270
|
+
// backfill DEFAULT never decides a new issue's policy and stays 0 to preserve old rows.
|
|
269
271
|
const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
|
|
270
272
|
if (!issueColsForAutoMerge.some((c) => c.name === "auto_merge")) {
|
|
271
273
|
db.exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
@@ -280,6 +280,8 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
|
|
|
280
280
|
"Widget renders.",
|
|
281
281
|
"--base-branch",
|
|
282
282
|
"main",
|
|
283
|
+
// NOT-489: auto-merge is the default; this walk drives the final-review action.
|
|
284
|
+
"--no-auto-merge",
|
|
283
285
|
]));
|
|
284
286
|
const issueId = created.id;
|
|
285
287
|
assert.equal(created.status, "ready");
|