patchrelay 0.36.1 → 0.36.3

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.36.1",
4
- "commit": "ceada430ca0b",
5
- "builtAt": "2026-04-08T23:27:56.372Z"
3
+ "version": "0.36.3",
4
+ "commit": "e132192dca38",
5
+ "builtAt": "2026-04-08T23:42:01.882Z"
6
6
  }
@@ -327,6 +327,21 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
327
327
  },
328
328
  };
329
329
  }
330
+ if (gateCheckStatus === "success"
331
+ && reviewDecision === "CHANGES_REQUESTED"
332
+ && mergeConflictDetected
333
+ && issue.factoryState !== "changes_requested"
334
+ && issue.activeRunId === undefined
335
+ && ageMs >= RECONCILIATION_GRACE_MS) {
336
+ return {
337
+ ciEntry,
338
+ finding: {
339
+ status: "fail",
340
+ scope: "github:branch-upkeep",
341
+ message: "PR is still dirty after requested changes, but no branch-upkeep run is active",
342
+ },
343
+ };
344
+ }
330
345
  if (gateCheckStatus === "success"
331
346
  && reviewDecision === "CHANGES_REQUESTED"
332
347
  && latestBlockingReviewHeadSha === pr.headRefOid
@@ -423,6 +438,9 @@ function deriveCiOwner(params) {
423
438
  : "downstream";
424
439
  }
425
440
  if (params.reviewDecision === "CHANGES_REQUESTED") {
441
+ if (params.mergeConflictDetected) {
442
+ return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
443
+ }
426
444
  if (params.factoryState === "changes_requested")
427
445
  return "patchrelay";
428
446
  if (params.reviewQuillAttempt)
@@ -451,6 +469,9 @@ function describeCiOwnership(params) {
451
469
  && params.latestBlockingReviewHeadSha
452
470
  && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
453
471
  if (params.owner === "patchrelay") {
472
+ if (params.mergeConflictDetected) {
473
+ return "PatchRelay owns the next branch-upkeep move";
474
+ }
454
475
  return params.gateCheckStatus === "failure"
455
476
  ? "PatchRelay owns the next CI repair move"
456
477
  : "PatchRelay owns the next requested-changes move";
@@ -479,6 +500,11 @@ function describeCiOwnership(params) {
479
500
  : "Waiting on external GitHub automation";
480
501
  }
481
502
  if (params.reviewDecision === "CHANGES_REQUESTED") {
503
+ if (params.mergeConflictDetected) {
504
+ return headAdvancedPastBlockingReview
505
+ ? "PR is still dirty after a newer pushed head and no branch-upkeep run is active"
506
+ : "PR is still dirty on the current blocked head and no branch-upkeep run is active";
507
+ }
482
508
  return blockingReviewTargetsCurrentHead
483
509
  ? "Requested changes still block the same head and no fix run is active"
484
510
  : "Waiting on review after a newer pushed head";
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  import { relativeTime, truncate } from "./format-utils.js";
5
- import { hasDisplayPrBlocker, isRereviewNeeded, prChecksFact } from "./pr-status.js";
5
+ import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
6
6
  // ─── State display ──────────────────────────────────────────────
7
7
  const TERMINAL_STATES = new Set(["done", "failed", "escalated"]);
8
8
  function needsOperatorIntervention(issue) {
@@ -17,6 +17,8 @@ function effectiveState(issue) {
17
17
  return "blocked";
18
18
  if (issue.sessionState === "waiting_input")
19
19
  return "awaiting_input";
20
+ if (issue.prNumber !== undefined)
21
+ return issue.factoryState;
20
22
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
21
23
  return "ready";
22
24
  return issue.factoryState;
@@ -80,18 +82,22 @@ function buildFacts(issue, selected) {
80
82
  facts.push({ text: "operator action needed", color: "red" });
81
83
  }
82
84
  // Review state — only show when it matters (not yet approved, or changes requested)
83
- if (issue.prReviewState === "approved") {
85
+ if (isApprovedReviewState(issue.prReviewState)) {
84
86
  facts.push({ text: "approved", color: "green" });
85
87
  }
86
88
  else if (rereviewNeeded) {
87
89
  facts.push({ text: "re-review needed", color: "yellow" });
88
90
  }
89
- else if (issue.prReviewState === "changes_requested") {
91
+ else if (isChangesRequestedReviewState(issue.prReviewState)) {
90
92
  facts.push({ text: "changes requested", color: "yellow" });
91
93
  }
92
- else if (issue.prNumber !== undefined && !issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))) {
94
+ else if (issue.prNumber !== undefined
95
+ && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
93
96
  facts.push({ text: "awaiting review", color: "yellow" });
94
97
  }
98
+ if (issue.factoryState === "awaiting_queue") {
99
+ facts.push({ text: "merge queue", color: "cyan" });
100
+ }
95
101
  // Check status — compact
96
102
  const checksFact = prChecksFact(issue);
97
103
  if (checksFact) {
@@ -133,8 +139,10 @@ function blockerText(issue) {
133
139
  }
134
140
  if (rereviewNeeded)
135
141
  return "Awaiting re-review after requested changes";
136
- if (issue.prReviewState === "changes_requested")
142
+ if (isChangesRequestedReviewState(issue.prReviewState))
137
143
  return "Review changes requested";
144
+ if (issue.prNumber !== undefined && isAwaitingReviewState(issue.prReviewState))
145
+ return "Awaiting review";
138
146
  return null;
139
147
  }
140
148
  // ─── Render ─────────────────────────────────────────────────────
@@ -3,7 +3,7 @@ import { buildTimelineRows } from "./timeline-presentation.js";
3
3
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
4
4
  import { progressBar } from "./format-utils.js";
5
5
  import { describePatchRelayFreshness } from "./freshness.js";
6
- import { hasDisplayPrBlocker, isRereviewNeeded, prChecksFact } from "./pr-status.js";
6
+ import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
7
7
  import { renderRichTextLines, renderTextLines } from "./render-rich-text.js";
8
8
  const SESSION_DISPLAY = {
9
9
  idle: { label: "idle", color: "blueBright" },
@@ -400,12 +400,17 @@ function buildFactSegments(issue, issueContext) {
400
400
  const rereviewNeeded = isRereviewNeeded(issue);
401
401
  if (issue.prNumber !== undefined)
402
402
  facts.push([{ text: `PR #${issue.prNumber}`, color: "cyan" }]);
403
- if (issue.prReviewState === "approved")
403
+ if (isApprovedReviewState(issue.prReviewState))
404
404
  facts.push([{ text: "approved", color: "green" }]);
405
405
  else if (rereviewNeeded)
406
406
  facts.push([{ text: "re-review needed", color: "yellow" }]);
407
- else if (issue.prReviewState === "changes_requested")
407
+ else if (isChangesRequestedReviewState(issue.prReviewState))
408
408
  facts.push([{ text: "changes requested", color: "yellow" }]);
409
+ else if (issue.prNumber !== undefined
410
+ && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
411
+ facts.push([{ text: "awaiting review", color: "yellow" }]);
412
+ if (issue.factoryState === "awaiting_queue")
413
+ facts.push([{ text: "merge queue", color: "cyan" }]);
409
414
  if (issue.waitingReason && issue.sessionState === "waiting_input")
410
415
  facts.push([{ text: issue.waitingReason, color: "yellow" }]);
411
416
  const checks = prChecksFact({
@@ -512,6 +517,8 @@ function effectiveState(issue) {
512
517
  return "blocked";
513
518
  if (issue.sessionState === "waiting_input")
514
519
  return "awaiting_input";
520
+ if (issue.prNumber !== undefined)
521
+ return issue.factoryState;
515
522
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
516
523
  return "ready";
517
524
  return issue.factoryState;
@@ -549,10 +556,11 @@ function blockerText(issue, issueContext) {
549
556
  }
550
557
  if (rereviewNeeded)
551
558
  return "Awaiting re-review after requested changes";
552
- if (issue.prReviewState === "changes_requested")
559
+ if (isChangesRequestedReviewState(issue.prReviewState))
553
560
  return "Review changes requested";
554
- if (issue.prNumber !== undefined && !issue.prReviewState && effectiveState(issue) !== "done")
561
+ if (issue.prNumber !== undefined && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
555
562
  return "Awaiting review";
563
+ }
556
564
  return null;
557
565
  }
558
566
  function cleanCommand(raw) {
@@ -7,6 +7,20 @@ function isFailingCheckStatus(status) {
7
7
  function isPendingCheckStatus(status) {
8
8
  return status === "pending" || status === "in_progress";
9
9
  }
10
+ function normalizeReviewState(state) {
11
+ const normalized = state?.trim().toLowerCase();
12
+ return normalized && normalized.length > 0 ? normalized : undefined;
13
+ }
14
+ export function isApprovedReviewState(state) {
15
+ return normalizeReviewState(state) === "approved";
16
+ }
17
+ export function isChangesRequestedReviewState(state) {
18
+ return normalizeReviewState(state) === "changes_requested";
19
+ }
20
+ export function isAwaitingReviewState(state) {
21
+ const normalized = normalizeReviewState(state);
22
+ return normalized === "review_required" || normalized === "commented";
23
+ }
10
24
  export function hasPendingPrChecks(issue) {
11
25
  const summary = issue.prChecksSummary;
12
26
  if (summary?.total) {
@@ -29,7 +43,7 @@ export function arePrChecksCompleteAndGreen(issue) {
29
43
  return isPassingCheckStatus(issue.prCheckStatus);
30
44
  }
31
45
  export function isRereviewNeeded(issue) {
32
- return issue.prReviewState === "changes_requested"
46
+ return isChangesRequestedReviewState(issue.prReviewState)
33
47
  && arePrChecksCompleteAndGreen(issue)
34
48
  && !issue.activeRunType;
35
49
  }
@@ -61,10 +75,13 @@ export function hasDisplayPrBlocker(issue) {
61
75
  if (issue.prNumber === undefined || issue.activeRunType) {
62
76
  return false;
63
77
  }
78
+ if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
79
+ return true;
80
+ }
64
81
  if (hasPendingPrChecks(issue) || hasFailedPrChecks(issue)) {
65
82
  return true;
66
83
  }
67
- if (issue.prReviewState === "changes_requested" && !isRereviewNeeded(issue)) {
84
+ if (isChangesRequestedReviewState(issue.prReviewState) && !isRereviewNeeded(issue)) {
68
85
  return true;
69
86
  }
70
87
  if (!issue.prReviewState && issue.factoryState === "pr_open") {
@@ -67,7 +67,7 @@ export function computeAggregates(issues) {
67
67
  active++;
68
68
  if (!issue.activeRunType && issue.blockedByCount > 0)
69
69
  blocked++;
70
- if (!issue.activeRunType && issue.readyForExecution && !isDone && !isFailed)
70
+ if (!issue.activeRunType && issue.prNumber === undefined && issue.readyForExecution && !isDone && !isFailed)
71
71
  ready++;
72
72
  if (isDone)
73
73
  done++;
@@ -16,6 +16,22 @@ function isReviewDecisionChangesRequested(value) {
16
16
  function isReviewDecisionReviewRequired(value) {
17
17
  return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
18
18
  }
19
+ function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
20
+ const promptContext = [
21
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
22
+ `This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
23
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
24
+ ].join(" ");
25
+ return {
26
+ branchUpkeepRequired: true,
27
+ reviewFixMode: "branch_upkeep",
28
+ wakeReason: "branch_upkeep",
29
+ promptContext,
30
+ ...(mergeStateStatus ? { mergeStateStatus } : {}),
31
+ ...(headSha ? { failingHeadSha: headSha } : {}),
32
+ baseBranch,
33
+ };
34
+ }
19
35
  function hasCompletedReviewQuillVerdict(entries) {
20
36
  return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
21
37
  && entry.name === "review-quill/verdict"
@@ -475,6 +491,23 @@ export class IdleIssueReconciler {
475
491
  mergeConflictDetected,
476
492
  downstreamOwned,
477
493
  });
494
+ if (reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
495
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
496
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
497
+ pendingRunType: reactiveIntent.runType,
498
+ pendingRunContext: buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid),
499
+ });
500
+ this.feed?.publish({
501
+ level: "warn",
502
+ kind: "github",
503
+ issueKey: issue.issueKey,
504
+ projectId: issue.projectId,
505
+ stage: reactiveIntent.compatibilityFactoryState,
506
+ status: "branch_upkeep_queued",
507
+ summary: `PR #${issue.prNumber} is still dirty after requested changes, dispatching branch upkeep`,
508
+ });
509
+ return;
510
+ }
478
511
  if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
479
512
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
480
513
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
@@ -510,7 +543,7 @@ export class IdleIssueReconciler {
510
543
  return;
511
544
  }
512
545
  if (mergeConflictDetected) {
513
- this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR is dirty but not yet queue-admitted; leaving PatchRelay in review state");
546
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR is dirty but no automation owner was derived");
514
547
  }
515
548
  }
516
549
  catch (error) {
@@ -59,6 +59,13 @@ export function deriveIssueSessionReactiveIntent(params) {
59
59
  };
60
60
  }
61
61
  if (params.prReviewState === "changes_requested") {
62
+ if (params.mergeConflictDetected) {
63
+ return {
64
+ runType: "branch_upkeep",
65
+ wakeReason: "branch_upkeep",
66
+ compatibilityFactoryState: "changes_requested",
67
+ };
68
+ }
62
69
  return {
63
70
  runType: "review_fix",
64
71
  wakeReason: "review_changes_requested",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.1",
3
+ "version": "0.36.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {