patchrelay 0.60.0 → 0.62.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.60.0",
4
- "commit": "116496455e0c",
5
- "builtAt": "2026-05-04T22:16:11.428Z"
3
+ "version": "0.62.0",
4
+ "commit": "8ec3fef80b54",
5
+ "builtAt": "2026-05-04T22:29:13.205Z"
6
6
  }
@@ -342,6 +342,21 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
342
342
  && issue.factoryState !== "awaiting_queue"
343
343
  && issue.activeRunId === undefined
344
344
  && ageMs >= RECONCILIATION_GRACE_MS) {
345
+ // Plan §6.1: when the PR is also approved, this is the
346
+ // "In Review · stuck at admission" condition — the lander would
347
+ // accept the verdict but branch CI is red and (post-§4.3) we no
348
+ // longer auto-repair. Keep the same scope/status pair so existing
349
+ // dashboards continue to surface it; just sharpen the message.
350
+ if (reviewDecision === "APPROVED") {
351
+ return {
352
+ ciEntry,
353
+ finding: {
354
+ status: "fail",
355
+ scope: "github:ci",
356
+ message: "In Review · stuck at admission — PR is approved but gate CI is red and no CI repair is running",
357
+ },
358
+ };
359
+ }
345
360
  return {
346
361
  ciEntry,
347
362
  finding: {
@@ -402,6 +402,22 @@ export class IssueStore {
402
402
  .all();
403
403
  return rows.map(mapIssueRow);
404
404
  }
405
+ // Issues that are approved by review-quill but stuck in In Review
406
+ // because branch CI is failing — the merge-steward never admits them.
407
+ // Plan §6.2: surface this as IN_REVIEW_STUCK so an operator notices
408
+ // before the issue goes silent for hours.
409
+ listApprovedRedCiIssues() {
410
+ const rows = this.connection
411
+ .prepare(`SELECT * FROM issues
412
+ WHERE factory_state = 'pr_open'
413
+ AND active_run_id IS NULL
414
+ AND pending_run_type IS NULL
415
+ AND pr_number IS NOT NULL
416
+ AND pr_review_state = 'approved'
417
+ AND pr_check_status = 'failure'`)
418
+ .all();
419
+ return rows.map(mapIssueRow);
420
+ }
405
421
  replaceIssueDependencies(params) {
406
422
  const now = isoNow();
407
423
  this.connection
@@ -271,7 +271,7 @@ function buildStructuredReviewContext(context) {
271
271
  lines.push("No inline review comments were captured for this review.");
272
272
  return lines.join("\n");
273
273
  }
274
- lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "A requested-changes turn is only complete if you push a newer PR head or deliberately escalate because you are blocked.", "");
274
+ lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "Complete the turn either by pushing a newer PR head with the fix, or — if your reviewer-pass produces only comments, test wording, or PR-body changes — by editing the PR body via `gh pr edit` instead of pushing. Do not push a commit that produces a patch-id-equivalent diff just to make the fix unmistakable.", "If you are blocked, deliberately escalate instead of pushing.", "");
275
275
  for (const comment of reviewComments) {
276
276
  const location = comment.path
277
277
  ? `${comment.path}${comment.line !== undefined ? `:${comment.line}` : ""}${comment.side ? ` (${comment.side})` : ""}`
@@ -293,12 +293,12 @@ function buildRequestedChangesContext(runType, context) {
293
293
  const mode = resolveRequestedChangesMode(runType, context);
294
294
  const lines = [];
295
295
  if (mode === "branch_upkeep") {
296
- lines.push("Branch upkeep is required on the existing PR branch.", "Goal: restore merge readiness on the current branch and push a newer head without regressing review or CI readiness.");
296
+ lines.push("Branch upkeep is required on the existing PR branch.", "Goal: restore merge readiness on the current branch. Push a newer head only when the work actually changes the diff against the base; do not republish a patch-id-equivalent head.");
297
297
  }
298
298
  else {
299
299
  const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
300
300
  const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
301
- lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness and push a newer head on the current PR branch.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
301
+ lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness on the current PR branch. Push a newer head only when the fix actually changes the diff; if the reviewer-pass produces only comments, test wording, or PR-body changes, edit the PR body via `gh pr edit` instead.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
302
302
  appendStructuredReviewContext(lines, context);
303
303
  }
304
304
  return lines.join("\n").trim();
@@ -2,6 +2,11 @@ import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
2
  import { execCommand } from "./utils.js";
3
3
  const QUEUE_HEALTH_GRACE_MS = 120_000;
4
4
  const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
5
+ // Plan §6.2: an approved PR with red branch CI for >= this long is
6
+ // stuck at admission — operator notice is needed before the issue
7
+ // goes silent for hours.
8
+ const IN_REVIEW_STUCK_THRESHOLD_MS = 30 * 60 * 1000;
9
+ const IN_REVIEW_STUCK_FEED_COOLDOWN_MS = 30 * 60 * 1000;
5
10
  function isDuplicateProbe(issue, context) {
6
11
  const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
7
12
  const headSha = typeof context?.failureHeadSha === "string" ? context.failureHeadSha : undefined;
@@ -17,6 +22,7 @@ export class QueueHealthMonitor {
17
22
  logger;
18
23
  feed;
19
24
  probeFailureFeedTimes = new Map();
25
+ inReviewStuckFeedTimes = new Map();
20
26
  constructor(db, config, advancer, logger, feed) {
21
27
  this.db = db;
22
28
  this.config = config;
@@ -28,6 +34,42 @@ export class QueueHealthMonitor {
28
34
  for (const issue of this.db.issues.listAwaitingQueueIssues()) {
29
35
  await this.probeQueuedIssue(issue);
30
36
  }
37
+ for (const issue of this.db.issues.listApprovedRedCiIssues()) {
38
+ this.probeInReviewStuckIssue(issue);
39
+ }
40
+ }
41
+ // Plan §6.2: emit IN_REVIEW_STUCK when an approved PR has a red gate
42
+ // for more than 30 minutes. Consequence of plan §4.3 — branch CI
43
+ // failures while approved no longer trigger ci_repair, so the
44
+ // condition is otherwise invisible to the operator.
45
+ probeInReviewStuckIssue(issue) {
46
+ if (!issue.prNumber)
47
+ return;
48
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
49
+ if (!project)
50
+ return;
51
+ const reference = issue.lastGitHubFailureAt ?? issue.updatedAt;
52
+ const stuckMs = Date.now() - Date.parse(reference);
53
+ if (stuckMs < IN_REVIEW_STUCK_THRESHOLD_MS)
54
+ return;
55
+ const feedKey = `${issue.projectId}::${issue.linearIssueId}`;
56
+ const lastFedAt = this.inReviewStuckFeedTimes.get(feedKey) ?? 0;
57
+ if (Date.now() - lastFedAt < IN_REVIEW_STUCK_FEED_COOLDOWN_MS)
58
+ return;
59
+ this.inReviewStuckFeedTimes.set(feedKey, Date.now());
60
+ const minutes = Math.round(stuckMs / 60_000);
61
+ const failedCheck = issue.lastGitHubFailureCheckName ?? "branch CI";
62
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, stuckMs, failedCheck }, "Queue health: approved PR is stuck at admission with red branch CI");
63
+ this.feed?.publish({
64
+ level: "warn",
65
+ kind: "github",
66
+ issueKey: issue.issueKey,
67
+ projectId: issue.projectId,
68
+ stage: "pr_open",
69
+ status: "in_review_stuck",
70
+ summary: `In Review · stuck at admission — PR #${issue.prNumber} has been approved with red ${failedCheck} for ${minutes} min`,
71
+ detail: issue.lastGitHubFailureCheckUrl ?? undefined,
72
+ });
31
73
  }
32
74
  async probeQueuedIssue(issue) {
33
75
  if (!issue.prNumber)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {