patchrelay 0.35.11 → 0.35.13

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.
Files changed (52) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +19 -1
  4. package/dist/cli/commands/issues.js +18 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +160 -47
  7. package/dist/cli/formatters/text.js +51 -90
  8. package/dist/cli/help.js +15 -8
  9. package/dist/cli/index.js +3 -58
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +21 -12
  12. package/dist/cli/watch/HelpBar.js +3 -3
  13. package/dist/cli/watch/IssueDetailView.js +63 -130
  14. package/dist/cli/watch/IssueRow.js +82 -27
  15. package/dist/cli/watch/StatusBar.js +8 -4
  16. package/dist/cli/watch/detail-rows.js +589 -0
  17. package/dist/cli/watch/render-rich-text.js +226 -0
  18. package/dist/cli/watch/state-visualization.js +48 -23
  19. package/dist/cli/watch/timeline-builder.js +2 -1
  20. package/dist/cli/watch/use-detail-stream.js +10 -104
  21. package/dist/cli/watch/use-watch-stream.js +11 -102
  22. package/dist/cli/watch/watch-state.js +129 -56
  23. package/dist/codex-thread-utils.js +3 -0
  24. package/dist/db/migrations.js +239 -2
  25. package/dist/db.js +628 -39
  26. package/dist/github-app-token.js +7 -0
  27. package/dist/github-failure-context.js +44 -1
  28. package/dist/github-rollup.js +47 -0
  29. package/dist/github-webhook-handler.js +423 -52
  30. package/dist/github-webhooks.js +7 -0
  31. package/dist/http.js +12 -264
  32. package/dist/idle-reconciliation.js +268 -76
  33. package/dist/issue-query-service.js +221 -129
  34. package/dist/issue-session-events.js +151 -0
  35. package/dist/issue-session.js +99 -0
  36. package/dist/linear-client.js +39 -25
  37. package/dist/linear-session-reporting.js +12 -0
  38. package/dist/linear-session-sync.js +253 -24
  39. package/dist/linear-workflow.js +33 -0
  40. package/dist/merge-queue-protocol.js +0 -51
  41. package/dist/preflight.js +1 -4
  42. package/dist/queue-health-monitor.js +11 -7
  43. package/dist/run-orchestrator.js +1364 -147
  44. package/dist/run-reporting.js +5 -3
  45. package/dist/service.js +279 -102
  46. package/dist/status-note.js +56 -0
  47. package/dist/waiting-reason.js +65 -0
  48. package/dist/webhook-handler.js +270 -79
  49. package/package.json +3 -2
  50. package/dist/cli/commands/feed.js +0 -60
  51. package/dist/cli/watch/FeedView.js +0 -28
  52. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -4,8 +4,9 @@ import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-w
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
5
5
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
6
6
  import { buildGitHubStateActivity } from "./linear-session-reporting.js";
7
- import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
7
+ import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
8
8
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
9
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
9
10
  import { resolveSecret } from "./resolve-secret.js";
10
11
  import { safeJsonParse } from "./utils.js";
11
12
  /**
@@ -20,6 +21,7 @@ function isMetadataOnlyCheckEvent(event) {
20
21
  return event.eventSource === "check_run"
21
22
  && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
22
23
  }
24
+ const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
23
25
  export class GitHubWebhookHandler {
24
26
  config;
25
27
  db;
@@ -30,7 +32,9 @@ export class GitHubWebhookHandler {
30
32
  feed;
31
33
  failureContextResolver;
32
34
  ciSnapshotResolver;
33
- constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
35
+ fetchImpl;
36
+ patchRelayAuthorLogins = new Set();
37
+ constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
34
38
  this.config = config;
35
39
  this.db = db;
36
40
  this.linearProvider = linearProvider;
@@ -40,6 +44,19 @@ export class GitHubWebhookHandler {
40
44
  this.feed = feed;
41
45
  this.failureContextResolver = failureContextResolver;
42
46
  this.ciSnapshotResolver = ciSnapshotResolver;
47
+ this.fetchImpl = fetchImpl;
48
+ for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
49
+ this.patchRelayAuthorLogins.add(login);
50
+ }
51
+ }
52
+ setPatchRelayAuthorLogins(logins) {
53
+ this.patchRelayAuthorLogins.clear();
54
+ for (const login of logins) {
55
+ const normalized = normalizeAuthorLogin(login);
56
+ if (normalized) {
57
+ this.patchRelayAuthorLogins.add(normalized);
58
+ }
59
+ }
43
60
  }
44
61
  async acceptGitHubWebhook(params) {
45
62
  // Deduplicate
@@ -126,44 +143,30 @@ export class GitHubWebhookHandler {
126
143
  ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
127
144
  ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
128
145
  ...(event.prState !== undefined ? { prState: event.prState } : {}),
146
+ ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
147
+ ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
129
148
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
130
149
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
131
150
  });
132
151
  await this.updateCiSnapshot(issue, event, project);
133
152
  await this.updateFailureProvenance(issue, event, project);
134
- if (!isMetadataOnlyCheckEvent(event)) {
153
+ const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
154
+ if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
135
155
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
136
156
  const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
137
- const newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState, {
138
- prReviewState: afterMetadata.prReviewState,
139
- activeRunId: afterMetadata.activeRunId,
140
- });
157
+ const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
141
158
  // Only transition and notify when the state actually changes.
142
159
  // Multiple check_suite events can arrive for the same outcome.
143
160
  if (newState && newState !== afterMetadata.factoryState) {
144
- this.db.upsertIssue({
161
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
145
162
  projectId: issue.projectId,
146
163
  linearIssueId: issue.linearIssueId,
147
164
  factoryState: newState,
148
165
  });
149
- if (newState === "awaiting_queue") {
150
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
151
- }
152
166
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
153
167
  const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
154
168
  void this.emitLinearActivity(transitionedIssue, newState, event);
155
169
  void this.syncLinearSession(transitionedIssue);
156
- // Schedule merge prep when entering awaiting_queue
157
- if (newState === "awaiting_queue") {
158
- const proj = this.config.projects.find((p) => p.id === issue.projectId);
159
- const protocol = resolveMergeQueueProtocol(proj);
160
- void requestMergeQueueAdmission({
161
- issue: transitionedIssue,
162
- protocol,
163
- logger: this.logger,
164
- feed: this.feed,
165
- });
166
- }
167
170
  }
168
171
  }
169
172
  // Re-read issue after all upserts so reactive run logic sees current state
@@ -171,7 +174,7 @@ export class GitHubWebhookHandler {
171
174
  // Reset repair counters on new push — but only when no repair run is active,
172
175
  // since Codex pushes during repair and resetting mid-run would bypass budgets.
173
176
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
174
- this.db.upsertIssue({
177
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
175
178
  projectId: issue.projectId,
176
179
  linearIssueId: issue.linearIssueId,
177
180
  ciRepairAttempts: 0,
@@ -192,6 +195,7 @@ export class GitHubWebhookHandler {
192
195
  lastAttemptedFailureHeadSha: null,
193
196
  lastAttemptedFailureSignature: null,
194
197
  });
198
+ await this.maybeRequestRereviewAfterPush(freshIssue, event, project);
195
199
  }
196
200
  this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
197
201
  this.feed?.publish({
@@ -207,12 +211,28 @@ export class GitHubWebhookHandler {
207
211
  // Queue eviction check runs bypass the metadata-only filter because
208
212
  // they're individual check_run events (not check_suite), but they
209
213
  // must drive state transitions.
210
- if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
214
+ if (queueEvictionCheck || this.isGateCheckEvent(event, project)) {
211
215
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
212
216
  }
213
217
  else if (!isMetadataOnlyCheckEvent(event)) {
214
218
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
215
219
  }
220
+ if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
221
+ await this.handleTerminalPrEvent(freshIssue, event);
222
+ }
223
+ }
224
+ resolveFactoryStateForEvent(issue, event, project) {
225
+ if (event.triggerEvent === "check_failed"
226
+ && this.isQueueEvictionFailure(issue, event, project)
227
+ && issue.prState === "open"
228
+ && issue.activeRunId === undefined
229
+ && !TERMINAL_STATES.has(issue.factoryState)) {
230
+ return "repairing_queue";
231
+ }
232
+ return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
233
+ prReviewState: issue.prReviewState,
234
+ activeRunId: issue.activeRunId,
235
+ });
216
236
  }
217
237
  async updateCiSnapshot(issue, event, project) {
218
238
  if (event.triggerEvent === "pr_merged") {
@@ -279,6 +299,7 @@ export class GitHubWebhookHandler {
279
299
  this.db.upsertIssue({
280
300
  projectId: issue.projectId,
281
301
  linearIssueId: issue.linearIssueId,
302
+ prCheckStatus: snapshot.gateCheckStatus,
282
303
  lastGitHubCiSnapshotHeadSha: snapshot.headSha,
283
304
  lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
284
305
  lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
@@ -294,6 +315,18 @@ export class GitHubWebhookHandler {
294
315
  // merge_group_failed after pr_merged) must not resurrect done issues.
295
316
  if (TERMINAL_STATES.has(issue.factoryState))
296
317
  return;
318
+ if (!this.isPatchRelayOwnedPr(issue)) {
319
+ this.feed?.publish({
320
+ level: "info",
321
+ kind: "github",
322
+ issueKey: issue.issueKey,
323
+ projectId: issue.projectId,
324
+ stage: issue.factoryState,
325
+ status: "ignored_non_patchrelay_pr",
326
+ summary: `Ignored ${event.triggerEvent} on non-PatchRelay-owned PR`,
327
+ });
328
+ return;
329
+ }
297
330
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
298
331
  // External merge queue eviction: react only to the configured check
299
332
  // name, not to any CI failure. Regular CI failures still get ci_repair.
@@ -303,14 +336,10 @@ export class GitHubWebhookHandler {
303
336
  if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
304
337
  return;
305
338
  }
339
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
306
340
  this.db.upsertIssue({
307
341
  projectId: issue.projectId,
308
342
  linearIssueId: issue.linearIssueId,
309
- pendingRunType: "queue_repair",
310
- pendingRunContextJson: JSON.stringify({
311
- ...queueRepairContext,
312
- ...failureContext,
313
- }),
314
343
  lastGitHubFailureSource: "queue_eviction",
315
344
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
316
345
  lastGitHubFailureSignature: failureContext.failureSignature ?? null,
@@ -321,8 +350,20 @@ export class GitHubWebhookHandler {
321
350
  lastQueueSignalAt: new Date().toISOString(),
322
351
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
323
352
  });
324
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
325
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
353
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
354
+ projectId: issue.projectId,
355
+ linearIssueId: issue.linearIssueId,
356
+ eventType: "merge_steward_incident",
357
+ eventJson: JSON.stringify({
358
+ ...queueRepairContext,
359
+ ...failureContext,
360
+ }),
361
+ dedupeKey: failureContext.failureSignature,
362
+ });
363
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
364
+ const queuedRunType = hadPendingWake
365
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
366
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
326
367
  this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
327
368
  this.feed?.publish({
328
369
  level: "warn",
@@ -331,7 +372,7 @@ export class GitHubWebhookHandler {
331
372
  projectId: issue.projectId,
332
373
  stage: "repairing_queue",
333
374
  status: "queue_repair_queued",
334
- summary: `Queue repair queued after external failure from ${event.checkName}`,
375
+ summary: `${queuedRunType ?? "queue_repair"} queued after external failure from ${event.checkName}`,
335
376
  detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
336
377
  });
337
378
  }
@@ -352,16 +393,11 @@ export class GitHubWebhookHandler {
352
393
  if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
353
394
  return;
354
395
  }
396
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
355
397
  const snapshot = this.getRelevantCiSnapshot(issue, event);
356
398
  this.db.upsertIssue({
357
399
  projectId: issue.projectId,
358
400
  linearIssueId: issue.linearIssueId,
359
- pendingRunType: "ci_repair",
360
- pendingRunContextJson: JSON.stringify({
361
- ...failureContext,
362
- checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
363
- ...(snapshot ? { ciSnapshot: snapshot } : {}),
364
- }),
365
401
  lastGitHubFailureSource: "branch_ci",
366
402
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
367
403
  lastGitHubFailureSignature: failureContext.failureSignature ?? null,
@@ -371,8 +407,21 @@ export class GitHubWebhookHandler {
371
407
  lastGitHubFailureAt: new Date().toISOString(),
372
408
  lastQueueIncidentJson: null,
373
409
  });
374
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
375
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
410
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
411
+ projectId: issue.projectId,
412
+ linearIssueId: issue.linearIssueId,
413
+ eventType: "settled_red_ci",
414
+ eventJson: JSON.stringify({
415
+ ...failureContext,
416
+ checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
417
+ ...(snapshot ? { ciSnapshot: snapshot } : {}),
418
+ }),
419
+ dedupeKey: failureContext.failureSignature,
420
+ });
421
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
422
+ const queuedRunType = hadPendingWake
423
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
424
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
376
425
  this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
377
426
  this.feed?.publish({
378
427
  level: "warn",
@@ -381,24 +430,145 @@ export class GitHubWebhookHandler {
381
430
  projectId: issue.projectId,
382
431
  stage: "repairing_ci",
383
432
  status: "ci_repair_queued",
384
- summary: `CI repair queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
433
+ summary: `${queuedRunType ?? "ci_repair"} queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
385
434
  detail: summarizeGitHubFailureContext(failureContext),
386
435
  });
387
436
  }
388
437
  }
389
438
  if (event.triggerEvent === "review_changes_requested") {
390
- this.db.upsertIssue({
439
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
440
+ const reviewComments = await this.fetchReviewCommentsForEvent(event).catch((error) => {
441
+ this.logger.warn({
442
+ issueKey: issue.issueKey,
443
+ prNumber: event.prNumber,
444
+ reviewId: event.reviewId,
445
+ error: error instanceof Error ? error.message : String(error),
446
+ }, "Failed to fetch inline review comments for requested-changes event");
447
+ return undefined;
448
+ });
449
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
391
450
  projectId: issue.projectId,
392
451
  linearIssueId: issue.linearIssueId,
393
- pendingRunType: "review_fix",
394
- pendingRunContextJson: JSON.stringify({
452
+ eventType: "review_changes_requested",
453
+ eventJson: JSON.stringify({
395
454
  reviewBody: event.reviewBody,
455
+ reviewCommitId: event.reviewCommitId,
456
+ reviewId: event.reviewId,
457
+ reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
396
458
  reviewerName: event.reviewerName,
459
+ ...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
397
460
  }),
461
+ dedupeKey: [
462
+ "review_changes_requested",
463
+ issue.prHeadSha ?? event.headSha ?? "unknown-sha",
464
+ event.reviewerName ?? "unknown-reviewer",
465
+ ].join("::"),
398
466
  });
399
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
400
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
467
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
468
+ const queuedRunType = hadPendingWake
469
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
470
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
401
471
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
472
+ this.feed?.publish({
473
+ level: "warn",
474
+ kind: "github",
475
+ issueKey: issue.issueKey,
476
+ projectId: issue.projectId,
477
+ stage: "changes_requested",
478
+ status: "review_fix_queued",
479
+ summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
480
+ detail: reviewComments && reviewComments.length > 0
481
+ ? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
482
+ : event.reviewBody?.slice(0, 200) ?? event.reviewerName,
483
+ });
484
+ }
485
+ }
486
+ async handleTerminalPrEvent(issue, event) {
487
+ const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
488
+ this.db.appendIssueSessionEvent({
489
+ projectId: issue.projectId,
490
+ linearIssueId: issue.linearIssueId,
491
+ eventType,
492
+ dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
493
+ });
494
+ this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
495
+ const run = issue.activeRunId ? this.db.getRun(issue.activeRunId) : undefined;
496
+ if (run?.threadId && run.turnId) {
497
+ try {
498
+ await this.codex.steerTurn({
499
+ threadId: run.threadId,
500
+ turnId: run.turnId,
501
+ input: event.triggerEvent === "pr_merged"
502
+ ? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
503
+ : "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
504
+ });
505
+ }
506
+ catch (error) {
507
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run after terminal PR event");
508
+ }
509
+ }
510
+ const commitTerminalUpdate = () => {
511
+ if (run) {
512
+ this.db.finishRun(run.id, {
513
+ status: "released",
514
+ failureReason: event.triggerEvent === "pr_merged"
515
+ ? "Pull request merged during active run"
516
+ : "Pull request closed during active run",
517
+ });
518
+ }
519
+ this.db.upsertIssue({
520
+ projectId: issue.projectId,
521
+ linearIssueId: issue.linearIssueId,
522
+ activeRunId: null,
523
+ factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
524
+ });
525
+ };
526
+ const activeLease = this.db.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
527
+ if (activeLease) {
528
+ this.db.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
529
+ }
530
+ else {
531
+ this.db.transaction(commitTerminalUpdate);
532
+ }
533
+ this.db.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
534
+ const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
535
+ if (event.triggerEvent === "pr_merged") {
536
+ await this.completeLinearIssueAfterMerge(updatedIssue);
537
+ }
538
+ void this.syncLinearSession(updatedIssue);
539
+ }
540
+ async completeLinearIssueAfterMerge(issue) {
541
+ const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
542
+ if (!linear)
543
+ return;
544
+ try {
545
+ const liveIssue = await linear.getIssue(issue.linearIssueId);
546
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
547
+ if (!targetState) {
548
+ this.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
549
+ return;
550
+ }
551
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
552
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
553
+ this.db.upsertIssue({
554
+ projectId: issue.projectId,
555
+ linearIssueId: issue.linearIssueId,
556
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
557
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
558
+ });
559
+ return;
560
+ }
561
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
562
+ this.db.upsertIssue({
563
+ projectId: issue.projectId,
564
+ linearIssueId: issue.linearIssueId,
565
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
566
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
567
+ });
568
+ }
569
+ catch (error) {
570
+ const msg = error instanceof Error ? error.message : String(error);
571
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
402
572
  }
403
573
  }
404
574
  async updateFailureProvenance(issue, event, project) {
@@ -511,8 +681,9 @@ export class GitHubWebhookHandler {
511
681
  : typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
512
682
  if (!signature)
513
683
  return false;
514
- if (issue.pendingRunType === runType && issue.pendingRunContextJson) {
515
- const existing = safeJsonParse(issue.pendingRunContextJson);
684
+ const pendingWake = this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
685
+ if (pendingWake?.runType === runType) {
686
+ const existing = pendingWake.context;
516
687
  if (existing?.failureSignature === signature
517
688
  && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
518
689
  this.feed?.publish({
@@ -544,10 +715,10 @@ export class GitHubWebhookHandler {
544
715
  }
545
716
  getGateCheckNames(project) {
546
717
  const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
547
- return configured.length > 0 ? configured : ["Tests"];
718
+ return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
548
719
  }
549
720
  getPrimaryGateCheckName(project) {
550
- return this.getGateCheckNames(project)[0] ?? "Tests";
721
+ return this.getGateCheckNames(project)[0] ?? "verify";
551
722
  }
552
723
  isGateCheckEvent(event, project) {
553
724
  if (event.eventSource !== "check_run" || !event.checkName)
@@ -562,8 +733,7 @@ export class GitHubWebhookHandler {
562
733
  }
563
734
  isQueueEvictionFailure(issue, event, project) {
564
735
  const protocol = resolveMergeQueueProtocol(project);
565
- return issue.factoryState === "awaiting_queue"
566
- && event.eventSource === "check_run"
736
+ return event.eventSource === "check_run"
567
737
  && event.checkName === protocol.evictionCheckName;
568
738
  }
569
739
  isSettledBranchFailure(issue, event, project) {
@@ -653,6 +823,64 @@ export class GitHubWebhookHandler {
653
823
  this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
654
824
  }
655
825
  }
826
+ async fetchReviewCommentsForEvent(event) {
827
+ if (event.triggerEvent !== "review_changes_requested") {
828
+ return undefined;
829
+ }
830
+ if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
831
+ return undefined;
832
+ }
833
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
834
+ if (!token) {
835
+ this.logger.debug({ prNumber: event.prNumber, reviewId: event.reviewId }, "Skipping inline review comment fetch because no GitHub API token is available");
836
+ return undefined;
837
+ }
838
+ const [owner, repo] = event.repoFullName.split("/", 2);
839
+ if (!owner || !repo) {
840
+ return undefined;
841
+ }
842
+ const response = await this.fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
843
+ headers: {
844
+ Authorization: `Bearer ${token}`,
845
+ Accept: "application/vnd.github+json",
846
+ "User-Agent": "patchrelay",
847
+ "X-GitHub-Api-Version": "2022-11-28",
848
+ },
849
+ });
850
+ if (!response.ok) {
851
+ throw new Error(`GitHub review comment fetch failed (${response.status})`);
852
+ }
853
+ const payload = await response.json();
854
+ if (!Array.isArray(payload)) {
855
+ return undefined;
856
+ }
857
+ const comments = [];
858
+ for (const entry of payload) {
859
+ if (!entry || typeof entry !== "object")
860
+ continue;
861
+ const record = entry;
862
+ const body = typeof record.body === "string" ? record.body.trim() : "";
863
+ const id = typeof record.id === "number" ? record.id : undefined;
864
+ if (!body || id === undefined)
865
+ continue;
866
+ comments.push({
867
+ id,
868
+ body,
869
+ ...(typeof record.path === "string" ? { path: record.path } : {}),
870
+ ...(typeof record.line === "number" ? { line: record.line } : {}),
871
+ ...(typeof record.side === "string" ? { side: record.side } : {}),
872
+ ...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
873
+ ...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
874
+ ...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
875
+ ...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
876
+ ...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
877
+ ...(typeof record.user?.login === "string"
878
+ ? { authorLogin: String(record.user.login) }
879
+ : {}),
880
+ });
881
+ }
882
+ return comments;
883
+ }
656
884
  async handlePrComment(payload) {
657
885
  if (payload.action !== "created")
658
886
  return;
@@ -675,6 +903,8 @@ export class GitHubWebhookHandler {
675
903
  const issue = this.db.getIssueByPrNumber(prNumber);
676
904
  if (!issue)
677
905
  return;
906
+ if (!this.isPatchRelayOwnedPr(issue))
907
+ return;
678
908
  this.feed?.publish({
679
909
  level: "info",
680
910
  kind: "comment",
@@ -695,6 +925,7 @@ export class GitHubWebhookHandler {
695
925
  input: `GitHub PR comment from ${author}:\n\n${body}`,
696
926
  });
697
927
  this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
928
+ return;
698
929
  }
699
930
  catch (error) {
700
931
  const msg = error instanceof Error ? error.message : String(error);
@@ -702,7 +933,147 @@ export class GitHubWebhookHandler {
702
933
  }
703
934
  }
704
935
  }
936
+ this.db.appendIssueSessionEvent({
937
+ projectId: issue.projectId,
938
+ linearIssueId: issue.linearIssueId,
939
+ eventType: "followup_comment",
940
+ eventJson: JSON.stringify({ body, author }),
941
+ });
942
+ this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
943
+ }
944
+ async maybeRequestRereviewAfterPush(issue, event, project) {
945
+ if (event.triggerEvent !== "pr_synchronize")
946
+ return;
947
+ if (issue.activeRunId !== undefined)
948
+ return;
949
+ if (issue.prState !== "open" || issue.prReviewState !== "changes_requested" || issue.prNumber === undefined)
950
+ return;
951
+ if (!this.isPatchRelayOwnedPr(issue))
952
+ return;
953
+ const reviewerName = this.findLatestRequestedChangesReviewer(issue.projectId, issue.linearIssueId);
954
+ if (!reviewerName) {
955
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no prior reviewer was recorded");
956
+ return;
957
+ }
958
+ const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
959
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
960
+ if (!token) {
961
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no GitHub token is available");
962
+ this.feed?.publish({
963
+ level: "warn",
964
+ kind: "github",
965
+ issueKey: issue.issueKey,
966
+ projectId: issue.projectId,
967
+ stage: issue.factoryState,
968
+ status: "rereview_request_skipped",
969
+ summary: `Skipped auto re-review request for PR #${issue.prNumber}`,
970
+ detail: "No GitHub token available for requested_reviewers API call",
971
+ });
972
+ return;
973
+ }
974
+ const response = await this.fetchImpl(`https://api.github.com/repos/${repoFullName}/pulls/${issue.prNumber}/requested_reviewers`, {
975
+ method: "POST",
976
+ headers: {
977
+ authorization: `Bearer ${token}`,
978
+ accept: "application/vnd.github+json",
979
+ "content-type": "application/json",
980
+ "user-agent": "patchrelay",
981
+ },
982
+ body: JSON.stringify({ reviewers: [reviewerName] }),
983
+ });
984
+ if (!response.ok) {
985
+ const detail = await this.readGitHubErrorResponse(response);
986
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName, status: response.status, detail }, "Failed to auto request re-review after push");
987
+ this.feed?.publish({
988
+ level: "warn",
989
+ kind: "github",
990
+ issueKey: issue.issueKey,
991
+ projectId: issue.projectId,
992
+ stage: issue.factoryState,
993
+ status: "rereview_request_failed",
994
+ summary: `Failed to auto request re-review from ${reviewerName}`,
995
+ detail,
996
+ });
997
+ return;
998
+ }
999
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName }, "Auto requested re-review after push");
1000
+ this.feed?.publish({
1001
+ level: "info",
1002
+ kind: "github",
1003
+ issueKey: issue.issueKey,
1004
+ projectId: issue.projectId,
1005
+ stage: issue.factoryState,
1006
+ status: "rereview_requested",
1007
+ summary: `Requested re-review from ${reviewerName} on PR #${issue.prNumber}`,
1008
+ });
1009
+ }
1010
+ findLatestRequestedChangesReviewer(projectId, linearIssueId) {
1011
+ const event = this.db
1012
+ .listIssueSessionEvents(projectId, linearIssueId)
1013
+ .findLast((candidate) => candidate.eventType === "review_changes_requested");
1014
+ if (!event?.eventJson)
1015
+ return undefined;
1016
+ const payload = safeJsonParse(event.eventJson);
1017
+ return typeof payload?.reviewerName === "string" && payload.reviewerName.trim()
1018
+ ? payload.reviewerName.trim()
1019
+ : undefined;
1020
+ }
1021
+ async readGitHubErrorResponse(response) {
1022
+ try {
1023
+ const payload = await response.json();
1024
+ if (typeof payload?.message === "string" && payload.message.trim()) {
1025
+ return payload.message.trim();
1026
+ }
1027
+ if (payload?.errors !== undefined) {
1028
+ return JSON.stringify(payload.errors);
1029
+ }
1030
+ }
1031
+ catch {
1032
+ // Fall through to status text.
1033
+ }
1034
+ return response.statusText || `GitHub API responded with ${response.status}`;
1035
+ }
1036
+ peekPendingSessionWakeRunType(projectId, issueId) {
1037
+ return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
1038
+ }
1039
+ enqueuePendingSessionWake(projectId, issueId) {
1040
+ const wake = this.db.peekIssueSessionWake(projectId, issueId);
1041
+ if (!wake) {
1042
+ return undefined;
1043
+ }
1044
+ this.enqueueIssue(projectId, issueId);
1045
+ return wake.runType;
1046
+ }
1047
+ isPatchRelayOwnedPr(issue) {
1048
+ const author = normalizeAuthorLogin(issue.prAuthorLogin);
1049
+ if (author) {
1050
+ if (this.patchRelayAuthorLogins.size > 0) {
1051
+ return this.patchRelayAuthorLogins.has(author);
1052
+ }
1053
+ return author.includes("patchrelay");
1054
+ }
1055
+ // Transitional fallback for rows written before author tracking existed.
1056
+ return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
1057
+ }
1058
+ }
1059
+ function normalizeAuthorLogin(login) {
1060
+ const normalized = login?.trim().toLowerCase();
1061
+ return normalized ? normalized : undefined;
1062
+ }
1063
+ function resolvePatchRelayAuthorLoginsFromEnv() {
1064
+ return [
1065
+ process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
1066
+ process.env.PATCHRELAY_GITHUB_BOT_NAME,
1067
+ ]
1068
+ .flatMap((value) => (value ?? "").split(","))
1069
+ .map((value) => normalizeAuthorLogin(value))
1070
+ .filter((value) => Boolean(value));
1071
+ }
1072
+ function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
1073
+ if (!repoFullName || prNumber === undefined || reviewId === undefined) {
1074
+ return undefined;
705
1075
  }
1076
+ return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
706
1077
  }
707
1078
  function resolveCheckClass(checkName, project) {
708
1079
  if (!checkName || !project)