patchrelay 0.31.0 → 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/db/migrations.js +5 -0
- package/dist/db.js +57 -2
- package/dist/github-failure-context.js +93 -0
- package/dist/github-webhook-handler.js +181 -19
- package/dist/run-orchestrator.js +9 -3
- package/dist/service.js +1 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/db/migrations.js
CHANGED
|
@@ -204,6 +204,11 @@ export function runPatchRelayMigrations(connection) {
|
|
|
204
204
|
addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
|
|
205
205
|
addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
|
|
206
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");
|
|
207
212
|
addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
|
|
208
213
|
addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
|
|
209
214
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
|
package/dist/db.js
CHANGED
|
@@ -189,6 +189,26 @@ export class PatchRelayDatabase {
|
|
|
189
189
|
sets.push("last_github_failure_at = @lastGitHubFailureAt");
|
|
190
190
|
values.lastGitHubFailureAt = params.lastGitHubFailureAt;
|
|
191
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
|
+
}
|
|
192
212
|
if (params.lastQueueSignalAt !== undefined) {
|
|
193
213
|
sets.push("last_queue_signal_at = @lastQueueSignalAt");
|
|
194
214
|
values.lastQueueSignalAt = params.lastQueueSignalAt;
|
|
@@ -236,7 +256,9 @@ export class PatchRelayDatabase {
|
|
|
236
256
|
branch_name, worktree_path, thread_id, active_run_id,
|
|
237
257
|
agent_session_id,
|
|
238
258
|
pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
|
|
239
|
-
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,
|
|
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,
|
|
240
262
|
last_attempted_failure_head_sha, last_attempted_failure_signature,
|
|
241
263
|
updated_at
|
|
242
264
|
) VALUES (
|
|
@@ -246,7 +268,9 @@ export class PatchRelayDatabase {
|
|
|
246
268
|
@branchName, @worktreePath, @threadId, @activeRunId,
|
|
247
269
|
@agentSessionId,
|
|
248
270
|
@prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
|
|
249
|
-
@lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
|
|
271
|
+
@lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
|
|
272
|
+
@lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
|
|
273
|
+
@lastQueueSignalAt, @lastQueueIncidentJson,
|
|
250
274
|
@lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
|
|
251
275
|
@now
|
|
252
276
|
)
|
|
@@ -281,6 +305,11 @@ export class PatchRelayDatabase {
|
|
|
281
305
|
lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
|
|
282
306
|
lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
|
|
283
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,
|
|
284
313
|
lastQueueSignalAt: params.lastQueueSignalAt ?? null,
|
|
285
314
|
lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
|
|
286
315
|
lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
|
|
@@ -381,6 +410,17 @@ export class PatchRelayDatabase {
|
|
|
381
410
|
linearIssueId: String(row.linear_issue_id),
|
|
382
411
|
}));
|
|
383
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
|
+
}
|
|
384
424
|
countUnresolvedBlockers(projectId, linearIssueId) {
|
|
385
425
|
const row = this.connection.prepare(`
|
|
386
426
|
SELECT COUNT(*) AS count
|
|
@@ -632,6 +672,21 @@ function mapIssueRow(row) {
|
|
|
632
672
|
...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
|
|
633
673
|
? { lastGitHubFailureAt: String(row.last_github_failure_at) }
|
|
634
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
|
+
: {}),
|
|
635
690
|
...(row.last_queue_signal_at !== null && row.last_queue_signal_at !== undefined
|
|
636
691
|
? { lastQueueSignalAt: String(row.last_queue_signal_at) }
|
|
637
692
|
: {}),
|
|
@@ -75,6 +75,21 @@ export function createGitHubFailureContextResolver() {
|
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
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
|
+
}
|
|
78
93
|
export function parseGitHubFailureContext(value) {
|
|
79
94
|
if (!value)
|
|
80
95
|
return undefined;
|
|
@@ -123,6 +138,18 @@ async function resolveFailedCheckRun(repoFullName, event) {
|
|
|
123
138
|
?? checks.find((entry) => entry.name && event.checkName && entry.name.includes(event.checkName))
|
|
124
139
|
?? checks[0];
|
|
125
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
|
+
}
|
|
126
153
|
async function resolveWorkflowJob(repoFullName, workflowRunId, preferredName) {
|
|
127
154
|
const response = await execCommand("gh", [
|
|
128
155
|
"api",
|
|
@@ -173,6 +200,22 @@ function mapCheckRunSummary(row) {
|
|
|
173
200
|
...(typeof output?.text === "string" ? { outputText: sanitizeDiagnosticText(output.text, 240) } : {}),
|
|
174
201
|
};
|
|
175
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
|
+
}
|
|
176
219
|
function mapWorkflowJobSummary(row) {
|
|
177
220
|
const steps = Array.isArray(row.steps) ? row.steps.filter((entry) => Boolean(entry) && typeof entry === "object") : [];
|
|
178
221
|
const failedStep = steps.find((entry) => {
|
|
@@ -192,6 +235,56 @@ function parseWorkflowRunId(url) {
|
|
|
192
235
|
const match = url.match(/\/actions\/runs\/(\d+)/);
|
|
193
236
|
return match ? Number(match[1]) : undefined;
|
|
194
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
|
+
}
|
|
195
288
|
function buildFailureSignature(parts) {
|
|
196
289
|
return [
|
|
197
290
|
parts.source,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
|
|
2
|
-
import { createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
|
|
2
|
+
import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
|
|
3
3
|
import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
|
|
4
4
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
5
5
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
@@ -10,12 +10,11 @@ import { resolveSecret } from "./resolve-secret.js";
|
|
|
10
10
|
import { safeJsonParse } from "./utils.js";
|
|
11
11
|
/**
|
|
12
12
|
* GitHub sends both check_run and check_suite completion events.
|
|
13
|
-
* A single CI run generates
|
|
14
|
-
* but only
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 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.
|
|
19
18
|
*/
|
|
20
19
|
function isMetadataOnlyCheckEvent(event) {
|
|
21
20
|
return event.eventSource === "check_run"
|
|
@@ -30,7 +29,8 @@ export class GitHubWebhookHandler {
|
|
|
30
29
|
codex;
|
|
31
30
|
feed;
|
|
32
31
|
failureContextResolver;
|
|
33
|
-
|
|
32
|
+
ciSnapshotResolver;
|
|
33
|
+
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
|
|
34
34
|
this.config = config;
|
|
35
35
|
this.db = db;
|
|
36
36
|
this.linearProvider = linearProvider;
|
|
@@ -39,6 +39,7 @@ export class GitHubWebhookHandler {
|
|
|
39
39
|
this.codex = codex;
|
|
40
40
|
this.feed = feed;
|
|
41
41
|
this.failureContextResolver = failureContextResolver;
|
|
42
|
+
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
42
43
|
}
|
|
43
44
|
async acceptGitHubWebhook(params) {
|
|
44
45
|
// Deduplicate
|
|
@@ -128,6 +129,7 @@ export class GitHubWebhookHandler {
|
|
|
128
129
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
129
130
|
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
130
131
|
});
|
|
132
|
+
await this.updateCiSnapshot(issue, event, project);
|
|
131
133
|
await this.updateFailureProvenance(issue, event, project);
|
|
132
134
|
if (!isMetadataOnlyCheckEvent(event)) {
|
|
133
135
|
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
@@ -178,6 +180,11 @@ export class GitHubWebhookHandler {
|
|
|
178
180
|
lastGitHubFailureCheckUrl: null,
|
|
179
181
|
lastGitHubFailureContextJson: null,
|
|
180
182
|
lastGitHubFailureAt: null,
|
|
183
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
184
|
+
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
185
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
186
|
+
lastGitHubCiSnapshotJson: null,
|
|
187
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
181
188
|
lastQueueIncidentJson: null,
|
|
182
189
|
lastAttemptedFailureHeadSha: null,
|
|
183
190
|
lastAttemptedFailureSignature: null,
|
|
@@ -197,14 +204,85 @@ export class GitHubWebhookHandler {
|
|
|
197
204
|
// Queue eviction check runs bypass the metadata-only filter because
|
|
198
205
|
// they're individual check_run events (not check_suite), but they
|
|
199
206
|
// must drive state transitions.
|
|
200
|
-
|
|
201
|
-
if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
|
|
207
|
+
if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
|
|
202
208
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
203
209
|
}
|
|
204
210
|
else if (!isMetadataOnlyCheckEvent(event)) {
|
|
205
211
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
206
212
|
}
|
|
207
213
|
}
|
|
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
|
+
}
|
|
208
286
|
async maybeEnqueueReactiveRun(issue, event, project) {
|
|
209
287
|
// Don't trigger if there's already an active run
|
|
210
288
|
if (issue.activeRunId !== undefined)
|
|
@@ -216,10 +294,7 @@ export class GitHubWebhookHandler {
|
|
|
216
294
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
217
295
|
// External merge queue eviction: react only to the configured check
|
|
218
296
|
// name, not to any CI failure. Regular CI failures still get ci_repair.
|
|
219
|
-
|
|
220
|
-
const queueCheckName = protocol.evictionCheckName;
|
|
221
|
-
if (issue.factoryState === "awaiting_queue"
|
|
222
|
-
&& event.checkName === queueCheckName) {
|
|
297
|
+
if (this.isQueueEvictionFailure(issue, event, project)) {
|
|
223
298
|
const queueRepairContext = buildQueueRepairContextFromEvent(event);
|
|
224
299
|
const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
|
|
225
300
|
if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
|
|
@@ -257,10 +332,23 @@ export class GitHubWebhookHandler {
|
|
|
257
332
|
});
|
|
258
333
|
}
|
|
259
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
|
+
}
|
|
260
347
|
const failureContext = await this.resolveBranchFailureContext(issue, event, project);
|
|
261
348
|
if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
|
|
262
349
|
return;
|
|
263
350
|
}
|
|
351
|
+
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
264
352
|
this.db.upsertIssue({
|
|
265
353
|
projectId: issue.projectId,
|
|
266
354
|
linearIssueId: issue.linearIssueId,
|
|
@@ -268,6 +356,7 @@ export class GitHubWebhookHandler {
|
|
|
268
356
|
pendingRunContextJson: JSON.stringify({
|
|
269
357
|
...failureContext,
|
|
270
358
|
checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
359
|
+
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
271
360
|
}),
|
|
272
361
|
lastGitHubFailureSource: "branch_ci",
|
|
273
362
|
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
@@ -307,12 +396,14 @@ export class GitHubWebhookHandler {
|
|
|
307
396
|
}
|
|
308
397
|
}
|
|
309
398
|
async updateFailureProvenance(issue, event, project) {
|
|
310
|
-
const
|
|
311
|
-
const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
|
|
399
|
+
const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
312
400
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
313
|
-
const source =
|
|
401
|
+
const source = isQueueEvictionCheck
|
|
314
402
|
? "queue_eviction"
|
|
315
403
|
: "branch_ci";
|
|
404
|
+
if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
316
407
|
const failureContext = source === "queue_eviction"
|
|
317
408
|
? this.buildQueueFailureContext(issue, event)
|
|
318
409
|
: await this.resolveBranchFailureContext(issue, event, project);
|
|
@@ -337,9 +428,12 @@ export class GitHubWebhookHandler {
|
|
|
337
428
|
});
|
|
338
429
|
return;
|
|
339
430
|
}
|
|
340
|
-
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
|
|
431
|
+
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
|
|
341
432
|
|| event.triggerEvent === "pr_synchronize"
|
|
342
433
|
|| event.triggerEvent === "pr_merged") {
|
|
434
|
+
if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
343
437
|
this.db.upsertIssue({
|
|
344
438
|
projectId: issue.projectId,
|
|
345
439
|
linearIssueId: issue.linearIssueId,
|
|
@@ -358,10 +452,19 @@ export class GitHubWebhookHandler {
|
|
|
358
452
|
}
|
|
359
453
|
async resolveBranchFailureContext(issue, event, project) {
|
|
360
454
|
const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
|
|
455
|
+
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
456
|
+
const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
|
|
361
457
|
const context = await this.failureContextResolver.resolve({
|
|
362
458
|
source: "branch_ci",
|
|
363
459
|
repoFullName,
|
|
364
|
-
event
|
|
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,
|
|
365
468
|
});
|
|
366
469
|
return {
|
|
367
470
|
...(context ? context : {}),
|
|
@@ -433,6 +536,65 @@ export class GitHubWebhookHandler {
|
|
|
433
536
|
}
|
|
434
537
|
return false;
|
|
435
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];
|
|
597
|
+
}
|
|
436
598
|
async emitLinearActivity(issue, newState, event) {
|
|
437
599
|
if (!issue.agentSessionId)
|
|
438
600
|
return;
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -57,11 +57,17 @@ function buildRunPrompt(issue, runType, repoPath, context) {
|
|
|
57
57
|
}
|
|
58
58
|
// Add run-type-specific context for reactive runs
|
|
59
59
|
switch (runType) {
|
|
60
|
-
case "ci_repair":
|
|
61
|
-
|
|
60
|
+
case "ci_repair": {
|
|
61
|
+
const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
|
|
62
|
+
? context.ciSnapshot
|
|
63
|
+
: undefined;
|
|
64
|
+
lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Diagnose the whole snapshot, fix the root cause and directly related fallout, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
|
|
65
|
+
? `All failed checks in settled snapshot:\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
|
|
66
|
+
: "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
|
|
62
67
|
? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
|
|
63
|
-
: "", "", "Read the CI
|
|
68
|
+
: "", "", "Read the latest CI logs, consider the broader PR context, fix the likely root cause and any directly related fallout in one pass, run verification, commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
|
|
64
69
|
break;
|
|
70
|
+
}
|
|
65
71
|
case "review_fix":
|
|
66
72
|
lines.push("## Review Changes Requested", "", "A reviewer has requested changes on your PR. Address the feedback and push.", context?.reviewerName ? `Reviewer: ${String(context.reviewerName)}` : "", context?.reviewBody ? `\n## Review comment\n\n${String(context.reviewBody)}` : "", "", "Steps:", "1. Read the review feedback and PR comments (`gh pr view --comments`).", "2. Check the current diff (`git diff origin/main`) — a prior rebase may have already resolved some concerns (e.g., scope-bundling from stale commits).", "3. For each review point: if already resolved, note why. If not, fix it.", "4. Run verification, commit and push.", "5. If you believe all concerns are resolved, request a re-review: `gh pr edit <PR#> --add-reviewer <reviewer>`.", " Do NOT just post a comment saying \"resolved\" — the reviewer must re-review to dismiss the CHANGES_REQUESTED state.", "");
|
|
67
73
|
break;
|
package/dist/service.js
CHANGED
|
@@ -436,7 +436,7 @@ export class PatchRelayService {
|
|
|
436
436
|
// Infer run type from current state instead of always resetting to implementation
|
|
437
437
|
let runType = "implementation";
|
|
438
438
|
let factoryState = "delegated";
|
|
439
|
-
if (issue.prNumber && issue.prCheckStatus === "failed") {
|
|
439
|
+
if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
|
|
440
440
|
runType = "ci_repair";
|
|
441
441
|
factoryState = "repairing_ci";
|
|
442
442
|
}
|