patchrelay 0.71.2 → 0.72.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.71.2",
4
- "commit": "252cbbebc8d3",
5
- "builtAt": "2026-05-24T00:18:53.503Z"
3
+ "version": "0.72.0",
4
+ "commit": "d4d672824c5f",
5
+ "builtAt": "2026-05-24T01:19:45.865Z"
6
6
  }
@@ -1,4 +1,4 @@
1
- import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredDeployLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredMergeQueueLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
3
  import { isCompletedLinearState } from "./pr-state.js";
4
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
@@ -67,24 +67,21 @@ async function syncQueuedForDeployLabel(params) {
67
67
  await linear.updateIssueLabels({ issueId: issue.linearIssueId, removeNames: [labelName] });
68
68
  }
69
69
  }
70
- // True only when (a) the issue is In Deploy AND (b) the project's
71
- // Linear workflow has no In Deploy-equivalent state — detected by the
72
- // preferred-deploying state collapsing to the same name as the
73
- // preferred-review state. When the project does have a real In Deploy
74
- // state, `setIssueState` flows the issue there and the label is
75
- // unnecessary.
70
+ // True only when (a) the issue is in the merge queue (`awaiting_queue`)
71
+ // AND (b) the project's Linear workflow has no dedicated In Merge Queue
72
+ // state — detected by the preferred merge-queue state collapsing to the
73
+ // same name as the reviewing state. When the project has a real In Merge
74
+ // Queue (or Deploying) state, `setIssueState` flows the issue there and
75
+ // the label is unnecessary.
76
76
  function isQueuedForDeployFallback(issue, liveIssue) {
77
77
  if (issue.factoryState !== "awaiting_queue")
78
78
  return false;
79
- const deploying = resolvePreferredDeployingLinearState(liveIssue);
80
- const review = resolvePreferredReviewLinearState(liveIssue);
81
- const deployUnstarted = resolvePreferredDeployLinearState(liveIssue);
82
- if (!deploying || !review)
79
+ const mergeQueue = resolvePreferredMergeQueueLinearState(liveIssue);
80
+ const reviewing = resolvePreferredReviewingLinearState(liveIssue);
81
+ if (!mergeQueue || !reviewing)
83
82
  return false;
84
- // No "deploying"/"deploy" state in the workflow both resolve to
85
- // a review state. That's the fallback condition.
86
- return deploying.trim().toLowerCase() === review.trim().toLowerCase()
87
- && (deployUnstarted ?? "").trim().toLowerCase() === review.trim().toLowerCase();
83
+ // No dedicated merge-queue state it collapses to the reviewing state.
84
+ return mergeQueue.trim().toLowerCase() === reviewing.trim().toLowerCase();
88
85
  }
89
86
  async function syncCompletedLinearState(params) {
90
87
  const { db, issue, linear, liveIssue } = params;
@@ -121,49 +118,115 @@ function shouldAutoAdvanceLinearState(issue) {
121
118
  const normalizedName = issue.currentLinearState?.trim().toLowerCase();
122
119
  return normalizedName !== "done" && normalizedName !== "completed" && normalizedName !== "complete";
123
120
  }
124
- function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue) {
125
- if (issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated"
126
- || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
121
+ // ─── Unified PR-lifecycle Linear-state mapping ─────────────────────
122
+ //
123
+ // Five phases, in lifecycle order:
124
+ // Implementing → Reviewing → In Merge Queue → Deploying → Done
125
+ //
126
+ // Every phase is decided from DURABLE signals (factoryState, prState,
127
+ // prReviewState) — never the ephemeral activeRunId / sessionState / run
128
+ // type. That is what kills the Implementing↔Reviewing flap: the state
129
+ // only moves on a real lifecycle handoff (a review verdict, an approval,
130
+ // a merge), not on whichever transient webhook happens to recompute it
131
+ // while a run briefly holds a lease.
132
+ //
133
+ // Branches are ordered "furthest along the lifecycle wins" so a stale
134
+ // earlier signal can never pull a more-advanced issue backwards.
135
+ function resolveDesiredActiveWorkflowState(issue, trackedIssue, _options, liveIssue) {
136
+ // 1. Operator must act — overrides everything.
137
+ if (needsHumanAttention(issue, trackedIssue)) {
127
138
  return resolvePreferredHumanNeededLinearState(liveIssue);
128
139
  }
140
+ // 2. Completed → Done. Covers today's merge→done path (the factory has
141
+ // no post-merge state yet), so a done issue never reads as Deploying.
142
+ if (issue.factoryState === "done") {
143
+ return resolvePreferredCompletedLinearState(liveIssue);
144
+ }
145
+ // 3. Paused with no PR and nothing for us to do → backlog.
129
146
  const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
130
- const pausedNoPrWork = issue.prNumber === undefined && (!issue.delegatedToPatchRelay || blocked);
131
- if (pausedNoPrWork) {
147
+ const noPr = issue.prNumber === undefined && !issue.prUrl;
148
+ if (noPr && (issue.delegatedToPatchRelay === false || blocked)) {
132
149
  return resolvePreferredQueuedLinearState(liveIssue);
133
150
  }
134
- const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
135
- || options?.activeRunType !== undefined
136
- || trackedIssue?.sessionState === "running"
137
- || (issue.factoryState === "delegated" && !blocked && trackedIssue?.readyForExecution !== false)
138
- || issue.factoryState === "implementing"
139
- || issue.factoryState === "changes_requested"
140
- || issue.factoryState === "repairing_ci"
141
- || issue.factoryState === "repairing_queue");
142
- if (activelyWorking) {
151
+ // 4. Post-merge: the change is on main, deploy running → Deploying.
152
+ // Durable signal: the PR is merged. (Until PR3 makes merge a
153
+ // non-terminal phase this only fires in the merged-not-yet-done
154
+ // window; branch 2 already caught factoryState==="done".)
155
+ if (normalize(issue.prState) === "merged") {
156
+ return resolvePreferredDeployingLinearState(liveIssue);
157
+ }
158
+ // 5. Patchrelay is actively addressing review/CI/queue feedback →
159
+ // Implementing. These factory states persist for the run's whole
160
+ // duration, so this is stable, not flappy — and it is exactly the
161
+ // "show when patchrelay handles feedback" behavior we want.
162
+ if (isAddressingFeedback(issue)) {
143
163
  return resolvePreferredImplementingLinearState(liveIssue);
144
164
  }
145
- if (issue.factoryState === "awaiting_queue"
146
- || issue.prReviewState === "approved"
147
- || isApprovedAndGreen(issue.prReviewState, issue.prCheckStatus)) {
148
- return resolvePreferredDeployingLinearState(liveIssue);
165
+ // 6. Approved / admitted to the merge queue → In Merge Queue.
166
+ if (isInMergeQueue(issue)) {
167
+ return resolvePreferredMergeQueueLinearState(liveIssue);
149
168
  }
150
- if (hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson)) {
169
+ // 7. Pre-review-feedback implementation work (incl. a draft PR)
170
+ // Implementing.
171
+ if (isImplementing(issue, trackedIssue)) {
172
+ return resolvePreferredImplementingLinearState(liveIssue);
173
+ }
174
+ // 8. PR exists and is under review → Reviewing.
175
+ if (isReviewBound(issue)) {
151
176
  return resolvePreferredReviewingLinearState(liveIssue);
152
177
  }
153
- const reviewBound = issue.prNumber !== undefined
178
+ return undefined;
179
+ }
180
+ function normalize(value) {
181
+ const trimmed = value?.trim().toLowerCase();
182
+ return trimmed ? trimmed : undefined;
183
+ }
184
+ function needsHumanAttention(issue, trackedIssue) {
185
+ return issue.factoryState === "awaiting_input"
186
+ || issue.factoryState === "failed"
187
+ || issue.factoryState === "escalated"
188
+ || trackedIssue?.sessionState === "waiting_input"
189
+ || trackedIssue?.sessionState === "failed";
190
+ }
191
+ // Active code work to address feedback. Durable factory states +
192
+ // changes-requested review verdict — no run-id involvement. Gated on
193
+ // delegation: an undelegated PR (operator paused us) is not being worked
194
+ // by patchrelay, so it must not read as Implementing.
195
+ function isAddressingFeedback(issue) {
196
+ if (issue.delegatedToPatchRelay === false)
197
+ return false;
198
+ return issue.factoryState === "changes_requested"
199
+ || issue.factoryState === "repairing_ci"
200
+ || issue.factoryState === "repairing_queue"
201
+ || normalize(issue.prReviewState) === "changes_requested";
202
+ }
203
+ // Approved and heading to / sitting in the merge queue. Not yet merged
204
+ // (branch 4 catches merged first).
205
+ function isInMergeQueue(issue) {
206
+ return issue.factoryState === "awaiting_queue"
207
+ || normalize(issue.prReviewState) === "approved";
208
+ }
209
+ // Initial implementation, before review starts. A draft PR still counts
210
+ // as implementing. Gated on delegation so we never claim Implementing
211
+ // for work that isn't ours.
212
+ function isImplementing(issue, trackedIssue) {
213
+ if (issue.delegatedToPatchRelay === false)
214
+ return false;
215
+ if (issue.factoryState === "implementing")
216
+ return true;
217
+ if (issue.factoryState === "delegated") {
218
+ const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
219
+ return !blocked && trackedIssue?.readyForExecution !== false;
220
+ }
221
+ return issue.prIsDraft === true;
222
+ }
223
+ function isReviewBound(issue) {
224
+ return issue.prNumber !== undefined
154
225
  || Boolean(issue.prUrl)
155
226
  || issue.factoryState === "pr_open"
156
227
  || issue.prReviewState !== undefined
157
- || issue.prCheckStatus !== undefined;
158
- if (reviewBound) {
159
- return resolvePreferredReviewLinearState(liveIssue);
160
- }
161
- return undefined;
162
- }
163
- function isApprovedAndGreen(prReviewState, prCheckStatus) {
164
- const normalizedReview = prReviewState?.trim().toLowerCase();
165
- const normalizedChecks = prCheckStatus?.trim().toLowerCase();
166
- return normalizedReview === "approved" && (normalizedChecks === "success" || normalizedChecks === "passed");
228
+ || issue.prCheckStatus !== undefined
229
+ || hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson);
167
230
  }
168
231
  function hasPendingReviewQuillVerdict(snapshotJson) {
169
232
  if (!snapshotJson)
@@ -59,16 +59,37 @@ export function resolvePreferredReviewingLinearState(issue) {
59
59
  fallback: resolvePreferredReviewLinearState(issue),
60
60
  });
61
61
  }
62
+ // The pre-merge "approved, awaiting/undergoing landing" phase. Covers a
63
+ // PR that is queued, being tested in the speculative branch, or actively
64
+ // merging — i.e. everything the merge queue owns up to (but not past)
65
+ // the merge. NOT post-merge: that is `resolvePreferredDeployingLinearState`.
66
+ // Without a dedicated queue state, collapses to the reviewing state (and
67
+ // the `queued-for-deploy` label disambiguates — see state-sync).
68
+ export function resolvePreferredMergeQueueLinearState(issue) {
69
+ return resolvePreferredLinearState(issue, {
70
+ names: ["in merge queue", "merge queue", "in queue", "queued", "queue", "merging", "landing", "ready to merge"],
71
+ types: ["started"],
72
+ fallback: resolvePreferredLinearState(issue, {
73
+ names: ["in merge queue", "merge queue", "queued", "ready to merge", "ready to deploy", "ready for deploy", "to deploy", "merge"],
74
+ types: ["unstarted"],
75
+ fallback: resolvePreferredReviewingLinearState(issue),
76
+ }),
77
+ });
78
+ }
79
+ // Unstarted deploy column, used as a fallback by the started variant.
62
80
  export function resolvePreferredDeployLinearState(issue) {
63
81
  return resolvePreferredLinearState(issue, {
64
- names: ["deploy", "ready to deploy", "ready for deploy", "merge"],
82
+ names: ["deploy", "to deploy", "ready to ship"],
65
83
  types: ["unstarted"],
66
- fallback: resolvePreferredReviewLinearState(issue),
84
+ fallback: resolvePreferredMergeQueueLinearState(issue),
67
85
  });
68
86
  }
87
+ // Strictly POST-merge: the change is on main and the deploy workflow is
88
+ // running. "merging" lives in the merge-queue phase, not here. Without a
89
+ // dedicated deploy state, collapses back to the merge-queue state.
69
90
  export function resolvePreferredDeployingLinearState(issue) {
70
91
  return resolvePreferredLinearState(issue, {
71
- names: ["deploying", "merging", "shipping"],
92
+ names: ["deploying", "deployment", "in deploy", "shipping", "releasing", "rollout"],
72
93
  types: ["started"],
73
94
  fallback: resolvePreferredDeployLinearState(issue),
74
95
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.71.2",
3
+ "version": "0.72.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {