patchrelay 0.30.1 → 0.32.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,4 +1,5 @@
1
1
  import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
2
+ import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
2
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
4
5
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
@@ -9,12 +10,11 @@ import { resolveSecret } from "./resolve-secret.js";
9
10
  import { safeJsonParse } from "./utils.js";
10
11
  /**
11
12
  * GitHub sends both check_run and check_suite completion events.
12
- * A single CI run generates 10+ individual check_run events as each job finishes,
13
- * but only 1 check_suite event when the entire suite completes. Reacting to
14
- * individual check_run events causes the factory state to flicker rapidly
15
- * between pr_open and repairing_ci. We only drive state transitions and reactive
16
- * runs from check_suite events. Individual check_run events still update PR
17
- * metadata (prCheckStatus) for observability.
13
+ * A single CI run generates many individual check_run events as each job finishes,
14
+ * but PatchRelay should only start ci_repair once the configured gate check
15
+ * (for example `Tests`) has gone terminal for the current PR head SHA. We still
16
+ * treat most check_run events as metadata-only and only react to queue eviction
17
+ * checks or the settled gate check.
18
18
  */
19
19
  function isMetadataOnlyCheckEvent(event) {
20
20
  return event.eventSource === "check_run"
@@ -28,7 +28,9 @@ export class GitHubWebhookHandler {
28
28
  logger;
29
29
  codex;
30
30
  feed;
31
- constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed) {
31
+ failureContextResolver;
32
+ ciSnapshotResolver;
33
+ constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
32
34
  this.config = config;
33
35
  this.db = db;
34
36
  this.linearProvider = linearProvider;
@@ -36,6 +38,8 @@ export class GitHubWebhookHandler {
36
38
  this.logger = logger;
37
39
  this.codex = codex;
38
40
  this.feed = feed;
41
+ this.failureContextResolver = failureContextResolver;
42
+ this.ciSnapshotResolver = ciSnapshotResolver;
39
43
  }
40
44
  async acceptGitHubWebhook(params) {
41
45
  // Deduplicate
@@ -114,6 +118,7 @@ export class GitHubWebhookHandler {
114
118
  this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
115
119
  return;
116
120
  }
121
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
117
122
  // Update PR state on the issue
118
123
  this.db.upsertIssue({
119
124
  projectId: issue.projectId,
@@ -124,7 +129,8 @@ export class GitHubWebhookHandler {
124
129
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
125
130
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
126
131
  });
127
- this.updateFailureProvenance(issue, event);
132
+ await this.updateCiSnapshot(issue, event, project);
133
+ await this.updateFailureProvenance(issue, event, project);
128
134
  if (!isMetadataOnlyCheckEvent(event)) {
129
135
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
130
136
  const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -168,10 +174,20 @@ export class GitHubWebhookHandler {
168
174
  ciRepairAttempts: 0,
169
175
  queueRepairAttempts: 0,
170
176
  lastGitHubFailureSource: null,
177
+ lastGitHubFailureHeadSha: null,
178
+ lastGitHubFailureSignature: null,
171
179
  lastGitHubFailureCheckName: null,
172
180
  lastGitHubFailureCheckUrl: null,
181
+ lastGitHubFailureContextJson: null,
173
182
  lastGitHubFailureAt: null,
183
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
184
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
185
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
186
+ lastGitHubCiSnapshotJson: null,
187
+ lastGitHubCiSnapshotSettledAt: null,
174
188
  lastQueueIncidentJson: null,
189
+ lastAttemptedFailureHeadSha: null,
190
+ lastAttemptedFailureSignature: null,
175
191
  });
176
192
  }
177
193
  this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
@@ -188,16 +204,86 @@ export class GitHubWebhookHandler {
188
204
  // Queue eviction check runs bypass the metadata-only filter because
189
205
  // they're individual check_run events (not check_suite), but they
190
206
  // must drive state transitions.
191
- const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
192
- const protocol = resolveMergeQueueProtocol(project);
193
- if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
194
- this.maybeEnqueueReactiveRun(freshIssue, event, project);
207
+ if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
208
+ await this.maybeEnqueueReactiveRun(freshIssue, event, project);
195
209
  }
196
210
  else if (!isMetadataOnlyCheckEvent(event)) {
197
- this.maybeEnqueueReactiveRun(freshIssue, event, project);
211
+ await this.maybeEnqueueReactiveRun(freshIssue, event, project);
198
212
  }
199
213
  }
200
- maybeEnqueueReactiveRun(issue, event, project) {
214
+ async updateCiSnapshot(issue, event, project) {
215
+ if (event.triggerEvent === "pr_merged") {
216
+ this.db.upsertIssue({
217
+ projectId: issue.projectId,
218
+ linearIssueId: issue.linearIssueId,
219
+ lastGitHubCiSnapshotHeadSha: null,
220
+ lastGitHubCiSnapshotGateCheckName: null,
221
+ lastGitHubCiSnapshotGateCheckStatus: null,
222
+ lastGitHubCiSnapshotJson: null,
223
+ lastGitHubCiSnapshotSettledAt: null,
224
+ });
225
+ return;
226
+ }
227
+ if (event.triggerEvent === "pr_synchronize") {
228
+ this.db.upsertIssue({
229
+ projectId: issue.projectId,
230
+ linearIssueId: issue.linearIssueId,
231
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
232
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
233
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
234
+ lastGitHubCiSnapshotJson: null,
235
+ lastGitHubCiSnapshotSettledAt: null,
236
+ });
237
+ return;
238
+ }
239
+ if (issue.prState !== "open")
240
+ return;
241
+ if (event.eventSource !== "check_run")
242
+ return;
243
+ if (this.isQueueEvictionFailure(issue, event, project))
244
+ return;
245
+ if (!this.isGateCheckEvent(event, project))
246
+ return;
247
+ if (this.isStaleGateEvent(issue, event))
248
+ return;
249
+ const snapshot = await this.ciSnapshotResolver.resolve({
250
+ repoFullName: project?.github?.repoFullName ?? event.repoFullName,
251
+ event,
252
+ gateCheckNames: this.getGateCheckNames(project),
253
+ });
254
+ if (!snapshot) {
255
+ this.db.upsertIssue({
256
+ projectId: issue.projectId,
257
+ linearIssueId: issue.linearIssueId,
258
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
259
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
260
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
261
+ lastGitHubCiSnapshotJson: null,
262
+ lastGitHubCiSnapshotSettledAt: null,
263
+ });
264
+ this.logger.warn({ issueKey: issue.issueKey, repoFullName: project?.github?.repoFullName ?? event.repoFullName, headSha: event.headSha }, "Could not resolve settled CI snapshot; waiting before CI repair");
265
+ this.feed?.publish({
266
+ level: "warn",
267
+ kind: "github",
268
+ issueKey: issue.issueKey,
269
+ projectId: issue.projectId,
270
+ stage: issue.factoryState,
271
+ status: "ci_snapshot_unavailable",
272
+ summary: `Could not resolve settled ${this.getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
273
+ });
274
+ return;
275
+ }
276
+ this.db.upsertIssue({
277
+ projectId: issue.projectId,
278
+ linearIssueId: issue.linearIssueId,
279
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha,
280
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
281
+ lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
282
+ lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
283
+ lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
284
+ });
285
+ }
286
+ async maybeEnqueueReactiveRun(issue, event, project) {
201
287
  // Don't trigger if there's already an active run
202
288
  if (issue.activeRunId !== undefined)
203
289
  return;
@@ -208,19 +294,26 @@ export class GitHubWebhookHandler {
208
294
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
209
295
  // External merge queue eviction: react only to the configured check
210
296
  // name, not to any CI failure. Regular CI failures still get ci_repair.
211
- const protocol = resolveMergeQueueProtocol(project);
212
- const queueCheckName = protocol.evictionCheckName;
213
- if (issue.factoryState === "awaiting_queue"
214
- && event.checkName === queueCheckName) {
297
+ if (this.isQueueEvictionFailure(issue, event, project)) {
215
298
  const queueRepairContext = buildQueueRepairContextFromEvent(event);
299
+ const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
300
+ if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
301
+ return;
302
+ }
216
303
  this.db.upsertIssue({
217
304
  projectId: issue.projectId,
218
305
  linearIssueId: issue.linearIssueId,
219
306
  pendingRunType: "queue_repair",
220
- pendingRunContextJson: JSON.stringify(queueRepairContext),
307
+ pendingRunContextJson: JSON.stringify({
308
+ ...queueRepairContext,
309
+ ...failureContext,
310
+ }),
221
311
  lastGitHubFailureSource: "queue_eviction",
312
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
313
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
222
314
  lastGitHubFailureCheckName: event.checkName ?? null,
223
315
  lastGitHubFailureCheckUrl: event.checkUrl ?? null,
316
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
224
317
  lastGitHubFailureAt: new Date().toISOString(),
225
318
  lastQueueSignalAt: new Date().toISOString(),
226
319
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
@@ -239,23 +332,53 @@ export class GitHubWebhookHandler {
239
332
  });
240
333
  }
241
334
  else {
335
+ if (!this.isSettledBranchFailure(issue, event, project)) {
336
+ this.feed?.publish({
337
+ level: "info",
338
+ kind: "github",
339
+ issueKey: issue.issueKey,
340
+ projectId: issue.projectId,
341
+ stage: issue.factoryState,
342
+ status: "ci_waiting_for_settlement",
343
+ summary: `Waiting for settled ${this.getPrimaryGateCheckName(project)} result before starting CI repair`,
344
+ });
345
+ return;
346
+ }
347
+ const failureContext = await this.resolveBranchFailureContext(issue, event, project);
348
+ if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
349
+ return;
350
+ }
351
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
242
352
  this.db.upsertIssue({
243
353
  projectId: issue.projectId,
244
354
  linearIssueId: issue.linearIssueId,
245
355
  pendingRunType: "ci_repair",
246
356
  pendingRunContextJson: JSON.stringify({
247
- checkName: event.checkName,
248
- checkUrl: event.checkUrl,
249
- checkClass: resolveCheckClass(event.checkName, project),
357
+ ...failureContext,
358
+ checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
359
+ ...(snapshot ? { ciSnapshot: snapshot } : {}),
250
360
  }),
251
361
  lastGitHubFailureSource: "branch_ci",
252
- lastGitHubFailureCheckName: event.checkName ?? null,
253
- lastGitHubFailureCheckUrl: event.checkUrl ?? null,
362
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
363
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
364
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
365
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
366
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
254
367
  lastGitHubFailureAt: new Date().toISOString(),
255
368
  lastQueueIncidentJson: null,
256
369
  });
257
370
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
258
- this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
371
+ this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
372
+ this.feed?.publish({
373
+ level: "warn",
374
+ kind: "github",
375
+ issueKey: issue.issueKey,
376
+ projectId: issue.projectId,
377
+ stage: "repairing_ci",
378
+ status: "ci_repair_queued",
379
+ summary: `CI repair queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
380
+ detail: summarizeGitHubFailureContext(failureContext),
381
+ });
259
382
  }
260
383
  }
261
384
  if (event.triggerEvent === "review_changes_requested") {
@@ -272,23 +395,27 @@ export class GitHubWebhookHandler {
272
395
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
273
396
  }
274
397
  }
275
- updateFailureProvenance(issue, event) {
276
- const project = this.config.projects.find((p) => p.id === issue.projectId);
277
- const protocol = resolveMergeQueueProtocol(project);
278
- const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
398
+ async updateFailureProvenance(issue, event, project) {
399
+ const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
279
400
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
280
- if (isMetadataOnlyCheckEvent(event) && !isQueueEvictionCheck) {
281
- return;
282
- }
283
- const source = issue.factoryState === "awaiting_queue" && isQueueEvictionCheck
401
+ const source = isQueueEvictionCheck
284
402
  ? "queue_eviction"
285
403
  : "branch_ci";
404
+ if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
405
+ return;
406
+ }
407
+ const failureContext = source === "queue_eviction"
408
+ ? this.buildQueueFailureContext(issue, event)
409
+ : await this.resolveBranchFailureContext(issue, event, project);
286
410
  this.db.upsertIssue({
287
411
  projectId: issue.projectId,
288
412
  linearIssueId: issue.linearIssueId,
289
413
  lastGitHubFailureSource: source,
290
- lastGitHubFailureCheckName: event.checkName ?? null,
291
- lastGitHubFailureCheckUrl: event.checkUrl ?? null,
414
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
415
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
416
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
417
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
418
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
292
419
  lastGitHubFailureAt: new Date().toISOString(),
293
420
  ...(source === "queue_eviction"
294
421
  ? {
@@ -301,19 +428,172 @@ export class GitHubWebhookHandler {
301
428
  });
302
429
  return;
303
430
  }
304
- if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
431
+ if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
305
432
  || event.triggerEvent === "pr_synchronize"
306
433
  || event.triggerEvent === "pr_merged") {
434
+ if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
435
+ return;
436
+ }
307
437
  this.db.upsertIssue({
308
438
  projectId: issue.projectId,
309
439
  linearIssueId: issue.linearIssueId,
310
440
  lastGitHubFailureSource: null,
441
+ lastGitHubFailureHeadSha: null,
442
+ lastGitHubFailureSignature: null,
311
443
  lastGitHubFailureCheckName: null,
312
444
  lastGitHubFailureCheckUrl: null,
445
+ lastGitHubFailureContextJson: null,
313
446
  lastGitHubFailureAt: null,
314
447
  lastQueueIncidentJson: null,
448
+ lastAttemptedFailureHeadSha: null,
449
+ lastAttemptedFailureSignature: null,
450
+ });
451
+ }
452
+ }
453
+ async resolveBranchFailureContext(issue, event, project) {
454
+ const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
455
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
456
+ const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
457
+ const context = await this.failureContextResolver.resolve({
458
+ source: "branch_ci",
459
+ repoFullName,
460
+ event: primaryFailedCheck
461
+ ? {
462
+ ...event,
463
+ checkName: primaryFailedCheck.name,
464
+ checkUrl: primaryFailedCheck.detailsUrl ?? event.checkUrl,
465
+ checkDetailsUrl: primaryFailedCheck.detailsUrl ?? event.checkDetailsUrl,
466
+ }
467
+ : event,
468
+ });
469
+ return {
470
+ ...(context ? context : {}),
471
+ ...(context?.headSha || event.headSha ? { failureHeadSha: context?.headSha ?? event.headSha } : {}),
472
+ ...(context?.failureSignature ? { failureSignature: context.failureSignature } : {}),
473
+ };
474
+ }
475
+ buildQueueFailureContext(issue, event, queueRepairContext) {
476
+ const repoFullName = event.repoFullName || this.config.projects.find((p) => p.id === issue.projectId)?.github?.repoFullName || "";
477
+ const incident = queueRepairContext && typeof queueRepairContext === "object"
478
+ ? queueRepairContext
479
+ : undefined;
480
+ const summary = typeof incident?.incidentSummary === "string"
481
+ ? incident.incidentSummary
482
+ : event.checkOutputSummary ?? event.checkOutputTitle;
483
+ const failureHeadSha = event.headSha;
484
+ const failureSignature = [
485
+ "queue_eviction",
486
+ failureHeadSha ?? "unknown-sha",
487
+ event.checkName ?? "merge-steward/queue",
488
+ ].join("::");
489
+ return {
490
+ source: "queue_eviction",
491
+ repoFullName,
492
+ capturedAt: new Date().toISOString(),
493
+ ...(failureHeadSha ? { headSha: failureHeadSha, failureHeadSha } : {}),
494
+ ...(event.checkName ? { checkName: event.checkName } : {}),
495
+ ...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
496
+ ...(event.checkDetailsUrl ? { checkDetailsUrl: event.checkDetailsUrl } : {}),
497
+ ...(summary ? { summary } : {}),
498
+ failureSignature,
499
+ };
500
+ }
501
+ hasDuplicatePendingReactiveRun(issue, runType, failureContext) {
502
+ const signature = typeof failureContext.failureSignature === "string" ? failureContext.failureSignature : undefined;
503
+ const headSha = typeof failureContext.failureHeadSha === "string"
504
+ ? failureContext.failureHeadSha
505
+ : typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
506
+ if (!signature)
507
+ return false;
508
+ if (issue.pendingRunType === runType && issue.pendingRunContextJson) {
509
+ const existing = safeJsonParse(issue.pendingRunContextJson);
510
+ if (existing?.failureSignature === signature
511
+ && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
512
+ this.feed?.publish({
513
+ level: "info",
514
+ kind: "github",
515
+ issueKey: issue.issueKey,
516
+ projectId: issue.projectId,
517
+ stage: issue.factoryState,
518
+ status: "repair_deduped",
519
+ summary: `Skipped duplicate ${runType} for ${signature}`,
520
+ });
521
+ return true;
522
+ }
523
+ }
524
+ if (issue.lastAttemptedFailureSignature === signature
525
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha)) {
526
+ this.feed?.publish({
527
+ level: "info",
528
+ kind: "github",
529
+ issueKey: issue.issueKey,
530
+ projectId: issue.projectId,
531
+ stage: issue.factoryState,
532
+ status: "repair_deduped",
533
+ summary: `Already attempted ${runType} for this failing PR head`,
315
534
  });
535
+ return true;
316
536
  }
537
+ return false;
538
+ }
539
+ getGateCheckNames(project) {
540
+ const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
541
+ return configured.length > 0 ? configured : ["Tests"];
542
+ }
543
+ getPrimaryGateCheckName(project) {
544
+ return this.getGateCheckNames(project)[0] ?? "Tests";
545
+ }
546
+ isGateCheckEvent(event, project) {
547
+ if (event.eventSource !== "check_run" || !event.checkName)
548
+ return false;
549
+ const normalized = event.checkName.trim().toLowerCase();
550
+ return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
551
+ }
552
+ isStaleGateEvent(issue, event) {
553
+ return Boolean(issue.lastGitHubCiSnapshotHeadSha
554
+ && event.headSha
555
+ && issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
556
+ }
557
+ isQueueEvictionFailure(issue, event, project) {
558
+ const protocol = resolveMergeQueueProtocol(project);
559
+ return issue.factoryState === "awaiting_queue"
560
+ && event.eventSource === "check_run"
561
+ && event.checkName === protocol.evictionCheckName;
562
+ }
563
+ isSettledBranchFailure(issue, event, project) {
564
+ if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
565
+ return false;
566
+ if (!this.isGateCheckEvent(event, project))
567
+ return false;
568
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
569
+ return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
570
+ }
571
+ canClearFailureProvenance(issue, event, project) {
572
+ if (event.triggerEvent !== "check_passed")
573
+ return true;
574
+ if (this.isQueueEvictionFailure(issue, event, project)) {
575
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
576
+ }
577
+ if (!this.isGateCheckEvent(event, project)) {
578
+ return true;
579
+ }
580
+ if (this.isStaleGateEvent(issue, event)) {
581
+ return false;
582
+ }
583
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
584
+ }
585
+ getRelevantCiSnapshot(issue, event) {
586
+ const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
587
+ if (!snapshot)
588
+ return undefined;
589
+ if (snapshot.headSha !== event.headSha)
590
+ return undefined;
591
+ return snapshot;
592
+ }
593
+ pickPrimaryFailedCheck(snapshot) {
594
+ const gateName = snapshot.gateCheckName?.trim().toLowerCase();
595
+ return snapshot.failedChecks.find((entry) => entry.name.trim().toLowerCase() !== gateName)
596
+ ?? snapshot.failedChecks[0];
317
597
  }
318
598
  async emitLinearActivity(issue, newState, event) {
319
599
  if (!issue.agentSessionId)
@@ -421,9 +701,9 @@ export class GitHubWebhookHandler {
421
701
  function resolveCheckClass(checkName, project) {
422
702
  if (!checkName || !project)
423
703
  return "code";
424
- if (project.reviewChecks.some((name) => checkName.includes(name)))
704
+ if ((project.reviewChecks ?? []).some((name) => checkName.includes(name)))
425
705
  return "review";
426
- if (project.gateChecks.some((name) => checkName.includes(name)))
706
+ if ((project.gateChecks ?? []).some((name) => checkName.includes(name)))
427
707
  return "gate";
428
708
  return "code";
429
709
  }
@@ -1,3 +1,4 @@
1
+ import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
1
2
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
2
3
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
4
  import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
@@ -149,6 +150,7 @@ export class IssueQueryService {
149
150
  buildQueueProtocol(projectId, issue) {
150
151
  const project = this.config.projects.find((entry) => entry.id === projectId);
151
152
  const protocol = resolveMergeQueueProtocol(project);
153
+ const failureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
152
154
  const queueIncident = issue.lastQueueIncidentJson
153
155
  ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
154
156
  : undefined;
@@ -159,8 +161,12 @@ export class IssueQueryService {
159
161
  evictionCheckName: protocol.evictionCheckName,
160
162
  prNumber: issue.prNumber ?? null,
161
163
  lastFailureSource: issue.lastGitHubFailureSource ?? null,
164
+ lastFailureHeadSha: issue.lastGitHubFailureHeadSha ?? failureContext?.headSha ?? null,
165
+ lastFailureSignature: issue.lastGitHubFailureSignature ?? failureContext?.failureSignature ?? null,
162
166
  lastFailureCheckName: issue.lastGitHubFailureCheckName ?? null,
163
167
  lastFailureCheckUrl: issue.lastGitHubFailureCheckUrl ?? null,
168
+ lastFailureStepName: failureContext?.stepName ?? null,
169
+ lastFailureSummary: summarizeGitHubFailureContext(failureContext) ?? null,
164
170
  lastFailureAt: issue.lastGitHubFailureAt ?? null,
165
171
  lastQueueSignalAt: issue.lastQueueSignalAt ?? null,
166
172
  lastIncidentId: queueIncident?.incidentId ?? null,