patchrelay 0.52.6 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/issues.js +17 -1
- package/dist/cli/formatters/text.js +9 -0
- package/dist/cli/help.js +3 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/operator-client.js +9 -0
- package/dist/codex-app-server.js +99 -73
- package/dist/prompting/patchrelay.js +4 -0
- package/dist/reactive-publish-delta.js +30 -0
- package/dist/reactive-run-policy.js +80 -0
- package/dist/run-completion-policy.js +3 -0
- package/dist/run-finalizer.js +13 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
2
2
|
import { getRunTypeFlag } from "../args.js";
|
|
3
3
|
import { CliUsageError } from "../errors.js";
|
|
4
4
|
import { formatJson } from "../formatters/json.js";
|
|
5
|
-
import { formatAudit, formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatTranscriptSource, formatWorktree } from "../formatters/text.js";
|
|
5
|
+
import { formatAudit, formatClose, formatInspect, formatList, formatLive, formatOpen, formatPrompt, formatRetry, formatSessionHistory, formatTranscriptSource, formatWorktree } from "../formatters/text.js";
|
|
6
6
|
import { buildOpenCommand } from "../interactive.js";
|
|
7
7
|
import { writeOutput } from "../output.js";
|
|
8
8
|
export async function handleIssueCommand(params) {
|
|
@@ -40,6 +40,8 @@ export async function handleIssueCommand(params) {
|
|
|
40
40
|
return await handleAuditCommand(nested);
|
|
41
41
|
case "transcript-source":
|
|
42
42
|
return await handleTranscriptSourceCommand(nested);
|
|
43
|
+
case "prompt":
|
|
44
|
+
return await handlePromptCommand(nested);
|
|
43
45
|
case "retry":
|
|
44
46
|
return await handleRetryCommand(nested);
|
|
45
47
|
case "close":
|
|
@@ -190,6 +192,20 @@ export async function handleRetryCommand(params) {
|
|
|
190
192
|
writeOutput(params.stdout, params.json ? formatJson(result) : formatRetry(result));
|
|
191
193
|
return 0;
|
|
192
194
|
}
|
|
195
|
+
export async function handlePromptCommand(params) {
|
|
196
|
+
const issueKey = params.commandArgs[0];
|
|
197
|
+
if (!issueKey) {
|
|
198
|
+
throw new Error("prompt requires <issueKey>.");
|
|
199
|
+
}
|
|
200
|
+
const text = params.commandArgs.slice(1).join(" ").trim();
|
|
201
|
+
if (!text) {
|
|
202
|
+
throw new Error("prompt requires <text>.");
|
|
203
|
+
}
|
|
204
|
+
const result = await params.data.promptIssue(issueKey, text);
|
|
205
|
+
const payload = { issueKey, ...result };
|
|
206
|
+
writeOutput(params.stdout, params.json ? formatJson(payload) : formatPrompt(payload));
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
193
209
|
export async function handleCloseCommand(params) {
|
|
194
210
|
const issueKey = params.commandArgs[0];
|
|
195
211
|
if (!issueKey) {
|
|
@@ -84,6 +84,15 @@ export function formatRetry(result) {
|
|
|
84
84
|
.filter(Boolean)
|
|
85
85
|
.join("\n")}\n`;
|
|
86
86
|
}
|
|
87
|
+
export function formatPrompt(result) {
|
|
88
|
+
return `${[
|
|
89
|
+
value("Issue", result.issueKey),
|
|
90
|
+
value("Delivered", result.delivered ? "yes" : "no"),
|
|
91
|
+
result.queued ? value("Queued", "yes") : undefined,
|
|
92
|
+
]
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join("\n")}\n`;
|
|
95
|
+
}
|
|
87
96
|
export function formatAudit(result) {
|
|
88
97
|
const lines = [
|
|
89
98
|
`${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
|
package/dist/cli/help.js
CHANGED
|
@@ -39,6 +39,7 @@ export function rootHelpText() {
|
|
|
39
39
|
" issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
|
|
40
40
|
" issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
|
|
41
41
|
" issue transcript-source <issueKey> [--run <id>] [--json] Show the raw Codex session file for one issue run",
|
|
42
|
+
" issue prompt <issueKey> <text> [--json] Send operator guidance to the active or next run",
|
|
42
43
|
" issue close <issueKey> [--failed] [--reason <text>] [--json]",
|
|
43
44
|
" Force-close one issue and release any active run",
|
|
44
45
|
" service status [--json] Show systemd state and local health",
|
|
@@ -154,6 +155,7 @@ export function issueHelpText() {
|
|
|
154
155
|
" open <issueKey> Open Codex in the issue worktree",
|
|
155
156
|
" sessions <issueKey> Show recorded Codex app-server sessions",
|
|
156
157
|
" transcript-source <issueKey> Show the raw Codex session file for one issue run",
|
|
158
|
+
" prompt <issueKey> <text> Send operator guidance to the active or next run",
|
|
157
159
|
" retry <issueKey> Requeue a run",
|
|
158
160
|
" close <issueKey> Force-close a stuck issue",
|
|
159
161
|
"",
|
|
@@ -163,6 +165,7 @@ export function issueHelpText() {
|
|
|
163
165
|
" patchrelay issue watch USE-54",
|
|
164
166
|
" patchrelay issue sessions USE-54",
|
|
165
167
|
" patchrelay issue transcript-source USE-54",
|
|
168
|
+
" patchrelay issue prompt USE-54 \"rebuild this branch cleanly from main\"",
|
|
166
169
|
" patchrelay close USE-54 --reason \"already handled manually\"",
|
|
167
170
|
].join("\n");
|
|
168
171
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -67,6 +67,9 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
67
67
|
case "transcript-source":
|
|
68
68
|
assertKnownFlags(parsed, "issue", ["run", "json"]);
|
|
69
69
|
return;
|
|
70
|
+
case "prompt":
|
|
71
|
+
assertKnownFlags(parsed, "issue", ["json"]);
|
|
72
|
+
return;
|
|
70
73
|
case "retry":
|
|
71
74
|
assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
|
|
72
75
|
return;
|
|
@@ -15,6 +15,15 @@ export class CliOperatorApiClient {
|
|
|
15
15
|
}
|
|
16
16
|
return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
|
|
17
17
|
}
|
|
18
|
+
async promptIssue(issueKey, text) {
|
|
19
|
+
if (!issueKey.trim()) {
|
|
20
|
+
throw new Error("Issue key is required.");
|
|
21
|
+
}
|
|
22
|
+
if (!text.trim()) {
|
|
23
|
+
throw new Error("Prompt text is required.");
|
|
24
|
+
}
|
|
25
|
+
return await this.requestJson(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, undefined, { method: "POST", body: { text } });
|
|
26
|
+
}
|
|
18
27
|
async listInstallations() {
|
|
19
28
|
return await this.requestJson("/api/installations");
|
|
20
29
|
}
|
package/dist/codex-app-server.js
CHANGED
|
@@ -45,6 +45,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
45
45
|
stdoutBuffer = "";
|
|
46
46
|
started = false;
|
|
47
47
|
stopping = false;
|
|
48
|
+
startPromise;
|
|
48
49
|
constructor(config, logger, spawnProcess = spawn) {
|
|
49
50
|
super();
|
|
50
51
|
this.config = config;
|
|
@@ -68,76 +69,16 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
68
69
|
if (this.started) {
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdout error");
|
|
82
|
-
});
|
|
83
|
-
this.child.stderr.on("error", (error) => {
|
|
84
|
-
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stderr error");
|
|
85
|
-
});
|
|
86
|
-
this.child.stderr.on("data", (chunk) => {
|
|
87
|
-
const line = chunk.toString().trim();
|
|
88
|
-
if (line) {
|
|
89
|
-
this.logger.warn({ output: sanitizeDiagnosticText(line) }, "Codex app-server stderr");
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
this.child.on("error", (error) => {
|
|
93
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
94
|
-
this.logger.error({
|
|
95
|
-
error: sanitizeDiagnosticText(err.message),
|
|
96
|
-
pendingRequestCount: this.pending.size,
|
|
97
|
-
}, "Codex app-server process errored");
|
|
98
|
-
this.rejectAllPending(err);
|
|
99
|
-
});
|
|
100
|
-
this.child.on("close", (code, signal) => {
|
|
101
|
-
this.started = false;
|
|
102
|
-
const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
|
|
103
|
-
log({
|
|
104
|
-
code: code ?? 1,
|
|
105
|
-
signal: signal ?? null,
|
|
106
|
-
pendingRequestCount: this.pending.size,
|
|
107
|
-
}, this.stopping ? "Codex app-server stopped" : "Codex app-server exited");
|
|
108
|
-
this.stopping = false;
|
|
109
|
-
this.rejectAllPending(new Error(`Codex app-server exited with code ${code ?? 1}`));
|
|
110
|
-
});
|
|
111
|
-
this.child.stdout.on("data", (chunk) => {
|
|
112
|
-
this.stdoutBuffer += chunk.toString("utf8");
|
|
113
|
-
if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
|
|
114
|
-
this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
|
|
115
|
-
this.stdoutBuffer = "";
|
|
116
|
-
this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
|
|
117
|
-
this.child?.kill("SIGTERM");
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
this.drainMessages();
|
|
121
|
-
});
|
|
122
|
-
const initializeResponse = await this.sendRequest("initialize", {
|
|
123
|
-
clientInfo: {
|
|
124
|
-
name: "patchrelay",
|
|
125
|
-
title: "PatchRelay",
|
|
126
|
-
version: "0.1.0",
|
|
127
|
-
},
|
|
128
|
-
capabilities: {
|
|
129
|
-
experimentalApi: true,
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
const serverInfo = initializeResponse && typeof initializeResponse === "object" && "serverInfo" in initializeResponse
|
|
133
|
-
? initializeResponse.serverInfo
|
|
134
|
-
: undefined;
|
|
135
|
-
this.logger.info({
|
|
136
|
-
serverName: typeof serverInfo?.name === "string" ? serverInfo.name : undefined,
|
|
137
|
-
serverVersion: typeof serverInfo?.version === "string" ? serverInfo.version : undefined,
|
|
138
|
-
}, "Connected to Codex app-server");
|
|
139
|
-
this.sendNotification("initialized");
|
|
140
|
-
this.started = true;
|
|
72
|
+
if (this.startPromise) {
|
|
73
|
+
return await this.startPromise;
|
|
74
|
+
}
|
|
75
|
+
this.startPromise = this.startInternal();
|
|
76
|
+
try {
|
|
77
|
+
await this.startPromise;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.startPromise = undefined;
|
|
81
|
+
}
|
|
141
82
|
}
|
|
142
83
|
async stop() {
|
|
143
84
|
const child = this.child;
|
|
@@ -276,9 +217,7 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
276
217
|
});
|
|
277
218
|
}
|
|
278
219
|
async sendRequest(method, params) {
|
|
279
|
-
|
|
280
|
-
throw new Error("Codex app-server is not running");
|
|
281
|
-
}
|
|
220
|
+
await this.ensureRunningForRequest(method);
|
|
282
221
|
const id = this.nextRequestId++;
|
|
283
222
|
const requestTimeoutMs = this.config.requestTimeoutMs ?? CodexAppServerClient.DEFAULT_REQUEST_TIMEOUT_MS;
|
|
284
223
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -316,6 +255,93 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
316
255
|
throw err;
|
|
317
256
|
});
|
|
318
257
|
}
|
|
258
|
+
async startInternal() {
|
|
259
|
+
this.stopping = false;
|
|
260
|
+
this.stdoutBuffer = "";
|
|
261
|
+
const launch = resolveCodexAppServerLaunch(this.config);
|
|
262
|
+
this.logger.info({ command: launch.command, args: launch.args }, "Starting Codex app-server");
|
|
263
|
+
this.child = this.spawnProcess(launch.command, launch.args, {
|
|
264
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
265
|
+
});
|
|
266
|
+
this.child.stdin.on("error", (error) => {
|
|
267
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdin error");
|
|
268
|
+
});
|
|
269
|
+
this.child.stdout.on("error", (error) => {
|
|
270
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdout error");
|
|
271
|
+
});
|
|
272
|
+
this.child.stderr.on("error", (error) => {
|
|
273
|
+
this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stderr error");
|
|
274
|
+
});
|
|
275
|
+
this.child.stderr.on("data", (chunk) => {
|
|
276
|
+
const line = chunk.toString().trim();
|
|
277
|
+
if (line) {
|
|
278
|
+
this.logger.warn({ output: sanitizeDiagnosticText(line) }, "Codex app-server stderr");
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
this.child.on("error", (error) => {
|
|
282
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
283
|
+
this.logger.error({
|
|
284
|
+
error: sanitizeDiagnosticText(err.message),
|
|
285
|
+
pendingRequestCount: this.pending.size,
|
|
286
|
+
}, "Codex app-server process errored");
|
|
287
|
+
this.rejectAllPending(err);
|
|
288
|
+
});
|
|
289
|
+
this.child.on("close", (code, signal) => {
|
|
290
|
+
this.started = false;
|
|
291
|
+
this.child = undefined;
|
|
292
|
+
this.stdoutBuffer = "";
|
|
293
|
+
const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
|
|
294
|
+
log({
|
|
295
|
+
code: code ?? 1,
|
|
296
|
+
signal: signal ?? null,
|
|
297
|
+
pendingRequestCount: this.pending.size,
|
|
298
|
+
}, this.stopping ? "Codex app-server stopped" : "Codex app-server exited");
|
|
299
|
+
this.stopping = false;
|
|
300
|
+
this.rejectAllPending(new Error(`Codex app-server exited with code ${code ?? 1}`));
|
|
301
|
+
});
|
|
302
|
+
this.child.stdout.on("data", (chunk) => {
|
|
303
|
+
this.stdoutBuffer += chunk.toString("utf8");
|
|
304
|
+
if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
|
|
305
|
+
this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
|
|
306
|
+
this.stdoutBuffer = "";
|
|
307
|
+
this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
|
|
308
|
+
this.child?.kill("SIGTERM");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.drainMessages();
|
|
312
|
+
});
|
|
313
|
+
const initializeResponse = await this.sendRequest("initialize", {
|
|
314
|
+
clientInfo: {
|
|
315
|
+
name: "patchrelay",
|
|
316
|
+
title: "PatchRelay",
|
|
317
|
+
version: "0.1.0",
|
|
318
|
+
},
|
|
319
|
+
capabilities: {
|
|
320
|
+
experimentalApi: true,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
const serverInfo = initializeResponse && typeof initializeResponse === "object" && "serverInfo" in initializeResponse
|
|
324
|
+
? initializeResponse.serverInfo
|
|
325
|
+
: undefined;
|
|
326
|
+
this.logger.info({
|
|
327
|
+
serverName: typeof serverInfo?.name === "string" ? serverInfo.name : undefined,
|
|
328
|
+
serverVersion: typeof serverInfo?.version === "string" ? serverInfo.version : undefined,
|
|
329
|
+
}, "Connected to Codex app-server");
|
|
330
|
+
this.sendNotification("initialized");
|
|
331
|
+
this.started = true;
|
|
332
|
+
}
|
|
333
|
+
async ensureRunningForRequest(method) {
|
|
334
|
+
if (this.child?.stdin) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (method !== "initialize") {
|
|
338
|
+
this.logger.warn({ method }, "Codex app-server is unavailable before request; restarting");
|
|
339
|
+
}
|
|
340
|
+
await this.start();
|
|
341
|
+
if (!this.child?.stdin) {
|
|
342
|
+
throw new Error("Codex app-server is not running");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
319
345
|
writeMessage(message) {
|
|
320
346
|
if (!this.child?.stdin) {
|
|
321
347
|
throw new Error("Codex app-server stdin is unavailable");
|
|
@@ -306,6 +306,7 @@ function buildCiRepairContext(context) {
|
|
|
306
306
|
"Goal: restore CI readiness and push a branch that is likely to pass the next full CI run.",
|
|
307
307
|
"Before changing code or config, reproduce the failure on the exact failing head or identify the concrete log signature that justifies the fix.",
|
|
308
308
|
"If the exact failing head does not reproduce locally and the logs do not support a scoped fix, prefer a rerun-only repair over speculative branch changes.",
|
|
309
|
+
"Do not use broad revert stacks or repo-wide package-manager/workflow/docs cleanups as a repair tactic; stay on the failing incident only.",
|
|
309
310
|
snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "",
|
|
310
311
|
snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "",
|
|
311
312
|
snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "",
|
|
@@ -510,6 +511,9 @@ function buildPrePushSelfReviewSection(target, runType) {
|
|
|
510
511
|
lines.push("Name 2-4 concrete invariants most likely to regress in the touched flow, confirm which file or path enforces each one, and verify at least one adjacent path you did not edit directly.", "If you changed schema, enums, shared vocabulary, normalization helpers, or compatibility mappings, inspect the main read/write paths that can bypass the new abstraction and verify one legacy-flow and one new-flow case before publishing.");
|
|
511
512
|
}
|
|
512
513
|
lines.push("Do not widen scope for optional cleanup. If the issue explicitly allows a non-PR outcome, complete that outcome clearly; otherwise publish before stopping.");
|
|
514
|
+
if (runType === "review_fix" || runType === "branch_upkeep" || runType === "ci_repair" || runType === "queue_repair") {
|
|
515
|
+
lines.push("On reactive repair runs, do not publish broad revert stacks or unrelated workflow/package-manager/docs churn. If that seems necessary, stop and surface the blocker instead.");
|
|
516
|
+
}
|
|
513
517
|
return lines;
|
|
514
518
|
}
|
|
515
519
|
function buildPublicationContract(runType, issueClass) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execCommand, safeJsonParse } from "./utils.js";
|
|
2
|
+
export async function readReactivePublishDelta(repoFullName, baseHeadSha, publishedHeadSha) {
|
|
3
|
+
const { stdout, exitCode } = await execCommand("gh", [
|
|
4
|
+
"api",
|
|
5
|
+
`repos/${repoFullName}/compare/${baseHeadSha}...${publishedHeadSha}`,
|
|
6
|
+
], { timeoutMs: 10_000 });
|
|
7
|
+
if (exitCode !== 0) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const payload = safeJsonParse(stdout);
|
|
11
|
+
if (!payload) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const changedFiles = (payload.files ?? [])
|
|
15
|
+
.map((entry) => entry.filename?.trim())
|
|
16
|
+
.filter((entry) => Boolean(entry));
|
|
17
|
+
const commitSubjects = (payload.commits ?? [])
|
|
18
|
+
.map((entry) => firstLine(entry.commit?.message))
|
|
19
|
+
.filter((entry) => Boolean(entry));
|
|
20
|
+
return {
|
|
21
|
+
changedFiles,
|
|
22
|
+
commitSubjects,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function firstLine(value) {
|
|
26
|
+
const trimmed = value?.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return undefined;
|
|
29
|
+
return trimmed.split(/\r?\n/, 1)[0]?.trim() || undefined;
|
|
30
|
+
}
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
|
|
2
|
+
import { readReactivePublishDelta } from "./reactive-publish-delta.js";
|
|
2
3
|
import { readLatestRequestedChangesReviewContext } from "./remote-pr-review.js";
|
|
4
|
+
const REACTIVE_SCOPE_RISK_PREFIXES = [
|
|
5
|
+
".github/workflows/",
|
|
6
|
+
"scripts/bootstrap-worktree.",
|
|
7
|
+
];
|
|
8
|
+
const REACTIVE_SCOPE_RISK_EXACT_PATHS = new Set([
|
|
9
|
+
".patchrelay/hooks/prepare-worktree",
|
|
10
|
+
"AGENTS.md",
|
|
11
|
+
"CLAUDE.md",
|
|
12
|
+
"DEV_SETUP.md",
|
|
13
|
+
"IMPLEMENTATION_WORKFLOW.md",
|
|
14
|
+
"REVIEW_WORKFLOW.md",
|
|
15
|
+
"package.json",
|
|
16
|
+
"package-lock.json",
|
|
17
|
+
"pnpm-lock.yaml",
|
|
18
|
+
]);
|
|
3
19
|
export class ReactiveRunPolicy {
|
|
4
20
|
config;
|
|
5
21
|
db;
|
|
@@ -73,6 +89,57 @@ export class ReactiveRunPolicy {
|
|
|
73
89
|
return undefined;
|
|
74
90
|
}
|
|
75
91
|
}
|
|
92
|
+
async verifyReactiveRunStayedInScope(run, issue) {
|
|
93
|
+
if (run.runType !== "ci_repair" && run.runType !== "review_fix" && run.runType !== "queue_repair" && run.runType !== "branch_upkeep") {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (!issue.prNumber || issue.prState !== "open") {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
100
|
+
const repoFullName = project?.github?.repoFullName;
|
|
101
|
+
const baselineHeadSha = resolveReactiveBaselineHead(run, issue);
|
|
102
|
+
if (!repoFullName || !baselineHeadSha) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
107
|
+
if (!snapshot || snapshot.prState !== "open" || !snapshot.headSha || snapshot.headSha === baselineHeadSha) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const delta = await readReactivePublishDelta(repoFullName, baselineHeadSha, snapshot.headSha);
|
|
111
|
+
if (!delta) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const revertSubjects = delta.commitSubjects.filter((subject) => /^Revert\s+"/.test(subject));
|
|
115
|
+
if (revertSubjects.length > 0) {
|
|
116
|
+
return [
|
|
117
|
+
`Reactive ${run.runType.replace("_", "-")} for PR #${issue.prNumber} introduced revert commit(s) after ${baselineHeadSha.slice(0, 8)}.`,
|
|
118
|
+
`PatchRelay must use scoped edits or a clean reroll instead of revert-stack cleanup.`,
|
|
119
|
+
`Reverts: ${revertSubjects.slice(0, 3).join("; ")}`,
|
|
120
|
+
].join(" ");
|
|
121
|
+
}
|
|
122
|
+
if (run.runType === "review_fix" || run.runType === "ci_repair") {
|
|
123
|
+
const riskyFiles = delta.changedFiles.filter(isReactiveScopeRiskPath);
|
|
124
|
+
if (riskyFiles.length > 0) {
|
|
125
|
+
return [
|
|
126
|
+
`Reactive ${run.runType.replace("_", "-")} for PR #${issue.prNumber} widened scope after ${baselineHeadSha.slice(0, 8)} by touching repo-meta files.`,
|
|
127
|
+
`PatchRelay should stop instead of publishing workflow, package-manager, bootstrap, or docs churn during reactive repair.`,
|
|
128
|
+
`Files: ${riskyFiles.slice(0, 6).join(", ")}`,
|
|
129
|
+
].join(" ");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
this.logger.debug({
|
|
136
|
+
issueKey: issue.issueKey,
|
|
137
|
+
prNumber: issue.prNumber,
|
|
138
|
+
error: error instanceof Error ? error.message : String(error),
|
|
139
|
+
}, "Failed to verify reactive publish scope");
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
76
143
|
async refreshIssueAfterReactivePublish(run, issue) {
|
|
77
144
|
if (run.runType !== "ci_repair" && run.runType !== "queue_repair" && !isRequestedChangesRunType(run.runType)) {
|
|
78
145
|
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
@@ -235,6 +302,19 @@ export class ReactiveRunPolicy {
|
|
|
235
302
|
};
|
|
236
303
|
}
|
|
237
304
|
}
|
|
305
|
+
function resolveReactiveBaselineHead(run, issue) {
|
|
306
|
+
if (run.runType === "review_fix" || run.runType === "branch_upkeep") {
|
|
307
|
+
return run.sourceHeadSha;
|
|
308
|
+
}
|
|
309
|
+
if (run.runType === "ci_repair" || run.runType === "queue_repair") {
|
|
310
|
+
return issue.lastGitHubFailureHeadSha;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
function isReactiveScopeRiskPath(filePath) {
|
|
315
|
+
return REACTIVE_SCOPE_RISK_EXACT_PATHS.has(filePath)
|
|
316
|
+
|| REACTIVE_SCOPE_RISK_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
317
|
+
}
|
|
238
318
|
function hasStructuredReviewContext(context) {
|
|
239
319
|
if (!context)
|
|
240
320
|
return false;
|
|
@@ -27,6 +27,9 @@ export class RunCompletionPolicy {
|
|
|
27
27
|
async verifyReviewFixAdvancedHead(run, issue) {
|
|
28
28
|
return await this.reactive.verifyReviewFixAdvancedHead(run, issue);
|
|
29
29
|
}
|
|
30
|
+
async verifyReactiveRunStayedInScope(run, issue) {
|
|
31
|
+
return await this.reactive.verifyReactiveRunStayedInScope(run, issue);
|
|
32
|
+
}
|
|
30
33
|
async refreshIssueAfterReactivePublish(run, issue) {
|
|
31
34
|
return await this.reactive.refreshIssueAfterReactivePublish(run, issue);
|
|
32
35
|
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -231,6 +231,19 @@ export class RunFinalizer {
|
|
|
231
231
|
});
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
|
+
const reactiveScopeError = await this.completionPolicy.verifyReactiveRunStayedInScope(run, freshIssue);
|
|
235
|
+
if (reactiveScopeError) {
|
|
236
|
+
this.failRunAndClear(run, reactiveScopeError, "escalated");
|
|
237
|
+
this.syncFailureOutcome({
|
|
238
|
+
run,
|
|
239
|
+
fallbackIssue: freshIssue,
|
|
240
|
+
message: reactiveScopeError,
|
|
241
|
+
level: "error",
|
|
242
|
+
status: "reactive_scope_drift_blocked",
|
|
243
|
+
summary: reactiveScopeError,
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
234
247
|
const publishedOutcomeError = await this.completionPolicy.verifyPublishedRunOutcome(run, freshIssue);
|
|
235
248
|
if (publishedOutcomeError) {
|
|
236
249
|
await handleNoPrCompletionCheck({
|