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.
- package/dist/agent-session-plan.js +7 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/state-visualization.js +8 -1
- package/dist/db/issue-store.js +3 -0
- package/dist/db/issue-upsert-columns.js +1 -0
- package/dist/db/migrations.js +4 -0
- package/dist/github-webhook-terminal-handler.js +14 -2
- package/dist/idle-reconciliation.js +103 -3
- package/dist/linear-status-comment-sync.js +2 -0
- package/dist/linear-workflow-state-sync.js +107 -45
- package/dist/linear-workflow.js +24 -3
- package/dist/merge-queue-protocol.js +1 -0
- package/dist/post-merge-deploy.js +83 -0
- package/package.json +1 -1
|
@@ -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":
|
package/dist/build-info.json
CHANGED
|
@@ -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",
|
package/dist/db/issue-store.js
CHANGED
|
@@ -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
|
/**
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
71
|
-
// Linear workflow has no In
|
|
72
|
-
// preferred-
|
|
73
|
-
//
|
|
74
|
-
// state, `setIssueState` flows the issue there and
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (!deploying || !review)
|
|
79
|
+
const mergeQueue = resolvePreferredMergeQueueLinearState(liveIssue);
|
|
80
|
+
const reviewing = resolvePreferredReviewingLinearState(liveIssue);
|
|
81
|
+
if (!mergeQueue || !reviewing)
|
|
83
82
|
return false;
|
|
84
|
-
// No
|
|
85
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
131
|
-
if (
|
|
147
|
+
const noPr = issue.prNumber === undefined && !issue.prUrl;
|
|
148
|
+
if (noPr && (issue.delegatedToPatchRelay === false || blocked)) {
|
|
132
149
|
return resolvePreferredQueuedLinearState(liveIssue);
|
|
133
150
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/linear-workflow.js
CHANGED
|
@@ -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", "
|
|
82
|
+
names: ["deploy", "to deploy", "ready to ship"],
|
|
65
83
|
types: ["unstarted"],
|
|
66
|
-
fallback:
|
|
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", "
|
|
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
|
+
}
|