@h-rig/bundle-default-lifecycle 0.0.6-alpha.155 → 0.0.6-alpha.157
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/src/branch-naming.d.ts +15 -0
- package/dist/src/branch-naming.js +33 -0
- package/dist/src/cli.js +4 -4
- package/dist/src/closeoutEquivalence.d.ts +1 -1
- package/dist/src/control-plane/completion-verification.d.ts +19 -0
- package/dist/src/control-plane/completion-verification.js +1917 -0
- package/dist/src/control-plane/pr-automation.d.ts +87 -0
- package/dist/src/control-plane/pr-automation.js +638 -0
- package/dist/src/control-plane/task-verify.d.ts +1 -0
- package/dist/src/control-plane/task-verify.js +1484 -0
- package/dist/src/control-plane/verifier.d.ts +138 -0
- package/dist/src/control-plane/verifier.js +1478 -0
- package/dist/src/defaultPipeline.js +4 -4
- package/dist/src/index.js +2716 -54
- package/dist/src/native/closeout-runners.d.ts +5 -0
- package/dist/src/native/closeout-runners.js +41 -0
- package/dist/src/native/in-process-closeout.d.ts +40 -0
- package/dist/src/native/in-process-closeout.js +802 -0
- package/dist/src/pipelineCloseout.d.ts +1 -1
- package/dist/src/pipelineCloseout.js +2712 -52
- package/dist/src/plugin.d.ts +4 -17
- package/dist/src/plugin.js +2029 -25
- package/dist/src/stages/auto-merge.d.ts +1 -2
- package/dist/src/stages/auto-merge.js +657 -3
- package/dist/src/stages/commit.d.ts +1 -1
- package/dist/src/stages/commit.js +657 -3
- package/dist/src/stages/isolation.js +3 -2
- package/dist/src/stages/merge-gate.d.ts +1 -2
- package/dist/src/stages/merge-gate.js +1 -1
- package/dist/src/stages/open-pr.d.ts +2 -2
- package/dist/src/stages/open-pr.js +657 -3
- package/dist/src/stages/push.d.ts +1 -1
- package/dist/src/stages/push.js +657 -3
- package/dist/src/stages/source-closeout.d.ts +1 -1
- package/dist/src/stages/source-closeout.js +657 -3
- package/dist/src/stages/validate.d.ts +1 -1
- package/package.json +32 -5
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/bundle-default-lifecycle/src/control-plane/verifier.ts
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { resolveRuntimeSecrets } from "@rig/runtime/control-plane/runtime/baked-secrets";
|
|
6
|
+
import { readPrMetadata } from "@rig/runtime/control-plane/native/git-ops";
|
|
7
|
+
import { loadRuntimeContextFromEnv } from "@rig/runtime/control-plane/runtime/context";
|
|
8
|
+
import { readConfiguredTaskSourceTask } from "@rig/runtime/control-plane/tasks/source-lifecycle";
|
|
9
|
+
import { artifactDirForId, lookupTask, readTaskConfig } from "@rig/runtime/control-plane/native/task-state";
|
|
10
|
+
import { nowIso, resolveHarnessPaths, runCapture } from "@rig/runtime/control-plane/native/utils";
|
|
11
|
+
import {
|
|
12
|
+
collectPrReviewEvidence,
|
|
13
|
+
evaluateStrictPrMergeGate,
|
|
14
|
+
parseGreptileScore,
|
|
15
|
+
stripHtml
|
|
16
|
+
} from "@rig/pr-review-plugin";
|
|
17
|
+
async function verifyTask(options) {
|
|
18
|
+
const paths = resolveHarnessPaths(options.projectRoot);
|
|
19
|
+
const taskId = options.taskId;
|
|
20
|
+
const normalizedTaskId = lookupTask(options.projectRoot, taskId);
|
|
21
|
+
const artifactDir = artifactDirForId(options.projectRoot, taskId);
|
|
22
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
23
|
+
const validationSummaryPath = resolve(artifactDir, "validation-summary.json");
|
|
24
|
+
const reviewFeedbackPath = resolve(artifactDir, "review-feedback.md");
|
|
25
|
+
const reviewStatePath = resolve(artifactDir, "review-state.json");
|
|
26
|
+
const greptileRawPath = resolve(artifactDir, "review-greptile-raw.json");
|
|
27
|
+
const prStates = readPrMetadata(options.projectRoot, taskId);
|
|
28
|
+
const prState = prStates[0] || null;
|
|
29
|
+
const localReasons = [];
|
|
30
|
+
const aiReasons = [];
|
|
31
|
+
const aiWarnings = [];
|
|
32
|
+
let aiVerdict = "SKIP";
|
|
33
|
+
let aiRawFeedback = "";
|
|
34
|
+
const persistArtifacts = options.persistArtifacts !== false;
|
|
35
|
+
if (!normalizedTaskId && !await hasConfiguredSourceTask(options.projectRoot, taskId)) {
|
|
36
|
+
localReasons.push(`[Task Config] Unknown task id '${taskId}' in task-config or configured task source.`);
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(validationSummaryPath)) {
|
|
39
|
+
localReasons.push(`[Artifact Quality] validation-summary.json not found at ${validationSummaryPath}.`);
|
|
40
|
+
} else {
|
|
41
|
+
const summary = await parseValidationSummary(validationSummaryPath);
|
|
42
|
+
if (!isAcceptedValidationSummary(summary)) {
|
|
43
|
+
localReasons.push(`[Validation] validation-summary status is '${summary?.status ?? "unknown"}', expected 'pass' or zero-check 'skipped'.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const file of ["task-result.json", "decision-log.md", "next-actions.md", "changed-files.txt"]) {
|
|
47
|
+
const requiredPath = resolve(artifactDir, file);
|
|
48
|
+
if (!existsSync(requiredPath)) {
|
|
49
|
+
localReasons.push(`[Artifact Quality] Missing required artifact file: ${requiredPath}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const taskResultPath = resolve(artifactDir, "task-result.json");
|
|
53
|
+
if (existsSync(taskResultPath)) {
|
|
54
|
+
const taskResult = await readJsonFile(taskResultPath);
|
|
55
|
+
const artifactStatus = typeof taskResult?.status === "string" ? taskResult.status.trim().toLowerCase() : "";
|
|
56
|
+
if (artifactStatus === "partial") {
|
|
57
|
+
localReasons.push("[Artifact Quality] task-result.json status is 'partial'; completion requires a terminal result.");
|
|
58
|
+
}
|
|
59
|
+
if (hasNonEmptyBlockers(taskResult?.blockers)) {
|
|
60
|
+
localReasons.push("[Artifact Quality] task-result.json blockers must be empty for completion.");
|
|
61
|
+
}
|
|
62
|
+
if (nextActionsIndicateRemainingScope(stringifyNextActions(taskResult?.nextActions ?? taskResult?.next_actions))) {
|
|
63
|
+
localReasons.push("[Artifact Quality] task-result.json next actions indicate remaining implementation scope.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const nextActionsPath = resolve(artifactDir, "next-actions.md");
|
|
67
|
+
if (existsSync(nextActionsPath)) {
|
|
68
|
+
const nextActionsContent = await Bun.file(nextActionsPath).text();
|
|
69
|
+
if (nextActionsContent.includes("TODO: Replace this scaffold") || nextActionsContent.includes("bd-<downstream-task-id>")) {
|
|
70
|
+
localReasons.push("[Artifact Quality] next-actions.md still contains scaffold placeholder text. Replace with real recommendations.");
|
|
71
|
+
}
|
|
72
|
+
if (nextActionsIndicateRemainingScope(nextActionsContent)) {
|
|
73
|
+
localReasons.push("[Artifact Quality] next-actions.md indicates remaining implementation scope.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const sourceCloseoutIssueId = resolveGithubSourceIssueId(options.projectRoot, taskId);
|
|
77
|
+
if (sourceCloseoutIssueId) {
|
|
78
|
+
localReasons.push(...evaluateGithubSourceIssuePrCloseout(options.projectRoot, prStates, sourceCloseoutIssueId));
|
|
79
|
+
}
|
|
80
|
+
const reviewMode = await loadReviewMode(paths.reviewProfilePath, process.env.AI_REVIEW_MODE || "advisory");
|
|
81
|
+
const reviewProvider = await loadReviewProvider(paths.reviewProfilePath, process.env.AI_REVIEW_PROVIDER || "github");
|
|
82
|
+
if (!options.skipAiReview && localReasons.length === 0 && reviewProvider === "greptile" && reviewMode !== "off") {
|
|
83
|
+
const ai = await runGreptileReview({
|
|
84
|
+
projectRoot: options.projectRoot,
|
|
85
|
+
taskId,
|
|
86
|
+
artifactDir,
|
|
87
|
+
prStates,
|
|
88
|
+
reviewMode
|
|
89
|
+
});
|
|
90
|
+
aiVerdict = ai.verdict;
|
|
91
|
+
aiRawFeedback = ai.feedback;
|
|
92
|
+
aiReasons.push(...ai.reasons);
|
|
93
|
+
aiWarnings.push(...ai.warnings);
|
|
94
|
+
if (reviewMode === "required" && ai.verdict !== "APPROVE" && ai.reasons.length === 0) {
|
|
95
|
+
aiReasons.push(`[AI Review] Required mode needs a completed Greptile approval; current verdict is ${ai.verdict}.`);
|
|
96
|
+
}
|
|
97
|
+
if (persistArtifacts && ai.rawResponse) {
|
|
98
|
+
writeFileSync(greptileRawPath, `${ai.rawResponse}
|
|
99
|
+
`, "utf-8");
|
|
100
|
+
}
|
|
101
|
+
} else if (!options.skipAiReview && reviewMode === "off") {
|
|
102
|
+
aiWarnings.push("[AI Review] AI review mode is off.");
|
|
103
|
+
}
|
|
104
|
+
const aiReviewApproved = options.skipAiReview || reviewProvider !== "greptile" || isAiReviewApproved({
|
|
105
|
+
reviewMode,
|
|
106
|
+
aiVerdict,
|
|
107
|
+
aiReasons
|
|
108
|
+
});
|
|
109
|
+
if (persistArtifacts) {
|
|
110
|
+
writeFeedbackFile({
|
|
111
|
+
taskId,
|
|
112
|
+
provider: reviewProvider,
|
|
113
|
+
mode: reviewMode,
|
|
114
|
+
verdict: aiVerdict,
|
|
115
|
+
localReasons,
|
|
116
|
+
aiReasons,
|
|
117
|
+
aiWarnings,
|
|
118
|
+
aiRawFeedback,
|
|
119
|
+
output: reviewFeedbackPath
|
|
120
|
+
});
|
|
121
|
+
writeReviewStateFile({
|
|
122
|
+
taskId,
|
|
123
|
+
approved: localReasons.length === 0 && aiReviewApproved,
|
|
124
|
+
provider: reviewProvider,
|
|
125
|
+
mode: reviewMode,
|
|
126
|
+
verdict: aiVerdict,
|
|
127
|
+
prState,
|
|
128
|
+
localReasons,
|
|
129
|
+
aiReasons,
|
|
130
|
+
aiWarnings,
|
|
131
|
+
output: reviewStatePath
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (localReasons.length > 0) {
|
|
135
|
+
return {
|
|
136
|
+
approved: false,
|
|
137
|
+
localReasons,
|
|
138
|
+
aiReasons,
|
|
139
|
+
aiWarnings,
|
|
140
|
+
aiVerdict,
|
|
141
|
+
reviewFeedbackPath,
|
|
142
|
+
reviewStatePath
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!aiReviewApproved) {
|
|
146
|
+
return {
|
|
147
|
+
approved: false,
|
|
148
|
+
localReasons,
|
|
149
|
+
aiReasons,
|
|
150
|
+
aiWarnings,
|
|
151
|
+
aiVerdict,
|
|
152
|
+
reviewFeedbackPath,
|
|
153
|
+
reviewStatePath
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
approved: true,
|
|
158
|
+
localReasons,
|
|
159
|
+
aiReasons,
|
|
160
|
+
aiWarnings,
|
|
161
|
+
aiVerdict,
|
|
162
|
+
reviewFeedbackPath,
|
|
163
|
+
reviewStatePath
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function readJsonFile(path) {
|
|
167
|
+
try {
|
|
168
|
+
return await Bun.file(path).json();
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function hasNonEmptyBlockers(blockers) {
|
|
174
|
+
if (blockers == null) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (typeof blockers === "string") {
|
|
178
|
+
const normalized = blockers.trim().toLowerCase();
|
|
179
|
+
return normalized.length > 0 && normalized !== "none" && normalized !== "n/a";
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(blockers)) {
|
|
182
|
+
return blockers.some((entry) => {
|
|
183
|
+
if (typeof entry === "string") {
|
|
184
|
+
const normalized = entry.trim().toLowerCase();
|
|
185
|
+
return normalized.length > 0 && normalized !== "none" && normalized !== "n/a";
|
|
186
|
+
}
|
|
187
|
+
return entry != null;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (typeof blockers === "object") {
|
|
191
|
+
return Object.keys(blockers).length > 0;
|
|
192
|
+
}
|
|
193
|
+
return Boolean(blockers);
|
|
194
|
+
}
|
|
195
|
+
function stringifyNextActions(value) {
|
|
196
|
+
if (value == null) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
if (typeof value === "string") {
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value.map((entry) => stringifyNextActions(entry)).join(`
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
206
|
+
return JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
function nextActionsIndicateRemainingScope(content) {
|
|
209
|
+
const normalized = content.trim();
|
|
210
|
+
if (!normalized) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const lower = normalized.toLowerCase();
|
|
214
|
+
if (/^(#\s*)?next actions\s*\n+\s*(none|n\/a|no remaining scope)\.?\s*$/i.test(normalized)) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return /^\s*- \[ \]/m.test(normalized) || /\b(remaining scope|still need|needs? to be implemented|not yet implemented|not implemented|follow[- ]?up required|blocked by|blocker:)\b/i.test(lower);
|
|
218
|
+
}
|
|
219
|
+
async function hasConfiguredSourceTask(projectRoot, taskId) {
|
|
220
|
+
return readConfiguredTaskSourceTask(projectRoot, taskId).then((result) => result.task !== null).catch(() => false);
|
|
221
|
+
}
|
|
222
|
+
function resolveGithubSourceIssueId(projectRoot, taskId) {
|
|
223
|
+
const fromRuntime = loadRuntimeContextFromEnv()?.sourceTask?.sourceIssueId;
|
|
224
|
+
if (typeof fromRuntime === "string" && isGithubSourceIssueId(fromRuntime)) {
|
|
225
|
+
return fromRuntime;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const taskConfig = readTaskConfig(projectRoot);
|
|
229
|
+
const entry = taskConfig[taskId];
|
|
230
|
+
const sourceIssueId = typeof entry?.sourceIssueId === "string" ? entry.sourceIssueId : typeof entry?.source_issue_id === "string" ? entry.source_issue_id : null;
|
|
231
|
+
if (sourceIssueId && isGithubSourceIssueId(sourceIssueId)) {
|
|
232
|
+
return sourceIssueId;
|
|
233
|
+
}
|
|
234
|
+
const rig = entry?._rig;
|
|
235
|
+
if (rig && typeof rig === "object" && !Array.isArray(rig)) {
|
|
236
|
+
const rigRecord = rig;
|
|
237
|
+
const rigSourceIssueId = typeof rigRecord.sourceIssueId === "string" ? rigRecord.sourceIssueId : null;
|
|
238
|
+
if (rigSourceIssueId && isGithubSourceIssueId(rigSourceIssueId)) {
|
|
239
|
+
return rigSourceIssueId;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
function isGithubSourceIssueId(value) {
|
|
246
|
+
return /^[^/\s]+\/[^#\s]+#\d+$/.test(value.trim());
|
|
247
|
+
}
|
|
248
|
+
function evaluateGithubSourceIssuePrCloseout(projectRoot, prStates, sourceIssueId) {
|
|
249
|
+
const issueNumber = sourceIssueId.match(/#(\d+)$/)?.[1];
|
|
250
|
+
if (!issueNumber) {
|
|
251
|
+
return [`[Source Issue] GitHub issue task ${sourceIssueId} has an invalid source issue id.`];
|
|
252
|
+
}
|
|
253
|
+
if (prStates.length === 0) {
|
|
254
|
+
return [
|
|
255
|
+
`[Source Issue] GitHub issue task ${sourceIssueId} must have an open, green, mergeable PR with resolved review feedback and closeout intent (for example "Closes #${issueNumber}"). Source issue closure or merge is not required before completion.`
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
const rejectionReasons = [];
|
|
259
|
+
for (const prState of prStates) {
|
|
260
|
+
const hydratedPr = hydratePrCloseoutState(projectRoot, prState);
|
|
261
|
+
const prReasons = evaluateSinglePrSourceIssueCloseout(hydratedPr, sourceIssueId, issueNumber);
|
|
262
|
+
if (prReasons.length === 0) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
rejectionReasons.push(...prReasons);
|
|
266
|
+
}
|
|
267
|
+
return uniqueStrings(rejectionReasons);
|
|
268
|
+
}
|
|
269
|
+
function hydratePrCloseoutState(projectRoot, prState) {
|
|
270
|
+
if (hasLocalPrCloseoutEvidence(prState)) {
|
|
271
|
+
return prState;
|
|
272
|
+
}
|
|
273
|
+
const snapshot = loadGithubPullRequestCloseoutSnapshot(projectRoot, prState);
|
|
274
|
+
return snapshot ? { ...prState, ...snapshot } : prState;
|
|
275
|
+
}
|
|
276
|
+
function hasLocalPrCloseoutEvidence(prState) {
|
|
277
|
+
const hasCloseoutText = typeof prState.title === "string" || typeof prState.body === "string" || Array.isArray(prState.closingIssues) && prState.closingIssues.length > 0;
|
|
278
|
+
return hasCloseoutText && typeof prState.state === "string" && typeof prState.isDraft === "boolean" && typeof prState.mergeable === "string" && typeof prState.mergeStateStatus === "string" && typeof prState.reviewDecision === "string" && Array.isArray(prState.statusCheckRollup) && Array.isArray(prState.reviewThreads);
|
|
279
|
+
}
|
|
280
|
+
function loadGithubPullRequestCloseoutSnapshot(projectRoot, prState) {
|
|
281
|
+
const prNumber = parsePullRequestNumber(prState.url || "");
|
|
282
|
+
if (!prNumber) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const repoName = deriveRepoName(projectRoot, prState);
|
|
287
|
+
if (!repoName) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const view = runGhJson(projectRoot, [
|
|
291
|
+
"pr",
|
|
292
|
+
"view",
|
|
293
|
+
`${prNumber}`,
|
|
294
|
+
"--repo",
|
|
295
|
+
repoName,
|
|
296
|
+
"--json",
|
|
297
|
+
"state,isDraft,mergeable,mergeStateStatus,reviewDecision,title,body,statusCheckRollup"
|
|
298
|
+
]);
|
|
299
|
+
return {
|
|
300
|
+
state: stringField(view, "state"),
|
|
301
|
+
isDraft: booleanField(view, "isDraft"),
|
|
302
|
+
mergeable: stringField(view, "mergeable"),
|
|
303
|
+
mergeStateStatus: stringField(view, "mergeStateStatus"),
|
|
304
|
+
reviewDecision: stringField(view, "reviewDecision"),
|
|
305
|
+
title: stringField(view, "title"),
|
|
306
|
+
body: stringField(view, "body"),
|
|
307
|
+
statusCheckRollup: statusCheckRollupField(view, "statusCheckRollup"),
|
|
308
|
+
reviewThreads: loadGithubReviewThreads(projectRoot, repoName, prNumber)
|
|
309
|
+
};
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function evaluateSinglePrSourceIssueCloseout(prState, sourceIssueId, issueNumber) {
|
|
315
|
+
const prLabel = prState.url || prState.branch || "recorded PR";
|
|
316
|
+
const reasons = [];
|
|
317
|
+
if (!prStateClosesIssue(prState, sourceIssueId, issueNumber)) {
|
|
318
|
+
reasons.push(`[Source Issue] GitHub issue task ${sourceIssueId} must have PR closeout intent (for example "Closes #${issueNumber}"). "Closes part of #N" is not sufficient.`);
|
|
319
|
+
}
|
|
320
|
+
const state = (prState.state || "").toUpperCase();
|
|
321
|
+
if (state !== "OPEN") {
|
|
322
|
+
reasons.push(`[Source Issue] PR ${prLabel} must be open before operator-controlled merge; current state is ${prState.state || "unknown"}.`);
|
|
323
|
+
}
|
|
324
|
+
if (prState.isDraft !== false) {
|
|
325
|
+
reasons.push(`[Source Issue] PR ${prLabel} must not be a draft.`);
|
|
326
|
+
}
|
|
327
|
+
const mergeable = (prState.mergeable || "").toUpperCase();
|
|
328
|
+
const mergeStateStatus = (prState.mergeStateStatus || "").toUpperCase();
|
|
329
|
+
if (mergeable !== "MERGEABLE" || mergeStateStatus === "DIRTY" || mergeStateStatus === "BLOCKED") {
|
|
330
|
+
reasons.push(`[Source Issue] PR ${prLabel} must be mergeable before completion (mergeable=${prState.mergeable || "unknown"}, mergeStateStatus=${prState.mergeStateStatus || "unknown"}).`);
|
|
331
|
+
}
|
|
332
|
+
reasons.push(...evaluateSourceCloseoutChecks(prState, prLabel));
|
|
333
|
+
reasons.push(...evaluateSourceCloseoutReviewState(prState, prLabel));
|
|
334
|
+
return reasons;
|
|
335
|
+
}
|
|
336
|
+
function evaluateSourceCloseoutChecks(prState, prLabel) {
|
|
337
|
+
const checks = prState.statusCheckRollup;
|
|
338
|
+
if (!Array.isArray(checks) || checks.length === 0) {
|
|
339
|
+
return [`[Source Issue] PR ${prLabel} must have green check evidence before completion.`];
|
|
340
|
+
}
|
|
341
|
+
const ciGate = evaluatePullRequestCiChecks(checks, "PR", 0, { mergeStateStatus: prState.mergeStateStatus });
|
|
342
|
+
if (ciGate.verdict === "APPROVE") {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
return ciGate.reasons.map((reason) => reason.replace("PR#0", prLabel));
|
|
346
|
+
}
|
|
347
|
+
function evaluateSourceCloseoutReviewState(prState, prLabel) {
|
|
348
|
+
const reasons = [];
|
|
349
|
+
const reviewDecision = (prState.reviewDecision || "").toUpperCase();
|
|
350
|
+
if (reviewDecision === "REVIEW_REQUIRED" || reviewDecision === "CHANGES_REQUESTED") {
|
|
351
|
+
reasons.push(`[Source Issue] PR ${prLabel} required review is unresolved (${prState.reviewDecision}).`);
|
|
352
|
+
}
|
|
353
|
+
if (!Array.isArray(prState.reviewThreads)) {
|
|
354
|
+
reasons.push(`[Source Issue] PR ${prLabel} review thread resolution could not be verified.`);
|
|
355
|
+
return reasons;
|
|
356
|
+
}
|
|
357
|
+
for (const comment of filterUnresolvedReviewThreadComments(prState.reviewThreads)) {
|
|
358
|
+
reasons.push(`[Source Issue] PR ${prLabel} has unresolved review thread on ${comment.path || "unknown path"}: ${summarizeComment(comment.body || "")}`);
|
|
359
|
+
}
|
|
360
|
+
return reasons;
|
|
361
|
+
}
|
|
362
|
+
function filterUnresolvedReviewThreadComments(threads) {
|
|
363
|
+
const comments = [];
|
|
364
|
+
for (const thread of threads) {
|
|
365
|
+
if (thread.isResolved === true || thread.isOutdated === true) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const threadComments = thread.comments?.nodes ?? [];
|
|
369
|
+
if (threadComments.length === 0) {
|
|
370
|
+
comments.push({ body: "Unresolved review thread" });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
comments.push(threadComments[threadComments.length - 1]);
|
|
374
|
+
}
|
|
375
|
+
return comments;
|
|
376
|
+
}
|
|
377
|
+
function stringField(record, key) {
|
|
378
|
+
const value = record[key];
|
|
379
|
+
return typeof value === "string" ? value : undefined;
|
|
380
|
+
}
|
|
381
|
+
function booleanField(record, key) {
|
|
382
|
+
const value = record[key];
|
|
383
|
+
return typeof value === "boolean" ? value : undefined;
|
|
384
|
+
}
|
|
385
|
+
function statusCheckRollupField(record, key) {
|
|
386
|
+
const value = record[key];
|
|
387
|
+
if (!Array.isArray(value)) {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
return value.filter(isGithubStatusCheckRollupItem);
|
|
391
|
+
}
|
|
392
|
+
function isGithubStatusCheckRollupItem(value) {
|
|
393
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
394
|
+
}
|
|
395
|
+
function uniqueStrings(values) {
|
|
396
|
+
return Array.from(new Set(values));
|
|
397
|
+
}
|
|
398
|
+
function prStateClosesIssue(pr, sourceIssueId, issueNumber) {
|
|
399
|
+
const closingIssues = pr.closingIssues ?? [];
|
|
400
|
+
if (closingIssues.some((issue) => closingIssueMatches(issue, sourceIssueId, issueNumber))) {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
const text = [pr.title, pr.body].filter((value) => typeof value === "string").join(`
|
|
404
|
+
`);
|
|
405
|
+
return prTextClosesIssue(text, issueNumber);
|
|
406
|
+
}
|
|
407
|
+
function closingIssueMatches(issue, sourceIssueId, issueNumber) {
|
|
408
|
+
if (typeof issue === "number") {
|
|
409
|
+
return String(issue) === issueNumber;
|
|
410
|
+
}
|
|
411
|
+
if (typeof issue === "string") {
|
|
412
|
+
return issue === sourceIssueId || issue.replace(/^#/, "") === issueNumber;
|
|
413
|
+
}
|
|
414
|
+
return String(issue.number ?? "") === issueNumber || issue.id === issueNumber || issue.sourceIssueId === sourceIssueId;
|
|
415
|
+
}
|
|
416
|
+
function prTextClosesIssue(text, issueNumber) {
|
|
417
|
+
if (!text.trim()) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const escaped = issueNumber.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
421
|
+
const closePattern = new RegExp(`\\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\\s+#${escaped}\\b`, "i");
|
|
422
|
+
return closePattern.test(text);
|
|
423
|
+
}
|
|
424
|
+
async function parseValidationSummary(path) {
|
|
425
|
+
return readJsonFile(path);
|
|
426
|
+
}
|
|
427
|
+
function isAcceptedValidationSummary(summary) {
|
|
428
|
+
if (!summary) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
if (summary.status === "pass") {
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
return summary.status === "skipped" && summary.total === 0 && summary.failed === 0;
|
|
435
|
+
}
|
|
436
|
+
async function loadReviewMode(reviewProfilePath, fallback) {
|
|
437
|
+
const parsed = existsSync(reviewProfilePath) ? await readJsonFile(reviewProfilePath) : null;
|
|
438
|
+
const mode = parsed?.mode;
|
|
439
|
+
if (mode === "off" || mode === "advisory" || mode === "required") {
|
|
440
|
+
return mode;
|
|
441
|
+
}
|
|
442
|
+
if (fallback === "off" || fallback === "advisory" || fallback === "required") {
|
|
443
|
+
return fallback;
|
|
444
|
+
}
|
|
445
|
+
return "advisory";
|
|
446
|
+
}
|
|
447
|
+
async function loadReviewProvider(reviewProfilePath, fallback) {
|
|
448
|
+
const parsed = existsSync(reviewProfilePath) ? await readJsonFile(reviewProfilePath) : null;
|
|
449
|
+
const provider = parsed?.provider;
|
|
450
|
+
if (typeof provider === "string" && provider.trim().length > 0) {
|
|
451
|
+
return provider;
|
|
452
|
+
}
|
|
453
|
+
return fallback || "greptile";
|
|
454
|
+
}
|
|
455
|
+
function resolveRepoSlug(projectRoot) {
|
|
456
|
+
const paths = resolveHarnessPaths(projectRoot);
|
|
457
|
+
const remote = runCapture(["git", "-C", paths.monorepoRoot, "remote", "get-url", "origin"], projectRoot).stdout.trim() || runCapture(["git", "-C", projectRoot, "remote", "get-url", "origin"], projectRoot).stdout.trim();
|
|
458
|
+
if (!remote) {
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
if (remote.startsWith("git@")) {
|
|
462
|
+
return remote.split(":")[1]?.replace(/\.git$/, "") || "";
|
|
463
|
+
}
|
|
464
|
+
const cleaned = remote.replace(/^https?:\/\//, "").replace(/\.git$/, "");
|
|
465
|
+
const slash = cleaned.indexOf("/");
|
|
466
|
+
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned;
|
|
467
|
+
}
|
|
468
|
+
async function runGreptileReview(options) {
|
|
469
|
+
const reasons = [];
|
|
470
|
+
const warnings = [];
|
|
471
|
+
const secrets = resolveRuntimeSecrets(process.env);
|
|
472
|
+
const apiKey = secrets.GREPTILE_API_KEY || "";
|
|
473
|
+
const apiBase = secrets.GREPTILE_API_BASE || "https://api.greptile.com/mcp";
|
|
474
|
+
const remote = secrets.GREPTILE_REMOTE || "github";
|
|
475
|
+
const greptileDefaultBranch = secrets.GREPTILE_DEFAULT_BRANCH || "main";
|
|
476
|
+
const { pollAttempts, pollIntervalMs } = resolveGreptilePollSettings({
|
|
477
|
+
reviewMode: options.reviewMode,
|
|
478
|
+
secrets
|
|
479
|
+
});
|
|
480
|
+
if (!apiKey) {
|
|
481
|
+
reasons.push("[AI Review] Missing GREPTILE_API_KEY.");
|
|
482
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawResponse: "" };
|
|
483
|
+
}
|
|
484
|
+
if (options.prStates.length === 0) {
|
|
485
|
+
reasons.push("[AI Review] Missing pr-state.json or no PRs were recorded for review.");
|
|
486
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawResponse: "" };
|
|
487
|
+
}
|
|
488
|
+
const perPrResults = [];
|
|
489
|
+
for (const prState of options.prStates) {
|
|
490
|
+
try {
|
|
491
|
+
const prResult = await runGreptileReviewForPr({
|
|
492
|
+
apiBase,
|
|
493
|
+
apiKey,
|
|
494
|
+
remote,
|
|
495
|
+
defaultBranch: greptileDefaultBranch,
|
|
496
|
+
projectRoot: options.projectRoot,
|
|
497
|
+
taskId: options.taskId,
|
|
498
|
+
prState,
|
|
499
|
+
reviewMode: options.reviewMode,
|
|
500
|
+
pollAttempts,
|
|
501
|
+
pollIntervalMs
|
|
502
|
+
});
|
|
503
|
+
if (prResult.verdict === "SKIP") {
|
|
504
|
+
warnings.push(...prResult.reasons.map(asGreptileInfrastructureWarning));
|
|
505
|
+
} else {
|
|
506
|
+
reasons.push(...prResult.reasons);
|
|
507
|
+
}
|
|
508
|
+
warnings.push(...prResult.warnings);
|
|
509
|
+
perPrResults.push(prResult);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
+
const fallback = await runGithubGreptileFallbackReviewForPr({
|
|
513
|
+
projectRoot: options.projectRoot,
|
|
514
|
+
taskId: options.taskId,
|
|
515
|
+
prState,
|
|
516
|
+
reviewMode: options.reviewMode,
|
|
517
|
+
infrastructureError: message,
|
|
518
|
+
pollAttempts,
|
|
519
|
+
pollIntervalMs
|
|
520
|
+
}).catch(() => null);
|
|
521
|
+
if (fallback) {
|
|
522
|
+
if (fallback.verdict === "SKIP") {
|
|
523
|
+
warnings.push(...fallback.reasons.map(asGreptileInfrastructureWarning));
|
|
524
|
+
} else {
|
|
525
|
+
reasons.push(...fallback.reasons);
|
|
526
|
+
}
|
|
527
|
+
warnings.push(...fallback.warnings);
|
|
528
|
+
perPrResults.push(fallback);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
warnings.push(asGreptileInfrastructureWarning(`Greptile infrastructure failure for ${prState.url || prState.branch || options.taskId}: ${message}`));
|
|
532
|
+
perPrResults.push({
|
|
533
|
+
verdict: "SKIP",
|
|
534
|
+
feedback: "",
|
|
535
|
+
reasons: [],
|
|
536
|
+
warnings: [],
|
|
537
|
+
rawPayload: { pr: prState, error: message }
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (perPrResults.some((result) => result.verdict === "REJECT")) {
|
|
542
|
+
return {
|
|
543
|
+
verdict: "REJECT",
|
|
544
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
545
|
+
|
|
546
|
+
`),
|
|
547
|
+
reasons,
|
|
548
|
+
warnings,
|
|
549
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (perPrResults.every((result) => result.verdict === "APPROVE")) {
|
|
553
|
+
return {
|
|
554
|
+
verdict: "APPROVE",
|
|
555
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
556
|
+
|
|
557
|
+
`),
|
|
558
|
+
reasons,
|
|
559
|
+
warnings,
|
|
560
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
verdict: "SKIP",
|
|
565
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
566
|
+
|
|
567
|
+
`),
|
|
568
|
+
reasons,
|
|
569
|
+
warnings,
|
|
570
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function writeFeedbackFile(options) {
|
|
574
|
+
const lines = [
|
|
575
|
+
"# AI Review Feedback",
|
|
576
|
+
"",
|
|
577
|
+
`- Task: ${options.taskId}`,
|
|
578
|
+
`- Provider: ${options.provider}`,
|
|
579
|
+
`- Mode: ${options.mode}`,
|
|
580
|
+
`- Verdict: ${options.verdict}`,
|
|
581
|
+
""
|
|
582
|
+
];
|
|
583
|
+
if (options.localReasons.length > 0) {
|
|
584
|
+
lines.push("## Local Rejection Reasons", "");
|
|
585
|
+
for (const reason of options.localReasons) {
|
|
586
|
+
lines.push(`- ${reason}`);
|
|
587
|
+
}
|
|
588
|
+
lines.push("");
|
|
589
|
+
}
|
|
590
|
+
if (options.aiReasons.length > 0) {
|
|
591
|
+
lines.push("## AI Rejection Reasons", "");
|
|
592
|
+
for (const reason of options.aiReasons) {
|
|
593
|
+
lines.push(`- ${reason}`);
|
|
594
|
+
}
|
|
595
|
+
lines.push("");
|
|
596
|
+
}
|
|
597
|
+
if (options.aiWarnings.length > 0) {
|
|
598
|
+
lines.push("## AI Warnings", "");
|
|
599
|
+
for (const warning of options.aiWarnings) {
|
|
600
|
+
lines.push(`- ${warning}`);
|
|
601
|
+
}
|
|
602
|
+
lines.push("");
|
|
603
|
+
}
|
|
604
|
+
if (options.aiRawFeedback) {
|
|
605
|
+
lines.push("## Raw Reviewer Feedback", "", "```text", options.aiRawFeedback, "```", "");
|
|
606
|
+
}
|
|
607
|
+
writeFileSync(options.output, `${lines.join(`
|
|
608
|
+
`)}
|
|
609
|
+
`, "utf-8");
|
|
610
|
+
}
|
|
611
|
+
function writeReviewStateFile(options) {
|
|
612
|
+
const payload = {
|
|
613
|
+
task_id: options.taskId,
|
|
614
|
+
approved: options.approved,
|
|
615
|
+
provider: options.provider,
|
|
616
|
+
mode: options.mode,
|
|
617
|
+
verdict: options.verdict,
|
|
618
|
+
pr: options.prState,
|
|
619
|
+
local_reasons: options.localReasons,
|
|
620
|
+
ai_reasons: options.aiReasons,
|
|
621
|
+
ai_warnings: options.aiWarnings,
|
|
622
|
+
updated_at: nowIso()
|
|
623
|
+
};
|
|
624
|
+
writeFileSync(options.output, `${JSON.stringify(payload, null, 2)}
|
|
625
|
+
`, "utf-8");
|
|
626
|
+
}
|
|
627
|
+
async function runGreptileReviewForPr(options) {
|
|
628
|
+
const reasons = [];
|
|
629
|
+
const warnings = [];
|
|
630
|
+
const repoName = deriveRepoName(options.projectRoot, options.prState);
|
|
631
|
+
const prNumber = parsePullRequestNumber(options.prState.url || "");
|
|
632
|
+
const defaultBranch = options.defaultBranch;
|
|
633
|
+
const expectedHeadSha = resolvePrHeadSha(options.projectRoot, options.prState);
|
|
634
|
+
if (!repoName) {
|
|
635
|
+
reasons.push(`[AI Review] Could not resolve repository slug for ${options.prState.repoLabel || options.prState.target || "PR"}.`);
|
|
636
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawPayload: { pr: options.prState } };
|
|
637
|
+
}
|
|
638
|
+
if (!prNumber) {
|
|
639
|
+
reasons.push(`[AI Review] Could not parse PR number from ${options.prState.url || "missing PR URL"}.`);
|
|
640
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawPayload: { pr: options.prState } };
|
|
641
|
+
}
|
|
642
|
+
const githubPrState = loadGithubPullRequestState(options.projectRoot, repoName, prNumber);
|
|
643
|
+
if (shouldPreferGithubGreptileFallback(githubPrState)) {
|
|
644
|
+
return runGithubGreptileFallbackReviewForPr({
|
|
645
|
+
projectRoot: options.projectRoot,
|
|
646
|
+
taskId: options.taskId,
|
|
647
|
+
prState: options.prState,
|
|
648
|
+
reviewMode: options.reviewMode,
|
|
649
|
+
infrastructureError: undefined,
|
|
650
|
+
pollAttempts: options.pollAttempts,
|
|
651
|
+
pollIntervalMs: options.pollIntervalMs
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const initialReviewsPayload = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_code_reviews", {
|
|
655
|
+
name: repoName,
|
|
656
|
+
remote: options.remote,
|
|
657
|
+
defaultBranch,
|
|
658
|
+
prNumber,
|
|
659
|
+
limit: 20
|
|
660
|
+
});
|
|
661
|
+
const existingReview = findGreptileReviewForHeadSha(initialReviewsPayload.codeReviews || [], expectedHeadSha);
|
|
662
|
+
const shouldTrigger = shouldTriggerGreptileReview(existingReview, expectedHeadSha);
|
|
663
|
+
let triggerStartedAt = Date.now();
|
|
664
|
+
if (shouldTrigger) {
|
|
665
|
+
triggerStartedAt = Date.now();
|
|
666
|
+
await callGreptileMcpTool(options.apiBase, options.apiKey, "trigger_code_review", {
|
|
667
|
+
name: repoName,
|
|
668
|
+
remote: options.remote,
|
|
669
|
+
defaultBranch,
|
|
670
|
+
branch: options.prState.branch,
|
|
671
|
+
prNumber
|
|
672
|
+
}).catch((error) => {
|
|
673
|
+
throw new Error(`Greptile trigger failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
|
|
674
|
+
});
|
|
675
|
+
} else {
|
|
676
|
+
const existingCreatedAt = Date.parse(existingReview?.createdAt || "");
|
|
677
|
+
triggerStartedAt = Number.isFinite(existingCreatedAt) ? existingCreatedAt : Date.now();
|
|
678
|
+
}
|
|
679
|
+
let selectedReview = null;
|
|
680
|
+
let reviewsPayload = initialReviewsPayload;
|
|
681
|
+
let githubCheckRollup = [];
|
|
682
|
+
let githubCheckState = { pending: false, completed: false };
|
|
683
|
+
for (let attempt = 0;; attempt += 1) {
|
|
684
|
+
const listPayload = attempt === 0 ? initialReviewsPayload : await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_code_reviews", {
|
|
685
|
+
name: repoName,
|
|
686
|
+
remote: options.remote,
|
|
687
|
+
defaultBranch,
|
|
688
|
+
prNumber,
|
|
689
|
+
limit: 20
|
|
690
|
+
});
|
|
691
|
+
reviewsPayload = listPayload;
|
|
692
|
+
selectedReview = pickRelevantCodeReview(listPayload.codeReviews || [], triggerStartedAt, expectedHeadSha);
|
|
693
|
+
githubCheckRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
|
|
694
|
+
githubCheckState = classifyGithubGreptileCheckState(githubCheckRollup);
|
|
695
|
+
if (!shouldContinueGreptileMcpPolling({
|
|
696
|
+
attempt,
|
|
697
|
+
pollAttempts: options.pollAttempts,
|
|
698
|
+
githubCheckState,
|
|
699
|
+
selectedReview
|
|
700
|
+
})) {
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
await Bun.sleep(options.pollIntervalMs);
|
|
704
|
+
}
|
|
705
|
+
const ciGate = evaluatePullRequestCiChecks(githubCheckRollup, repoName, prNumber, { mergeStateStatus: options.prState.mergeStateStatus });
|
|
706
|
+
if (ciGate.verdict !== "APPROVE") {
|
|
707
|
+
return {
|
|
708
|
+
verdict: ciGate.verdict,
|
|
709
|
+
feedback: "",
|
|
710
|
+
reasons: ciGate.reasons,
|
|
711
|
+
warnings,
|
|
712
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, checkRollup: githubCheckRollup }
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
if (!selectedReview) {
|
|
716
|
+
reasons.push(`[AI Review] Greptile did not produce a review for ${repoName}#${prNumber}.`);
|
|
717
|
+
return {
|
|
718
|
+
verdict: "SKIP",
|
|
719
|
+
feedback: "",
|
|
720
|
+
reasons,
|
|
721
|
+
warnings,
|
|
722
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload }
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (selectedReview.status === "FAILED") {
|
|
726
|
+
reasons.push(`[AI Review] Greptile review failed for ${repoName}#${prNumber}.`);
|
|
727
|
+
return {
|
|
728
|
+
verdict: "SKIP",
|
|
729
|
+
feedback: "",
|
|
730
|
+
reasons,
|
|
731
|
+
warnings,
|
|
732
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (selectedReview.status === "SKIPPED") {
|
|
736
|
+
reasons.push(`[AI Review] Greptile skipped review for ${repoName}#${prNumber}.`);
|
|
737
|
+
return {
|
|
738
|
+
verdict: "SKIP",
|
|
739
|
+
feedback: "",
|
|
740
|
+
reasons,
|
|
741
|
+
warnings,
|
|
742
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (selectedReview.status !== "COMPLETED") {
|
|
746
|
+
if (githubCheckState.completed) {
|
|
747
|
+
return runGithubGreptileFallbackReviewForPr({
|
|
748
|
+
projectRoot: options.projectRoot,
|
|
749
|
+
taskId: options.taskId,
|
|
750
|
+
prState: options.prState,
|
|
751
|
+
reviewMode: options.reviewMode,
|
|
752
|
+
infrastructureError: `Greptile MCP review stayed ${selectedReview.status} after the GitHub Greptile check completed.`,
|
|
753
|
+
pollAttempts: options.pollAttempts,
|
|
754
|
+
pollIntervalMs: options.pollIntervalMs
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
reasons.push(`[AI Review] Greptile review for ${repoName}#${prNumber} did not finish before timeout (last status: ${selectedReview.status}).`);
|
|
758
|
+
return {
|
|
759
|
+
verdict: "SKIP",
|
|
760
|
+
feedback: "",
|
|
761
|
+
reasons,
|
|
762
|
+
warnings,
|
|
763
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const reviewDetails = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "get_code_review", { codeReviewId: selectedReview.id });
|
|
767
|
+
const commentsPayload = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_merge_request_comments", {
|
|
768
|
+
name: repoName,
|
|
769
|
+
remote: options.remote,
|
|
770
|
+
defaultBranch,
|
|
771
|
+
prNumber,
|
|
772
|
+
greptileGenerated: true,
|
|
773
|
+
createdAfter: selectedReview.createdAt
|
|
774
|
+
});
|
|
775
|
+
const actionableComments = filterActionableGreptileComments(commentsPayload.comments || []);
|
|
776
|
+
const reviewBody = reviewDetails.codeReview?.body || "";
|
|
777
|
+
const score = parseGreptileScore(reviewBody);
|
|
778
|
+
const feedback = [
|
|
779
|
+
`## ${options.prState.repoLabel || repoName} PR Review`,
|
|
780
|
+
"",
|
|
781
|
+
`- PR: ${options.prState.url || `${repoName}#${prNumber}`}`,
|
|
782
|
+
`- Review ID: ${selectedReview.id}`,
|
|
783
|
+
`- Status: ${selectedReview.status}`,
|
|
784
|
+
"",
|
|
785
|
+
reviewBody ? stripHtml(reviewBody).trim() : "Greptile completed without summary body."
|
|
786
|
+
].filter(Boolean).join(`
|
|
787
|
+
`);
|
|
788
|
+
if (actionableComments.length > 0) {
|
|
789
|
+
for (const comment of actionableComments) {
|
|
790
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.filePath}: ${summarizeComment(comment.body || "")}`);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
verdict: "REJECT",
|
|
794
|
+
feedback,
|
|
795
|
+
reasons,
|
|
796
|
+
warnings,
|
|
797
|
+
rawPayload: {
|
|
798
|
+
pr: options.prState,
|
|
799
|
+
codeReviews: reviewsPayload,
|
|
800
|
+
selectedReview,
|
|
801
|
+
reviewDetails,
|
|
802
|
+
comments: commentsPayload
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
|
|
807
|
+
if (/not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(blockerScanBody)) {
|
|
808
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
|
|
809
|
+
return {
|
|
810
|
+
verdict: "REJECT",
|
|
811
|
+
feedback,
|
|
812
|
+
reasons,
|
|
813
|
+
warnings,
|
|
814
|
+
rawPayload: {
|
|
815
|
+
pr: options.prState,
|
|
816
|
+
codeReviews: reviewsPayload,
|
|
817
|
+
selectedReview,
|
|
818
|
+
reviewDetails,
|
|
819
|
+
comments: commentsPayload
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (score?.scale === 5 && score.value < 5) {
|
|
824
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
|
|
825
|
+
return {
|
|
826
|
+
verdict: "REJECT",
|
|
827
|
+
feedback,
|
|
828
|
+
reasons,
|
|
829
|
+
warnings,
|
|
830
|
+
rawPayload: {
|
|
831
|
+
pr: options.prState,
|
|
832
|
+
codeReviews: reviewsPayload,
|
|
833
|
+
selectedReview,
|
|
834
|
+
reviewDetails,
|
|
835
|
+
comments: commentsPayload,
|
|
836
|
+
score
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
|
|
841
|
+
let strictGate = null;
|
|
842
|
+
try {
|
|
843
|
+
const strictEvidence = await collectStrictPrEvidenceForVerifier({
|
|
844
|
+
projectRoot: options.projectRoot,
|
|
845
|
+
taskId: options.taskId,
|
|
846
|
+
prUrl,
|
|
847
|
+
apiSignals: [{
|
|
848
|
+
id: selectedReview.id,
|
|
849
|
+
body: reviewBody,
|
|
850
|
+
reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
|
|
851
|
+
status: selectedReview.status
|
|
852
|
+
}]
|
|
853
|
+
});
|
|
854
|
+
strictGate = evaluateStrictPrMergeGate(strictEvidence);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
|
|
857
|
+
return {
|
|
858
|
+
verdict: "REJECT",
|
|
859
|
+
feedback,
|
|
860
|
+
reasons,
|
|
861
|
+
warnings,
|
|
862
|
+
rawPayload: {
|
|
863
|
+
pr: options.prState,
|
|
864
|
+
codeReviews: reviewsPayload,
|
|
865
|
+
selectedReview,
|
|
866
|
+
reviewDetails,
|
|
867
|
+
comments: commentsPayload,
|
|
868
|
+
score
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
if (!strictGate.approved) {
|
|
873
|
+
return {
|
|
874
|
+
verdict: strictGate.pending ? "SKIP" : "REJECT",
|
|
875
|
+
feedback,
|
|
876
|
+
reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
|
|
877
|
+
warnings: [...warnings, ...strictGate.warnings],
|
|
878
|
+
rawPayload: {
|
|
879
|
+
pr: options.prState,
|
|
880
|
+
codeReviews: reviewsPayload,
|
|
881
|
+
selectedReview,
|
|
882
|
+
reviewDetails,
|
|
883
|
+
comments: commentsPayload,
|
|
884
|
+
score,
|
|
885
|
+
strictGate: {
|
|
886
|
+
approved: strictGate.approved,
|
|
887
|
+
pending: strictGate.pending,
|
|
888
|
+
reasons: strictGate.reasons,
|
|
889
|
+
reasonDetails: strictGate.reasonDetails,
|
|
890
|
+
warnings: strictGate.warnings,
|
|
891
|
+
greptile: strictGate.evidence.greptile,
|
|
892
|
+
readErrors: strictGate.evidence.readErrors
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
verdict: "APPROVE",
|
|
899
|
+
feedback,
|
|
900
|
+
reasons,
|
|
901
|
+
warnings,
|
|
902
|
+
rawPayload: {
|
|
903
|
+
pr: options.prState,
|
|
904
|
+
codeReviews: reviewsPayload,
|
|
905
|
+
selectedReview,
|
|
906
|
+
reviewDetails,
|
|
907
|
+
comments: commentsPayload,
|
|
908
|
+
strictGate: {
|
|
909
|
+
approved: strictGate.approved,
|
|
910
|
+
pending: strictGate.pending,
|
|
911
|
+
reasons: strictGate.reasons,
|
|
912
|
+
reasonDetails: strictGate.reasonDetails,
|
|
913
|
+
warnings: strictGate.warnings,
|
|
914
|
+
greptile: strictGate.evidence.greptile,
|
|
915
|
+
readErrors: strictGate.evidence.readErrors
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
async function runGithubGreptileFallbackReviewForPr(options) {
|
|
921
|
+
const repoName = deriveRepoName(options.projectRoot, options.prState);
|
|
922
|
+
const prNumber = parsePullRequestNumber(options.prState.url || "");
|
|
923
|
+
const expectedHeadSha = resolvePrHeadSha(options.projectRoot, options.prState);
|
|
924
|
+
if (!repoName || !prNumber) {
|
|
925
|
+
return {
|
|
926
|
+
verdict: "SKIP",
|
|
927
|
+
feedback: "",
|
|
928
|
+
reasons: [],
|
|
929
|
+
warnings: buildGithubGreptileFallbackWarnings(options),
|
|
930
|
+
rawPayload: buildGithubGreptileFallbackRawPayload(options)
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
let reviews = [];
|
|
934
|
+
let selectedReview = null;
|
|
935
|
+
let latestReview = null;
|
|
936
|
+
let fallbackReview = null;
|
|
937
|
+
let threads = [];
|
|
938
|
+
let actionableThreads = [];
|
|
939
|
+
let checkRollup = [];
|
|
940
|
+
let checkState = { pending: false, completed: false };
|
|
941
|
+
for (let attempt = 0;; attempt += 1) {
|
|
942
|
+
reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
|
|
943
|
+
selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
|
|
944
|
+
latestReview = pickLatestGithubGreptileReview(reviews);
|
|
945
|
+
fallbackReview = selectedReview ?? latestReview;
|
|
946
|
+
threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
|
|
947
|
+
actionableThreads = filterActionableGithubGreptileThreads(threads);
|
|
948
|
+
checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
|
|
949
|
+
checkState = classifyGithubGreptileCheckState(checkRollup);
|
|
950
|
+
const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
|
|
951
|
+
if (!shouldContinueGithubGreptileFallbackPolling({
|
|
952
|
+
attempt,
|
|
953
|
+
pollAttempts: options.pollAttempts,
|
|
954
|
+
checkState,
|
|
955
|
+
fallbackReview,
|
|
956
|
+
selectedReview,
|
|
957
|
+
approvedViaReviewedAncestor
|
|
958
|
+
})) {
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
await Bun.sleep(options.pollIntervalMs);
|
|
962
|
+
}
|
|
963
|
+
const ciGate = evaluatePullRequestCiChecks(checkRollup, repoName, prNumber, { mergeStateStatus: options.prState.mergeStateStatus });
|
|
964
|
+
if (ciGate.verdict !== "APPROVE") {
|
|
965
|
+
return {
|
|
966
|
+
verdict: ciGate.verdict,
|
|
967
|
+
feedback: "",
|
|
968
|
+
reasons: ciGate.reasons,
|
|
969
|
+
warnings: buildGithubGreptileFallbackWarnings(options),
|
|
970
|
+
rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const feedback = [
|
|
974
|
+
`## ${options.prState.repoLabel || repoName} PR Review`,
|
|
975
|
+
"",
|
|
976
|
+
`- PR: ${options.prState.url || `${repoName}#${prNumber}`}`,
|
|
977
|
+
"- Source: GitHub Greptile fallback",
|
|
978
|
+
fallbackReview?.html_url ? `- Review: ${fallbackReview.html_url}` : "",
|
|
979
|
+
fallbackReview?.state ? `- Status: ${fallbackReview.state}` : "",
|
|
980
|
+
"",
|
|
981
|
+
fallbackReview?.body?.trim() ? stripHtml(fallbackReview.body).trim() : "Greptile MCP was unavailable, so verification used GitHub review threads instead."
|
|
982
|
+
].filter(Boolean).join(`
|
|
983
|
+
`);
|
|
984
|
+
const warnings = buildGithubGreptileFallbackWarnings(options);
|
|
985
|
+
if (checkState.pending) {
|
|
986
|
+
return {
|
|
987
|
+
verdict: "SKIP",
|
|
988
|
+
feedback,
|
|
989
|
+
reasons: [
|
|
990
|
+
`[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is still in progress.`
|
|
991
|
+
],
|
|
992
|
+
warnings,
|
|
993
|
+
rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
|
|
997
|
+
let strictGate;
|
|
998
|
+
try {
|
|
999
|
+
const strictEvidence = await collectStrictPrEvidenceForVerifier({
|
|
1000
|
+
projectRoot: options.projectRoot,
|
|
1001
|
+
taskId: options.taskId,
|
|
1002
|
+
prUrl
|
|
1003
|
+
});
|
|
1004
|
+
strictGate = evaluateStrictPrMergeGate(strictEvidence);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
return {
|
|
1007
|
+
verdict: "REJECT",
|
|
1008
|
+
feedback,
|
|
1009
|
+
reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
|
|
1010
|
+
warnings,
|
|
1011
|
+
rawPayload: {
|
|
1012
|
+
pr: options.prState,
|
|
1013
|
+
selectedReview: fallbackReview,
|
|
1014
|
+
reviews,
|
|
1015
|
+
threads,
|
|
1016
|
+
checkRollup,
|
|
1017
|
+
actionableThreads,
|
|
1018
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
if (!strictGate.approved) {
|
|
1023
|
+
return {
|
|
1024
|
+
verdict: strictGate.pending ? "SKIP" : "REJECT",
|
|
1025
|
+
feedback,
|
|
1026
|
+
reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
|
|
1027
|
+
warnings: [...warnings, ...strictGate.warnings],
|
|
1028
|
+
rawPayload: {
|
|
1029
|
+
pr: options.prState,
|
|
1030
|
+
selectedReview: fallbackReview,
|
|
1031
|
+
reviews,
|
|
1032
|
+
threads,
|
|
1033
|
+
checkRollup,
|
|
1034
|
+
actionableThreads,
|
|
1035
|
+
strictGate: {
|
|
1036
|
+
approved: strictGate.approved,
|
|
1037
|
+
pending: strictGate.pending,
|
|
1038
|
+
reasons: strictGate.reasons,
|
|
1039
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1040
|
+
warnings: strictGate.warnings,
|
|
1041
|
+
greptile: strictGate.evidence.greptile
|
|
1042
|
+
},
|
|
1043
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
verdict: "APPROVE",
|
|
1049
|
+
feedback,
|
|
1050
|
+
reasons: [],
|
|
1051
|
+
warnings,
|
|
1052
|
+
rawPayload: {
|
|
1053
|
+
pr: options.prState,
|
|
1054
|
+
selectedReview: fallbackReview,
|
|
1055
|
+
reviews,
|
|
1056
|
+
threads,
|
|
1057
|
+
checkRollup,
|
|
1058
|
+
strictGate: {
|
|
1059
|
+
approved: strictGate.approved,
|
|
1060
|
+
pending: strictGate.pending,
|
|
1061
|
+
reasons: strictGate.reasons,
|
|
1062
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1063
|
+
warnings: strictGate.warnings,
|
|
1064
|
+
greptile: strictGate.evidence.greptile
|
|
1065
|
+
},
|
|
1066
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function buildGithubGreptileFallbackWarnings(options) {
|
|
1071
|
+
if (!options.infrastructureError?.trim()) {
|
|
1072
|
+
return [];
|
|
1073
|
+
}
|
|
1074
|
+
return [
|
|
1075
|
+
asGreptileInfrastructureWarning(`Greptile infrastructure failure for ${options.prState.url || options.prState.branch || options.taskId}: ${options.infrastructureError}`)
|
|
1076
|
+
];
|
|
1077
|
+
}
|
|
1078
|
+
function buildGithubGreptileFallbackRawPayload(options) {
|
|
1079
|
+
return options.infrastructureError?.trim() ? { pr: options.prState, error: options.infrastructureError } : { pr: options.prState };
|
|
1080
|
+
}
|
|
1081
|
+
async function callGreptileMcpTool(apiBase, apiKey, name, args) {
|
|
1082
|
+
return callGreptileMcpToolWithTimeout(apiBase, apiKey, name, args, resolveGreptileRequestTimeoutMs(process.env.GREPTILE_REQUEST_TIMEOUT_MS));
|
|
1083
|
+
}
|
|
1084
|
+
async function callGreptileMcpToolWithTimeout(apiBase, apiKey, name, args, timeoutMs) {
|
|
1085
|
+
const controller = new AbortController;
|
|
1086
|
+
const timeoutId = setTimeout(() => {
|
|
1087
|
+
controller.abort(new Error(`Greptile MCP tool ${name} timed out after ${timeoutMs}ms.`));
|
|
1088
|
+
}, timeoutMs);
|
|
1089
|
+
let response;
|
|
1090
|
+
try {
|
|
1091
|
+
response = await fetch(apiBase, {
|
|
1092
|
+
method: "POST",
|
|
1093
|
+
headers: {
|
|
1094
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1095
|
+
"Content-Type": "application/json"
|
|
1096
|
+
},
|
|
1097
|
+
body: JSON.stringify({
|
|
1098
|
+
jsonrpc: "2.0",
|
|
1099
|
+
id: `rig-${name}-${Date.now()}`,
|
|
1100
|
+
method: "tools/call",
|
|
1101
|
+
params: { name, arguments: args }
|
|
1102
|
+
}),
|
|
1103
|
+
signal: controller.signal
|
|
1104
|
+
});
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
if (controller.signal.aborted) {
|
|
1107
|
+
throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${name} timed out after ${timeoutMs}ms.`);
|
|
1108
|
+
}
|
|
1109
|
+
throw error;
|
|
1110
|
+
} finally {
|
|
1111
|
+
clearTimeout(timeoutId);
|
|
1112
|
+
}
|
|
1113
|
+
const raw = await response.text();
|
|
1114
|
+
if (!response.ok) {
|
|
1115
|
+
throw new Error(`HTTP ${response.status}: ${raw}`);
|
|
1116
|
+
}
|
|
1117
|
+
let envelope;
|
|
1118
|
+
try {
|
|
1119
|
+
envelope = JSON.parse(raw);
|
|
1120
|
+
} catch {
|
|
1121
|
+
throw new Error(`Malformed MCP response: ${raw}`);
|
|
1122
|
+
}
|
|
1123
|
+
if (envelope.error?.message) {
|
|
1124
|
+
throw new Error(envelope.error.message);
|
|
1125
|
+
}
|
|
1126
|
+
const text = (envelope.result?.content || []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text || "").join(`
|
|
1127
|
+
`).trim();
|
|
1128
|
+
if (!text) {
|
|
1129
|
+
throw new Error(`MCP tool ${name} returned no text payload.`);
|
|
1130
|
+
}
|
|
1131
|
+
return text;
|
|
1132
|
+
}
|
|
1133
|
+
function resolveGreptileRequestTimeoutMs(rawValue) {
|
|
1134
|
+
const DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS = 30000;
|
|
1135
|
+
const parsed = Number.parseInt(rawValue || `${DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS}`, 10);
|
|
1136
|
+
return Number.isFinite(parsed) && parsed >= 1000 ? parsed : DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS;
|
|
1137
|
+
}
|
|
1138
|
+
async function callGreptileMcpToolJson(apiBase, apiKey, name, args) {
|
|
1139
|
+
const text = await callGreptileMcpTool(apiBase, apiKey, name, args);
|
|
1140
|
+
try {
|
|
1141
|
+
return JSON.parse(text);
|
|
1142
|
+
} catch {
|
|
1143
|
+
throw new Error(`MCP tool ${name} returned malformed JSON: ${text}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
function pickRelevantCodeReview(reviews, triggerStartedAt, expectedHeadSha) {
|
|
1147
|
+
const normalized = [...reviews].sort((a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""));
|
|
1148
|
+
const exactSha = normalized.find((review) => expectedHeadSha && review.metadata?.checkHeadSha === expectedHeadSha);
|
|
1149
|
+
if (exactSha) {
|
|
1150
|
+
return exactSha;
|
|
1151
|
+
}
|
|
1152
|
+
const triggered = normalized.find((review) => Date.parse(review.createdAt || "") >= triggerStartedAt - 1000);
|
|
1153
|
+
return triggered || normalized[0] || null;
|
|
1154
|
+
}
|
|
1155
|
+
function filterActionableGreptileComments(comments) {
|
|
1156
|
+
return comments.filter((comment) => comment.sourceType === "greptile" && typeof comment.filePath === "string" && comment.filePath.trim().length > 0 && comment.addressed === false);
|
|
1157
|
+
}
|
|
1158
|
+
function findGreptileReviewForHeadSha(reviews, expectedHeadSha) {
|
|
1159
|
+
if (!expectedHeadSha) {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
const normalized = [...reviews].sort((a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""));
|
|
1163
|
+
return normalized.find((review) => review.metadata?.checkHeadSha === expectedHeadSha) || null;
|
|
1164
|
+
}
|
|
1165
|
+
function isGreptileReviewTerminal(status) {
|
|
1166
|
+
return status === "COMPLETED" || status === "FAILED" || status === "SKIPPED";
|
|
1167
|
+
}
|
|
1168
|
+
function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
|
|
1169
|
+
if (!existingReview) {
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
if (!expectedHeadSha) {
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
function shouldContinueGreptileMcpPolling(options) {
|
|
1181
|
+
if (options.githubCheckState.completed) {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
if (options.attempt + 1 >= options.pollAttempts) {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
function shouldContinueGithubGreptileFallbackPolling(options) {
|
|
1193
|
+
const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
|
|
1194
|
+
if (options.attempt + 1 >= options.pollAttempts) {
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
if (waitingForVisiblePendingReview) {
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
|
|
1201
|
+
if (reviewNotVisibleYet) {
|
|
1202
|
+
return options.attempt + 1 < options.pollAttempts;
|
|
1203
|
+
}
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
function resolveGreptilePollSettings(options) {
|
|
1207
|
+
const DEFAULT_POLL_ATTEMPTS = 60;
|
|
1208
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
1209
|
+
const REQUIRED_MODE_MIN_ATTEMPTS = 180;
|
|
1210
|
+
const configuredAttempts = Number.parseInt(options.secrets.GREPTILE_POLL_ATTEMPTS || `${DEFAULT_POLL_ATTEMPTS}`, 10);
|
|
1211
|
+
const configuredIntervalMs = Number.parseInt(options.secrets.GREPTILE_POLL_INTERVAL_MS || `${DEFAULT_POLL_INTERVAL_MS}`, 10);
|
|
1212
|
+
const pollAttempts = Number.isFinite(configuredAttempts) && configuredAttempts > 0 ? configuredAttempts : DEFAULT_POLL_ATTEMPTS;
|
|
1213
|
+
const pollIntervalMs = Number.isFinite(configuredIntervalMs) && configuredIntervalMs > 0 ? configuredIntervalMs : DEFAULT_POLL_INTERVAL_MS;
|
|
1214
|
+
if (options.reviewMode !== "required") {
|
|
1215
|
+
return { pollAttempts, pollIntervalMs };
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
pollAttempts: Math.max(REQUIRED_MODE_MIN_ATTEMPTS, pollAttempts),
|
|
1219
|
+
pollIntervalMs: Math.max(DEFAULT_POLL_INTERVAL_MS, pollIntervalMs)
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function loadGithubPullRequestState(projectRoot, repoName, prNumber) {
|
|
1223
|
+
const response = runGhJson(projectRoot, [
|
|
1224
|
+
"api",
|
|
1225
|
+
`repos/${repoName}/pulls/${prNumber}`
|
|
1226
|
+
]);
|
|
1227
|
+
return {
|
|
1228
|
+
state: response.state || "",
|
|
1229
|
+
merged: response.merged,
|
|
1230
|
+
merged_at: response.merged_at ?? null
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function shouldPreferGithubGreptileFallback(prState) {
|
|
1234
|
+
if ((prState?.state || "").toUpperCase() === "MERGED") {
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
if (prState?.merged === true) {
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
return typeof prState?.merged_at === "string" && prState.merged_at.trim().length > 0;
|
|
1241
|
+
}
|
|
1242
|
+
function parsePullRequestNumber(url) {
|
|
1243
|
+
const match = /\/pull\/(\d+)(?:\/|$)/.exec(url);
|
|
1244
|
+
return match ? Number.parseInt(match[1] || "0", 10) : 0;
|
|
1245
|
+
}
|
|
1246
|
+
function runGhJson(projectRoot, args) {
|
|
1247
|
+
const result = runCapture(["gh", ...args], projectRoot);
|
|
1248
|
+
if (result.exitCode !== 0) {
|
|
1249
|
+
throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
return JSON.parse(result.stdout);
|
|
1253
|
+
} catch {
|
|
1254
|
+
throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
async function collectStrictPrEvidenceForVerifier(input) {
|
|
1258
|
+
return collectPrReviewEvidence({
|
|
1259
|
+
projectRoot: input.projectRoot,
|
|
1260
|
+
prUrl: input.prUrl,
|
|
1261
|
+
taskId: input.taskId,
|
|
1262
|
+
runId: "verifier",
|
|
1263
|
+
cycle: 0,
|
|
1264
|
+
apiSignals: input.apiSignals ?? [],
|
|
1265
|
+
command: async (args, options) => {
|
|
1266
|
+
const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
|
|
1267
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
function deriveRepoName(projectRoot, prState) {
|
|
1272
|
+
const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
|
|
1273
|
+
if (fromUrl?.[1]) {
|
|
1274
|
+
return fromUrl[1];
|
|
1275
|
+
}
|
|
1276
|
+
if (prState.target === "monorepo") {
|
|
1277
|
+
return resolveRepoSlug(projectRoot);
|
|
1278
|
+
}
|
|
1279
|
+
return runCapture(["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], projectRoot).stdout.trim();
|
|
1280
|
+
}
|
|
1281
|
+
function resolvePrHeadSha(projectRoot, prState) {
|
|
1282
|
+
const repoRoot = resolvePrRepoRoot(projectRoot, prState);
|
|
1283
|
+
return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
|
|
1284
|
+
}
|
|
1285
|
+
function isGreptileGithubLogin(login) {
|
|
1286
|
+
const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
|
|
1287
|
+
return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
|
|
1288
|
+
}
|
|
1289
|
+
function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
|
|
1290
|
+
const matching = sortGithubGreptileReviews(reviews);
|
|
1291
|
+
if (expectedHeadSha) {
|
|
1292
|
+
const exact = matching.find((review) => review.commit_id === expectedHeadSha);
|
|
1293
|
+
if (exact) {
|
|
1294
|
+
return exact;
|
|
1295
|
+
}
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
return matching[0] || null;
|
|
1299
|
+
}
|
|
1300
|
+
function pickLatestGithubGreptileReview(reviews) {
|
|
1301
|
+
return sortGithubGreptileReviews(reviews)[0] || null;
|
|
1302
|
+
}
|
|
1303
|
+
function sortGithubGreptileReviews(reviews) {
|
|
1304
|
+
return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
|
|
1305
|
+
}
|
|
1306
|
+
function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
|
|
1307
|
+
const response = runGhJson(projectRoot, [
|
|
1308
|
+
"pr",
|
|
1309
|
+
"view",
|
|
1310
|
+
`${prNumber}`,
|
|
1311
|
+
"--repo",
|
|
1312
|
+
repoName,
|
|
1313
|
+
"--json",
|
|
1314
|
+
"statusCheckRollup"
|
|
1315
|
+
]);
|
|
1316
|
+
return response.statusCheckRollup || [];
|
|
1317
|
+
}
|
|
1318
|
+
function evaluatePullRequestCiChecks(checks, repoName, prNumber, options = {}) {
|
|
1319
|
+
const isPendingCheck = (check) => {
|
|
1320
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
1321
|
+
return (check.status || "").toUpperCase() !== "COMPLETED";
|
|
1322
|
+
}
|
|
1323
|
+
const state = (check.state || check.status || "").toUpperCase();
|
|
1324
|
+
return state === "PENDING" || state === "EXPECTED" || state === "QUEUED" || state === "IN_PROGRESS";
|
|
1325
|
+
};
|
|
1326
|
+
const pendingGreptile = checks.filter((check) => {
|
|
1327
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
1328
|
+
return label.includes("greptile") && isPendingCheck(check);
|
|
1329
|
+
});
|
|
1330
|
+
if (pendingGreptile.length > 0) {
|
|
1331
|
+
return {
|
|
1332
|
+
verdict: "SKIP",
|
|
1333
|
+
reasons: pendingGreptile.map((check) => `[CI] ${repoName}#${prNumber} mandatory Greptile check is still pending: ${check.name || check.context || "unknown"}.`)
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
const nonGreptileChecks = checks.filter((check) => {
|
|
1337
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
1338
|
+
return label.length > 0 && !label.includes("greptile");
|
|
1339
|
+
});
|
|
1340
|
+
const pending = nonGreptileChecks.filter(isPendingCheck);
|
|
1341
|
+
const mergeClean = (options.mergeStateStatus || "").toUpperCase() === "CLEAN";
|
|
1342
|
+
if (pending.length > 0 && !mergeClean) {
|
|
1343
|
+
return {
|
|
1344
|
+
verdict: "SKIP",
|
|
1345
|
+
reasons: pending.map((check) => `[CI] ${repoName}#${prNumber} check is still pending: ${check.name || check.context || "unknown"}.`)
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
const failing = nonGreptileChecks.filter((check) => {
|
|
1349
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
1350
|
+
const conclusion = (check.conclusion || "").toUpperCase();
|
|
1351
|
+
return conclusion.length > 0 && !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion);
|
|
1352
|
+
}
|
|
1353
|
+
const state = (check.state || check.conclusion || "").toUpperCase();
|
|
1354
|
+
return state.length > 0 && !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state);
|
|
1355
|
+
});
|
|
1356
|
+
if (failing.length > 0) {
|
|
1357
|
+
return {
|
|
1358
|
+
verdict: "REJECT",
|
|
1359
|
+
reasons: failing.map((check) => `[CI] ${repoName}#${prNumber} check failed: ${check.name || check.context || "unknown"} (${check.conclusion || check.state || check.status || "unknown"}).`)
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
return { verdict: "APPROVE", reasons: [] };
|
|
1363
|
+
}
|
|
1364
|
+
function classifyGithubGreptileCheckState(checks) {
|
|
1365
|
+
const greptileChecks = checks.filter((check) => {
|
|
1366
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
1367
|
+
return label.includes("greptile");
|
|
1368
|
+
});
|
|
1369
|
+
if (greptileChecks.length === 0) {
|
|
1370
|
+
return { pending: false, completed: false };
|
|
1371
|
+
}
|
|
1372
|
+
for (const check of greptileChecks) {
|
|
1373
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
1374
|
+
if ((check.status || "").toUpperCase() !== "COMPLETED") {
|
|
1375
|
+
return { pending: true, completed: false };
|
|
1376
|
+
}
|
|
1377
|
+
return { pending: false, completed: true };
|
|
1378
|
+
}
|
|
1379
|
+
const state = (check.state || "").toUpperCase();
|
|
1380
|
+
if (state === "PENDING" || state === "EXPECTED") {
|
|
1381
|
+
return { pending: true, completed: false };
|
|
1382
|
+
}
|
|
1383
|
+
if (state) {
|
|
1384
|
+
return { pending: false, completed: true };
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return { pending: false, completed: false };
|
|
1388
|
+
}
|
|
1389
|
+
function isGithubGreptileCheckApproved(_checks) {
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
|
|
1393
|
+
const [owner, name] = repoName.split("/");
|
|
1394
|
+
if (!owner || !name) {
|
|
1395
|
+
return [];
|
|
1396
|
+
}
|
|
1397
|
+
const response = runGhJson(projectRoot, [
|
|
1398
|
+
"api",
|
|
1399
|
+
"graphql",
|
|
1400
|
+
"-F",
|
|
1401
|
+
`owner=${owner}`,
|
|
1402
|
+
"-F",
|
|
1403
|
+
`name=${name}`,
|
|
1404
|
+
"-F",
|
|
1405
|
+
`prNumber=${prNumber}`,
|
|
1406
|
+
"-f",
|
|
1407
|
+
"query=query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$prNumber) { reviewThreads(first: 100) { nodes { isResolved isOutdated comments(first: 20) { nodes { author { login } body path url createdAt } } } } } } }"
|
|
1408
|
+
]);
|
|
1409
|
+
return response.data?.repository?.pullRequest?.reviewThreads?.nodes || [];
|
|
1410
|
+
}
|
|
1411
|
+
function filterActionableGithubGreptileThreads(threads) {
|
|
1412
|
+
return threads.flatMap((thread) => {
|
|
1413
|
+
if (thread.isResolved || thread.isOutdated) {
|
|
1414
|
+
return [];
|
|
1415
|
+
}
|
|
1416
|
+
const comments = thread.comments?.nodes || [];
|
|
1417
|
+
const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
|
|
1418
|
+
if (!latestGreptileComment?.path?.trim()) {
|
|
1419
|
+
return [];
|
|
1420
|
+
}
|
|
1421
|
+
return [latestGreptileComment];
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
function resolvePrRepoRoot(projectRoot, prState) {
|
|
1425
|
+
const runtimeWorkspace = process.env.RIG_TASK_WORKSPACE?.trim();
|
|
1426
|
+
if (prState.target === "monorepo" && runtimeWorkspace && existsSync(resolve(runtimeWorkspace, ".git"))) {
|
|
1427
|
+
return runtimeWorkspace;
|
|
1428
|
+
}
|
|
1429
|
+
const paths = resolveHarnessPaths(projectRoot);
|
|
1430
|
+
return prState.target === "monorepo" ? paths.monorepoRoot : projectRoot;
|
|
1431
|
+
}
|
|
1432
|
+
function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headCommit) {
|
|
1433
|
+
if (!reviewedCommit || !headCommit) {
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
const repoRoot = resolvePrRepoRoot(projectRoot, prState);
|
|
1437
|
+
return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
|
|
1438
|
+
}
|
|
1439
|
+
function summarizeComment(input) {
|
|
1440
|
+
const text = stripHtml(input).replace(/\s+/g, " ").trim();
|
|
1441
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
1442
|
+
}
|
|
1443
|
+
function asGreptileInfrastructureWarning(reason) {
|
|
1444
|
+
return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
|
|
1445
|
+
}
|
|
1446
|
+
function isAiReviewApproved(input) {
|
|
1447
|
+
if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
if (input.reviewMode !== "required") {
|
|
1451
|
+
return true;
|
|
1452
|
+
}
|
|
1453
|
+
return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
|
|
1454
|
+
}
|
|
1455
|
+
var __testOnly = {
|
|
1456
|
+
asGreptileInfrastructureWarning,
|
|
1457
|
+
callGreptileMcpToolWithTimeout,
|
|
1458
|
+
classifyGithubGreptileCheckState,
|
|
1459
|
+
filterActionableGithubGreptileThreads,
|
|
1460
|
+
isGithubGreptileCheckApproved,
|
|
1461
|
+
isAiReviewApproved,
|
|
1462
|
+
parseGreptileScore,
|
|
1463
|
+
pickLatestGithubGreptileReview,
|
|
1464
|
+
pickRelevantGithubGreptileReview,
|
|
1465
|
+
resolveGreptileRequestTimeoutMs,
|
|
1466
|
+
resolveGreptilePollSettings,
|
|
1467
|
+
shouldPreferGithubGreptileFallback,
|
|
1468
|
+
shouldContinueGithubGreptileFallbackPolling,
|
|
1469
|
+
shouldContinueGreptileMcpPolling,
|
|
1470
|
+
shouldTriggerGreptileReview
|
|
1471
|
+
};
|
|
1472
|
+
export {
|
|
1473
|
+
verifyTask,
|
|
1474
|
+
pickRelevantCodeReview,
|
|
1475
|
+
filterActionableGreptileComments,
|
|
1476
|
+
evaluatePullRequestCiChecks,
|
|
1477
|
+
__testOnly
|
|
1478
|
+
};
|