patchrelay 0.35.16 → 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.
@@ -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: "Operator requested retry of review-fix work.",
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:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
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 });
@@ -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
+ }
@@ -18,6 +18,7 @@ const RUN_LABELS = {
18
18
  implementation: "implementation",
19
19
  ci_repair: "ci repair",
20
20
  review_fix: "review fix",
21
+ branch_upkeep: "branch upkeep",
21
22
  queue_repair: "queue repair",
22
23
  };
23
24
  function runStatusSymbol(status) {
@@ -16,6 +16,7 @@ const RUN_LABELS = {
16
16
  implementation: "implement",
17
17
  ci_repair: "ci fix",
18
18
  review_fix: "review fix",
19
+ branch_upkeep: "branch upkeep",
19
20
  queue_repair: "merge fix",
20
21
  };
21
22
  function runDotColor(status) {
@@ -31,6 +31,7 @@ const RUN_LABELS = {
31
31
  implementation: "implementation",
32
32
  ci_repair: "ci repair",
33
33
  review_fix: "review fix",
34
+ branch_upkeep: "branch upkeep",
34
35
  queue_repair: "queue repair",
35
36
  };
36
37
  const STATE_LABELS = {
@@ -4,6 +4,7 @@ const RUN_TYPE_TO_STATE = {
4
4
  implementation: "implementing",
5
5
  ci_repair: "repairing_ci",
6
6
  review_fix: "changes_requested",
7
+ branch_upkeep: "changes_requested",
7
8
  queue_repair: "repairing_queue",
8
9
  };
9
10
  function extractTransitions(feedEvents) {
@@ -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) => issue.activeRunId === undefined)
813
- .filter((issue) => this.countUnresolvedBlockers(issue.projectId, issue.linearIssueId) === 0)
814
- .filter((issue) => issue.pendingRunType !== undefined || this.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined)
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();