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.
Files changed (50) hide show
  1. package/README.md +47 -9
  2. package/dist/awaiting-input-reason.js +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/cluster-health.js +59 -3
  5. package/dist/cli/help.js +1 -1
  6. package/dist/cli/output.js +2 -0
  7. package/dist/db/issue-session-store.js +0 -14
  8. package/dist/db/issue-store.js +8 -16
  9. package/dist/db/migrations.js +6 -13
  10. package/dist/db.js +1 -3
  11. package/dist/github-linear-session-sync.js +57 -0
  12. package/dist/github-pr-comment-handler.js +74 -0
  13. package/dist/github-webhook-failure-context.js +70 -0
  14. package/dist/github-webhook-handler.js +49 -965
  15. package/dist/github-webhook-issue-resolution.js +46 -0
  16. package/dist/github-webhook-policy.js +105 -0
  17. package/dist/github-webhook-reactive-run.js +302 -0
  18. package/dist/github-webhook-state-projector.js +231 -0
  19. package/dist/github-webhook-terminal-handler.js +111 -0
  20. package/dist/github-webhooks.js +4 -0
  21. package/dist/idle-reconciliation.js +22 -23
  22. package/dist/issue-overview-query.js +11 -57
  23. package/dist/issue-session-projector.js +1 -0
  24. package/dist/issue-session.js +8 -0
  25. package/dist/legacy-issue-overview.js +58 -0
  26. package/dist/linear-session-reporting.js +30 -1
  27. package/dist/linear-session-sync.js +9 -1
  28. package/dist/linear-status-comment-sync.js +34 -1
  29. package/dist/linear-workflow-state-sync.js +2 -2
  30. package/dist/operator-retry-event.js +15 -12
  31. package/dist/paused-issue-state.js +24 -0
  32. package/dist/reactive-pr-state.js +65 -0
  33. package/dist/reactive-run-policy.js +35 -118
  34. package/dist/remote-pr-state.js +11 -0
  35. package/dist/run-launcher.js +0 -1
  36. package/dist/run-orchestrator.js +22 -11
  37. package/dist/run-reconciler.js +10 -0
  38. package/dist/run-recovery-service.js +1 -10
  39. package/dist/service-issue-actions.js +5 -0
  40. package/dist/service-startup-recovery.js +9 -6
  41. package/dist/service.js +0 -1
  42. package/dist/tracked-issue-list-query.js +3 -1
  43. package/dist/tracked-issue-projector.js +3 -0
  44. package/dist/waiting-reason.js +10 -0
  45. package/dist/webhooks/agent-session-handler.js +9 -1
  46. package/dist/webhooks/comment-wake-handler.js +12 -0
  47. package/dist/webhooks/decision-helpers.js +44 -3
  48. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  49. package/dist/webhooks/desired-stage-recorder.js +40 -10
  50. 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
- * GitHub sends both check_run and check_suite completion events.
15
- * A single CI run generates many individual check_run events as each job finishes,
16
- * but PatchRelay should only start ci_repair once the configured gate check
17
- * (for example `Tests`) has gone terminal for the current PR head SHA. We still
18
- * treat most check_run events as metadata-only and only react to queue eviction
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
- patchRelayAuthorLogins = new Set();
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
- for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
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
- if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
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.handlePrComment(payload);
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
- // Route to issue via branch name
134
- const issue = this.db.issues.getIssueByBranch(event.branchName);
135
- if (!issue) {
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
- if (issue.prState !== "open")
275
- return;
276
- if (event.eventSource !== "check_run")
277
- return;
278
- if (this.isQueueEvictionFailure(issue, event, project))
279
- return;
280
- if (!this.isGateCheckEvent(event, project))
281
- return;
282
- if (this.isStaleGateEvent(issue, event))
283
- return;
284
- const snapshot = await this.ciSnapshotResolver.resolve({
285
- repoFullName: project?.github?.repoFullName ?? event.repoFullName,
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
- gateCheckNames: this.getGateCheckNames(project),
288
- });
289
- if (!snapshot) {
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
- async maybeEnqueueReactiveRun(issue, event, project) {
323
- // Don't trigger if there's already an active run
324
- if (issue.activeRunId !== undefined)
325
- return;
326
- // Don't trigger on terminal issues — late-arriving webhooks (e.g.
327
- // merge_group_failed after pr_merged) must not resurrect done issues.
328
- if (isIssueTerminal(issue))
329
- return;
330
- if (!this.isPatchRelayOwnedPr(issue)) {
331
- this.feed?.publish({
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
  }