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