patchrelay 0.71.2 → 0.73.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.
@@ -161,6 +161,13 @@ export function buildAgentSessionPlan(params) {
161
161
  ], ["completed", "completed", "completed", "inProgress"]);
162
162
  case "repairing_queue":
163
163
  return setStatuses(queueRepairPlan(params.queueRepairAttempts ?? 1), ["completed", "completed", "completed", "inProgress"]);
164
+ case "deploying":
165
+ return setStatuses([
166
+ { content: "Prepare workspace", status: "completed" },
167
+ { content: "Verification passed", status: "completed" },
168
+ { content: "Merged", status: "completed" },
169
+ { content: "Deploying", status: "inProgress" },
170
+ ], ["completed", "completed", "completed", "inProgress"]);
164
171
  case "awaiting_input":
165
172
  return awaitingInputPlan();
166
173
  case "escalated":
@@ -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.73.0",
4
+ "commit": "46cf80cc9eb7",
5
+ "builtAt": "2026-05-24T02:06:29.741Z"
6
6
  }
@@ -8,12 +8,13 @@ const STATE_LABELS = {
8
8
  repairing_ci: "repairing_ci",
9
9
  awaiting_queue: "awaiting_queue",
10
10
  repairing_queue: "repairing_queue",
11
+ deploying: "deploying",
11
12
  awaiting_input: "awaiting_input",
12
13
  escalated: "escalated",
13
14
  done: "done",
14
15
  failed: "failed",
15
16
  };
16
- const MAIN_STATES = ["delegated", "implementing", "pr_open", "awaiting_queue", "done"];
17
+ const MAIN_STATES = ["delegated", "implementing", "pr_open", "awaiting_queue", "deploying", "done"];
17
18
  const PR_LOOP_STATES = ["changes_requested", "repairing_ci"];
18
19
  const QUEUE_LOOP_STATES = ["repairing_queue"];
19
20
  const EXIT_STATES = ["awaiting_input", "escalated", "failed"];
@@ -139,6 +140,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
139
140
  : "PatchRelay is preparing or waiting to resume queue repair.",
140
141
  });
141
142
  break;
143
+ case "deploying":
144
+ observations.push({
145
+ tone: "info",
146
+ text: "PatchRelay merged the PR and is watching the deploy workflow on main.",
147
+ });
148
+ break;
142
149
  case "done":
143
150
  observations.push({
144
151
  tone: "success",
@@ -480,5 +480,8 @@ export function mapIssueRow(row) {
480
480
  ...(row.orchestration_settle_until !== null && row.orchestration_settle_until !== undefined
481
481
  ? { orchestrationSettleUntil: String(row.orchestration_settle_until) }
482
482
  : {}),
483
+ ...(row.deploy_started_at !== null && row.deploy_started_at !== undefined
484
+ ? { deployStartedAt: String(row.deploy_started_at) }
485
+ : {}),
483
486
  };
484
487
  }
@@ -71,6 +71,7 @@ export const ISSUE_COLUMN_DEFS = {
71
71
  zombieRecoveryAttempts: { column: "zombie_recovery_attempts", insertDefault: 0 },
72
72
  lastZombieRecoveryAt: { column: "last_zombie_recovery_at" },
73
73
  orchestrationSettleUntil: { column: "orchestration_settle_until" },
74
+ deployStartedAt: { column: "deploy_started_at" },
74
75
  };
75
76
  export const ISSUE_COLUMN_KEYS = Object.keys(ISSUE_COLUMN_DEFS);
76
77
  /**
@@ -359,6 +359,10 @@ export function runPatchRelayMigrations(connection) {
359
359
  removeRetiredIssueColumnsIfPresent(connection);
360
360
  addColumnIfMissing(connection, "issues", "issue_triage_hash", "TEXT");
361
361
  addColumnIfMissing(connection, "issues", "issue_triage_result_json", "TEXT");
362
+ // PR3: post-merge deploy tracking. Timestamp the issue entered the
363
+ // `deploying` state, so the deploy watcher only considers deploy runs
364
+ // created at/after the merge (and can time out a never-arriving deploy).
365
+ addColumnIfMissing(connection, "issues", "deploy_started_at", "TEXT");
362
366
  }
363
367
  function addColumnIfMissing(connection, table, column, definition) {
364
368
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
@@ -1,10 +1,16 @@
1
1
  import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
2
+ import { resolvePostMergeFactoryState } from "./post-merge-deploy.js";
2
3
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
4
  import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
5
  import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
5
6
  export async function handleGitHubTerminalPrEvent(params) {
6
7
  const { db, linearProvider, wakeDispatcher, logger, codex, issue, event, config } = params;
7
8
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
9
+ // PR3: when the project configures a deploy workflow, a merge enters the
10
+ // `deploying` watch state instead of completing immediately. Linear
11
+ // completion is deferred until the deploy succeeds (idle reconciler).
12
+ const project = config.projects.find((candidate) => candidate.id === issue.projectId);
13
+ const postMergeState = resolvePostMergeFactoryState(project);
8
14
  db.issueSessions.appendIssueSessionEvent({
9
15
  projectId: issue.projectId,
10
16
  linearIssueId: issue.linearIssueId,
@@ -37,13 +43,14 @@ export async function handleGitHubTerminalPrEvent(params) {
37
43
  });
38
44
  }
39
45
  const terminalFactoryState = event.triggerEvent === "pr_merged"
40
- ? "done"
46
+ ? postMergeState
41
47
  : resolveClosedPrFactoryState(issue);
42
48
  db.issues.upsertIssue({
43
49
  projectId: issue.projectId,
44
50
  linearIssueId: issue.linearIssueId,
45
51
  activeRunId: null,
46
52
  factoryState: terminalFactoryState,
53
+ ...(terminalFactoryState === "deploying" ? { deployStartedAt: new Date().toISOString() } : {}),
47
54
  });
48
55
  };
49
56
  const activeLease = db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
@@ -68,7 +75,12 @@ export async function handleGitHubTerminalPrEvent(params) {
68
75
  eventType: "child_delivered",
69
76
  wakeDispatcher,
70
77
  });
71
- await completeLinearIssueAfterMerge(params, updatedIssue);
78
+ // Only complete Linear now when there's no deploy to watch. While
79
+ // `deploying`, the issue stays in the Deploying state and the idle
80
+ // reconciler completes it once the deploy workflow succeeds.
81
+ if (postMergeState === "done") {
82
+ await completeLinearIssueAfterMerge(params, updatedIssue);
83
+ }
72
84
  }
73
85
  void syncGitHubLinearSession({
74
86
  config,
@@ -1,3 +1,5 @@
1
+ import { TERMINAL_STATES } from "./factory-state.js";
2
+ import { DEPLOY_WATCH_TIMEOUT_MS, evaluateDeploy, isDeployTrackingEnabled, } from "./post-merge-deploy.js";
1
3
  import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
2
4
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
5
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
@@ -14,12 +16,16 @@ export class IdleIssueReconciler {
14
16
  wakeDispatcher;
15
17
  logger;
16
18
  feed;
17
- constructor(db, config, wakeDispatcher, logger, feed) {
19
+ deployEvaluator;
20
+ constructor(db, config, wakeDispatcher, logger, feed,
21
+ // Injectable for tests; production uses the real `gh`-backed watcher.
22
+ deployEvaluator = evaluateDeploy) {
18
23
  this.db = db;
19
24
  this.config = config;
20
25
  this.wakeDispatcher = wakeDispatcher;
21
26
  this.logger = logger;
22
27
  this.feed = feed;
28
+ this.deployEvaluator = deployEvaluator;
23
29
  }
24
30
  async reconcile() {
25
31
  // Wrap the entire reconcile pass in a dispatcher tick. Every
@@ -34,7 +40,7 @@ export class IdleIssueReconciler {
34
40
  async reconcileBody() {
35
41
  for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
36
42
  if (issue.prState === "merged") {
37
- this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
43
+ await this.handleMergedIssue(issue);
38
44
  continue;
39
45
  }
40
46
  if (issue.lastGitHubFailureSource === "queue_eviction") {
@@ -119,8 +125,101 @@ export class IdleIssueReconciler {
119
125
  return false;
120
126
  if (issue.pendingRunType !== undefined)
121
127
  return false;
128
+ // A merged PR cannot be un-merged: never re-probe it back toward the
129
+ // queue. This matters for deploy-failed issues (escalated while
130
+ // prState === "merged") — recovery-to-awaiting_queue would be wrong.
131
+ if (issue.prState === "merged")
132
+ return false;
122
133
  return issue.factoryState === "escalated" || issue.factoryState === "failed";
123
134
  }
135
+ // PR3: route a merged PR either into post-merge deploy tracking or
136
+ // straight to done. Called from both the idle pass and the GitHub
137
+ // reconcile path, so the deploying-vs-done decision lives in one place.
138
+ async handleMergedIssue(issue) {
139
+ if (issue.factoryState === "deploying") {
140
+ await this.watchDeploy(issue);
141
+ return;
142
+ }
143
+ // Already finalized (done/escalated/failed) — never re-open it.
144
+ if (TERMINAL_STATES.has(issue.factoryState))
145
+ return;
146
+ const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
147
+ if (isDeployTrackingEnabled(project)) {
148
+ this.advanceIdleIssue(issue, "deploying", { clearFailureProvenance: true });
149
+ this.db.issues.upsertIssue({
150
+ projectId: issue.projectId,
151
+ linearIssueId: issue.linearIssueId,
152
+ deployStartedAt: new Date().toISOString(),
153
+ });
154
+ }
155
+ else {
156
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
157
+ }
158
+ }
159
+ // Poll the project's deploy workflow for a merged issue sitting in
160
+ // `deploying`: success → done, failure → escalate, still running → wait
161
+ // (with a timeout backstop so a never-arriving deploy can't strand it).
162
+ async watchDeploy(issue) {
163
+ const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
164
+ const protocol = resolveMergeQueueProtocol(project);
165
+ const workflowName = protocol.deployWorkflowName;
166
+ const repoFullName = protocol.repoFullName;
167
+ if (!workflowName || !repoFullName) {
168
+ // Misconfigured / tracking disabled after entering — don't strand it.
169
+ this.finishDeploy(issue, "done");
170
+ return;
171
+ }
172
+ const since = issue.deployStartedAt ?? issue.updatedAt;
173
+ const outcome = await this.deployEvaluator({
174
+ repoFullName,
175
+ workflowName,
176
+ baseBranch: protocol.baseBranch ?? "main",
177
+ sinceIso: since,
178
+ logger: this.logger,
179
+ });
180
+ if (outcome === "succeeded") {
181
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Deploy succeeded; completing issue");
182
+ this.finishDeploy(issue, "done");
183
+ this.feed?.publish({
184
+ level: "info",
185
+ kind: "stage",
186
+ issueKey: issue.issueKey,
187
+ projectId: issue.projectId,
188
+ stage: "done",
189
+ status: "deployed",
190
+ summary: `Deploy succeeded for PR #${issue.prNumber}`,
191
+ });
192
+ return;
193
+ }
194
+ if (outcome === "failed") {
195
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Deploy failed; escalating for operator attention");
196
+ this.finishDeploy(issue, "escalated");
197
+ this.feed?.publish({
198
+ level: "error",
199
+ kind: "workflow",
200
+ issueKey: issue.issueKey,
201
+ projectId: issue.projectId,
202
+ stage: "deploying",
203
+ status: "deploy_failed",
204
+ summary: `Deploy failed for PR #${issue.prNumber}; needs operator attention`,
205
+ });
206
+ return;
207
+ }
208
+ // Still pending — apply the timeout backstop.
209
+ const sinceMs = Date.parse(since);
210
+ if (Number.isFinite(sinceMs) && Date.now() - sinceMs > DEPLOY_WATCH_TIMEOUT_MS) {
211
+ this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Deploy not observed within timeout; completing issue (change is already on main)");
212
+ this.finishDeploy(issue, "done");
213
+ }
214
+ }
215
+ finishDeploy(issue, state) {
216
+ this.advanceIdleIssue(issue, state, state === "done" ? { clearFailureProvenance: true } : undefined);
217
+ this.db.issues.upsertIssue({
218
+ projectId: issue.projectId,
219
+ linearIssueId: issue.linearIssueId,
220
+ deployStartedAt: null,
221
+ });
222
+ }
124
223
  advanceIdleIssue(issue, newState, options) {
125
224
  if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
126
225
  return;
@@ -367,7 +466,8 @@ export class IdleIssueReconciler {
367
466
  });
368
467
  if (pr.state === "MERGED") {
369
468
  this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
370
- this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
469
+ const merged = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? { ...issue, prState: "merged" };
470
+ await this.handleMergedIssue(merged);
371
471
  return;
372
472
  }
373
473
  if (pr.state === "CLOSED") {
@@ -203,6 +203,8 @@ function statusHeadline(issue, activeRunType) {
203
203
  return "Handed off downstream for merge";
204
204
  case "repairing_queue":
205
205
  return "Repairing merge handoff";
206
+ case "deploying":
207
+ return issue.prNumber !== undefined ? `Deploying merged PR #${issue.prNumber}` : "Deploying after merge";
206
208
  case "awaiting_input":
207
209
  return "Waiting for more input";
208
210
  case "failed":
@@ -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,114 @@ 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 signals: factoryState === "deploying" (post-merge deploy
153
+ // watch in progress) or the PR is merged but not yet done.
154
+ if (issue.factoryState === "deploying" || normalize(issue.prState) === "merged") {
155
+ return resolvePreferredDeployingLinearState(liveIssue);
156
+ }
157
+ // 5. Patchrelay is actively addressing review/CI/queue feedback →
158
+ // Implementing. These factory states persist for the run's whole
159
+ // duration, so this is stable, not flappy — and it is exactly the
160
+ // "show when patchrelay handles feedback" behavior we want.
161
+ if (isAddressingFeedback(issue)) {
143
162
  return resolvePreferredImplementingLinearState(liveIssue);
144
163
  }
145
- if (issue.factoryState === "awaiting_queue"
146
- || issue.prReviewState === "approved"
147
- || isApprovedAndGreen(issue.prReviewState, issue.prCheckStatus)) {
148
- return resolvePreferredDeployingLinearState(liveIssue);
164
+ // 6. Approved / admitted to the merge queue → In Merge Queue.
165
+ if (isInMergeQueue(issue)) {
166
+ return resolvePreferredMergeQueueLinearState(liveIssue);
149
167
  }
150
- if (hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson)) {
168
+ // 7. Pre-review-feedback implementation work (incl. a draft PR)
169
+ // Implementing.
170
+ if (isImplementing(issue, trackedIssue)) {
171
+ return resolvePreferredImplementingLinearState(liveIssue);
172
+ }
173
+ // 8. PR exists and is under review → Reviewing.
174
+ if (isReviewBound(issue)) {
151
175
  return resolvePreferredReviewingLinearState(liveIssue);
152
176
  }
153
- const reviewBound = issue.prNumber !== undefined
177
+ return undefined;
178
+ }
179
+ function normalize(value) {
180
+ const trimmed = value?.trim().toLowerCase();
181
+ return trimmed ? trimmed : undefined;
182
+ }
183
+ function needsHumanAttention(issue, trackedIssue) {
184
+ return issue.factoryState === "awaiting_input"
185
+ || issue.factoryState === "failed"
186
+ || issue.factoryState === "escalated"
187
+ || trackedIssue?.sessionState === "waiting_input"
188
+ || trackedIssue?.sessionState === "failed";
189
+ }
190
+ // Active code work to address feedback. Durable factory states +
191
+ // changes-requested review verdict — no run-id involvement. Gated on
192
+ // delegation: an undelegated PR (operator paused us) is not being worked
193
+ // by patchrelay, so it must not read as Implementing.
194
+ function isAddressingFeedback(issue) {
195
+ if (issue.delegatedToPatchRelay === false)
196
+ return false;
197
+ return issue.factoryState === "changes_requested"
198
+ || issue.factoryState === "repairing_ci"
199
+ || issue.factoryState === "repairing_queue"
200
+ || normalize(issue.prReviewState) === "changes_requested";
201
+ }
202
+ // Approved and heading to / sitting in the merge queue. Not yet merged
203
+ // (branch 4 catches merged first).
204
+ function isInMergeQueue(issue) {
205
+ return issue.factoryState === "awaiting_queue"
206
+ || normalize(issue.prReviewState) === "approved";
207
+ }
208
+ // Initial implementation, before review starts. A draft PR still counts
209
+ // as implementing. Gated on delegation so we never claim Implementing
210
+ // for work that isn't ours.
211
+ function isImplementing(issue, trackedIssue) {
212
+ if (issue.delegatedToPatchRelay === false)
213
+ return false;
214
+ if (issue.factoryState === "implementing")
215
+ return true;
216
+ if (issue.factoryState === "delegated") {
217
+ const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
218
+ return !blocked && trackedIssue?.readyForExecution !== false;
219
+ }
220
+ return issue.prIsDraft === true;
221
+ }
222
+ function isReviewBound(issue) {
223
+ return issue.prNumber !== undefined
154
224
  || Boolean(issue.prUrl)
155
225
  || issue.factoryState === "pr_open"
156
226
  || 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");
227
+ || issue.prCheckStatus !== undefined
228
+ || hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson);
167
229
  }
168
230
  function hasPendingReviewQuillVerdict(snapshotJson) {
169
231
  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
  });
@@ -19,5 +19,6 @@ export function resolveMergeQueueProtocol(project) {
19
19
  specBranchPattern: project?.github?.specBranchPattern ?? DEFAULT_SPEC_BRANCH_PATTERN,
20
20
  noCacheLabel: project?.github?.noCacheLabel ?? DEFAULT_NO_CACHE_LABEL,
21
21
  queuedForDeployLabel: project?.github?.queuedForDeployLabel ?? DEFAULT_QUEUED_FOR_DEPLOY_LABEL,
22
+ deployWorkflowName: project?.github?.deployWorkflowName,
22
23
  };
23
24
  }
@@ -0,0 +1,83 @@
1
+ import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
+ import { execCommand } from "./utils.js";
3
+ // How long an issue may sit in `deploying` before we give up watching and
4
+ // advance to `done` anyway. The change is already on `main`; if no deploy
5
+ // run ever shows up (no workflow triggered, or it was superseded and GC'd),
6
+ // we must not strand the issue. 20 minutes comfortably covers a queued +
7
+ // running deploy without leaving issues stuck for hours.
8
+ export const DEPLOY_WATCH_TIMEOUT_MS = 20 * 60_000;
9
+ // Small grace window: a deploy run triggered by the merge push can be
10
+ // created a few seconds before we stamp `deployStartedAt`. Don't exclude it.
11
+ const SINCE_GRACE_MS = 2 * 60_000;
12
+ /**
13
+ * Whether a merge should enter the `deploying` watch state. Opt-in per
14
+ * project via `github.deployWorkflowName`; absent → advance straight to
15
+ * `done` (today's behavior, no risk of stranding issues).
16
+ */
17
+ export function isDeployTrackingEnabled(project) {
18
+ return Boolean(resolveMergeQueueProtocol(project).deployWorkflowName);
19
+ }
20
+ export function resolvePostMergeFactoryState(project) {
21
+ return isDeployTrackingEnabled(project) ? "deploying" : "done";
22
+ }
23
+ /**
24
+ * Decide the deploy outcome from the recent runs of the deploy workflow on
25
+ * the base branch. Pure and total so it can be unit-tested without GitHub.
26
+ *
27
+ * Only runs created at/after `sinceIso` (minus a small grace) count — any
28
+ * deploy on `main` after the merge includes the merged change, since `main`
29
+ * only moves forward. The most recent decisive run wins; cancelled/skipped
30
+ * runs are ignored (a later run supersedes them).
31
+ */
32
+ export function interpretDeployRuns(runs, sinceIso) {
33
+ const sinceMs = Date.parse(sinceIso);
34
+ const cutoff = Number.isFinite(sinceMs) ? sinceMs - SINCE_GRACE_MS : -Infinity;
35
+ const relevant = runs
36
+ .filter((r) => {
37
+ const t = Date.parse(r.createdAt);
38
+ return Number.isFinite(t) && t >= cutoff;
39
+ })
40
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
41
+ for (const run of relevant) {
42
+ const conclusion = (run.conclusion ?? "").toLowerCase();
43
+ const status = run.status.toLowerCase();
44
+ if (status !== "completed") {
45
+ // queued / in_progress / waiting / requested → still deploying.
46
+ return "pending";
47
+ }
48
+ if (conclusion === "success")
49
+ return "succeeded";
50
+ if (conclusion === "failure" || conclusion === "timed_out" || conclusion === "startup_failure") {
51
+ return "failed";
52
+ }
53
+ // cancelled / skipped / neutral / stale / action_required — not decisive;
54
+ // look at the next-most-recent run.
55
+ }
56
+ return "pending";
57
+ }
58
+ /**
59
+ * Query the deploy workflow's recent runs on the base branch and interpret
60
+ * them. Returns "pending" on any query error so the watcher simply retries
61
+ * next tick (and the timeout backstops a permanently-absent deploy).
62
+ */
63
+ export async function evaluateDeploy(params) {
64
+ const { repoFullName, workflowName, baseBranch, sinceIso, logger } = params;
65
+ try {
66
+ const { stdout } = await execCommand("gh", [
67
+ "run", "list",
68
+ "--repo", repoFullName,
69
+ "--workflow", workflowName,
70
+ "--branch", baseBranch,
71
+ "--json", "status,conclusion,createdAt",
72
+ "-L", "15",
73
+ ], { timeoutMs: 15_000 });
74
+ const runs = JSON.parse(stdout);
75
+ if (!Array.isArray(runs))
76
+ return "pending";
77
+ return interpretDeployRuns(runs, sinceIso);
78
+ }
79
+ catch (error) {
80
+ logger?.debug({ repoFullName, workflowName, error: error instanceof Error ? error.message : String(error) }, "Deploy watch query failed; will retry");
81
+ return "pending";
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.71.2",
3
+ "version": "0.73.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {