patchrelay 0.35.17 → 0.36.0
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 +11 -2
- package/dist/agent-session-plan.js +14 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +1 -0
- package/dist/cli/cluster-health.js +739 -0
- package/dist/cli/commands/cluster.js +14 -0
- package/dist/cli/data.js +9 -5
- package/dist/cli/help.js +21 -0
- package/dist/cli/index.js +27 -2
- package/dist/cli/output.js +38 -0
- package/dist/cli/watch/StateHistoryView.js +1 -0
- package/dist/cli/watch/TimelineRow.js +1 -0
- package/dist/cli/watch/detail-rows.js +1 -0
- package/dist/cli/watch/history-builder.js +1 -0
- package/dist/db/migrations.js +9 -0
- package/dist/db.js +32 -8
- package/dist/github-webhook-handler.js +5 -78
- package/dist/idle-reconciliation.js +52 -2
- package/dist/issue-query-service.js +2 -0
- package/dist/issue-session-events.js +2 -2
- package/dist/issue-session.js +2 -0
- package/dist/linear-session-reporting.js +2 -0
- package/dist/linear-session-sync.js +2 -0
- package/dist/run-orchestrator.js +196 -31
- package/dist/service.js +13 -5
- package/dist/waiting-reason.js +8 -2
- package/dist/webhook-handler.js +71 -13
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { collectClusterHealth } from "../cluster-health.js";
|
|
2
|
+
import { CliUsageError } from "../errors.js";
|
|
3
|
+
import { formatJson } from "../formatters/json.js";
|
|
4
|
+
import { writeOutput } from "../output.js";
|
|
5
|
+
import { formatClusterHealth } from "../output.js";
|
|
6
|
+
export async function handleClusterCommand(params) {
|
|
7
|
+
const subcommand = params.commandArgs[0];
|
|
8
|
+
if (subcommand) {
|
|
9
|
+
throw new CliUsageError(`Unknown cluster command: ${subcommand}`, "cluster");
|
|
10
|
+
}
|
|
11
|
+
const report = await collectClusterHealth(params.config, params.data.db, params.runCommand);
|
|
12
|
+
writeOutput(params.stdout, params.json ? formatJson(report) : formatClusterHealth(report));
|
|
13
|
+
return report.ok ? 0 : 1;
|
|
14
|
+
}
|
package/dist/cli/data.js
CHANGED
|
@@ -180,6 +180,7 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
180
180
|
if (!issue)
|
|
181
181
|
return undefined;
|
|
182
182
|
const dbIssue = this.db.getIssueByKey(issueKey);
|
|
183
|
+
const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
183
184
|
if (dbIssue.activeRunId !== undefined) {
|
|
184
185
|
throw new Error(`Issue ${issueKey} already has an active run.`);
|
|
185
186
|
}
|
|
@@ -189,13 +190,13 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
189
190
|
: dbIssue.prCheckStatus === "failed" || dbIssue.prCheckStatus === "failure" || issue.latestFailureSource === "branch_ci" || issue.factoryState === "repairing_ci"
|
|
190
191
|
? "ci_repair"
|
|
191
192
|
: dbIssue.prReviewState === "changes_requested" || issue.factoryState === "changes_requested"
|
|
192
|
-
? "review_fix"
|
|
193
|
+
? (dbIssue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep" ? "branch_upkeep" : "review_fix")
|
|
193
194
|
: "implementation"));
|
|
194
195
|
const factoryState = runType === "queue_repair"
|
|
195
196
|
? "repairing_queue"
|
|
196
197
|
: runType === "ci_repair"
|
|
197
198
|
? "repairing_ci"
|
|
198
|
-
: runType === "review_fix"
|
|
199
|
+
: runType === "review_fix" || runType === "branch_upkeep"
|
|
199
200
|
? "changes_requested"
|
|
200
201
|
: "delegated";
|
|
201
202
|
this.appendRetryWake(dbIssue, runType);
|
|
@@ -273,16 +274,19 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
273
274
|
});
|
|
274
275
|
return;
|
|
275
276
|
}
|
|
276
|
-
if (runType === "review_fix") {
|
|
277
|
+
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
277
278
|
this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
278
279
|
projectId: issue.projectId,
|
|
279
280
|
linearIssueId: issue.linearIssueId,
|
|
280
281
|
eventType: "review_changes_requested",
|
|
281
282
|
eventJson: JSON.stringify({
|
|
282
|
-
reviewBody:
|
|
283
|
+
reviewBody: runType === "branch_upkeep"
|
|
284
|
+
? "Operator requested retry of branch upkeep after requested changes."
|
|
285
|
+
: "Operator requested retry of review-fix work.",
|
|
286
|
+
...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
|
|
283
287
|
source: "operator_retry",
|
|
284
288
|
}),
|
|
285
|
-
dedupeKey: `operator_retry
|
|
289
|
+
dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
|
|
286
290
|
});
|
|
287
291
|
return;
|
|
288
292
|
}
|
package/dist/cli/help.js
CHANGED
|
@@ -38,6 +38,7 @@ export function rootHelpText() {
|
|
|
38
38
|
" issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
|
|
39
39
|
" issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
|
|
40
40
|
" service status [--json] Show systemd state and local health",
|
|
41
|
+
" cluster [--json] Check service + workflow health across all tracked issues",
|
|
41
42
|
" service logs [--lines <count>] [--json] Show recent service logs",
|
|
42
43
|
" serve Run the local PatchRelay service",
|
|
43
44
|
"",
|
|
@@ -62,6 +63,7 @@ export function rootHelpText() {
|
|
|
62
63
|
" patchrelay issue list --active",
|
|
63
64
|
" patchrelay issue watch USE-54",
|
|
64
65
|
" patchrelay service status",
|
|
66
|
+
" patchrelay cluster",
|
|
65
67
|
" patchrelay version --json",
|
|
66
68
|
"",
|
|
67
69
|
"Command help:",
|
|
@@ -70,6 +72,7 @@ export function rootHelpText() {
|
|
|
70
72
|
" patchrelay help repo",
|
|
71
73
|
" patchrelay help issue",
|
|
72
74
|
" patchrelay help service",
|
|
75
|
+
" patchrelay help cluster",
|
|
73
76
|
].join("\n");
|
|
74
77
|
}
|
|
75
78
|
export function linearHelpText() {
|
|
@@ -171,8 +174,26 @@ export function serviceHelpText() {
|
|
|
171
174
|
" patchrelay service logs --lines 100",
|
|
172
175
|
].join("\n");
|
|
173
176
|
}
|
|
177
|
+
export function clusterHelpText() {
|
|
178
|
+
return [
|
|
179
|
+
"Usage:",
|
|
180
|
+
" patchrelay cluster [--json]",
|
|
181
|
+
"",
|
|
182
|
+
"Behavior:",
|
|
183
|
+
" Aggregates local PatchRelay service health with workflow checks for every",
|
|
184
|
+
" tracked non-done issue. The command looks for unmanaged blockers, lost",
|
|
185
|
+
" dispatch, stale PR handoffs, and downstream waits that no longer have a",
|
|
186
|
+
" healthy automation owner.",
|
|
187
|
+
"",
|
|
188
|
+
"Examples:",
|
|
189
|
+
" patchrelay cluster",
|
|
190
|
+
" patchrelay cluster --json",
|
|
191
|
+
].join("\n");
|
|
192
|
+
}
|
|
174
193
|
export function helpTextFor(topic) {
|
|
175
194
|
switch (topic) {
|
|
195
|
+
case "cluster":
|
|
196
|
+
return clusterHelpText();
|
|
176
197
|
case "linear":
|
|
177
198
|
return linearHelpText();
|
|
178
199
|
case "repo":
|
package/dist/cli/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../config.js";
|
|
|
2
2
|
import { getBuildInfo } from "../build-info.js";
|
|
3
3
|
import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args.js";
|
|
4
4
|
import { handleIssueCommand, } from "./commands/issues.js";
|
|
5
|
+
import { handleClusterCommand } from "./commands/cluster.js";
|
|
5
6
|
import { handleLinearCommand } from "./commands/linear.js";
|
|
6
7
|
import { handleRepoCommand } from "./commands/repo.js";
|
|
7
8
|
import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
|
|
@@ -20,6 +21,7 @@ function getCommandConfigProfile(command) {
|
|
|
20
21
|
case "linear":
|
|
21
22
|
case "dashboard":
|
|
22
23
|
return "operator_cli";
|
|
24
|
+
case "cluster":
|
|
23
25
|
case "repo":
|
|
24
26
|
case "issue":
|
|
25
27
|
return "cli";
|
|
@@ -73,6 +75,9 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
73
75
|
case "doctor":
|
|
74
76
|
assertKnownFlags(parsed, command, ["json"]);
|
|
75
77
|
return;
|
|
78
|
+
case "cluster":
|
|
79
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
80
|
+
return;
|
|
76
81
|
case "init":
|
|
77
82
|
assertKnownFlags(parsed, command, ["force", "json", "public-base-url"]);
|
|
78
83
|
return;
|
|
@@ -162,7 +167,7 @@ export async function runCli(argv, options) {
|
|
|
162
167
|
const json = parsed.flags.get("json") === true;
|
|
163
168
|
if (command === "help") {
|
|
164
169
|
const topic = commandArgs[0];
|
|
165
|
-
if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service") {
|
|
170
|
+
if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster") {
|
|
166
171
|
writeOutput(stdout, `${helpTextFor(topic)}\n`);
|
|
167
172
|
return 0;
|
|
168
173
|
}
|
|
@@ -183,7 +188,7 @@ export async function runCli(argv, options) {
|
|
|
183
188
|
? "linear"
|
|
184
189
|
: command === "repo"
|
|
185
190
|
? "repo"
|
|
186
|
-
: command === "issue" || command === "service"
|
|
191
|
+
: command === "issue" || command === "service" || command === "cluster"
|
|
187
192
|
? command
|
|
188
193
|
: "root";
|
|
189
194
|
writeOutput(stdout, `${helpTextFor(helpTopic)}\n`);
|
|
@@ -272,6 +277,10 @@ export async function runCli(argv, options) {
|
|
|
272
277
|
return 1;
|
|
273
278
|
}
|
|
274
279
|
}
|
|
280
|
+
if (command === "cluster" && commandArgs[0]) {
|
|
281
|
+
writeUsageError(stderr, new CliUsageError(`Unknown cluster command: ${commandArgs[0]}`, "cluster"));
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
275
284
|
const config = options?.config ??
|
|
276
285
|
loadConfig(undefined, {
|
|
277
286
|
profile: getCommandConfigProfile(command),
|
|
@@ -311,6 +320,22 @@ export async function runCli(argv, options) {
|
|
|
311
320
|
runInteractive,
|
|
312
321
|
});
|
|
313
322
|
}
|
|
323
|
+
if (command === "cluster") {
|
|
324
|
+
const issueData = await ensureIssueDataAccess(data, config);
|
|
325
|
+
if (!data) {
|
|
326
|
+
data = issueData;
|
|
327
|
+
ownsData = true;
|
|
328
|
+
}
|
|
329
|
+
return await handleClusterCommand({
|
|
330
|
+
commandArgs,
|
|
331
|
+
parsed,
|
|
332
|
+
json,
|
|
333
|
+
stdout,
|
|
334
|
+
data: issueData,
|
|
335
|
+
config,
|
|
336
|
+
runCommand,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
314
339
|
if (command === "dashboard") {
|
|
315
340
|
const { handleWatchCommand } = await import("./commands/watch.js");
|
|
316
341
|
return await handleWatchCommand({ config, parsed });
|
package/dist/cli/output.js
CHANGED
|
@@ -22,3 +22,41 @@ export function formatDoctor(report, cliVersion, serviceVersion) {
|
|
|
22
22
|
lines.push(report.ok ? "Doctor result: ready" : "Doctor result: not ready");
|
|
23
23
|
return `${lines.join("\n")}\n`;
|
|
24
24
|
}
|
|
25
|
+
export function formatClusterHealth(report) {
|
|
26
|
+
const lines = ["PatchRelay cluster", ""];
|
|
27
|
+
for (const check of report.checks) {
|
|
28
|
+
const marker = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
29
|
+
const detail = [
|
|
30
|
+
check.scope,
|
|
31
|
+
check.issueKey,
|
|
32
|
+
check.prNumber !== undefined ? `PR #${check.prNumber}` : undefined,
|
|
33
|
+
].filter(Boolean).join(" ");
|
|
34
|
+
lines.push(`${marker} [${detail}] ${check.message}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(`Summary: tracked=${report.summary.trackedIssues} non_done=${report.summary.openIssues} active_runs=${report.summary.activeRuns} blocked=${report.summary.blockedIssues} ready=${report.summary.readyIssues}`);
|
|
38
|
+
if (report.summary.ciTrackedPrs > 0) {
|
|
39
|
+
lines.push(`CI summary: prs=${report.summary.ciTrackedPrs} pending=${report.summary.ciPending} success=${report.summary.ciSuccess} failure=${report.summary.ciFailure} unknown=${report.summary.ciUnknown} missing_owner=${report.summary.ciOrphaned}`);
|
|
40
|
+
for (const entry of report.ci) {
|
|
41
|
+
lines.push(`CI ${entry.issueKey ?? entry.projectId} PR #${entry.prNumber} gate=${entry.gateStatus} next=${formatCiOwnerLabel(entry.owner)} ${entry.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
lines.push(report.ok ? "Cluster result: no ownership gaps detected" : "Cluster result: attention needed");
|
|
45
|
+
return `${lines.join("\n")}\n`;
|
|
46
|
+
}
|
|
47
|
+
function formatCiOwnerLabel(owner) {
|
|
48
|
+
switch (owner) {
|
|
49
|
+
case "patchrelay":
|
|
50
|
+
return "patchrelay";
|
|
51
|
+
case "reviewer":
|
|
52
|
+
return "reviewer";
|
|
53
|
+
case "review-quill":
|
|
54
|
+
return "review-quill";
|
|
55
|
+
case "downstream":
|
|
56
|
+
return "merge-queue";
|
|
57
|
+
case "external":
|
|
58
|
+
return "ci/github";
|
|
59
|
+
default:
|
|
60
|
+
return "missing";
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
26
26
|
pr_author_login TEXT,
|
|
27
27
|
pr_review_state TEXT,
|
|
28
28
|
pr_check_status TEXT,
|
|
29
|
+
last_blocking_review_head_sha TEXT,
|
|
29
30
|
ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
30
31
|
queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
31
32
|
updated_at TEXT NOT NULL,
|
|
@@ -39,6 +40,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
39
40
|
linear_issue_id TEXT NOT NULL,
|
|
40
41
|
run_type TEXT NOT NULL DEFAULT 'implementation',
|
|
41
42
|
status TEXT NOT NULL,
|
|
43
|
+
source_head_sha TEXT,
|
|
42
44
|
prompt_text TEXT,
|
|
43
45
|
thread_id TEXT,
|
|
44
46
|
turn_id TEXT,
|
|
@@ -239,6 +241,10 @@ export function runPatchRelayMigrations(connection) {
|
|
|
239
241
|
addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
240
242
|
// Add review_fix_attempts counter
|
|
241
243
|
addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
244
|
+
// Preserve the PR head SHA seen when a run started so PatchRelay can
|
|
245
|
+
// verify that requested-changes work actually published a new head.
|
|
246
|
+
addColumnIfMissing(connection, "runs", "source_head_sha", "TEXT");
|
|
247
|
+
addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
|
|
242
248
|
// Collapse awaiting_review into pr_open (state normalization)
|
|
243
249
|
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
|
244
250
|
// Add Linear issue description, priority, estimate
|
|
@@ -319,6 +325,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
319
325
|
pr_author_login TEXT,
|
|
320
326
|
pr_review_state TEXT,
|
|
321
327
|
pr_check_status TEXT,
|
|
328
|
+
last_blocking_review_head_sha TEXT,
|
|
322
329
|
last_github_failure_source TEXT,
|
|
323
330
|
last_github_failure_head_sha TEXT,
|
|
324
331
|
last_github_failure_signature TEXT,
|
|
@@ -374,6 +381,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
374
381
|
pr_author_login,
|
|
375
382
|
pr_review_state,
|
|
376
383
|
pr_check_status,
|
|
384
|
+
last_blocking_review_head_sha,
|
|
377
385
|
last_github_failure_source,
|
|
378
386
|
last_github_failure_head_sha,
|
|
379
387
|
last_github_failure_signature,
|
|
@@ -427,6 +435,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
427
435
|
pr_author_login,
|
|
428
436
|
pr_review_state,
|
|
429
437
|
pr_check_status,
|
|
438
|
+
last_blocking_review_head_sha,
|
|
430
439
|
last_github_failure_source,
|
|
431
440
|
last_github_failure_head_sha,
|
|
432
441
|
last_github_failure_signature,
|
package/dist/db.js
CHANGED
|
@@ -254,6 +254,10 @@ export class PatchRelayDatabase {
|
|
|
254
254
|
sets.push("pr_check_status = @prCheckStatus");
|
|
255
255
|
values.prCheckStatus = params.prCheckStatus;
|
|
256
256
|
}
|
|
257
|
+
if (params.lastBlockingReviewHeadSha !== undefined) {
|
|
258
|
+
sets.push("last_blocking_review_head_sha = @lastBlockingReviewHeadSha");
|
|
259
|
+
values.lastBlockingReviewHeadSha = params.lastBlockingReviewHeadSha;
|
|
260
|
+
}
|
|
257
261
|
if (params.lastGitHubFailureSource !== undefined) {
|
|
258
262
|
sets.push("last_github_failure_source = @lastGitHubFailureSource");
|
|
259
263
|
values.lastGitHubFailureSource = params.lastGitHubFailureSource;
|
|
@@ -348,7 +352,7 @@ export class PatchRelayDatabase {
|
|
|
348
352
|
current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
|
|
349
353
|
branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
|
|
350
354
|
agent_session_id,
|
|
351
|
-
pr_number, pr_url, pr_state, pr_head_sha, pr_author_login, pr_review_state, pr_check_status,
|
|
355
|
+
pr_number, pr_url, pr_state, pr_head_sha, pr_author_login, pr_review_state, pr_check_status, last_blocking_review_head_sha,
|
|
352
356
|
last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
|
|
353
357
|
last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
|
|
354
358
|
last_queue_signal_at, last_queue_incident_json,
|
|
@@ -360,7 +364,7 @@ export class PatchRelayDatabase {
|
|
|
360
364
|
@currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
361
365
|
@branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
|
|
362
366
|
@agentSessionId,
|
|
363
|
-
@prNumber, @prUrl, @prState, @prHeadSha, @prAuthorLogin, @prReviewState, @prCheckStatus,
|
|
367
|
+
@prNumber, @prUrl, @prState, @prHeadSha, @prAuthorLogin, @prReviewState, @prCheckStatus, @lastBlockingReviewHeadSha,
|
|
364
368
|
@lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
|
|
365
369
|
@lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
|
|
366
370
|
@lastQueueSignalAt, @lastQueueIncidentJson,
|
|
@@ -394,6 +398,7 @@ export class PatchRelayDatabase {
|
|
|
394
398
|
prAuthorLogin: params.prAuthorLogin ?? null,
|
|
395
399
|
prReviewState: params.prReviewState ?? null,
|
|
396
400
|
prCheckStatus: params.prCheckStatus ?? null,
|
|
401
|
+
lastBlockingReviewHeadSha: params.lastBlockingReviewHeadSha ?? null,
|
|
397
402
|
lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
|
|
398
403
|
lastGitHubFailureHeadSha: params.lastGitHubFailureHeadSha ?? null,
|
|
399
404
|
lastGitHubFailureSignature: params.lastGitHubFailureSignature ?? null,
|
|
@@ -809,9 +814,22 @@ export class PatchRelayDatabase {
|
|
|
809
814
|
}
|
|
810
815
|
listIssuesReadyForExecution() {
|
|
811
816
|
return this.listIssues()
|
|
812
|
-
.filter((issue) =>
|
|
813
|
-
|
|
814
|
-
|
|
817
|
+
.filter((issue) => isIssueSessionReadyForExecution({
|
|
818
|
+
factoryState: issue.factoryState,
|
|
819
|
+
sessionState: deriveIssueSessionState({
|
|
820
|
+
activeRunId: issue.activeRunId,
|
|
821
|
+
factoryState: issue.factoryState,
|
|
822
|
+
}),
|
|
823
|
+
activeRunId: issue.activeRunId,
|
|
824
|
+
blockedByCount: this.countUnresolvedBlockers(issue.projectId, issue.linearIssueId),
|
|
825
|
+
hasPendingWake: this.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined,
|
|
826
|
+
hasLegacyPendingRun: issue.pendingRunType !== undefined,
|
|
827
|
+
prNumber: issue.prNumber,
|
|
828
|
+
prState: issue.prState,
|
|
829
|
+
prReviewState: issue.prReviewState,
|
|
830
|
+
prCheckStatus: issue.prCheckStatus,
|
|
831
|
+
latestFailureSource: issue.lastGitHubFailureSource,
|
|
832
|
+
}))
|
|
815
833
|
.map((issue) => ({
|
|
816
834
|
projectId: issue.projectId,
|
|
817
835
|
linearIssueId: issue.linearIssueId,
|
|
@@ -869,9 +887,9 @@ export class PatchRelayDatabase {
|
|
|
869
887
|
createRun(params) {
|
|
870
888
|
const now = isoNow();
|
|
871
889
|
const result = this.connection.prepare(`
|
|
872
|
-
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, prompt_text, started_at)
|
|
873
|
-
VALUES (?, ?, ?, ?, 'queued', ?, ?)
|
|
874
|
-
`).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.promptText ?? null, now);
|
|
890
|
+
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, source_head_sha, prompt_text, started_at)
|
|
891
|
+
VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
|
|
892
|
+
`).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
|
|
875
893
|
const run = this.getRun(Number(result.lastInsertRowid));
|
|
876
894
|
const issue = this.getIssue(params.projectId, params.linearIssueId);
|
|
877
895
|
if (issue) {
|
|
@@ -992,8 +1010,10 @@ export class PatchRelayDatabase {
|
|
|
992
1010
|
factoryState: issue.factoryState,
|
|
993
1011
|
pendingRunType: issue.pendingRunType,
|
|
994
1012
|
prNumber: issue.prNumber,
|
|
1013
|
+
prHeadSha: issue.prHeadSha,
|
|
995
1014
|
prReviewState: issue.prReviewState,
|
|
996
1015
|
prCheckStatus: issue.prCheckStatus,
|
|
1016
|
+
lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
|
|
997
1017
|
latestFailureCheckName: issue.lastGitHubFailureCheckName,
|
|
998
1018
|
});
|
|
999
1019
|
const latestRun = this.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
@@ -1241,6 +1261,9 @@ function mapIssueRow(row) {
|
|
|
1241
1261
|
...(row.pr_author_login !== null && row.pr_author_login !== undefined ? { prAuthorLogin: String(row.pr_author_login) } : {}),
|
|
1242
1262
|
...(row.pr_review_state !== null && row.pr_review_state !== undefined ? { prReviewState: String(row.pr_review_state) } : {}),
|
|
1243
1263
|
...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
|
|
1264
|
+
...(row.last_blocking_review_head_sha !== null && row.last_blocking_review_head_sha !== undefined
|
|
1265
|
+
? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) }
|
|
1266
|
+
: {}),
|
|
1244
1267
|
...(row.last_github_failure_source !== null && row.last_github_failure_source !== undefined
|
|
1245
1268
|
? { lastGitHubFailureSource: String(row.last_github_failure_source) }
|
|
1246
1269
|
: {}),
|
|
@@ -1347,6 +1370,7 @@ function mapRunRow(row) {
|
|
|
1347
1370
|
linearIssueId: String(row.linear_issue_id),
|
|
1348
1371
|
runType: String(row.run_type ?? "implementation"),
|
|
1349
1372
|
status: String(row.status),
|
|
1373
|
+
...(row.source_head_sha !== null ? { sourceHeadSha: String(row.source_head_sha) } : {}),
|
|
1350
1374
|
...(row.prompt_text !== null ? { promptText: String(row.prompt_text) } : {}),
|
|
1351
1375
|
...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
|
|
1352
1376
|
...(row.turn_id !== null ? { turnId: String(row.turn_id) } : {}),
|
|
@@ -147,6 +147,11 @@ export class GitHubWebhookHandler {
|
|
|
147
147
|
...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
|
|
148
148
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
149
149
|
...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
|
|
150
|
+
...(event.reviewState === "changes_requested"
|
|
151
|
+
? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
|
|
152
|
+
: event.reviewState === "approved"
|
|
153
|
+
? { lastBlockingReviewHeadSha: null }
|
|
154
|
+
: {}),
|
|
150
155
|
});
|
|
151
156
|
await this.updateCiSnapshot(issue, event, project);
|
|
152
157
|
await this.updateFailureProvenance(issue, event, project);
|
|
@@ -195,7 +200,6 @@ export class GitHubWebhookHandler {
|
|
|
195
200
|
lastAttemptedFailureHeadSha: null,
|
|
196
201
|
lastAttemptedFailureSignature: null,
|
|
197
202
|
});
|
|
198
|
-
await this.maybeRequestRereviewAfterPush(freshIssue, event, project);
|
|
199
203
|
}
|
|
200
204
|
this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
|
|
201
205
|
this.feed?.publish({
|
|
@@ -941,83 +945,6 @@ export class GitHubWebhookHandler {
|
|
|
941
945
|
});
|
|
942
946
|
this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
943
947
|
}
|
|
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
948
|
async readGitHubErrorResponse(response) {
|
|
1022
949
|
try {
|
|
1023
950
|
const payload = await response.json();
|
|
@@ -129,6 +129,11 @@ export class IdleIssueReconciler {
|
|
|
129
129
|
await this.reconcileFromGitHub(issue);
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
for (const issue of this.db.listIssues()) {
|
|
133
|
+
if (!this.shouldProbeTerminalIssueFromGitHub(issue))
|
|
134
|
+
continue;
|
|
135
|
+
await this.reconcileFromGitHub(issue);
|
|
136
|
+
}
|
|
132
137
|
for (const issue of this.db.listBlockedDelegatedIssues()) {
|
|
133
138
|
const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
134
139
|
if (unresolved === 0) {
|
|
@@ -144,6 +149,15 @@ export class IdleIssueReconciler {
|
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
}
|
|
152
|
+
shouldProbeTerminalIssueFromGitHub(issue) {
|
|
153
|
+
if (issue.prNumber === undefined)
|
|
154
|
+
return false;
|
|
155
|
+
if (issue.activeRunId !== undefined)
|
|
156
|
+
return false;
|
|
157
|
+
if (issue.pendingRunType !== undefined)
|
|
158
|
+
return false;
|
|
159
|
+
return issue.factoryState === "escalated" || issue.factoryState === "failed";
|
|
160
|
+
}
|
|
147
161
|
advanceIdleIssue(issue, newState, options) {
|
|
148
162
|
if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
|
|
149
163
|
return;
|
|
@@ -205,9 +219,9 @@ export class IdleIssueReconciler {
|
|
|
205
219
|
eventType = "settled_red_ci";
|
|
206
220
|
dedupeKey = `${dedupeScope}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
|
|
207
221
|
}
|
|
208
|
-
else if (runType === "review_fix") {
|
|
222
|
+
else if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
209
223
|
eventType = "review_changes_requested";
|
|
210
|
-
dedupeKey = `${dedupeScope}
|
|
224
|
+
dedupeKey = `${dedupeScope}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
|
|
211
225
|
}
|
|
212
226
|
else {
|
|
213
227
|
eventType = "delegated";
|
|
@@ -377,6 +391,7 @@ export class IdleIssueReconciler {
|
|
|
377
391
|
"--json", "headRefOid,state,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup",
|
|
378
392
|
], { timeoutMs: 10_000 });
|
|
379
393
|
const pr = JSON.parse(stdout);
|
|
394
|
+
const previousHeadSha = issue.prHeadSha;
|
|
380
395
|
const gateCheckNames = getGateCheckNames(project);
|
|
381
396
|
const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
|
|
382
397
|
this.db.upsertIssue({
|
|
@@ -415,6 +430,23 @@ export class IdleIssueReconciler {
|
|
|
415
430
|
});
|
|
416
431
|
return;
|
|
417
432
|
}
|
|
433
|
+
const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
|
|
434
|
+
if (issue.factoryState !== "awaiting_input") {
|
|
435
|
+
const terminalRecoveryState = this.deriveTerminalRecoveryState(issue, pr.reviewDecision, gateCheckStatus, headAdvanced);
|
|
436
|
+
if (terminalRecoveryState) {
|
|
437
|
+
this.logger.info({
|
|
438
|
+
issueKey: issue.issueKey,
|
|
439
|
+
prNumber: issue.prNumber,
|
|
440
|
+
from: issue.factoryState,
|
|
441
|
+
to: terminalRecoveryState,
|
|
442
|
+
gateCheckStatus,
|
|
443
|
+
reviewDecision: pr.reviewDecision,
|
|
444
|
+
headAdvanced,
|
|
445
|
+
}, "Reconciliation: recovered terminal issue from newer GitHub truth");
|
|
446
|
+
this.advanceIdleIssue(issue, terminalRecoveryState, { clearFailureProvenance: true });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
418
450
|
if (isReviewDecisionReviewRequired(pr.reviewDecision)
|
|
419
451
|
&& gateCheckStatus === "success"
|
|
420
452
|
&& hasCompletedReviewQuillVerdict(pr.statusCheckRollup)) {
|
|
@@ -492,4 +524,22 @@ export class IdleIssueReconciler {
|
|
|
492
524
|
}
|
|
493
525
|
}
|
|
494
526
|
}
|
|
527
|
+
deriveTerminalRecoveryState(issue, reviewDecision, gateCheckStatus, headAdvanced) {
|
|
528
|
+
if (issue.factoryState !== "escalated" && issue.factoryState !== "failed") {
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
if (isReviewDecisionApproved(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
|
|
532
|
+
return "awaiting_queue";
|
|
533
|
+
}
|
|
534
|
+
if (gateCheckStatus === "pending") {
|
|
535
|
+
return "pr_open";
|
|
536
|
+
}
|
|
537
|
+
if (headAdvanced && !isFailingCheckStatus(gateCheckStatus)) {
|
|
538
|
+
return "pr_open";
|
|
539
|
+
}
|
|
540
|
+
if (isReviewDecisionReviewRequired(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
|
|
541
|
+
return "pr_open";
|
|
542
|
+
}
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
495
545
|
}
|
|
@@ -143,8 +143,10 @@ export class IssueQueryService {
|
|
|
143
143
|
factoryState: issueRecord?.factoryState ?? "delegated",
|
|
144
144
|
pendingRunType: issueRecord?.pendingRunType,
|
|
145
145
|
prNumber: session.prNumber,
|
|
146
|
+
prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
|
|
146
147
|
prReviewState: issueRecord?.prReviewState,
|
|
147
148
|
prCheckStatus: issueRecord?.prCheckStatus,
|
|
149
|
+
lastBlockingReviewHeadSha: issueRecord?.lastBlockingReviewHeadSha,
|
|
148
150
|
latestFailureCheckName: issueRecord?.lastGitHubFailureCheckName,
|
|
149
151
|
});
|
|
150
152
|
const issue = {
|
|
@@ -33,8 +33,8 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
33
33
|
break;
|
|
34
34
|
case "review_changes_requested":
|
|
35
35
|
if (runType !== "queue_repair" && runType !== "ci_repair") {
|
|
36
|
-
runType = "review_fix";
|
|
37
|
-
wakeReason = "review_changes_requested";
|
|
36
|
+
runType = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
|
|
37
|
+
wakeReason = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
|
|
38
38
|
Object.assign(context, payload ?? {});
|
|
39
39
|
}
|
|
40
40
|
break;
|
package/dist/issue-session.js
CHANGED
|
@@ -18,6 +18,8 @@ export function deriveIssueSessionWakeReason(params) {
|
|
|
18
18
|
return "delegated";
|
|
19
19
|
if (params.pendingRunType === "review_fix")
|
|
20
20
|
return "review_changes_requested";
|
|
21
|
+
if (params.pendingRunType === "branch_upkeep")
|
|
22
|
+
return "branch_upkeep";
|
|
21
23
|
if (params.pendingRunType === "ci_repair")
|
|
22
24
|
return "settled_red_ci";
|
|
23
25
|
if (params.pendingRunType === "queue_repair")
|