patchrelay 0.68.3 → 0.68.5
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/build-info.json +3 -3
- package/dist/cli/cluster-health/active-overlap.js +64 -0
- package/dist/cli/cluster-health/ci-classification.js +186 -0
- package/dist/cli/cluster-health/github-issue-health.js +212 -0
- package/dist/cli/cluster-health/github-probe.js +67 -0
- package/dist/cli/cluster-health/index.js +168 -0
- package/dist/cli/cluster-health/local-issue-health.js +91 -0
- package/dist/cli/cluster-health/review-quill-probe.js +126 -0
- package/dist/cli/cluster-health/service-probe.js +65 -0
- package/dist/cli/cluster-health/shared.js +13 -0
- package/dist/cli/cluster-health/types.js +1 -0
- package/dist/cli/commands/cluster.js +1 -1
- package/dist/codex-app-server.js +46 -9
- package/dist/idle-reconciliation.js +16 -36
- package/dist/reconcile-pr-fetch.js +23 -0
- package/dist/reconcile-pr-state-updates.js +40 -0
- package/dist/run-orchestrator.js +1 -1
- package/dist/run-reconciler.js +57 -1
- package/package.json +1 -1
- package/dist/cli/cluster-health.js +0 -976
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export async function collectActiveOverlapFindings(snapshots, runCommand) {
|
|
2
|
+
const findings = [];
|
|
3
|
+
const diffsByProject = new Map();
|
|
4
|
+
for (const snapshot of snapshots) {
|
|
5
|
+
const { issue } = snapshot;
|
|
6
|
+
if (issue.activeRunId === undefined || !issue.worktreePath) {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
const files = await listModifiedTrackedFiles(runCommand, issue.worktreePath);
|
|
10
|
+
if (files.size === 0) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const projectDiffs = diffsByProject.get(issue.projectId) ?? [];
|
|
14
|
+
projectDiffs.push({ issue, files });
|
|
15
|
+
diffsByProject.set(issue.projectId, projectDiffs);
|
|
16
|
+
}
|
|
17
|
+
for (const [projectId, diffs] of diffsByProject) {
|
|
18
|
+
for (let leftIndex = 0; leftIndex < diffs.length; leftIndex += 1) {
|
|
19
|
+
const left = diffs[leftIndex];
|
|
20
|
+
for (let rightIndex = leftIndex + 1; rightIndex < diffs.length; rightIndex += 1) {
|
|
21
|
+
const right = diffs[rightIndex];
|
|
22
|
+
const overlap = [...left.files].filter((file) => right.files.has(file)).sort();
|
|
23
|
+
if (overlap.length === 0) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
findings.push({
|
|
27
|
+
status: "warn",
|
|
28
|
+
scope: "issue:overlap",
|
|
29
|
+
message: `Active work overlaps with ${right.issue.issueKey ?? right.issue.linearIssueId}: ${overlap.slice(0, 3).join(", ")}${overlap.length > 3 ? " ..." : ""}`,
|
|
30
|
+
...(left.issue.issueKey ? { issueKey: left.issue.issueKey } : {}),
|
|
31
|
+
projectId,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
export async function listModifiedTrackedFiles(runCommand, worktreePath) {
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = await runCommand("git", ["-C", worktreePath, "status", "--porcelain", "--untracked-files=no"]);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return new Set();
|
|
45
|
+
}
|
|
46
|
+
if (result.exitCode !== 0) {
|
|
47
|
+
return new Set();
|
|
48
|
+
}
|
|
49
|
+
const files = new Set();
|
|
50
|
+
for (const line of result.stdout.split("\n")) {
|
|
51
|
+
if (line.trim().length === 0)
|
|
52
|
+
continue;
|
|
53
|
+
const rawPath = line.slice(3).trim();
|
|
54
|
+
if (!rawPath)
|
|
55
|
+
continue;
|
|
56
|
+
const normalized = rawPath.includes(" -> ")
|
|
57
|
+
? rawPath.split(" -> ").at(-1)?.trim()
|
|
58
|
+
: rawPath;
|
|
59
|
+
if (normalized) {
|
|
60
|
+
files.add(normalized);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { deriveGateCheckStatusFromRollup } from "../../github-rollup.js";
|
|
2
|
+
export function getGateCheckNames(project) {
|
|
3
|
+
const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
4
|
+
return configured.length > 0 ? configured : ["verify"];
|
|
5
|
+
}
|
|
6
|
+
export function deriveCiGateStatus(statusCheckRollup, gateCheckNames) {
|
|
7
|
+
const gateStatus = deriveGateCheckStatusFromRollup(statusCheckRollup, gateCheckNames);
|
|
8
|
+
if (gateStatus) {
|
|
9
|
+
return gateStatus;
|
|
10
|
+
}
|
|
11
|
+
const entries = Array.isArray(statusCheckRollup) ? statusCheckRollup : [];
|
|
12
|
+
if (entries.length === 0) {
|
|
13
|
+
return "unknown";
|
|
14
|
+
}
|
|
15
|
+
const hasPending = entries.some((entry) => {
|
|
16
|
+
const status = entry.status?.trim().toLowerCase();
|
|
17
|
+
return status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending";
|
|
18
|
+
});
|
|
19
|
+
if (hasPending) {
|
|
20
|
+
return "pending";
|
|
21
|
+
}
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
export function deriveCiOwner(params) {
|
|
25
|
+
if (params.activeRunId !== undefined) {
|
|
26
|
+
return "patchrelay";
|
|
27
|
+
}
|
|
28
|
+
const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
|
|
29
|
+
&& params.latestBlockingReviewHeadSha
|
|
30
|
+
&& params.currentHeadSha !== params.latestBlockingReviewHeadSha);
|
|
31
|
+
if (params.gateCheckStatus === "failure") {
|
|
32
|
+
if (!params.delegatedToPatchRelay)
|
|
33
|
+
return "paused";
|
|
34
|
+
return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
|
|
35
|
+
}
|
|
36
|
+
if (params.gateCheckStatus === "pending") {
|
|
37
|
+
return "external";
|
|
38
|
+
}
|
|
39
|
+
if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
|
|
40
|
+
if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
|
|
41
|
+
return "paused";
|
|
42
|
+
}
|
|
43
|
+
return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
|
|
44
|
+
? "unknown"
|
|
45
|
+
: "downstream";
|
|
46
|
+
}
|
|
47
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
48
|
+
if (params.mergeConflictDetected) {
|
|
49
|
+
if (!params.delegatedToPatchRelay)
|
|
50
|
+
return "paused";
|
|
51
|
+
return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
|
|
52
|
+
}
|
|
53
|
+
if (!params.delegatedToPatchRelay)
|
|
54
|
+
return "paused";
|
|
55
|
+
if (params.factoryState === "changes_requested")
|
|
56
|
+
return "patchrelay";
|
|
57
|
+
if (params.reviewQuillAttempt?.backlog
|
|
58
|
+
&& params.currentHeadSha
|
|
59
|
+
&& params.reviewQuillAttempt.headSha
|
|
60
|
+
&& params.currentHeadSha !== params.reviewQuillAttempt.headSha) {
|
|
61
|
+
return "review-quill";
|
|
62
|
+
}
|
|
63
|
+
if (params.reviewQuillAttempt && !params.reviewQuillAttempt.backlog)
|
|
64
|
+
return "review-quill";
|
|
65
|
+
if (headAdvancedPastBlockingReview)
|
|
66
|
+
return "reviewer";
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
69
|
+
if (params.reviewDecision === "REVIEW_REQUIRED") {
|
|
70
|
+
if (params.reviewQuillAttempt)
|
|
71
|
+
return "review-quill";
|
|
72
|
+
if (params.gateCheckStatus === "success")
|
|
73
|
+
return "reviewer";
|
|
74
|
+
return params.reviewRequested ? "reviewer" : "unknown";
|
|
75
|
+
}
|
|
76
|
+
if (params.gateCheckStatus === "success" && params.factoryState === "pr_open") {
|
|
77
|
+
return "reviewer";
|
|
78
|
+
}
|
|
79
|
+
return "external";
|
|
80
|
+
}
|
|
81
|
+
export function describeCiOwnership(params) {
|
|
82
|
+
const blockingReviewTargetsCurrentHead = Boolean(params.currentHeadSha
|
|
83
|
+
&& params.latestBlockingReviewHeadSha
|
|
84
|
+
&& params.currentHeadSha === params.latestBlockingReviewHeadSha);
|
|
85
|
+
const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
|
|
86
|
+
&& params.latestBlockingReviewHeadSha
|
|
87
|
+
&& params.currentHeadSha !== params.latestBlockingReviewHeadSha);
|
|
88
|
+
if (params.owner === "patchrelay") {
|
|
89
|
+
if (params.mergeConflictDetected) {
|
|
90
|
+
return "PatchRelay owns the next branch-upkeep move";
|
|
91
|
+
}
|
|
92
|
+
return params.gateCheckStatus === "failure"
|
|
93
|
+
? "PatchRelay owns the next CI repair move"
|
|
94
|
+
: "PatchRelay owns the next requested-changes move";
|
|
95
|
+
}
|
|
96
|
+
if (params.owner === "review-quill") {
|
|
97
|
+
if (params.reviewQuillAttempt?.backlog) {
|
|
98
|
+
return "review-quill is actively reconciling this repo; this PR is waiting in the current review backlog";
|
|
99
|
+
}
|
|
100
|
+
return params.reviewQuillAttempt?.id && params.reviewQuillAttempt.status
|
|
101
|
+
? `review-quill attempt #${params.reviewQuillAttempt.id} is ${params.reviewQuillAttempt.status} on the current head`
|
|
102
|
+
: "review-quill owns the current review attempt";
|
|
103
|
+
}
|
|
104
|
+
if (params.owner === "reviewer") {
|
|
105
|
+
if (headAdvancedPastBlockingReview) {
|
|
106
|
+
return "Waiting on review of a newer pushed head";
|
|
107
|
+
}
|
|
108
|
+
return params.reviewRequested
|
|
109
|
+
? "Waiting on an active reviewer request"
|
|
110
|
+
: "Waiting on review of the current head";
|
|
111
|
+
}
|
|
112
|
+
if (params.owner === "downstream") {
|
|
113
|
+
return params.mergeConflictDetected
|
|
114
|
+
? "Downstream merge automation is expected to repair or requeue this PR"
|
|
115
|
+
: "Downstream merge automation owns the next move";
|
|
116
|
+
}
|
|
117
|
+
if (params.owner === "external") {
|
|
118
|
+
return params.gateCheckStatus === "pending"
|
|
119
|
+
? "Waiting on external CI checks to settle"
|
|
120
|
+
: "Waiting on external GitHub automation";
|
|
121
|
+
}
|
|
122
|
+
if (params.owner === "paused") {
|
|
123
|
+
if (params.gateCheckStatus === "failure") {
|
|
124
|
+
return "PatchRelay is paused; delegate the issue again to repair failing CI";
|
|
125
|
+
}
|
|
126
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
127
|
+
return params.mergeConflictDetected
|
|
128
|
+
? "PatchRelay is paused; delegate the issue again to repair the blocked PR branch"
|
|
129
|
+
: "PatchRelay is paused; delegate the issue again to address requested changes";
|
|
130
|
+
}
|
|
131
|
+
if (params.mergeConflictDetected) {
|
|
132
|
+
return "PatchRelay is paused; delegate the issue again to repair this merge conflict";
|
|
133
|
+
}
|
|
134
|
+
return "PatchRelay is paused; no automatic repair will start until the issue is delegated again";
|
|
135
|
+
}
|
|
136
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
137
|
+
if (params.mergeConflictDetected) {
|
|
138
|
+
return headAdvancedPastBlockingReview
|
|
139
|
+
? "PR is still dirty after a newer pushed head and no branch-upkeep run is active"
|
|
140
|
+
: "PR is still dirty on the current blocked head and no branch-upkeep run is active";
|
|
141
|
+
}
|
|
142
|
+
return blockingReviewTargetsCurrentHead
|
|
143
|
+
? "Requested changes still block the same head and no fix run is active"
|
|
144
|
+
: "Waiting on review after a newer pushed head";
|
|
145
|
+
}
|
|
146
|
+
if (params.reviewDecision === "REVIEW_REQUIRED") {
|
|
147
|
+
return "Waiting on review of the current head";
|
|
148
|
+
}
|
|
149
|
+
return "No visible next owner for this PR state";
|
|
150
|
+
}
|
|
151
|
+
export function buildCiEntry(params) {
|
|
152
|
+
const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
|
|
153
|
+
const owner = deriveCiOwner({
|
|
154
|
+
delegatedToPatchRelay,
|
|
155
|
+
gateCheckStatus,
|
|
156
|
+
activeRunId: issue.activeRunId,
|
|
157
|
+
factoryState: issue.factoryState,
|
|
158
|
+
reviewDecision,
|
|
159
|
+
reviewRequested,
|
|
160
|
+
currentHeadSha,
|
|
161
|
+
latestBlockingReviewHeadSha,
|
|
162
|
+
mergeConflictDetected,
|
|
163
|
+
reviewQuillAttempt,
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
167
|
+
projectId: issue.projectId,
|
|
168
|
+
prNumber: issue.prNumber,
|
|
169
|
+
gateStatus: gateCheckStatus,
|
|
170
|
+
owner,
|
|
171
|
+
orphaned: owner === "unknown",
|
|
172
|
+
factoryState: issue.factoryState,
|
|
173
|
+
...(reviewDecision ? { reviewDecision } : {}),
|
|
174
|
+
message: describeCiOwnership({
|
|
175
|
+
delegatedToPatchRelay,
|
|
176
|
+
gateCheckStatus,
|
|
177
|
+
owner,
|
|
178
|
+
reviewDecision,
|
|
179
|
+
reviewRequested,
|
|
180
|
+
currentHeadSha,
|
|
181
|
+
latestBlockingReviewHeadSha,
|
|
182
|
+
mergeConflictDetected,
|
|
183
|
+
reviewQuillAttempt,
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { resolveClosedPrDisposition } from "../../pr-state.js";
|
|
2
|
+
import { buildCiEntry, deriveCiGateStatus, getGateCheckNames, } from "./ci-classification.js";
|
|
3
|
+
import { extractLatestBlockingReviewHeadSha, extractRequestedReviewerLogins, probeGitHubPullRequest, } from "./github-probe.js";
|
|
4
|
+
import { RECONCILIATION_GRACE_MS } from "./shared.js";
|
|
5
|
+
export async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe) {
|
|
6
|
+
const { issue, ageMs } = snapshot;
|
|
7
|
+
const project = config.projects.find((entry) => entry.id === issue.projectId);
|
|
8
|
+
const repoFullName = project?.github?.repoFullName;
|
|
9
|
+
if (!repoFullName || issue.prNumber === undefined) {
|
|
10
|
+
return {
|
|
11
|
+
finding: issue.prNumber !== undefined
|
|
12
|
+
? {
|
|
13
|
+
status: "fail",
|
|
14
|
+
scope: "github:config",
|
|
15
|
+
message: "PR-backed issue has no GitHub repo configured",
|
|
16
|
+
}
|
|
17
|
+
: undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const probe = await probeGitHubPullRequest(runCommand, repoFullName, issue.prNumber);
|
|
21
|
+
if (!probe.ok) {
|
|
22
|
+
return {
|
|
23
|
+
ciEntry: {
|
|
24
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
25
|
+
projectId: issue.projectId,
|
|
26
|
+
prNumber: issue.prNumber,
|
|
27
|
+
gateStatus: "unknown",
|
|
28
|
+
owner: "unknown",
|
|
29
|
+
orphaned: true,
|
|
30
|
+
factoryState: issue.factoryState,
|
|
31
|
+
message: `GitHub probe failed: ${probe.error}`,
|
|
32
|
+
},
|
|
33
|
+
finding: {
|
|
34
|
+
status: "warn",
|
|
35
|
+
scope: "github:probe",
|
|
36
|
+
message: `Unable to query GitHub PR state: ${probe.error}`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const pr = probe.pr;
|
|
41
|
+
const gateCheckNames = getGateCheckNames(project);
|
|
42
|
+
const gateCheckStatus = deriveCiGateStatus(pr.statusCheckRollup, gateCheckNames);
|
|
43
|
+
const reviewDecision = pr.reviewDecision?.trim().toUpperCase();
|
|
44
|
+
const requestedReviewers = extractRequestedReviewerLogins(pr.reviewRequests);
|
|
45
|
+
const reviewRequested = requestedReviewers.length > 0;
|
|
46
|
+
const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
|
|
47
|
+
const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
|
|
48
|
+
const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
|
|
49
|
+
if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
50
|
+
return {
|
|
51
|
+
finding: {
|
|
52
|
+
status: "fail",
|
|
53
|
+
scope: "github:reconcile",
|
|
54
|
+
message: "PR is already merged but the issue has not advanced to done",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (pr.state === "CLOSED") {
|
|
59
|
+
const closedPrDisposition = resolveClosedPrDisposition(issue);
|
|
60
|
+
if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
61
|
+
return {
|
|
62
|
+
finding: {
|
|
63
|
+
status: "fail",
|
|
64
|
+
scope: "github:reconcile",
|
|
65
|
+
message: "PR is closed but unfinished work has not been re-delegated",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
const ciEntry = buildCiEntry({
|
|
72
|
+
issue,
|
|
73
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
74
|
+
gateCheckStatus,
|
|
75
|
+
reviewDecision,
|
|
76
|
+
reviewRequested,
|
|
77
|
+
currentHeadSha: pr.headRefOid,
|
|
78
|
+
latestBlockingReviewHeadSha,
|
|
79
|
+
mergeConflictDetected,
|
|
80
|
+
reviewQuillAttempt,
|
|
81
|
+
});
|
|
82
|
+
if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
83
|
+
return {
|
|
84
|
+
ciEntry,
|
|
85
|
+
finding: {
|
|
86
|
+
status: "fail",
|
|
87
|
+
scope: "github:reconcile",
|
|
88
|
+
message: "PR is already merged but the issue has not advanced to done",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
93
|
+
return {
|
|
94
|
+
ciEntry,
|
|
95
|
+
finding: {
|
|
96
|
+
status: "fail",
|
|
97
|
+
scope: "github:reconcile",
|
|
98
|
+
message: "PR is closed but the issue is still waiting on PR state",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (issue.delegatedToPatchRelay
|
|
103
|
+
&& gateCheckStatus === "failure"
|
|
104
|
+
&& issue.factoryState !== "repairing_ci"
|
|
105
|
+
// Plan §6.1 / §4.3: branch CI failures while In Deploy are
|
|
106
|
+
// metadata only — the lander's spec CI is the gate. Don't flag
|
|
107
|
+
// these as a missing-ci-repair condition.
|
|
108
|
+
&& issue.factoryState !== "awaiting_queue"
|
|
109
|
+
&& issue.activeRunId === undefined
|
|
110
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
111
|
+
// Plan §6.1: when the PR is also approved, this is the
|
|
112
|
+
// "In Review · stuck at admission" condition — the lander would
|
|
113
|
+
// accept the verdict but branch CI is red and (post-§4.3) we no
|
|
114
|
+
// longer auto-repair. Keep the same scope/status pair so existing
|
|
115
|
+
// dashboards continue to surface it; just sharpen the message.
|
|
116
|
+
if (reviewDecision === "APPROVED") {
|
|
117
|
+
return {
|
|
118
|
+
ciEntry,
|
|
119
|
+
finding: {
|
|
120
|
+
status: "fail",
|
|
121
|
+
scope: "github:ci",
|
|
122
|
+
message: "In Review · stuck at admission — PR is approved but gate CI is red and no CI repair is running",
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ciEntry,
|
|
128
|
+
finding: {
|
|
129
|
+
status: "fail",
|
|
130
|
+
scope: "github:ci",
|
|
131
|
+
message: "Gate CI is failing but no CI repair is running or queued",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (reviewDecision === "APPROVED" && issue.factoryState !== "awaiting_queue" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
136
|
+
return {
|
|
137
|
+
ciEntry,
|
|
138
|
+
finding: {
|
|
139
|
+
status: "fail",
|
|
140
|
+
scope: "github:reconcile",
|
|
141
|
+
message: "PR is approved but the issue has not handed off to downstream merge automation",
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (gateCheckStatus === "success"
|
|
146
|
+
&& reviewDecision === "CHANGES_REQUESTED"
|
|
147
|
+
&& mergeConflictDetected
|
|
148
|
+
&& issue.delegatedToPatchRelay
|
|
149
|
+
&& issue.factoryState !== "changes_requested"
|
|
150
|
+
&& issue.activeRunId === undefined
|
|
151
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
152
|
+
return {
|
|
153
|
+
ciEntry,
|
|
154
|
+
finding: {
|
|
155
|
+
status: "fail",
|
|
156
|
+
scope: "github:branch-upkeep",
|
|
157
|
+
message: "PR is still dirty after requested changes, but no branch-upkeep run is active",
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (gateCheckStatus === "success"
|
|
162
|
+
&& reviewDecision === "CHANGES_REQUESTED"
|
|
163
|
+
&& latestBlockingReviewHeadSha === pr.headRefOid
|
|
164
|
+
&& !reviewQuillAttempt
|
|
165
|
+
&& issue.delegatedToPatchRelay
|
|
166
|
+
&& issue.factoryState !== "changes_requested"
|
|
167
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
168
|
+
return {
|
|
169
|
+
ciEntry,
|
|
170
|
+
finding: {
|
|
171
|
+
status: "fail",
|
|
172
|
+
scope: "github:review-handoff",
|
|
173
|
+
message: "Requested changes still block the current head, but no review fix is running",
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (requestedReviewers.includes("review-quill") && reviewQuillProbe && reviewQuillProbe.status !== "pass") {
|
|
178
|
+
return {
|
|
179
|
+
ciEntry,
|
|
180
|
+
finding: {
|
|
181
|
+
status: "fail",
|
|
182
|
+
scope: "github:review-automation",
|
|
183
|
+
message: `PR is waiting on review-quill but the service is not healthy: ${reviewQuillProbe.message}`,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (issue.delegatedToPatchRelay
|
|
188
|
+
&& issue.factoryState === "awaiting_queue"
|
|
189
|
+
&& mergeConflictDetected
|
|
190
|
+
&& issue.activeRunId === undefined
|
|
191
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
192
|
+
return {
|
|
193
|
+
ciEntry,
|
|
194
|
+
finding: {
|
|
195
|
+
status: "fail",
|
|
196
|
+
scope: "github:queue",
|
|
197
|
+
message: "PR has merge conflicts but no queue repair is running or queued",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (issue.factoryState === "awaiting_queue" && mergeStewardProbe && mergeStewardProbe.status !== "pass" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
202
|
+
return {
|
|
203
|
+
ciEntry,
|
|
204
|
+
finding: {
|
|
205
|
+
status: "fail",
|
|
206
|
+
scope: "github:queue",
|
|
207
|
+
message: `Issue is waiting on downstream merge automation but merge-steward is not healthy: ${mergeStewardProbe.message}`,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { ciEntry };
|
|
212
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { safeJsonParse } from "./shared.js";
|
|
2
|
+
export async function probeGitHubPullRequest(runCommand, repoFullName, prNumber) {
|
|
3
|
+
let result;
|
|
4
|
+
try {
|
|
5
|
+
result = await runCommand("gh", [
|
|
6
|
+
"pr",
|
|
7
|
+
"view",
|
|
8
|
+
String(prNumber),
|
|
9
|
+
"--repo",
|
|
10
|
+
repoFullName,
|
|
11
|
+
"--json",
|
|
12
|
+
"state,reviewDecision,reviewRequests,latestReviews,statusCheckRollup,mergeable,mergeStateStatus,headRefOid",
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
17
|
+
}
|
|
18
|
+
if (result.exitCode !== 0) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ") || `gh exited ${result.exitCode}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const parsed = safeJsonParse(result.stdout);
|
|
25
|
+
if (!parsed) {
|
|
26
|
+
return { ok: false, error: "invalid JSON from gh pr view" };
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, pr: parsed };
|
|
29
|
+
}
|
|
30
|
+
export function extractLatestBlockingReviewHeadSha(latestReviews) {
|
|
31
|
+
if (!Array.isArray(latestReviews)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
for (const review of latestReviews) {
|
|
35
|
+
if (!review || typeof review !== "object")
|
|
36
|
+
continue;
|
|
37
|
+
const state = typeof review.state === "string"
|
|
38
|
+
? String(review.state).trim().toUpperCase()
|
|
39
|
+
: undefined;
|
|
40
|
+
if (state !== "CHANGES_REQUESTED")
|
|
41
|
+
continue;
|
|
42
|
+
const commitOid = typeof review.commit?.oid === "string"
|
|
43
|
+
? String(review.commit.oid).trim()
|
|
44
|
+
: undefined;
|
|
45
|
+
if (commitOid)
|
|
46
|
+
return commitOid;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
export function extractRequestedReviewerLogins(requests) {
|
|
51
|
+
if (!Array.isArray(requests)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const logins = requests.flatMap((request) => {
|
|
55
|
+
if (!request || typeof request !== "object") {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const direct = typeof request.login === "string"
|
|
59
|
+
? String(request.login)
|
|
60
|
+
: undefined;
|
|
61
|
+
const nested = typeof request.requestedReviewer?.login === "string"
|
|
62
|
+
? String(request.requestedReviewer.login)
|
|
63
|
+
: undefined;
|
|
64
|
+
return [direct, nested].filter((entry) => Boolean(entry)).map((entry) => entry.trim().toLowerCase());
|
|
65
|
+
});
|
|
66
|
+
return [...new Set(logins)];
|
|
67
|
+
}
|