patchrelay 0.65.0 → 0.67.0
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/dist/build-info.json +3 -3
- package/dist/change-identity.js +46 -0
- package/dist/cli/args.js +1 -0
- package/dist/cli/commands/sequence-check.js +139 -0
- package/dist/cli/help.js +18 -0
- package/dist/cli/index.js +20 -0
- package/dist/db/issue-store.js +20 -0
- package/dist/db/migrations.js +12 -0
- package/dist/db/run-store.js +28 -0
- package/dist/db.js +1 -0
- package/dist/factory-state.js +14 -0
- package/dist/github-webhook-handler.js +26 -0
- package/dist/github-webhook-policy.js +7 -1
- package/dist/github-webhook-sequence-backstop.js +94 -0
- package/dist/github-webhook-stack-coordination.js +42 -0
- package/dist/github-webhook-state-projector.js +83 -1
- package/dist/github-webhooks.js +1 -0
- package/dist/pr-sequencing.js +132 -0
- package/dist/prompting/patchrelay.js +14 -0
- package/dist/reactive-run-policy.js +7 -0
- package/dist/run-finalizer.js +83 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export function computeChangeIdentityFromWorktree(params) {
|
|
3
|
+
const cwd = params.worktreePath;
|
|
4
|
+
const baseSha = resolveSha(cwd, params.baseRef);
|
|
5
|
+
const headSha = params.headSha
|
|
6
|
+
? resolveSha(cwd, params.headSha)
|
|
7
|
+
: resolveSha(cwd, "HEAD");
|
|
8
|
+
if (!baseSha || !headSha)
|
|
9
|
+
return {};
|
|
10
|
+
const patchId = computePatchId(cwd, baseSha, headSha);
|
|
11
|
+
const integrationTreeId = computeIntegrationTreeId(cwd, baseSha, headSha);
|
|
12
|
+
return {
|
|
13
|
+
...(patchId ? { patchId } : {}),
|
|
14
|
+
...(integrationTreeId ? { integrationTreeId } : {}),
|
|
15
|
+
baseSha,
|
|
16
|
+
headSha,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function resolveSha(cwd, ref) {
|
|
20
|
+
const result = spawnSync("git", ["-C", cwd, "rev-parse", ref], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
});
|
|
23
|
+
if (result.status !== 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const sha = result.stdout.trim();
|
|
26
|
+
return sha || undefined;
|
|
27
|
+
}
|
|
28
|
+
function computePatchId(cwd, base, head) {
|
|
29
|
+
// Pipe `git diff` through `git patch-id --stable`. Use sh -c to keep
|
|
30
|
+
// the pipeline atomic and avoid plumbing stdio between two spawns.
|
|
31
|
+
const result = spawnSync("sh", ["-c", `git -C ${shellQuote(cwd)} diff ${shellQuote(base)}..${shellQuote(head)} | git patch-id --stable`], { encoding: "utf8" });
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const first = result.stdout.split(/\s+/, 1)[0]?.trim();
|
|
35
|
+
return first ? first : undefined;
|
|
36
|
+
}
|
|
37
|
+
function computeIntegrationTreeId(cwd, base, head) {
|
|
38
|
+
const result = spawnSync("git", ["-C", cwd, "merge-tree", "--write-tree", "--no-messages", base, head], { encoding: "utf8" });
|
|
39
|
+
if (result.status !== 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
const tree = result.stdout.trim().split(/\s+/, 1)[0];
|
|
42
|
+
return tree || undefined;
|
|
43
|
+
}
|
|
44
|
+
function shellQuote(value) {
|
|
45
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
46
|
+
}
|
package/dist/cli/args.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectStackingTarget } from "../../pr-sequencing.js";
|
|
4
|
+
import { CliUsageError } from "../errors.js";
|
|
5
|
+
import { formatJson } from "../formatters/json.js";
|
|
6
|
+
import { writeOutput } from "../output.js";
|
|
7
|
+
export async function handleSequenceCheckCommand(params) {
|
|
8
|
+
if (params.commandArgs.length > 0) {
|
|
9
|
+
throw new CliUsageError(`Unexpected argument for sequence-check: ${params.commandArgs[0]}`, "sequence-check");
|
|
10
|
+
}
|
|
11
|
+
const cwd = params.cwd ?? process.cwd();
|
|
12
|
+
const baseFlag = params.parsed.flags.get("base");
|
|
13
|
+
const overrideBase = typeof baseFlag === "string" ? baseFlag.trim() : "";
|
|
14
|
+
const self = params.selfProvider
|
|
15
|
+
? params.selfProvider()
|
|
16
|
+
: resolveSelf(cwd, overrideBase || undefined);
|
|
17
|
+
if (!self) {
|
|
18
|
+
writeOutput(params.stderr, "sequence-check: not inside a git work tree, or unable to resolve HEAD\n");
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const candidates = params.candidatesProvider
|
|
22
|
+
? params.candidatesProvider()
|
|
23
|
+
: collectCandidates(params.data.db, self.branch);
|
|
24
|
+
const probe = params.gitProbe ?? cliGitProbe(cwd);
|
|
25
|
+
const recommendation = await detectStackingTarget({
|
|
26
|
+
self,
|
|
27
|
+
candidates,
|
|
28
|
+
git: probe,
|
|
29
|
+
});
|
|
30
|
+
if (params.json) {
|
|
31
|
+
writeOutput(params.stdout, formatJson(recommendation));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
writeOutput(params.stdout, `${JSON.stringify(recommendation)}\n`);
|
|
35
|
+
}
|
|
36
|
+
writeOutput(params.stderr, formatHumanSummary(recommendation));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
function resolveSelf(cwd, overrideBase) {
|
|
40
|
+
const branchResult = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
if (branchResult.status !== 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const branch = branchResult.stdout.trim();
|
|
47
|
+
if (!branch || branch === "HEAD")
|
|
48
|
+
return undefined;
|
|
49
|
+
const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" });
|
|
50
|
+
if (headResult.status !== 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
const headSha = headResult.stdout.trim();
|
|
53
|
+
if (!headSha)
|
|
54
|
+
return undefined;
|
|
55
|
+
const baseRef = overrideBase ?? resolveDefaultBranchRef(cwd);
|
|
56
|
+
if (!baseRef)
|
|
57
|
+
return undefined;
|
|
58
|
+
return { branch, headSha, baseRef };
|
|
59
|
+
}
|
|
60
|
+
function resolveDefaultBranchRef(cwd) {
|
|
61
|
+
// Prefer the symbolic upstream of origin/HEAD; fall back to main.
|
|
62
|
+
const symbolic = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
});
|
|
66
|
+
if (symbolic.status === 0) {
|
|
67
|
+
const ref = symbolic.stdout.trim();
|
|
68
|
+
if (ref)
|
|
69
|
+
return ref;
|
|
70
|
+
}
|
|
71
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
72
|
+
const probe = spawnSync("git", ["rev-parse", "--verify", candidate], {
|
|
73
|
+
cwd,
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
});
|
|
76
|
+
if (probe.status === 0)
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
return "origin/main";
|
|
80
|
+
}
|
|
81
|
+
function collectCandidates(db, selfBranch) {
|
|
82
|
+
const issues = db.issues.listIssues();
|
|
83
|
+
const candidates = [];
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const issue of issues) {
|
|
86
|
+
if (issue.factoryState !== "pr_open" && issue.factoryState !== "awaiting_queue")
|
|
87
|
+
continue;
|
|
88
|
+
if (!issue.branchName || !issue.prHeadSha || !issue.prNumber)
|
|
89
|
+
continue;
|
|
90
|
+
if (issue.branchName === selfBranch)
|
|
91
|
+
continue;
|
|
92
|
+
const queueAgeMs = issue.updatedAt
|
|
93
|
+
? Math.max(0, now - Date.parse(issue.updatedAt))
|
|
94
|
+
: undefined;
|
|
95
|
+
candidates.push({
|
|
96
|
+
prNumber: issue.prNumber,
|
|
97
|
+
branch: issue.branchName,
|
|
98
|
+
headSha: issue.prHeadSha,
|
|
99
|
+
...(issue.prReviewState ? { reviewState: issue.prReviewState } : {}),
|
|
100
|
+
...(issue.prCheckStatus ? { checkStatus: issue.prCheckStatus } : {}),
|
|
101
|
+
factoryState: issue.factoryState,
|
|
102
|
+
...(queueAgeMs !== undefined ? { queueAgeMs } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return candidates;
|
|
106
|
+
}
|
|
107
|
+
function cliGitProbe(cwd) {
|
|
108
|
+
return {
|
|
109
|
+
async changedFiles(baseRef, headSha) {
|
|
110
|
+
const result = spawnSync("git", ["diff", "--name-only", `${baseRef}...${headSha}`], { cwd, encoding: "utf8" });
|
|
111
|
+
if (result.status !== 0) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return result.stdout
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
},
|
|
119
|
+
async hasConflict(headSha, candidateHeadSha) {
|
|
120
|
+
// `git merge-tree --write-tree` exits non-zero on conflict in
|
|
121
|
+
// modern git; with `--no-messages` it suppresses commit-msg
|
|
122
|
+
// suggestions. Use the auto-merge-base form (two operands).
|
|
123
|
+
const result = spawnSync("git", ["merge-tree", "--write-tree", "--no-messages", headSha, candidateHeadSha], { cwd, encoding: "utf8" });
|
|
124
|
+
return result.status !== 0;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function formatHumanSummary(recommendation) {
|
|
129
|
+
if (recommendation.recommendation === "open_pr_against_main") {
|
|
130
|
+
return `sequence-check: open PR against main — ${recommendation.reason}\n`;
|
|
131
|
+
}
|
|
132
|
+
return [
|
|
133
|
+
`sequence-check: rebase onto PR #${recommendation.parentPr} (${recommendation.parentBranch})`,
|
|
134
|
+
` reason: ${recommendation.reason}`,
|
|
135
|
+
` parent head: ${recommendation.parentHead}`,
|
|
136
|
+
"",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
export { path };
|
package/dist/cli/help.js
CHANGED
|
@@ -214,7 +214,25 @@ export function helpTextFor(topic) {
|
|
|
214
214
|
return issueHelpText();
|
|
215
215
|
case "service":
|
|
216
216
|
return serviceHelpText();
|
|
217
|
+
case "sequence-check":
|
|
218
|
+
return sequenceCheckHelpText();
|
|
217
219
|
default:
|
|
218
220
|
return rootHelpText();
|
|
219
221
|
}
|
|
220
222
|
}
|
|
223
|
+
function sequenceCheckHelpText() {
|
|
224
|
+
return [
|
|
225
|
+
"patchrelay sequence-check",
|
|
226
|
+
"",
|
|
227
|
+
"Detect whether the current branch should be stacked on an in-flight PR before",
|
|
228
|
+
"opening a new one. Run from inside the worktree right before `gh pr create`.",
|
|
229
|
+
"",
|
|
230
|
+
"Output: JSON recommendation on stdout. Either:",
|
|
231
|
+
" {\"recommendation\":\"open_pr_against_main\",\"reason\":\"…\"}",
|
|
232
|
+
" {\"recommendation\":\"rebase_onto\",\"parentPr\":509,\"parentBranch\":\"…\",…}",
|
|
233
|
+
"",
|
|
234
|
+
"Flags:",
|
|
235
|
+
" --base <ref> Override the base ref used for diff (default: origin/HEAD)",
|
|
236
|
+
" --json Emit pretty-printed JSON",
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args
|
|
|
4
4
|
import { handleIssueCommand, } from "./commands/issues.js";
|
|
5
5
|
import { handleClusterCommand } from "./commands/cluster.js";
|
|
6
6
|
import { handleLinearCommand } from "./commands/linear.js";
|
|
7
|
+
import { handleSequenceCheckCommand } from "./commands/sequence-check.js";
|
|
7
8
|
import { handleRepoCommand } from "./commands/repo.js";
|
|
8
9
|
import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
|
|
9
10
|
import { CliUsageError } from "./errors.js";
|
|
@@ -24,6 +25,7 @@ function getCommandConfigProfile(command) {
|
|
|
24
25
|
case "cluster":
|
|
25
26
|
case "repo":
|
|
26
27
|
case "issue":
|
|
28
|
+
case "sequence-check":
|
|
27
29
|
return "cli";
|
|
28
30
|
default:
|
|
29
31
|
return "service";
|
|
@@ -87,6 +89,9 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
87
89
|
case "cluster":
|
|
88
90
|
assertKnownFlags(parsed, command, ["json"]);
|
|
89
91
|
return;
|
|
92
|
+
case "sequence-check":
|
|
93
|
+
assertKnownFlags(parsed, command, ["json", "base"]);
|
|
94
|
+
return;
|
|
90
95
|
case "init":
|
|
91
96
|
assertKnownFlags(parsed, command, ["force", "json", "public-base-url"]);
|
|
92
97
|
return;
|
|
@@ -349,6 +354,21 @@ export async function runCli(argv, options) {
|
|
|
349
354
|
const { handleWatchCommand } = await import("./commands/watch.js");
|
|
350
355
|
return await handleWatchCommand({ config, parsed });
|
|
351
356
|
}
|
|
357
|
+
if (command === "sequence-check") {
|
|
358
|
+
const issueData = await ensureIssueDataAccess(data, config);
|
|
359
|
+
if (!data) {
|
|
360
|
+
data = issueData;
|
|
361
|
+
ownsData = true;
|
|
362
|
+
}
|
|
363
|
+
return await handleSequenceCheckCommand({
|
|
364
|
+
commandArgs,
|
|
365
|
+
parsed,
|
|
366
|
+
json,
|
|
367
|
+
stdout,
|
|
368
|
+
stderr,
|
|
369
|
+
data: issueData,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
352
372
|
throw new Error(`Unknown command: ${command}`);
|
|
353
373
|
}
|
|
354
374
|
catch (error) {
|
package/dist/db/issue-store.js
CHANGED
|
@@ -224,6 +224,10 @@ export class IssueStore {
|
|
|
224
224
|
sets.push("last_published_head_sha = @lastPublishedHeadSha");
|
|
225
225
|
values.lastPublishedHeadSha = params.lastPublishedHeadSha;
|
|
226
226
|
}
|
|
227
|
+
if (params.parentPrBranch !== undefined) {
|
|
228
|
+
sets.push("parent_pr_branch = @parentPrBranch");
|
|
229
|
+
values.parentPrBranch = params.parentPrBranch;
|
|
230
|
+
}
|
|
227
231
|
if (params.ciRepairAttempts !== undefined) {
|
|
228
232
|
sets.push("ci_repair_attempts = @ciRepairAttempts");
|
|
229
233
|
values.ciRepairAttempts = params.ciRepairAttempts;
|
|
@@ -264,6 +268,7 @@ export class IssueStore {
|
|
|
264
268
|
last_queue_signal_at, last_queue_incident_json,
|
|
265
269
|
last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
|
|
266
270
|
last_published_patch_id, last_published_integration_tree_id, last_published_head_sha,
|
|
271
|
+
parent_pr_branch,
|
|
267
272
|
ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
|
|
268
273
|
updated_at
|
|
269
274
|
) VALUES (
|
|
@@ -278,6 +283,7 @@ export class IssueStore {
|
|
|
278
283
|
@lastQueueSignalAt, @lastQueueIncidentJson,
|
|
279
284
|
@lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
|
|
280
285
|
@lastPublishedPatchId, @lastPublishedIntegrationTreeId, @lastPublishedHeadSha,
|
|
286
|
+
@parentPrBranch,
|
|
281
287
|
@ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
|
|
282
288
|
@now
|
|
283
289
|
)
|
|
@@ -336,6 +342,7 @@ export class IssueStore {
|
|
|
336
342
|
lastPublishedPatchId: params.lastPublishedPatchId ?? null,
|
|
337
343
|
lastPublishedIntegrationTreeId: params.lastPublishedIntegrationTreeId ?? null,
|
|
338
344
|
lastPublishedHeadSha: params.lastPublishedHeadSha ?? null,
|
|
345
|
+
parentPrBranch: params.parentPrBranch ?? null,
|
|
339
346
|
ciRepairAttempts: params.ciRepairAttempts ?? 0,
|
|
340
347
|
queueRepairAttempts: params.queueRepairAttempts ?? 0,
|
|
341
348
|
reviewFixAttempts: params.reviewFixAttempts ?? 0,
|
|
@@ -419,6 +426,16 @@ export class IssueStore {
|
|
|
419
426
|
.all();
|
|
420
427
|
return rows.map(mapIssueRow);
|
|
421
428
|
}
|
|
429
|
+
// Plan §8.3: parent-of-child index. Given a parent's branch name,
|
|
430
|
+
// list every issue whose `parent_pr_branch` matches — i.e. PRs
|
|
431
|
+
// stacked on that parent. The index is hit on every
|
|
432
|
+
// `pr_synchronize` for a parent so it must stay cheap.
|
|
433
|
+
listIssuesWithParentBranch(branchName) {
|
|
434
|
+
const rows = this.connection
|
|
435
|
+
.prepare(`SELECT * FROM issues WHERE parent_pr_branch = ? AND factory_state NOT IN ('done', 'failed')`)
|
|
436
|
+
.all(branchName);
|
|
437
|
+
return rows.map(mapIssueRow);
|
|
438
|
+
}
|
|
422
439
|
// Issues that are approved by review-quill but stuck in In Review
|
|
423
440
|
// because branch CI is failing — the merge-steward never admits them.
|
|
424
441
|
// Plan §6.2: surface this as IN_REVIEW_STUCK so an operator notices
|
|
@@ -731,6 +748,9 @@ export function mapIssueRow(row) {
|
|
|
731
748
|
...(row.last_published_head_sha !== null && row.last_published_head_sha !== undefined
|
|
732
749
|
? { lastPublishedHeadSha: String(row.last_published_head_sha) }
|
|
733
750
|
: {}),
|
|
751
|
+
...(row.parent_pr_branch !== null && row.parent_pr_branch !== undefined
|
|
752
|
+
? { parentPrBranch: String(row.parent_pr_branch) }
|
|
753
|
+
: {}),
|
|
734
754
|
ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
|
|
735
755
|
queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
|
|
736
756
|
reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
|
package/dist/db/migrations.js
CHANGED
|
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
35
35
|
ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
36
36
|
queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
37
37
|
orchestration_settle_until TEXT,
|
|
38
|
+
parent_pr_branch TEXT,
|
|
38
39
|
updated_at TEXT NOT NULL,
|
|
39
40
|
UNIQUE(project_id, linear_issue_id)
|
|
40
41
|
);
|
|
@@ -62,6 +63,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
62
63
|
summary_json TEXT,
|
|
63
64
|
report_json TEXT,
|
|
64
65
|
failure_reason TEXT,
|
|
66
|
+
should_not_publish INTEGER NOT NULL DEFAULT 0,
|
|
65
67
|
started_at TEXT NOT NULL,
|
|
66
68
|
ended_at TEXT
|
|
67
69
|
);
|
|
@@ -297,6 +299,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
297
299
|
addColumnIfMissing(connection, "runs", "completion_check_why", "TEXT");
|
|
298
300
|
addColumnIfMissing(connection, "runs", "completion_check_recommended_reply", "TEXT");
|
|
299
301
|
addColumnIfMissing(connection, "runs", "completion_checked_at", "TEXT");
|
|
302
|
+
// Plan §4.4: hard publication-suppression flag for the
|
|
303
|
+
// mid-run-approval cancellation primitive.
|
|
304
|
+
addColumnIfMissing(connection, "runs", "should_not_publish", "INTEGER NOT NULL DEFAULT 0");
|
|
300
305
|
addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
|
|
301
306
|
// Collapse awaiting_review into pr_open (state normalization)
|
|
302
307
|
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
|
@@ -341,6 +346,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
341
346
|
addColumnIfMissing(connection, "issues", "last_published_patch_id", "TEXT");
|
|
342
347
|
addColumnIfMissing(connection, "issues", "last_published_integration_tree_id", "TEXT");
|
|
343
348
|
addColumnIfMissing(connection, "issues", "last_published_head_sha", "TEXT");
|
|
349
|
+
// Plan §8.3: parent-of-child index for stacked PRs.
|
|
350
|
+
addColumnIfMissing(connection, "issues", "parent_pr_branch", "TEXT");
|
|
351
|
+
connection.exec(`CREATE INDEX IF NOT EXISTS idx_issues_parent_pr_branch ON issues(parent_pr_branch);`);
|
|
344
352
|
addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
|
|
345
353
|
addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
|
|
346
354
|
addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
|
|
@@ -418,6 +426,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
418
426
|
last_published_patch_id TEXT,
|
|
419
427
|
last_published_integration_tree_id TEXT,
|
|
420
428
|
last_published_head_sha TEXT,
|
|
429
|
+
parent_pr_branch TEXT,
|
|
421
430
|
ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
422
431
|
queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
423
432
|
review_fix_attempts INTEGER NOT NULL DEFAULT 0,
|
|
@@ -484,6 +493,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
484
493
|
last_published_patch_id,
|
|
485
494
|
last_published_integration_tree_id,
|
|
486
495
|
last_published_head_sha,
|
|
496
|
+
parent_pr_branch,
|
|
487
497
|
ci_repair_attempts,
|
|
488
498
|
queue_repair_attempts,
|
|
489
499
|
review_fix_attempts,
|
|
@@ -548,6 +558,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
548
558
|
last_published_patch_id,
|
|
549
559
|
last_published_integration_tree_id,
|
|
550
560
|
last_published_head_sha,
|
|
561
|
+
parent_pr_branch,
|
|
551
562
|
COALESCE(ci_repair_attempts, 0),
|
|
552
563
|
COALESCE(queue_repair_attempts, 0),
|
|
553
564
|
COALESCE(review_fix_attempts, 0),
|
|
@@ -564,6 +575,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
564
575
|
CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
|
|
565
576
|
CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
|
|
566
577
|
CREATE INDEX IF NOT EXISTS idx_issues_branch ON issues(branch_name);
|
|
578
|
+
CREATE INDEX IF NOT EXISTS idx_issues_parent_pr_branch ON issues(parent_pr_branch);
|
|
567
579
|
`);
|
|
568
580
|
}
|
|
569
581
|
finally {
|
package/dist/db/run-store.js
CHANGED
|
@@ -102,6 +102,34 @@ export class RunStore {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
// Plan §4.4: flag a still-running run as superseded. We deliberately
|
|
106
|
+
// do NOT change `status` here — the Codex turn must finish naturally
|
|
107
|
+
// so the notification handler can deliver `turn/completed` to the
|
|
108
|
+
// run-finalizer (run-notification-handler ignores any run whose status
|
|
109
|
+
// is not 'running'). The finalizer reads `should_not_publish` and
|
|
110
|
+
// routes to `releaseSupersededRun`, which is where the row finally
|
|
111
|
+
// moves to status='superseded'. Setting status here would orphan the
|
|
112
|
+
// run: notifications would be dropped, the issue's activeRunId would
|
|
113
|
+
// never be cleared, and the lease would stay held.
|
|
114
|
+
markSuperseded(runId, params) {
|
|
115
|
+
this.connection.prepare(`
|
|
116
|
+
UPDATE runs SET
|
|
117
|
+
should_not_publish = 1,
|
|
118
|
+
failure_reason = COALESCE(failure_reason, ?)
|
|
119
|
+
WHERE id = ?
|
|
120
|
+
AND status IN ('queued', 'running')
|
|
121
|
+
`).run(params.reason, runId);
|
|
122
|
+
const run = this.getRunById(runId);
|
|
123
|
+
if (!run)
|
|
124
|
+
return;
|
|
125
|
+
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
126
|
+
if (issue) {
|
|
127
|
+
this.syncIssueSessionFromIssue(issue, {
|
|
128
|
+
summaryText: params.reason,
|
|
129
|
+
lastRunType: run.runType,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
105
133
|
saveCompletionCheck(runId, params) {
|
|
106
134
|
this.connection.prepare(`
|
|
107
135
|
UPDATE runs SET
|
package/dist/db.js
CHANGED
|
@@ -289,6 +289,7 @@ function mapRunRow(row) {
|
|
|
289
289
|
...(row.summary_json !== null ? { summaryJson: String(row.summary_json) } : {}),
|
|
290
290
|
...(row.report_json !== null ? { reportJson: String(row.report_json) } : {}),
|
|
291
291
|
...(row.failure_reason !== null ? { failureReason: String(row.failure_reason) } : {}),
|
|
292
|
+
...(row.should_not_publish === 1 || row.should_not_publish === true ? { shouldNotPublish: true } : {}),
|
|
292
293
|
startedAt: String(row.started_at),
|
|
293
294
|
...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
|
|
294
295
|
};
|
package/dist/factory-state.js
CHANGED
|
@@ -43,6 +43,20 @@ const TRANSITION_RULES = [
|
|
|
43
43
|
{ event: "review_approved",
|
|
44
44
|
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
45
45
|
to: "awaiting_queue" },
|
|
46
|
+
// Plan §4.4: mid-run approval cancellation. When an approval lands
|
|
47
|
+
// on the same head a `review_fix` run was launched against, the
|
|
48
|
+
// run's premise no longer holds — there is no "fix" to publish, the
|
|
49
|
+
// PR is already approved. We let the transition fire so factoryState
|
|
50
|
+
// reflects reality (awaiting_queue), and the observer in
|
|
51
|
+
// reactive-run-policy.ts supersedes the still-running Codex turn so
|
|
52
|
+
// it can't push a cosmetic patch-id-equivalent commit.
|
|
53
|
+
{ event: "review_approved",
|
|
54
|
+
guard: (s, ctx) => isOpen(s)
|
|
55
|
+
&& ctx.activeRunId !== undefined
|
|
56
|
+
&& ctx.activeRunType === "review_fix"
|
|
57
|
+
&& ctx.approvalHeadSha !== undefined
|
|
58
|
+
&& ctx.approvalHeadSha === ctx.activeRunSourceHeadSha,
|
|
59
|
+
to: "awaiting_queue" },
|
|
46
60
|
{ event: "review_changes_requested",
|
|
47
61
|
guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
|
|
48
62
|
to: "changes_requested" },
|
|
@@ -7,6 +7,8 @@ import { resolveGitHubWebhookIssue } from "./github-webhook-issue-resolution.js"
|
|
|
7
7
|
import { maybeCloseLatePublishedImplementationPr } from "./github-webhook-late-publication-guard.js";
|
|
8
8
|
import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
|
|
9
9
|
import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
|
|
10
|
+
import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
|
|
11
|
+
import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
|
|
10
12
|
import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
|
|
11
13
|
export class GitHubWebhookHandler {
|
|
12
14
|
config;
|
|
@@ -130,6 +132,30 @@ export class GitHubWebhookHandler {
|
|
|
130
132
|
failureContextResolver: this.failureContextResolver,
|
|
131
133
|
fetchImpl: this.fetchImpl,
|
|
132
134
|
});
|
|
135
|
+
if (event.triggerEvent === "pr_opened") {
|
|
136
|
+
await maybeRunSequenceBackstop({
|
|
137
|
+
db: this.db,
|
|
138
|
+
logger: this.logger,
|
|
139
|
+
...(this.feed ? { feed: this.feed } : {}),
|
|
140
|
+
event,
|
|
141
|
+
fetchImpl: this.fetchImpl,
|
|
142
|
+
}).catch((error) => {
|
|
143
|
+
this.logger.warn({ err: error }, "sequence-check backstop failed");
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Plan §8.3: parent-moved trigger. When a PR's head advances,
|
|
147
|
+
// any child PR stacked on it becomes stale relative to its
|
|
148
|
+
// declared base — enqueue a `branch_upkeep` run on each child
|
|
149
|
+
// so it rebases onto the new parent head.
|
|
150
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
151
|
+
maybeFanChildRebaseWakes({
|
|
152
|
+
db: this.db,
|
|
153
|
+
logger: this.logger,
|
|
154
|
+
...(this.feed ? { feed: this.feed } : {}),
|
|
155
|
+
enqueueIssue: this.enqueueIssue,
|
|
156
|
+
event,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
133
159
|
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
134
160
|
await handleGitHubTerminalPrEvent({
|
|
135
161
|
config: this.config,
|
|
@@ -75,7 +75,7 @@ export function canClearFailureProvenance(issue, event, project) {
|
|
|
75
75
|
}
|
|
76
76
|
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
77
77
|
}
|
|
78
|
-
export function resolveGitHubFactoryStateForEvent(issue, event, project) {
|
|
78
|
+
export function resolveGitHubFactoryStateForEvent(issue, event, project, activeRun) {
|
|
79
79
|
if (event.triggerEvent === "pr_closed") {
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
@@ -89,10 +89,16 @@ export function resolveGitHubFactoryStateForEvent(issue, event, project) {
|
|
|
89
89
|
const failureSource = event.triggerEvent === "check_failed"
|
|
90
90
|
? (isQueueEvictionFailure(issue, event, project) ? "queue_eviction" : "branch_ci")
|
|
91
91
|
: undefined;
|
|
92
|
+
const approvalHeadSha = event.triggerEvent === "review_approved"
|
|
93
|
+
? (event.reviewCommitId ?? event.headSha)
|
|
94
|
+
: undefined;
|
|
92
95
|
const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
|
|
93
96
|
prReviewState: issue.prReviewState,
|
|
94
97
|
activeRunId: issue.activeRunId,
|
|
95
98
|
failureSource,
|
|
99
|
+
...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
|
|
100
|
+
...(activeRun?.sourceHeadSha ? { activeRunSourceHeadSha: activeRun.sourceHeadSha } : {}),
|
|
101
|
+
...(approvalHeadSha ? { approvalHeadSha } : {}),
|
|
96
102
|
});
|
|
97
103
|
if (resolved !== undefined) {
|
|
98
104
|
return resolved;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Plan §8.2: backstop for missed sequence-checks. When a PR is
|
|
2
|
+
// opened and its changed-file set overlaps with another in-flight
|
|
3
|
+
// PR's, surface an operator event so the agent can be re-prompted
|
|
4
|
+
// (or a human can intervene). The full merge-tree probe lives in
|
|
5
|
+
// the CLI command; the backstop is intentionally cheap — file-set
|
|
6
|
+
// overlap only — because the webhook handler has no worktree.
|
|
7
|
+
export async function maybeRunSequenceBackstop(params) {
|
|
8
|
+
const { db, logger, feed, event } = params;
|
|
9
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
10
|
+
if (event.triggerEvent !== "pr_opened")
|
|
11
|
+
return;
|
|
12
|
+
if (!event.repoFullName || event.prNumber === undefined)
|
|
13
|
+
return;
|
|
14
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
15
|
+
if (!token)
|
|
16
|
+
return;
|
|
17
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
18
|
+
if (!owner || !repo)
|
|
19
|
+
return;
|
|
20
|
+
const newPrFiles = await listChangedFiles(fetchImpl, token, owner, repo, event.prNumber).catch(() => undefined);
|
|
21
|
+
if (!newPrFiles || newPrFiles.size === 0)
|
|
22
|
+
return;
|
|
23
|
+
const candidates = db.issues
|
|
24
|
+
.listIssues()
|
|
25
|
+
.filter((issue) => (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue")
|
|
26
|
+
&& issue.prNumber !== undefined
|
|
27
|
+
&& issue.prNumber !== event.prNumber
|
|
28
|
+
&& issue.branchName !== undefined
|
|
29
|
+
&& issue.branchName !== event.branchName);
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
const candidateFiles = await listChangedFiles(fetchImpl, token, owner, repo, candidate.prNumber).catch(() => undefined);
|
|
32
|
+
if (!candidateFiles)
|
|
33
|
+
continue;
|
|
34
|
+
const overlap = intersect(newPrFiles, candidateFiles);
|
|
35
|
+
if (overlap.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
logger.info({
|
|
38
|
+
event: "sequence_backstop_overlap_detected",
|
|
39
|
+
prNumber: event.prNumber,
|
|
40
|
+
candidatePrNumber: candidate.prNumber,
|
|
41
|
+
overlap: overlap.slice(0, 10),
|
|
42
|
+
}, "potential stack-target detected on pr_opened");
|
|
43
|
+
feed?.publish({
|
|
44
|
+
level: "warn",
|
|
45
|
+
kind: "github",
|
|
46
|
+
summary: `PR #${event.prNumber} may need to stack on PR #${candidate.prNumber} (overlapping files)`,
|
|
47
|
+
detail: `Overlapping files: ${overlap.slice(0, 5).join(", ")}${overlap.length > 5 ? "…" : ""}`,
|
|
48
|
+
...(candidate.issueKey ? { issueKey: candidate.issueKey } : {}),
|
|
49
|
+
...(candidate.projectId ? { projectId: candidate.projectId } : {}),
|
|
50
|
+
});
|
|
51
|
+
// First overlap is enough — the operator-facing signal does not
|
|
52
|
+
// need to enumerate every potential parent.
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function listChangedFiles(fetchImpl, token, owner, repo, prNumber) {
|
|
57
|
+
const result = new Set();
|
|
58
|
+
let page = 1;
|
|
59
|
+
// GitHub caps `pulls/{n}/files` at 3000 across pages of 100.
|
|
60
|
+
while (page <= 30) {
|
|
61
|
+
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/files?per_page=100&page=${page}`, {
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${token}`,
|
|
64
|
+
Accept: "application/vnd.github+json",
|
|
65
|
+
"User-Agent": "patchrelay",
|
|
66
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok)
|
|
70
|
+
return undefined;
|
|
71
|
+
const payload = (await response.json());
|
|
72
|
+
if (!Array.isArray(payload))
|
|
73
|
+
return undefined;
|
|
74
|
+
for (const entry of payload) {
|
|
75
|
+
if (!entry || typeof entry !== "object")
|
|
76
|
+
continue;
|
|
77
|
+
const filename = entry.filename;
|
|
78
|
+
if (typeof filename === "string" && filename)
|
|
79
|
+
result.add(filename);
|
|
80
|
+
}
|
|
81
|
+
if (payload.length < 100)
|
|
82
|
+
break;
|
|
83
|
+
page += 1;
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function intersect(a, b) {
|
|
88
|
+
const result = [];
|
|
89
|
+
for (const value of a) {
|
|
90
|
+
if (b.has(value))
|
|
91
|
+
result.push(value);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Plan §8.3-8.4: when a parent PR's head moves (review-fix push,
|
|
2
|
+
// eviction repair, base-branch update), child PRs stacked on it
|
|
3
|
+
// become stale. Patchrelay treats this as a wake event for each
|
|
4
|
+
// matching child and enqueues a `branch_upkeep` run to rebase the
|
|
5
|
+
// child onto the new parent head.
|
|
6
|
+
export function maybeFanChildRebaseWakes(params) {
|
|
7
|
+
const { db, logger, feed, enqueueIssue, event } = params;
|
|
8
|
+
if (event.triggerEvent !== "pr_synchronize")
|
|
9
|
+
return;
|
|
10
|
+
if (!event.branchName)
|
|
11
|
+
return;
|
|
12
|
+
const children = db.issues.listIssuesWithParentBranch(event.branchName);
|
|
13
|
+
if (children.length === 0)
|
|
14
|
+
return;
|
|
15
|
+
for (const child of children) {
|
|
16
|
+
if (child.activeRunId !== undefined) {
|
|
17
|
+
// Child already has a run going; let it complete and the next
|
|
18
|
+
// reconcile cycle pick up the new parent state.
|
|
19
|
+
logger.debug({ parentBranch: event.branchName, childIssue: child.issueKey, childRunId: child.activeRunId }, "Skipping child-rebase wake — child has an active run");
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
db.issues.upsertIssue({
|
|
23
|
+
projectId: child.projectId,
|
|
24
|
+
linearIssueId: child.linearIssueId,
|
|
25
|
+
pendingRunType: "branch_upkeep",
|
|
26
|
+
});
|
|
27
|
+
enqueueIssue(child.projectId, child.linearIssueId);
|
|
28
|
+
logger.info({
|
|
29
|
+
parentBranch: event.branchName,
|
|
30
|
+
parentHeadSha: event.headSha,
|
|
31
|
+
childIssue: child.issueKey,
|
|
32
|
+
childPrNumber: child.prNumber,
|
|
33
|
+
}, "Enqueued branch_upkeep on stacked child after parent PR head moved");
|
|
34
|
+
feed?.publish({
|
|
35
|
+
level: "info",
|
|
36
|
+
kind: "github",
|
|
37
|
+
summary: `Parent PR head moved on ${event.branchName} — branch_upkeep queued for child PR #${child.prNumber ?? "?"}`,
|
|
38
|
+
...(child.issueKey ? { issueKey: child.issueKey } : {}),
|
|
39
|
+
...(child.projectId ? { projectId: child.projectId } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -8,6 +8,12 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
8
8
|
const failureContextResolver = deps.failureContextResolver ?? createGitHubFailureContextResolver();
|
|
9
9
|
const ciSnapshotResolver = deps.ciSnapshotResolver ?? createGitHubCiSnapshotResolver();
|
|
10
10
|
const immediateCheckStatus = deriveImmediatePrCheckStatus(issue, event, project);
|
|
11
|
+
// Plan §8.3: when a PR's base ref differs from the repo default,
|
|
12
|
+
// it's stacked on another open PR. Cache the parent branch so we
|
|
13
|
+
// can fan child-rebase wakes on parent's `pr_synchronize`. Clear
|
|
14
|
+
// the field when a base ref reverts to the default (e.g. parent
|
|
15
|
+
// landed and GitHub auto-retargeted) or when the PR closes.
|
|
16
|
+
const parentPrBranch = computeParentPrBranchUpdate(event, project);
|
|
11
17
|
deps.db.issues.upsertIssue({
|
|
12
18
|
projectId: issue.projectId,
|
|
13
19
|
linearIssueId: issue.linearIssueId,
|
|
@@ -19,6 +25,7 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
19
25
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
20
26
|
...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
|
|
21
27
|
...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
|
|
28
|
+
...(parentPrBranch !== undefined ? { parentPrBranch } : {}),
|
|
22
29
|
...(event.reviewState === "changes_requested"
|
|
23
30
|
? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
|
|
24
31
|
: event.reviewState === "approved"
|
|
@@ -33,7 +40,15 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
33
40
|
const queueEvictionCheck = isQueueEvictionFailure(issue, event, project);
|
|
34
41
|
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
35
42
|
const afterMetadata = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
36
|
-
const
|
|
43
|
+
const activeRun = afterMetadata.activeRunId
|
|
44
|
+
? deps.db.runs.getRunById(afterMetadata.activeRunId)
|
|
45
|
+
: undefined;
|
|
46
|
+
const newState = resolveGitHubFactoryStateForEvent(afterMetadata, event, project, activeRun
|
|
47
|
+
? {
|
|
48
|
+
...(activeRun.runType ? { runType: activeRun.runType } : {}),
|
|
49
|
+
...(activeRun.sourceHeadSha ? { sourceHeadSha: activeRun.sourceHeadSha } : {}),
|
|
50
|
+
}
|
|
51
|
+
: undefined);
|
|
37
52
|
if (newState && newState !== afterMetadata.factoryState) {
|
|
38
53
|
deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
39
54
|
projectId: issue.projectId,
|
|
@@ -41,6 +56,20 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
41
56
|
factoryState: newState,
|
|
42
57
|
});
|
|
43
58
|
deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
59
|
+
// Plan §4.4: when the transition fired *because* an approval
|
|
60
|
+
// landed during a review_fix run on the same head (the
|
|
61
|
+
// mid-run-approval rule), the run's premise is gone. Mark it
|
|
62
|
+
// superseded and set the publication-suppression flag so the
|
|
63
|
+
// finalizer cannot push a cosmetic patch-id-equivalent commit.
|
|
64
|
+
maybeSupersedeActiveRun({
|
|
65
|
+
db: deps.db,
|
|
66
|
+
logger: deps.logger,
|
|
67
|
+
feed: deps.feed,
|
|
68
|
+
issue: afterMetadata,
|
|
69
|
+
newState,
|
|
70
|
+
event,
|
|
71
|
+
activeRun,
|
|
72
|
+
});
|
|
44
73
|
const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
45
74
|
void emitGitHubLinearActivity({
|
|
46
75
|
linearProvider: deps.linearProvider,
|
|
@@ -96,6 +125,59 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
96
125
|
});
|
|
97
126
|
return freshIssue;
|
|
98
127
|
}
|
|
128
|
+
// Plan §8.3: derive the cached parent-PR-branch state from a webhook
|
|
129
|
+
// event. Returns `undefined` to mean "no change" (event isn't a
|
|
130
|
+
// PR-shape event with a base ref); returns `null` to mean "clear the
|
|
131
|
+
// field" (PR closed, or base ref is now the repo default).
|
|
132
|
+
function computeParentPrBranchUpdate(event, project) {
|
|
133
|
+
if (event.triggerEvent === "pr_closed" || event.triggerEvent === "pr_merged") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (event.prBaseRef === undefined) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const repoDefault = project?.github?.baseBranch ?? "main";
|
|
140
|
+
if (!event.prBaseRef || event.prBaseRef === repoDefault) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return event.prBaseRef;
|
|
144
|
+
}
|
|
145
|
+
// Plan §4.4: when the mid-run-approval transition fires, the active
|
|
146
|
+
// review_fix run's premise no longer holds — there is no fix to
|
|
147
|
+
// publish. Mark it superseded and set the publication-suppression
|
|
148
|
+
// flag. The Codex turn may have produced output already; the
|
|
149
|
+
// finalizer reads `shouldNotPublish` and refuses to push.
|
|
150
|
+
function maybeSupersedeActiveRun(params) {
|
|
151
|
+
const { db, logger, feed, issue, newState, event, activeRun } = params;
|
|
152
|
+
if (event.triggerEvent !== "review_approved")
|
|
153
|
+
return;
|
|
154
|
+
if (newState !== "awaiting_queue")
|
|
155
|
+
return;
|
|
156
|
+
if (!activeRun)
|
|
157
|
+
return;
|
|
158
|
+
if (activeRun.runType !== "review_fix")
|
|
159
|
+
return;
|
|
160
|
+
const approvalHead = event.reviewCommitId ?? event.headSha;
|
|
161
|
+
if (!approvalHead || !activeRun.sourceHeadSha)
|
|
162
|
+
return;
|
|
163
|
+
if (approvalHead !== activeRun.sourceHeadSha)
|
|
164
|
+
return;
|
|
165
|
+
db.runs.markSuperseded(activeRun.id, {
|
|
166
|
+
reason: "approved on the same head; further publication suppressed",
|
|
167
|
+
});
|
|
168
|
+
logger.info({
|
|
169
|
+
issueKey: issue.issueKey,
|
|
170
|
+
runId: activeRun.id,
|
|
171
|
+
headSha: approvalHead,
|
|
172
|
+
}, "Superseded mid-run review_fix after approval landed on the same head");
|
|
173
|
+
feed?.publish({
|
|
174
|
+
level: "info",
|
|
175
|
+
kind: "agent",
|
|
176
|
+
summary: `Superseded review_fix run #${activeRun.id} — PR approved on the same head`,
|
|
177
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
178
|
+
...(issue.projectId ? { projectId: issue.projectId } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
99
181
|
async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
|
|
100
182
|
if (event.triggerEvent === "pr_merged") {
|
|
101
183
|
deps.db.issues.upsertIssue({
|
package/dist/github-webhooks.js
CHANGED
|
@@ -70,6 +70,7 @@ function normalizePullRequestEvent(payload, repoFullName) {
|
|
|
70
70
|
prLabels: Array.isArray(pr.labels)
|
|
71
71
|
? pr.labels.map((label) => label?.name).filter((label) => typeof label === "string" && label.trim().length > 0)
|
|
72
72
|
: undefined,
|
|
73
|
+
prBaseRef: pr.base.ref,
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
function normalizePullRequestReviewEvent(payload, repoFullName) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Plan §8.2: detect when an outgoing PR would conflict with an
|
|
2
|
+
// already-in-flight PR and recommend stacking against it instead of
|
|
3
|
+
// opening against main. Pure logic — IO is injected via the
|
|
4
|
+
// `GitProbe` interface so tests can drive it deterministically and
|
|
5
|
+
// the CLI can wire it to `spawnSync` git invocations.
|
|
6
|
+
const DEFAULT_SKIP_LABELS = ["wip", "do-not-merge", "do-not-merge-before"];
|
|
7
|
+
export async function detectStackingTarget(params) {
|
|
8
|
+
const { self, candidates, git } = params;
|
|
9
|
+
const skipLabels = (params.skipLabels ?? DEFAULT_SKIP_LABELS).map((label) => label.trim().toLowerCase());
|
|
10
|
+
if (candidates.length === 0) {
|
|
11
|
+
return { recommendation: "open_pr_against_main", reason: "no in-flight PRs to stack on" };
|
|
12
|
+
}
|
|
13
|
+
// Step 1: file-overlap pre-filter. Compute self's changed files
|
|
14
|
+
// once, then keep candidates whose changed-file set intersects.
|
|
15
|
+
const ownFiles = new Set(await git.changedFiles(self.baseRef, self.headSha));
|
|
16
|
+
if (ownFiles.size === 0) {
|
|
17
|
+
return { recommendation: "open_pr_against_main", reason: "no files changed in current branch" };
|
|
18
|
+
}
|
|
19
|
+
const overlappingCandidates = [];
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
if (candidate.branch === self.branch || candidate.headSha === self.headSha)
|
|
22
|
+
continue;
|
|
23
|
+
if (hasSkipLabel(candidate, skipLabels))
|
|
24
|
+
continue;
|
|
25
|
+
let candidateFiles;
|
|
26
|
+
try {
|
|
27
|
+
candidateFiles = await git.changedFiles(self.baseRef, candidate.headSha);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const overlap = candidateFiles.filter((file) => ownFiles.has(file));
|
|
33
|
+
if (overlap.length > 0) {
|
|
34
|
+
overlappingCandidates.push({ candidate, overlap });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (overlappingCandidates.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
recommendation: "open_pr_against_main",
|
|
40
|
+
reason: "no overlapping in-flight PRs",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Step 2: real conflict probe via merge-tree on each surviving
|
|
44
|
+
// candidate. We score only those that actually conflict; trees
|
|
45
|
+
// that auto-merge (different lines of the same file) are skipped.
|
|
46
|
+
const conflicting = [];
|
|
47
|
+
for (const entry of overlappingCandidates) {
|
|
48
|
+
let conflict = false;
|
|
49
|
+
try {
|
|
50
|
+
conflict = await git.hasConflict(self.headSha, entry.candidate.headSha);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Treat probe failure as "no conflict known" — fall through to
|
|
54
|
+
// open against main rather than recommending a possibly wrong
|
|
55
|
+
// stack target.
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (conflict) {
|
|
59
|
+
conflicting.push(entry);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (conflicting.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
recommendation: "open_pr_against_main",
|
|
65
|
+
reason: "overlapping files but no real conflict on merge-tree probe",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Step 3: score by likelihood-to-land-first. Higher score wins.
|
|
69
|
+
const ranked = conflicting
|
|
70
|
+
.map((entry) => ({ ...entry, score: scoreCandidate(entry.candidate) }))
|
|
71
|
+
.sort((a, b) => {
|
|
72
|
+
if (b.score !== a.score)
|
|
73
|
+
return b.score - a.score;
|
|
74
|
+
// Tie: prefer older queue entry.
|
|
75
|
+
const ageA = a.candidate.queueAgeMs ?? 0;
|
|
76
|
+
const ageB = b.candidate.queueAgeMs ?? 0;
|
|
77
|
+
if (ageA !== ageB)
|
|
78
|
+
return ageB - ageA;
|
|
79
|
+
// Final tie: lowest PR number first (it was opened earlier).
|
|
80
|
+
return a.candidate.prNumber - b.candidate.prNumber;
|
|
81
|
+
});
|
|
82
|
+
const winner = ranked[0];
|
|
83
|
+
const reasonBits = [];
|
|
84
|
+
reasonBits.push(`conflict on ${formatFileList(winner.overlap)}`);
|
|
85
|
+
reasonBits.push(`PR #${winner.candidate.prNumber} ${describeReadiness(winner.candidate)}`);
|
|
86
|
+
return {
|
|
87
|
+
recommendation: "rebase_onto",
|
|
88
|
+
parentPr: winner.candidate.prNumber,
|
|
89
|
+
parentBranch: winner.candidate.branch,
|
|
90
|
+
parentHead: winner.candidate.headSha,
|
|
91
|
+
reason: reasonBits.join("; ") + ", expected to land first",
|
|
92
|
+
conflictingFiles: winner.overlap,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function scoreCandidate(candidate) {
|
|
96
|
+
const review = (candidate.reviewState ?? "").trim().toLowerCase();
|
|
97
|
+
const checks = (candidate.checkStatus ?? "").trim().toLowerCase();
|
|
98
|
+
const factory = (candidate.factoryState ?? "").trim().toLowerCase();
|
|
99
|
+
let score = 0;
|
|
100
|
+
// Already in the queue → most likely to land first.
|
|
101
|
+
if (factory === "awaiting_queue")
|
|
102
|
+
score += 100;
|
|
103
|
+
if (review === "approved")
|
|
104
|
+
score += 50;
|
|
105
|
+
if (checks === "success" || checks === "passed")
|
|
106
|
+
score += 25;
|
|
107
|
+
if (review === "changes_requested")
|
|
108
|
+
score -= 30;
|
|
109
|
+
return score;
|
|
110
|
+
}
|
|
111
|
+
function hasSkipLabel(candidate, skipLabels) {
|
|
112
|
+
const labels = (candidate.labels ?? []).map((label) => label.trim().toLowerCase());
|
|
113
|
+
return labels.some((label) => skipLabels.includes(label));
|
|
114
|
+
}
|
|
115
|
+
function describeReadiness(candidate) {
|
|
116
|
+
const review = (candidate.reviewState ?? "").trim().toLowerCase();
|
|
117
|
+
const checks = (candidate.checkStatus ?? "").trim().toLowerCase();
|
|
118
|
+
const factory = (candidate.factoryState ?? "").trim().toLowerCase();
|
|
119
|
+
if (factory === "awaiting_queue")
|
|
120
|
+
return "is in the merge queue";
|
|
121
|
+
if (review === "approved" && (checks === "success" || checks === "passed")) {
|
|
122
|
+
return "is approved + green";
|
|
123
|
+
}
|
|
124
|
+
if (review === "approved")
|
|
125
|
+
return "is approved";
|
|
126
|
+
return "is in flight";
|
|
127
|
+
}
|
|
128
|
+
function formatFileList(files) {
|
|
129
|
+
if (files.length <= 3)
|
|
130
|
+
return files.join(", ");
|
|
131
|
+
return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`;
|
|
132
|
+
}
|
|
@@ -42,11 +42,18 @@ function buildPromptHeader(issue) {
|
|
|
42
42
|
: prContext.kind === "closed_pr_paused"
|
|
43
43
|
? `Previous PR: #${prContext.prNumber} (closed; redelegate to replace it)`
|
|
44
44
|
: undefined;
|
|
45
|
+
// Plan §4.2(b): surface the patch-id of the last patchrelay-attributed
|
|
46
|
+
// publish so the agent can compute its own diff's patch-id pre-push
|
|
47
|
+
// and skip the publish if the result is patch-id-equivalent.
|
|
48
|
+
const patchIdLine = issue.lastPublishedPatchId
|
|
49
|
+
? `Last published patch-id: ${issue.lastPublishedPatchId}`
|
|
50
|
+
: undefined;
|
|
45
51
|
return [
|
|
46
52
|
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
47
53
|
issue.title ? `Title: ${issue.title}` : undefined,
|
|
48
54
|
issue.branchName ? `Branch: ${issue.branchName}` : undefined,
|
|
49
55
|
prLine,
|
|
56
|
+
patchIdLine,
|
|
50
57
|
].filter(Boolean).join("\n");
|
|
51
58
|
}
|
|
52
59
|
function extractIssueSection(description, heading) {
|
|
@@ -538,6 +545,10 @@ function buildPublicationContract(runType, issueClass) {
|
|
|
538
545
|
"If this is code-delivery work, publish before stopping: commit, push the issue branch, and open or update the PR.",
|
|
539
546
|
"If the issue explicitly allows a non-PR outcome, complete that outcome clearly instead of inventing a PR.",
|
|
540
547
|
"",
|
|
548
|
+
"Right before `gh pr create`, run `patchrelay sequence-check` from the worktree.",
|
|
549
|
+
"If the JSON recommendation is `rebase_onto`, rebase the branch onto the named parent and pass `--base <parent_branch>` to `gh pr create`. Include the recommendation's reason in the PR body under a `Stacked on #NNN` header.",
|
|
550
|
+
"If the recommendation is `open_pr_against_main`, proceed with `gh pr create` against the default base.",
|
|
551
|
+
"",
|
|
541
552
|
...buildPrePushSelfReviewSection("new_pr", runType),
|
|
542
553
|
].join("\n");
|
|
543
554
|
}
|
|
@@ -548,6 +559,9 @@ function buildPublicationContract(runType, issueClass) {
|
|
|
548
559
|
"Do not open a new PR.",
|
|
549
560
|
"A PR-less stop is not a successful outcome for a repair run unless a genuine external blocker prevents any correct push.",
|
|
550
561
|
"",
|
|
562
|
+
"Before pushing, compute `git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable` and compare its first field to the `Last published patch-id` shown in the prompt header (if any).",
|
|
563
|
+
"If they match, do not push — finish the run as a no-op. Edit the PR body via `gh pr edit` instead if a textual update is needed.",
|
|
564
|
+
"",
|
|
551
565
|
...buildPrePushSelfReviewSection("existing_pr", runType),
|
|
552
566
|
].join("\n");
|
|
553
567
|
}
|
|
@@ -62,6 +62,13 @@ export class ReactiveRunPolicy {
|
|
|
62
62
|
if (!isRequestedChangesRunType(run.runType)) {
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
|
+
// Plan §4.4: a superseded run was deliberately cancelled mid-flight
|
|
66
|
+
// because the PR was approved on the same head. Demanding a new head
|
|
67
|
+
// would be wrong — there's nothing to push, and the publication-
|
|
68
|
+
// suppression flag is the contract that says so.
|
|
69
|
+
if (run.shouldNotPublish || run.status === "superseded") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
65
72
|
if (!issue.prNumber || issue.prState !== "open") {
|
|
66
73
|
return undefined;
|
|
67
74
|
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -2,6 +2,7 @@ import { buildStageReport, countEventMethods } from "./run-reporting.js";
|
|
|
2
2
|
import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
|
|
4
4
|
import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
5
|
+
import { computeChangeIdentityFromWorktree } from "./change-identity.js";
|
|
5
6
|
function parseEventJson(eventJson) {
|
|
6
7
|
if (!eventJson)
|
|
7
8
|
return undefined;
|
|
@@ -131,10 +132,73 @@ export class RunFinalizer {
|
|
|
131
132
|
return undefined;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
// Plan §4.2(c): record the identity of the head we just published
|
|
136
|
+
// so subsequent runs can recognize a patch-id-equivalent re-push.
|
|
137
|
+
// Only fires when the current head SHA is observably different from
|
|
138
|
+
// the run's starting sourceHeadSha — a no-op publish would not
|
|
139
|
+
// advance the head.
|
|
140
|
+
maybeUpdateLastPublishedIdentity(run, issue) {
|
|
141
|
+
if (!issue.worktreePath || !issue.prHeadSha)
|
|
142
|
+
return;
|
|
143
|
+
if (run.sourceHeadSha && run.sourceHeadSha === issue.prHeadSha)
|
|
144
|
+
return;
|
|
145
|
+
if (issue.lastPublishedHeadSha === issue.prHeadSha)
|
|
146
|
+
return;
|
|
147
|
+
const identity = computeChangeIdentityFromWorktree({
|
|
148
|
+
worktreePath: issue.worktreePath,
|
|
149
|
+
baseRef: "origin/main",
|
|
150
|
+
headSha: issue.prHeadSha,
|
|
151
|
+
});
|
|
152
|
+
if (!identity.patchId && !identity.integrationTreeId)
|
|
153
|
+
return;
|
|
154
|
+
this.db.issues.upsertIssue({
|
|
155
|
+
projectId: issue.projectId,
|
|
156
|
+
linearIssueId: issue.linearIssueId,
|
|
157
|
+
...(identity.patchId ? { lastPublishedPatchId: identity.patchId } : {}),
|
|
158
|
+
...(identity.integrationTreeId ? { lastPublishedIntegrationTreeId: identity.integrationTreeId } : {}),
|
|
159
|
+
lastPublishedHeadSha: issue.prHeadSha,
|
|
160
|
+
});
|
|
161
|
+
this.logger.info({
|
|
162
|
+
issueKey: issue.issueKey,
|
|
163
|
+
prHeadSha: issue.prHeadSha,
|
|
164
|
+
patchId: identity.patchId,
|
|
165
|
+
}, "Recorded last-published change identity after run completion");
|
|
166
|
+
}
|
|
134
167
|
clearProgressAndRelease(run) {
|
|
135
168
|
this.linearSync.clearProgress(run.id);
|
|
136
169
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
137
170
|
}
|
|
171
|
+
// Plan §4.4: finalize a run that was superseded mid-flight. The
|
|
172
|
+
// status row was already moved to `superseded` by the trigger
|
|
173
|
+
// observer; this just makes sure the issue's activeRunId is
|
|
174
|
+
// cleared, the lease is released, and the operator sees a
|
|
175
|
+
// clean recap event. No publication, no follow-up enqueue —
|
|
176
|
+
// the approval that triggered supersedure already advanced the
|
|
177
|
+
// factoryState.
|
|
178
|
+
releaseSupersededRun(run, threadId, completedTurnId) {
|
|
179
|
+
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
180
|
+
this.db.runs.finishRun(run.id, {
|
|
181
|
+
status: "superseded",
|
|
182
|
+
threadId,
|
|
183
|
+
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
184
|
+
failureReason: run.failureReason ?? "approved on the same head; further publication suppressed",
|
|
185
|
+
});
|
|
186
|
+
this.db.issues.upsertIssue({
|
|
187
|
+
projectId: run.projectId,
|
|
188
|
+
linearIssueId: run.linearIssueId,
|
|
189
|
+
activeRunId: null,
|
|
190
|
+
pendingRunType: null,
|
|
191
|
+
pendingRunContextJson: null,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
this.clearProgressAndRelease(run);
|
|
195
|
+
this.feed?.publish({
|
|
196
|
+
level: "info",
|
|
197
|
+
kind: "agent",
|
|
198
|
+
summary: `Run #${run.id} superseded — publication suppressed (approved on the same head)`,
|
|
199
|
+
...(run.projectId ? { projectId: run.projectId } : {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
138
202
|
enqueuePendingWakeIfPresent(params) {
|
|
139
203
|
const wake = this.db.issueSessions.peekIssueSessionWake(params.run.projectId, params.run.linearIssueId);
|
|
140
204
|
if (!wake)
|
|
@@ -201,6 +265,19 @@ export class RunFinalizer {
|
|
|
201
265
|
}
|
|
202
266
|
async finalizeCompletedRun(params) {
|
|
203
267
|
const { run, issue, thread, threadId } = params;
|
|
268
|
+
// Plan §4.4: a run flagged shouldNotPublish was deliberately
|
|
269
|
+
// superseded mid-flight (the PR was approved on the same head
|
|
270
|
+
// while a review_fix run was still producing output). The Codex
|
|
271
|
+
// turn may have completed; the finalizer must NOT run any of
|
|
272
|
+
// the publication-verification policies — they all assume the
|
|
273
|
+
// run was supposed to publish, and would either fail it
|
|
274
|
+
// spuriously (`verifyReviewFixAdvancedHead`) or open new
|
|
275
|
+
// follow-up work. Just record the supersedure outcome and
|
|
276
|
+
// release the lease.
|
|
277
|
+
if (run.shouldNotPublish || run.status === "superseded") {
|
|
278
|
+
this.releaseSupersededRun(run, threadId, params.completedTurnId);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
204
281
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
205
282
|
const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
|
|
206
283
|
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
@@ -268,6 +345,12 @@ export class RunFinalizer {
|
|
|
268
345
|
return;
|
|
269
346
|
}
|
|
270
347
|
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
|
348
|
+
// Plan §4.2(c): post-hoc change-identity detection. When the run
|
|
349
|
+
// produced a new head SHA, compute and persist the patch-id and
|
|
350
|
+
// integration-tree-id so the next run's prompt rule can recognize
|
|
351
|
+
// a patch-id-equivalent re-push and skip the publish. Best-effort:
|
|
352
|
+
// any git error returns undefined and we leave the cache as-is.
|
|
353
|
+
this.maybeUpdateLastPublishedIdentity(run, refreshedIssue);
|
|
271
354
|
const postRunFollowUp = await this.completionPolicy.resolvePostRunFollowUp(run, refreshedIssue);
|
|
272
355
|
const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
|
|
273
356
|
const publicationRecapSummary = await this.generatePublicationRecap({
|