patchrelay 0.35.10 → 0.35.12

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 +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +275 -74
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -57,6 +57,11 @@ fi
57
57
  exec /usr/bin/gh "$@"
58
58
  `;
59
59
  await writeFile(ghWrapper, script, { mode: 0o755 });
60
+ const currentPath = process.env.PATH ?? "";
61
+ const pathEntries = currentPath.split(path.delimiter).filter(Boolean);
62
+ if (!pathEntries.includes(binDir)) {
63
+ process.env.PATH = [binDir, ...pathEntries].join(path.delimiter);
64
+ }
60
65
  logger.debug({ path: ghWrapper }, "Wrote gh wrapper script");
61
66
  }
62
67
  /**
@@ -144,6 +149,8 @@ export function createGitHubAppTokenManager(credentials, logger) {
144
149
  await mkdir(path.dirname(tokenFile), { recursive: true });
145
150
  await writeFile(tokenFile, token, { mode: 0o600 });
146
151
  cachedToken = token;
152
+ process.env.GH_TOKEN = token;
153
+ process.env.GITHUB_TOKEN = token;
147
154
  logger.debug("Refreshed GitHub App installation token");
148
155
  }
149
156
  catch (error) {
@@ -33,7 +33,14 @@ export function createGitHubFailureContextResolver() {
33
33
  const annotations = failedCheck?.id
34
34
  ? await resolveAnnotations(repoFullName, failedCheck.id)
35
35
  : undefined;
36
- const summary = firstNonEmpty(annotations?.[0], failedCheck?.outputTitle, failedCheck?.outputSummary, event.checkOutputTitle, event.checkOutputSummary, workflowJob?.stepName ? `Failed step: ${workflowJob.stepName}` : undefined);
36
+ const summary = pickFailureSummary({
37
+ annotations,
38
+ failedCheckOutputTitle: failedCheck?.outputTitle,
39
+ failedCheckOutputSummary: failedCheck?.outputSummary,
40
+ eventCheckOutputTitle: event.checkOutputTitle,
41
+ eventCheckOutputSummary: event.checkOutputSummary,
42
+ workflowStepName: workflowJob?.stepName,
43
+ });
37
44
  const checkName = firstNonEmpty(failedCheck?.name, event.checkName);
38
45
  const checkUrl = firstNonEmpty(failedCheck?.htmlUrl, event.checkUrl);
39
46
  const checkDetailsUrl = firstNonEmpty(failedCheck?.detailsUrl, event.checkDetailsUrl);
@@ -105,6 +112,12 @@ export function summarizeGitHubFailureContext(context) {
105
112
  const step = context.stepName ? `${lead ?? "CI"} -> ${context.stepName}` : lead;
106
113
  return firstNonEmpty(step && context.summary ? `${step}: ${context.summary}` : undefined, step, context.summary);
107
114
  }
115
+ export function pickFailureSummary(params) {
116
+ const preferredAnnotation = pickPreferredFailureAnnotation(params.annotations);
117
+ const structuredSummary = firstNonEmpty(params.failedCheckOutputTitle, params.failedCheckOutputSummary, params.eventCheckOutputTitle, params.eventCheckOutputSummary);
118
+ const failedStepSummary = params.workflowStepName ? `Failed step: ${params.workflowStepName}` : undefined;
119
+ return firstNonEmpty(preferredAnnotation, structuredSummary, failedStepSummary, params.annotations?.[0]);
120
+ }
108
121
  function buildFallbackFailureContext(source, repoFullName, event) {
109
122
  const summary = firstNonEmpty(event.checkOutputTitle, event.checkOutputSummary, event.checkOutputText ? sanitizeDiagnosticText(event.checkOutputText, 240) : undefined);
110
123
  return {
@@ -119,6 +132,36 @@ function buildFallbackFailureContext(source, repoFullName, event) {
119
132
  ...(summary ? { summary } : {}),
120
133
  };
121
134
  }
135
+ export function pickPreferredFailureAnnotation(annotations) {
136
+ if (!Array.isArray(annotations) || annotations.length === 0)
137
+ return undefined;
138
+ const ranked = annotations
139
+ .map((annotation) => ({ annotation, score: scoreFailureAnnotation(annotation) }))
140
+ .filter((entry) => entry.score > 0)
141
+ .sort((left, right) => right.score - left.score);
142
+ return ranked[0]?.annotation;
143
+ }
144
+ function scoreFailureAnnotation(annotation) {
145
+ const text = annotation.trim();
146
+ if (!text)
147
+ return 0;
148
+ const lower = text.toLowerCase();
149
+ if (lower.startsWith("process completed with exit code"))
150
+ return 0;
151
+ if (lower.includes("actions target node.js 20 but are being forced to run on node.js 24"))
152
+ return 0;
153
+ let score = 1;
154
+ if (!lower.includes("(.github)")) {
155
+ score += 2;
156
+ }
157
+ if (lower.includes("assertionerror") || lower.includes("expected values to be strictly equal")) {
158
+ score += 2;
159
+ }
160
+ if (lower.includes("error") || lower.includes("exception") || lower.includes("failed")) {
161
+ score += 1;
162
+ }
163
+ return score;
164
+ }
122
165
  async function resolveFailedCheckRun(repoFullName, event) {
123
166
  if (!event.headSha)
124
167
  return undefined;
@@ -0,0 +1,47 @@
1
+ const FAILED_CONCLUSIONS = new Set([
2
+ "action_required",
3
+ "cancelled",
4
+ "failure",
5
+ "stale",
6
+ "startup_failure",
7
+ "timed_out",
8
+ ]);
9
+ function normalizeGateStatus(entry) {
10
+ const status = entry.status?.trim().toLowerCase();
11
+ if (status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending") {
12
+ return "pending";
13
+ }
14
+ const conclusion = entry.conclusion?.trim().toLowerCase();
15
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
16
+ return "success";
17
+ }
18
+ if (conclusion && FAILED_CONCLUSIONS.has(conclusion)) {
19
+ return "failure";
20
+ }
21
+ return status === "completed" ? "failure" : "pending";
22
+ }
23
+ export function deriveGateCheckStatusFromRollup(statusCheckRollup, gateCheckNames) {
24
+ if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
25
+ return undefined;
26
+ }
27
+ const expectedNames = gateCheckNames
28
+ .map((entry) => entry.trim().toLowerCase())
29
+ .filter(Boolean);
30
+ if (expectedNames.length === 0) {
31
+ return undefined;
32
+ }
33
+ const matches = statusCheckRollup.filter((entry) => {
34
+ if (typeof entry?.name !== "string" || !entry.name.trim())
35
+ return false;
36
+ return expectedNames.includes(entry.name.trim().toLowerCase());
37
+ });
38
+ if (matches.length === 0) {
39
+ return undefined;
40
+ }
41
+ const normalized = matches.map((entry) => normalizeGateStatus(entry));
42
+ if (normalized.some((status) => status === "pending"))
43
+ return "pending";
44
+ if (normalized.some((status) => status === "failure"))
45
+ return "failure";
46
+ return "success";
47
+ }
@@ -4,8 +4,9 @@ import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-w
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
5
5
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
6
6
  import { buildGitHubStateActivity } from "./linear-session-reporting.js";
7
- import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
7
+ import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
8
8
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
9
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
9
10
  import { resolveSecret } from "./resolve-secret.js";
10
11
  import { safeJsonParse } from "./utils.js";
11
12
  /**
@@ -20,6 +21,7 @@ function isMetadataOnlyCheckEvent(event) {
20
21
  return event.eventSource === "check_run"
21
22
  && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
22
23
  }
24
+ const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
23
25
  export class GitHubWebhookHandler {
24
26
  config;
25
27
  db;
@@ -30,6 +32,7 @@ export class GitHubWebhookHandler {
30
32
  feed;
31
33
  failureContextResolver;
32
34
  ciSnapshotResolver;
35
+ patchRelayAuthorLogins = new Set();
33
36
  constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
34
37
  this.config = config;
35
38
  this.db = db;
@@ -40,6 +43,18 @@ export class GitHubWebhookHandler {
40
43
  this.feed = feed;
41
44
  this.failureContextResolver = failureContextResolver;
42
45
  this.ciSnapshotResolver = ciSnapshotResolver;
46
+ for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
47
+ this.patchRelayAuthorLogins.add(login);
48
+ }
49
+ }
50
+ setPatchRelayAuthorLogins(logins) {
51
+ this.patchRelayAuthorLogins.clear();
52
+ for (const login of logins) {
53
+ const normalized = normalizeAuthorLogin(login);
54
+ if (normalized) {
55
+ this.patchRelayAuthorLogins.add(normalized);
56
+ }
57
+ }
43
58
  }
44
59
  async acceptGitHubWebhook(params) {
45
60
  // Deduplicate
@@ -126,44 +141,30 @@ export class GitHubWebhookHandler {
126
141
  ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
127
142
  ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
128
143
  ...(event.prState !== undefined ? { prState: event.prState } : {}),
144
+ ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
145
+ ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
129
146
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
130
147
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
131
148
  });
132
149
  await this.updateCiSnapshot(issue, event, project);
133
150
  await this.updateFailureProvenance(issue, event, project);
134
- if (!isMetadataOnlyCheckEvent(event)) {
151
+ const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
152
+ if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
135
153
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
136
154
  const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
137
- const newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState, {
138
- prReviewState: afterMetadata.prReviewState,
139
- activeRunId: afterMetadata.activeRunId,
140
- });
155
+ const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
141
156
  // Only transition and notify when the state actually changes.
142
157
  // Multiple check_suite events can arrive for the same outcome.
143
158
  if (newState && newState !== afterMetadata.factoryState) {
144
- this.db.upsertIssue({
159
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
145
160
  projectId: issue.projectId,
146
161
  linearIssueId: issue.linearIssueId,
147
162
  factoryState: newState,
148
163
  });
149
- if (newState === "awaiting_queue") {
150
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
151
- }
152
164
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
153
165
  const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
154
166
  void this.emitLinearActivity(transitionedIssue, newState, event);
155
167
  void this.syncLinearSession(transitionedIssue);
156
- // Schedule merge prep when entering awaiting_queue
157
- if (newState === "awaiting_queue") {
158
- const proj = this.config.projects.find((p) => p.id === issue.projectId);
159
- const protocol = resolveMergeQueueProtocol(proj);
160
- void requestMergeQueueAdmission({
161
- issue: transitionedIssue,
162
- protocol,
163
- logger: this.logger,
164
- feed: this.feed,
165
- });
166
- }
167
168
  }
168
169
  }
169
170
  // Re-read issue after all upserts so reactive run logic sees current state
@@ -171,7 +172,7 @@ export class GitHubWebhookHandler {
171
172
  // Reset repair counters on new push — but only when no repair run is active,
172
173
  // since Codex pushes during repair and resetting mid-run would bypass budgets.
173
174
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
174
- this.db.upsertIssue({
175
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
175
176
  projectId: issue.projectId,
176
177
  linearIssueId: issue.linearIssueId,
177
178
  ciRepairAttempts: 0,
@@ -207,12 +208,28 @@ export class GitHubWebhookHandler {
207
208
  // Queue eviction check runs bypass the metadata-only filter because
208
209
  // they're individual check_run events (not check_suite), but they
209
210
  // must drive state transitions.
210
- if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
211
+ if (queueEvictionCheck || this.isGateCheckEvent(event, project)) {
211
212
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
212
213
  }
213
214
  else if (!isMetadataOnlyCheckEvent(event)) {
214
215
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
215
216
  }
217
+ if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
218
+ await this.handleTerminalPrEvent(freshIssue, event);
219
+ }
220
+ }
221
+ resolveFactoryStateForEvent(issue, event, project) {
222
+ if (event.triggerEvent === "check_failed"
223
+ && this.isQueueEvictionFailure(issue, event, project)
224
+ && issue.prState === "open"
225
+ && issue.activeRunId === undefined
226
+ && !TERMINAL_STATES.has(issue.factoryState)) {
227
+ return "repairing_queue";
228
+ }
229
+ return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
230
+ prReviewState: issue.prReviewState,
231
+ activeRunId: issue.activeRunId,
232
+ });
216
233
  }
217
234
  async updateCiSnapshot(issue, event, project) {
218
235
  if (event.triggerEvent === "pr_merged") {
@@ -279,6 +296,7 @@ export class GitHubWebhookHandler {
279
296
  this.db.upsertIssue({
280
297
  projectId: issue.projectId,
281
298
  linearIssueId: issue.linearIssueId,
299
+ prCheckStatus: snapshot.gateCheckStatus,
282
300
  lastGitHubCiSnapshotHeadSha: snapshot.headSha,
283
301
  lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
284
302
  lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
@@ -294,6 +312,18 @@ export class GitHubWebhookHandler {
294
312
  // merge_group_failed after pr_merged) must not resurrect done issues.
295
313
  if (TERMINAL_STATES.has(issue.factoryState))
296
314
  return;
315
+ if (!this.isPatchRelayOwnedPr(issue)) {
316
+ this.feed?.publish({
317
+ level: "info",
318
+ kind: "github",
319
+ issueKey: issue.issueKey,
320
+ projectId: issue.projectId,
321
+ stage: issue.factoryState,
322
+ status: "ignored_non_patchrelay_pr",
323
+ summary: `Ignored ${event.triggerEvent} on non-PatchRelay-owned PR`,
324
+ });
325
+ return;
326
+ }
297
327
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
298
328
  // External merge queue eviction: react only to the configured check
299
329
  // name, not to any CI failure. Regular CI failures still get ci_repair.
@@ -303,14 +333,10 @@ export class GitHubWebhookHandler {
303
333
  if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
304
334
  return;
305
335
  }
336
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
306
337
  this.db.upsertIssue({
307
338
  projectId: issue.projectId,
308
339
  linearIssueId: issue.linearIssueId,
309
- pendingRunType: "queue_repair",
310
- pendingRunContextJson: JSON.stringify({
311
- ...queueRepairContext,
312
- ...failureContext,
313
- }),
314
340
  lastGitHubFailureSource: "queue_eviction",
315
341
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
316
342
  lastGitHubFailureSignature: failureContext.failureSignature ?? null,
@@ -321,8 +347,20 @@ export class GitHubWebhookHandler {
321
347
  lastQueueSignalAt: new Date().toISOString(),
322
348
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
323
349
  });
324
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
325
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
350
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
351
+ projectId: issue.projectId,
352
+ linearIssueId: issue.linearIssueId,
353
+ eventType: "merge_steward_incident",
354
+ eventJson: JSON.stringify({
355
+ ...queueRepairContext,
356
+ ...failureContext,
357
+ }),
358
+ dedupeKey: failureContext.failureSignature,
359
+ });
360
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
361
+ const queuedRunType = hadPendingWake
362
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
363
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
326
364
  this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
327
365
  this.feed?.publish({
328
366
  level: "warn",
@@ -331,7 +369,7 @@ export class GitHubWebhookHandler {
331
369
  projectId: issue.projectId,
332
370
  stage: "repairing_queue",
333
371
  status: "queue_repair_queued",
334
- summary: `Queue repair queued after external failure from ${event.checkName}`,
372
+ summary: `${queuedRunType ?? "queue_repair"} queued after external failure from ${event.checkName}`,
335
373
  detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
336
374
  });
337
375
  }
@@ -352,16 +390,11 @@ export class GitHubWebhookHandler {
352
390
  if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
353
391
  return;
354
392
  }
393
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
355
394
  const snapshot = this.getRelevantCiSnapshot(issue, event);
356
395
  this.db.upsertIssue({
357
396
  projectId: issue.projectId,
358
397
  linearIssueId: issue.linearIssueId,
359
- pendingRunType: "ci_repair",
360
- pendingRunContextJson: JSON.stringify({
361
- ...failureContext,
362
- checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
363
- ...(snapshot ? { ciSnapshot: snapshot } : {}),
364
- }),
365
398
  lastGitHubFailureSource: "branch_ci",
366
399
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
367
400
  lastGitHubFailureSignature: failureContext.failureSignature ?? null,
@@ -371,8 +404,21 @@ export class GitHubWebhookHandler {
371
404
  lastGitHubFailureAt: new Date().toISOString(),
372
405
  lastQueueIncidentJson: null,
373
406
  });
374
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
375
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
407
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
408
+ projectId: issue.projectId,
409
+ linearIssueId: issue.linearIssueId,
410
+ eventType: "settled_red_ci",
411
+ eventJson: JSON.stringify({
412
+ ...failureContext,
413
+ checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
414
+ ...(snapshot ? { ciSnapshot: snapshot } : {}),
415
+ }),
416
+ dedupeKey: failureContext.failureSignature,
417
+ });
418
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
419
+ const queuedRunType = hadPendingWake
420
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
421
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
376
422
  this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
377
423
  this.feed?.publish({
378
424
  level: "warn",
@@ -381,24 +427,130 @@ export class GitHubWebhookHandler {
381
427
  projectId: issue.projectId,
382
428
  stage: "repairing_ci",
383
429
  status: "ci_repair_queued",
384
- summary: `CI repair queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
430
+ summary: `${queuedRunType ?? "ci_repair"} queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
385
431
  detail: summarizeGitHubFailureContext(failureContext),
386
432
  });
387
433
  }
388
434
  }
389
435
  if (event.triggerEvent === "review_changes_requested") {
390
- this.db.upsertIssue({
436
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
437
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
391
438
  projectId: issue.projectId,
392
439
  linearIssueId: issue.linearIssueId,
393
- pendingRunType: "review_fix",
394
- pendingRunContextJson: JSON.stringify({
440
+ eventType: "review_changes_requested",
441
+ eventJson: JSON.stringify({
395
442
  reviewBody: event.reviewBody,
396
443
  reviewerName: event.reviewerName,
397
444
  }),
445
+ dedupeKey: [
446
+ "review_changes_requested",
447
+ issue.prHeadSha ?? event.headSha ?? "unknown-sha",
448
+ event.reviewerName ?? "unknown-reviewer",
449
+ ].join("::"),
398
450
  });
399
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
400
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
451
+ this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
452
+ const queuedRunType = hadPendingWake
453
+ ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
454
+ : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
401
455
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
456
+ this.feed?.publish({
457
+ level: "warn",
458
+ kind: "github",
459
+ issueKey: issue.issueKey,
460
+ projectId: issue.projectId,
461
+ stage: "changes_requested",
462
+ status: "review_fix_queued",
463
+ summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
464
+ detail: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
465
+ });
466
+ }
467
+ }
468
+ async handleTerminalPrEvent(issue, event) {
469
+ const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
470
+ this.db.appendIssueSessionEvent({
471
+ projectId: issue.projectId,
472
+ linearIssueId: issue.linearIssueId,
473
+ eventType,
474
+ dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
475
+ });
476
+ this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
477
+ const run = issue.activeRunId ? this.db.getRun(issue.activeRunId) : undefined;
478
+ if (run?.threadId && run.turnId) {
479
+ try {
480
+ await this.codex.steerTurn({
481
+ threadId: run.threadId,
482
+ turnId: run.turnId,
483
+ input: event.triggerEvent === "pr_merged"
484
+ ? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
485
+ : "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
486
+ });
487
+ }
488
+ catch (error) {
489
+ 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");
490
+ }
491
+ }
492
+ const commitTerminalUpdate = () => {
493
+ if (run) {
494
+ this.db.finishRun(run.id, {
495
+ status: "released",
496
+ failureReason: event.triggerEvent === "pr_merged"
497
+ ? "Pull request merged during active run"
498
+ : "Pull request closed during active run",
499
+ });
500
+ }
501
+ this.db.upsertIssue({
502
+ projectId: issue.projectId,
503
+ linearIssueId: issue.linearIssueId,
504
+ activeRunId: null,
505
+ factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
506
+ });
507
+ };
508
+ const activeLease = this.db.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
509
+ if (activeLease) {
510
+ this.db.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
511
+ }
512
+ else {
513
+ this.db.transaction(commitTerminalUpdate);
514
+ }
515
+ this.db.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
516
+ const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
517
+ if (event.triggerEvent === "pr_merged") {
518
+ await this.completeLinearIssueAfterMerge(updatedIssue);
519
+ }
520
+ void this.syncLinearSession(updatedIssue);
521
+ }
522
+ async completeLinearIssueAfterMerge(issue) {
523
+ const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
524
+ if (!linear)
525
+ return;
526
+ try {
527
+ const liveIssue = await linear.getIssue(issue.linearIssueId);
528
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
529
+ if (!targetState) {
530
+ this.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
531
+ return;
532
+ }
533
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
534
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
535
+ this.db.upsertIssue({
536
+ projectId: issue.projectId,
537
+ linearIssueId: issue.linearIssueId,
538
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
539
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
540
+ });
541
+ return;
542
+ }
543
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
544
+ this.db.upsertIssue({
545
+ projectId: issue.projectId,
546
+ linearIssueId: issue.linearIssueId,
547
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
548
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
549
+ });
550
+ }
551
+ catch (error) {
552
+ const msg = error instanceof Error ? error.message : String(error);
553
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
402
554
  }
403
555
  }
404
556
  async updateFailureProvenance(issue, event, project) {
@@ -511,8 +663,9 @@ export class GitHubWebhookHandler {
511
663
  : typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
512
664
  if (!signature)
513
665
  return false;
514
- if (issue.pendingRunType === runType && issue.pendingRunContextJson) {
515
- const existing = safeJsonParse(issue.pendingRunContextJson);
666
+ const pendingWake = this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
667
+ if (pendingWake?.runType === runType) {
668
+ const existing = pendingWake.context;
516
669
  if (existing?.failureSignature === signature
517
670
  && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
518
671
  this.feed?.publish({
@@ -544,10 +697,10 @@ export class GitHubWebhookHandler {
544
697
  }
545
698
  getGateCheckNames(project) {
546
699
  const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
547
- return configured.length > 0 ? configured : ["Tests"];
700
+ return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
548
701
  }
549
702
  getPrimaryGateCheckName(project) {
550
- return this.getGateCheckNames(project)[0] ?? "Tests";
703
+ return this.getGateCheckNames(project)[0] ?? "verify";
551
704
  }
552
705
  isGateCheckEvent(event, project) {
553
706
  if (event.eventSource !== "check_run" || !event.checkName)
@@ -562,8 +715,7 @@ export class GitHubWebhookHandler {
562
715
  }
563
716
  isQueueEvictionFailure(issue, event, project) {
564
717
  const protocol = resolveMergeQueueProtocol(project);
565
- return issue.factoryState === "awaiting_queue"
566
- && event.eventSource === "check_run"
718
+ return event.eventSource === "check_run"
567
719
  && event.checkName === protocol.evictionCheckName;
568
720
  }
569
721
  isSettledBranchFailure(issue, event, project) {
@@ -675,6 +827,8 @@ export class GitHubWebhookHandler {
675
827
  const issue = this.db.getIssueByPrNumber(prNumber);
676
828
  if (!issue)
677
829
  return;
830
+ if (!this.isPatchRelayOwnedPr(issue))
831
+ return;
678
832
  this.feed?.publish({
679
833
  level: "info",
680
834
  kind: "comment",
@@ -695,6 +849,7 @@ export class GitHubWebhookHandler {
695
849
  input: `GitHub PR comment from ${author}:\n\n${body}`,
696
850
  });
697
851
  this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
852
+ return;
698
853
  }
699
854
  catch (error) {
700
855
  const msg = error instanceof Error ? error.message : String(error);
@@ -702,8 +857,50 @@ export class GitHubWebhookHandler {
702
857
  }
703
858
  }
704
859
  }
860
+ this.db.appendIssueSessionEvent({
861
+ projectId: issue.projectId,
862
+ linearIssueId: issue.linearIssueId,
863
+ eventType: "followup_comment",
864
+ eventJson: JSON.stringify({ body, author }),
865
+ });
866
+ this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
867
+ }
868
+ peekPendingSessionWakeRunType(projectId, issueId) {
869
+ return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
870
+ }
871
+ enqueuePendingSessionWake(projectId, issueId) {
872
+ const wake = this.db.peekIssueSessionWake(projectId, issueId);
873
+ if (!wake) {
874
+ return undefined;
875
+ }
876
+ this.enqueueIssue(projectId, issueId);
877
+ return wake.runType;
878
+ }
879
+ isPatchRelayOwnedPr(issue) {
880
+ const author = normalizeAuthorLogin(issue.prAuthorLogin);
881
+ if (author) {
882
+ if (this.patchRelayAuthorLogins.size > 0) {
883
+ return this.patchRelayAuthorLogins.has(author);
884
+ }
885
+ return author.includes("patchrelay");
886
+ }
887
+ // Transitional fallback for rows written before author tracking existed.
888
+ return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
705
889
  }
706
890
  }
891
+ function normalizeAuthorLogin(login) {
892
+ const normalized = login?.trim().toLowerCase();
893
+ return normalized ? normalized : undefined;
894
+ }
895
+ function resolvePatchRelayAuthorLoginsFromEnv() {
896
+ return [
897
+ process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
898
+ process.env.PATCHRELAY_GITHUB_BOT_NAME,
899
+ ]
900
+ .flatMap((value) => (value ?? "").split(","))
901
+ .map((value) => normalizeAuthorLogin(value))
902
+ .filter((value) => Boolean(value));
903
+ }
707
904
  function resolveCheckClass(checkName, project) {
708
905
  if (!checkName || !project)
709
906
  return "code";
@@ -64,6 +64,10 @@ function normalizePullRequestEvent(payload, repoFullName) {
64
64
  prNumber: pr.number,
65
65
  prUrl: pr.html_url,
66
66
  prState,
67
+ prAuthorLogin: pr.user?.login ?? undefined,
68
+ prLabels: Array.isArray(pr.labels)
69
+ ? pr.labels.map((label) => label?.name).filter((label) => typeof label === "string" && label.trim().length > 0)
70
+ : undefined,
67
71
  };
68
72
  }
69
73
  function normalizePullRequestReviewEvent(payload, repoFullName) {
@@ -97,6 +101,7 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
97
101
  prNumber: pr.number,
98
102
  prUrl: pr.html_url,
99
103
  prState: "open",
104
+ prAuthorLogin: pr.user?.login ?? undefined,
100
105
  reviewState,
101
106
  reviewBody: review.body ?? undefined,
102
107
  reviewerName: review.user?.login ?? undefined,