patchrelay 0.38.0 → 0.38.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/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +41 -976
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +231 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/issue-overview-query.js +8 -57
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-orchestrator.js +21 -7
- package/package.json +1 -1
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
import { resolveFactoryStateFromGitHub } from "./factory-state.js";
|
|
2
|
-
import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
|
|
3
1
|
import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
|
|
4
|
-
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
5
|
-
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
6
|
-
import { buildGitHubStateActivity } from "./linear-session-reporting.js";
|
|
7
|
-
import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
|
|
8
|
-
import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
|
|
9
|
-
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
10
|
-
import { buildClosedPrCleanupFields, isIssueTerminal, resolveClosedPrFactoryState, resolveClosedPrDisposition, } from "./pr-state.js";
|
|
11
2
|
import { resolveSecret } from "./resolve-secret.js";
|
|
12
3
|
import { safeJsonParse } from "./utils.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* checks or the settled gate check.
|
|
20
|
-
*/
|
|
21
|
-
function isMetadataOnlyCheckEvent(event) {
|
|
22
|
-
return event.eventSource === "check_run"
|
|
23
|
-
&& (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
|
|
24
|
-
}
|
|
25
|
-
const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
|
|
4
|
+
import { GitHubPrCommentHandler } from "./github-pr-comment-handler.js";
|
|
5
|
+
import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, } from "./github-failure-context.js";
|
|
6
|
+
import { resolveGitHubWebhookIssue } from "./github-webhook-issue-resolution.js";
|
|
7
|
+
import { projectGitHubWebhookState } from "./github-webhook-state-projector.js";
|
|
8
|
+
import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js";
|
|
9
|
+
import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
|
|
26
10
|
export class GitHubWebhookHandler {
|
|
27
11
|
config;
|
|
28
12
|
db;
|
|
@@ -34,6 +18,7 @@ export class GitHubWebhookHandler {
|
|
|
34
18
|
failureContextResolver;
|
|
35
19
|
ciSnapshotResolver;
|
|
36
20
|
fetchImpl;
|
|
21
|
+
prCommentHandler;
|
|
37
22
|
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
|
|
38
23
|
this.config = config;
|
|
39
24
|
this.db = db;
|
|
@@ -45,20 +30,17 @@ export class GitHubWebhookHandler {
|
|
|
45
30
|
this.failureContextResolver = failureContextResolver;
|
|
46
31
|
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
47
32
|
this.fetchImpl = fetchImpl;
|
|
33
|
+
this.prCommentHandler = new GitHubPrCommentHandler(db, enqueueIssue, logger, codex, feed);
|
|
48
34
|
}
|
|
49
35
|
async acceptGitHubWebhook(params) {
|
|
50
|
-
// Deduplicate
|
|
51
36
|
if (this.db.webhookEvents.isWebhookDuplicate(params.deliveryId)) {
|
|
52
37
|
return { status: 200, body: { ok: true, duplicate: true } };
|
|
53
38
|
}
|
|
54
|
-
// Store the event
|
|
55
39
|
const stored = this.db.webhookEvents.insertWebhookEvent(params.deliveryId, new Date().toISOString());
|
|
56
|
-
// Parse payload
|
|
57
40
|
const payload = safeJsonParse(params.rawBody.toString("utf8"));
|
|
58
41
|
if (!payload) {
|
|
59
42
|
return { status: 400, body: { ok: false, reason: "invalid_json" } };
|
|
60
43
|
}
|
|
61
|
-
// Find matching project by repo
|
|
62
44
|
const repoFullName = typeof payload === "object" && payload !== null && "repository" in payload
|
|
63
45
|
? payload.repository
|
|
64
46
|
: undefined;
|
|
@@ -68,12 +50,9 @@ export class GitHubWebhookHandler {
|
|
|
68
50
|
const project = repoName
|
|
69
51
|
? this.config.projects.find((p) => p.github?.repoFullName === repoName)
|
|
70
52
|
: undefined;
|
|
71
|
-
// Verify signature using global GitHub App webhook secret
|
|
72
53
|
const webhookSecret = resolveSecret("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
|
|
73
|
-
if (webhookSecret) {
|
|
74
|
-
|
|
75
|
-
return { status: 401, body: { ok: false, reason: "invalid_signature" } };
|
|
76
|
-
}
|
|
54
|
+
if (webhookSecret && !verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
|
|
55
|
+
return { status: 401, body: { ok: false, reason: "invalid_signature" } };
|
|
77
56
|
}
|
|
78
57
|
if (stored.duplicate) {
|
|
79
58
|
return { status: 200, body: { ok: true, duplicate: true } };
|
|
@@ -93,20 +72,11 @@ export class GitHubWebhookHandler {
|
|
|
93
72
|
const payload = safeJsonParse(params.rawBody);
|
|
94
73
|
if (!payload || typeof payload !== "object")
|
|
95
74
|
return;
|
|
96
|
-
// Push to a base branch advances the merge queue for affected projects.
|
|
97
|
-
// This catches external merges (human PRs, direct pushes) that PatchRelay
|
|
98
|
-
// does not track as issues but that make queued branches stale.
|
|
99
75
|
if (params.eventType === "push") {
|
|
100
|
-
const pushPayload = payload;
|
|
101
|
-
const ref = pushPayload.ref;
|
|
102
|
-
const repoFullName = pushPayload.repository?.full_name;
|
|
103
|
-
if (ref && repoFullName) {
|
|
104
|
-
// Push to base branch — external merge queue handles advancement.
|
|
105
|
-
}
|
|
106
76
|
return;
|
|
107
77
|
}
|
|
108
78
|
if (params.eventType === "issue_comment") {
|
|
109
|
-
await this.
|
|
79
|
+
await this.prCommentHandler.handleCreatedComment(payload);
|
|
110
80
|
return;
|
|
111
81
|
}
|
|
112
82
|
const event = normalizeGitHubWebhook({
|
|
@@ -122,949 +92,44 @@ export class GitHubWebhookHandler {
|
|
|
122
92
|
this.logger.debug({ repoFullName: event.repoFullName, triggerEvent: event.triggerEvent }, "GitHub webhook: no configured project for repository");
|
|
123
93
|
return;
|
|
124
94
|
}
|
|
125
|
-
const resolved = this.
|
|
95
|
+
const resolved = resolveGitHubWebhookIssue(this.db, project, event);
|
|
126
96
|
const issue = resolved?.issue;
|
|
127
97
|
if (!issue) {
|
|
128
98
|
this.logger.debug({ repoFullName: event.repoFullName, branchName: event.branchName, prNumber: event.prNumber, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching tracked issue");
|
|
129
99
|
return;
|
|
130
100
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
: event.reviewState === "approved"
|
|
147
|
-
? { lastBlockingReviewHeadSha: null }
|
|
148
|
-
: {}),
|
|
149
|
-
...(event.triggerEvent === "pr_closed"
|
|
150
|
-
? buildClosedPrCleanupFields()
|
|
151
|
-
: {}),
|
|
152
|
-
});
|
|
153
|
-
await this.updateCiSnapshot(issue, event, project);
|
|
154
|
-
await this.updateFailureProvenance(issue, event, project);
|
|
155
|
-
const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
156
|
-
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
157
|
-
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
158
|
-
const afterMetadata = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
159
|
-
const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
|
|
160
|
-
// Only transition and notify when the state actually changes.
|
|
161
|
-
// Multiple check_suite events can arrive for the same outcome.
|
|
162
|
-
if (newState && newState !== afterMetadata.factoryState) {
|
|
163
|
-
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
164
|
-
projectId: issue.projectId,
|
|
165
|
-
linearIssueId: issue.linearIssueId,
|
|
166
|
-
factoryState: newState,
|
|
167
|
-
});
|
|
168
|
-
this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
169
|
-
const transitionedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
170
|
-
void this.emitLinearActivity(transitionedIssue, newState, event);
|
|
171
|
-
void this.syncLinearSession(transitionedIssue);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
// Re-read issue after all upserts so reactive run logic sees current state
|
|
175
|
-
const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
176
|
-
// Reset repair counters on new push — but only when no repair run is active,
|
|
177
|
-
// since Codex pushes during repair and resetting mid-run would bypass budgets.
|
|
178
|
-
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
179
|
-
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
180
|
-
projectId: issue.projectId,
|
|
181
|
-
linearIssueId: issue.linearIssueId,
|
|
182
|
-
ciRepairAttempts: 0,
|
|
183
|
-
queueRepairAttempts: 0,
|
|
184
|
-
lastGitHubFailureSource: null,
|
|
185
|
-
lastGitHubFailureHeadSha: null,
|
|
186
|
-
lastGitHubFailureSignature: null,
|
|
187
|
-
lastGitHubFailureCheckName: null,
|
|
188
|
-
lastGitHubFailureCheckUrl: null,
|
|
189
|
-
lastGitHubFailureContextJson: null,
|
|
190
|
-
lastGitHubFailureAt: null,
|
|
191
|
-
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
192
|
-
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
193
|
-
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
194
|
-
lastGitHubCiSnapshotJson: null,
|
|
195
|
-
lastGitHubCiSnapshotSettledAt: null,
|
|
196
|
-
lastQueueIncidentJson: null,
|
|
197
|
-
lastAttemptedFailureHeadSha: null,
|
|
198
|
-
lastAttemptedFailureSignature: null,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
202
|
-
this.feed?.publish({
|
|
203
|
-
level: event.triggerEvent.includes("failed") ? "warn" : "info",
|
|
204
|
-
kind: "github",
|
|
205
|
-
issueKey: freshIssue.issueKey,
|
|
206
|
-
projectId: freshIssue.projectId,
|
|
207
|
-
stage: freshIssue.factoryState,
|
|
208
|
-
status: event.triggerEvent,
|
|
209
|
-
summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
|
|
210
|
-
detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
|
|
211
|
-
});
|
|
212
|
-
// Queue eviction check runs bypass the metadata-only filter because
|
|
213
|
-
// they're individual check_run events (not check_suite), but they
|
|
214
|
-
// must drive state transitions.
|
|
215
|
-
if (queueEvictionCheck || this.isGateCheckEvent(event, project)) {
|
|
216
|
-
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
217
|
-
}
|
|
218
|
-
else if (!isMetadataOnlyCheckEvent(event)) {
|
|
219
|
-
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
220
|
-
}
|
|
221
|
-
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
222
|
-
await this.handleTerminalPrEvent(freshIssue, event);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
resolveIssueForEvent(project, event) {
|
|
226
|
-
if (event.prNumber !== undefined) {
|
|
227
|
-
const byPr = this.db.issues.getIssueByPrNumber(event.prNumber);
|
|
228
|
-
if (byPr && byPr.projectId === project.id) {
|
|
229
|
-
return { issue: byPr, linkedBy: "pr" };
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
const byBranch = this.db.issues.getIssueByBranch(event.branchName);
|
|
233
|
-
if (byBranch && byBranch.projectId === project.id) {
|
|
234
|
-
return { issue: byBranch, linkedBy: "branch" };
|
|
235
|
-
}
|
|
236
|
-
const byIssueKey = this.resolveIssueByExplicitIssueKey(project, event);
|
|
237
|
-
if (byIssueKey) {
|
|
238
|
-
return { issue: byIssueKey, linkedBy: "issue_key" };
|
|
239
|
-
}
|
|
240
|
-
return undefined;
|
|
241
|
-
}
|
|
242
|
-
resolveIssueByExplicitIssueKey(project, event) {
|
|
243
|
-
const candidates = new Set();
|
|
244
|
-
const sources = [event.prTitle, event.prBody, event.branchName];
|
|
245
|
-
for (const prefix of project.issueKeyPrefixes) {
|
|
246
|
-
const normalizedPrefix = prefix.trim();
|
|
247
|
-
if (!normalizedPrefix)
|
|
248
|
-
continue;
|
|
249
|
-
const pattern = new RegExp(`\\b${escapeRegExp(normalizedPrefix)}-\\d+\\b`, "gi");
|
|
250
|
-
for (const source of sources) {
|
|
251
|
-
if (!source)
|
|
252
|
-
continue;
|
|
253
|
-
for (const match of source.matchAll(pattern)) {
|
|
254
|
-
candidates.add(match[0].toUpperCase());
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
if (candidates.size !== 1) {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
const [issueKey] = [...candidates];
|
|
262
|
-
if (!issueKey) {
|
|
263
|
-
return undefined;
|
|
264
|
-
}
|
|
265
|
-
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
266
|
-
return issue?.projectId === project.id ? issue : undefined;
|
|
267
|
-
}
|
|
268
|
-
resolveFactoryStateForEvent(issue, event, project) {
|
|
269
|
-
if (event.triggerEvent === "pr_closed") {
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
272
|
-
const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
|
|
273
|
-
&& (event.prState === "open" || event.prNumber !== undefined)
|
|
274
|
-
? "pr_open"
|
|
275
|
-
: issue.factoryState;
|
|
276
|
-
if (event.triggerEvent === "check_failed"
|
|
277
|
-
&& this.isQueueEvictionFailure(issue, event, project)
|
|
278
|
-
&& issue.prState === "open"
|
|
279
|
-
&& issue.activeRunId === undefined
|
|
280
|
-
&& !isIssueTerminal(issue)) {
|
|
281
|
-
return "repairing_queue";
|
|
282
|
-
}
|
|
283
|
-
const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
|
|
284
|
-
prReviewState: issue.prReviewState,
|
|
285
|
-
activeRunId: issue.activeRunId,
|
|
286
|
-
});
|
|
287
|
-
if (resolved !== undefined) {
|
|
288
|
-
return resolved;
|
|
289
|
-
}
|
|
290
|
-
if (effectiveCurrentState !== issue.factoryState) {
|
|
291
|
-
return effectiveCurrentState;
|
|
292
|
-
}
|
|
293
|
-
return undefined;
|
|
294
|
-
}
|
|
295
|
-
async updateCiSnapshot(issue, event, project) {
|
|
296
|
-
if (event.triggerEvent === "pr_merged") {
|
|
297
|
-
this.db.issues.upsertIssue({
|
|
298
|
-
projectId: issue.projectId,
|
|
299
|
-
linearIssueId: issue.linearIssueId,
|
|
300
|
-
lastGitHubCiSnapshotHeadSha: null,
|
|
301
|
-
lastGitHubCiSnapshotGateCheckName: null,
|
|
302
|
-
lastGitHubCiSnapshotGateCheckStatus: null,
|
|
303
|
-
lastGitHubCiSnapshotJson: null,
|
|
304
|
-
lastGitHubCiSnapshotSettledAt: null,
|
|
305
|
-
});
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (event.triggerEvent === "pr_synchronize") {
|
|
309
|
-
this.db.issues.upsertIssue({
|
|
310
|
-
projectId: issue.projectId,
|
|
311
|
-
linearIssueId: issue.linearIssueId,
|
|
312
|
-
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
313
|
-
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
314
|
-
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
315
|
-
lastGitHubCiSnapshotJson: null,
|
|
316
|
-
lastGitHubCiSnapshotSettledAt: null,
|
|
317
|
-
});
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (issue.prState !== "open")
|
|
321
|
-
return;
|
|
322
|
-
if (event.eventSource !== "check_run")
|
|
323
|
-
return;
|
|
324
|
-
if (this.isQueueEvictionFailure(issue, event, project))
|
|
325
|
-
return;
|
|
326
|
-
if (!this.isGateCheckEvent(event, project))
|
|
327
|
-
return;
|
|
328
|
-
if (this.isStaleGateEvent(issue, event))
|
|
329
|
-
return;
|
|
330
|
-
const snapshot = await this.ciSnapshotResolver.resolve({
|
|
331
|
-
repoFullName: project?.github?.repoFullName ?? event.repoFullName,
|
|
101
|
+
const freshIssue = await projectGitHubWebhookState({
|
|
102
|
+
config: this.config,
|
|
103
|
+
db: this.db,
|
|
104
|
+
linearProvider: this.linearProvider,
|
|
105
|
+
logger: this.logger,
|
|
106
|
+
feed: this.feed,
|
|
107
|
+
failureContextResolver: this.failureContextResolver,
|
|
108
|
+
ciSnapshotResolver: this.ciSnapshotResolver,
|
|
109
|
+
}, issue, event, project, resolved.linkedBy);
|
|
110
|
+
await maybeEnqueueGitHubReactiveRun({
|
|
111
|
+
db: this.db,
|
|
112
|
+
logger: this.logger,
|
|
113
|
+
feed: this.feed,
|
|
114
|
+
enqueueIssue: this.enqueueIssue,
|
|
115
|
+
issue: freshIssue,
|
|
332
116
|
event,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.db.issues.upsertIssue({
|
|
337
|
-
projectId: issue.projectId,
|
|
338
|
-
linearIssueId: issue.linearIssueId,
|
|
339
|
-
lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
|
|
340
|
-
lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
|
|
341
|
-
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
342
|
-
lastGitHubCiSnapshotJson: null,
|
|
343
|
-
lastGitHubCiSnapshotSettledAt: null,
|
|
344
|
-
});
|
|
345
|
-
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");
|
|
346
|
-
this.feed?.publish({
|
|
347
|
-
level: "warn",
|
|
348
|
-
kind: "github",
|
|
349
|
-
issueKey: issue.issueKey,
|
|
350
|
-
projectId: issue.projectId,
|
|
351
|
-
stage: issue.factoryState,
|
|
352
|
-
status: "ci_snapshot_unavailable",
|
|
353
|
-
summary: `Could not resolve settled ${this.getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
|
|
354
|
-
});
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
this.db.issues.upsertIssue({
|
|
358
|
-
projectId: issue.projectId,
|
|
359
|
-
linearIssueId: issue.linearIssueId,
|
|
360
|
-
prCheckStatus: snapshot.gateCheckStatus,
|
|
361
|
-
lastGitHubCiSnapshotHeadSha: snapshot.headSha,
|
|
362
|
-
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
|
|
363
|
-
lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
|
|
364
|
-
lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
|
|
365
|
-
lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
async maybeEnqueueReactiveRun(issue, event, project) {
|
|
369
|
-
// Don't trigger if there's already an active run
|
|
370
|
-
if (issue.activeRunId !== undefined)
|
|
371
|
-
return;
|
|
372
|
-
// Don't trigger on terminal issues — late-arriving webhooks (e.g.
|
|
373
|
-
// merge_group_failed after pr_merged) must not resurrect done issues.
|
|
374
|
-
if (isIssueTerminal(issue))
|
|
375
|
-
return;
|
|
376
|
-
if (!issue.delegatedToPatchRelay) {
|
|
377
|
-
this.feed?.publish({
|
|
378
|
-
level: "info",
|
|
379
|
-
kind: "github",
|
|
380
|
-
issueKey: issue.issueKey,
|
|
381
|
-
projectId: issue.projectId,
|
|
382
|
-
stage: issue.factoryState,
|
|
383
|
-
status: "ignored_undelegated",
|
|
384
|
-
summary: `Ignored ${event.triggerEvent} because the issue is undelegated`,
|
|
385
|
-
});
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
389
|
-
// External merge queue eviction: react only to the configured check
|
|
390
|
-
// name, not to any CI failure. Regular CI failures still get ci_repair.
|
|
391
|
-
if (this.isQueueEvictionFailure(issue, event, project)) {
|
|
392
|
-
const queueRepairContext = buildQueueRepairContextFromEvent(event);
|
|
393
|
-
const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
|
|
394
|
-
if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
398
|
-
this.db.issues.upsertIssue({
|
|
399
|
-
projectId: issue.projectId,
|
|
400
|
-
linearIssueId: issue.linearIssueId,
|
|
401
|
-
lastGitHubFailureSource: "queue_eviction",
|
|
402
|
-
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
403
|
-
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
404
|
-
lastGitHubFailureCheckName: event.checkName ?? null,
|
|
405
|
-
lastGitHubFailureCheckUrl: event.checkUrl ?? null,
|
|
406
|
-
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
407
|
-
lastGitHubFailureAt: new Date().toISOString(),
|
|
408
|
-
lastQueueSignalAt: new Date().toISOString(),
|
|
409
|
-
lastQueueIncidentJson: JSON.stringify(queueRepairContext),
|
|
410
|
-
});
|
|
411
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
412
|
-
projectId: issue.projectId,
|
|
413
|
-
linearIssueId: issue.linearIssueId,
|
|
414
|
-
eventType: "merge_steward_incident",
|
|
415
|
-
eventJson: JSON.stringify({
|
|
416
|
-
...queueRepairContext,
|
|
417
|
-
...failureContext,
|
|
418
|
-
}),
|
|
419
|
-
dedupeKey: failureContext.failureSignature,
|
|
420
|
-
});
|
|
421
|
-
const queuedRunType = hadPendingWake
|
|
422
|
-
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
423
|
-
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
424
|
-
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
425
|
-
this.feed?.publish({
|
|
426
|
-
level: "warn",
|
|
427
|
-
kind: "github",
|
|
428
|
-
issueKey: issue.issueKey,
|
|
429
|
-
projectId: issue.projectId,
|
|
430
|
-
stage: "repairing_queue",
|
|
431
|
-
status: "queue_repair_queued",
|
|
432
|
-
summary: `${queuedRunType ?? "queue_repair"} queued after external failure from ${event.checkName}`,
|
|
433
|
-
detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
if (!this.isSettledBranchFailure(issue, event, project)) {
|
|
438
|
-
this.feed?.publish({
|
|
439
|
-
level: "info",
|
|
440
|
-
kind: "github",
|
|
441
|
-
issueKey: issue.issueKey,
|
|
442
|
-
projectId: issue.projectId,
|
|
443
|
-
stage: issue.factoryState,
|
|
444
|
-
status: "ci_waiting_for_settlement",
|
|
445
|
-
summary: `Waiting for settled ${this.getPrimaryGateCheckName(project)} result before starting CI repair`,
|
|
446
|
-
});
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const failureContext = await this.resolveBranchFailureContext(issue, event, project);
|
|
450
|
-
if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
454
|
-
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
455
|
-
this.db.issues.upsertIssue({
|
|
456
|
-
projectId: issue.projectId,
|
|
457
|
-
linearIssueId: issue.linearIssueId,
|
|
458
|
-
lastGitHubFailureSource: "branch_ci",
|
|
459
|
-
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
460
|
-
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
461
|
-
lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
|
|
462
|
-
lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
|
|
463
|
-
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
464
|
-
lastGitHubFailureAt: new Date().toISOString(),
|
|
465
|
-
lastQueueIncidentJson: null,
|
|
466
|
-
});
|
|
467
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
468
|
-
projectId: issue.projectId,
|
|
469
|
-
linearIssueId: issue.linearIssueId,
|
|
470
|
-
eventType: "settled_red_ci",
|
|
471
|
-
eventJson: JSON.stringify({
|
|
472
|
-
...failureContext,
|
|
473
|
-
checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
474
|
-
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
475
|
-
}),
|
|
476
|
-
dedupeKey: failureContext.failureSignature,
|
|
477
|
-
});
|
|
478
|
-
const queuedRunType = hadPendingWake
|
|
479
|
-
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
480
|
-
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
481
|
-
this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
482
|
-
this.feed?.publish({
|
|
483
|
-
level: "warn",
|
|
484
|
-
kind: "github",
|
|
485
|
-
issueKey: issue.issueKey,
|
|
486
|
-
projectId: issue.projectId,
|
|
487
|
-
stage: "repairing_ci",
|
|
488
|
-
status: "ci_repair_queued",
|
|
489
|
-
summary: `${queuedRunType ?? "ci_repair"} queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
|
|
490
|
-
detail: summarizeGitHubFailureContext(failureContext),
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
if (event.triggerEvent === "review_changes_requested") {
|
|
495
|
-
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
496
|
-
const reviewComments = await this.fetchReviewCommentsForEvent(event).catch((error) => {
|
|
497
|
-
this.logger.warn({
|
|
498
|
-
issueKey: issue.issueKey,
|
|
499
|
-
prNumber: event.prNumber,
|
|
500
|
-
reviewId: event.reviewId,
|
|
501
|
-
error: error instanceof Error ? error.message : String(error),
|
|
502
|
-
}, "Failed to fetch inline review comments for requested-changes event");
|
|
503
|
-
return undefined;
|
|
504
|
-
});
|
|
505
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
506
|
-
projectId: issue.projectId,
|
|
507
|
-
linearIssueId: issue.linearIssueId,
|
|
508
|
-
eventType: "review_changes_requested",
|
|
509
|
-
eventJson: JSON.stringify({
|
|
510
|
-
reviewBody: event.reviewBody,
|
|
511
|
-
reviewCommitId: event.reviewCommitId,
|
|
512
|
-
reviewId: event.reviewId,
|
|
513
|
-
reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
|
|
514
|
-
reviewerName: event.reviewerName,
|
|
515
|
-
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
516
|
-
}),
|
|
517
|
-
dedupeKey: [
|
|
518
|
-
"review_changes_requested",
|
|
519
|
-
issue.prHeadSha ?? event.headSha ?? "unknown-sha",
|
|
520
|
-
event.reviewerName ?? "unknown-reviewer",
|
|
521
|
-
].join("::"),
|
|
522
|
-
});
|
|
523
|
-
const queuedRunType = hadPendingWake
|
|
524
|
-
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
525
|
-
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
526
|
-
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
527
|
-
this.feed?.publish({
|
|
528
|
-
level: "warn",
|
|
529
|
-
kind: "github",
|
|
530
|
-
issueKey: issue.issueKey,
|
|
531
|
-
projectId: issue.projectId,
|
|
532
|
-
stage: "changes_requested",
|
|
533
|
-
status: "review_fix_queued",
|
|
534
|
-
summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
|
|
535
|
-
detail: reviewComments && reviewComments.length > 0
|
|
536
|
-
? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
|
|
537
|
-
: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
async handleTerminalPrEvent(issue, event) {
|
|
542
|
-
const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
|
|
543
|
-
this.db.issueSessions.appendIssueSessionEvent({
|
|
544
|
-
projectId: issue.projectId,
|
|
545
|
-
linearIssueId: issue.linearIssueId,
|
|
546
|
-
eventType,
|
|
547
|
-
dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
|
|
548
|
-
});
|
|
549
|
-
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
550
|
-
const run = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
|
|
551
|
-
if (run?.threadId && run.turnId) {
|
|
552
|
-
try {
|
|
553
|
-
await this.codex.steerTurn({
|
|
554
|
-
threadId: run.threadId,
|
|
555
|
-
turnId: run.turnId,
|
|
556
|
-
input: event.triggerEvent === "pr_merged"
|
|
557
|
-
? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
|
|
558
|
-
: "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
catch (error) {
|
|
562
|
-
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run after terminal PR event");
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
const commitTerminalUpdate = () => {
|
|
566
|
-
if (run) {
|
|
567
|
-
this.db.runs.finishRun(run.id, {
|
|
568
|
-
status: "released",
|
|
569
|
-
failureReason: event.triggerEvent === "pr_merged"
|
|
570
|
-
? "Pull request merged during active run"
|
|
571
|
-
: "Pull request closed during active run",
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
const terminalFactoryState = event.triggerEvent === "pr_merged"
|
|
575
|
-
? "done"
|
|
576
|
-
: resolveClosedPrFactoryState(issue);
|
|
577
|
-
this.db.issues.upsertIssue({
|
|
578
|
-
projectId: issue.projectId,
|
|
579
|
-
linearIssueId: issue.linearIssueId,
|
|
580
|
-
activeRunId: null,
|
|
581
|
-
factoryState: terminalFactoryState,
|
|
582
|
-
});
|
|
583
|
-
};
|
|
584
|
-
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
|
|
585
|
-
if (activeLease) {
|
|
586
|
-
this.db.issueSessions.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
this.db.transaction(commitTerminalUpdate);
|
|
590
|
-
}
|
|
591
|
-
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
592
|
-
const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
593
|
-
if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
|
|
594
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
595
|
-
projectId: issue.projectId,
|
|
596
|
-
linearIssueId: issue.linearIssueId,
|
|
597
|
-
eventType: "delegated",
|
|
598
|
-
dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
|
|
599
|
-
});
|
|
600
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
601
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
if (event.triggerEvent === "pr_merged") {
|
|
605
|
-
await this.completeLinearIssueAfterMerge(updatedIssue);
|
|
606
|
-
}
|
|
607
|
-
void this.syncLinearSession(updatedIssue);
|
|
608
|
-
}
|
|
609
|
-
async completeLinearIssueAfterMerge(issue) {
|
|
610
|
-
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
611
|
-
if (!linear)
|
|
612
|
-
return;
|
|
613
|
-
try {
|
|
614
|
-
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
615
|
-
const targetState = resolvePreferredCompletedLinearState(liveIssue);
|
|
616
|
-
if (!targetState) {
|
|
617
|
-
this.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
621
|
-
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
622
|
-
this.db.issues.upsertIssue({
|
|
623
|
-
projectId: issue.projectId,
|
|
624
|
-
linearIssueId: issue.linearIssueId,
|
|
625
|
-
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
626
|
-
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
627
|
-
});
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
631
|
-
this.db.issues.upsertIssue({
|
|
632
|
-
projectId: issue.projectId,
|
|
633
|
-
linearIssueId: issue.linearIssueId,
|
|
634
|
-
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
635
|
-
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
catch (error) {
|
|
639
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
640
|
-
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
async updateFailureProvenance(issue, event, project) {
|
|
644
|
-
const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
645
|
-
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
646
|
-
const source = isQueueEvictionCheck
|
|
647
|
-
? "queue_eviction"
|
|
648
|
-
: "branch_ci";
|
|
649
|
-
if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
const failureContext = source === "queue_eviction"
|
|
653
|
-
? this.buildQueueFailureContext(issue, event)
|
|
654
|
-
: await this.resolveBranchFailureContext(issue, event, project);
|
|
655
|
-
this.db.issues.upsertIssue({
|
|
656
|
-
projectId: issue.projectId,
|
|
657
|
-
linearIssueId: issue.linearIssueId,
|
|
658
|
-
lastGitHubFailureSource: source,
|
|
659
|
-
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
|
|
660
|
-
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
661
|
-
lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
|
|
662
|
-
lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
|
|
663
|
-
lastGitHubFailureContextJson: JSON.stringify(failureContext),
|
|
664
|
-
lastGitHubFailureAt: new Date().toISOString(),
|
|
665
|
-
...(source === "queue_eviction"
|
|
666
|
-
? {
|
|
667
|
-
lastQueueSignalAt: new Date().toISOString(),
|
|
668
|
-
lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
|
|
669
|
-
}
|
|
670
|
-
: {
|
|
671
|
-
lastQueueIncidentJson: null,
|
|
672
|
-
}),
|
|
673
|
-
});
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
|
|
677
|
-
|| event.triggerEvent === "pr_synchronize"
|
|
678
|
-
|| event.triggerEvent === "pr_merged") {
|
|
679
|
-
if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
this.db.issues.upsertIssue({
|
|
683
|
-
projectId: issue.projectId,
|
|
684
|
-
linearIssueId: issue.linearIssueId,
|
|
685
|
-
lastGitHubFailureSource: null,
|
|
686
|
-
lastGitHubFailureHeadSha: null,
|
|
687
|
-
lastGitHubFailureSignature: null,
|
|
688
|
-
lastGitHubFailureCheckName: null,
|
|
689
|
-
lastGitHubFailureCheckUrl: null,
|
|
690
|
-
lastGitHubFailureContextJson: null,
|
|
691
|
-
lastGitHubFailureAt: null,
|
|
692
|
-
lastQueueIncidentJson: null,
|
|
693
|
-
lastAttemptedFailureHeadSha: null,
|
|
694
|
-
lastAttemptedFailureSignature: null,
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
async resolveBranchFailureContext(issue, event, project) {
|
|
699
|
-
const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
|
|
700
|
-
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
701
|
-
const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
|
|
702
|
-
const context = await this.failureContextResolver.resolve({
|
|
703
|
-
source: "branch_ci",
|
|
704
|
-
repoFullName,
|
|
705
|
-
event: primaryFailedCheck
|
|
706
|
-
? {
|
|
707
|
-
...event,
|
|
708
|
-
checkName: primaryFailedCheck.name,
|
|
709
|
-
checkUrl: primaryFailedCheck.detailsUrl ?? event.checkUrl,
|
|
710
|
-
checkDetailsUrl: primaryFailedCheck.detailsUrl ?? event.checkDetailsUrl,
|
|
711
|
-
}
|
|
712
|
-
: event,
|
|
713
|
-
});
|
|
714
|
-
return {
|
|
715
|
-
...(context ? context : {}),
|
|
716
|
-
...(context?.headSha || event.headSha ? { failureHeadSha: context?.headSha ?? event.headSha } : {}),
|
|
717
|
-
...(context?.failureSignature ? { failureSignature: context.failureSignature } : {}),
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
buildQueueFailureContext(issue, event, queueRepairContext) {
|
|
721
|
-
const repoFullName = event.repoFullName || this.config.projects.find((p) => p.id === issue.projectId)?.github?.repoFullName || "";
|
|
722
|
-
const incident = queueRepairContext && typeof queueRepairContext === "object"
|
|
723
|
-
? queueRepairContext
|
|
724
|
-
: undefined;
|
|
725
|
-
const summary = typeof incident?.incidentSummary === "string"
|
|
726
|
-
? incident.incidentSummary
|
|
727
|
-
: event.checkOutputSummary ?? event.checkOutputTitle;
|
|
728
|
-
const failureHeadSha = event.headSha;
|
|
729
|
-
const failureSignature = [
|
|
730
|
-
"queue_eviction",
|
|
731
|
-
failureHeadSha ?? "unknown-sha",
|
|
732
|
-
event.checkName ?? "merge-steward/queue",
|
|
733
|
-
].join("::");
|
|
734
|
-
return {
|
|
735
|
-
source: "queue_eviction",
|
|
736
|
-
repoFullName,
|
|
737
|
-
capturedAt: new Date().toISOString(),
|
|
738
|
-
...(failureHeadSha ? { headSha: failureHeadSha, failureHeadSha } : {}),
|
|
739
|
-
...(event.checkName ? { checkName: event.checkName } : {}),
|
|
740
|
-
...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
|
|
741
|
-
...(event.checkDetailsUrl ? { checkDetailsUrl: event.checkDetailsUrl } : {}),
|
|
742
|
-
...(summary ? { summary } : {}),
|
|
743
|
-
failureSignature,
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
hasDuplicatePendingReactiveRun(issue, runType, failureContext) {
|
|
747
|
-
const signature = typeof failureContext.failureSignature === "string" ? failureContext.failureSignature : undefined;
|
|
748
|
-
const headSha = typeof failureContext.failureHeadSha === "string"
|
|
749
|
-
? failureContext.failureHeadSha
|
|
750
|
-
: typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
|
|
751
|
-
if (!signature)
|
|
752
|
-
return false;
|
|
753
|
-
const pendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
|
|
754
|
-
if (pendingWake?.runType === runType && pendingWake.eventIds.length > 0) {
|
|
755
|
-
const existing = pendingWake.context;
|
|
756
|
-
if (existing?.failureSignature === signature
|
|
757
|
-
&& (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
|
|
758
|
-
this.feed?.publish({
|
|
759
|
-
level: "info",
|
|
760
|
-
kind: "github",
|
|
761
|
-
issueKey: issue.issueKey,
|
|
762
|
-
projectId: issue.projectId,
|
|
763
|
-
stage: issue.factoryState,
|
|
764
|
-
status: "repair_deduped",
|
|
765
|
-
summary: `Skipped duplicate ${runType} for ${signature}`,
|
|
766
|
-
});
|
|
767
|
-
return true;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
if (issue.lastAttemptedFailureSignature === signature
|
|
771
|
-
&& (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha)) {
|
|
772
|
-
this.feed?.publish({
|
|
773
|
-
level: "info",
|
|
774
|
-
kind: "github",
|
|
775
|
-
issueKey: issue.issueKey,
|
|
776
|
-
projectId: issue.projectId,
|
|
777
|
-
stage: issue.factoryState,
|
|
778
|
-
status: "repair_deduped",
|
|
779
|
-
summary: `Already attempted ${runType} for this failing PR head`,
|
|
780
|
-
});
|
|
781
|
-
return true;
|
|
782
|
-
}
|
|
783
|
-
return false;
|
|
784
|
-
}
|
|
785
|
-
getGateCheckNames(project) {
|
|
786
|
-
const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
787
|
-
return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
|
|
788
|
-
}
|
|
789
|
-
getPrimaryGateCheckName(project) {
|
|
790
|
-
return this.getGateCheckNames(project)[0] ?? "verify";
|
|
791
|
-
}
|
|
792
|
-
isGateCheckEvent(event, project) {
|
|
793
|
-
if (event.eventSource !== "check_run" || !event.checkName)
|
|
794
|
-
return false;
|
|
795
|
-
const normalized = event.checkName.trim().toLowerCase();
|
|
796
|
-
return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
|
|
797
|
-
}
|
|
798
|
-
deriveImmediatePrCheckStatus(issue, event, project) {
|
|
799
|
-
if (event.triggerEvent === "pr_synchronize") {
|
|
800
|
-
return "pending";
|
|
801
|
-
}
|
|
802
|
-
if (event.eventSource !== "check_run") {
|
|
803
|
-
return undefined;
|
|
804
|
-
}
|
|
805
|
-
if (!this.isGateCheckEvent(event, project)) {
|
|
806
|
-
return undefined;
|
|
807
|
-
}
|
|
808
|
-
if (this.isStaleGateEvent(issue, event)) {
|
|
809
|
-
return undefined;
|
|
810
|
-
}
|
|
811
|
-
return event.checkStatus;
|
|
812
|
-
}
|
|
813
|
-
isStaleGateEvent(issue, event) {
|
|
814
|
-
return Boolean(issue.lastGitHubCiSnapshotHeadSha
|
|
815
|
-
&& event.headSha
|
|
816
|
-
&& issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
|
|
817
|
-
}
|
|
818
|
-
isQueueEvictionFailure(issue, event, project) {
|
|
819
|
-
const protocol = resolveMergeQueueProtocol(project);
|
|
820
|
-
return event.eventSource === "check_run"
|
|
821
|
-
&& event.checkName === protocol.evictionCheckName;
|
|
822
|
-
}
|
|
823
|
-
isSettledBranchFailure(issue, event, project) {
|
|
824
|
-
if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
|
|
825
|
-
return false;
|
|
826
|
-
if (!this.isGateCheckEvent(event, project))
|
|
827
|
-
return false;
|
|
828
|
-
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
829
|
-
return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
|
|
830
|
-
}
|
|
831
|
-
canClearFailureProvenance(issue, event, project) {
|
|
832
|
-
if (event.triggerEvent !== "check_passed")
|
|
833
|
-
return true;
|
|
834
|
-
if (this.isQueueEvictionFailure(issue, event, project)) {
|
|
835
|
-
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
836
|
-
}
|
|
837
|
-
if (!this.isGateCheckEvent(event, project)) {
|
|
838
|
-
return true;
|
|
839
|
-
}
|
|
840
|
-
if (this.isStaleGateEvent(issue, event)) {
|
|
841
|
-
return false;
|
|
842
|
-
}
|
|
843
|
-
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
844
|
-
}
|
|
845
|
-
getRelevantCiSnapshot(issue, event) {
|
|
846
|
-
const snapshot = this.db.issues.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
|
|
847
|
-
if (!snapshot)
|
|
848
|
-
return undefined;
|
|
849
|
-
if (snapshot.headSha !== event.headSha)
|
|
850
|
-
return undefined;
|
|
851
|
-
return snapshot;
|
|
852
|
-
}
|
|
853
|
-
pickPrimaryFailedCheck(snapshot) {
|
|
854
|
-
const gateName = snapshot.gateCheckName?.trim().toLowerCase();
|
|
855
|
-
return snapshot.failedChecks.find((entry) => entry.name.trim().toLowerCase() !== gateName)
|
|
856
|
-
?? snapshot.failedChecks[0];
|
|
857
|
-
}
|
|
858
|
-
async emitLinearActivity(issue, newState, event) {
|
|
859
|
-
if (!issue.agentSessionId)
|
|
860
|
-
return;
|
|
861
|
-
try {
|
|
862
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
863
|
-
if (!linear?.createAgentActivity)
|
|
864
|
-
return;
|
|
865
|
-
const content = buildGitHubStateActivity(issue.factoryState, event);
|
|
866
|
-
if (!content)
|
|
867
|
-
return;
|
|
868
|
-
const allowEphemeral = content.type === "thought" || content.type === "action";
|
|
869
|
-
await linear.createAgentActivity({
|
|
870
|
-
agentSessionId: issue.agentSessionId,
|
|
871
|
-
content,
|
|
872
|
-
...(allowEphemeral ? { ephemeral: false } : {}),
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
catch (error) {
|
|
876
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
877
|
-
this.logger.warn({ issueKey: issue.issueKey, newState, error: msg }, "Failed to emit Linear activity from GitHub webhook");
|
|
878
|
-
this.feed?.publish({
|
|
879
|
-
level: "warn",
|
|
880
|
-
kind: "linear",
|
|
881
|
-
issueKey: issue.issueKey,
|
|
882
|
-
projectId: issue.projectId,
|
|
883
|
-
status: "linear_error",
|
|
884
|
-
summary: `Linear activity failed: ${msg}`,
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
async syncLinearSession(issue) {
|
|
889
|
-
if (!issue.agentSessionId)
|
|
890
|
-
return;
|
|
891
|
-
try {
|
|
892
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
893
|
-
if (!linear?.updateAgentSession)
|
|
894
|
-
return;
|
|
895
|
-
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
896
|
-
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
897
|
-
...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
|
|
898
|
-
});
|
|
899
|
-
await linear.updateAgentSession({
|
|
900
|
-
agentSessionId: issue.agentSessionId,
|
|
901
|
-
plan: buildAgentSessionPlanForIssue(issue),
|
|
902
|
-
...(externalUrls ? { externalUrls } : {}),
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
catch (error) {
|
|
906
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
907
|
-
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
async fetchReviewCommentsForEvent(event) {
|
|
911
|
-
if (event.triggerEvent !== "review_changes_requested") {
|
|
912
|
-
return undefined;
|
|
913
|
-
}
|
|
914
|
-
if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
|
|
915
|
-
return undefined;
|
|
916
|
-
}
|
|
917
|
-
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
918
|
-
if (!token) {
|
|
919
|
-
this.logger.debug({ prNumber: event.prNumber, reviewId: event.reviewId }, "Skipping inline review comment fetch because no GitHub API token is available");
|
|
920
|
-
return undefined;
|
|
921
|
-
}
|
|
922
|
-
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
923
|
-
if (!owner || !repo) {
|
|
924
|
-
return undefined;
|
|
925
|
-
}
|
|
926
|
-
const response = await this.fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
|
|
927
|
-
headers: {
|
|
928
|
-
Authorization: `Bearer ${token}`,
|
|
929
|
-
Accept: "application/vnd.github+json",
|
|
930
|
-
"User-Agent": "patchrelay",
|
|
931
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
932
|
-
},
|
|
117
|
+
project,
|
|
118
|
+
failureContextResolver: this.failureContextResolver,
|
|
119
|
+
fetchImpl: this.fetchImpl,
|
|
933
120
|
});
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const record = entry;
|
|
946
|
-
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
947
|
-
const id = typeof record.id === "number" ? record.id : undefined;
|
|
948
|
-
if (!body || id === undefined)
|
|
949
|
-
continue;
|
|
950
|
-
comments.push({
|
|
951
|
-
id,
|
|
952
|
-
body,
|
|
953
|
-
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
954
|
-
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
955
|
-
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
956
|
-
...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
|
|
957
|
-
...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
|
|
958
|
-
...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
|
|
959
|
-
...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
|
|
960
|
-
...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
|
|
961
|
-
...(typeof record.user?.login === "string"
|
|
962
|
-
? { authorLogin: String(record.user.login) }
|
|
963
|
-
: {}),
|
|
121
|
+
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
122
|
+
await handleGitHubTerminalPrEvent({
|
|
123
|
+
config: this.config,
|
|
124
|
+
db: this.db,
|
|
125
|
+
linearProvider: this.linearProvider,
|
|
126
|
+
enqueueIssue: this.enqueueIssue,
|
|
127
|
+
logger: this.logger,
|
|
128
|
+
codex: this.codex,
|
|
129
|
+
feed: this.feed,
|
|
130
|
+
issue: freshIssue,
|
|
131
|
+
event,
|
|
964
132
|
});
|
|
965
133
|
}
|
|
966
|
-
return comments;
|
|
967
|
-
}
|
|
968
|
-
async handlePrComment(payload) {
|
|
969
|
-
if (payload.action !== "created")
|
|
970
|
-
return;
|
|
971
|
-
const issuePayload = payload.issue;
|
|
972
|
-
const comment = payload.comment;
|
|
973
|
-
if (!issuePayload || !comment)
|
|
974
|
-
return;
|
|
975
|
-
if (!issuePayload.pull_request)
|
|
976
|
-
return; // only PR comments
|
|
977
|
-
const body = typeof comment.body === "string" ? comment.body : "";
|
|
978
|
-
if (!body.trim())
|
|
979
|
-
return;
|
|
980
|
-
const user = comment.user;
|
|
981
|
-
const author = typeof user?.login === "string" ? user.login : "unknown";
|
|
982
|
-
if (typeof user?.type === "string" && user.type === "Bot")
|
|
983
|
-
return;
|
|
984
|
-
const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
|
|
985
|
-
if (!prNumber)
|
|
986
|
-
return;
|
|
987
|
-
const issue = this.db.issues.getIssueByPrNumber(prNumber);
|
|
988
|
-
if (!issue)
|
|
989
|
-
return;
|
|
990
|
-
this.feed?.publish({
|
|
991
|
-
level: "info",
|
|
992
|
-
kind: "comment",
|
|
993
|
-
issueKey: issue.issueKey,
|
|
994
|
-
projectId: issue.projectId,
|
|
995
|
-
stage: issue.factoryState,
|
|
996
|
-
status: "pr_comment",
|
|
997
|
-
summary: `GitHub PR comment from ${author}`,
|
|
998
|
-
detail: body.slice(0, 200),
|
|
999
|
-
});
|
|
1000
|
-
if (issue.activeRunId) {
|
|
1001
|
-
const run = this.db.runs.getRunById(issue.activeRunId);
|
|
1002
|
-
if (run?.threadId && run.turnId) {
|
|
1003
|
-
try {
|
|
1004
|
-
await this.codex.steerTurn({
|
|
1005
|
-
threadId: run.threadId,
|
|
1006
|
-
turnId: run.turnId,
|
|
1007
|
-
input: `GitHub PR comment from ${author}:\n\n${body}`,
|
|
1008
|
-
});
|
|
1009
|
-
this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
catch (error) {
|
|
1013
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1014
|
-
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to forward GitHub PR comment");
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
this.db.issueSessions.appendIssueSessionEvent({
|
|
1019
|
-
projectId: issue.projectId,
|
|
1020
|
-
linearIssueId: issue.linearIssueId,
|
|
1021
|
-
eventType: "followup_comment",
|
|
1022
|
-
eventJson: JSON.stringify({ body, author }),
|
|
1023
|
-
});
|
|
1024
|
-
this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
1025
|
-
}
|
|
1026
|
-
async readGitHubErrorResponse(response) {
|
|
1027
|
-
try {
|
|
1028
|
-
const payload = await response.json();
|
|
1029
|
-
if (typeof payload?.message === "string" && payload.message.trim()) {
|
|
1030
|
-
return payload.message.trim();
|
|
1031
|
-
}
|
|
1032
|
-
if (payload?.errors !== undefined) {
|
|
1033
|
-
return JSON.stringify(payload.errors);
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
catch {
|
|
1037
|
-
// Fall through to status text.
|
|
1038
|
-
}
|
|
1039
|
-
return response.statusText || `GitHub API responded with ${response.status}`;
|
|
1040
|
-
}
|
|
1041
|
-
peekPendingSessionWakeRunType(projectId, issueId) {
|
|
1042
|
-
return this.db.issueSessions.peekIssueSessionWake(projectId, issueId)?.runType;
|
|
1043
|
-
}
|
|
1044
|
-
enqueuePendingSessionWake(projectId, issueId) {
|
|
1045
|
-
const wake = this.db.issueSessions.peekIssueSessionWake(projectId, issueId);
|
|
1046
|
-
if (!wake) {
|
|
1047
|
-
return undefined;
|
|
1048
|
-
}
|
|
1049
|
-
this.enqueueIssue(projectId, issueId);
|
|
1050
|
-
return wake.runType;
|
|
1051
134
|
}
|
|
1052
135
|
}
|
|
1053
|
-
function escapeRegExp(value) {
|
|
1054
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1055
|
-
}
|
|
1056
|
-
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
1057
|
-
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
|
1058
|
-
return undefined;
|
|
1059
|
-
}
|
|
1060
|
-
return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
|
|
1061
|
-
}
|
|
1062
|
-
function resolveCheckClass(checkName, project) {
|
|
1063
|
-
if (!checkName || !project)
|
|
1064
|
-
return "code";
|
|
1065
|
-
if ((project.reviewChecks ?? []).some((name) => checkName.includes(name)))
|
|
1066
|
-
return "review";
|
|
1067
|
-
if ((project.gateChecks ?? []).some((name) => checkName.includes(name)))
|
|
1068
|
-
return "gate";
|
|
1069
|
-
return "code";
|
|
1070
|
-
}
|