patchrelay 0.61.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.61.0",
4
- "commit": "d6c22120c20c",
5
- "builtAt": "2026-05-04T22:20:57.540Z"
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
@@ -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.61.0",
3
+ "version": "0.62.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {