patchrelay 0.31.0 → 0.32.1
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/commands/watch.js +9 -3
- package/dist/cli/watch/App.js +9 -4
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +89 -1
- package/dist/cli/watch/IssueListView.js +8 -3
- package/dist/cli/watch/IssueRow.js +58 -5
- package/dist/cli/watch/StatusBar.js +2 -2
- package/dist/cli/watch/use-detail-stream.js +4 -2
- package/dist/cli/watch/use-watch-stream.js +3 -1
- package/dist/db/linear-installation-store.js +67 -3
- package/dist/db/migrations.js +10 -0
- package/dist/db.js +68 -2
- package/dist/github-failure-context.js +93 -0
- package/dist/github-webhook-handler.js +187 -19
- package/dist/linear-client.js +3 -3
- package/dist/run-orchestrator.js +39 -15
- package/dist/service.js +5 -1
- package/dist/webhook-handler.js +15 -1
- package/package.json +3 -3
|
@@ -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
|
|
@@ -144,6 +146,9 @@ export class GitHubWebhookHandler {
|
|
|
144
146
|
linearIssueId: issue.linearIssueId,
|
|
145
147
|
factoryState: newState,
|
|
146
148
|
});
|
|
149
|
+
if (newState === "awaiting_queue") {
|
|
150
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
|
|
151
|
+
}
|
|
147
152
|
this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
148
153
|
const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
149
154
|
void this.emitLinearActivity(transitionedIssue, newState, event);
|
|
@@ -178,6 +183,11 @@ export class GitHubWebhookHandler {
|
|
|
178
183
|
lastGitHubFailureCheckUrl: null,
|
|
179
184
|
lastGitHubFailureContextJson: null,
|
|
180
185
|
lastGitHubFailureAt: null,
|
|
186
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
187
|
+
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
188
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
189
|
+
lastGitHubCiSnapshotJson: null,
|
|
190
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
181
191
|
lastQueueIncidentJson: null,
|
|
182
192
|
lastAttemptedFailureHeadSha: null,
|
|
183
193
|
lastAttemptedFailureSignature: null,
|
|
@@ -197,14 +207,85 @@ export class GitHubWebhookHandler {
|
|
|
197
207
|
// Queue eviction check runs bypass the metadata-only filter because
|
|
198
208
|
// they're individual check_run events (not check_suite), but they
|
|
199
209
|
// must drive state transitions.
|
|
200
|
-
|
|
201
|
-
if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
|
|
210
|
+
if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
|
|
202
211
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
203
212
|
}
|
|
204
213
|
else if (!isMetadataOnlyCheckEvent(event)) {
|
|
205
214
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
206
215
|
}
|
|
207
216
|
}
|
|
217
|
+
async updateCiSnapshot(issue, event, project) {
|
|
218
|
+
if (event.triggerEvent === "pr_merged") {
|
|
219
|
+
this.db.upsertIssue({
|
|
220
|
+
projectId: issue.projectId,
|
|
221
|
+
linearIssueId: issue.linearIssueId,
|
|
222
|
+
lastGitHubCiSnapshotHeadSha: null,
|
|
223
|
+
lastGitHubCiSnapshotGateCheckName: null,
|
|
224
|
+
lastGitHubCiSnapshotGateCheckStatus: null,
|
|
225
|
+
lastGitHubCiSnapshotJson: null,
|
|
226
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
231
|
+
this.db.upsertIssue({
|
|
232
|
+
projectId: issue.projectId,
|
|
233
|
+
linearIssueId: issue.linearIssueId,
|
|
234
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
235
|
+
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
236
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
237
|
+
lastGitHubCiSnapshotJson: null,
|
|
238
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (issue.prState !== "open")
|
|
243
|
+
return;
|
|
244
|
+
if (event.eventSource !== "check_run")
|
|
245
|
+
return;
|
|
246
|
+
if (this.isQueueEvictionFailure(issue, event, project))
|
|
247
|
+
return;
|
|
248
|
+
if (!this.isGateCheckEvent(event, project))
|
|
249
|
+
return;
|
|
250
|
+
if (this.isStaleGateEvent(issue, event))
|
|
251
|
+
return;
|
|
252
|
+
const snapshot = await this.ciSnapshotResolver.resolve({
|
|
253
|
+
repoFullName: project?.github?.repoFullName ?? event.repoFullName,
|
|
254
|
+
event,
|
|
255
|
+
gateCheckNames: this.getGateCheckNames(project),
|
|
256
|
+
});
|
|
257
|
+
if (!snapshot) {
|
|
258
|
+
this.db.upsertIssue({
|
|
259
|
+
projectId: issue.projectId,
|
|
260
|
+
linearIssueId: issue.linearIssueId,
|
|
261
|
+
lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
|
|
262
|
+
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
263
|
+
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
264
|
+
lastGitHubCiSnapshotJson: null,
|
|
265
|
+
lastGitHubCiSnapshotSettledAt: null,
|
|
266
|
+
});
|
|
267
|
+
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");
|
|
268
|
+
this.feed?.publish({
|
|
269
|
+
level: "warn",
|
|
270
|
+
kind: "github",
|
|
271
|
+
issueKey: issue.issueKey,
|
|
272
|
+
projectId: issue.projectId,
|
|
273
|
+
stage: issue.factoryState,
|
|
274
|
+
status: "ci_snapshot_unavailable",
|
|
275
|
+
summary: `Could not resolve settled ${this.getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.db.upsertIssue({
|
|
280
|
+
projectId: issue.projectId,
|
|
281
|
+
linearIssueId: issue.linearIssueId,
|
|
282
|
+
lastGitHubCiSnapshotHeadSha: snapshot.headSha,
|
|
283
|
+
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
|
|
284
|
+
lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
|
|
285
|
+
lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
|
|
286
|
+
lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
208
289
|
async maybeEnqueueReactiveRun(issue, event, project) {
|
|
209
290
|
// Don't trigger if there's already an active run
|
|
210
291
|
if (issue.activeRunId !== undefined)
|
|
@@ -216,10 +297,7 @@ export class GitHubWebhookHandler {
|
|
|
216
297
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
217
298
|
// External merge queue eviction: react only to the configured check
|
|
218
299
|
// 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) {
|
|
300
|
+
if (this.isQueueEvictionFailure(issue, event, project)) {
|
|
223
301
|
const queueRepairContext = buildQueueRepairContextFromEvent(event);
|
|
224
302
|
const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
|
|
225
303
|
if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
|
|
@@ -243,6 +321,7 @@ export class GitHubWebhookHandler {
|
|
|
243
321
|
lastQueueSignalAt: new Date().toISOString(),
|
|
244
322
|
lastQueueIncidentJson: JSON.stringify(queueRepairContext),
|
|
245
323
|
});
|
|
324
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
246
325
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
247
326
|
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
248
327
|
this.feed?.publish({
|
|
@@ -257,10 +336,23 @@ export class GitHubWebhookHandler {
|
|
|
257
336
|
});
|
|
258
337
|
}
|
|
259
338
|
else {
|
|
339
|
+
if (!this.isSettledBranchFailure(issue, event, project)) {
|
|
340
|
+
this.feed?.publish({
|
|
341
|
+
level: "info",
|
|
342
|
+
kind: "github",
|
|
343
|
+
issueKey: issue.issueKey,
|
|
344
|
+
projectId: issue.projectId,
|
|
345
|
+
stage: issue.factoryState,
|
|
346
|
+
status: "ci_waiting_for_settlement",
|
|
347
|
+
summary: `Waiting for settled ${this.getPrimaryGateCheckName(project)} result before starting CI repair`,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
260
351
|
const failureContext = await this.resolveBranchFailureContext(issue, event, project);
|
|
261
352
|
if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
|
|
262
353
|
return;
|
|
263
354
|
}
|
|
355
|
+
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
264
356
|
this.db.upsertIssue({
|
|
265
357
|
projectId: issue.projectId,
|
|
266
358
|
linearIssueId: issue.linearIssueId,
|
|
@@ -268,6 +360,7 @@ export class GitHubWebhookHandler {
|
|
|
268
360
|
pendingRunContextJson: JSON.stringify({
|
|
269
361
|
...failureContext,
|
|
270
362
|
checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
363
|
+
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
271
364
|
}),
|
|
272
365
|
lastGitHubFailureSource: "branch_ci",
|
|
273
366
|
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
@@ -278,6 +371,7 @@ export class GitHubWebhookHandler {
|
|
|
278
371
|
lastGitHubFailureAt: new Date().toISOString(),
|
|
279
372
|
lastQueueIncidentJson: null,
|
|
280
373
|
});
|
|
374
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
281
375
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
282
376
|
this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
283
377
|
this.feed?.publish({
|
|
@@ -302,17 +396,20 @@ export class GitHubWebhookHandler {
|
|
|
302
396
|
reviewerName: event.reviewerName,
|
|
303
397
|
}),
|
|
304
398
|
});
|
|
399
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
305
400
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
306
401
|
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
307
402
|
}
|
|
308
403
|
}
|
|
309
404
|
async updateFailureProvenance(issue, event, project) {
|
|
310
|
-
const
|
|
311
|
-
const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
|
|
405
|
+
const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
312
406
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
313
|
-
const source =
|
|
407
|
+
const source = isQueueEvictionCheck
|
|
314
408
|
? "queue_eviction"
|
|
315
409
|
: "branch_ci";
|
|
410
|
+
if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
316
413
|
const failureContext = source === "queue_eviction"
|
|
317
414
|
? this.buildQueueFailureContext(issue, event)
|
|
318
415
|
: await this.resolveBranchFailureContext(issue, event, project);
|
|
@@ -337,9 +434,12 @@ export class GitHubWebhookHandler {
|
|
|
337
434
|
});
|
|
338
435
|
return;
|
|
339
436
|
}
|
|
340
|
-
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
|
|
437
|
+
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
|
|
341
438
|
|| event.triggerEvent === "pr_synchronize"
|
|
342
439
|
|| event.triggerEvent === "pr_merged") {
|
|
440
|
+
if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
343
443
|
this.db.upsertIssue({
|
|
344
444
|
projectId: issue.projectId,
|
|
345
445
|
linearIssueId: issue.linearIssueId,
|
|
@@ -358,10 +458,19 @@ export class GitHubWebhookHandler {
|
|
|
358
458
|
}
|
|
359
459
|
async resolveBranchFailureContext(issue, event, project) {
|
|
360
460
|
const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
|
|
461
|
+
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
462
|
+
const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
|
|
361
463
|
const context = await this.failureContextResolver.resolve({
|
|
362
464
|
source: "branch_ci",
|
|
363
465
|
repoFullName,
|
|
364
|
-
event
|
|
466
|
+
event: primaryFailedCheck
|
|
467
|
+
? {
|
|
468
|
+
...event,
|
|
469
|
+
checkName: primaryFailedCheck.name,
|
|
470
|
+
checkUrl: primaryFailedCheck.detailsUrl ?? event.checkUrl,
|
|
471
|
+
checkDetailsUrl: primaryFailedCheck.detailsUrl ?? event.checkDetailsUrl,
|
|
472
|
+
}
|
|
473
|
+
: event,
|
|
365
474
|
});
|
|
366
475
|
return {
|
|
367
476
|
...(context ? context : {}),
|
|
@@ -433,6 +542,65 @@ export class GitHubWebhookHandler {
|
|
|
433
542
|
}
|
|
434
543
|
return false;
|
|
435
544
|
}
|
|
545
|
+
getGateCheckNames(project) {
|
|
546
|
+
const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
547
|
+
return configured.length > 0 ? configured : ["Tests"];
|
|
548
|
+
}
|
|
549
|
+
getPrimaryGateCheckName(project) {
|
|
550
|
+
return this.getGateCheckNames(project)[0] ?? "Tests";
|
|
551
|
+
}
|
|
552
|
+
isGateCheckEvent(event, project) {
|
|
553
|
+
if (event.eventSource !== "check_run" || !event.checkName)
|
|
554
|
+
return false;
|
|
555
|
+
const normalized = event.checkName.trim().toLowerCase();
|
|
556
|
+
return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
|
|
557
|
+
}
|
|
558
|
+
isStaleGateEvent(issue, event) {
|
|
559
|
+
return Boolean(issue.lastGitHubCiSnapshotHeadSha
|
|
560
|
+
&& event.headSha
|
|
561
|
+
&& issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
|
|
562
|
+
}
|
|
563
|
+
isQueueEvictionFailure(issue, event, project) {
|
|
564
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
565
|
+
return issue.factoryState === "awaiting_queue"
|
|
566
|
+
&& event.eventSource === "check_run"
|
|
567
|
+
&& event.checkName === protocol.evictionCheckName;
|
|
568
|
+
}
|
|
569
|
+
isSettledBranchFailure(issue, event, project) {
|
|
570
|
+
if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
|
|
571
|
+
return false;
|
|
572
|
+
if (!this.isGateCheckEvent(event, project))
|
|
573
|
+
return false;
|
|
574
|
+
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
575
|
+
return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
|
|
576
|
+
}
|
|
577
|
+
canClearFailureProvenance(issue, event, project) {
|
|
578
|
+
if (event.triggerEvent !== "check_passed")
|
|
579
|
+
return true;
|
|
580
|
+
if (this.isQueueEvictionFailure(issue, event, project)) {
|
|
581
|
+
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
582
|
+
}
|
|
583
|
+
if (!this.isGateCheckEvent(event, project)) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
if (this.isStaleGateEvent(issue, event)) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
590
|
+
}
|
|
591
|
+
getRelevantCiSnapshot(issue, event) {
|
|
592
|
+
const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
|
|
593
|
+
if (!snapshot)
|
|
594
|
+
return undefined;
|
|
595
|
+
if (snapshot.headSha !== event.headSha)
|
|
596
|
+
return undefined;
|
|
597
|
+
return snapshot;
|
|
598
|
+
}
|
|
599
|
+
pickPrimaryFailedCheck(snapshot) {
|
|
600
|
+
const gateName = snapshot.gateCheckName?.trim().toLowerCase();
|
|
601
|
+
return snapshot.failedChecks.find((entry) => entry.name.trim().toLowerCase() !== gateName)
|
|
602
|
+
?? snapshot.failedChecks[0];
|
|
603
|
+
}
|
|
436
604
|
async emitLinearActivity(issue, newState, event) {
|
|
437
605
|
if (!issue.agentSessionId)
|
|
438
606
|
return;
|
package/dist/linear-client.js
CHANGED
|
@@ -370,9 +370,9 @@ export class DatabaseBackedLinearClientProvider {
|
|
|
370
370
|
this.logger = logger;
|
|
371
371
|
}
|
|
372
372
|
async forProject(projectId) {
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
return await this.forInstallationId(
|
|
373
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
|
|
374
|
+
if (installation) {
|
|
375
|
+
return await this.forInstallationId(installation.id);
|
|
376
376
|
}
|
|
377
377
|
return undefined;
|
|
378
378
|
}
|
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. Start from the specific failing check/job/step below on the latest remote PR branch tip, fix that concrete failure first, 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
|
+
? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\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
|
-
: "", "", "
|
|
68
|
+
: "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "Read the latest logs for the named failing check, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then 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;
|
|
@@ -182,6 +188,7 @@ export class RunOrchestrator {
|
|
|
182
188
|
}
|
|
183
189
|
: {}),
|
|
184
190
|
});
|
|
191
|
+
this.db.setBranchOwner(item.projectId, item.issueId, "patchrelay");
|
|
185
192
|
return created;
|
|
186
193
|
});
|
|
187
194
|
if (!run)
|
|
@@ -287,8 +294,9 @@ export class RunOrchestrator {
|
|
|
287
294
|
* Risks mitigated:
|
|
288
295
|
* - Dirty worktree from interrupted run → stash before, pop after
|
|
289
296
|
* - Conflicts → abort rebase, throw so the run fails with a clear reason
|
|
290
|
-
* - Already up-to-date → no-op
|
|
291
|
-
* -
|
|
297
|
+
* - Already up-to-date → no-op
|
|
298
|
+
* - Keep publishing explicit: the orchestrator updates the local worktree
|
|
299
|
+
* only; the agent/run owns any later branch push.
|
|
292
300
|
*/
|
|
293
301
|
async freshenWorktree(worktreePath, project, issue) {
|
|
294
302
|
const gitBin = this.config.runner.gitBin;
|
|
@@ -325,14 +333,7 @@ export class RunOrchestrator {
|
|
|
325
333
|
this.logger.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
|
|
326
334
|
return;
|
|
327
335
|
}
|
|
328
|
-
|
|
329
|
-
const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
|
|
330
|
-
if (pushResult.exitCode !== 0) {
|
|
331
|
-
this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Pre-run rebase push failed, proceeding anyway");
|
|
332
|
-
}
|
|
333
|
-
else {
|
|
334
|
-
this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased and pushed onto latest base");
|
|
335
|
-
}
|
|
336
|
+
this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased locally onto latest base");
|
|
336
337
|
// Restore stashed changes
|
|
337
338
|
if (didStash)
|
|
338
339
|
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
@@ -464,6 +465,9 @@ export class RunOrchestrator {
|
|
|
464
465
|
}
|
|
465
466
|
: {}),
|
|
466
467
|
});
|
|
468
|
+
if (postRunState === "awaiting_queue") {
|
|
469
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
|
|
470
|
+
}
|
|
467
471
|
});
|
|
468
472
|
// If we advanced to awaiting_queue, enqueue for merge prep
|
|
469
473
|
if (postRunState === "awaiting_queue") {
|
|
@@ -564,7 +568,7 @@ export class RunOrchestrator {
|
|
|
564
568
|
}
|
|
565
569
|
// Review approved + checks not failed — advance to awaiting_queue
|
|
566
570
|
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
567
|
-
if (issue.factoryState !== "awaiting_queue") {
|
|
571
|
+
if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
|
|
568
572
|
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
569
573
|
}
|
|
570
574
|
continue;
|
|
@@ -682,6 +686,10 @@ export class RunOrchestrator {
|
|
|
682
686
|
}
|
|
683
687
|
: {}),
|
|
684
688
|
});
|
|
689
|
+
const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
|
|
690
|
+
if (branchOwner) {
|
|
691
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
692
|
+
}
|
|
685
693
|
this.feed?.publish({
|
|
686
694
|
level: "info",
|
|
687
695
|
kind: "stage",
|
|
@@ -691,7 +699,7 @@ export class RunOrchestrator {
|
|
|
691
699
|
status: "reconciled",
|
|
692
700
|
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
693
701
|
});
|
|
694
|
-
if (newState === "awaiting_queue") {
|
|
702
|
+
if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
|
|
695
703
|
this.requestMergeQueueAdmission(issue, issue.projectId);
|
|
696
704
|
}
|
|
697
705
|
if (options?.pendingRunType) {
|
|
@@ -914,6 +922,9 @@ export class RunOrchestrator {
|
|
|
914
922
|
}
|
|
915
923
|
: {}),
|
|
916
924
|
});
|
|
925
|
+
if (postRunState === "awaiting_queue") {
|
|
926
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
|
|
927
|
+
}
|
|
917
928
|
});
|
|
918
929
|
if (postRunState) {
|
|
919
930
|
this.feed?.publish({
|
|
@@ -981,8 +992,21 @@ export class RunOrchestrator {
|
|
|
981
992
|
activeRunId: null,
|
|
982
993
|
factoryState: nextState,
|
|
983
994
|
});
|
|
995
|
+
const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
|
|
996
|
+
if (branchOwner) {
|
|
997
|
+
this.db.setBranchOwner(run.projectId, run.linearIssueId, branchOwner);
|
|
998
|
+
}
|
|
984
999
|
});
|
|
985
1000
|
}
|
|
1001
|
+
resolveBranchOwnerForStateTransition(newState, pendingRunType) {
|
|
1002
|
+
if (pendingRunType)
|
|
1003
|
+
return "patchrelay";
|
|
1004
|
+
if (newState === "awaiting_queue")
|
|
1005
|
+
return "merge_steward";
|
|
1006
|
+
if (newState === "repairing_ci" || newState === "repairing_queue")
|
|
1007
|
+
return "patchrelay";
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
986
1010
|
async verifyReactiveRunAdvancedBranch(run, issue) {
|
|
987
1011
|
if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
|
|
988
1012
|
return undefined;
|
package/dist/service.js
CHANGED
|
@@ -85,6 +85,10 @@ export class PatchRelayService {
|
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
async start() {
|
|
88
|
+
const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
|
|
89
|
+
for (const repair of repairedInstallations) {
|
|
90
|
+
this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
|
|
91
|
+
}
|
|
88
92
|
// Verify Linear connectivity for all configured projects before starting.
|
|
89
93
|
// Auth errors do not prevent startup (the OAuth callback must be reachable
|
|
90
94
|
// for `patchrelay linear connect`), but the service reports NOT READY until at
|
|
@@ -436,7 +440,7 @@ export class PatchRelayService {
|
|
|
436
440
|
// Infer run type from current state instead of always resetting to implementation
|
|
437
441
|
let runType = "implementation";
|
|
438
442
|
let factoryState = "delegated";
|
|
439
|
-
if (issue.prNumber && issue.prCheckStatus === "failed") {
|
|
443
|
+
if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
|
|
440
444
|
runType = "ci_repair";
|
|
441
445
|
factoryState = "repairing_ci";
|
|
442
446
|
}
|