patchrelay 0.35.11 → 0.35.13
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 +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +19 -1
- package/dist/cli/commands/issues.js +18 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +160 -47
- package/dist/cli/formatters/text.js +51 -90
- package/dist/cli/help.js +15 -8
- package/dist/cli/index.js +3 -58
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +21 -12
- package/dist/cli/watch/HelpBar.js +3 -3
- package/dist/cli/watch/IssueDetailView.js +63 -130
- package/dist/cli/watch/IssueRow.js +82 -27
- package/dist/cli/watch/StatusBar.js +8 -4
- package/dist/cli/watch/detail-rows.js +589 -0
- package/dist/cli/watch/render-rich-text.js +226 -0
- package/dist/cli/watch/state-visualization.js +48 -23
- package/dist/cli/watch/timeline-builder.js +2 -1
- package/dist/cli/watch/use-detail-stream.js +10 -104
- package/dist/cli/watch/use-watch-stream.js +11 -102
- package/dist/cli/watch/watch-state.js +129 -56
- package/dist/codex-thread-utils.js +3 -0
- package/dist/db/migrations.js +239 -2
- package/dist/db.js +628 -39
- package/dist/github-app-token.js +7 -0
- package/dist/github-failure-context.js +44 -1
- package/dist/github-rollup.js +47 -0
- package/dist/github-webhook-handler.js +423 -52
- package/dist/github-webhooks.js +7 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +268 -76
- package/dist/issue-query-service.js +221 -129
- package/dist/issue-session-events.js +151 -0
- package/dist/issue-session.js +99 -0
- package/dist/linear-client.js +39 -25
- package/dist/linear-session-reporting.js +12 -0
- package/dist/linear-session-sync.js +253 -24
- package/dist/linear-workflow.js +33 -0
- package/dist/merge-queue-protocol.js +0 -51
- package/dist/preflight.js +1 -4
- package/dist/queue-health-monitor.js +11 -7
- package/dist/run-orchestrator.js +1364 -147
- package/dist/run-reporting.js +5 -3
- package/dist/service.js +279 -102
- package/dist/status-note.js +56 -0
- package/dist/waiting-reason.js +65 -0
- package/dist/webhook-handler.js +270 -79
- package/package.json +3 -2
- package/dist/cli/commands/feed.js +0 -60
- package/dist/cli/watch/FeedView.js +0 -28
- package/dist/cli/watch/use-feed-stream.js +0 -92
|
@@ -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 {
|
|
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,7 +32,9 @@ export class GitHubWebhookHandler {
|
|
|
30
32
|
feed;
|
|
31
33
|
failureContextResolver;
|
|
32
34
|
ciSnapshotResolver;
|
|
33
|
-
|
|
35
|
+
fetchImpl;
|
|
36
|
+
patchRelayAuthorLogins = new Set();
|
|
37
|
+
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
|
|
34
38
|
this.config = config;
|
|
35
39
|
this.db = db;
|
|
36
40
|
this.linearProvider = linearProvider;
|
|
@@ -40,6 +44,19 @@ export class GitHubWebhookHandler {
|
|
|
40
44
|
this.feed = feed;
|
|
41
45
|
this.failureContextResolver = failureContextResolver;
|
|
42
46
|
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
47
|
+
this.fetchImpl = fetchImpl;
|
|
48
|
+
for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
|
|
49
|
+
this.patchRelayAuthorLogins.add(login);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
setPatchRelayAuthorLogins(logins) {
|
|
53
|
+
this.patchRelayAuthorLogins.clear();
|
|
54
|
+
for (const login of logins) {
|
|
55
|
+
const normalized = normalizeAuthorLogin(login);
|
|
56
|
+
if (normalized) {
|
|
57
|
+
this.patchRelayAuthorLogins.add(normalized);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
43
60
|
}
|
|
44
61
|
async acceptGitHubWebhook(params) {
|
|
45
62
|
// Deduplicate
|
|
@@ -126,44 +143,30 @@ export class GitHubWebhookHandler {
|
|
|
126
143
|
...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
|
|
127
144
|
...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
|
|
128
145
|
...(event.prState !== undefined ? { prState: event.prState } : {}),
|
|
146
|
+
...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
|
|
147
|
+
...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
|
|
129
148
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
130
149
|
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
131
150
|
});
|
|
132
151
|
await this.updateCiSnapshot(issue, event, project);
|
|
133
152
|
await this.updateFailureProvenance(issue, event, project);
|
|
134
|
-
|
|
153
|
+
const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
154
|
+
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
135
155
|
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
136
156
|
const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
137
|
-
const newState =
|
|
138
|
-
prReviewState: afterMetadata.prReviewState,
|
|
139
|
-
activeRunId: afterMetadata.activeRunId,
|
|
140
|
-
});
|
|
157
|
+
const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
|
|
141
158
|
// Only transition and notify when the state actually changes.
|
|
142
159
|
// Multiple check_suite events can arrive for the same outcome.
|
|
143
160
|
if (newState && newState !== afterMetadata.factoryState) {
|
|
144
|
-
this.db.
|
|
161
|
+
this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
145
162
|
projectId: issue.projectId,
|
|
146
163
|
linearIssueId: issue.linearIssueId,
|
|
147
164
|
factoryState: newState,
|
|
148
165
|
});
|
|
149
|
-
if (newState === "awaiting_queue") {
|
|
150
|
-
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
|
|
151
|
-
}
|
|
152
166
|
this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
153
167
|
const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
154
168
|
void this.emitLinearActivity(transitionedIssue, newState, event);
|
|
155
169
|
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
170
|
}
|
|
168
171
|
}
|
|
169
172
|
// Re-read issue after all upserts so reactive run logic sees current state
|
|
@@ -171,7 +174,7 @@ export class GitHubWebhookHandler {
|
|
|
171
174
|
// Reset repair counters on new push — but only when no repair run is active,
|
|
172
175
|
// since Codex pushes during repair and resetting mid-run would bypass budgets.
|
|
173
176
|
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
174
|
-
this.db.
|
|
177
|
+
this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
175
178
|
projectId: issue.projectId,
|
|
176
179
|
linearIssueId: issue.linearIssueId,
|
|
177
180
|
ciRepairAttempts: 0,
|
|
@@ -192,6 +195,7 @@ export class GitHubWebhookHandler {
|
|
|
192
195
|
lastAttemptedFailureHeadSha: null,
|
|
193
196
|
lastAttemptedFailureSignature: null,
|
|
194
197
|
});
|
|
198
|
+
await this.maybeRequestRereviewAfterPush(freshIssue, event, project);
|
|
195
199
|
}
|
|
196
200
|
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
197
201
|
this.feed?.publish({
|
|
@@ -207,12 +211,28 @@ export class GitHubWebhookHandler {
|
|
|
207
211
|
// Queue eviction check runs bypass the metadata-only filter because
|
|
208
212
|
// they're individual check_run events (not check_suite), but they
|
|
209
213
|
// must drive state transitions.
|
|
210
|
-
if (
|
|
214
|
+
if (queueEvictionCheck || this.isGateCheckEvent(event, project)) {
|
|
211
215
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
212
216
|
}
|
|
213
217
|
else if (!isMetadataOnlyCheckEvent(event)) {
|
|
214
218
|
await this.maybeEnqueueReactiveRun(freshIssue, event, project);
|
|
215
219
|
}
|
|
220
|
+
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
221
|
+
await this.handleTerminalPrEvent(freshIssue, event);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
resolveFactoryStateForEvent(issue, event, project) {
|
|
225
|
+
if (event.triggerEvent === "check_failed"
|
|
226
|
+
&& this.isQueueEvictionFailure(issue, event, project)
|
|
227
|
+
&& issue.prState === "open"
|
|
228
|
+
&& issue.activeRunId === undefined
|
|
229
|
+
&& !TERMINAL_STATES.has(issue.factoryState)) {
|
|
230
|
+
return "repairing_queue";
|
|
231
|
+
}
|
|
232
|
+
return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
|
|
233
|
+
prReviewState: issue.prReviewState,
|
|
234
|
+
activeRunId: issue.activeRunId,
|
|
235
|
+
});
|
|
216
236
|
}
|
|
217
237
|
async updateCiSnapshot(issue, event, project) {
|
|
218
238
|
if (event.triggerEvent === "pr_merged") {
|
|
@@ -279,6 +299,7 @@ export class GitHubWebhookHandler {
|
|
|
279
299
|
this.db.upsertIssue({
|
|
280
300
|
projectId: issue.projectId,
|
|
281
301
|
linearIssueId: issue.linearIssueId,
|
|
302
|
+
prCheckStatus: snapshot.gateCheckStatus,
|
|
282
303
|
lastGitHubCiSnapshotHeadSha: snapshot.headSha,
|
|
283
304
|
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
|
|
284
305
|
lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
|
|
@@ -294,6 +315,18 @@ export class GitHubWebhookHandler {
|
|
|
294
315
|
// merge_group_failed after pr_merged) must not resurrect done issues.
|
|
295
316
|
if (TERMINAL_STATES.has(issue.factoryState))
|
|
296
317
|
return;
|
|
318
|
+
if (!this.isPatchRelayOwnedPr(issue)) {
|
|
319
|
+
this.feed?.publish({
|
|
320
|
+
level: "info",
|
|
321
|
+
kind: "github",
|
|
322
|
+
issueKey: issue.issueKey,
|
|
323
|
+
projectId: issue.projectId,
|
|
324
|
+
stage: issue.factoryState,
|
|
325
|
+
status: "ignored_non_patchrelay_pr",
|
|
326
|
+
summary: `Ignored ${event.triggerEvent} on non-PatchRelay-owned PR`,
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
297
330
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
298
331
|
// External merge queue eviction: react only to the configured check
|
|
299
332
|
// name, not to any CI failure. Regular CI failures still get ci_repair.
|
|
@@ -303,14 +336,10 @@ export class GitHubWebhookHandler {
|
|
|
303
336
|
if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
|
|
304
337
|
return;
|
|
305
338
|
}
|
|
339
|
+
const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
306
340
|
this.db.upsertIssue({
|
|
307
341
|
projectId: issue.projectId,
|
|
308
342
|
linearIssueId: issue.linearIssueId,
|
|
309
|
-
pendingRunType: "queue_repair",
|
|
310
|
-
pendingRunContextJson: JSON.stringify({
|
|
311
|
-
...queueRepairContext,
|
|
312
|
-
...failureContext,
|
|
313
|
-
}),
|
|
314
343
|
lastGitHubFailureSource: "queue_eviction",
|
|
315
344
|
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
316
345
|
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
@@ -321,8 +350,20 @@ export class GitHubWebhookHandler {
|
|
|
321
350
|
lastQueueSignalAt: new Date().toISOString(),
|
|
322
351
|
lastQueueIncidentJson: JSON.stringify(queueRepairContext),
|
|
323
352
|
});
|
|
324
|
-
this.db.
|
|
325
|
-
|
|
353
|
+
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
354
|
+
projectId: issue.projectId,
|
|
355
|
+
linearIssueId: issue.linearIssueId,
|
|
356
|
+
eventType: "merge_steward_incident",
|
|
357
|
+
eventJson: JSON.stringify({
|
|
358
|
+
...queueRepairContext,
|
|
359
|
+
...failureContext,
|
|
360
|
+
}),
|
|
361
|
+
dedupeKey: failureContext.failureSignature,
|
|
362
|
+
});
|
|
363
|
+
this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
364
|
+
const queuedRunType = hadPendingWake
|
|
365
|
+
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
366
|
+
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
326
367
|
this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
327
368
|
this.feed?.publish({
|
|
328
369
|
level: "warn",
|
|
@@ -331,7 +372,7 @@ export class GitHubWebhookHandler {
|
|
|
331
372
|
projectId: issue.projectId,
|
|
332
373
|
stage: "repairing_queue",
|
|
333
374
|
status: "queue_repair_queued",
|
|
334
|
-
summary:
|
|
375
|
+
summary: `${queuedRunType ?? "queue_repair"} queued after external failure from ${event.checkName}`,
|
|
335
376
|
detail: queueRepairContext.incidentSummary ?? queueRepairContext.incidentUrl ?? event.checkUrl,
|
|
336
377
|
});
|
|
337
378
|
}
|
|
@@ -352,16 +393,11 @@ export class GitHubWebhookHandler {
|
|
|
352
393
|
if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
|
|
353
394
|
return;
|
|
354
395
|
}
|
|
396
|
+
const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
355
397
|
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
356
398
|
this.db.upsertIssue({
|
|
357
399
|
projectId: issue.projectId,
|
|
358
400
|
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
401
|
lastGitHubFailureSource: "branch_ci",
|
|
366
402
|
lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
|
|
367
403
|
lastGitHubFailureSignature: failureContext.failureSignature ?? null,
|
|
@@ -371,8 +407,21 @@ export class GitHubWebhookHandler {
|
|
|
371
407
|
lastGitHubFailureAt: new Date().toISOString(),
|
|
372
408
|
lastQueueIncidentJson: null,
|
|
373
409
|
});
|
|
374
|
-
this.db.
|
|
375
|
-
|
|
410
|
+
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
411
|
+
projectId: issue.projectId,
|
|
412
|
+
linearIssueId: issue.linearIssueId,
|
|
413
|
+
eventType: "settled_red_ci",
|
|
414
|
+
eventJson: JSON.stringify({
|
|
415
|
+
...failureContext,
|
|
416
|
+
checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
417
|
+
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
418
|
+
}),
|
|
419
|
+
dedupeKey: failureContext.failureSignature,
|
|
420
|
+
});
|
|
421
|
+
this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
422
|
+
const queuedRunType = hadPendingWake
|
|
423
|
+
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
424
|
+
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
376
425
|
this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
377
426
|
this.feed?.publish({
|
|
378
427
|
level: "warn",
|
|
@@ -381,24 +430,145 @@ export class GitHubWebhookHandler {
|
|
|
381
430
|
projectId: issue.projectId,
|
|
382
431
|
stage: "repairing_ci",
|
|
383
432
|
status: "ci_repair_queued",
|
|
384
|
-
summary:
|
|
433
|
+
summary: `${queuedRunType ?? "ci_repair"} queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
|
|
385
434
|
detail: summarizeGitHubFailureContext(failureContext),
|
|
386
435
|
});
|
|
387
436
|
}
|
|
388
437
|
}
|
|
389
438
|
if (event.triggerEvent === "review_changes_requested") {
|
|
390
|
-
this.db.
|
|
439
|
+
const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
440
|
+
const reviewComments = await this.fetchReviewCommentsForEvent(event).catch((error) => {
|
|
441
|
+
this.logger.warn({
|
|
442
|
+
issueKey: issue.issueKey,
|
|
443
|
+
prNumber: event.prNumber,
|
|
444
|
+
reviewId: event.reviewId,
|
|
445
|
+
error: error instanceof Error ? error.message : String(error),
|
|
446
|
+
}, "Failed to fetch inline review comments for requested-changes event");
|
|
447
|
+
return undefined;
|
|
448
|
+
});
|
|
449
|
+
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
391
450
|
projectId: issue.projectId,
|
|
392
451
|
linearIssueId: issue.linearIssueId,
|
|
393
|
-
|
|
394
|
-
|
|
452
|
+
eventType: "review_changes_requested",
|
|
453
|
+
eventJson: JSON.stringify({
|
|
395
454
|
reviewBody: event.reviewBody,
|
|
455
|
+
reviewCommitId: event.reviewCommitId,
|
|
456
|
+
reviewId: event.reviewId,
|
|
457
|
+
reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
|
|
396
458
|
reviewerName: event.reviewerName,
|
|
459
|
+
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
397
460
|
}),
|
|
461
|
+
dedupeKey: [
|
|
462
|
+
"review_changes_requested",
|
|
463
|
+
issue.prHeadSha ?? event.headSha ?? "unknown-sha",
|
|
464
|
+
event.reviewerName ?? "unknown-reviewer",
|
|
465
|
+
].join("::"),
|
|
398
466
|
});
|
|
399
|
-
this.db.
|
|
400
|
-
|
|
467
|
+
this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
468
|
+
const queuedRunType = hadPendingWake
|
|
469
|
+
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
470
|
+
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
401
471
|
this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
|
|
472
|
+
this.feed?.publish({
|
|
473
|
+
level: "warn",
|
|
474
|
+
kind: "github",
|
|
475
|
+
issueKey: issue.issueKey,
|
|
476
|
+
projectId: issue.projectId,
|
|
477
|
+
stage: "changes_requested",
|
|
478
|
+
status: "review_fix_queued",
|
|
479
|
+
summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
|
|
480
|
+
detail: reviewComments && reviewComments.length > 0
|
|
481
|
+
? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
|
|
482
|
+
: event.reviewBody?.slice(0, 200) ?? event.reviewerName,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async handleTerminalPrEvent(issue, event) {
|
|
487
|
+
const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
|
|
488
|
+
this.db.appendIssueSessionEvent({
|
|
489
|
+
projectId: issue.projectId,
|
|
490
|
+
linearIssueId: issue.linearIssueId,
|
|
491
|
+
eventType,
|
|
492
|
+
dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
|
|
493
|
+
});
|
|
494
|
+
this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
495
|
+
const run = issue.activeRunId ? this.db.getRun(issue.activeRunId) : undefined;
|
|
496
|
+
if (run?.threadId && run.turnId) {
|
|
497
|
+
try {
|
|
498
|
+
await this.codex.steerTurn({
|
|
499
|
+
threadId: run.threadId,
|
|
500
|
+
turnId: run.turnId,
|
|
501
|
+
input: event.triggerEvent === "pr_merged"
|
|
502
|
+
? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
|
|
503
|
+
: "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
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");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const commitTerminalUpdate = () => {
|
|
511
|
+
if (run) {
|
|
512
|
+
this.db.finishRun(run.id, {
|
|
513
|
+
status: "released",
|
|
514
|
+
failureReason: event.triggerEvent === "pr_merged"
|
|
515
|
+
? "Pull request merged during active run"
|
|
516
|
+
: "Pull request closed during active run",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
this.db.upsertIssue({
|
|
520
|
+
projectId: issue.projectId,
|
|
521
|
+
linearIssueId: issue.linearIssueId,
|
|
522
|
+
activeRunId: null,
|
|
523
|
+
factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
const activeLease = this.db.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
|
|
527
|
+
if (activeLease) {
|
|
528
|
+
this.db.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
this.db.transaction(commitTerminalUpdate);
|
|
532
|
+
}
|
|
533
|
+
this.db.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
534
|
+
const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
535
|
+
if (event.triggerEvent === "pr_merged") {
|
|
536
|
+
await this.completeLinearIssueAfterMerge(updatedIssue);
|
|
537
|
+
}
|
|
538
|
+
void this.syncLinearSession(updatedIssue);
|
|
539
|
+
}
|
|
540
|
+
async completeLinearIssueAfterMerge(issue) {
|
|
541
|
+
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
542
|
+
if (!linear)
|
|
543
|
+
return;
|
|
544
|
+
try {
|
|
545
|
+
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
546
|
+
const targetState = resolvePreferredCompletedLinearState(liveIssue);
|
|
547
|
+
if (!targetState) {
|
|
548
|
+
this.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
552
|
+
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
553
|
+
this.db.upsertIssue({
|
|
554
|
+
projectId: issue.projectId,
|
|
555
|
+
linearIssueId: issue.linearIssueId,
|
|
556
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
557
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
562
|
+
this.db.upsertIssue({
|
|
563
|
+
projectId: issue.projectId,
|
|
564
|
+
linearIssueId: issue.linearIssueId,
|
|
565
|
+
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
566
|
+
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
571
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
|
|
402
572
|
}
|
|
403
573
|
}
|
|
404
574
|
async updateFailureProvenance(issue, event, project) {
|
|
@@ -511,8 +681,9 @@ export class GitHubWebhookHandler {
|
|
|
511
681
|
: typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
|
|
512
682
|
if (!signature)
|
|
513
683
|
return false;
|
|
514
|
-
|
|
515
|
-
|
|
684
|
+
const pendingWake = this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
|
|
685
|
+
if (pendingWake?.runType === runType) {
|
|
686
|
+
const existing = pendingWake.context;
|
|
516
687
|
if (existing?.failureSignature === signature
|
|
517
688
|
&& (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
|
|
518
689
|
this.feed?.publish({
|
|
@@ -544,10 +715,10 @@ export class GitHubWebhookHandler {
|
|
|
544
715
|
}
|
|
545
716
|
getGateCheckNames(project) {
|
|
546
717
|
const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
547
|
-
return configured.length > 0 ? configured :
|
|
718
|
+
return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
|
|
548
719
|
}
|
|
549
720
|
getPrimaryGateCheckName(project) {
|
|
550
|
-
return this.getGateCheckNames(project)[0] ?? "
|
|
721
|
+
return this.getGateCheckNames(project)[0] ?? "verify";
|
|
551
722
|
}
|
|
552
723
|
isGateCheckEvent(event, project) {
|
|
553
724
|
if (event.eventSource !== "check_run" || !event.checkName)
|
|
@@ -562,8 +733,7 @@ export class GitHubWebhookHandler {
|
|
|
562
733
|
}
|
|
563
734
|
isQueueEvictionFailure(issue, event, project) {
|
|
564
735
|
const protocol = resolveMergeQueueProtocol(project);
|
|
565
|
-
return
|
|
566
|
-
&& event.eventSource === "check_run"
|
|
736
|
+
return event.eventSource === "check_run"
|
|
567
737
|
&& event.checkName === protocol.evictionCheckName;
|
|
568
738
|
}
|
|
569
739
|
isSettledBranchFailure(issue, event, project) {
|
|
@@ -653,6 +823,64 @@ export class GitHubWebhookHandler {
|
|
|
653
823
|
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
|
|
654
824
|
}
|
|
655
825
|
}
|
|
826
|
+
async fetchReviewCommentsForEvent(event) {
|
|
827
|
+
if (event.triggerEvent !== "review_changes_requested") {
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
834
|
+
if (!token) {
|
|
835
|
+
this.logger.debug({ prNumber: event.prNumber, reviewId: event.reviewId }, "Skipping inline review comment fetch because no GitHub API token is available");
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
839
|
+
if (!owner || !repo) {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
const response = await this.fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
|
|
843
|
+
headers: {
|
|
844
|
+
Authorization: `Bearer ${token}`,
|
|
845
|
+
Accept: "application/vnd.github+json",
|
|
846
|
+
"User-Agent": "patchrelay",
|
|
847
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
if (!response.ok) {
|
|
851
|
+
throw new Error(`GitHub review comment fetch failed (${response.status})`);
|
|
852
|
+
}
|
|
853
|
+
const payload = await response.json();
|
|
854
|
+
if (!Array.isArray(payload)) {
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const comments = [];
|
|
858
|
+
for (const entry of payload) {
|
|
859
|
+
if (!entry || typeof entry !== "object")
|
|
860
|
+
continue;
|
|
861
|
+
const record = entry;
|
|
862
|
+
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
863
|
+
const id = typeof record.id === "number" ? record.id : undefined;
|
|
864
|
+
if (!body || id === undefined)
|
|
865
|
+
continue;
|
|
866
|
+
comments.push({
|
|
867
|
+
id,
|
|
868
|
+
body,
|
|
869
|
+
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
870
|
+
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
871
|
+
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
872
|
+
...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
|
|
873
|
+
...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
|
|
874
|
+
...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
|
|
875
|
+
...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
|
|
876
|
+
...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
|
|
877
|
+
...(typeof record.user?.login === "string"
|
|
878
|
+
? { authorLogin: String(record.user.login) }
|
|
879
|
+
: {}),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
return comments;
|
|
883
|
+
}
|
|
656
884
|
async handlePrComment(payload) {
|
|
657
885
|
if (payload.action !== "created")
|
|
658
886
|
return;
|
|
@@ -675,6 +903,8 @@ export class GitHubWebhookHandler {
|
|
|
675
903
|
const issue = this.db.getIssueByPrNumber(prNumber);
|
|
676
904
|
if (!issue)
|
|
677
905
|
return;
|
|
906
|
+
if (!this.isPatchRelayOwnedPr(issue))
|
|
907
|
+
return;
|
|
678
908
|
this.feed?.publish({
|
|
679
909
|
level: "info",
|
|
680
910
|
kind: "comment",
|
|
@@ -695,6 +925,7 @@ export class GitHubWebhookHandler {
|
|
|
695
925
|
input: `GitHub PR comment from ${author}:\n\n${body}`,
|
|
696
926
|
});
|
|
697
927
|
this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
|
|
928
|
+
return;
|
|
698
929
|
}
|
|
699
930
|
catch (error) {
|
|
700
931
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -702,7 +933,147 @@ export class GitHubWebhookHandler {
|
|
|
702
933
|
}
|
|
703
934
|
}
|
|
704
935
|
}
|
|
936
|
+
this.db.appendIssueSessionEvent({
|
|
937
|
+
projectId: issue.projectId,
|
|
938
|
+
linearIssueId: issue.linearIssueId,
|
|
939
|
+
eventType: "followup_comment",
|
|
940
|
+
eventJson: JSON.stringify({ body, author }),
|
|
941
|
+
});
|
|
942
|
+
this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
943
|
+
}
|
|
944
|
+
async maybeRequestRereviewAfterPush(issue, event, project) {
|
|
945
|
+
if (event.triggerEvent !== "pr_synchronize")
|
|
946
|
+
return;
|
|
947
|
+
if (issue.activeRunId !== undefined)
|
|
948
|
+
return;
|
|
949
|
+
if (issue.prState !== "open" || issue.prReviewState !== "changes_requested" || issue.prNumber === undefined)
|
|
950
|
+
return;
|
|
951
|
+
if (!this.isPatchRelayOwnedPr(issue))
|
|
952
|
+
return;
|
|
953
|
+
const reviewerName = this.findLatestRequestedChangesReviewer(issue.projectId, issue.linearIssueId);
|
|
954
|
+
if (!reviewerName) {
|
|
955
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no prior reviewer was recorded");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
|
|
959
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
960
|
+
if (!token) {
|
|
961
|
+
this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Skipping auto re-review request because no GitHub token is available");
|
|
962
|
+
this.feed?.publish({
|
|
963
|
+
level: "warn",
|
|
964
|
+
kind: "github",
|
|
965
|
+
issueKey: issue.issueKey,
|
|
966
|
+
projectId: issue.projectId,
|
|
967
|
+
stage: issue.factoryState,
|
|
968
|
+
status: "rereview_request_skipped",
|
|
969
|
+
summary: `Skipped auto re-review request for PR #${issue.prNumber}`,
|
|
970
|
+
detail: "No GitHub token available for requested_reviewers API call",
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const response = await this.fetchImpl(`https://api.github.com/repos/${repoFullName}/pulls/${issue.prNumber}/requested_reviewers`, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: {
|
|
977
|
+
authorization: `Bearer ${token}`,
|
|
978
|
+
accept: "application/vnd.github+json",
|
|
979
|
+
"content-type": "application/json",
|
|
980
|
+
"user-agent": "patchrelay",
|
|
981
|
+
},
|
|
982
|
+
body: JSON.stringify({ reviewers: [reviewerName] }),
|
|
983
|
+
});
|
|
984
|
+
if (!response.ok) {
|
|
985
|
+
const detail = await this.readGitHubErrorResponse(response);
|
|
986
|
+
this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName, status: response.status, detail }, "Failed to auto request re-review after push");
|
|
987
|
+
this.feed?.publish({
|
|
988
|
+
level: "warn",
|
|
989
|
+
kind: "github",
|
|
990
|
+
issueKey: issue.issueKey,
|
|
991
|
+
projectId: issue.projectId,
|
|
992
|
+
stage: issue.factoryState,
|
|
993
|
+
status: "rereview_request_failed",
|
|
994
|
+
summary: `Failed to auto request re-review from ${reviewerName}`,
|
|
995
|
+
detail,
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewerName }, "Auto requested re-review after push");
|
|
1000
|
+
this.feed?.publish({
|
|
1001
|
+
level: "info",
|
|
1002
|
+
kind: "github",
|
|
1003
|
+
issueKey: issue.issueKey,
|
|
1004
|
+
projectId: issue.projectId,
|
|
1005
|
+
stage: issue.factoryState,
|
|
1006
|
+
status: "rereview_requested",
|
|
1007
|
+
summary: `Requested re-review from ${reviewerName} on PR #${issue.prNumber}`,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
findLatestRequestedChangesReviewer(projectId, linearIssueId) {
|
|
1011
|
+
const event = this.db
|
|
1012
|
+
.listIssueSessionEvents(projectId, linearIssueId)
|
|
1013
|
+
.findLast((candidate) => candidate.eventType === "review_changes_requested");
|
|
1014
|
+
if (!event?.eventJson)
|
|
1015
|
+
return undefined;
|
|
1016
|
+
const payload = safeJsonParse(event.eventJson);
|
|
1017
|
+
return typeof payload?.reviewerName === "string" && payload.reviewerName.trim()
|
|
1018
|
+
? payload.reviewerName.trim()
|
|
1019
|
+
: undefined;
|
|
1020
|
+
}
|
|
1021
|
+
async readGitHubErrorResponse(response) {
|
|
1022
|
+
try {
|
|
1023
|
+
const payload = await response.json();
|
|
1024
|
+
if (typeof payload?.message === "string" && payload.message.trim()) {
|
|
1025
|
+
return payload.message.trim();
|
|
1026
|
+
}
|
|
1027
|
+
if (payload?.errors !== undefined) {
|
|
1028
|
+
return JSON.stringify(payload.errors);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// Fall through to status text.
|
|
1033
|
+
}
|
|
1034
|
+
return response.statusText || `GitHub API responded with ${response.status}`;
|
|
1035
|
+
}
|
|
1036
|
+
peekPendingSessionWakeRunType(projectId, issueId) {
|
|
1037
|
+
return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
|
|
1038
|
+
}
|
|
1039
|
+
enqueuePendingSessionWake(projectId, issueId) {
|
|
1040
|
+
const wake = this.db.peekIssueSessionWake(projectId, issueId);
|
|
1041
|
+
if (!wake) {
|
|
1042
|
+
return undefined;
|
|
1043
|
+
}
|
|
1044
|
+
this.enqueueIssue(projectId, issueId);
|
|
1045
|
+
return wake.runType;
|
|
1046
|
+
}
|
|
1047
|
+
isPatchRelayOwnedPr(issue) {
|
|
1048
|
+
const author = normalizeAuthorLogin(issue.prAuthorLogin);
|
|
1049
|
+
if (author) {
|
|
1050
|
+
if (this.patchRelayAuthorLogins.size > 0) {
|
|
1051
|
+
return this.patchRelayAuthorLogins.has(author);
|
|
1052
|
+
}
|
|
1053
|
+
return author.includes("patchrelay");
|
|
1054
|
+
}
|
|
1055
|
+
// Transitional fallback for rows written before author tracking existed.
|
|
1056
|
+
return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function normalizeAuthorLogin(login) {
|
|
1060
|
+
const normalized = login?.trim().toLowerCase();
|
|
1061
|
+
return normalized ? normalized : undefined;
|
|
1062
|
+
}
|
|
1063
|
+
function resolvePatchRelayAuthorLoginsFromEnv() {
|
|
1064
|
+
return [
|
|
1065
|
+
process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
|
|
1066
|
+
process.env.PATCHRELAY_GITHUB_BOT_NAME,
|
|
1067
|
+
]
|
|
1068
|
+
.flatMap((value) => (value ?? "").split(","))
|
|
1069
|
+
.map((value) => normalizeAuthorLogin(value))
|
|
1070
|
+
.filter((value) => Boolean(value));
|
|
1071
|
+
}
|
|
1072
|
+
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
1073
|
+
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
|
1074
|
+
return undefined;
|
|
705
1075
|
}
|
|
1076
|
+
return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
|
|
706
1077
|
}
|
|
707
1078
|
function resolveCheckClass(checkName, project) {
|
|
708
1079
|
if (!checkName || !project)
|