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,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.30.1",
4
- "commit": "dee1d8b522b1",
5
- "builtAt": "2026-04-01T08:31:08.208Z"
3
+ "version": "0.32.0",
4
+ "commit": "21ba6968b0ff",
5
+ "builtAt": "2026-04-01T11:53:53.815Z"
6
6
  }
@@ -44,5 +44,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
44
44
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
45
45
  const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
46
  const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
48
48
  }
@@ -54,6 +54,11 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
54
54
  ciRepairAttempts: typeof i.ciRepairAttempts === "number" ? i.ciRepairAttempts : 0,
55
55
  queueRepairAttempts: typeof i.queueRepairAttempts === "number" ? i.queueRepairAttempts : 0,
56
56
  reviewFixAttempts: typeof i.reviewFixAttempts === "number" ? i.reviewFixAttempts : 0,
57
+ latestFailureSource: typeof i.latestFailureSource === "string" ? i.latestFailureSource : undefined,
58
+ latestFailureHeadSha: typeof i.latestFailureHeadSha === "string" ? i.latestFailureHeadSha : undefined,
59
+ latestFailureCheckName: typeof i.latestFailureCheckName === "string" ? i.latestFailureCheckName : undefined,
60
+ latestFailureStepName: typeof i.latestFailureStepName === "string" ? i.latestFailureStepName : undefined,
61
+ latestFailureSummary: typeof i.latestFailureSummary === "string" ? i.latestFailureSummary : undefined,
57
62
  runCount: runs.length,
58
63
  };
59
64
  }
@@ -171,6 +171,20 @@ function applyFeedEvent(state, event, receivedAt) {
171
171
  if (event.status === "check_passed" || event.status === "check_failed") {
172
172
  issue.prCheckStatus = event.status === "check_passed" ? "passed" : "failed";
173
173
  }
174
+ if (event.status === "ci_repair_queued") {
175
+ issue.factoryState = "repairing_ci";
176
+ issue.statusNote = event.detail ?? event.summary;
177
+ }
178
+ if (event.status === "queue_repair_queued") {
179
+ issue.factoryState = "repairing_queue";
180
+ issue.statusNote = event.detail ?? event.summary;
181
+ }
182
+ if (event.status === "repair_deduped" || event.status === "branch_not_advanced") {
183
+ issue.statusNote = event.summary;
184
+ }
185
+ }
186
+ if ((event.kind === "turn" || event.kind === "github") && event.status === "branch_not_advanced") {
187
+ issue.statusNote = event.summary;
174
188
  }
175
189
  issue.updatedAt = event.at;
176
190
  updated[index] = issue;
@@ -198,11 +198,21 @@ export function runPatchRelayMigrations(connection) {
198
198
  // Preserve GitHub failure provenance so reconciliation can distinguish
199
199
  // branch CI failures from merge-queue evictions after webhook delivery.
200
200
  addColumnIfMissing(connection, "issues", "last_github_failure_source", "TEXT");
201
+ addColumnIfMissing(connection, "issues", "last_github_failure_head_sha", "TEXT");
202
+ addColumnIfMissing(connection, "issues", "last_github_failure_signature", "TEXT");
201
203
  addColumnIfMissing(connection, "issues", "last_github_failure_check_name", "TEXT");
202
204
  addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
205
+ addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
203
206
  addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
207
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_head_sha", "TEXT");
208
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_name", "TEXT");
209
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_status", "TEXT");
210
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_json", "TEXT");
211
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_settled_at", "TEXT");
204
212
  addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
205
213
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
214
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
215
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
206
216
  }
207
217
  function addColumnIfMissing(connection, table, column, definition) {
208
218
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
1
2
  import { LinearInstallationStore } from "./db/linear-installation-store.js";
2
3
  import { OperatorFeedStore } from "./db/operator-feed-store.js";
3
4
  import { RepositoryLinkStore } from "./db/repository-link-store.js";
@@ -164,6 +165,14 @@ export class PatchRelayDatabase {
164
165
  sets.push("last_github_failure_source = @lastGitHubFailureSource");
165
166
  values.lastGitHubFailureSource = params.lastGitHubFailureSource;
166
167
  }
168
+ if (params.lastGitHubFailureHeadSha !== undefined) {
169
+ sets.push("last_github_failure_head_sha = @lastGitHubFailureHeadSha");
170
+ values.lastGitHubFailureHeadSha = params.lastGitHubFailureHeadSha;
171
+ }
172
+ if (params.lastGitHubFailureSignature !== undefined) {
173
+ sets.push("last_github_failure_signature = @lastGitHubFailureSignature");
174
+ values.lastGitHubFailureSignature = params.lastGitHubFailureSignature;
175
+ }
167
176
  if (params.lastGitHubFailureCheckName !== undefined) {
168
177
  sets.push("last_github_failure_check_name = @lastGitHubFailureCheckName");
169
178
  values.lastGitHubFailureCheckName = params.lastGitHubFailureCheckName;
@@ -172,10 +181,34 @@ export class PatchRelayDatabase {
172
181
  sets.push("last_github_failure_check_url = @lastGitHubFailureCheckUrl");
173
182
  values.lastGitHubFailureCheckUrl = params.lastGitHubFailureCheckUrl;
174
183
  }
184
+ if (params.lastGitHubFailureContextJson !== undefined) {
185
+ sets.push("last_github_failure_context_json = @lastGitHubFailureContextJson");
186
+ values.lastGitHubFailureContextJson = params.lastGitHubFailureContextJson;
187
+ }
175
188
  if (params.lastGitHubFailureAt !== undefined) {
176
189
  sets.push("last_github_failure_at = @lastGitHubFailureAt");
177
190
  values.lastGitHubFailureAt = params.lastGitHubFailureAt;
178
191
  }
192
+ if (params.lastGitHubCiSnapshotHeadSha !== undefined) {
193
+ sets.push("last_github_ci_snapshot_head_sha = @lastGitHubCiSnapshotHeadSha");
194
+ values.lastGitHubCiSnapshotHeadSha = params.lastGitHubCiSnapshotHeadSha;
195
+ }
196
+ if (params.lastGitHubCiSnapshotGateCheckName !== undefined) {
197
+ sets.push("last_github_ci_snapshot_gate_check_name = @lastGitHubCiSnapshotGateCheckName");
198
+ values.lastGitHubCiSnapshotGateCheckName = params.lastGitHubCiSnapshotGateCheckName;
199
+ }
200
+ if (params.lastGitHubCiSnapshotGateCheckStatus !== undefined) {
201
+ sets.push("last_github_ci_snapshot_gate_check_status = @lastGitHubCiSnapshotGateCheckStatus");
202
+ values.lastGitHubCiSnapshotGateCheckStatus = params.lastGitHubCiSnapshotGateCheckStatus;
203
+ }
204
+ if (params.lastGitHubCiSnapshotJson !== undefined) {
205
+ sets.push("last_github_ci_snapshot_json = @lastGitHubCiSnapshotJson");
206
+ values.lastGitHubCiSnapshotJson = params.lastGitHubCiSnapshotJson;
207
+ }
208
+ if (params.lastGitHubCiSnapshotSettledAt !== undefined) {
209
+ sets.push("last_github_ci_snapshot_settled_at = @lastGitHubCiSnapshotSettledAt");
210
+ values.lastGitHubCiSnapshotSettledAt = params.lastGitHubCiSnapshotSettledAt;
211
+ }
179
212
  if (params.lastQueueSignalAt !== undefined) {
180
213
  sets.push("last_queue_signal_at = @lastQueueSignalAt");
181
214
  values.lastQueueSignalAt = params.lastQueueSignalAt;
@@ -184,6 +217,14 @@ export class PatchRelayDatabase {
184
217
  sets.push("last_queue_incident_json = @lastQueueIncidentJson");
185
218
  values.lastQueueIncidentJson = params.lastQueueIncidentJson;
186
219
  }
220
+ if (params.lastAttemptedFailureHeadSha !== undefined) {
221
+ sets.push("last_attempted_failure_head_sha = @lastAttemptedFailureHeadSha");
222
+ values.lastAttemptedFailureHeadSha = params.lastAttemptedFailureHeadSha;
223
+ }
224
+ if (params.lastAttemptedFailureSignature !== undefined) {
225
+ sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
226
+ values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
227
+ }
187
228
  if (params.ciRepairAttempts !== undefined) {
188
229
  sets.push("ci_repair_attempts = @ciRepairAttempts");
189
230
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -215,7 +256,10 @@ export class PatchRelayDatabase {
215
256
  branch_name, worktree_path, thread_id, active_run_id,
216
257
  agent_session_id,
217
258
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
218
- last_github_failure_source, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_at, last_queue_signal_at, last_queue_incident_json,
259
+ last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
260
+ last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
261
+ last_queue_signal_at, last_queue_incident_json,
262
+ last_attempted_failure_head_sha, last_attempted_failure_signature,
219
263
  updated_at
220
264
  ) VALUES (
221
265
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
@@ -224,7 +268,10 @@ export class PatchRelayDatabase {
224
268
  @branchName, @worktreePath, @threadId, @activeRunId,
225
269
  @agentSessionId,
226
270
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
227
- @lastGitHubFailureSource, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
271
+ @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
272
+ @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
273
+ @lastQueueSignalAt, @lastQueueIncidentJson,
274
+ @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
228
275
  @now
229
276
  )
230
277
  `).run({
@@ -252,11 +299,21 @@ export class PatchRelayDatabase {
252
299
  prReviewState: params.prReviewState ?? null,
253
300
  prCheckStatus: params.prCheckStatus ?? null,
254
301
  lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
302
+ lastGitHubFailureHeadSha: params.lastGitHubFailureHeadSha ?? null,
303
+ lastGitHubFailureSignature: params.lastGitHubFailureSignature ?? null,
255
304
  lastGitHubFailureCheckName: params.lastGitHubFailureCheckName ?? null,
256
305
  lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
306
+ lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
257
307
  lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
308
+ lastGitHubCiSnapshotHeadSha: params.lastGitHubCiSnapshotHeadSha ?? null,
309
+ lastGitHubCiSnapshotGateCheckName: params.lastGitHubCiSnapshotGateCheckName ?? null,
310
+ lastGitHubCiSnapshotGateCheckStatus: params.lastGitHubCiSnapshotGateCheckStatus ?? null,
311
+ lastGitHubCiSnapshotJson: params.lastGitHubCiSnapshotJson ?? null,
312
+ lastGitHubCiSnapshotSettledAt: params.lastGitHubCiSnapshotSettledAt ?? null,
258
313
  lastQueueSignalAt: params.lastQueueSignalAt ?? null,
259
314
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
315
+ lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
316
+ lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
260
317
  now,
261
318
  });
262
319
  }
@@ -353,6 +410,17 @@ export class PatchRelayDatabase {
353
410
  linearIssueId: String(row.linear_issue_id),
354
411
  }));
355
412
  }
413
+ getLatestGitHubCiSnapshot(projectId, linearIssueId) {
414
+ const issue = this.getIssue(projectId, linearIssueId);
415
+ if (!issue?.lastGitHubCiSnapshotJson)
416
+ return undefined;
417
+ try {
418
+ return JSON.parse(issue.lastGitHubCiSnapshotJson);
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ }
356
424
  countUnresolvedBlockers(projectId, linearIssueId) {
357
425
  const row = this.connection.prepare(`
358
426
  SELECT COUNT(*) AS count
@@ -508,6 +576,7 @@ export class PatchRelayDatabase {
508
576
  issueToTrackedIssue(issue) {
509
577
  const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
510
578
  const unresolvedBlockedBy = blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
579
+ const failureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
511
580
  return {
512
581
  id: issue.id,
513
582
  projectId: issue.projectId,
@@ -521,6 +590,11 @@ export class PatchRelayDatabase {
521
590
  blockedByKeys: unresolvedBlockedBy
522
591
  .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
523
592
  readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined && unresolvedBlockedBy.length === 0,
593
+ ...(issue.lastGitHubFailureSource ? { latestFailureSource: issue.lastGitHubFailureSource } : {}),
594
+ ...(issue.lastGitHubFailureHeadSha ? { latestFailureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
595
+ ...(issue.lastGitHubFailureCheckName ? { latestFailureCheckName: issue.lastGitHubFailureCheckName } : {}),
596
+ ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
597
+ ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
524
598
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
525
599
  ...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
526
600
  updatedAt: issue.updatedAt,
@@ -580,21 +654,51 @@ function mapIssueRow(row) {
580
654
  ...(row.last_github_failure_source !== null && row.last_github_failure_source !== undefined
581
655
  ? { lastGitHubFailureSource: String(row.last_github_failure_source) }
582
656
  : {}),
657
+ ...(row.last_github_failure_head_sha !== null && row.last_github_failure_head_sha !== undefined
658
+ ? { lastGitHubFailureHeadSha: String(row.last_github_failure_head_sha) }
659
+ : {}),
660
+ ...(row.last_github_failure_signature !== null && row.last_github_failure_signature !== undefined
661
+ ? { lastGitHubFailureSignature: String(row.last_github_failure_signature) }
662
+ : {}),
583
663
  ...(row.last_github_failure_check_name !== null && row.last_github_failure_check_name !== undefined
584
664
  ? { lastGitHubFailureCheckName: String(row.last_github_failure_check_name) }
585
665
  : {}),
586
666
  ...(row.last_github_failure_check_url !== null && row.last_github_failure_check_url !== undefined
587
667
  ? { lastGitHubFailureCheckUrl: String(row.last_github_failure_check_url) }
588
668
  : {}),
669
+ ...(row.last_github_failure_context_json !== null && row.last_github_failure_context_json !== undefined
670
+ ? { lastGitHubFailureContextJson: String(row.last_github_failure_context_json) }
671
+ : {}),
589
672
  ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
590
673
  ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
591
674
  : {}),
675
+ ...(row.last_github_ci_snapshot_head_sha !== null && row.last_github_ci_snapshot_head_sha !== undefined
676
+ ? { lastGitHubCiSnapshotHeadSha: String(row.last_github_ci_snapshot_head_sha) }
677
+ : {}),
678
+ ...(row.last_github_ci_snapshot_gate_check_name !== null && row.last_github_ci_snapshot_gate_check_name !== undefined
679
+ ? { lastGitHubCiSnapshotGateCheckName: String(row.last_github_ci_snapshot_gate_check_name) }
680
+ : {}),
681
+ ...(row.last_github_ci_snapshot_gate_check_status !== null && row.last_github_ci_snapshot_gate_check_status !== undefined
682
+ ? { lastGitHubCiSnapshotGateCheckStatus: String(row.last_github_ci_snapshot_gate_check_status) }
683
+ : {}),
684
+ ...(row.last_github_ci_snapshot_json !== null && row.last_github_ci_snapshot_json !== undefined
685
+ ? { lastGitHubCiSnapshotJson: String(row.last_github_ci_snapshot_json) }
686
+ : {}),
687
+ ...(row.last_github_ci_snapshot_settled_at !== null && row.last_github_ci_snapshot_settled_at !== undefined
688
+ ? { lastGitHubCiSnapshotSettledAt: String(row.last_github_ci_snapshot_settled_at) }
689
+ : {}),
592
690
  ...(row.last_queue_signal_at !== null && row.last_queue_signal_at !== undefined
593
691
  ? { lastQueueSignalAt: String(row.last_queue_signal_at) }
594
692
  : {}),
595
693
  ...(row.last_queue_incident_json !== null && row.last_queue_incident_json !== undefined
596
694
  ? { lastQueueIncidentJson: String(row.last_queue_incident_json) }
597
695
  : {}),
696
+ ...(row.last_attempted_failure_head_sha !== null && row.last_attempted_failure_head_sha !== undefined
697
+ ? { lastAttemptedFailureHeadSha: String(row.last_attempted_failure_head_sha) }
698
+ : {}),
699
+ ...(row.last_attempted_failure_signature !== null && row.last_attempted_failure_signature !== undefined
700
+ ? { lastAttemptedFailureSignature: String(row.last_attempted_failure_signature) }
701
+ : {}),
598
702
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
599
703
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
600
704
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -0,0 +1,298 @@
1
+ import { execCommand, safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
2
+ const FAILED_CONCLUSIONS = new Set([
3
+ "failure",
4
+ "timed_out",
5
+ "cancelled",
6
+ "startup_failure",
7
+ "action_required",
8
+ "stale",
9
+ ]);
10
+ export function createGitHubFailureContextResolver() {
11
+ return {
12
+ resolve: async ({ source, repoFullName, event }) => {
13
+ if (!repoFullName)
14
+ return undefined;
15
+ if (source === "queue_eviction") {
16
+ const queueContext = buildFallbackFailureContext(source, repoFullName, event);
17
+ return {
18
+ ...queueContext,
19
+ failureSignature: buildFailureSignature({
20
+ source,
21
+ headSha: queueContext.headSha,
22
+ checkName: queueContext.checkName,
23
+ }),
24
+ };
25
+ }
26
+ const fallback = buildFallbackFailureContext(source, repoFullName, event);
27
+ try {
28
+ const failedCheck = await resolveFailedCheckRun(repoFullName, event);
29
+ const workflowRunId = parseWorkflowRunId(failedCheck?.detailsUrl ?? failedCheck?.htmlUrl ?? event.checkDetailsUrl ?? event.checkUrl);
30
+ const workflowJob = workflowRunId
31
+ ? await resolveWorkflowJob(repoFullName, workflowRunId, failedCheck?.name ?? event.checkName)
32
+ : undefined;
33
+ const annotations = failedCheck?.id
34
+ ? await resolveAnnotations(repoFullName, failedCheck.id)
35
+ : undefined;
36
+ const summary = firstNonEmpty(annotations?.[0], failedCheck?.outputTitle, failedCheck?.outputSummary, event.checkOutputTitle, event.checkOutputSummary, workflowJob?.stepName ? `Failed step: ${workflowJob.stepName}` : undefined);
37
+ const checkName = firstNonEmpty(failedCheck?.name, event.checkName);
38
+ const checkUrl = firstNonEmpty(failedCheck?.htmlUrl, event.checkUrl);
39
+ const checkDetailsUrl = firstNonEmpty(failedCheck?.detailsUrl, event.checkDetailsUrl);
40
+ const jobName = firstNonEmpty(workflowJob?.name, failedCheck?.name, event.checkName);
41
+ const stepName = workflowJob?.stepName;
42
+ return {
43
+ source,
44
+ repoFullName,
45
+ capturedAt: new Date().toISOString(),
46
+ ...(event.headSha ? { headSha: event.headSha } : {}),
47
+ ...(checkName ? { checkName } : {}),
48
+ ...(checkUrl ? { checkUrl } : {}),
49
+ ...(checkDetailsUrl ? { checkDetailsUrl } : {}),
50
+ ...(workflowRunId !== undefined ? { workflowRunId } : {}),
51
+ ...(jobName ? { jobName } : {}),
52
+ ...(stepName ? { stepName } : {}),
53
+ ...(summary ? { summary } : {}),
54
+ ...(annotations && annotations.length > 0 ? { annotations } : {}),
55
+ failureSignature: buildFailureSignature({
56
+ source,
57
+ headSha: event.headSha,
58
+ checkName,
59
+ jobName,
60
+ stepName,
61
+ }),
62
+ };
63
+ }
64
+ catch {
65
+ return {
66
+ ...fallback,
67
+ failureSignature: buildFailureSignature({
68
+ source,
69
+ headSha: fallback.headSha,
70
+ checkName: fallback.checkName,
71
+ stepName: fallback.stepName,
72
+ }),
73
+ };
74
+ }
75
+ },
76
+ };
77
+ }
78
+ export function createGitHubCiSnapshotResolver() {
79
+ return {
80
+ resolve: async ({ repoFullName, event, gateCheckNames }) => {
81
+ if (!repoFullName || !event.headSha)
82
+ return undefined;
83
+ try {
84
+ const checks = await resolveCheckSnapshotChecks(repoFullName, event.headSha);
85
+ return buildCiSnapshotFromChecks(checks, event, gateCheckNames);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ },
91
+ };
92
+ }
93
+ export function parseGitHubFailureContext(value) {
94
+ if (!value)
95
+ return undefined;
96
+ return safeJsonParse(value);
97
+ }
98
+ export function summarizeGitHubFailureContext(context) {
99
+ if (!context)
100
+ return undefined;
101
+ if (context.source === "queue_eviction") {
102
+ return firstNonEmpty(context.summary, context.checkName, "Queue eviction");
103
+ }
104
+ const lead = firstNonEmpty(context.jobName, context.checkName);
105
+ const step = context.stepName ? `${lead ?? "CI"} -> ${context.stepName}` : lead;
106
+ return firstNonEmpty(step && context.summary ? `${step}: ${context.summary}` : undefined, step, context.summary);
107
+ }
108
+ function buildFallbackFailureContext(source, repoFullName, event) {
109
+ const summary = firstNonEmpty(event.checkOutputTitle, event.checkOutputSummary, event.checkOutputText ? sanitizeDiagnosticText(event.checkOutputText, 240) : undefined);
110
+ return {
111
+ source,
112
+ repoFullName,
113
+ capturedAt: new Date().toISOString(),
114
+ ...(event.headSha ? { headSha: event.headSha } : {}),
115
+ ...(event.checkName ? { checkName: event.checkName } : {}),
116
+ ...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
117
+ ...(event.checkDetailsUrl ? { checkDetailsUrl: event.checkDetailsUrl } : {}),
118
+ ...(event.checkName ? { jobName: event.checkName } : {}),
119
+ ...(summary ? { summary } : {}),
120
+ };
121
+ }
122
+ async function resolveFailedCheckRun(repoFullName, event) {
123
+ if (!event.headSha)
124
+ return undefined;
125
+ const response = await execCommand("gh", [
126
+ "api",
127
+ `repos/${repoFullName}/commits/${event.headSha}/check-runs`,
128
+ "--method", "GET",
129
+ ], { timeoutMs: 15_000 });
130
+ if (response.exitCode !== 0) {
131
+ throw new Error(response.stderr || "gh api check-runs failed");
132
+ }
133
+ const payload = safeJsonParse(response.stdout);
134
+ const checks = (payload?.check_runs ?? [])
135
+ .map(mapCheckRunSummary)
136
+ .filter((entry) => entry.conclusion && FAILED_CONCLUSIONS.has(entry.conclusion.toLowerCase()));
137
+ return checks.find((entry) => entry.name === event.checkName)
138
+ ?? checks.find((entry) => entry.name && event.checkName && entry.name.includes(event.checkName))
139
+ ?? checks[0];
140
+ }
141
+ async function resolveCheckSnapshotChecks(repoFullName, headSha) {
142
+ const response = await execCommand("gh", [
143
+ "api",
144
+ `repos/${repoFullName}/commits/${headSha}/check-runs`,
145
+ "--method", "GET",
146
+ ], { timeoutMs: 15_000 });
147
+ if (response.exitCode !== 0) {
148
+ throw new Error(response.stderr || "gh api check-runs failed");
149
+ }
150
+ const payload = safeJsonParse(response.stdout);
151
+ return (payload?.check_runs ?? []).map(mapCiSnapshotCheck).filter((entry) => Boolean(entry));
152
+ }
153
+ async function resolveWorkflowJob(repoFullName, workflowRunId, preferredName) {
154
+ const response = await execCommand("gh", [
155
+ "api",
156
+ `repos/${repoFullName}/actions/runs/${workflowRunId}/jobs`,
157
+ "--method", "GET",
158
+ ], { timeoutMs: 15_000 });
159
+ if (response.exitCode !== 0) {
160
+ throw new Error(response.stderr || "gh api workflow jobs failed");
161
+ }
162
+ const payload = safeJsonParse(response.stdout);
163
+ const jobs = (payload?.jobs ?? []).map(mapWorkflowJobSummary);
164
+ return jobs.find((entry) => entry.name === preferredName)
165
+ ?? jobs.find((entry) => entry.name && preferredName && entry.name.includes(preferredName))
166
+ ?? jobs.find((entry) => entry.conclusion && FAILED_CONCLUSIONS.has(entry.conclusion.toLowerCase()))
167
+ ?? jobs[0];
168
+ }
169
+ async function resolveAnnotations(repoFullName, checkRunId) {
170
+ const response = await execCommand("gh", [
171
+ "api",
172
+ `repos/${repoFullName}/check-runs/${checkRunId}/annotations`,
173
+ "--method", "GET",
174
+ "-F", "per_page=20",
175
+ ], { timeoutMs: 15_000 });
176
+ if (response.exitCode !== 0) {
177
+ throw new Error(response.stderr || "gh api annotations failed");
178
+ }
179
+ const payload = safeJsonParse(response.stdout) ?? [];
180
+ return payload
181
+ .map((entry) => {
182
+ const title = typeof entry.title === "string" ? entry.title.trim() : "";
183
+ const message = typeof entry.message === "string" ? entry.message.trim() : "";
184
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
185
+ const rendered = [title, message, path ? `(${path})` : ""].filter(Boolean).join(": ");
186
+ return rendered ? sanitizeDiagnosticText(rendered, 240) : undefined;
187
+ })
188
+ .filter((entry) => Boolean(entry));
189
+ }
190
+ function mapCheckRunSummary(row) {
191
+ const output = row.output && typeof row.output === "object" ? row.output : undefined;
192
+ return {
193
+ ...(typeof row.id === "number" ? { id: row.id } : {}),
194
+ ...(typeof row.name === "string" ? { name: row.name } : {}),
195
+ ...(typeof row.html_url === "string" ? { htmlUrl: row.html_url } : {}),
196
+ ...(typeof row.details_url === "string" ? { detailsUrl: row.details_url } : {}),
197
+ ...(typeof row.conclusion === "string" ? { conclusion: row.conclusion } : {}),
198
+ ...(typeof output?.title === "string" ? { outputTitle: output.title } : {}),
199
+ ...(typeof output?.summary === "string" ? { outputSummary: sanitizeDiagnosticText(output.summary, 240) } : {}),
200
+ ...(typeof output?.text === "string" ? { outputText: sanitizeDiagnosticText(output.text, 240) } : {}),
201
+ };
202
+ }
203
+ function mapCiSnapshotCheck(row) {
204
+ if (typeof row.name !== "string" || !row.name.trim())
205
+ return undefined;
206
+ const output = row.output && typeof row.output === "object" ? row.output : undefined;
207
+ const status = deriveCheckStatus({
208
+ apiStatus: typeof row.status === "string" ? row.status : undefined,
209
+ apiConclusion: typeof row.conclusion === "string" ? row.conclusion : undefined,
210
+ });
211
+ return {
212
+ name: row.name.trim(),
213
+ status,
214
+ ...(typeof row.conclusion === "string" && row.conclusion.trim() ? { conclusion: row.conclusion.trim().toLowerCase() } : {}),
215
+ ...(typeof row.details_url === "string" && row.details_url.trim() ? { detailsUrl: row.details_url.trim() } : {}),
216
+ ...(firstNonEmpty(typeof output?.title === "string" ? output.title : undefined, typeof output?.summary === "string" ? sanitizeDiagnosticText(output.summary, 240) : undefined) ? { summary: firstNonEmpty(typeof output?.title === "string" ? output.title : undefined, typeof output?.summary === "string" ? sanitizeDiagnosticText(output.summary, 240) : undefined) } : {}),
217
+ };
218
+ }
219
+ function mapWorkflowJobSummary(row) {
220
+ const steps = Array.isArray(row.steps) ? row.steps.filter((entry) => Boolean(entry) && typeof entry === "object") : [];
221
+ const failedStep = steps.find((entry) => {
222
+ const conclusion = typeof entry.conclusion === "string" ? entry.conclusion.toLowerCase() : "";
223
+ return FAILED_CONCLUSIONS.has(conclusion);
224
+ });
225
+ const informativeStep = failedStep ?? steps.findLast((entry) => typeof entry.name === "string");
226
+ return {
227
+ ...(typeof row.name === "string" ? { name: row.name } : {}),
228
+ ...(typeof row.conclusion === "string" ? { conclusion: row.conclusion } : {}),
229
+ ...(typeof informativeStep?.name === "string" ? { stepName: informativeStep.name } : {}),
230
+ };
231
+ }
232
+ function parseWorkflowRunId(url) {
233
+ if (!url)
234
+ return undefined;
235
+ const match = url.match(/\/actions\/runs\/(\d+)/);
236
+ return match ? Number(match[1]) : undefined;
237
+ }
238
+ function buildCiSnapshotFromChecks(checks, event, gateCheckNames) {
239
+ const gateCheck = findGateCheck(checks, gateCheckNames, event.checkName);
240
+ const gateCheckName = gateCheck?.name ?? pickGateCheckName(gateCheckNames, event.checkName) ?? event.checkName;
241
+ const gateCheckStatus = gateCheck?.status ?? deriveCheckStatus({
242
+ eventStatus: event.checkStatus,
243
+ eventConclusion: event.triggerEvent === "check_passed" ? "success" : "failure",
244
+ });
245
+ const failedChecks = checks.filter((entry) => entry.status === "failure");
246
+ return {
247
+ headSha: event.headSha,
248
+ ...(gateCheckName ? { gateCheckName } : {}),
249
+ gateCheckStatus,
250
+ failedChecks,
251
+ checks,
252
+ ...(gateCheckStatus !== "pending" ? { settledAt: new Date().toISOString() } : {}),
253
+ capturedAt: new Date().toISOString(),
254
+ };
255
+ }
256
+ function findGateCheck(checks, gateCheckNames, fallbackCheckName) {
257
+ const exactNames = gateCheckNames.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
258
+ if (exactNames.length > 0) {
259
+ const exact = checks.find((entry) => exactNames.includes(entry.name.trim().toLowerCase()));
260
+ if (exact)
261
+ return exact;
262
+ }
263
+ if (!fallbackCheckName)
264
+ return undefined;
265
+ const fallback = fallbackCheckName.trim().toLowerCase();
266
+ return checks.find((entry) => entry.name.trim().toLowerCase() === fallback);
267
+ }
268
+ function pickGateCheckName(gateCheckNames, fallbackCheckName) {
269
+ return gateCheckNames.find((entry) => entry.trim().length > 0)?.trim()
270
+ ?? fallbackCheckName?.trim();
271
+ }
272
+ function deriveCheckStatus(params) {
273
+ const status = params.apiStatus?.trim().toLowerCase();
274
+ if (status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending") {
275
+ return "pending";
276
+ }
277
+ const conclusion = params.apiConclusion?.trim().toLowerCase()
278
+ ?? params.eventConclusion?.trim().toLowerCase()
279
+ ?? params.eventStatus?.trim().toLowerCase();
280
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
281
+ return "success";
282
+ }
283
+ if (conclusion && FAILED_CONCLUSIONS.has(conclusion)) {
284
+ return "failure";
285
+ }
286
+ return status === "completed" ? "failure" : "pending";
287
+ }
288
+ function buildFailureSignature(parts) {
289
+ return [
290
+ parts.source,
291
+ parts.headSha ?? "unknown-sha",
292
+ parts.jobName ?? parts.checkName ?? "unknown-check",
293
+ parts.stepName ?? "unknown-step",
294
+ ].join("::");
295
+ }
296
+ function firstNonEmpty(...values) {
297
+ return values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
298
+ }