patchrelay 0.35.11 → 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.
- package/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +0 -1
- package/dist/cli/commands/issues.js +2 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +110 -47
- package/dist/cli/formatters/text.js +6 -90
- package/dist/cli/help.js +3 -8
- package/dist/cli/index.js +0 -48
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +1 -12
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +57 -26
- package/dist/cli/watch/IssueRow.js +71 -27
- package/dist/cli/watch/StatusBar.js +7 -4
- 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 +18 -50
- 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 +248 -51
- package/dist/github-webhooks.js +5 -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 +1295 -146
- 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 +1 -1
- 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
package/dist/github-app-token.js
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
325
|
-
|
|
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:
|
|
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.
|
|
375
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
394
|
-
|
|
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.
|
|
400
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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 :
|
|
700
|
+
return configured.length > 0 ? configured : DEFAULT_GATE_CHECK_NAMES;
|
|
548
701
|
}
|
|
549
702
|
getPrimaryGateCheckName(project) {
|
|
550
|
-
return this.getGateCheckNames(project)[0] ?? "
|
|
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
|
|
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";
|
package/dist/github-webhooks.js
CHANGED
|
@@ -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,
|