patchrelay 0.68.3 → 0.68.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,976 +0,0 @@
1
- import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
2
- import { ACTIVE_RUN_STATES } from "../factory-state.js";
3
- import { isUndelegatedPausedNoPrWork } from "../paused-issue-state.js";
4
- import { hasOpenPr, resolveClosedPrDisposition } from "../pr-state.js";
5
- const RECONCILIATION_GRACE_MS = 120_000;
6
- const DOWNSTREAM_STALE_MS = 900_000;
7
- export async function collectClusterHealth(config, db, runCommand) {
8
- const checks = [];
9
- const ciEntries = [];
10
- const now = Date.now();
11
- const issues = db.listIssues();
12
- const openIssues = issues.filter((issue) => issue.factoryState !== "done");
13
- const trackedByKey = new Map(issues
14
- .filter((issue) => issue.issueKey)
15
- .map((issue) => [issue.issueKey, issue]));
16
- const trackedByLinearId = new Map(issues.map((issue) => [issue.linearIssueId, issue]));
17
- const patchRelayProbe = await probePatchRelayService(config);
18
- checks.push({
19
- status: patchRelayProbe.status,
20
- scope: "service:patchrelay",
21
- message: patchRelayProbe.message,
22
- });
23
- const snapshots = openIssues.map((issue) => {
24
- const tracked = db.getTrackedIssue(issue.projectId, issue.linearIssueId);
25
- const deps = db.issues.listIssueDependencies(issue.projectId, issue.linearIssueId);
26
- const blockedBy = deps.filter((dep) => !isResolvedDependency(dep));
27
- const missingTrackedBlockers = blockedBy.filter((dep) => {
28
- if (trackedByLinearId.has(dep.blockerLinearIssueId))
29
- return false;
30
- if (dep.blockerIssueKey && trackedByKey.has(dep.blockerIssueKey))
31
- return false;
32
- return true;
33
- });
34
- return {
35
- issue,
36
- session: db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId),
37
- blockedBy,
38
- missingTrackedBlockers,
39
- ageMs: Math.max(0, now - Date.parse(issue.updatedAt || new Date(0).toISOString())),
40
- readyForExecution: tracked?.readyForExecution ?? false,
41
- };
42
- });
43
- const reviewRelevantIssues = snapshots.filter((snapshot) => needsReviewAutomation(snapshot.issue));
44
- const queueRelevantIssues = snapshots.filter((snapshot) => snapshot.issue.factoryState === "awaiting_queue");
45
- const reviewQuillProbe = reviewRelevantIssues.length > 0
46
- ? await probeOptionalService(runCommand, "review-quill", {
47
- healthy: (payload) => {
48
- const parsed = payload;
49
- return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
50
- },
51
- summarize: (payload) => {
52
- const parsed = payload;
53
- return parsed.health?.reachable === true && parsed.health?.ok === true
54
- ? "Healthy"
55
- : `Unhealthy (${parsed.health?.reachable === false ? "service not reachable" : "service health unavailable"})`;
56
- },
57
- })
58
- : undefined;
59
- if (reviewQuillProbe) {
60
- checks.push({
61
- status: reviewQuillProbe.status,
62
- scope: "service:review-quill",
63
- message: reviewQuillProbe.message,
64
- });
65
- }
66
- const reviewQuillAttemptOwners = reviewQuillProbe?.status === "pass"
67
- ? await collectReviewQuillAttemptOwners(reviewRelevantIssues, config, runCommand)
68
- : new Map();
69
- const mergeStewardProbe = queueRelevantIssues.length > 0
70
- ? await probeOptionalService(runCommand, "merge-steward", {
71
- healthy: (payload) => {
72
- const parsed = payload;
73
- return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
74
- },
75
- summarize: (payload) => {
76
- const parsed = payload;
77
- return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active"
78
- ? "Healthy"
79
- : `Unhealthy (${parsed.health?.reachable === false ? "service not reachable" : parsed.systemd?.ActiveState ?? "unknown"})`;
80
- },
81
- })
82
- : undefined;
83
- if (mergeStewardProbe) {
84
- checks.push({
85
- status: mergeStewardProbe.status,
86
- scope: "service:merge-steward",
87
- message: mergeStewardProbe.message,
88
- });
89
- }
90
- for (const snapshot of snapshots) {
91
- const finding = evaluateLocalIssueHealth(snapshot);
92
- if (finding) {
93
- checks.push({
94
- ...finding,
95
- ...(snapshot.issue.issueKey ? { issueKey: snapshot.issue.issueKey } : {}),
96
- projectId: snapshot.issue.projectId,
97
- ...(snapshot.issue.prNumber !== undefined ? { prNumber: snapshot.issue.prNumber } : {}),
98
- });
99
- }
100
- }
101
- checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
102
- for (const snapshot of snapshots) {
103
- if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
104
- continue;
105
- }
106
- const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
107
- if (githubHealth.ciEntry) {
108
- ciEntries.push(githubHealth.ciEntry);
109
- }
110
- if (githubHealth.finding) {
111
- checks.push({
112
- ...githubHealth.finding,
113
- ...(snapshot.issue.issueKey ? { issueKey: snapshot.issue.issueKey } : {}),
114
- projectId: snapshot.issue.projectId,
115
- prNumber: snapshot.issue.prNumber,
116
- });
117
- }
118
- }
119
- const workflowFailures = checks.filter((check) => check.scope.startsWith("issue:") || check.scope.startsWith("github:"));
120
- if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") && openIssues.length > 0) {
121
- checks.push({
122
- status: "pass",
123
- scope: "workflow",
124
- message: `All ${openIssues.length} non-done issues currently have active work, a tracked blocker, or a downstream owner`,
125
- });
126
- }
127
- if (openIssues.length === 0) {
128
- checks.push({
129
- status: "pass",
130
- scope: "workflow",
131
- message: "No non-done issues are currently tracked",
132
- });
133
- }
134
- if (ciEntries.length > 0) {
135
- const orphanedCi = ciEntries.filter((entry) => entry.orphaned);
136
- checks.push({
137
- status: orphanedCi.length === 0 ? "pass" : "fail",
138
- scope: "ci",
139
- message: orphanedCi.length === 0
140
- ? `Tracked ${ciEntries.length} PR-backed issue${ciEntries.length === 1 ? "" : "s"} and each PR has a visible next owner`
141
- : `${orphanedCi.length} PR-backed issue${orphanedCi.length === 1 ? "" : "s"} ha${orphanedCi.length === 1 ? "s" : "ve"} no visible next owner`,
142
- });
143
- }
144
- const summary = {
145
- trackedIssues: issues.length,
146
- openIssues: openIssues.length,
147
- activeRuns: openIssues.filter((issue) => issue.activeRunId !== undefined).length,
148
- blockedIssues: snapshots.filter((snapshot) => snapshot.blockedBy.length > 0).length,
149
- readyIssues: snapshots.filter((snapshot) => snapshot.readyForExecution).length,
150
- ciTrackedPrs: ciEntries.length,
151
- ciPending: ciEntries.filter((entry) => entry.gateStatus === "pending").length,
152
- ciSuccess: ciEntries.filter((entry) => entry.gateStatus === "success").length,
153
- ciFailure: ciEntries.filter((entry) => entry.gateStatus === "failure").length,
154
- ciUnknown: ciEntries.filter((entry) => entry.gateStatus === "unknown").length,
155
- ciOrphaned: ciEntries.filter((entry) => entry.orphaned).length,
156
- passCount: checks.filter((check) => check.status === "pass").length,
157
- warnCount: checks.filter((check) => check.status === "warn").length,
158
- failCount: checks.filter((check) => check.status === "fail").length,
159
- };
160
- return {
161
- generatedAt: new Date().toISOString(),
162
- ok: summary.failCount === 0,
163
- summary,
164
- checks,
165
- ci: ciEntries,
166
- };
167
- }
168
- function evaluateLocalIssueHealth(snapshot) {
169
- const { issue, session, missingTrackedBlockers, blockedBy, ageMs, readyForExecution } = snapshot;
170
- const pausedNoPrWork = isUndelegatedPausedNoPrWork(issue);
171
- if (issue.factoryState === "failed" || issue.factoryState === "escalated") {
172
- return {
173
- status: "fail",
174
- scope: "issue:terminal",
175
- message: `Issue is in terminal failure state ${issue.factoryState}`,
176
- };
177
- }
178
- if (missingTrackedBlockers.length > 0) {
179
- return {
180
- status: "fail",
181
- scope: "issue:blockers",
182
- message: `Blocked by unmanaged issue${missingTrackedBlockers.length === 1 ? "" : "s"} ${missingTrackedBlockers.map((dep) => dep.blockerIssueKey ?? dep.blockerLinearIssueId).join(", ")}`,
183
- };
184
- }
185
- if (issue.activeRunId !== undefined && session?.sessionState !== "running") {
186
- return {
187
- status: "fail",
188
- scope: "issue:run-state",
189
- message: `Issue has active run #${issue.activeRunId} but session state is ${session?.sessionState ?? "missing"}`,
190
- };
191
- }
192
- if (issue.activeRunId === undefined && session?.sessionState === "running") {
193
- return {
194
- status: "fail",
195
- scope: "issue:run-state",
196
- message: "Issue session is marked running but no active run is attached",
197
- };
198
- }
199
- if (blockedBy.length > 0) {
200
- return undefined;
201
- }
202
- if (readyForExecution && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
203
- return {
204
- status: "fail",
205
- scope: "issue:dispatch",
206
- message: "Issue is ready for execution but no active run has started",
207
- };
208
- }
209
- if (!pausedNoPrWork && ACTIVE_RUN_STATES.has(issue.factoryState) && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
210
- return {
211
- status: "fail",
212
- scope: "issue:dispatch",
213
- message: `Issue is parked in ${issue.factoryState} without an active run`,
214
- };
215
- }
216
- if (!pausedNoPrWork && issue.factoryState === "delegated" && issue.activeRunId === undefined && !readyForExecution && ageMs >= RECONCILIATION_GRACE_MS) {
217
- return {
218
- status: "fail",
219
- scope: "issue:dispatch",
220
- message: "Delegated issue is idle but no wake is queued",
221
- };
222
- }
223
- if (issue.factoryState === "awaiting_input" && ageMs >= RECONCILIATION_GRACE_MS) {
224
- return {
225
- status: "warn",
226
- scope: "issue:operator",
227
- message: "Issue is waiting on operator input",
228
- };
229
- }
230
- if (issue.factoryState === "awaiting_queue" && ageMs >= DOWNSTREAM_STALE_MS) {
231
- return {
232
- status: "warn",
233
- scope: "issue:downstream",
234
- message: "Issue has been waiting on downstream merge automation for a long time",
235
- };
236
- }
237
- return undefined;
238
- }
239
- async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe) {
240
- const { issue, ageMs } = snapshot;
241
- const project = config.projects.find((entry) => entry.id === issue.projectId);
242
- const repoFullName = project?.github?.repoFullName;
243
- if (!repoFullName || issue.prNumber === undefined) {
244
- return {
245
- finding: issue.prNumber !== undefined
246
- ? {
247
- status: "fail",
248
- scope: "github:config",
249
- message: "PR-backed issue has no GitHub repo configured",
250
- }
251
- : undefined,
252
- };
253
- }
254
- const probe = await probeGitHubPullRequest(runCommand, repoFullName, issue.prNumber);
255
- if (!probe.ok) {
256
- return {
257
- ciEntry: {
258
- ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
259
- projectId: issue.projectId,
260
- prNumber: issue.prNumber,
261
- gateStatus: "unknown",
262
- owner: "unknown",
263
- orphaned: true,
264
- factoryState: issue.factoryState,
265
- message: `GitHub probe failed: ${probe.error}`,
266
- },
267
- finding: {
268
- status: "warn",
269
- scope: "github:probe",
270
- message: `Unable to query GitHub PR state: ${probe.error}`,
271
- },
272
- };
273
- }
274
- const pr = probe.pr;
275
- const gateCheckNames = getGateCheckNames(project);
276
- const gateCheckStatus = deriveCiGateStatus(pr.statusCheckRollup, gateCheckNames);
277
- const reviewDecision = pr.reviewDecision?.trim().toUpperCase();
278
- const requestedReviewers = extractRequestedReviewerLogins(pr.reviewRequests);
279
- const reviewRequested = requestedReviewers.length > 0;
280
- const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
281
- const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
282
- const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
283
- if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
284
- return {
285
- finding: {
286
- status: "fail",
287
- scope: "github:reconcile",
288
- message: "PR is already merged but the issue has not advanced to done",
289
- },
290
- };
291
- }
292
- if (pr.state === "CLOSED") {
293
- const closedPrDisposition = resolveClosedPrDisposition(issue);
294
- if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
295
- return {
296
- finding: {
297
- status: "fail",
298
- scope: "github:reconcile",
299
- message: "PR is closed but unfinished work has not been re-delegated",
300
- },
301
- };
302
- }
303
- return {};
304
- }
305
- const ciEntry = buildCiEntry({
306
- issue,
307
- delegatedToPatchRelay: issue.delegatedToPatchRelay,
308
- gateCheckStatus,
309
- reviewDecision,
310
- reviewRequested,
311
- currentHeadSha: pr.headRefOid,
312
- latestBlockingReviewHeadSha,
313
- mergeConflictDetected,
314
- reviewQuillAttempt,
315
- });
316
- if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
317
- return {
318
- ciEntry,
319
- finding: {
320
- status: "fail",
321
- scope: "github:reconcile",
322
- message: "PR is already merged but the issue has not advanced to done",
323
- },
324
- };
325
- }
326
- if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
327
- return {
328
- ciEntry,
329
- finding: {
330
- status: "fail",
331
- scope: "github:reconcile",
332
- message: "PR is closed but the issue is still waiting on PR state",
333
- },
334
- };
335
- }
336
- if (issue.delegatedToPatchRelay
337
- && gateCheckStatus === "failure"
338
- && issue.factoryState !== "repairing_ci"
339
- // Plan §6.1 / §4.3: branch CI failures while In Deploy are
340
- // metadata only — the lander's spec CI is the gate. Don't flag
341
- // these as a missing-ci-repair condition.
342
- && issue.factoryState !== "awaiting_queue"
343
- && issue.activeRunId === undefined
344
- && ageMs >= RECONCILIATION_GRACE_MS) {
345
- // Plan §6.1: when the PR is also approved, this is the
346
- // "In Review · stuck at admission" condition — the lander would
347
- // accept the verdict but branch CI is red and (post-§4.3) we no
348
- // longer auto-repair. Keep the same scope/status pair so existing
349
- // dashboards continue to surface it; just sharpen the message.
350
- if (reviewDecision === "APPROVED") {
351
- return {
352
- ciEntry,
353
- finding: {
354
- status: "fail",
355
- scope: "github:ci",
356
- message: "In Review · stuck at admission — PR is approved but gate CI is red and no CI repair is running",
357
- },
358
- };
359
- }
360
- return {
361
- ciEntry,
362
- finding: {
363
- status: "fail",
364
- scope: "github:ci",
365
- message: "Gate CI is failing but no CI repair is running or queued",
366
- },
367
- };
368
- }
369
- if (reviewDecision === "APPROVED" && issue.factoryState !== "awaiting_queue" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
370
- return {
371
- ciEntry,
372
- finding: {
373
- status: "fail",
374
- scope: "github:reconcile",
375
- message: "PR is approved but the issue has not handed off to downstream merge automation",
376
- },
377
- };
378
- }
379
- if (gateCheckStatus === "success"
380
- && reviewDecision === "CHANGES_REQUESTED"
381
- && mergeConflictDetected
382
- && issue.delegatedToPatchRelay
383
- && issue.factoryState !== "changes_requested"
384
- && issue.activeRunId === undefined
385
- && ageMs >= RECONCILIATION_GRACE_MS) {
386
- return {
387
- ciEntry,
388
- finding: {
389
- status: "fail",
390
- scope: "github:branch-upkeep",
391
- message: "PR is still dirty after requested changes, but no branch-upkeep run is active",
392
- },
393
- };
394
- }
395
- if (gateCheckStatus === "success"
396
- && reviewDecision === "CHANGES_REQUESTED"
397
- && latestBlockingReviewHeadSha === pr.headRefOid
398
- && !reviewQuillAttempt
399
- && issue.delegatedToPatchRelay
400
- && issue.factoryState !== "changes_requested"
401
- && ageMs >= RECONCILIATION_GRACE_MS) {
402
- return {
403
- ciEntry,
404
- finding: {
405
- status: "fail",
406
- scope: "github:review-handoff",
407
- message: "Requested changes still block the current head, but no review fix is running",
408
- },
409
- };
410
- }
411
- if (requestedReviewers.includes("review-quill") && reviewQuillProbe && reviewQuillProbe.status !== "pass") {
412
- return {
413
- ciEntry,
414
- finding: {
415
- status: "fail",
416
- scope: "github:review-automation",
417
- message: `PR is waiting on review-quill but the service is not healthy: ${reviewQuillProbe.message}`,
418
- },
419
- };
420
- }
421
- if (issue.delegatedToPatchRelay
422
- && issue.factoryState === "awaiting_queue"
423
- && mergeConflictDetected
424
- && issue.activeRunId === undefined
425
- && ageMs >= RECONCILIATION_GRACE_MS) {
426
- return {
427
- ciEntry,
428
- finding: {
429
- status: "fail",
430
- scope: "github:queue",
431
- message: "PR has merge conflicts but no queue repair is running or queued",
432
- },
433
- };
434
- }
435
- if (issue.factoryState === "awaiting_queue" && mergeStewardProbe && mergeStewardProbe.status !== "pass" && ageMs >= RECONCILIATION_GRACE_MS) {
436
- return {
437
- ciEntry,
438
- finding: {
439
- status: "fail",
440
- scope: "github:queue",
441
- message: `Issue is waiting on downstream merge automation but merge-steward is not healthy: ${mergeStewardProbe.message}`,
442
- },
443
- };
444
- }
445
- return { ciEntry };
446
- }
447
- function buildCiEntry(params) {
448
- const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
449
- const owner = deriveCiOwner({
450
- delegatedToPatchRelay,
451
- gateCheckStatus,
452
- activeRunId: issue.activeRunId,
453
- factoryState: issue.factoryState,
454
- reviewDecision,
455
- reviewRequested,
456
- currentHeadSha,
457
- latestBlockingReviewHeadSha,
458
- mergeConflictDetected,
459
- reviewQuillAttempt,
460
- });
461
- return {
462
- ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
463
- projectId: issue.projectId,
464
- prNumber: issue.prNumber,
465
- gateStatus: gateCheckStatus,
466
- owner,
467
- orphaned: owner === "unknown",
468
- factoryState: issue.factoryState,
469
- ...(reviewDecision ? { reviewDecision } : {}),
470
- message: describeCiOwnership({
471
- delegatedToPatchRelay,
472
- gateCheckStatus,
473
- owner,
474
- reviewDecision,
475
- reviewRequested,
476
- currentHeadSha,
477
- latestBlockingReviewHeadSha,
478
- mergeConflictDetected,
479
- reviewQuillAttempt,
480
- }),
481
- };
482
- }
483
- function deriveCiOwner(params) {
484
- if (params.activeRunId !== undefined) {
485
- return "patchrelay";
486
- }
487
- const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
488
- && params.latestBlockingReviewHeadSha
489
- && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
490
- if (params.gateCheckStatus === "failure") {
491
- if (!params.delegatedToPatchRelay)
492
- return "paused";
493
- return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
494
- }
495
- if (params.gateCheckStatus === "pending") {
496
- return "external";
497
- }
498
- if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
499
- if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
500
- return "paused";
501
- }
502
- return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
503
- ? "unknown"
504
- : "downstream";
505
- }
506
- if (params.reviewDecision === "CHANGES_REQUESTED") {
507
- if (params.mergeConflictDetected) {
508
- if (!params.delegatedToPatchRelay)
509
- return "paused";
510
- return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
511
- }
512
- if (!params.delegatedToPatchRelay)
513
- return "paused";
514
- if (params.factoryState === "changes_requested")
515
- return "patchrelay";
516
- if (params.reviewQuillAttempt?.backlog
517
- && params.currentHeadSha
518
- && params.reviewQuillAttempt.headSha
519
- && params.currentHeadSha !== params.reviewQuillAttempt.headSha) {
520
- return "review-quill";
521
- }
522
- if (params.reviewQuillAttempt && !params.reviewQuillAttempt.backlog)
523
- return "review-quill";
524
- if (headAdvancedPastBlockingReview)
525
- return "reviewer";
526
- return "unknown";
527
- }
528
- if (params.reviewDecision === "REVIEW_REQUIRED") {
529
- if (params.reviewQuillAttempt)
530
- return "review-quill";
531
- if (params.gateCheckStatus === "success")
532
- return "reviewer";
533
- return params.reviewRequested ? "reviewer" : "unknown";
534
- }
535
- if (params.gateCheckStatus === "success" && params.factoryState === "pr_open") {
536
- return "reviewer";
537
- }
538
- return "external";
539
- }
540
- function describeCiOwnership(params) {
541
- const blockingReviewTargetsCurrentHead = Boolean(params.currentHeadSha
542
- && params.latestBlockingReviewHeadSha
543
- && params.currentHeadSha === params.latestBlockingReviewHeadSha);
544
- const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
545
- && params.latestBlockingReviewHeadSha
546
- && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
547
- if (params.owner === "patchrelay") {
548
- if (params.mergeConflictDetected) {
549
- return "PatchRelay owns the next branch-upkeep move";
550
- }
551
- return params.gateCheckStatus === "failure"
552
- ? "PatchRelay owns the next CI repair move"
553
- : "PatchRelay owns the next requested-changes move";
554
- }
555
- if (params.owner === "review-quill") {
556
- if (params.reviewQuillAttempt?.backlog) {
557
- return "review-quill is actively reconciling this repo; this PR is waiting in the current review backlog";
558
- }
559
- return params.reviewQuillAttempt?.id && params.reviewQuillAttempt.status
560
- ? `review-quill attempt #${params.reviewQuillAttempt.id} is ${params.reviewQuillAttempt.status} on the current head`
561
- : "review-quill owns the current review attempt";
562
- }
563
- if (params.owner === "reviewer") {
564
- if (headAdvancedPastBlockingReview) {
565
- return "Waiting on review of a newer pushed head";
566
- }
567
- return params.reviewRequested
568
- ? "Waiting on an active reviewer request"
569
- : "Waiting on review of the current head";
570
- }
571
- if (params.owner === "downstream") {
572
- return params.mergeConflictDetected
573
- ? "Downstream merge automation is expected to repair or requeue this PR"
574
- : "Downstream merge automation owns the next move";
575
- }
576
- if (params.owner === "external") {
577
- return params.gateCheckStatus === "pending"
578
- ? "Waiting on external CI checks to settle"
579
- : "Waiting on external GitHub automation";
580
- }
581
- if (params.owner === "paused") {
582
- if (params.gateCheckStatus === "failure") {
583
- return "PatchRelay is paused; delegate the issue again to repair failing CI";
584
- }
585
- if (params.reviewDecision === "CHANGES_REQUESTED") {
586
- return params.mergeConflictDetected
587
- ? "PatchRelay is paused; delegate the issue again to repair the blocked PR branch"
588
- : "PatchRelay is paused; delegate the issue again to address requested changes";
589
- }
590
- if (params.mergeConflictDetected) {
591
- return "PatchRelay is paused; delegate the issue again to repair this merge conflict";
592
- }
593
- return "PatchRelay is paused; no automatic repair will start until the issue is delegated again";
594
- }
595
- if (params.reviewDecision === "CHANGES_REQUESTED") {
596
- if (params.mergeConflictDetected) {
597
- return headAdvancedPastBlockingReview
598
- ? "PR is still dirty after a newer pushed head and no branch-upkeep run is active"
599
- : "PR is still dirty on the current blocked head and no branch-upkeep run is active";
600
- }
601
- return blockingReviewTargetsCurrentHead
602
- ? "Requested changes still block the same head and no fix run is active"
603
- : "Waiting on review after a newer pushed head";
604
- }
605
- if (params.reviewDecision === "REVIEW_REQUIRED") {
606
- return "Waiting on review of the current head";
607
- }
608
- return "No visible next owner for this PR state";
609
- }
610
- function isResolvedDependency(dep) {
611
- const stateType = dep.blockerCurrentLinearStateType?.trim().toLowerCase();
612
- const state = dep.blockerCurrentLinearState?.trim().toLowerCase();
613
- return stateType === "completed"
614
- || stateType === "canceled"
615
- || stateType === "cancelled"
616
- || state === "done"
617
- || state === "canceled"
618
- || state === "cancelled";
619
- }
620
- function needsReviewAutomation(issue) {
621
- if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
622
- return false;
623
- }
624
- return hasOpenPr(issue.prNumber, issue.prState);
625
- }
626
- async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
627
- const owners = new Map();
628
- const repoBacklog = await probeReviewQuillRepoBacklog(runCommand);
629
- for (const snapshot of snapshots) {
630
- const issueKey = snapshot.issue.issueKey;
631
- const prNumber = snapshot.issue.prNumber;
632
- if (!issueKey || prNumber === undefined)
633
- continue;
634
- const project = config.projects.find((entry) => entry.id === snapshot.issue.projectId);
635
- const repoFullName = project?.github?.repoFullName;
636
- if (!repoFullName)
637
- continue;
638
- const probe = await probeReviewQuillAttempts(runCommand, repoFullName, prNumber);
639
- if (!probe.ok)
640
- continue;
641
- const activeAttempt = probe.attempts.find((attempt) => (attempt.status === "queued" || attempt.status === "running")
642
- && !attempt.stale
643
- && attempt.headSha === probe.currentHeadSha);
644
- if (!activeAttempt) {
645
- if (repoBacklog.has(repoFullName)) {
646
- owners.set(issueKey, { backlog: true, headSha: probe.latestAttemptHeadSha });
647
- }
648
- continue;
649
- }
650
- owners.set(issueKey, {
651
- id: activeAttempt.id,
652
- status: activeAttempt.status,
653
- headSha: activeAttempt.headSha,
654
- });
655
- }
656
- return owners;
657
- }
658
- async function probeReviewQuillRepoBacklog(runCommand) {
659
- let result;
660
- try {
661
- result = await runCommand("review-quill", ["status", "--json"]);
662
- }
663
- catch {
664
- return new Set();
665
- }
666
- if (result.exitCode !== 0) {
667
- return new Set();
668
- }
669
- const parsed = safeJsonParse(result.stdout);
670
- if (!parsed || parsed.runtime?.reconcileInProgress !== true || !Array.isArray(parsed.repos)) {
671
- return new Set();
672
- }
673
- const activeRepos = new Set();
674
- for (const repo of parsed.repos) {
675
- if (!repo || typeof repo !== "object")
676
- continue;
677
- const repoFullName = typeof repo.repoFullName === "string"
678
- ? String(repo.repoFullName).trim()
679
- : undefined;
680
- const runningAttempts = typeof repo.runningAttempts === "number"
681
- ? Number(repo.runningAttempts)
682
- : 0;
683
- const queuedAttempts = typeof repo.queuedAttempts === "number"
684
- ? Number(repo.queuedAttempts)
685
- : 0;
686
- if (!repoFullName)
687
- continue;
688
- if (runningAttempts > 0 || queuedAttempts > 0) {
689
- activeRepos.add(repoFullName);
690
- }
691
- }
692
- return activeRepos;
693
- }
694
- async function collectActiveOverlapFindings(snapshots, runCommand) {
695
- const findings = [];
696
- const diffsByProject = new Map();
697
- for (const snapshot of snapshots) {
698
- const { issue } = snapshot;
699
- if (issue.activeRunId === undefined || !issue.worktreePath) {
700
- continue;
701
- }
702
- const files = await listModifiedTrackedFiles(runCommand, issue.worktreePath);
703
- if (files.size === 0) {
704
- continue;
705
- }
706
- const projectDiffs = diffsByProject.get(issue.projectId) ?? [];
707
- projectDiffs.push({ issue, files });
708
- diffsByProject.set(issue.projectId, projectDiffs);
709
- }
710
- for (const [projectId, diffs] of diffsByProject) {
711
- for (let leftIndex = 0; leftIndex < diffs.length; leftIndex += 1) {
712
- const left = diffs[leftIndex];
713
- for (let rightIndex = leftIndex + 1; rightIndex < diffs.length; rightIndex += 1) {
714
- const right = diffs[rightIndex];
715
- const overlap = [...left.files].filter((file) => right.files.has(file)).sort();
716
- if (overlap.length === 0) {
717
- continue;
718
- }
719
- findings.push({
720
- status: "warn",
721
- scope: "issue:overlap",
722
- message: `Active work overlaps with ${right.issue.issueKey ?? right.issue.linearIssueId}: ${overlap.slice(0, 3).join(", ")}${overlap.length > 3 ? " ..." : ""}`,
723
- ...(left.issue.issueKey ? { issueKey: left.issue.issueKey } : {}),
724
- projectId,
725
- });
726
- }
727
- }
728
- }
729
- return findings;
730
- }
731
- async function listModifiedTrackedFiles(runCommand, worktreePath) {
732
- let result;
733
- try {
734
- result = await runCommand("git", ["-C", worktreePath, "status", "--porcelain", "--untracked-files=no"]);
735
- }
736
- catch {
737
- return new Set();
738
- }
739
- if (result.exitCode !== 0) {
740
- return new Set();
741
- }
742
- const files = new Set();
743
- for (const line of result.stdout.split("\n")) {
744
- if (line.trim().length === 0)
745
- continue;
746
- const rawPath = line.slice(3).trim();
747
- if (!rawPath)
748
- continue;
749
- const normalized = rawPath.includes(" -> ")
750
- ? rawPath.split(" -> ").at(-1)?.trim()
751
- : rawPath;
752
- if (normalized) {
753
- files.add(normalized);
754
- }
755
- }
756
- return files;
757
- }
758
- function getGateCheckNames(project) {
759
- const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
760
- return configured.length > 0 ? configured : ["verify"];
761
- }
762
- function deriveCiGateStatus(statusCheckRollup, gateCheckNames) {
763
- const gateStatus = deriveGateCheckStatusFromRollup(statusCheckRollup, gateCheckNames);
764
- if (gateStatus) {
765
- return gateStatus;
766
- }
767
- const entries = Array.isArray(statusCheckRollup) ? statusCheckRollup : [];
768
- if (entries.length === 0) {
769
- return "unknown";
770
- }
771
- const hasPending = entries.some((entry) => {
772
- const status = entry.status?.trim().toLowerCase();
773
- return status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending";
774
- });
775
- if (hasPending) {
776
- return "pending";
777
- }
778
- return "unknown";
779
- }
780
- async function probeReviewQuillAttempts(runCommand, repoFullName, prNumber) {
781
- const repoRef = repoFullName.split("/").at(-1);
782
- if (!repoRef) {
783
- return { ok: false, error: `Unable to derive review-quill repo id from ${repoFullName}` };
784
- }
785
- let attemptsResult;
786
- try {
787
- attemptsResult = await runCommand("review-quill", ["attempts", repoRef, String(prNumber), "--json"]);
788
- }
789
- catch (error) {
790
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
791
- }
792
- if (attemptsResult.exitCode !== 0) {
793
- return {
794
- ok: false,
795
- error: [attemptsResult.stderr.trim(), attemptsResult.stdout.trim()].filter(Boolean).join(" ") || `review-quill exited ${attemptsResult.exitCode}`,
796
- };
797
- }
798
- const parsedAttempts = safeJsonParse(attemptsResult.stdout);
799
- if (!parsedAttempts || !Array.isArray(parsedAttempts.attempts)) {
800
- return { ok: false, error: "invalid JSON from review-quill attempts" };
801
- }
802
- const prProbe = await probeGitHubPullRequest(runCommand, repoFullName, prNumber);
803
- if (!prProbe.ok) {
804
- return { ok: false, error: prProbe.error };
805
- }
806
- let latestAttemptHeadSha;
807
- const attempts = parsedAttempts.attempts.flatMap((entry) => {
808
- if (!entry || typeof entry !== "object")
809
- return [];
810
- const id = entry.id;
811
- const headSha = entry.headSha;
812
- const status = entry.status;
813
- const stale = entry.stale;
814
- if (!latestAttemptHeadSha && typeof headSha === "string" && headSha.trim().length > 0) {
815
- latestAttemptHeadSha = headSha.trim();
816
- }
817
- if (typeof id !== "number"
818
- || typeof headSha !== "string"
819
- || (status !== "queued" && status !== "running")) {
820
- return [];
821
- }
822
- return [{
823
- id,
824
- headSha,
825
- status: status,
826
- stale: stale === true,
827
- }];
828
- });
829
- return {
830
- ok: true,
831
- currentHeadSha: prProbe.pr.headRefOid,
832
- latestAttemptHeadSha,
833
- attempts,
834
- };
835
- }
836
- async function probePatchRelayService(config) {
837
- const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
838
- const healthUrl = `http://${host}:${config.server.port}${config.server.healthPath}`;
839
- const readyUrl = `http://${host}:${config.server.port}${config.server.readinessPath}`;
840
- try {
841
- const [healthResponse, readyResponse] = await Promise.all([
842
- fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }),
843
- fetch(readyUrl, { signal: AbortSignal.timeout(2_000) }),
844
- ]);
845
- const healthBody = await healthResponse.json();
846
- const readyBody = await readyResponse.json();
847
- if (healthResponse.ok && readyResponse.ok && readyBody.ready) {
848
- return {
849
- status: "pass",
850
- message: `Healthy${healthBody.version ? ` (v${healthBody.version})` : ""}`,
851
- };
852
- }
853
- return {
854
- status: "fail",
855
- message: `Reachable but not ready${readyBody.codexStarted === false || readyBody.linearConnected === false
856
- ? ` (${[
857
- readyBody.codexStarted === false ? "codex not started" : undefined,
858
- readyBody.linearConnected === false ? "Linear not connected" : undefined,
859
- ].filter(Boolean).join(", ")})`
860
- : ""}`,
861
- };
862
- }
863
- catch (error) {
864
- return {
865
- status: "fail",
866
- message: `Unavailable: ${error instanceof Error ? error.message : String(error)}`,
867
- };
868
- }
869
- }
870
- async function probeOptionalService(runCommand, binary, options) {
871
- let result;
872
- try {
873
- result = await runCommand(binary, ["service", "status", "--json"]);
874
- }
875
- catch (error) {
876
- return {
877
- status: "warn",
878
- message: `Unavailable: ${error instanceof Error ? error.message : String(error)}`,
879
- };
880
- }
881
- if (result.exitCode !== 0) {
882
- const errorText = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ");
883
- return {
884
- status: "warn",
885
- message: `Unavailable: ${errorText || `${binary} service status exited ${result.exitCode}`}`,
886
- };
887
- }
888
- const payload = safeJsonParse(result.stdout);
889
- if (!payload) {
890
- return {
891
- status: "warn",
892
- message: "Unavailable: unable to parse JSON status output",
893
- };
894
- }
895
- return {
896
- status: options.healthy(payload) ? "pass" : "fail",
897
- message: options.summarize(payload),
898
- };
899
- }
900
- async function probeGitHubPullRequest(runCommand, repoFullName, prNumber) {
901
- let result;
902
- try {
903
- result = await runCommand("gh", [
904
- "pr",
905
- "view",
906
- String(prNumber),
907
- "--repo",
908
- repoFullName,
909
- "--json",
910
- "state,reviewDecision,reviewRequests,latestReviews,statusCheckRollup,mergeable,mergeStateStatus,headRefOid",
911
- ]);
912
- }
913
- catch (error) {
914
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
915
- }
916
- if (result.exitCode !== 0) {
917
- return {
918
- ok: false,
919
- error: [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ") || `gh exited ${result.exitCode}`,
920
- };
921
- }
922
- const parsed = safeJsonParse(result.stdout);
923
- if (!parsed) {
924
- return { ok: false, error: "invalid JSON from gh pr view" };
925
- }
926
- return { ok: true, pr: parsed };
927
- }
928
- function extractLatestBlockingReviewHeadSha(latestReviews) {
929
- if (!Array.isArray(latestReviews)) {
930
- return undefined;
931
- }
932
- for (const review of latestReviews) {
933
- if (!review || typeof review !== "object")
934
- continue;
935
- const state = typeof review.state === "string"
936
- ? String(review.state).trim().toUpperCase()
937
- : undefined;
938
- if (state !== "CHANGES_REQUESTED")
939
- continue;
940
- const commitOid = typeof review.commit?.oid === "string"
941
- ? String(review.commit.oid).trim()
942
- : undefined;
943
- if (commitOid)
944
- return commitOid;
945
- }
946
- return undefined;
947
- }
948
- function extractRequestedReviewerLogins(requests) {
949
- if (!Array.isArray(requests)) {
950
- return [];
951
- }
952
- const logins = requests.flatMap((request) => {
953
- if (!request || typeof request !== "object") {
954
- return [];
955
- }
956
- const direct = typeof request.login === "string"
957
- ? String(request.login)
958
- : undefined;
959
- const nested = typeof request.requestedReviewer?.login === "string"
960
- ? String(request.requestedReviewer.login)
961
- : undefined;
962
- return [direct, nested].filter((entry) => Boolean(entry)).map((entry) => entry.trim().toLowerCase());
963
- });
964
- return [...new Set(logins)];
965
- }
966
- function safeJsonParse(value) {
967
- try {
968
- const parsed = JSON.parse(value);
969
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
970
- ? parsed
971
- : undefined;
972
- }
973
- catch {
974
- return undefined;
975
- }
976
- }