patchrelay 0.66.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/db/issue-store.js +20 -0
- package/dist/db/migrations.js +8 -0
- package/dist/github-webhook-handler.js +14 -0
- package/dist/github-webhook-stack-coordination.js +42 -0
- package/dist/github-webhook-state-projector.js +24 -0
- package/dist/github-webhooks.js +1 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
);
|
|
@@ -345,6 +346,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
345
346
|
addColumnIfMissing(connection, "issues", "last_published_patch_id", "TEXT");
|
|
346
347
|
addColumnIfMissing(connection, "issues", "last_published_integration_tree_id", "TEXT");
|
|
347
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);`);
|
|
348
352
|
addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
|
|
349
353
|
addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
|
|
350
354
|
addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
|
|
@@ -422,6 +426,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
422
426
|
last_published_patch_id TEXT,
|
|
423
427
|
last_published_integration_tree_id TEXT,
|
|
424
428
|
last_published_head_sha TEXT,
|
|
429
|
+
parent_pr_branch TEXT,
|
|
425
430
|
ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
426
431
|
queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
427
432
|
review_fix_attempts INTEGER NOT NULL DEFAULT 0,
|
|
@@ -488,6 +493,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
488
493
|
last_published_patch_id,
|
|
489
494
|
last_published_integration_tree_id,
|
|
490
495
|
last_published_head_sha,
|
|
496
|
+
parent_pr_branch,
|
|
491
497
|
ci_repair_attempts,
|
|
492
498
|
queue_repair_attempts,
|
|
493
499
|
review_fix_attempts,
|
|
@@ -552,6 +558,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
552
558
|
last_published_patch_id,
|
|
553
559
|
last_published_integration_tree_id,
|
|
554
560
|
last_published_head_sha,
|
|
561
|
+
parent_pr_branch,
|
|
555
562
|
COALESCE(ci_repair_attempts, 0),
|
|
556
563
|
COALESCE(queue_repair_attempts, 0),
|
|
557
564
|
COALESCE(review_fix_attempts, 0),
|
|
@@ -568,6 +575,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
568
575
|
CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
|
|
569
576
|
CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
|
|
570
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);
|
|
571
579
|
`);
|
|
572
580
|
}
|
|
573
581
|
finally {
|
|
@@ -8,6 +8,7 @@ import { maybeCloseLatePublishedImplementationPr } from "./github-webhook-late-p
|
|
|
8
8
|
import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
|
|
9
9
|
import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
|
|
10
10
|
import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
|
|
11
|
+
import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
|
|
11
12
|
import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
|
|
12
13
|
export class GitHubWebhookHandler {
|
|
13
14
|
config;
|
|
@@ -142,6 +143,19 @@ export class GitHubWebhookHandler {
|
|
|
142
143
|
this.logger.warn({ err: error }, "sequence-check backstop failed");
|
|
143
144
|
});
|
|
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
|
+
}
|
|
145
159
|
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
146
160
|
await handleGitHubTerminalPrEvent({
|
|
147
161
|
config: this.config,
|
|
@@ -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"
|
|
@@ -118,6 +125,23 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
118
125
|
});
|
|
119
126
|
return freshIssue;
|
|
120
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
|
+
}
|
|
121
145
|
// Plan §4.4: when the mid-run-approval transition fires, the active
|
|
122
146
|
// review_fix run's premise no longer holds — there is no fix to
|
|
123
147
|
// publish. Mark it superseded and set the publication-suppression
|
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) {
|