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.
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/IssueDetailView.js +1 -1
- package/dist/cli/watch/use-detail-stream.js +5 -0
- package/dist/cli/watch/watch-state.js +14 -0
- package/dist/db/migrations.js +10 -0
- package/dist/db.js +106 -2
- package/dist/github-failure-context.js +298 -0
- package/dist/github-webhook-handler.js +318 -38
- package/dist/issue-query-service.js +6 -0
- package/dist/run-orchestrator.js +162 -10
- package/dist/service.js +21 -2
- package/package.json +1 -1
|
@@ -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
|
|
13
|
-
* but only
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
357
|
+
...failureContext,
|
|
358
|
+
checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
359
|
+
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
250
360
|
}),
|
|
251
361
|
lastGitHubFailureSource: "branch_ci",
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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,
|