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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.66.0",
4
- "commit": "552ba486bf93",
5
- "builtAt": "2026-05-05T14:36:59.557Z"
3
+ "version": "0.67.0",
4
+ "commit": "3f61030e2318",
5
+ "builtAt": "2026-05-05T14:42:27.010Z"
6
6
  }
@@ -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),
@@ -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
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {