patchrelay 0.35.16 → 0.36.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/README.md +11 -2
- package/dist/agent-session-plan.js +14 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +1 -0
- package/dist/cli/cluster-health.js +739 -0
- package/dist/cli/commands/cluster.js +14 -0
- package/dist/cli/data.js +9 -5
- package/dist/cli/help.js +21 -0
- package/dist/cli/index.js +27 -2
- package/dist/cli/output.js +38 -0
- package/dist/cli/watch/StateHistoryView.js +1 -0
- package/dist/cli/watch/TimelineRow.js +1 -0
- package/dist/cli/watch/detail-rows.js +1 -0
- package/dist/cli/watch/history-builder.js +1 -0
- package/dist/db/migrations.js +9 -0
- package/dist/db.js +32 -8
- package/dist/github-webhook-handler.js +5 -78
- package/dist/idle-reconciliation.js +88 -6
- package/dist/issue-query-service.js +2 -0
- package/dist/issue-session-events.js +2 -2
- package/dist/issue-session.js +2 -0
- package/dist/linear-session-reporting.js +2 -0
- package/dist/linear-session-sync.js +2 -0
- package/dist/run-orchestrator.js +196 -31
- package/dist/service.js +13 -5
- package/dist/waiting-reason.js +8 -2
- package/dist/webhook-handler.js +71 -13
- package/package.json +1 -1
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
|
|
2
|
+
import { ACTIVE_RUN_STATES } from "../factory-state.js";
|
|
3
|
+
const RECONCILIATION_GRACE_MS = 120_000;
|
|
4
|
+
const DOWNSTREAM_STALE_MS = 900_000;
|
|
5
|
+
export async function collectClusterHealth(config, db, runCommand) {
|
|
6
|
+
const checks = [];
|
|
7
|
+
const ciEntries = [];
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const issues = db.listIssues();
|
|
10
|
+
const openIssues = issues.filter((issue) => issue.factoryState !== "done");
|
|
11
|
+
const trackedByKey = new Map(issues
|
|
12
|
+
.filter((issue) => issue.issueKey)
|
|
13
|
+
.map((issue) => [issue.issueKey, issue]));
|
|
14
|
+
const trackedByLinearId = new Map(issues.map((issue) => [issue.linearIssueId, issue]));
|
|
15
|
+
const patchRelayProbe = await probePatchRelayService(config);
|
|
16
|
+
checks.push({
|
|
17
|
+
status: patchRelayProbe.status,
|
|
18
|
+
scope: "service:patchrelay",
|
|
19
|
+
message: patchRelayProbe.message,
|
|
20
|
+
});
|
|
21
|
+
const snapshots = openIssues.map((issue) => {
|
|
22
|
+
const tracked = db.getTrackedIssue(issue.projectId, issue.linearIssueId);
|
|
23
|
+
const deps = db.listIssueDependencies(issue.projectId, issue.linearIssueId);
|
|
24
|
+
const blockedBy = deps.filter((dep) => !isResolvedDependency(dep));
|
|
25
|
+
const missingTrackedBlockers = blockedBy.filter((dep) => {
|
|
26
|
+
if (trackedByLinearId.has(dep.blockerLinearIssueId))
|
|
27
|
+
return false;
|
|
28
|
+
if (dep.blockerIssueKey && trackedByKey.has(dep.blockerIssueKey))
|
|
29
|
+
return false;
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
issue,
|
|
34
|
+
session: db.getIssueSession(issue.projectId, issue.linearIssueId),
|
|
35
|
+
blockedBy,
|
|
36
|
+
missingTrackedBlockers,
|
|
37
|
+
ageMs: Math.max(0, now - Date.parse(issue.updatedAt || new Date(0).toISOString())),
|
|
38
|
+
readyForExecution: tracked?.readyForExecution ?? false,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const reviewRelevantIssues = snapshots.filter((snapshot) => needsReviewAutomation(snapshot.issue));
|
|
42
|
+
const queueRelevantIssues = snapshots.filter((snapshot) => snapshot.issue.factoryState === "awaiting_queue");
|
|
43
|
+
const reviewQuillProbe = reviewRelevantIssues.length > 0
|
|
44
|
+
? await probeOptionalService(runCommand, "review-quill", {
|
|
45
|
+
healthy: (payload) => {
|
|
46
|
+
const parsed = payload;
|
|
47
|
+
return parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
|
|
48
|
+
},
|
|
49
|
+
summarize: (payload) => {
|
|
50
|
+
const parsed = payload;
|
|
51
|
+
return parsed.health?.ok === true
|
|
52
|
+
? `Healthy (${typeof parsed.watch?.runningAttempts === "number" ? `${parsed.watch.runningAttempts} running attempts` : "service reachable"})`
|
|
53
|
+
: `Unhealthy (${parsed.healthError ?? "service health unavailable"})`;
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
: undefined;
|
|
57
|
+
if (reviewQuillProbe) {
|
|
58
|
+
checks.push({
|
|
59
|
+
status: reviewQuillProbe.status,
|
|
60
|
+
scope: "service:review-quill",
|
|
61
|
+
message: reviewQuillProbe.message,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const reviewQuillAttemptOwners = reviewQuillProbe?.status === "pass"
|
|
65
|
+
? await collectReviewQuillAttemptOwners(reviewRelevantIssues, config, runCommand)
|
|
66
|
+
: new Map();
|
|
67
|
+
const mergeStewardProbe = queueRelevantIssues.length > 0
|
|
68
|
+
? await probeOptionalService(runCommand, "merge-steward", {
|
|
69
|
+
healthy: (payload) => {
|
|
70
|
+
const parsed = payload;
|
|
71
|
+
return parsed.systemd?.ActiveState === "active";
|
|
72
|
+
},
|
|
73
|
+
summarize: (payload) => {
|
|
74
|
+
const parsed = payload;
|
|
75
|
+
return parsed.systemd?.ActiveState === "active"
|
|
76
|
+
? "Healthy"
|
|
77
|
+
: `Unhealthy (${parsed.systemd?.ActiveState ?? "unknown"})`;
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
: undefined;
|
|
81
|
+
if (mergeStewardProbe) {
|
|
82
|
+
checks.push({
|
|
83
|
+
status: mergeStewardProbe.status,
|
|
84
|
+
scope: "service:merge-steward",
|
|
85
|
+
message: mergeStewardProbe.message,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const snapshot of snapshots) {
|
|
89
|
+
const finding = evaluateLocalIssueHealth(snapshot);
|
|
90
|
+
if (finding) {
|
|
91
|
+
checks.push({
|
|
92
|
+
...finding,
|
|
93
|
+
...(snapshot.issue.issueKey ? { issueKey: snapshot.issue.issueKey } : {}),
|
|
94
|
+
projectId: snapshot.issue.projectId,
|
|
95
|
+
...(snapshot.issue.prNumber !== undefined ? { prNumber: snapshot.issue.prNumber } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const snapshot of snapshots) {
|
|
100
|
+
if (!snapshot.issue.prNumber) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
|
|
104
|
+
if (githubHealth.ciEntry) {
|
|
105
|
+
ciEntries.push(githubHealth.ciEntry);
|
|
106
|
+
}
|
|
107
|
+
if (githubHealth.finding) {
|
|
108
|
+
checks.push({
|
|
109
|
+
...githubHealth.finding,
|
|
110
|
+
...(snapshot.issue.issueKey ? { issueKey: snapshot.issue.issueKey } : {}),
|
|
111
|
+
projectId: snapshot.issue.projectId,
|
|
112
|
+
prNumber: snapshot.issue.prNumber,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const workflowFailures = checks.filter((check) => check.scope.startsWith("issue:") || check.scope.startsWith("github:"));
|
|
117
|
+
if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") && openIssues.length > 0) {
|
|
118
|
+
checks.push({
|
|
119
|
+
status: "pass",
|
|
120
|
+
scope: "workflow",
|
|
121
|
+
message: `All ${openIssues.length} non-done issues currently have active work, a tracked blocker, or a downstream owner`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (openIssues.length === 0) {
|
|
125
|
+
checks.push({
|
|
126
|
+
status: "pass",
|
|
127
|
+
scope: "workflow",
|
|
128
|
+
message: "No non-done issues are currently tracked",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (ciEntries.length > 0) {
|
|
132
|
+
const orphanedCi = ciEntries.filter((entry) => entry.orphaned);
|
|
133
|
+
checks.push({
|
|
134
|
+
status: orphanedCi.length === 0 ? "pass" : "fail",
|
|
135
|
+
scope: "ci",
|
|
136
|
+
message: orphanedCi.length === 0
|
|
137
|
+
? `Tracked ${ciEntries.length} PR-backed issue${ciEntries.length === 1 ? "" : "s"} and each PR has a visible next owner`
|
|
138
|
+
: `${orphanedCi.length} PR-backed issue${orphanedCi.length === 1 ? "" : "s"} ha${orphanedCi.length === 1 ? "s" : "ve"} no visible next owner`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const summary = {
|
|
142
|
+
trackedIssues: issues.length,
|
|
143
|
+
openIssues: openIssues.length,
|
|
144
|
+
activeRuns: openIssues.filter((issue) => issue.activeRunId !== undefined).length,
|
|
145
|
+
blockedIssues: snapshots.filter((snapshot) => snapshot.blockedBy.length > 0).length,
|
|
146
|
+
readyIssues: snapshots.filter((snapshot) => snapshot.readyForExecution).length,
|
|
147
|
+
ciTrackedPrs: ciEntries.length,
|
|
148
|
+
ciPending: ciEntries.filter((entry) => entry.gateStatus === "pending").length,
|
|
149
|
+
ciSuccess: ciEntries.filter((entry) => entry.gateStatus === "success").length,
|
|
150
|
+
ciFailure: ciEntries.filter((entry) => entry.gateStatus === "failure").length,
|
|
151
|
+
ciUnknown: ciEntries.filter((entry) => entry.gateStatus === "unknown").length,
|
|
152
|
+
ciOrphaned: ciEntries.filter((entry) => entry.orphaned).length,
|
|
153
|
+
passCount: checks.filter((check) => check.status === "pass").length,
|
|
154
|
+
warnCount: checks.filter((check) => check.status === "warn").length,
|
|
155
|
+
failCount: checks.filter((check) => check.status === "fail").length,
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
generatedAt: new Date().toISOString(),
|
|
159
|
+
ok: summary.failCount === 0,
|
|
160
|
+
summary,
|
|
161
|
+
checks,
|
|
162
|
+
ci: ciEntries,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function evaluateLocalIssueHealth(snapshot) {
|
|
166
|
+
const { issue, session, missingTrackedBlockers, blockedBy, ageMs, readyForExecution } = snapshot;
|
|
167
|
+
if (issue.factoryState === "failed" || issue.factoryState === "escalated") {
|
|
168
|
+
return {
|
|
169
|
+
status: "fail",
|
|
170
|
+
scope: "issue:terminal",
|
|
171
|
+
message: `Issue is in terminal failure state ${issue.factoryState}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (missingTrackedBlockers.length > 0) {
|
|
175
|
+
return {
|
|
176
|
+
status: "fail",
|
|
177
|
+
scope: "issue:blockers",
|
|
178
|
+
message: `Blocked by unmanaged issue${missingTrackedBlockers.length === 1 ? "" : "s"} ${missingTrackedBlockers.map((dep) => dep.blockerIssueKey ?? dep.blockerLinearIssueId).join(", ")}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (issue.activeRunId !== undefined && session?.sessionState !== "running") {
|
|
182
|
+
return {
|
|
183
|
+
status: "fail",
|
|
184
|
+
scope: "issue:run-state",
|
|
185
|
+
message: `Issue has active run #${issue.activeRunId} but session state is ${session?.sessionState ?? "missing"}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (issue.activeRunId === undefined && session?.sessionState === "running") {
|
|
189
|
+
return {
|
|
190
|
+
status: "fail",
|
|
191
|
+
scope: "issue:run-state",
|
|
192
|
+
message: "Issue session is marked running but no active run is attached",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (blockedBy.length > 0) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
if (readyForExecution && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
199
|
+
return {
|
|
200
|
+
status: "fail",
|
|
201
|
+
scope: "issue:dispatch",
|
|
202
|
+
message: "Issue is ready for execution but no active run has started",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
206
|
+
return {
|
|
207
|
+
status: "fail",
|
|
208
|
+
scope: "issue:dispatch",
|
|
209
|
+
message: `Issue is parked in ${issue.factoryState} without an active run`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (issue.factoryState === "delegated" && issue.activeRunId === undefined && !readyForExecution && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
213
|
+
return {
|
|
214
|
+
status: "fail",
|
|
215
|
+
scope: "issue:dispatch",
|
|
216
|
+
message: "Delegated issue is idle but no wake is queued",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (issue.factoryState === "awaiting_input" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
220
|
+
return {
|
|
221
|
+
status: "warn",
|
|
222
|
+
scope: "issue:operator",
|
|
223
|
+
message: "Issue is waiting on operator input",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (issue.factoryState === "awaiting_queue" && ageMs >= DOWNSTREAM_STALE_MS) {
|
|
227
|
+
return {
|
|
228
|
+
status: "warn",
|
|
229
|
+
scope: "issue:downstream",
|
|
230
|
+
message: "Issue has been waiting on downstream merge automation for a long time",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe) {
|
|
236
|
+
const { issue, ageMs } = snapshot;
|
|
237
|
+
const project = config.projects.find((entry) => entry.id === issue.projectId);
|
|
238
|
+
const repoFullName = project?.github?.repoFullName;
|
|
239
|
+
if (!repoFullName || issue.prNumber === undefined) {
|
|
240
|
+
return {
|
|
241
|
+
finding: issue.prNumber !== undefined
|
|
242
|
+
? {
|
|
243
|
+
status: "fail",
|
|
244
|
+
scope: "github:config",
|
|
245
|
+
message: "PR-backed issue has no GitHub repo configured",
|
|
246
|
+
}
|
|
247
|
+
: undefined,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const probe = await probeGitHubPullRequest(runCommand, repoFullName, issue.prNumber);
|
|
251
|
+
if (!probe.ok) {
|
|
252
|
+
return {
|
|
253
|
+
ciEntry: {
|
|
254
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
255
|
+
projectId: issue.projectId,
|
|
256
|
+
prNumber: issue.prNumber,
|
|
257
|
+
gateStatus: "unknown",
|
|
258
|
+
owner: "unknown",
|
|
259
|
+
orphaned: true,
|
|
260
|
+
factoryState: issue.factoryState,
|
|
261
|
+
message: `GitHub probe failed: ${probe.error}`,
|
|
262
|
+
},
|
|
263
|
+
finding: {
|
|
264
|
+
status: "warn",
|
|
265
|
+
scope: "github:probe",
|
|
266
|
+
message: `Unable to query GitHub PR state: ${probe.error}`,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const pr = probe.pr;
|
|
271
|
+
const gateCheckNames = getGateCheckNames(project);
|
|
272
|
+
const gateCheckStatus = deriveCiGateStatus(pr.statusCheckRollup, gateCheckNames);
|
|
273
|
+
const reviewDecision = pr.reviewDecision?.trim().toUpperCase();
|
|
274
|
+
const requestedReviewers = extractRequestedReviewerLogins(pr.reviewRequests);
|
|
275
|
+
const reviewRequested = requestedReviewers.length > 0;
|
|
276
|
+
const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
|
|
277
|
+
const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
|
|
278
|
+
const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
|
|
279
|
+
const ciEntry = buildCiEntry({
|
|
280
|
+
issue,
|
|
281
|
+
gateCheckStatus,
|
|
282
|
+
reviewDecision,
|
|
283
|
+
reviewRequested,
|
|
284
|
+
currentHeadSha: pr.headRefOid,
|
|
285
|
+
latestBlockingReviewHeadSha,
|
|
286
|
+
mergeConflictDetected,
|
|
287
|
+
reviewQuillAttempt,
|
|
288
|
+
});
|
|
289
|
+
if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
290
|
+
return {
|
|
291
|
+
ciEntry,
|
|
292
|
+
finding: {
|
|
293
|
+
status: "fail",
|
|
294
|
+
scope: "github:reconcile",
|
|
295
|
+
message: "PR is already merged but the issue has not advanced to done",
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
300
|
+
return {
|
|
301
|
+
ciEntry,
|
|
302
|
+
finding: {
|
|
303
|
+
status: "fail",
|
|
304
|
+
scope: "github:reconcile",
|
|
305
|
+
message: "PR is closed but the issue is still waiting on PR state",
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (gateCheckStatus === "failure" && issue.factoryState !== "repairing_ci" && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
310
|
+
return {
|
|
311
|
+
ciEntry,
|
|
312
|
+
finding: {
|
|
313
|
+
status: "fail",
|
|
314
|
+
scope: "github:ci",
|
|
315
|
+
message: "Gate CI is failing but no CI repair is running or queued",
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (reviewDecision === "APPROVED" && issue.factoryState !== "awaiting_queue" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
320
|
+
return {
|
|
321
|
+
ciEntry,
|
|
322
|
+
finding: {
|
|
323
|
+
status: "fail",
|
|
324
|
+
scope: "github:reconcile",
|
|
325
|
+
message: "PR is approved but the issue has not handed off to downstream merge automation",
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (gateCheckStatus === "success"
|
|
330
|
+
&& reviewDecision === "CHANGES_REQUESTED"
|
|
331
|
+
&& latestBlockingReviewHeadSha === pr.headRefOid
|
|
332
|
+
&& !reviewQuillAttempt
|
|
333
|
+
&& issue.factoryState !== "changes_requested"
|
|
334
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
335
|
+
return {
|
|
336
|
+
ciEntry,
|
|
337
|
+
finding: {
|
|
338
|
+
status: "fail",
|
|
339
|
+
scope: "github:review-handoff",
|
|
340
|
+
message: "Requested changes still block the current head, but no review fix is running",
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (requestedReviewers.includes("review-quill") && reviewQuillProbe && reviewQuillProbe.status !== "pass") {
|
|
345
|
+
return {
|
|
346
|
+
ciEntry,
|
|
347
|
+
finding: {
|
|
348
|
+
status: "fail",
|
|
349
|
+
scope: "github:review-automation",
|
|
350
|
+
message: `PR is waiting on review-quill but the service is not healthy: ${reviewQuillProbe.message}`,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (issue.factoryState === "awaiting_queue" && mergeConflictDetected && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
355
|
+
return {
|
|
356
|
+
ciEntry,
|
|
357
|
+
finding: {
|
|
358
|
+
status: "fail",
|
|
359
|
+
scope: "github:queue",
|
|
360
|
+
message: "PR has merge conflicts but no queue repair is running or queued",
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (issue.factoryState === "awaiting_queue" && mergeStewardProbe && mergeStewardProbe.status !== "pass" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
365
|
+
return {
|
|
366
|
+
ciEntry,
|
|
367
|
+
finding: {
|
|
368
|
+
status: "fail",
|
|
369
|
+
scope: "github:queue",
|
|
370
|
+
message: `Issue is waiting on downstream merge automation but merge-steward is not healthy: ${mergeStewardProbe.message}`,
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return { ciEntry };
|
|
375
|
+
}
|
|
376
|
+
function buildCiEntry(params) {
|
|
377
|
+
const { issue, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
|
|
378
|
+
const owner = deriveCiOwner({
|
|
379
|
+
gateCheckStatus,
|
|
380
|
+
factoryState: issue.factoryState,
|
|
381
|
+
reviewDecision,
|
|
382
|
+
reviewRequested,
|
|
383
|
+
currentHeadSha,
|
|
384
|
+
latestBlockingReviewHeadSha,
|
|
385
|
+
mergeConflictDetected,
|
|
386
|
+
reviewQuillAttempt,
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
390
|
+
projectId: issue.projectId,
|
|
391
|
+
prNumber: issue.prNumber,
|
|
392
|
+
gateStatus: gateCheckStatus,
|
|
393
|
+
owner,
|
|
394
|
+
orphaned: owner === "unknown",
|
|
395
|
+
factoryState: issue.factoryState,
|
|
396
|
+
...(reviewDecision ? { reviewDecision } : {}),
|
|
397
|
+
message: describeCiOwnership({
|
|
398
|
+
gateCheckStatus,
|
|
399
|
+
owner,
|
|
400
|
+
reviewDecision,
|
|
401
|
+
reviewRequested,
|
|
402
|
+
currentHeadSha,
|
|
403
|
+
latestBlockingReviewHeadSha,
|
|
404
|
+
mergeConflictDetected,
|
|
405
|
+
reviewQuillAttempt,
|
|
406
|
+
}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function deriveCiOwner(params) {
|
|
410
|
+
const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
|
|
411
|
+
&& params.latestBlockingReviewHeadSha
|
|
412
|
+
&& params.currentHeadSha !== params.latestBlockingReviewHeadSha);
|
|
413
|
+
if (params.gateCheckStatus === "failure") {
|
|
414
|
+
return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
|
|
415
|
+
}
|
|
416
|
+
if (params.gateCheckStatus === "pending") {
|
|
417
|
+
return "external";
|
|
418
|
+
}
|
|
419
|
+
if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
|
|
420
|
+
return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
|
|
421
|
+
? "unknown"
|
|
422
|
+
: "downstream";
|
|
423
|
+
}
|
|
424
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
425
|
+
if (params.factoryState === "changes_requested")
|
|
426
|
+
return "patchrelay";
|
|
427
|
+
if (params.reviewQuillAttempt)
|
|
428
|
+
return "review-quill";
|
|
429
|
+
if (headAdvancedPastBlockingReview)
|
|
430
|
+
return "reviewer";
|
|
431
|
+
return "unknown";
|
|
432
|
+
}
|
|
433
|
+
if (params.reviewDecision === "REVIEW_REQUIRED") {
|
|
434
|
+
if (params.reviewQuillAttempt)
|
|
435
|
+
return "review-quill";
|
|
436
|
+
if (params.gateCheckStatus === "success")
|
|
437
|
+
return "reviewer";
|
|
438
|
+
return params.reviewRequested ? "reviewer" : "unknown";
|
|
439
|
+
}
|
|
440
|
+
if (params.gateCheckStatus === "success" && params.factoryState === "pr_open") {
|
|
441
|
+
return "reviewer";
|
|
442
|
+
}
|
|
443
|
+
return "external";
|
|
444
|
+
}
|
|
445
|
+
function describeCiOwnership(params) {
|
|
446
|
+
const blockingReviewTargetsCurrentHead = Boolean(params.currentHeadSha
|
|
447
|
+
&& params.latestBlockingReviewHeadSha
|
|
448
|
+
&& params.currentHeadSha === params.latestBlockingReviewHeadSha);
|
|
449
|
+
const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
|
|
450
|
+
&& params.latestBlockingReviewHeadSha
|
|
451
|
+
&& params.currentHeadSha !== params.latestBlockingReviewHeadSha);
|
|
452
|
+
if (params.owner === "patchrelay") {
|
|
453
|
+
return params.gateCheckStatus === "failure"
|
|
454
|
+
? "PatchRelay owns the next CI repair move"
|
|
455
|
+
: "PatchRelay owns the next requested-changes move";
|
|
456
|
+
}
|
|
457
|
+
if (params.owner === "review-quill") {
|
|
458
|
+
return params.reviewQuillAttempt
|
|
459
|
+
? `review-quill attempt #${params.reviewQuillAttempt.id} is ${params.reviewQuillAttempt.status} on the current head`
|
|
460
|
+
: "review-quill owns the current review attempt";
|
|
461
|
+
}
|
|
462
|
+
if (params.owner === "reviewer") {
|
|
463
|
+
if (headAdvancedPastBlockingReview) {
|
|
464
|
+
return "Waiting on review of a newer pushed head";
|
|
465
|
+
}
|
|
466
|
+
return params.reviewRequested
|
|
467
|
+
? "Waiting on an active reviewer request"
|
|
468
|
+
: "Waiting on review of the current head";
|
|
469
|
+
}
|
|
470
|
+
if (params.owner === "downstream") {
|
|
471
|
+
return params.mergeConflictDetected
|
|
472
|
+
? "Downstream merge automation is expected to repair or requeue this PR"
|
|
473
|
+
: "Downstream merge automation owns the next move";
|
|
474
|
+
}
|
|
475
|
+
if (params.owner === "external") {
|
|
476
|
+
return params.gateCheckStatus === "pending"
|
|
477
|
+
? "Waiting on external CI checks to settle"
|
|
478
|
+
: "Waiting on external GitHub automation";
|
|
479
|
+
}
|
|
480
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
481
|
+
return blockingReviewTargetsCurrentHead
|
|
482
|
+
? "Requested changes still block the same head and no fix run is active"
|
|
483
|
+
: "Waiting on review after a newer pushed head";
|
|
484
|
+
}
|
|
485
|
+
if (params.reviewDecision === "REVIEW_REQUIRED") {
|
|
486
|
+
return "Waiting on review of the current head";
|
|
487
|
+
}
|
|
488
|
+
return "No visible next owner for this PR state";
|
|
489
|
+
}
|
|
490
|
+
function isResolvedDependency(dep) {
|
|
491
|
+
return dep.blockerCurrentLinearStateType === "completed" || dep.blockerCurrentLinearState?.trim().toLowerCase() === "done";
|
|
492
|
+
}
|
|
493
|
+
function needsReviewAutomation(issue) {
|
|
494
|
+
if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
return issue.prNumber !== undefined;
|
|
498
|
+
}
|
|
499
|
+
async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
|
|
500
|
+
const owners = new Map();
|
|
501
|
+
for (const snapshot of snapshots) {
|
|
502
|
+
const issueKey = snapshot.issue.issueKey;
|
|
503
|
+
const prNumber = snapshot.issue.prNumber;
|
|
504
|
+
if (!issueKey || prNumber === undefined)
|
|
505
|
+
continue;
|
|
506
|
+
const project = config.projects.find((entry) => entry.id === snapshot.issue.projectId);
|
|
507
|
+
const repoFullName = project?.github?.repoFullName;
|
|
508
|
+
if (!repoFullName)
|
|
509
|
+
continue;
|
|
510
|
+
const probe = await probeReviewQuillAttempts(runCommand, repoFullName, prNumber);
|
|
511
|
+
if (!probe.ok)
|
|
512
|
+
continue;
|
|
513
|
+
const activeAttempt = probe.attempts.find((attempt) => (attempt.status === "queued" || attempt.status === "running")
|
|
514
|
+
&& !attempt.stale
|
|
515
|
+
&& attempt.headSha === probe.currentHeadSha);
|
|
516
|
+
if (!activeAttempt)
|
|
517
|
+
continue;
|
|
518
|
+
owners.set(issueKey, {
|
|
519
|
+
id: activeAttempt.id,
|
|
520
|
+
status: activeAttempt.status,
|
|
521
|
+
headSha: activeAttempt.headSha,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return owners;
|
|
525
|
+
}
|
|
526
|
+
function getGateCheckNames(project) {
|
|
527
|
+
const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
528
|
+
return configured.length > 0 ? configured : ["verify"];
|
|
529
|
+
}
|
|
530
|
+
function deriveCiGateStatus(statusCheckRollup, gateCheckNames) {
|
|
531
|
+
const gateStatus = deriveGateCheckStatusFromRollup(statusCheckRollup, gateCheckNames);
|
|
532
|
+
if (gateStatus) {
|
|
533
|
+
return gateStatus;
|
|
534
|
+
}
|
|
535
|
+
const entries = Array.isArray(statusCheckRollup) ? statusCheckRollup : [];
|
|
536
|
+
if (entries.length === 0) {
|
|
537
|
+
return "unknown";
|
|
538
|
+
}
|
|
539
|
+
const hasPending = entries.some((entry) => {
|
|
540
|
+
const status = entry.status?.trim().toLowerCase();
|
|
541
|
+
return status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending";
|
|
542
|
+
});
|
|
543
|
+
if (hasPending) {
|
|
544
|
+
return "pending";
|
|
545
|
+
}
|
|
546
|
+
return "unknown";
|
|
547
|
+
}
|
|
548
|
+
async function probeReviewQuillAttempts(runCommand, repoFullName, prNumber) {
|
|
549
|
+
const repoRef = repoFullName.split("/").at(-1);
|
|
550
|
+
if (!repoRef) {
|
|
551
|
+
return { ok: false, error: `Unable to derive review-quill repo id from ${repoFullName}` };
|
|
552
|
+
}
|
|
553
|
+
let attemptsResult;
|
|
554
|
+
try {
|
|
555
|
+
attemptsResult = await runCommand("review-quill", ["attempts", repoRef, String(prNumber), "--json"]);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
559
|
+
}
|
|
560
|
+
if (attemptsResult.exitCode !== 0) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
error: [attemptsResult.stderr.trim(), attemptsResult.stdout.trim()].filter(Boolean).join(" ") || `review-quill exited ${attemptsResult.exitCode}`,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const parsedAttempts = safeJsonParse(attemptsResult.stdout);
|
|
567
|
+
if (!parsedAttempts || !Array.isArray(parsedAttempts.attempts)) {
|
|
568
|
+
return { ok: false, error: "invalid JSON from review-quill attempts" };
|
|
569
|
+
}
|
|
570
|
+
const prProbe = await probeGitHubPullRequest(runCommand, repoFullName, prNumber);
|
|
571
|
+
if (!prProbe.ok) {
|
|
572
|
+
return { ok: false, error: prProbe.error };
|
|
573
|
+
}
|
|
574
|
+
const attempts = parsedAttempts.attempts.flatMap((entry) => {
|
|
575
|
+
if (!entry || typeof entry !== "object")
|
|
576
|
+
return [];
|
|
577
|
+
const id = entry.id;
|
|
578
|
+
const headSha = entry.headSha;
|
|
579
|
+
const status = entry.status;
|
|
580
|
+
const stale = entry.stale;
|
|
581
|
+
if (typeof id !== "number"
|
|
582
|
+
|| typeof headSha !== "string"
|
|
583
|
+
|| (status !== "queued" && status !== "running")) {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
return [{
|
|
587
|
+
id,
|
|
588
|
+
headSha,
|
|
589
|
+
status: status,
|
|
590
|
+
stale: stale === true,
|
|
591
|
+
}];
|
|
592
|
+
});
|
|
593
|
+
return {
|
|
594
|
+
ok: true,
|
|
595
|
+
currentHeadSha: prProbe.pr.headRefOid,
|
|
596
|
+
attempts,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function probePatchRelayService(config) {
|
|
600
|
+
const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
|
|
601
|
+
const healthUrl = `http://${host}:${config.server.port}${config.server.healthPath}`;
|
|
602
|
+
const readyUrl = `http://${host}:${config.server.port}${config.server.readinessPath}`;
|
|
603
|
+
try {
|
|
604
|
+
const [healthResponse, readyResponse] = await Promise.all([
|
|
605
|
+
fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }),
|
|
606
|
+
fetch(readyUrl, { signal: AbortSignal.timeout(2_000) }),
|
|
607
|
+
]);
|
|
608
|
+
const healthBody = await healthResponse.json();
|
|
609
|
+
const readyBody = await readyResponse.json();
|
|
610
|
+
if (healthResponse.ok && readyResponse.ok && readyBody.ready) {
|
|
611
|
+
return {
|
|
612
|
+
status: "pass",
|
|
613
|
+
message: `Healthy${healthBody.version ? ` (v${healthBody.version})` : ""}`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
status: "fail",
|
|
618
|
+
message: `Reachable but not ready${readyBody.codexStarted === false || readyBody.linearConnected === false
|
|
619
|
+
? ` (${[
|
|
620
|
+
readyBody.codexStarted === false ? "codex not started" : undefined,
|
|
621
|
+
readyBody.linearConnected === false ? "Linear not connected" : undefined,
|
|
622
|
+
].filter(Boolean).join(", ")})`
|
|
623
|
+
: ""}`,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
return {
|
|
628
|
+
status: "fail",
|
|
629
|
+
message: `Unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function probeOptionalService(runCommand, binary, options) {
|
|
634
|
+
let result;
|
|
635
|
+
try {
|
|
636
|
+
result = await runCommand(binary, ["service", "status", "--json"]);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
return {
|
|
640
|
+
status: "warn",
|
|
641
|
+
message: `Unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (result.exitCode !== 0) {
|
|
645
|
+
const errorText = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ");
|
|
646
|
+
return {
|
|
647
|
+
status: "warn",
|
|
648
|
+
message: `Unavailable: ${errorText || `${binary} service status exited ${result.exitCode}`}`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const payload = safeJsonParse(result.stdout);
|
|
652
|
+
if (!payload) {
|
|
653
|
+
return {
|
|
654
|
+
status: "warn",
|
|
655
|
+
message: "Unavailable: unable to parse JSON status output",
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
status: options.healthy(payload) ? "pass" : "fail",
|
|
660
|
+
message: options.summarize(payload),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async function probeGitHubPullRequest(runCommand, repoFullName, prNumber) {
|
|
664
|
+
let result;
|
|
665
|
+
try {
|
|
666
|
+
result = await runCommand("gh", [
|
|
667
|
+
"pr",
|
|
668
|
+
"view",
|
|
669
|
+
String(prNumber),
|
|
670
|
+
"--repo",
|
|
671
|
+
repoFullName,
|
|
672
|
+
"--json",
|
|
673
|
+
"state,reviewDecision,reviewRequests,latestReviews,statusCheckRollup,mergeable,mergeStateStatus,headRefOid",
|
|
674
|
+
]);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
678
|
+
}
|
|
679
|
+
if (result.exitCode !== 0) {
|
|
680
|
+
return {
|
|
681
|
+
ok: false,
|
|
682
|
+
error: [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ") || `gh exited ${result.exitCode}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const parsed = safeJsonParse(result.stdout);
|
|
686
|
+
if (!parsed) {
|
|
687
|
+
return { ok: false, error: "invalid JSON from gh pr view" };
|
|
688
|
+
}
|
|
689
|
+
return { ok: true, pr: parsed };
|
|
690
|
+
}
|
|
691
|
+
function extractLatestBlockingReviewHeadSha(latestReviews) {
|
|
692
|
+
if (!Array.isArray(latestReviews)) {
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
for (const review of latestReviews) {
|
|
696
|
+
if (!review || typeof review !== "object")
|
|
697
|
+
continue;
|
|
698
|
+
const state = typeof review.state === "string"
|
|
699
|
+
? String(review.state).trim().toUpperCase()
|
|
700
|
+
: undefined;
|
|
701
|
+
if (state !== "CHANGES_REQUESTED")
|
|
702
|
+
continue;
|
|
703
|
+
const commitOid = typeof review.commit?.oid === "string"
|
|
704
|
+
? String(review.commit.oid).trim()
|
|
705
|
+
: undefined;
|
|
706
|
+
if (commitOid)
|
|
707
|
+
return commitOid;
|
|
708
|
+
}
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
function extractRequestedReviewerLogins(requests) {
|
|
712
|
+
if (!Array.isArray(requests)) {
|
|
713
|
+
return [];
|
|
714
|
+
}
|
|
715
|
+
const logins = requests.flatMap((request) => {
|
|
716
|
+
if (!request || typeof request !== "object") {
|
|
717
|
+
return [];
|
|
718
|
+
}
|
|
719
|
+
const direct = typeof request.login === "string"
|
|
720
|
+
? String(request.login)
|
|
721
|
+
: undefined;
|
|
722
|
+
const nested = typeof request.requestedReviewer?.login === "string"
|
|
723
|
+
? String(request.requestedReviewer.login)
|
|
724
|
+
: undefined;
|
|
725
|
+
return [direct, nested].filter((entry) => Boolean(entry)).map((entry) => entry.trim().toLowerCase());
|
|
726
|
+
});
|
|
727
|
+
return [...new Set(logins)];
|
|
728
|
+
}
|
|
729
|
+
function safeJsonParse(value) {
|
|
730
|
+
try {
|
|
731
|
+
const parsed = JSON.parse(value);
|
|
732
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
733
|
+
? parsed
|
|
734
|
+
: undefined;
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
}
|