@h-rig/bundle-default-lifecycle 0.0.6-alpha.154 → 0.0.6-alpha.156
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
|
@@ -1,16 +1,2643 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
2
17
|
var __require = import.meta.require;
|
|
3
18
|
|
|
19
|
+
// packages/bundle-default-lifecycle/src/control-plane/pr-automation.ts
|
|
20
|
+
var exports_pr_automation = {};
|
|
21
|
+
__export(exports_pr_automation, {
|
|
22
|
+
runRepoDefaultMerge: () => runRepoDefaultMerge,
|
|
23
|
+
runPrAutomation: () => runPrAutomation,
|
|
24
|
+
resolvePrAutomationLimits: () => resolvePrAutomationLimits,
|
|
25
|
+
requestGreptileRereview: () => requestGreptileRereview,
|
|
26
|
+
pushBranchSyncedWithOrigin: () => pushBranchSyncedWithOrigin,
|
|
27
|
+
hasCommittableRunChanges: () => hasCommittableRunChanges,
|
|
28
|
+
gateNeedsGreptileRereview: () => gateNeedsGreptileRereview,
|
|
29
|
+
commitRunChanges: () => commitRunChanges,
|
|
30
|
+
collectPendingPrChecks: () => collectPendingPrChecks,
|
|
31
|
+
collectActionablePrFeedback: () => collectActionablePrFeedback,
|
|
32
|
+
closeIssueAfterMergedPr: () => closeIssueAfterMergedPr,
|
|
33
|
+
buildPrAutomationBody: () => buildPrAutomationBody,
|
|
34
|
+
UPLOADED_SNAPSHOT_PR_MARKER: () => UPLOADED_SNAPSHOT_PR_MARKER
|
|
35
|
+
});
|
|
36
|
+
import { assertSafeGitBranchName } from "@rig/shared/safe-identifiers";
|
|
37
|
+
import { runStrictPrMergeGate } from "@rig/pr-review-plugin";
|
|
38
|
+
import {
|
|
39
|
+
strictMergeHeadShaFromGate
|
|
40
|
+
} from "@rig/contracts";
|
|
41
|
+
function positiveInt(value, fallback) {
|
|
42
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
43
|
+
}
|
|
44
|
+
function resolvePrAutomationLimits(config) {
|
|
45
|
+
return {
|
|
46
|
+
maxPrFixIterations: positiveInt(config?.automation?.maxPrFixIterations, 100500)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function buildPrAutomationBody(input) {
|
|
50
|
+
const lines = [
|
|
51
|
+
input.summary?.trim() || "Rig completed this task autonomously.",
|
|
52
|
+
"",
|
|
53
|
+
`Run: ${input.runId}`
|
|
54
|
+
];
|
|
55
|
+
if (/^\d+$/.test(input.taskId)) {
|
|
56
|
+
lines.push("", `Closes #${input.taskId}`);
|
|
57
|
+
}
|
|
58
|
+
if (input.uploadedSnapshot) {
|
|
59
|
+
lines.push("", UPLOADED_SNAPSHOT_PR_MARKER, "This PR was seeded from an uploaded local working-tree snapshot. Review local snapshot provenance before merging if repository policy requires it.");
|
|
60
|
+
}
|
|
61
|
+
return lines.join(`
|
|
62
|
+
`);
|
|
63
|
+
}
|
|
64
|
+
function wildcardToRegExp(pattern) {
|
|
65
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
66
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
67
|
+
}
|
|
68
|
+
function isAllowedFailure(name, allowedFailures) {
|
|
69
|
+
return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
|
|
70
|
+
}
|
|
71
|
+
function isPendingCheck(check) {
|
|
72
|
+
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
73
|
+
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
74
|
+
return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(conclusion) || ["pending", "queued", "in_progress", "waiting", "requested", "expected"].includes(state);
|
|
75
|
+
}
|
|
76
|
+
function isPassingCheck(check) {
|
|
77
|
+
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
78
|
+
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
79
|
+
return ["success", "successful", "passed", "neutral", "skipped"].includes(conclusion) || ["success", "successful", "passed", "completed"].includes(state);
|
|
80
|
+
}
|
|
81
|
+
function isFailingCheck(check) {
|
|
82
|
+
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
83
|
+
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
84
|
+
return ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(conclusion) || ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(state);
|
|
85
|
+
}
|
|
86
|
+
function collectPendingPrChecks(input) {
|
|
87
|
+
const allowedFailures = input.allowedFailures ?? [];
|
|
88
|
+
const pending = [];
|
|
89
|
+
for (const check of input.checks ?? []) {
|
|
90
|
+
const name = check.name.trim();
|
|
91
|
+
if (!name || isAllowedFailure(name, allowedFailures))
|
|
92
|
+
continue;
|
|
93
|
+
if (isPendingCheck(check) && !isPassingCheck(check))
|
|
94
|
+
pending.push(name);
|
|
95
|
+
}
|
|
96
|
+
return pending;
|
|
97
|
+
}
|
|
98
|
+
function collectActionablePrFeedback(input) {
|
|
99
|
+
const allowedFailures = input.allowedFailures ?? [];
|
|
100
|
+
const feedback = [];
|
|
101
|
+
for (const check of input.checks ?? []) {
|
|
102
|
+
const name = check.name.trim();
|
|
103
|
+
if (!name || !isFailingCheck(check) || isAllowedFailure(name, allowedFailures))
|
|
104
|
+
continue;
|
|
105
|
+
feedback.push(`Check failed: ${name}${check.detailsUrl ? ` (${check.detailsUrl})` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
for (const thread of input.reviewThreads ?? []) {
|
|
108
|
+
if (thread.resolved === true)
|
|
109
|
+
continue;
|
|
110
|
+
const body = thread.body.trim();
|
|
111
|
+
if (!body)
|
|
112
|
+
continue;
|
|
113
|
+
feedback.push(`Review feedback from ${thread.author?.trim() || "reviewer"}: ${body}`);
|
|
114
|
+
}
|
|
115
|
+
return feedback;
|
|
116
|
+
}
|
|
117
|
+
function parseJsonArray(value) {
|
|
118
|
+
if (!value?.trim())
|
|
119
|
+
return [];
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(value);
|
|
122
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parsePrChecks(value) {
|
|
128
|
+
return parseJsonArray(value).flatMap((entry) => {
|
|
129
|
+
if (!entry || typeof entry !== "object")
|
|
130
|
+
return [];
|
|
131
|
+
const record = entry;
|
|
132
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
133
|
+
if (!name.trim())
|
|
134
|
+
return [];
|
|
135
|
+
return [{
|
|
136
|
+
name,
|
|
137
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
138
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
139
|
+
conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
|
|
140
|
+
detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.link === "string" ? record.link : null
|
|
141
|
+
}];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function parsePrViewStatusCheckRollup(value) {
|
|
145
|
+
if (!value?.trim())
|
|
146
|
+
return [];
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(value);
|
|
149
|
+
const rollup = Array.isArray(parsed.statusCheckRollup) ? parsed.statusCheckRollup : [];
|
|
150
|
+
return rollup.flatMap((entry) => {
|
|
151
|
+
if (!entry || typeof entry !== "object")
|
|
152
|
+
return [];
|
|
153
|
+
const record = entry;
|
|
154
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
155
|
+
if (!name.trim())
|
|
156
|
+
return [];
|
|
157
|
+
return [{
|
|
158
|
+
name,
|
|
159
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
160
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
161
|
+
conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
|
|
162
|
+
detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.link === "string" ? record.link : null
|
|
163
|
+
}];
|
|
164
|
+
});
|
|
165
|
+
} catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function readPrChecks(input) {
|
|
170
|
+
const checks = await input.command(["pr", "checks", input.prUrl, "--json", "name,state,link"], input.cwd ? { cwd: input.cwd } : undefined);
|
|
171
|
+
if (checks.exitCode === 0) {
|
|
172
|
+
return parsePrChecks(checks.stdout);
|
|
173
|
+
}
|
|
174
|
+
const combined = `${checks.stderr ?? ""}
|
|
175
|
+
${checks.stdout ?? ""}`;
|
|
176
|
+
if (!/unknown flag.*--json|unknown flag: --json|unknown shorthand flag/i.test(combined)) {
|
|
177
|
+
throw new Error(`gh pr checks ${input.prUrl} --json name,state,link failed (${checks.exitCode}): ${checks.stderr ?? checks.stdout ?? ""}`.trim());
|
|
178
|
+
}
|
|
179
|
+
const view = await runChecked(input.command, ["pr", "view", input.prUrl, "--json", "statusCheckRollup"], input.cwd, "gh");
|
|
180
|
+
return parsePrViewStatusCheckRollup(view.stdout);
|
|
181
|
+
}
|
|
182
|
+
function parsePrViewReviewThreads(value) {
|
|
183
|
+
if (!value?.trim())
|
|
184
|
+
return [];
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(value);
|
|
187
|
+
const feedback = [];
|
|
188
|
+
const reviewThreads = parsed.reviewThreads;
|
|
189
|
+
if (Array.isArray(reviewThreads)) {
|
|
190
|
+
feedback.push(...reviewThreads.flatMap((entry) => {
|
|
191
|
+
if (!entry || typeof entry !== "object")
|
|
192
|
+
return [];
|
|
193
|
+
const record = entry;
|
|
194
|
+
const body = typeof record.body === "string" ? record.body : null;
|
|
195
|
+
if (!body)
|
|
196
|
+
return [];
|
|
197
|
+
return [{
|
|
198
|
+
id: typeof record.id === "string" ? record.id : null,
|
|
199
|
+
body,
|
|
200
|
+
resolved: typeof record.resolved === "boolean" ? record.resolved : false,
|
|
201
|
+
author: typeof record.author === "string" ? record.author : null
|
|
202
|
+
}];
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
const reviews = parsed.reviews;
|
|
206
|
+
if (Array.isArray(reviews)) {
|
|
207
|
+
feedback.push(...reviews.flatMap((entry) => {
|
|
208
|
+
if (!entry || typeof entry !== "object")
|
|
209
|
+
return [];
|
|
210
|
+
const record = entry;
|
|
211
|
+
const state = typeof record.state === "string" ? record.state.toUpperCase() : "";
|
|
212
|
+
if (state !== "CHANGES_REQUESTED")
|
|
213
|
+
return [];
|
|
214
|
+
const body = typeof record.body === "string" && record.body.trim() ? record.body : "Changes requested by reviewer.";
|
|
215
|
+
const author = record.author && typeof record.author === "object" ? record.author.login : null;
|
|
216
|
+
return [{
|
|
217
|
+
id: typeof record.id === "string" ? record.id : null,
|
|
218
|
+
body,
|
|
219
|
+
resolved: false,
|
|
220
|
+
author: typeof author === "string" ? author : null
|
|
221
|
+
}];
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
const reviewDecision = typeof parsed.reviewDecision === "string" ? parsed.reviewDecision.toUpperCase() : "";
|
|
225
|
+
if (reviewDecision === "CHANGES_REQUESTED" && feedback.length === 0) {
|
|
226
|
+
feedback.push({ body: "Changes requested by reviewer.", resolved: false });
|
|
227
|
+
}
|
|
228
|
+
return feedback;
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function findPrUrl(output) {
|
|
234
|
+
return output?.split(/\s+/).map((part) => part.trim()).find((part) => /^https?:\/\/\S+\/pull\/\d+$/i.test(part)) ?? null;
|
|
235
|
+
}
|
|
236
|
+
function normalizePrUrl(stdout) {
|
|
237
|
+
const url = findPrUrl(stdout);
|
|
238
|
+
if (!url)
|
|
239
|
+
throw new Error("gh pr create did not return a PR URL");
|
|
240
|
+
return url;
|
|
241
|
+
}
|
|
242
|
+
function parseGitHubPullRequestUrl(prUrl) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = new URL(prUrl);
|
|
245
|
+
const [owner, repo, kind, number] = parsed.pathname.split("/").filter(Boolean);
|
|
246
|
+
if (!owner || !repo || kind !== "pull" || !number || !/^\d+$/.test(number))
|
|
247
|
+
return null;
|
|
248
|
+
return { owner, repo, number };
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function ensureExistingPrBodyHasRigMarkers(input) {
|
|
254
|
+
const view = await input.command(["pr", "view", input.prUrl, "--json", "body"], input.cwd ? { cwd: input.cwd } : undefined);
|
|
255
|
+
if (view.exitCode !== 0) {
|
|
256
|
+
throw new Error(`gh pr view ${input.prUrl} --json body failed (${view.exitCode}): ${view.stderr ?? view.stdout ?? ""}`.trim());
|
|
257
|
+
}
|
|
258
|
+
let currentBody = "";
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(view.stdout ?? "{}");
|
|
261
|
+
currentBody = typeof parsed.body === "string" ? parsed.body : "";
|
|
262
|
+
} catch {
|
|
263
|
+
currentBody = "";
|
|
264
|
+
}
|
|
265
|
+
const requiredBlocks = input.body.split(/\n{2,}/).map((block) => block.trim()).filter((block) => /^Run: /i.test(block) || /^Closes #\d+/i.test(block));
|
|
266
|
+
const missing = requiredBlocks.filter((block) => {
|
|
267
|
+
if (/^Run: /i.test(block))
|
|
268
|
+
return !/^Run: \S+/im.test(currentBody);
|
|
269
|
+
return !currentBody.includes(block);
|
|
270
|
+
});
|
|
271
|
+
if (missing.length === 0)
|
|
272
|
+
return;
|
|
273
|
+
const nextBody = [currentBody.trim(), ...missing].filter(Boolean).join(`
|
|
274
|
+
|
|
275
|
+
`);
|
|
276
|
+
const pr = parseGitHubPullRequestUrl(input.prUrl);
|
|
277
|
+
if (!pr)
|
|
278
|
+
throw new Error(`Cannot update existing PR body for unrecognized PR URL: ${input.prUrl}`);
|
|
279
|
+
const edit = await input.command(["api", `repos/${pr.owner}/${pr.repo}/issues/${pr.number}`, "-X", "PATCH", "-f", `body=${nextBody}`], input.cwd ? { cwd: input.cwd } : undefined);
|
|
280
|
+
if (edit.exitCode !== 0) {
|
|
281
|
+
throw new Error(`gh api repos/${pr.owner}/${pr.repo}/issues/${pr.number} -X PATCH -f body=<redacted> failed (${edit.exitCode}): ${edit.stderr ?? edit.stdout ?? ""}`.trim());
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function runChecked(command, args, cwd, label = "gh") {
|
|
285
|
+
const result = await command(args, cwd ? { cwd } : undefined);
|
|
286
|
+
if (result.exitCode !== 0) {
|
|
287
|
+
throw new Error(`${label} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim());
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
function statusPathFromShortLine(line) {
|
|
292
|
+
const rawPath = line.length > 3 ? line.slice(3).trim() : "";
|
|
293
|
+
const renamedPath = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1).trim() : rawPath;
|
|
294
|
+
if (renamedPath.startsWith('"') && renamedPath.endsWith('"')) {
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(renamedPath);
|
|
297
|
+
} catch {
|
|
298
|
+
return renamedPath.slice(1, -1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return renamedPath;
|
|
302
|
+
}
|
|
303
|
+
function isRuntimeCommitExcludedPath(path) {
|
|
304
|
+
const normalized = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
305
|
+
return RIG_RUNTIME_COMMIT_EXCLUDES.some((excluded) => normalized === excluded || normalized.startsWith(`${excluded}/`));
|
|
306
|
+
}
|
|
307
|
+
function committableRunChangePaths(statusText) {
|
|
308
|
+
const seen = new Set;
|
|
309
|
+
const paths = [];
|
|
310
|
+
for (const line of statusText.split(/\r?\n/)) {
|
|
311
|
+
const path = statusPathFromShortLine(line);
|
|
312
|
+
if (!path || isRuntimeCommitExcludedPath(path) || seen.has(path))
|
|
313
|
+
continue;
|
|
314
|
+
seen.add(path);
|
|
315
|
+
paths.push(path);
|
|
316
|
+
}
|
|
317
|
+
return paths;
|
|
318
|
+
}
|
|
319
|
+
function hasCommittableRunChanges(statusText) {
|
|
320
|
+
return committableRunChangePaths(statusText).length > 0;
|
|
321
|
+
}
|
|
322
|
+
async function commitRunChanges(input) {
|
|
323
|
+
const status = await runChecked(input.command, ["status", "--short", "--untracked-files=all"], input.cwd, "git");
|
|
324
|
+
const statusText = status.stdout ?? "";
|
|
325
|
+
const committablePaths = committableRunChangePaths(statusText);
|
|
326
|
+
if (!statusText.trim() || committablePaths.length === 0) {
|
|
327
|
+
return { committed: false, status: statusText };
|
|
328
|
+
}
|
|
329
|
+
await runChecked(input.command, ["add", "-A", "--", ...committablePaths], input.cwd, "git");
|
|
330
|
+
const staged = await input.command(["diff", "--cached", "--quiet"], { cwd: input.cwd });
|
|
331
|
+
if (staged.exitCode === 0) {
|
|
332
|
+
return { committed: false, status: statusText };
|
|
333
|
+
}
|
|
334
|
+
if (staged.exitCode !== 1) {
|
|
335
|
+
throw new Error(`git diff --cached --quiet failed (${staged.exitCode}): ${staged.stderr ?? staged.stdout ?? ""}`.trim());
|
|
336
|
+
}
|
|
337
|
+
await runChecked(input.command, ["commit", "-m", input.message], input.cwd, "git");
|
|
338
|
+
return { committed: true, status: statusText };
|
|
339
|
+
}
|
|
340
|
+
async function closeIssueAfterMergedPr(input) {
|
|
341
|
+
await input.updateTaskSource(input.projectRoot, {
|
|
342
|
+
taskId: input.taskId,
|
|
343
|
+
sourceTask: input.sourceTask,
|
|
344
|
+
update: {
|
|
345
|
+
status: "closed",
|
|
346
|
+
comment: [
|
|
347
|
+
"<!-- rig:status-comment -->",
|
|
348
|
+
"### Rig status: closed",
|
|
349
|
+
"",
|
|
350
|
+
"Rig PR merged and closed this task.",
|
|
351
|
+
"",
|
|
352
|
+
`- Run: ${input.runId}`,
|
|
353
|
+
`- PR merged: ${input.prUrl}`
|
|
354
|
+
].join(`
|
|
355
|
+
`)
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function parseGitHubRepoFromPrUrl(prUrl) {
|
|
360
|
+
const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/i.exec(prUrl.trim());
|
|
361
|
+
return match ? { owner: match[1], repo: match[2] } : null;
|
|
362
|
+
}
|
|
363
|
+
async function resolveRepoDefaultMergeFlag(input) {
|
|
364
|
+
const repo = parseGitHubRepoFromPrUrl(input.prUrl);
|
|
365
|
+
if (!repo)
|
|
366
|
+
throw new Error(`Cannot resolve GitHub repository from PR URL: ${input.prUrl}`);
|
|
367
|
+
const result = await input.command(["api", `repos/${repo.owner}/${repo.repo}`], input.cwd ? { cwd: input.cwd } : undefined);
|
|
368
|
+
if (result.exitCode !== 0) {
|
|
369
|
+
throw new Error(`Could not read repository merge policy for ${repo.owner}/${repo.repo}: ${result.stderr ?? result.stdout ?? "gh api failed"}`.trim());
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(result.stdout ?? "{}");
|
|
373
|
+
if (parsed.allow_merge_commit !== false)
|
|
374
|
+
return "--merge";
|
|
375
|
+
if (parsed.allow_squash_merge !== false)
|
|
376
|
+
return "--squash";
|
|
377
|
+
if (parsed.allow_rebase_merge !== false)
|
|
378
|
+
return "--rebase";
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw new Error(`Could not parse repository merge policy for ${repo.owner}/${repo.repo}: ${error instanceof Error ? error.message : String(error)}`);
|
|
381
|
+
}
|
|
382
|
+
throw new Error(`Repository ${repo.owner}/${repo.repo} has no enabled merge method for repo-default merge.`);
|
|
383
|
+
}
|
|
384
|
+
async function runRepoDefaultMerge(input) {
|
|
385
|
+
const merge = input.config?.merge ?? {};
|
|
386
|
+
if (merge.mode === "off")
|
|
387
|
+
return;
|
|
388
|
+
const requireGreptile = (input.config?.review?.provider ?? "greptile") === "greptile";
|
|
389
|
+
const matchHeadSha = strictMergeHeadShaFromGate(input.strictGate, input.prUrl, requireGreptile);
|
|
390
|
+
const method = merge.method ?? "repo-default";
|
|
391
|
+
const args = ["pr", "merge", input.prUrl];
|
|
392
|
+
if (method === "repo-default") {
|
|
393
|
+
args.push(await resolveRepoDefaultMergeFlag({ prUrl: input.prUrl, command: input.command, cwd: input.cwd }));
|
|
394
|
+
} else {
|
|
395
|
+
args.push(`--${method}`);
|
|
396
|
+
}
|
|
397
|
+
args.push("--match-head-commit", matchHeadSha);
|
|
398
|
+
if (merge.deleteBranch === true) {
|
|
399
|
+
args.push("--delete-branch");
|
|
400
|
+
}
|
|
401
|
+
if (merge.bypass === true) {
|
|
402
|
+
args.push("--admin");
|
|
403
|
+
}
|
|
404
|
+
await runChecked(input.command, args, input.cwd);
|
|
405
|
+
}
|
|
406
|
+
function shouldAttemptRigMerge(config) {
|
|
407
|
+
const mode = config?.merge?.mode;
|
|
408
|
+
return mode !== "off" && mode !== "pr-ready";
|
|
409
|
+
}
|
|
410
|
+
function isPendingOnlyGate(result) {
|
|
411
|
+
return result.pending && result.reasonDetails.length > 0 && result.reasonDetails.every((reason) => reason.reasonClass === "pending" && reason.suggestedAction === "wait");
|
|
412
|
+
}
|
|
413
|
+
function gateNeedsGreptileRereview(result) {
|
|
414
|
+
if (result.approved)
|
|
415
|
+
return false;
|
|
416
|
+
const staleShaEvidence = result.reasonDetails.some((reason) => reason.surface === "greptile" && (reason.code === "greptile_stale" || reason.code === "greptile_not_current_head" && !!reason.reviewedSha && reason.reviewedSha !== reason.headSha));
|
|
417
|
+
const hasParseableGreptileScore = !!result.evidence.greptile.score;
|
|
418
|
+
const greptileNeedsPrompt = !hasParseableGreptileScore && result.reasonDetails.some((reason) => reason.surface === "greptile" && reason.suggestedAction === "ask_greptile" && (reason.code === "greptile_not_current_head" || reason.code === "greptile_score_missing" || reason.code === "greptile_mapping_unproven"));
|
|
419
|
+
const greptileStillRunning = result.reasonDetails.some((reason) => reason.code === "greptile_pending" || reason.code === "greptile_missing");
|
|
420
|
+
return (staleShaEvidence || greptileNeedsPrompt) && !greptileStillRunning;
|
|
421
|
+
}
|
|
422
|
+
async function requestGreptileRereview(input) {
|
|
423
|
+
const sha = input.headSha?.trim() || "unknown";
|
|
424
|
+
const marker = `<!-- ${GREPTILE_REREVIEW_MARKER_PREFIX}:${sha} -->`;
|
|
425
|
+
const existing = await input.command(["pr", "view", input.prUrl, "--json", "comments"], input.cwd ? { cwd: input.cwd } : undefined);
|
|
426
|
+
if (existing.exitCode === 0 && (existing.stdout ?? "").includes(marker)) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
await runChecked(input.command, [
|
|
430
|
+
"pr",
|
|
431
|
+
"comment",
|
|
432
|
+
input.prUrl,
|
|
433
|
+
"--body",
|
|
434
|
+
`${marker}
|
|
435
|
+
@greptileai review
|
|
436
|
+
|
|
437
|
+
Rig strict merge gate: Greptile evidence is bound to an older commit. Requesting a fresh review of the current head (${sha}). Merge waits until Greptile re-reviews this exact commit.`
|
|
438
|
+
], input.cwd);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
async function pushBranchSyncedWithOrigin(input) {
|
|
442
|
+
const branch = assertSafeGitBranchName(input.branch, "PR branch");
|
|
443
|
+
const fetched = await input.gitCommand(["fetch", "origin", branch], { cwd: input.projectRoot });
|
|
444
|
+
if (fetched.exitCode === 0) {
|
|
445
|
+
const originRef = `origin/${branch}`;
|
|
446
|
+
const behind = await input.gitCommand(["rev-list", "--count", `HEAD..${originRef}`], { cwd: input.projectRoot });
|
|
447
|
+
const behindCount = Number.parseInt((behind.stdout ?? "").trim(), 10);
|
|
448
|
+
if (behind.exitCode === 0 && Number.isFinite(behindCount) && behindCount > 0) {
|
|
449
|
+
await runChecked(input.gitCommand, ["rebase", "--autostash", originRef], input.projectRoot, "git");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const pushed = await input.gitCommand(["push", "--set-upstream", "origin", branch], { cwd: input.projectRoot });
|
|
453
|
+
if (pushed.exitCode !== 0) {
|
|
454
|
+
await runChecked(input.gitCommand, ["push", "--set-upstream", "--force-with-lease", "origin", branch], input.projectRoot, "git");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function syncBranchAfterPrFeedback(input) {
|
|
458
|
+
if (!input.gitCommand)
|
|
459
|
+
return;
|
|
460
|
+
const branch = assertSafeGitBranchName(input.branch, "PR branch");
|
|
461
|
+
await commitRunChanges({
|
|
462
|
+
cwd: input.projectRoot,
|
|
463
|
+
message: `rig: address PR feedback for task ${input.taskId}`,
|
|
464
|
+
command: input.gitCommand
|
|
465
|
+
});
|
|
466
|
+
await pushBranchSyncedWithOrigin({ projectRoot: input.projectRoot, branch, gitCommand: input.gitCommand });
|
|
467
|
+
}
|
|
468
|
+
async function runPrAutomation(input) {
|
|
469
|
+
const branch = assertSafeGitBranchName(input.branch, "PR branch");
|
|
470
|
+
const prConfig = input.config?.pr ?? {};
|
|
471
|
+
const requireGreptile = (input.config?.review?.provider ?? "greptile") === "greptile";
|
|
472
|
+
if (prConfig.mode === "off" || prConfig.mode === "ask") {
|
|
473
|
+
return { status: "skipped", iterations: 0, actionableFeedback: [] };
|
|
474
|
+
}
|
|
475
|
+
const body = buildPrAutomationBody({
|
|
476
|
+
taskId: input.taskId,
|
|
477
|
+
runId: input.runId,
|
|
478
|
+
summary: input.sourceTask?.title ? `Rig completed: ${input.sourceTask.title}` : null,
|
|
479
|
+
uploadedSnapshot: input.uploadedSnapshot
|
|
480
|
+
});
|
|
481
|
+
if (input.gitCommand) {
|
|
482
|
+
await pushBranchSyncedWithOrigin({ projectRoot: input.projectRoot, branch, gitCommand: input.gitCommand });
|
|
483
|
+
}
|
|
484
|
+
const createArgs = [
|
|
485
|
+
"pr",
|
|
486
|
+
"create",
|
|
487
|
+
"--head",
|
|
488
|
+
branch,
|
|
489
|
+
"--title",
|
|
490
|
+
input.sourceTask?.title?.trim() || `Rig task ${input.taskId}`,
|
|
491
|
+
"--body",
|
|
492
|
+
body
|
|
493
|
+
];
|
|
494
|
+
const createResult = await input.command(createArgs, { cwd: input.projectRoot });
|
|
495
|
+
const existingPrUrl = createResult.exitCode === 0 ? null : /pull request .*already exists/i.test(`${createResult.stderr ?? ""}
|
|
496
|
+
${createResult.stdout ?? ""}`) ? findPrUrl(`${createResult.stderr ?? ""}
|
|
497
|
+
${createResult.stdout ?? ""}`) : null;
|
|
498
|
+
if (createResult.exitCode !== 0 && !existingPrUrl) {
|
|
499
|
+
throw new Error(`gh ${createArgs.join(" ")} failed (${createResult.exitCode}): ${createResult.stderr ?? createResult.stdout ?? ""}`.trim());
|
|
500
|
+
}
|
|
501
|
+
const prUrl = existingPrUrl ?? normalizePrUrl(createResult.stdout);
|
|
502
|
+
if (existingPrUrl) {
|
|
503
|
+
await ensureExistingPrBodyHasRigMarkers({ prUrl, body, command: input.command, cwd: input.projectRoot });
|
|
504
|
+
}
|
|
505
|
+
await input.lifecycle?.onPrOpened?.({ prUrl });
|
|
506
|
+
const { maxPrFixIterations } = resolvePrAutomationLimits(input.config);
|
|
507
|
+
let latestFeedback = [];
|
|
508
|
+
let pendingElapsedMs = 0;
|
|
509
|
+
const shouldMerge = shouldAttemptRigMerge(input.config);
|
|
510
|
+
for (let iteration = 1;iteration <= maxPrFixIterations; iteration += 1) {
|
|
511
|
+
await input.lifecycle?.onReviewCiStarted?.({ prUrl, iteration });
|
|
512
|
+
if (!shouldMerge) {
|
|
513
|
+
const checks = prConfig.watchChecks === false ? [] : await readPrChecks({ prUrl, command: input.command, cwd: input.projectRoot });
|
|
514
|
+
const reviewThreads = prConfig.autoFixReview === false ? [] : parsePrViewReviewThreads((await runChecked(input.command, ["pr", "view", prUrl, "--json", "reviewDecision,reviews"], input.projectRoot)).stdout);
|
|
515
|
+
latestFeedback = collectActionablePrFeedback({
|
|
516
|
+
checks,
|
|
517
|
+
reviewThreads,
|
|
518
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? []
|
|
519
|
+
});
|
|
520
|
+
const pendingChecks = collectPendingPrChecks({ checks, allowedFailures: input.config?.merge?.allowedFailures ?? [] });
|
|
521
|
+
if (latestFeedback.length === 0 && pendingChecks.length > 0) {
|
|
522
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
523
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
524
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
525
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: pendingChecks.map((name) => `Check still pending: ${name}`), merged: false };
|
|
526
|
+
}
|
|
527
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
528
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
529
|
+
pendingElapsedMs += sleepMs;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (latestFeedback.length === 0) {
|
|
533
|
+
pendingElapsedMs = 0;
|
|
534
|
+
return { status: "opened", prUrl, iterations: iteration, actionableFeedback: [], merged: false };
|
|
535
|
+
}
|
|
536
|
+
pendingElapsedMs = 0;
|
|
537
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
538
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
539
|
+
}
|
|
540
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
541
|
+
await input.steerPi([
|
|
542
|
+
`PR automation found actionable feedback on ${prUrl}.`,
|
|
543
|
+
`Fix iteration ${iteration + 1}/${maxPrFixIterations}.`,
|
|
544
|
+
"",
|
|
545
|
+
...latestFeedback.map((entry) => `- ${entry}`)
|
|
546
|
+
].join(`
|
|
547
|
+
`));
|
|
548
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch, gitCommand: input.gitCommand });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const gate = await runStrictPrMergeGate({
|
|
552
|
+
projectRoot: input.projectRoot,
|
|
553
|
+
prUrl,
|
|
554
|
+
taskId: input.taskId,
|
|
555
|
+
runId: input.runId,
|
|
556
|
+
cycle: iteration,
|
|
557
|
+
command: input.command,
|
|
558
|
+
artifactRoot: input.artifactRoot,
|
|
559
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? [],
|
|
560
|
+
greptileApi: requireGreptile ? input.greptileApi : undefined,
|
|
561
|
+
requireGreptile
|
|
562
|
+
});
|
|
563
|
+
latestFeedback = [...gate.actionableFeedback];
|
|
564
|
+
if (requireGreptile && gateNeedsGreptileRereview(gate)) {
|
|
565
|
+
const requested = await requestGreptileRereview({
|
|
566
|
+
prUrl,
|
|
567
|
+
headSha: gate.evidence.headSha ?? null,
|
|
568
|
+
command: input.command,
|
|
569
|
+
cwd: input.projectRoot
|
|
570
|
+
});
|
|
571
|
+
if (requested) {
|
|
572
|
+
await input.lifecycle?.onFeedback?.({
|
|
573
|
+
prUrl,
|
|
574
|
+
iteration,
|
|
575
|
+
feedback: [`Requested a fresh Greptile review for current head ${gate.evidence.headSha ?? "unknown"}; merge stays blocked until Greptile re-reviews it.`]
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
579
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
580
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
581
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
582
|
+
}
|
|
583
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
584
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
585
|
+
pendingElapsedMs += sleepMs;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (gate.approved) {
|
|
589
|
+
pendingElapsedMs = 0;
|
|
590
|
+
const finalGate = await runStrictPrMergeGate({
|
|
591
|
+
projectRoot: input.projectRoot,
|
|
592
|
+
prUrl,
|
|
593
|
+
taskId: input.taskId,
|
|
594
|
+
runId: input.runId,
|
|
595
|
+
cycle: iteration,
|
|
596
|
+
command: input.command,
|
|
597
|
+
artifactRoot: input.artifactRoot,
|
|
598
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? [],
|
|
599
|
+
greptileApi: requireGreptile ? input.greptileApi : undefined,
|
|
600
|
+
requireGreptile,
|
|
601
|
+
final: true
|
|
602
|
+
});
|
|
603
|
+
if (finalGate.approved) {
|
|
604
|
+
await input.lifecycle?.onMergeStarted?.({ prUrl });
|
|
605
|
+
await runRepoDefaultMerge({ prUrl, config: input.config, command: input.command, cwd: input.projectRoot, strictGate: finalGate });
|
|
606
|
+
await input.lifecycle?.onMerged?.({ prUrl });
|
|
607
|
+
return { status: "merged", prUrl, iterations: iteration, actionableFeedback: [], merged: true };
|
|
608
|
+
}
|
|
609
|
+
latestFeedback = [...finalGate.actionableFeedback];
|
|
610
|
+
if (isPendingOnlyGate(finalGate)) {
|
|
611
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
612
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
613
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
614
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
615
|
+
}
|
|
616
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
617
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
618
|
+
pendingElapsedMs += sleepMs;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
622
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
623
|
+
}
|
|
624
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
625
|
+
await input.steerPi(finalGate.steeringPrompt);
|
|
626
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch, gitCommand: input.gitCommand });
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (isPendingOnlyGate(gate)) {
|
|
630
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
631
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
632
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
633
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
634
|
+
}
|
|
635
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
636
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
637
|
+
pendingElapsedMs += sleepMs;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
pendingElapsedMs = 0;
|
|
641
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
642
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
643
|
+
}
|
|
644
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
645
|
+
await input.steerPi(gate.steeringPrompt);
|
|
646
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch, gitCommand: input.gitCommand });
|
|
647
|
+
}
|
|
648
|
+
return { status: "needs_attention", prUrl, iterations: maxPrFixIterations, actionableFeedback: latestFeedback, merged: false };
|
|
649
|
+
}
|
|
650
|
+
var UPLOADED_SNAPSHOT_PR_MARKER = "<!-- rig:uploaded-snapshot -->", RIG_RUNTIME_COMMIT_EXCLUDES, GREPTILE_REREVIEW_MARKER_PREFIX = "rig:greptile-rereview";
|
|
651
|
+
var init_pr_automation = __esm(() => {
|
|
652
|
+
RIG_RUNTIME_COMMIT_EXCLUDES = [
|
|
653
|
+
".rig",
|
|
654
|
+
"artifacts",
|
|
655
|
+
"node_modules"
|
|
656
|
+
];
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// packages/bundle-default-lifecycle/src/control-plane/verifier.ts
|
|
660
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
661
|
+
import { resolve } from "path";
|
|
662
|
+
import { resolveRuntimeSecrets } from "@rig/runtime/control-plane/runtime/baked-secrets";
|
|
663
|
+
import { readPrMetadata } from "@rig/runtime/control-plane/native/git-ops";
|
|
664
|
+
import { loadRuntimeContextFromEnv } from "@rig/runtime/control-plane/runtime/context";
|
|
665
|
+
import { readConfiguredTaskSourceTask } from "@rig/runtime/control-plane/tasks/source-lifecycle";
|
|
666
|
+
import { artifactDirForId, lookupTask, readTaskConfig } from "@rig/runtime/control-plane/native/task-state";
|
|
667
|
+
import { nowIso, resolveHarnessPaths, runCapture } from "@rig/runtime/control-plane/native/utils";
|
|
668
|
+
import {
|
|
669
|
+
collectPrReviewEvidence,
|
|
670
|
+
evaluateStrictPrMergeGate,
|
|
671
|
+
parseGreptileScore,
|
|
672
|
+
stripHtml
|
|
673
|
+
} from "@rig/pr-review-plugin";
|
|
674
|
+
async function verifyTask(options) {
|
|
675
|
+
const paths = resolveHarnessPaths(options.projectRoot);
|
|
676
|
+
const taskId = options.taskId;
|
|
677
|
+
const normalizedTaskId = lookupTask(options.projectRoot, taskId);
|
|
678
|
+
const artifactDir = artifactDirForId(options.projectRoot, taskId);
|
|
679
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
680
|
+
const validationSummaryPath = resolve(artifactDir, "validation-summary.json");
|
|
681
|
+
const reviewFeedbackPath = resolve(artifactDir, "review-feedback.md");
|
|
682
|
+
const reviewStatePath = resolve(artifactDir, "review-state.json");
|
|
683
|
+
const greptileRawPath = resolve(artifactDir, "review-greptile-raw.json");
|
|
684
|
+
const prStates = readPrMetadata(options.projectRoot, taskId);
|
|
685
|
+
const prState = prStates[0] || null;
|
|
686
|
+
const localReasons = [];
|
|
687
|
+
const aiReasons = [];
|
|
688
|
+
const aiWarnings = [];
|
|
689
|
+
let aiVerdict = "SKIP";
|
|
690
|
+
let aiRawFeedback = "";
|
|
691
|
+
const persistArtifacts = options.persistArtifacts !== false;
|
|
692
|
+
if (!normalizedTaskId && !await hasConfiguredSourceTask(options.projectRoot, taskId)) {
|
|
693
|
+
localReasons.push(`[Task Config] Unknown task id '${taskId}' in task-config or configured task source.`);
|
|
694
|
+
}
|
|
695
|
+
if (!existsSync(validationSummaryPath)) {
|
|
696
|
+
localReasons.push(`[Artifact Quality] validation-summary.json not found at ${validationSummaryPath}.`);
|
|
697
|
+
} else {
|
|
698
|
+
const summary = await parseValidationSummary(validationSummaryPath);
|
|
699
|
+
if (!isAcceptedValidationSummary(summary)) {
|
|
700
|
+
localReasons.push(`[Validation] validation-summary status is '${summary?.status ?? "unknown"}', expected 'pass' or zero-check 'skipped'.`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
for (const file of ["task-result.json", "decision-log.md", "next-actions.md", "changed-files.txt"]) {
|
|
704
|
+
const requiredPath = resolve(artifactDir, file);
|
|
705
|
+
if (!existsSync(requiredPath)) {
|
|
706
|
+
localReasons.push(`[Artifact Quality] Missing required artifact file: ${requiredPath}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const taskResultPath = resolve(artifactDir, "task-result.json");
|
|
710
|
+
if (existsSync(taskResultPath)) {
|
|
711
|
+
const taskResult = await readJsonFile(taskResultPath);
|
|
712
|
+
const artifactStatus = typeof taskResult?.status === "string" ? taskResult.status.trim().toLowerCase() : "";
|
|
713
|
+
if (artifactStatus === "partial") {
|
|
714
|
+
localReasons.push("[Artifact Quality] task-result.json status is 'partial'; completion requires a terminal result.");
|
|
715
|
+
}
|
|
716
|
+
if (hasNonEmptyBlockers(taskResult?.blockers)) {
|
|
717
|
+
localReasons.push("[Artifact Quality] task-result.json blockers must be empty for completion.");
|
|
718
|
+
}
|
|
719
|
+
if (nextActionsIndicateRemainingScope(stringifyNextActions(taskResult?.nextActions ?? taskResult?.next_actions))) {
|
|
720
|
+
localReasons.push("[Artifact Quality] task-result.json next actions indicate remaining implementation scope.");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const nextActionsPath = resolve(artifactDir, "next-actions.md");
|
|
724
|
+
if (existsSync(nextActionsPath)) {
|
|
725
|
+
const nextActionsContent = await Bun.file(nextActionsPath).text();
|
|
726
|
+
if (nextActionsContent.includes("TODO: Replace this scaffold") || nextActionsContent.includes("bd-<downstream-task-id>")) {
|
|
727
|
+
localReasons.push("[Artifact Quality] next-actions.md still contains scaffold placeholder text. Replace with real recommendations.");
|
|
728
|
+
}
|
|
729
|
+
if (nextActionsIndicateRemainingScope(nextActionsContent)) {
|
|
730
|
+
localReasons.push("[Artifact Quality] next-actions.md indicates remaining implementation scope.");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const sourceCloseoutIssueId = resolveGithubSourceIssueId(options.projectRoot, taskId);
|
|
734
|
+
if (sourceCloseoutIssueId) {
|
|
735
|
+
localReasons.push(...evaluateGithubSourceIssuePrCloseout(options.projectRoot, prStates, sourceCloseoutIssueId));
|
|
736
|
+
}
|
|
737
|
+
const reviewMode = await loadReviewMode(paths.reviewProfilePath, process.env.AI_REVIEW_MODE || "advisory");
|
|
738
|
+
const reviewProvider = await loadReviewProvider(paths.reviewProfilePath, process.env.AI_REVIEW_PROVIDER || "github");
|
|
739
|
+
if (!options.skipAiReview && localReasons.length === 0 && reviewProvider === "greptile" && reviewMode !== "off") {
|
|
740
|
+
const ai = await runGreptileReview({
|
|
741
|
+
projectRoot: options.projectRoot,
|
|
742
|
+
taskId,
|
|
743
|
+
artifactDir,
|
|
744
|
+
prStates,
|
|
745
|
+
reviewMode
|
|
746
|
+
});
|
|
747
|
+
aiVerdict = ai.verdict;
|
|
748
|
+
aiRawFeedback = ai.feedback;
|
|
749
|
+
aiReasons.push(...ai.reasons);
|
|
750
|
+
aiWarnings.push(...ai.warnings);
|
|
751
|
+
if (reviewMode === "required" && ai.verdict !== "APPROVE" && ai.reasons.length === 0) {
|
|
752
|
+
aiReasons.push(`[AI Review] Required mode needs a completed Greptile approval; current verdict is ${ai.verdict}.`);
|
|
753
|
+
}
|
|
754
|
+
if (persistArtifacts && ai.rawResponse) {
|
|
755
|
+
writeFileSync(greptileRawPath, `${ai.rawResponse}
|
|
756
|
+
`, "utf-8");
|
|
757
|
+
}
|
|
758
|
+
} else if (!options.skipAiReview && reviewMode === "off") {
|
|
759
|
+
aiWarnings.push("[AI Review] AI review mode is off.");
|
|
760
|
+
}
|
|
761
|
+
const aiReviewApproved = options.skipAiReview || reviewProvider !== "greptile" || isAiReviewApproved({
|
|
762
|
+
reviewMode,
|
|
763
|
+
aiVerdict,
|
|
764
|
+
aiReasons
|
|
765
|
+
});
|
|
766
|
+
if (persistArtifacts) {
|
|
767
|
+
writeFeedbackFile({
|
|
768
|
+
taskId,
|
|
769
|
+
provider: reviewProvider,
|
|
770
|
+
mode: reviewMode,
|
|
771
|
+
verdict: aiVerdict,
|
|
772
|
+
localReasons,
|
|
773
|
+
aiReasons,
|
|
774
|
+
aiWarnings,
|
|
775
|
+
aiRawFeedback,
|
|
776
|
+
output: reviewFeedbackPath
|
|
777
|
+
});
|
|
778
|
+
writeReviewStateFile({
|
|
779
|
+
taskId,
|
|
780
|
+
approved: localReasons.length === 0 && aiReviewApproved,
|
|
781
|
+
provider: reviewProvider,
|
|
782
|
+
mode: reviewMode,
|
|
783
|
+
verdict: aiVerdict,
|
|
784
|
+
prState,
|
|
785
|
+
localReasons,
|
|
786
|
+
aiReasons,
|
|
787
|
+
aiWarnings,
|
|
788
|
+
output: reviewStatePath
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
if (localReasons.length > 0) {
|
|
792
|
+
return {
|
|
793
|
+
approved: false,
|
|
794
|
+
localReasons,
|
|
795
|
+
aiReasons,
|
|
796
|
+
aiWarnings,
|
|
797
|
+
aiVerdict,
|
|
798
|
+
reviewFeedbackPath,
|
|
799
|
+
reviewStatePath
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (!aiReviewApproved) {
|
|
803
|
+
return {
|
|
804
|
+
approved: false,
|
|
805
|
+
localReasons,
|
|
806
|
+
aiReasons,
|
|
807
|
+
aiWarnings,
|
|
808
|
+
aiVerdict,
|
|
809
|
+
reviewFeedbackPath,
|
|
810
|
+
reviewStatePath
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
approved: true,
|
|
815
|
+
localReasons,
|
|
816
|
+
aiReasons,
|
|
817
|
+
aiWarnings,
|
|
818
|
+
aiVerdict,
|
|
819
|
+
reviewFeedbackPath,
|
|
820
|
+
reviewStatePath
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
async function readJsonFile(path) {
|
|
824
|
+
try {
|
|
825
|
+
return await Bun.file(path).json();
|
|
826
|
+
} catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function hasNonEmptyBlockers(blockers) {
|
|
831
|
+
if (blockers == null) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
if (typeof blockers === "string") {
|
|
835
|
+
const normalized = blockers.trim().toLowerCase();
|
|
836
|
+
return normalized.length > 0 && normalized !== "none" && normalized !== "n/a";
|
|
837
|
+
}
|
|
838
|
+
if (Array.isArray(blockers)) {
|
|
839
|
+
return blockers.some((entry) => {
|
|
840
|
+
if (typeof entry === "string") {
|
|
841
|
+
const normalized = entry.trim().toLowerCase();
|
|
842
|
+
return normalized.length > 0 && normalized !== "none" && normalized !== "n/a";
|
|
843
|
+
}
|
|
844
|
+
return entry != null;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
if (typeof blockers === "object") {
|
|
848
|
+
return Object.keys(blockers).length > 0;
|
|
849
|
+
}
|
|
850
|
+
return Boolean(blockers);
|
|
851
|
+
}
|
|
852
|
+
function stringifyNextActions(value) {
|
|
853
|
+
if (value == null) {
|
|
854
|
+
return "";
|
|
855
|
+
}
|
|
856
|
+
if (typeof value === "string") {
|
|
857
|
+
return value;
|
|
858
|
+
}
|
|
859
|
+
if (Array.isArray(value)) {
|
|
860
|
+
return value.map((entry) => stringifyNextActions(entry)).join(`
|
|
861
|
+
`);
|
|
862
|
+
}
|
|
863
|
+
return JSON.stringify(value);
|
|
864
|
+
}
|
|
865
|
+
function nextActionsIndicateRemainingScope(content) {
|
|
866
|
+
const normalized = content.trim();
|
|
867
|
+
if (!normalized) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
const lower = normalized.toLowerCase();
|
|
871
|
+
if (/^(#\s*)?next actions\s*\n+\s*(none|n\/a|no remaining scope)\.?\s*$/i.test(normalized)) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
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);
|
|
875
|
+
}
|
|
876
|
+
async function hasConfiguredSourceTask(projectRoot, taskId) {
|
|
877
|
+
return readConfiguredTaskSourceTask(projectRoot, taskId).then((result) => result.task !== null).catch(() => false);
|
|
878
|
+
}
|
|
879
|
+
function resolveGithubSourceIssueId(projectRoot, taskId) {
|
|
880
|
+
const fromRuntime = loadRuntimeContextFromEnv()?.sourceTask?.sourceIssueId;
|
|
881
|
+
if (typeof fromRuntime === "string" && isGithubSourceIssueId(fromRuntime)) {
|
|
882
|
+
return fromRuntime;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
const taskConfig = readTaskConfig(projectRoot);
|
|
886
|
+
const entry = taskConfig[taskId];
|
|
887
|
+
const sourceIssueId = typeof entry?.sourceIssueId === "string" ? entry.sourceIssueId : typeof entry?.source_issue_id === "string" ? entry.source_issue_id : null;
|
|
888
|
+
if (sourceIssueId && isGithubSourceIssueId(sourceIssueId)) {
|
|
889
|
+
return sourceIssueId;
|
|
890
|
+
}
|
|
891
|
+
const rig = entry?._rig;
|
|
892
|
+
if (rig && typeof rig === "object" && !Array.isArray(rig)) {
|
|
893
|
+
const rigRecord = rig;
|
|
894
|
+
const rigSourceIssueId = typeof rigRecord.sourceIssueId === "string" ? rigRecord.sourceIssueId : null;
|
|
895
|
+
if (rigSourceIssueId && isGithubSourceIssueId(rigSourceIssueId)) {
|
|
896
|
+
return rigSourceIssueId;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} catch {}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
function isGithubSourceIssueId(value) {
|
|
903
|
+
return /^[^/\s]+\/[^#\s]+#\d+$/.test(value.trim());
|
|
904
|
+
}
|
|
905
|
+
function evaluateGithubSourceIssuePrCloseout(projectRoot, prStates, sourceIssueId) {
|
|
906
|
+
const issueNumber = sourceIssueId.match(/#(\d+)$/)?.[1];
|
|
907
|
+
if (!issueNumber) {
|
|
908
|
+
return [`[Source Issue] GitHub issue task ${sourceIssueId} has an invalid source issue id.`];
|
|
909
|
+
}
|
|
910
|
+
if (prStates.length === 0) {
|
|
911
|
+
return [
|
|
912
|
+
`[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.`
|
|
913
|
+
];
|
|
914
|
+
}
|
|
915
|
+
const rejectionReasons = [];
|
|
916
|
+
for (const prState of prStates) {
|
|
917
|
+
const hydratedPr = hydratePrCloseoutState(projectRoot, prState);
|
|
918
|
+
const prReasons = evaluateSinglePrSourceIssueCloseout(hydratedPr, sourceIssueId, issueNumber);
|
|
919
|
+
if (prReasons.length === 0) {
|
|
920
|
+
return [];
|
|
921
|
+
}
|
|
922
|
+
rejectionReasons.push(...prReasons);
|
|
923
|
+
}
|
|
924
|
+
return uniqueStrings(rejectionReasons);
|
|
925
|
+
}
|
|
926
|
+
function hydratePrCloseoutState(projectRoot, prState) {
|
|
927
|
+
if (hasLocalPrCloseoutEvidence(prState)) {
|
|
928
|
+
return prState;
|
|
929
|
+
}
|
|
930
|
+
const snapshot = loadGithubPullRequestCloseoutSnapshot(projectRoot, prState);
|
|
931
|
+
return snapshot ? { ...prState, ...snapshot } : prState;
|
|
932
|
+
}
|
|
933
|
+
function hasLocalPrCloseoutEvidence(prState) {
|
|
934
|
+
const hasCloseoutText = typeof prState.title === "string" || typeof prState.body === "string" || Array.isArray(prState.closingIssues) && prState.closingIssues.length > 0;
|
|
935
|
+
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);
|
|
936
|
+
}
|
|
937
|
+
function loadGithubPullRequestCloseoutSnapshot(projectRoot, prState) {
|
|
938
|
+
const prNumber = parsePullRequestNumber(prState.url || "");
|
|
939
|
+
if (!prNumber) {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
const repoName = deriveRepoName(projectRoot, prState);
|
|
944
|
+
if (!repoName) {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
const view = runGhJson(projectRoot, [
|
|
948
|
+
"pr",
|
|
949
|
+
"view",
|
|
950
|
+
`${prNumber}`,
|
|
951
|
+
"--repo",
|
|
952
|
+
repoName,
|
|
953
|
+
"--json",
|
|
954
|
+
"state,isDraft,mergeable,mergeStateStatus,reviewDecision,title,body,statusCheckRollup"
|
|
955
|
+
]);
|
|
956
|
+
return {
|
|
957
|
+
state: stringField(view, "state"),
|
|
958
|
+
isDraft: booleanField(view, "isDraft"),
|
|
959
|
+
mergeable: stringField(view, "mergeable"),
|
|
960
|
+
mergeStateStatus: stringField(view, "mergeStateStatus"),
|
|
961
|
+
reviewDecision: stringField(view, "reviewDecision"),
|
|
962
|
+
title: stringField(view, "title"),
|
|
963
|
+
body: stringField(view, "body"),
|
|
964
|
+
statusCheckRollup: statusCheckRollupField(view, "statusCheckRollup"),
|
|
965
|
+
reviewThreads: loadGithubReviewThreads(projectRoot, repoName, prNumber)
|
|
966
|
+
};
|
|
967
|
+
} catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function evaluateSinglePrSourceIssueCloseout(prState, sourceIssueId, issueNumber) {
|
|
972
|
+
const prLabel = prState.url || prState.branch || "recorded PR";
|
|
973
|
+
const reasons = [];
|
|
974
|
+
if (!prStateClosesIssue(prState, sourceIssueId, issueNumber)) {
|
|
975
|
+
reasons.push(`[Source Issue] GitHub issue task ${sourceIssueId} must have PR closeout intent (for example "Closes #${issueNumber}"). "Closes part of #N" is not sufficient.`);
|
|
976
|
+
}
|
|
977
|
+
const state = (prState.state || "").toUpperCase();
|
|
978
|
+
if (state !== "OPEN") {
|
|
979
|
+
reasons.push(`[Source Issue] PR ${prLabel} must be open before operator-controlled merge; current state is ${prState.state || "unknown"}.`);
|
|
980
|
+
}
|
|
981
|
+
if (prState.isDraft !== false) {
|
|
982
|
+
reasons.push(`[Source Issue] PR ${prLabel} must not be a draft.`);
|
|
983
|
+
}
|
|
984
|
+
const mergeable = (prState.mergeable || "").toUpperCase();
|
|
985
|
+
const mergeStateStatus = (prState.mergeStateStatus || "").toUpperCase();
|
|
986
|
+
if (mergeable !== "MERGEABLE" || mergeStateStatus === "DIRTY" || mergeStateStatus === "BLOCKED") {
|
|
987
|
+
reasons.push(`[Source Issue] PR ${prLabel} must be mergeable before completion (mergeable=${prState.mergeable || "unknown"}, mergeStateStatus=${prState.mergeStateStatus || "unknown"}).`);
|
|
988
|
+
}
|
|
989
|
+
reasons.push(...evaluateSourceCloseoutChecks(prState, prLabel));
|
|
990
|
+
reasons.push(...evaluateSourceCloseoutReviewState(prState, prLabel));
|
|
991
|
+
return reasons;
|
|
992
|
+
}
|
|
993
|
+
function evaluateSourceCloseoutChecks(prState, prLabel) {
|
|
994
|
+
const checks = prState.statusCheckRollup;
|
|
995
|
+
if (!Array.isArray(checks) || checks.length === 0) {
|
|
996
|
+
return [`[Source Issue] PR ${prLabel} must have green check evidence before completion.`];
|
|
997
|
+
}
|
|
998
|
+
const ciGate = evaluatePullRequestCiChecks(checks, "PR", 0, { mergeStateStatus: prState.mergeStateStatus });
|
|
999
|
+
if (ciGate.verdict === "APPROVE") {
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
return ciGate.reasons.map((reason) => reason.replace("PR#0", prLabel));
|
|
1003
|
+
}
|
|
1004
|
+
function evaluateSourceCloseoutReviewState(prState, prLabel) {
|
|
1005
|
+
const reasons = [];
|
|
1006
|
+
const reviewDecision = (prState.reviewDecision || "").toUpperCase();
|
|
1007
|
+
if (reviewDecision === "REVIEW_REQUIRED" || reviewDecision === "CHANGES_REQUESTED") {
|
|
1008
|
+
reasons.push(`[Source Issue] PR ${prLabel} required review is unresolved (${prState.reviewDecision}).`);
|
|
1009
|
+
}
|
|
1010
|
+
if (!Array.isArray(prState.reviewThreads)) {
|
|
1011
|
+
reasons.push(`[Source Issue] PR ${prLabel} review thread resolution could not be verified.`);
|
|
1012
|
+
return reasons;
|
|
1013
|
+
}
|
|
1014
|
+
for (const comment of filterUnresolvedReviewThreadComments(prState.reviewThreads)) {
|
|
1015
|
+
reasons.push(`[Source Issue] PR ${prLabel} has unresolved review thread on ${comment.path || "unknown path"}: ${summarizeComment(comment.body || "")}`);
|
|
1016
|
+
}
|
|
1017
|
+
return reasons;
|
|
1018
|
+
}
|
|
1019
|
+
function filterUnresolvedReviewThreadComments(threads) {
|
|
1020
|
+
const comments = [];
|
|
1021
|
+
for (const thread of threads) {
|
|
1022
|
+
if (thread.isResolved === true || thread.isOutdated === true) {
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const threadComments = thread.comments?.nodes ?? [];
|
|
1026
|
+
if (threadComments.length === 0) {
|
|
1027
|
+
comments.push({ body: "Unresolved review thread" });
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
comments.push(threadComments[threadComments.length - 1]);
|
|
1031
|
+
}
|
|
1032
|
+
return comments;
|
|
1033
|
+
}
|
|
1034
|
+
function stringField(record, key) {
|
|
1035
|
+
const value = record[key];
|
|
1036
|
+
return typeof value === "string" ? value : undefined;
|
|
1037
|
+
}
|
|
1038
|
+
function booleanField(record, key) {
|
|
1039
|
+
const value = record[key];
|
|
1040
|
+
return typeof value === "boolean" ? value : undefined;
|
|
1041
|
+
}
|
|
1042
|
+
function statusCheckRollupField(record, key) {
|
|
1043
|
+
const value = record[key];
|
|
1044
|
+
if (!Array.isArray(value)) {
|
|
1045
|
+
return [];
|
|
1046
|
+
}
|
|
1047
|
+
return value.filter(isGithubStatusCheckRollupItem);
|
|
1048
|
+
}
|
|
1049
|
+
function isGithubStatusCheckRollupItem(value) {
|
|
1050
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1051
|
+
}
|
|
1052
|
+
function uniqueStrings(values) {
|
|
1053
|
+
return Array.from(new Set(values));
|
|
1054
|
+
}
|
|
1055
|
+
function prStateClosesIssue(pr, sourceIssueId, issueNumber) {
|
|
1056
|
+
const closingIssues = pr.closingIssues ?? [];
|
|
1057
|
+
if (closingIssues.some((issue) => closingIssueMatches(issue, sourceIssueId, issueNumber))) {
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
const text = [pr.title, pr.body].filter((value) => typeof value === "string").join(`
|
|
1061
|
+
`);
|
|
1062
|
+
return prTextClosesIssue(text, issueNumber);
|
|
1063
|
+
}
|
|
1064
|
+
function closingIssueMatches(issue, sourceIssueId, issueNumber) {
|
|
1065
|
+
if (typeof issue === "number") {
|
|
1066
|
+
return String(issue) === issueNumber;
|
|
1067
|
+
}
|
|
1068
|
+
if (typeof issue === "string") {
|
|
1069
|
+
return issue === sourceIssueId || issue.replace(/^#/, "") === issueNumber;
|
|
1070
|
+
}
|
|
1071
|
+
return String(issue.number ?? "") === issueNumber || issue.id === issueNumber || issue.sourceIssueId === sourceIssueId;
|
|
1072
|
+
}
|
|
1073
|
+
function prTextClosesIssue(text, issueNumber) {
|
|
1074
|
+
if (!text.trim()) {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
const escaped = issueNumber.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1078
|
+
const closePattern = new RegExp(`\\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\\s+#${escaped}\\b`, "i");
|
|
1079
|
+
return closePattern.test(text);
|
|
1080
|
+
}
|
|
1081
|
+
async function parseValidationSummary(path) {
|
|
1082
|
+
return readJsonFile(path);
|
|
1083
|
+
}
|
|
1084
|
+
function isAcceptedValidationSummary(summary) {
|
|
1085
|
+
if (!summary) {
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
if (summary.status === "pass") {
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
return summary.status === "skipped" && summary.total === 0 && summary.failed === 0;
|
|
1092
|
+
}
|
|
1093
|
+
async function loadReviewMode(reviewProfilePath, fallback) {
|
|
1094
|
+
const parsed = existsSync(reviewProfilePath) ? await readJsonFile(reviewProfilePath) : null;
|
|
1095
|
+
const mode = parsed?.mode;
|
|
1096
|
+
if (mode === "off" || mode === "advisory" || mode === "required") {
|
|
1097
|
+
return mode;
|
|
1098
|
+
}
|
|
1099
|
+
if (fallback === "off" || fallback === "advisory" || fallback === "required") {
|
|
1100
|
+
return fallback;
|
|
1101
|
+
}
|
|
1102
|
+
return "advisory";
|
|
1103
|
+
}
|
|
1104
|
+
async function loadReviewProvider(reviewProfilePath, fallback) {
|
|
1105
|
+
const parsed = existsSync(reviewProfilePath) ? await readJsonFile(reviewProfilePath) : null;
|
|
1106
|
+
const provider = parsed?.provider;
|
|
1107
|
+
if (typeof provider === "string" && provider.trim().length > 0) {
|
|
1108
|
+
return provider;
|
|
1109
|
+
}
|
|
1110
|
+
return fallback || "greptile";
|
|
1111
|
+
}
|
|
1112
|
+
function resolveRepoSlug(projectRoot) {
|
|
1113
|
+
const paths = resolveHarnessPaths(projectRoot);
|
|
1114
|
+
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();
|
|
1115
|
+
if (!remote) {
|
|
1116
|
+
return "";
|
|
1117
|
+
}
|
|
1118
|
+
if (remote.startsWith("git@")) {
|
|
1119
|
+
return remote.split(":")[1]?.replace(/\.git$/, "") || "";
|
|
1120
|
+
}
|
|
1121
|
+
const cleaned = remote.replace(/^https?:\/\//, "").replace(/\.git$/, "");
|
|
1122
|
+
const slash = cleaned.indexOf("/");
|
|
1123
|
+
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned;
|
|
1124
|
+
}
|
|
1125
|
+
async function runGreptileReview(options) {
|
|
1126
|
+
const reasons = [];
|
|
1127
|
+
const warnings = [];
|
|
1128
|
+
const secrets = resolveRuntimeSecrets(process.env);
|
|
1129
|
+
const apiKey = secrets.GREPTILE_API_KEY || "";
|
|
1130
|
+
const apiBase = secrets.GREPTILE_API_BASE || "https://api.greptile.com/mcp";
|
|
1131
|
+
const remote = secrets.GREPTILE_REMOTE || "github";
|
|
1132
|
+
const greptileDefaultBranch = secrets.GREPTILE_DEFAULT_BRANCH || "main";
|
|
1133
|
+
const { pollAttempts, pollIntervalMs } = resolveGreptilePollSettings({
|
|
1134
|
+
reviewMode: options.reviewMode,
|
|
1135
|
+
secrets
|
|
1136
|
+
});
|
|
1137
|
+
if (!apiKey) {
|
|
1138
|
+
reasons.push("[AI Review] Missing GREPTILE_API_KEY.");
|
|
1139
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawResponse: "" };
|
|
1140
|
+
}
|
|
1141
|
+
if (options.prStates.length === 0) {
|
|
1142
|
+
reasons.push("[AI Review] Missing pr-state.json or no PRs were recorded for review.");
|
|
1143
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawResponse: "" };
|
|
1144
|
+
}
|
|
1145
|
+
const perPrResults = [];
|
|
1146
|
+
for (const prState of options.prStates) {
|
|
1147
|
+
try {
|
|
1148
|
+
const prResult = await runGreptileReviewForPr({
|
|
1149
|
+
apiBase,
|
|
1150
|
+
apiKey,
|
|
1151
|
+
remote,
|
|
1152
|
+
defaultBranch: greptileDefaultBranch,
|
|
1153
|
+
projectRoot: options.projectRoot,
|
|
1154
|
+
taskId: options.taskId,
|
|
1155
|
+
prState,
|
|
1156
|
+
reviewMode: options.reviewMode,
|
|
1157
|
+
pollAttempts,
|
|
1158
|
+
pollIntervalMs
|
|
1159
|
+
});
|
|
1160
|
+
if (prResult.verdict === "SKIP") {
|
|
1161
|
+
warnings.push(...prResult.reasons.map(asGreptileInfrastructureWarning));
|
|
1162
|
+
} else {
|
|
1163
|
+
reasons.push(...prResult.reasons);
|
|
1164
|
+
}
|
|
1165
|
+
warnings.push(...prResult.warnings);
|
|
1166
|
+
perPrResults.push(prResult);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1169
|
+
const fallback = await runGithubGreptileFallbackReviewForPr({
|
|
1170
|
+
projectRoot: options.projectRoot,
|
|
1171
|
+
taskId: options.taskId,
|
|
1172
|
+
prState,
|
|
1173
|
+
reviewMode: options.reviewMode,
|
|
1174
|
+
infrastructureError: message,
|
|
1175
|
+
pollAttempts,
|
|
1176
|
+
pollIntervalMs
|
|
1177
|
+
}).catch(() => null);
|
|
1178
|
+
if (fallback) {
|
|
1179
|
+
if (fallback.verdict === "SKIP") {
|
|
1180
|
+
warnings.push(...fallback.reasons.map(asGreptileInfrastructureWarning));
|
|
1181
|
+
} else {
|
|
1182
|
+
reasons.push(...fallback.reasons);
|
|
1183
|
+
}
|
|
1184
|
+
warnings.push(...fallback.warnings);
|
|
1185
|
+
perPrResults.push(fallback);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
warnings.push(asGreptileInfrastructureWarning(`Greptile infrastructure failure for ${prState.url || prState.branch || options.taskId}: ${message}`));
|
|
1189
|
+
perPrResults.push({
|
|
1190
|
+
verdict: "SKIP",
|
|
1191
|
+
feedback: "",
|
|
1192
|
+
reasons: [],
|
|
1193
|
+
warnings: [],
|
|
1194
|
+
rawPayload: { pr: prState, error: message }
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (perPrResults.some((result) => result.verdict === "REJECT")) {
|
|
1199
|
+
return {
|
|
1200
|
+
verdict: "REJECT",
|
|
1201
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
1202
|
+
|
|
1203
|
+
`),
|
|
1204
|
+
reasons,
|
|
1205
|
+
warnings,
|
|
1206
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
if (perPrResults.every((result) => result.verdict === "APPROVE")) {
|
|
1210
|
+
return {
|
|
1211
|
+
verdict: "APPROVE",
|
|
1212
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
1213
|
+
|
|
1214
|
+
`),
|
|
1215
|
+
reasons,
|
|
1216
|
+
warnings,
|
|
1217
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
verdict: "SKIP",
|
|
1222
|
+
feedback: perPrResults.map((result) => result.feedback).filter(Boolean).join(`
|
|
1223
|
+
|
|
1224
|
+
`),
|
|
1225
|
+
reasons,
|
|
1226
|
+
warnings,
|
|
1227
|
+
rawResponse: JSON.stringify(perPrResults.map((result) => result.rawPayload), null, 2)
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function writeFeedbackFile(options) {
|
|
1231
|
+
const lines = [
|
|
1232
|
+
"# AI Review Feedback",
|
|
1233
|
+
"",
|
|
1234
|
+
`- Task: ${options.taskId}`,
|
|
1235
|
+
`- Provider: ${options.provider}`,
|
|
1236
|
+
`- Mode: ${options.mode}`,
|
|
1237
|
+
`- Verdict: ${options.verdict}`,
|
|
1238
|
+
""
|
|
1239
|
+
];
|
|
1240
|
+
if (options.localReasons.length > 0) {
|
|
1241
|
+
lines.push("## Local Rejection Reasons", "");
|
|
1242
|
+
for (const reason of options.localReasons) {
|
|
1243
|
+
lines.push(`- ${reason}`);
|
|
1244
|
+
}
|
|
1245
|
+
lines.push("");
|
|
1246
|
+
}
|
|
1247
|
+
if (options.aiReasons.length > 0) {
|
|
1248
|
+
lines.push("## AI Rejection Reasons", "");
|
|
1249
|
+
for (const reason of options.aiReasons) {
|
|
1250
|
+
lines.push(`- ${reason}`);
|
|
1251
|
+
}
|
|
1252
|
+
lines.push("");
|
|
1253
|
+
}
|
|
1254
|
+
if (options.aiWarnings.length > 0) {
|
|
1255
|
+
lines.push("## AI Warnings", "");
|
|
1256
|
+
for (const warning of options.aiWarnings) {
|
|
1257
|
+
lines.push(`- ${warning}`);
|
|
1258
|
+
}
|
|
1259
|
+
lines.push("");
|
|
1260
|
+
}
|
|
1261
|
+
if (options.aiRawFeedback) {
|
|
1262
|
+
lines.push("## Raw Reviewer Feedback", "", "```text", options.aiRawFeedback, "```", "");
|
|
1263
|
+
}
|
|
1264
|
+
writeFileSync(options.output, `${lines.join(`
|
|
1265
|
+
`)}
|
|
1266
|
+
`, "utf-8");
|
|
1267
|
+
}
|
|
1268
|
+
function writeReviewStateFile(options) {
|
|
1269
|
+
const payload = {
|
|
1270
|
+
task_id: options.taskId,
|
|
1271
|
+
approved: options.approved,
|
|
1272
|
+
provider: options.provider,
|
|
1273
|
+
mode: options.mode,
|
|
1274
|
+
verdict: options.verdict,
|
|
1275
|
+
pr: options.prState,
|
|
1276
|
+
local_reasons: options.localReasons,
|
|
1277
|
+
ai_reasons: options.aiReasons,
|
|
1278
|
+
ai_warnings: options.aiWarnings,
|
|
1279
|
+
updated_at: nowIso()
|
|
1280
|
+
};
|
|
1281
|
+
writeFileSync(options.output, `${JSON.stringify(payload, null, 2)}
|
|
1282
|
+
`, "utf-8");
|
|
1283
|
+
}
|
|
1284
|
+
async function runGreptileReviewForPr(options) {
|
|
1285
|
+
const reasons = [];
|
|
1286
|
+
const warnings = [];
|
|
1287
|
+
const repoName = deriveRepoName(options.projectRoot, options.prState);
|
|
1288
|
+
const prNumber = parsePullRequestNumber(options.prState.url || "");
|
|
1289
|
+
const defaultBranch = options.defaultBranch;
|
|
1290
|
+
const expectedHeadSha = resolvePrHeadSha(options.projectRoot, options.prState);
|
|
1291
|
+
if (!repoName) {
|
|
1292
|
+
reasons.push(`[AI Review] Could not resolve repository slug for ${options.prState.repoLabel || options.prState.target || "PR"}.`);
|
|
1293
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawPayload: { pr: options.prState } };
|
|
1294
|
+
}
|
|
1295
|
+
if (!prNumber) {
|
|
1296
|
+
reasons.push(`[AI Review] Could not parse PR number from ${options.prState.url || "missing PR URL"}.`);
|
|
1297
|
+
return { verdict: "SKIP", feedback: "", reasons, warnings, rawPayload: { pr: options.prState } };
|
|
1298
|
+
}
|
|
1299
|
+
const githubPrState = loadGithubPullRequestState(options.projectRoot, repoName, prNumber);
|
|
1300
|
+
if (shouldPreferGithubGreptileFallback(githubPrState)) {
|
|
1301
|
+
return runGithubGreptileFallbackReviewForPr({
|
|
1302
|
+
projectRoot: options.projectRoot,
|
|
1303
|
+
taskId: options.taskId,
|
|
1304
|
+
prState: options.prState,
|
|
1305
|
+
reviewMode: options.reviewMode,
|
|
1306
|
+
infrastructureError: undefined,
|
|
1307
|
+
pollAttempts: options.pollAttempts,
|
|
1308
|
+
pollIntervalMs: options.pollIntervalMs
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
const initialReviewsPayload = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_code_reviews", {
|
|
1312
|
+
name: repoName,
|
|
1313
|
+
remote: options.remote,
|
|
1314
|
+
defaultBranch,
|
|
1315
|
+
prNumber,
|
|
1316
|
+
limit: 20
|
|
1317
|
+
});
|
|
1318
|
+
const existingReview = findGreptileReviewForHeadSha(initialReviewsPayload.codeReviews || [], expectedHeadSha);
|
|
1319
|
+
const shouldTrigger = shouldTriggerGreptileReview(existingReview, expectedHeadSha);
|
|
1320
|
+
let triggerStartedAt = Date.now();
|
|
1321
|
+
if (shouldTrigger) {
|
|
1322
|
+
triggerStartedAt = Date.now();
|
|
1323
|
+
await callGreptileMcpTool(options.apiBase, options.apiKey, "trigger_code_review", {
|
|
1324
|
+
name: repoName,
|
|
1325
|
+
remote: options.remote,
|
|
1326
|
+
defaultBranch,
|
|
1327
|
+
branch: options.prState.branch,
|
|
1328
|
+
prNumber
|
|
1329
|
+
}).catch((error) => {
|
|
1330
|
+
throw new Error(`Greptile trigger failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1331
|
+
});
|
|
1332
|
+
} else {
|
|
1333
|
+
const existingCreatedAt = Date.parse(existingReview?.createdAt || "");
|
|
1334
|
+
triggerStartedAt = Number.isFinite(existingCreatedAt) ? existingCreatedAt : Date.now();
|
|
1335
|
+
}
|
|
1336
|
+
let selectedReview = null;
|
|
1337
|
+
let reviewsPayload = initialReviewsPayload;
|
|
1338
|
+
let githubCheckRollup = [];
|
|
1339
|
+
let githubCheckState = { pending: false, completed: false };
|
|
1340
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1341
|
+
const listPayload = attempt === 0 ? initialReviewsPayload : await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_code_reviews", {
|
|
1342
|
+
name: repoName,
|
|
1343
|
+
remote: options.remote,
|
|
1344
|
+
defaultBranch,
|
|
1345
|
+
prNumber,
|
|
1346
|
+
limit: 20
|
|
1347
|
+
});
|
|
1348
|
+
reviewsPayload = listPayload;
|
|
1349
|
+
selectedReview = pickRelevantCodeReview(listPayload.codeReviews || [], triggerStartedAt, expectedHeadSha);
|
|
1350
|
+
githubCheckRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
|
|
1351
|
+
githubCheckState = classifyGithubGreptileCheckState(githubCheckRollup);
|
|
1352
|
+
if (!shouldContinueGreptileMcpPolling({
|
|
1353
|
+
attempt,
|
|
1354
|
+
pollAttempts: options.pollAttempts,
|
|
1355
|
+
githubCheckState,
|
|
1356
|
+
selectedReview
|
|
1357
|
+
})) {
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
await Bun.sleep(options.pollIntervalMs);
|
|
1361
|
+
}
|
|
1362
|
+
const ciGate = evaluatePullRequestCiChecks(githubCheckRollup, repoName, prNumber, { mergeStateStatus: options.prState.mergeStateStatus });
|
|
1363
|
+
if (ciGate.verdict !== "APPROVE") {
|
|
1364
|
+
return {
|
|
1365
|
+
verdict: ciGate.verdict,
|
|
1366
|
+
feedback: "",
|
|
1367
|
+
reasons: ciGate.reasons,
|
|
1368
|
+
warnings,
|
|
1369
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, checkRollup: githubCheckRollup }
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
if (!selectedReview) {
|
|
1373
|
+
reasons.push(`[AI Review] Greptile did not produce a review for ${repoName}#${prNumber}.`);
|
|
1374
|
+
return {
|
|
1375
|
+
verdict: "SKIP",
|
|
1376
|
+
feedback: "",
|
|
1377
|
+
reasons,
|
|
1378
|
+
warnings,
|
|
1379
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload }
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
if (selectedReview.status === "FAILED") {
|
|
1383
|
+
reasons.push(`[AI Review] Greptile review failed for ${repoName}#${prNumber}.`);
|
|
1384
|
+
return {
|
|
1385
|
+
verdict: "SKIP",
|
|
1386
|
+
feedback: "",
|
|
1387
|
+
reasons,
|
|
1388
|
+
warnings,
|
|
1389
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
if (selectedReview.status === "SKIPPED") {
|
|
1393
|
+
reasons.push(`[AI Review] Greptile skipped review for ${repoName}#${prNumber}.`);
|
|
1394
|
+
return {
|
|
1395
|
+
verdict: "SKIP",
|
|
1396
|
+
feedback: "",
|
|
1397
|
+
reasons,
|
|
1398
|
+
warnings,
|
|
1399
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
if (selectedReview.status !== "COMPLETED") {
|
|
1403
|
+
if (githubCheckState.completed) {
|
|
1404
|
+
return runGithubGreptileFallbackReviewForPr({
|
|
1405
|
+
projectRoot: options.projectRoot,
|
|
1406
|
+
taskId: options.taskId,
|
|
1407
|
+
prState: options.prState,
|
|
1408
|
+
reviewMode: options.reviewMode,
|
|
1409
|
+
infrastructureError: `Greptile MCP review stayed ${selectedReview.status} after the GitHub Greptile check completed.`,
|
|
1410
|
+
pollAttempts: options.pollAttempts,
|
|
1411
|
+
pollIntervalMs: options.pollIntervalMs
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
reasons.push(`[AI Review] Greptile review for ${repoName}#${prNumber} did not finish before timeout (last status: ${selectedReview.status}).`);
|
|
1415
|
+
return {
|
|
1416
|
+
verdict: "SKIP",
|
|
1417
|
+
feedback: "",
|
|
1418
|
+
reasons,
|
|
1419
|
+
warnings,
|
|
1420
|
+
rawPayload: { pr: options.prState, codeReviews: reviewsPayload, selectedReview }
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const reviewDetails = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "get_code_review", { codeReviewId: selectedReview.id });
|
|
1424
|
+
const commentsPayload = await callGreptileMcpToolJson(options.apiBase, options.apiKey, "list_merge_request_comments", {
|
|
1425
|
+
name: repoName,
|
|
1426
|
+
remote: options.remote,
|
|
1427
|
+
defaultBranch,
|
|
1428
|
+
prNumber,
|
|
1429
|
+
greptileGenerated: true,
|
|
1430
|
+
createdAfter: selectedReview.createdAt
|
|
1431
|
+
});
|
|
1432
|
+
const actionableComments = filterActionableGreptileComments(commentsPayload.comments || []);
|
|
1433
|
+
const reviewBody = reviewDetails.codeReview?.body || "";
|
|
1434
|
+
const score = parseGreptileScore(reviewBody);
|
|
1435
|
+
const feedback = [
|
|
1436
|
+
`## ${options.prState.repoLabel || repoName} PR Review`,
|
|
1437
|
+
"",
|
|
1438
|
+
`- PR: ${options.prState.url || `${repoName}#${prNumber}`}`,
|
|
1439
|
+
`- Review ID: ${selectedReview.id}`,
|
|
1440
|
+
`- Status: ${selectedReview.status}`,
|
|
1441
|
+
"",
|
|
1442
|
+
reviewBody ? stripHtml(reviewBody).trim() : "Greptile completed without summary body."
|
|
1443
|
+
].filter(Boolean).join(`
|
|
1444
|
+
`);
|
|
1445
|
+
if (actionableComments.length > 0) {
|
|
1446
|
+
for (const comment of actionableComments) {
|
|
1447
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.filePath}: ${summarizeComment(comment.body || "")}`);
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
verdict: "REJECT",
|
|
1451
|
+
feedback,
|
|
1452
|
+
reasons,
|
|
1453
|
+
warnings,
|
|
1454
|
+
rawPayload: {
|
|
1455
|
+
pr: options.prState,
|
|
1456
|
+
codeReviews: reviewsPayload,
|
|
1457
|
+
selectedReview,
|
|
1458
|
+
reviewDetails,
|
|
1459
|
+
comments: commentsPayload
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
|
|
1464
|
+
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)) {
|
|
1465
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
|
|
1466
|
+
return {
|
|
1467
|
+
verdict: "REJECT",
|
|
1468
|
+
feedback,
|
|
1469
|
+
reasons,
|
|
1470
|
+
warnings,
|
|
1471
|
+
rawPayload: {
|
|
1472
|
+
pr: options.prState,
|
|
1473
|
+
codeReviews: reviewsPayload,
|
|
1474
|
+
selectedReview,
|
|
1475
|
+
reviewDetails,
|
|
1476
|
+
comments: commentsPayload
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (score?.scale === 5 && score.value < 5) {
|
|
1481
|
+
reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
|
|
1482
|
+
return {
|
|
1483
|
+
verdict: "REJECT",
|
|
1484
|
+
feedback,
|
|
1485
|
+
reasons,
|
|
1486
|
+
warnings,
|
|
1487
|
+
rawPayload: {
|
|
1488
|
+
pr: options.prState,
|
|
1489
|
+
codeReviews: reviewsPayload,
|
|
1490
|
+
selectedReview,
|
|
1491
|
+
reviewDetails,
|
|
1492
|
+
comments: commentsPayload,
|
|
1493
|
+
score
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
|
|
1498
|
+
let strictGate = null;
|
|
1499
|
+
try {
|
|
1500
|
+
const strictEvidence = await collectStrictPrEvidenceForVerifier({
|
|
1501
|
+
projectRoot: options.projectRoot,
|
|
1502
|
+
taskId: options.taskId,
|
|
1503
|
+
prUrl,
|
|
1504
|
+
apiSignals: [{
|
|
1505
|
+
id: selectedReview.id,
|
|
1506
|
+
body: reviewBody,
|
|
1507
|
+
reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
|
|
1508
|
+
status: selectedReview.status
|
|
1509
|
+
}]
|
|
1510
|
+
});
|
|
1511
|
+
strictGate = evaluateStrictPrMergeGate(strictEvidence);
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
return {
|
|
1515
|
+
verdict: "REJECT",
|
|
1516
|
+
feedback,
|
|
1517
|
+
reasons,
|
|
1518
|
+
warnings,
|
|
1519
|
+
rawPayload: {
|
|
1520
|
+
pr: options.prState,
|
|
1521
|
+
codeReviews: reviewsPayload,
|
|
1522
|
+
selectedReview,
|
|
1523
|
+
reviewDetails,
|
|
1524
|
+
comments: commentsPayload,
|
|
1525
|
+
score
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
if (!strictGate.approved) {
|
|
1530
|
+
return {
|
|
1531
|
+
verdict: strictGate.pending ? "SKIP" : "REJECT",
|
|
1532
|
+
feedback,
|
|
1533
|
+
reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
|
|
1534
|
+
warnings: [...warnings, ...strictGate.warnings],
|
|
1535
|
+
rawPayload: {
|
|
1536
|
+
pr: options.prState,
|
|
1537
|
+
codeReviews: reviewsPayload,
|
|
1538
|
+
selectedReview,
|
|
1539
|
+
reviewDetails,
|
|
1540
|
+
comments: commentsPayload,
|
|
1541
|
+
score,
|
|
1542
|
+
strictGate: {
|
|
1543
|
+
approved: strictGate.approved,
|
|
1544
|
+
pending: strictGate.pending,
|
|
1545
|
+
reasons: strictGate.reasons,
|
|
1546
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1547
|
+
warnings: strictGate.warnings,
|
|
1548
|
+
greptile: strictGate.evidence.greptile,
|
|
1549
|
+
readErrors: strictGate.evidence.readErrors
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
return {
|
|
1555
|
+
verdict: "APPROVE",
|
|
1556
|
+
feedback,
|
|
1557
|
+
reasons,
|
|
1558
|
+
warnings,
|
|
1559
|
+
rawPayload: {
|
|
1560
|
+
pr: options.prState,
|
|
1561
|
+
codeReviews: reviewsPayload,
|
|
1562
|
+
selectedReview,
|
|
1563
|
+
reviewDetails,
|
|
1564
|
+
comments: commentsPayload,
|
|
1565
|
+
strictGate: {
|
|
1566
|
+
approved: strictGate.approved,
|
|
1567
|
+
pending: strictGate.pending,
|
|
1568
|
+
reasons: strictGate.reasons,
|
|
1569
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1570
|
+
warnings: strictGate.warnings,
|
|
1571
|
+
greptile: strictGate.evidence.greptile,
|
|
1572
|
+
readErrors: strictGate.evidence.readErrors
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
async function runGithubGreptileFallbackReviewForPr(options) {
|
|
1578
|
+
const repoName = deriveRepoName(options.projectRoot, options.prState);
|
|
1579
|
+
const prNumber = parsePullRequestNumber(options.prState.url || "");
|
|
1580
|
+
const expectedHeadSha = resolvePrHeadSha(options.projectRoot, options.prState);
|
|
1581
|
+
if (!repoName || !prNumber) {
|
|
1582
|
+
return {
|
|
1583
|
+
verdict: "SKIP",
|
|
1584
|
+
feedback: "",
|
|
1585
|
+
reasons: [],
|
|
1586
|
+
warnings: buildGithubGreptileFallbackWarnings(options),
|
|
1587
|
+
rawPayload: buildGithubGreptileFallbackRawPayload(options)
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
let reviews = [];
|
|
1591
|
+
let selectedReview = null;
|
|
1592
|
+
let latestReview = null;
|
|
1593
|
+
let fallbackReview = null;
|
|
1594
|
+
let threads = [];
|
|
1595
|
+
let actionableThreads = [];
|
|
1596
|
+
let checkRollup = [];
|
|
1597
|
+
let checkState = { pending: false, completed: false };
|
|
1598
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1599
|
+
reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
|
|
1600
|
+
selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
|
|
1601
|
+
latestReview = pickLatestGithubGreptileReview(reviews);
|
|
1602
|
+
fallbackReview = selectedReview ?? latestReview;
|
|
1603
|
+
threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
|
|
1604
|
+
actionableThreads = filterActionableGithubGreptileThreads(threads);
|
|
1605
|
+
checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
|
|
1606
|
+
checkState = classifyGithubGreptileCheckState(checkRollup);
|
|
1607
|
+
const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
|
|
1608
|
+
if (!shouldContinueGithubGreptileFallbackPolling({
|
|
1609
|
+
attempt,
|
|
1610
|
+
pollAttempts: options.pollAttempts,
|
|
1611
|
+
checkState,
|
|
1612
|
+
fallbackReview,
|
|
1613
|
+
selectedReview,
|
|
1614
|
+
approvedViaReviewedAncestor
|
|
1615
|
+
})) {
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
await Bun.sleep(options.pollIntervalMs);
|
|
1619
|
+
}
|
|
1620
|
+
const ciGate = evaluatePullRequestCiChecks(checkRollup, repoName, prNumber, { mergeStateStatus: options.prState.mergeStateStatus });
|
|
1621
|
+
if (ciGate.verdict !== "APPROVE") {
|
|
1622
|
+
return {
|
|
1623
|
+
verdict: ciGate.verdict,
|
|
1624
|
+
feedback: "",
|
|
1625
|
+
reasons: ciGate.reasons,
|
|
1626
|
+
warnings: buildGithubGreptileFallbackWarnings(options),
|
|
1627
|
+
rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const feedback = [
|
|
1631
|
+
`## ${options.prState.repoLabel || repoName} PR Review`,
|
|
1632
|
+
"",
|
|
1633
|
+
`- PR: ${options.prState.url || `${repoName}#${prNumber}`}`,
|
|
1634
|
+
"- Source: GitHub Greptile fallback",
|
|
1635
|
+
fallbackReview?.html_url ? `- Review: ${fallbackReview.html_url}` : "",
|
|
1636
|
+
fallbackReview?.state ? `- Status: ${fallbackReview.state}` : "",
|
|
1637
|
+
"",
|
|
1638
|
+
fallbackReview?.body?.trim() ? stripHtml(fallbackReview.body).trim() : "Greptile MCP was unavailable, so verification used GitHub review threads instead."
|
|
1639
|
+
].filter(Boolean).join(`
|
|
1640
|
+
`);
|
|
1641
|
+
const warnings = buildGithubGreptileFallbackWarnings(options);
|
|
1642
|
+
if (checkState.pending) {
|
|
1643
|
+
return {
|
|
1644
|
+
verdict: "SKIP",
|
|
1645
|
+
feedback,
|
|
1646
|
+
reasons: [
|
|
1647
|
+
`[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is still in progress.`
|
|
1648
|
+
],
|
|
1649
|
+
warnings,
|
|
1650
|
+
rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
|
|
1654
|
+
let strictGate;
|
|
1655
|
+
try {
|
|
1656
|
+
const strictEvidence = await collectStrictPrEvidenceForVerifier({
|
|
1657
|
+
projectRoot: options.projectRoot,
|
|
1658
|
+
taskId: options.taskId,
|
|
1659
|
+
prUrl
|
|
1660
|
+
});
|
|
1661
|
+
strictGate = evaluateStrictPrMergeGate(strictEvidence);
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
return {
|
|
1664
|
+
verdict: "REJECT",
|
|
1665
|
+
feedback,
|
|
1666
|
+
reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
|
|
1667
|
+
warnings,
|
|
1668
|
+
rawPayload: {
|
|
1669
|
+
pr: options.prState,
|
|
1670
|
+
selectedReview: fallbackReview,
|
|
1671
|
+
reviews,
|
|
1672
|
+
threads,
|
|
1673
|
+
checkRollup,
|
|
1674
|
+
actionableThreads,
|
|
1675
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
if (!strictGate.approved) {
|
|
1680
|
+
return {
|
|
1681
|
+
verdict: strictGate.pending ? "SKIP" : "REJECT",
|
|
1682
|
+
feedback,
|
|
1683
|
+
reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
|
|
1684
|
+
warnings: [...warnings, ...strictGate.warnings],
|
|
1685
|
+
rawPayload: {
|
|
1686
|
+
pr: options.prState,
|
|
1687
|
+
selectedReview: fallbackReview,
|
|
1688
|
+
reviews,
|
|
1689
|
+
threads,
|
|
1690
|
+
checkRollup,
|
|
1691
|
+
actionableThreads,
|
|
1692
|
+
strictGate: {
|
|
1693
|
+
approved: strictGate.approved,
|
|
1694
|
+
pending: strictGate.pending,
|
|
1695
|
+
reasons: strictGate.reasons,
|
|
1696
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1697
|
+
warnings: strictGate.warnings,
|
|
1698
|
+
greptile: strictGate.evidence.greptile
|
|
1699
|
+
},
|
|
1700
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
verdict: "APPROVE",
|
|
1706
|
+
feedback,
|
|
1707
|
+
reasons: [],
|
|
1708
|
+
warnings,
|
|
1709
|
+
rawPayload: {
|
|
1710
|
+
pr: options.prState,
|
|
1711
|
+
selectedReview: fallbackReview,
|
|
1712
|
+
reviews,
|
|
1713
|
+
threads,
|
|
1714
|
+
checkRollup,
|
|
1715
|
+
strictGate: {
|
|
1716
|
+
approved: strictGate.approved,
|
|
1717
|
+
pending: strictGate.pending,
|
|
1718
|
+
reasons: strictGate.reasons,
|
|
1719
|
+
reasonDetails: strictGate.reasonDetails,
|
|
1720
|
+
warnings: strictGate.warnings,
|
|
1721
|
+
greptile: strictGate.evidence.greptile
|
|
1722
|
+
},
|
|
1723
|
+
...buildGithubGreptileFallbackRawPayload(options)
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
function buildGithubGreptileFallbackWarnings(options) {
|
|
1728
|
+
if (!options.infrastructureError?.trim()) {
|
|
1729
|
+
return [];
|
|
1730
|
+
}
|
|
1731
|
+
return [
|
|
1732
|
+
asGreptileInfrastructureWarning(`Greptile infrastructure failure for ${options.prState.url || options.prState.branch || options.taskId}: ${options.infrastructureError}`)
|
|
1733
|
+
];
|
|
1734
|
+
}
|
|
1735
|
+
function buildGithubGreptileFallbackRawPayload(options) {
|
|
1736
|
+
return options.infrastructureError?.trim() ? { pr: options.prState, error: options.infrastructureError } : { pr: options.prState };
|
|
1737
|
+
}
|
|
1738
|
+
async function callGreptileMcpTool(apiBase, apiKey, name, args) {
|
|
1739
|
+
return callGreptileMcpToolWithTimeout(apiBase, apiKey, name, args, resolveGreptileRequestTimeoutMs(process.env.GREPTILE_REQUEST_TIMEOUT_MS));
|
|
1740
|
+
}
|
|
1741
|
+
async function callGreptileMcpToolWithTimeout(apiBase, apiKey, name, args, timeoutMs) {
|
|
1742
|
+
const controller = new AbortController;
|
|
1743
|
+
const timeoutId = setTimeout(() => {
|
|
1744
|
+
controller.abort(new Error(`Greptile MCP tool ${name} timed out after ${timeoutMs}ms.`));
|
|
1745
|
+
}, timeoutMs);
|
|
1746
|
+
let response;
|
|
1747
|
+
try {
|
|
1748
|
+
response = await fetch(apiBase, {
|
|
1749
|
+
method: "POST",
|
|
1750
|
+
headers: {
|
|
1751
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1752
|
+
"Content-Type": "application/json"
|
|
1753
|
+
},
|
|
1754
|
+
body: JSON.stringify({
|
|
1755
|
+
jsonrpc: "2.0",
|
|
1756
|
+
id: `rig-${name}-${Date.now()}`,
|
|
1757
|
+
method: "tools/call",
|
|
1758
|
+
params: { name, arguments: args }
|
|
1759
|
+
}),
|
|
1760
|
+
signal: controller.signal
|
|
1761
|
+
});
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
if (controller.signal.aborted) {
|
|
1764
|
+
throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${name} timed out after ${timeoutMs}ms.`);
|
|
1765
|
+
}
|
|
1766
|
+
throw error;
|
|
1767
|
+
} finally {
|
|
1768
|
+
clearTimeout(timeoutId);
|
|
1769
|
+
}
|
|
1770
|
+
const raw = await response.text();
|
|
1771
|
+
if (!response.ok) {
|
|
1772
|
+
throw new Error(`HTTP ${response.status}: ${raw}`);
|
|
1773
|
+
}
|
|
1774
|
+
let envelope;
|
|
1775
|
+
try {
|
|
1776
|
+
envelope = JSON.parse(raw);
|
|
1777
|
+
} catch {
|
|
1778
|
+
throw new Error(`Malformed MCP response: ${raw}`);
|
|
1779
|
+
}
|
|
1780
|
+
if (envelope.error?.message) {
|
|
1781
|
+
throw new Error(envelope.error.message);
|
|
1782
|
+
}
|
|
1783
|
+
const text = (envelope.result?.content || []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text || "").join(`
|
|
1784
|
+
`).trim();
|
|
1785
|
+
if (!text) {
|
|
1786
|
+
throw new Error(`MCP tool ${name} returned no text payload.`);
|
|
1787
|
+
}
|
|
1788
|
+
return text;
|
|
1789
|
+
}
|
|
1790
|
+
function resolveGreptileRequestTimeoutMs(rawValue) {
|
|
1791
|
+
const DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS = 30000;
|
|
1792
|
+
const parsed = Number.parseInt(rawValue || `${DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS}`, 10);
|
|
1793
|
+
return Number.isFinite(parsed) && parsed >= 1000 ? parsed : DEFAULT_GREPTILE_REQUEST_TIMEOUT_MS;
|
|
1794
|
+
}
|
|
1795
|
+
async function callGreptileMcpToolJson(apiBase, apiKey, name, args) {
|
|
1796
|
+
const text = await callGreptileMcpTool(apiBase, apiKey, name, args);
|
|
1797
|
+
try {
|
|
1798
|
+
return JSON.parse(text);
|
|
1799
|
+
} catch {
|
|
1800
|
+
throw new Error(`MCP tool ${name} returned malformed JSON: ${text}`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
function pickRelevantCodeReview(reviews, triggerStartedAt, expectedHeadSha) {
|
|
1804
|
+
const normalized = [...reviews].sort((a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""));
|
|
1805
|
+
const exactSha = normalized.find((review) => expectedHeadSha && review.metadata?.checkHeadSha === expectedHeadSha);
|
|
1806
|
+
if (exactSha) {
|
|
1807
|
+
return exactSha;
|
|
1808
|
+
}
|
|
1809
|
+
const triggered = normalized.find((review) => Date.parse(review.createdAt || "") >= triggerStartedAt - 1000);
|
|
1810
|
+
return triggered || normalized[0] || null;
|
|
1811
|
+
}
|
|
1812
|
+
function filterActionableGreptileComments(comments) {
|
|
1813
|
+
return comments.filter((comment) => comment.sourceType === "greptile" && typeof comment.filePath === "string" && comment.filePath.trim().length > 0 && comment.addressed === false);
|
|
1814
|
+
}
|
|
1815
|
+
function findGreptileReviewForHeadSha(reviews, expectedHeadSha) {
|
|
1816
|
+
if (!expectedHeadSha) {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
const normalized = [...reviews].sort((a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""));
|
|
1820
|
+
return normalized.find((review) => review.metadata?.checkHeadSha === expectedHeadSha) || null;
|
|
1821
|
+
}
|
|
1822
|
+
function isGreptileReviewTerminal(status) {
|
|
1823
|
+
return status === "COMPLETED" || status === "FAILED" || status === "SKIPPED";
|
|
1824
|
+
}
|
|
1825
|
+
function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
|
|
1826
|
+
if (!existingReview) {
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
if (!expectedHeadSha) {
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
function shouldContinueGreptileMcpPolling(options) {
|
|
1838
|
+
if (options.githubCheckState.completed) {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
if (options.attempt + 1 >= options.pollAttempts) {
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1844
|
+
if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
return true;
|
|
1848
|
+
}
|
|
1849
|
+
function shouldContinueGithubGreptileFallbackPolling(options) {
|
|
1850
|
+
const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
|
|
1851
|
+
if (options.attempt + 1 >= options.pollAttempts) {
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
if (waitingForVisiblePendingReview) {
|
|
1855
|
+
return true;
|
|
1856
|
+
}
|
|
1857
|
+
const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
|
|
1858
|
+
if (reviewNotVisibleYet) {
|
|
1859
|
+
return options.attempt + 1 < options.pollAttempts;
|
|
1860
|
+
}
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
function resolveGreptilePollSettings(options) {
|
|
1864
|
+
const DEFAULT_POLL_ATTEMPTS = 60;
|
|
1865
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
1866
|
+
const REQUIRED_MODE_MIN_ATTEMPTS = 180;
|
|
1867
|
+
const configuredAttempts = Number.parseInt(options.secrets.GREPTILE_POLL_ATTEMPTS || `${DEFAULT_POLL_ATTEMPTS}`, 10);
|
|
1868
|
+
const configuredIntervalMs = Number.parseInt(options.secrets.GREPTILE_POLL_INTERVAL_MS || `${DEFAULT_POLL_INTERVAL_MS}`, 10);
|
|
1869
|
+
const pollAttempts = Number.isFinite(configuredAttempts) && configuredAttempts > 0 ? configuredAttempts : DEFAULT_POLL_ATTEMPTS;
|
|
1870
|
+
const pollIntervalMs = Number.isFinite(configuredIntervalMs) && configuredIntervalMs > 0 ? configuredIntervalMs : DEFAULT_POLL_INTERVAL_MS;
|
|
1871
|
+
if (options.reviewMode !== "required") {
|
|
1872
|
+
return { pollAttempts, pollIntervalMs };
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
pollAttempts: Math.max(REQUIRED_MODE_MIN_ATTEMPTS, pollAttempts),
|
|
1876
|
+
pollIntervalMs: Math.max(DEFAULT_POLL_INTERVAL_MS, pollIntervalMs)
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
function loadGithubPullRequestState(projectRoot, repoName, prNumber) {
|
|
1880
|
+
const response = runGhJson(projectRoot, [
|
|
1881
|
+
"api",
|
|
1882
|
+
`repos/${repoName}/pulls/${prNumber}`
|
|
1883
|
+
]);
|
|
1884
|
+
return {
|
|
1885
|
+
state: response.state || "",
|
|
1886
|
+
merged: response.merged,
|
|
1887
|
+
merged_at: response.merged_at ?? null
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
function shouldPreferGithubGreptileFallback(prState) {
|
|
1891
|
+
if ((prState?.state || "").toUpperCase() === "MERGED") {
|
|
1892
|
+
return true;
|
|
1893
|
+
}
|
|
1894
|
+
if (prState?.merged === true) {
|
|
1895
|
+
return true;
|
|
1896
|
+
}
|
|
1897
|
+
return typeof prState?.merged_at === "string" && prState.merged_at.trim().length > 0;
|
|
1898
|
+
}
|
|
1899
|
+
function parsePullRequestNumber(url) {
|
|
1900
|
+
const match = /\/pull\/(\d+)(?:\/|$)/.exec(url);
|
|
1901
|
+
return match ? Number.parseInt(match[1] || "0", 10) : 0;
|
|
1902
|
+
}
|
|
1903
|
+
function runGhJson(projectRoot, args) {
|
|
1904
|
+
const result = runCapture(["gh", ...args], projectRoot);
|
|
1905
|
+
if (result.exitCode !== 0) {
|
|
1906
|
+
throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
|
|
1907
|
+
}
|
|
1908
|
+
try {
|
|
1909
|
+
return JSON.parse(result.stdout);
|
|
1910
|
+
} catch {
|
|
1911
|
+
throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
async function collectStrictPrEvidenceForVerifier(input) {
|
|
1915
|
+
return collectPrReviewEvidence({
|
|
1916
|
+
projectRoot: input.projectRoot,
|
|
1917
|
+
prUrl: input.prUrl,
|
|
1918
|
+
taskId: input.taskId,
|
|
1919
|
+
runId: "verifier",
|
|
1920
|
+
cycle: 0,
|
|
1921
|
+
apiSignals: input.apiSignals ?? [],
|
|
1922
|
+
command: async (args, options) => {
|
|
1923
|
+
const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
|
|
1924
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
function deriveRepoName(projectRoot, prState) {
|
|
1929
|
+
const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
|
|
1930
|
+
if (fromUrl?.[1]) {
|
|
1931
|
+
return fromUrl[1];
|
|
1932
|
+
}
|
|
1933
|
+
if (prState.target === "monorepo") {
|
|
1934
|
+
return resolveRepoSlug(projectRoot);
|
|
1935
|
+
}
|
|
1936
|
+
return runCapture(["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], projectRoot).stdout.trim();
|
|
1937
|
+
}
|
|
1938
|
+
function resolvePrHeadSha(projectRoot, prState) {
|
|
1939
|
+
const repoRoot = resolvePrRepoRoot(projectRoot, prState);
|
|
1940
|
+
return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
|
|
1941
|
+
}
|
|
1942
|
+
function isGreptileGithubLogin(login) {
|
|
1943
|
+
const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
|
|
1944
|
+
return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
|
|
1945
|
+
}
|
|
1946
|
+
function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
|
|
1947
|
+
const matching = sortGithubGreptileReviews(reviews);
|
|
1948
|
+
if (expectedHeadSha) {
|
|
1949
|
+
const exact = matching.find((review) => review.commit_id === expectedHeadSha);
|
|
1950
|
+
if (exact) {
|
|
1951
|
+
return exact;
|
|
1952
|
+
}
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
return matching[0] || null;
|
|
1956
|
+
}
|
|
1957
|
+
function pickLatestGithubGreptileReview(reviews) {
|
|
1958
|
+
return sortGithubGreptileReviews(reviews)[0] || null;
|
|
1959
|
+
}
|
|
1960
|
+
function sortGithubGreptileReviews(reviews) {
|
|
1961
|
+
return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
|
|
1962
|
+
}
|
|
1963
|
+
function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
|
|
1964
|
+
const response = runGhJson(projectRoot, [
|
|
1965
|
+
"pr",
|
|
1966
|
+
"view",
|
|
1967
|
+
`${prNumber}`,
|
|
1968
|
+
"--repo",
|
|
1969
|
+
repoName,
|
|
1970
|
+
"--json",
|
|
1971
|
+
"statusCheckRollup"
|
|
1972
|
+
]);
|
|
1973
|
+
return response.statusCheckRollup || [];
|
|
1974
|
+
}
|
|
1975
|
+
function evaluatePullRequestCiChecks(checks, repoName, prNumber, options = {}) {
|
|
1976
|
+
const isPendingCheck2 = (check) => {
|
|
1977
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
1978
|
+
return (check.status || "").toUpperCase() !== "COMPLETED";
|
|
1979
|
+
}
|
|
1980
|
+
const state = (check.state || check.status || "").toUpperCase();
|
|
1981
|
+
return state === "PENDING" || state === "EXPECTED" || state === "QUEUED" || state === "IN_PROGRESS";
|
|
1982
|
+
};
|
|
1983
|
+
const pendingGreptile = checks.filter((check) => {
|
|
1984
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
1985
|
+
return label.includes("greptile") && isPendingCheck2(check);
|
|
1986
|
+
});
|
|
1987
|
+
if (pendingGreptile.length > 0) {
|
|
1988
|
+
return {
|
|
1989
|
+
verdict: "SKIP",
|
|
1990
|
+
reasons: pendingGreptile.map((check) => `[CI] ${repoName}#${prNumber} mandatory Greptile check is still pending: ${check.name || check.context || "unknown"}.`)
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
const nonGreptileChecks = checks.filter((check) => {
|
|
1994
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
1995
|
+
return label.length > 0 && !label.includes("greptile");
|
|
1996
|
+
});
|
|
1997
|
+
const pending = nonGreptileChecks.filter(isPendingCheck2);
|
|
1998
|
+
const mergeClean = (options.mergeStateStatus || "").toUpperCase() === "CLEAN";
|
|
1999
|
+
if (pending.length > 0 && !mergeClean) {
|
|
2000
|
+
return {
|
|
2001
|
+
verdict: "SKIP",
|
|
2002
|
+
reasons: pending.map((check) => `[CI] ${repoName}#${prNumber} check is still pending: ${check.name || check.context || "unknown"}.`)
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
const failing = nonGreptileChecks.filter((check) => {
|
|
2006
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
2007
|
+
const conclusion = (check.conclusion || "").toUpperCase();
|
|
2008
|
+
return conclusion.length > 0 && !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion);
|
|
2009
|
+
}
|
|
2010
|
+
const state = (check.state || check.conclusion || "").toUpperCase();
|
|
2011
|
+
return state.length > 0 && !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state);
|
|
2012
|
+
});
|
|
2013
|
+
if (failing.length > 0) {
|
|
2014
|
+
return {
|
|
2015
|
+
verdict: "REJECT",
|
|
2016
|
+
reasons: failing.map((check) => `[CI] ${repoName}#${prNumber} check failed: ${check.name || check.context || "unknown"} (${check.conclusion || check.state || check.status || "unknown"}).`)
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
return { verdict: "APPROVE", reasons: [] };
|
|
2020
|
+
}
|
|
2021
|
+
function classifyGithubGreptileCheckState(checks) {
|
|
2022
|
+
const greptileChecks = checks.filter((check) => {
|
|
2023
|
+
const label = (check.name || check.context || "").toLowerCase();
|
|
2024
|
+
return label.includes("greptile");
|
|
2025
|
+
});
|
|
2026
|
+
if (greptileChecks.length === 0) {
|
|
2027
|
+
return { pending: false, completed: false };
|
|
2028
|
+
}
|
|
2029
|
+
for (const check of greptileChecks) {
|
|
2030
|
+
if ((check.__typename || "") === "CheckRun") {
|
|
2031
|
+
if ((check.status || "").toUpperCase() !== "COMPLETED") {
|
|
2032
|
+
return { pending: true, completed: false };
|
|
2033
|
+
}
|
|
2034
|
+
return { pending: false, completed: true };
|
|
2035
|
+
}
|
|
2036
|
+
const state = (check.state || "").toUpperCase();
|
|
2037
|
+
if (state === "PENDING" || state === "EXPECTED") {
|
|
2038
|
+
return { pending: true, completed: false };
|
|
2039
|
+
}
|
|
2040
|
+
if (state) {
|
|
2041
|
+
return { pending: false, completed: true };
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
return { pending: false, completed: false };
|
|
2045
|
+
}
|
|
2046
|
+
function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
|
|
2047
|
+
const [owner, name] = repoName.split("/");
|
|
2048
|
+
if (!owner || !name) {
|
|
2049
|
+
return [];
|
|
2050
|
+
}
|
|
2051
|
+
const response = runGhJson(projectRoot, [
|
|
2052
|
+
"api",
|
|
2053
|
+
"graphql",
|
|
2054
|
+
"-F",
|
|
2055
|
+
`owner=${owner}`,
|
|
2056
|
+
"-F",
|
|
2057
|
+
`name=${name}`,
|
|
2058
|
+
"-F",
|
|
2059
|
+
`prNumber=${prNumber}`,
|
|
2060
|
+
"-f",
|
|
2061
|
+
"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 } } } } } } }"
|
|
2062
|
+
]);
|
|
2063
|
+
return response.data?.repository?.pullRequest?.reviewThreads?.nodes || [];
|
|
2064
|
+
}
|
|
2065
|
+
function filterActionableGithubGreptileThreads(threads) {
|
|
2066
|
+
return threads.flatMap((thread) => {
|
|
2067
|
+
if (thread.isResolved || thread.isOutdated) {
|
|
2068
|
+
return [];
|
|
2069
|
+
}
|
|
2070
|
+
const comments = thread.comments?.nodes || [];
|
|
2071
|
+
const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
|
|
2072
|
+
if (!latestGreptileComment?.path?.trim()) {
|
|
2073
|
+
return [];
|
|
2074
|
+
}
|
|
2075
|
+
return [latestGreptileComment];
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
function resolvePrRepoRoot(projectRoot, prState) {
|
|
2079
|
+
const runtimeWorkspace = process.env.RIG_TASK_WORKSPACE?.trim();
|
|
2080
|
+
if (prState.target === "monorepo" && runtimeWorkspace && existsSync(resolve(runtimeWorkspace, ".git"))) {
|
|
2081
|
+
return runtimeWorkspace;
|
|
2082
|
+
}
|
|
2083
|
+
const paths = resolveHarnessPaths(projectRoot);
|
|
2084
|
+
return prState.target === "monorepo" ? paths.monorepoRoot : projectRoot;
|
|
2085
|
+
}
|
|
2086
|
+
function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headCommit) {
|
|
2087
|
+
if (!reviewedCommit || !headCommit) {
|
|
2088
|
+
return false;
|
|
2089
|
+
}
|
|
2090
|
+
const repoRoot = resolvePrRepoRoot(projectRoot, prState);
|
|
2091
|
+
return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
|
|
2092
|
+
}
|
|
2093
|
+
function summarizeComment(input) {
|
|
2094
|
+
const text = stripHtml(input).replace(/\s+/g, " ").trim();
|
|
2095
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
2096
|
+
}
|
|
2097
|
+
function asGreptileInfrastructureWarning(reason) {
|
|
2098
|
+
return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
|
|
2099
|
+
}
|
|
2100
|
+
function isAiReviewApproved(input) {
|
|
2101
|
+
if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
|
|
2102
|
+
return false;
|
|
2103
|
+
}
|
|
2104
|
+
if (input.reviewMode !== "required") {
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2107
|
+
return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
|
|
2108
|
+
}
|
|
2109
|
+
var init_verifier = () => {};
|
|
2110
|
+
|
|
2111
|
+
// packages/bundle-default-lifecycle/src/control-plane/completion-verification.ts
|
|
2112
|
+
var exports_completion_verification = {};
|
|
2113
|
+
__export(exports_completion_verification, {
|
|
2114
|
+
runCompletionVerificationGate: () => runCompletionVerificationGate,
|
|
2115
|
+
formatCompletionBlockedMessage: () => formatCompletionBlockedMessage,
|
|
2116
|
+
closeCompletedTaskSource: () => closeCompletedTaskSource
|
|
2117
|
+
});
|
|
2118
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2119
|
+
import { resolve as resolve2 } from "path";
|
|
2120
|
+
import { safePathSegment } from "@rig/shared/safe-identifiers";
|
|
2121
|
+
import {
|
|
2122
|
+
escapeRegExp,
|
|
2123
|
+
resolveBunCli,
|
|
2124
|
+
resolveBunCliInvocation,
|
|
2125
|
+
resolveTaskScopes,
|
|
2126
|
+
resolvePolicyContent
|
|
2127
|
+
} from "@rig/hook-kit";
|
|
2128
|
+
import { loadPolicy, seedPolicyFromContent } from "@rig/runtime/control-plane/runtime/guard";
|
|
2129
|
+
import { gitCommit, gitMergePr, gitOpenPr, readPrMetadata as readPrMetadata2, resolveTaskBranchRef } from "@rig/runtime/control-plane/native/git-ops";
|
|
2130
|
+
import { runStrictPrMergeGate as runStrictPrMergeGate2 } from "@rig/pr-review-plugin";
|
|
2131
|
+
import { strictMergeHeadShaFromGate as strictMergeHeadShaFromGate2 } from "@rig/contracts";
|
|
2132
|
+
import { changedFilesForTask, pendingFilesForTask, taskArtifacts, taskValidate as taskValidate2 } from "@rig/runtime/control-plane/native/task-ops";
|
|
2133
|
+
import { currentTaskId } from "@rig/runtime/control-plane/native/task-state";
|
|
2134
|
+
import { resolveHarnessPaths as resolveHarnessPaths2, runCapture as runCapture2 } from "@rig/runtime/control-plane/native/utils";
|
|
2135
|
+
import { readSourceAwareTaskStatus } from "@rig/runtime/control-plane/tasks/source-aware-task-config-source";
|
|
2136
|
+
import {
|
|
2137
|
+
buildTaskRunLifecycleComment,
|
|
2138
|
+
updateConfiguredTaskSourceTask
|
|
2139
|
+
} from "@rig/runtime/control-plane/tasks/source-lifecycle";
|
|
2140
|
+
import { buildPluginHostContext as buildPluginHostContext2 } from "@rig/runtime/control-plane/plugin-host-context";
|
|
2141
|
+
async function closeCompletedTaskSource(projectRoot, taskId) {
|
|
2142
|
+
const comment = buildTaskRunLifecycleComment({
|
|
2143
|
+
runId: process.env.RIG_SERVER_RUN_ID || taskId,
|
|
2144
|
+
status: "closed",
|
|
2145
|
+
summary: "Rig completion verification approved and closed this task.",
|
|
2146
|
+
runtimeWorkspace: process.env.RIG_TASK_WORKSPACE,
|
|
2147
|
+
logsDir: process.env.RIG_LOGS_DIR,
|
|
2148
|
+
sessionDir: process.env.RIG_SESSION_FILE
|
|
2149
|
+
});
|
|
2150
|
+
const result = await updateConfiguredTaskSourceTask(projectRoot, {
|
|
2151
|
+
taskId,
|
|
2152
|
+
update: { status: "closed", comment }
|
|
2153
|
+
});
|
|
2154
|
+
const status = result.status ?? await readSourceAwareTaskStatus(projectRoot, result.taskId);
|
|
2155
|
+
if (!result.updated && status == null) {
|
|
2156
|
+
return {
|
|
2157
|
+
ok: true,
|
|
2158
|
+
status,
|
|
2159
|
+
message: `No source-aware task source configured for ${taskId}; closeout skipped.`
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
const ok = isClosedStatus(status);
|
|
2163
|
+
return {
|
|
2164
|
+
ok,
|
|
2165
|
+
status,
|
|
2166
|
+
message: ok ? `Task source closed for ${taskId}.` : `Task source closeout failed for ${taskId}; updated=${result.updated}, status=${String(status)}`
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
function isClosedStatus(status) {
|
|
2170
|
+
return status === "closed" || status === "CLOSED" || status === "completed" || status === "cancelled" || status === "blocked";
|
|
2171
|
+
}
|
|
2172
|
+
async function runCompletionVerificationGate(projectRoot) {
|
|
2173
|
+
seedPolicyFromContent(resolvePolicyContent(projectRoot));
|
|
2174
|
+
const taskId = currentTaskId(projectRoot);
|
|
2175
|
+
if (!taskId) {
|
|
2176
|
+
return { ok: true };
|
|
2177
|
+
}
|
|
2178
|
+
const paths = resolveHarnessPaths2(projectRoot);
|
|
2179
|
+
let failed = false;
|
|
2180
|
+
let sourceCloseoutAllowed = false;
|
|
2181
|
+
console.log(`=== Completion Verification: ${taskId} ===`);
|
|
2182
|
+
const scopes = await resolveTaskScopes(projectRoot, taskId);
|
|
2183
|
+
const taskChangedFiles = changedFilesForTask(projectRoot, taskId, true);
|
|
2184
|
+
const sourceInArtifacts = taskChangedFiles.filter((file) => /^artifacts\//.test(file) && /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file));
|
|
2185
|
+
if (sourceInArtifacts.length > 0) {
|
|
2186
|
+
console.log(`
|
|
2187
|
+
[0/3] Source code in artifacts check...`);
|
|
2188
|
+
console.log("FAIL: Source code files found in artifacts/ directory.");
|
|
2189
|
+
console.log("Code belongs in the project source tree, not artifacts/.");
|
|
2190
|
+
console.log("Affected files:");
|
|
2191
|
+
for (const f of sourceInArtifacts) {
|
|
2192
|
+
console.log(` - ${f}`);
|
|
2193
|
+
}
|
|
2194
|
+
failed = true;
|
|
2195
|
+
}
|
|
2196
|
+
const pluginHostCtx = await buildPluginHostContext2(projectRoot).catch((error) => {
|
|
2197
|
+
console.warn(`[completion-verification] plugin host unavailable for validators: ${error instanceof Error ? error.message : String(error)}`);
|
|
2198
|
+
return null;
|
|
2199
|
+
});
|
|
2200
|
+
console.log(`
|
|
2201
|
+
[1/3] Task validation...`);
|
|
2202
|
+
if (!await taskValidate2(projectRoot, taskId, pluginHostCtx?.validatorRegistry ?? undefined)) {
|
|
2203
|
+
console.log(`FAIL: Validation failed for ${taskId}`);
|
|
2204
|
+
failed = true;
|
|
2205
|
+
} else {
|
|
2206
|
+
console.log("OK: Validation passes");
|
|
2207
|
+
}
|
|
2208
|
+
if (taskChangedFiles.some((file) => /^packages\/protos\//.test(file))) {
|
|
2209
|
+
console.log(`
|
|
2210
|
+
[1.5/3] Proto/codegen drift gate...`);
|
|
2211
|
+
if (!await runProtoQualityGate(paths.monorepoRoot)) {
|
|
2212
|
+
failed = true;
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
taskArtifacts(projectRoot, taskId);
|
|
2216
|
+
const policy = loadPolicy(projectRoot);
|
|
2217
|
+
const openPrEnabled = policy.completion.checks.includes("open-pr");
|
|
2218
|
+
const autoMergeEnabled = policy.completion.checks.includes("auto-merge");
|
|
2219
|
+
console.log(`
|
|
2220
|
+
[2/3] Verifier preflight...`);
|
|
2221
|
+
if (!failed) {
|
|
2222
|
+
const localVerifyOutcome = await verifyTask({
|
|
2223
|
+
projectRoot,
|
|
2224
|
+
taskId,
|
|
2225
|
+
skipAiReview: true,
|
|
2226
|
+
persistArtifacts: false
|
|
2227
|
+
});
|
|
2228
|
+
if (!localVerifyOutcome.approved) {
|
|
2229
|
+
console.log("REJECT:");
|
|
2230
|
+
for (const reason of localVerifyOutcome.localReasons) {
|
|
2231
|
+
console.log(`- ${reason}`);
|
|
2232
|
+
}
|
|
2233
|
+
console.log("FAIL: Local verifier preflight rejected completion");
|
|
2234
|
+
failed = true;
|
|
2235
|
+
await recordVerifierFailure(projectRoot, taskId, paths);
|
|
2236
|
+
} else {
|
|
2237
|
+
console.log("OK: Local verifier preflight passes");
|
|
2238
|
+
}
|
|
2239
|
+
} else {
|
|
2240
|
+
console.log("Verifier preflight: skipped (earlier checks failed)");
|
|
2241
|
+
}
|
|
2242
|
+
const pendingTaskChangedFiles = pendingFilesForTask(projectRoot, taskId, true);
|
|
2243
|
+
const hasLocalChanges = pendingTaskChangedFiles.length > 0;
|
|
2244
|
+
console.log(`
|
|
2245
|
+
[post] Auto-committing task changes...`);
|
|
2246
|
+
if (failed) {
|
|
2247
|
+
console.log("Auto-commit: skipped (earlier checks failed)");
|
|
2248
|
+
} else if (hasLocalChanges) {
|
|
2249
|
+
gitCommit({
|
|
2250
|
+
projectRoot,
|
|
2251
|
+
taskId,
|
|
2252
|
+
target: "monorepo",
|
|
2253
|
+
message: `rig: ${taskId} task completion`,
|
|
2254
|
+
allowEmpty: false,
|
|
2255
|
+
scoped: false
|
|
2256
|
+
});
|
|
2257
|
+
console.log("OK: Task changes committed");
|
|
2258
|
+
} else {
|
|
2259
|
+
console.log("Auto-commit: skipped (no changes detected)");
|
|
2260
|
+
}
|
|
2261
|
+
const prReady = !failed && (hasLocalChanges || repoHasRemoteRelevantCommits(projectRoot, paths.monorepoRoot) || repoHasPublishedTaskBranch(projectRoot, paths.monorepoRoot, taskId));
|
|
2262
|
+
if (!failed && openPrEnabled && prReady) {
|
|
2263
|
+
console.log(`
|
|
2264
|
+
[post] Auto push + PR handoff...`);
|
|
2265
|
+
try {
|
|
2266
|
+
const pr = gitOpenPr({
|
|
2267
|
+
projectRoot,
|
|
2268
|
+
taskId,
|
|
2269
|
+
target: "monorepo"
|
|
2270
|
+
});
|
|
2271
|
+
console.log(`PR ready (${pr.repoLabel}): ${pr.url}`);
|
|
2272
|
+
if (pr.reviewer && pr.reviewerSource) {
|
|
2273
|
+
console.log(`Reviewer assigned: ${pr.reviewer} (${pr.reviewerSource})`);
|
|
2274
|
+
} else if (pr.reviewer) {
|
|
2275
|
+
console.log(`Reviewer assigned: ${pr.reviewer}`);
|
|
2276
|
+
}
|
|
2277
|
+
console.log("OK: Auto PR handoff complete");
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
console.log("FAIL: Auto PR handoff failed");
|
|
2280
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
2281
|
+
failed = true;
|
|
2282
|
+
}
|
|
2283
|
+
} else if (failed) {
|
|
2284
|
+
console.log(`
|
|
2285
|
+
[post] Auto PR handoff: skipped (earlier checks failed)`);
|
|
2286
|
+
} else if (!failed) {
|
|
2287
|
+
console.log(openPrEnabled ? `
|
|
2288
|
+
[post] Auto PR handoff: skipped (no changes detected)` : `
|
|
2289
|
+
[post] Auto PR handoff: skipped (not in policy completion.checks)`);
|
|
2290
|
+
}
|
|
2291
|
+
console.log(`
|
|
2292
|
+
[3/3] Verifier review...`);
|
|
2293
|
+
const verifyOutcome = await verifyTask({
|
|
2294
|
+
projectRoot,
|
|
2295
|
+
taskId
|
|
2296
|
+
});
|
|
2297
|
+
if (!verifyOutcome.approved) {
|
|
2298
|
+
console.log("REJECT:");
|
|
2299
|
+
for (const reason of verifyOutcome.localReasons) {
|
|
2300
|
+
console.log(`- ${reason}`);
|
|
2301
|
+
}
|
|
2302
|
+
for (const reason of verifyOutcome.aiReasons) {
|
|
2303
|
+
console.log(`- ${reason}`);
|
|
2304
|
+
}
|
|
2305
|
+
console.log("FAIL: Verifier rejected completion");
|
|
2306
|
+
failed = true;
|
|
2307
|
+
await recordVerifierFailure(projectRoot, taskId, paths);
|
|
2308
|
+
} else {
|
|
2309
|
+
console.log("OK: Verifier approved");
|
|
2310
|
+
}
|
|
2311
|
+
if (!failed && autoMergeEnabled) {
|
|
2312
|
+
console.log(`
|
|
2313
|
+
[post] Auto-merge...`);
|
|
2314
|
+
try {
|
|
2315
|
+
const prs = readPrMetadata2(projectRoot, taskId);
|
|
2316
|
+
if (prs.length === 0) {
|
|
2317
|
+
console.log("Auto-merge: skipped (no PR metadata found)");
|
|
2318
|
+
} else {
|
|
2319
|
+
let cycle = 0;
|
|
2320
|
+
for (const pr of prs) {
|
|
2321
|
+
cycle += 1;
|
|
2322
|
+
const gate = await runStrictPrMergeGate2({
|
|
2323
|
+
projectRoot,
|
|
2324
|
+
prUrl: pr.url,
|
|
2325
|
+
taskId,
|
|
2326
|
+
runId: "completion-verification",
|
|
2327
|
+
cycle,
|
|
2328
|
+
final: true,
|
|
2329
|
+
command: async (args, options) => {
|
|
2330
|
+
const result = runCapture2(["gh", ...args], options?.cwd ?? projectRoot);
|
|
2331
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
if (!gate.approved) {
|
|
2335
|
+
console.log(`FAIL: Strict merge gate blocked PR (${pr.repoLabel}): ${pr.url}`);
|
|
2336
|
+
for (const reason of gate.reasons) {
|
|
2337
|
+
console.log(`- ${reason}`);
|
|
2338
|
+
}
|
|
2339
|
+
failed = true;
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
const mergeResult = gitMergePr({
|
|
2343
|
+
projectRoot,
|
|
2344
|
+
pr,
|
|
2345
|
+
method: "squash",
|
|
2346
|
+
deleteBranch: true,
|
|
2347
|
+
matchHeadCommit: strictMergeHeadShaFromGate2(gate, pr.url)
|
|
2348
|
+
});
|
|
2349
|
+
if (mergeResult.status === "merged" || mergeResult.status === "already-merged") {
|
|
2350
|
+
console.log(`OK: PR merge confirmed (${pr.repoLabel}): ${pr.url}`);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (!failed) {
|
|
2354
|
+
sourceCloseoutAllowed = true;
|
|
2355
|
+
console.log("OK: Auto-merge complete");
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
console.log("FAIL: Auto-merge failed");
|
|
2360
|
+
console.log(error instanceof Error ? error.message : String(error));
|
|
2361
|
+
failed = true;
|
|
2362
|
+
}
|
|
2363
|
+
} else if (!failed) {
|
|
2364
|
+
console.log(`
|
|
2365
|
+
[post] Auto-merge: skipped (not in policy completion.checks)`);
|
|
2366
|
+
}
|
|
2367
|
+
const artifactDir = resolve2(paths.artifactsDir, safePathSegment(taskId, { fallback: "task", maxLength: 96 }));
|
|
2368
|
+
mkdirSync2(artifactDir, { recursive: true });
|
|
2369
|
+
writeFileSync2(resolve2(artifactDir, "review-status.txt"), failed ? `REJECTED
|
|
2370
|
+
` : `APPROVED
|
|
2371
|
+
`, "utf-8");
|
|
2372
|
+
if (!failed) {
|
|
2373
|
+
await recordTaskRepoCommits(projectRoot, taskId, paths);
|
|
2374
|
+
if (sourceCloseoutAllowed) {
|
|
2375
|
+
const closeout = await closeCompletedTaskSource(projectRoot, taskId);
|
|
2376
|
+
if (!closeout.ok) {
|
|
2377
|
+
console.log(`FAIL: ${closeout.message}`);
|
|
2378
|
+
failed = true;
|
|
2379
|
+
} else {
|
|
2380
|
+
console.log(`OK: ${closeout.message}`);
|
|
2381
|
+
}
|
|
2382
|
+
} else {
|
|
2383
|
+
console.log("Task source closeout skipped until an approved PR merge completes.");
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
if (!failed) {
|
|
2387
|
+
console.log(`
|
|
2388
|
+
=== All checks passed for ${taskId} ===`);
|
|
2389
|
+
console.log(autoMergeEnabled ? "Task approved and merged." : "Task ready for review.");
|
|
2390
|
+
return { ok: true };
|
|
2391
|
+
}
|
|
2392
|
+
console.log(`
|
|
2393
|
+
=== COMPLETION BLOCKED ===`);
|
|
2394
|
+
console.log(formatCompletionBlockedMessage(taskId));
|
|
2395
|
+
return { ok: false };
|
|
2396
|
+
}
|
|
2397
|
+
function formatCompletionBlockedMessage(taskId) {
|
|
2398
|
+
return `BLOCKED: Fix the failures above before completing ${taskId}.`;
|
|
2399
|
+
}
|
|
2400
|
+
async function runBunTool(args, cwd) {
|
|
2401
|
+
const bunCli = resolveBunCliInvocation();
|
|
2402
|
+
const proc = Bun.spawn([resolveBunCli(), ...args], {
|
|
2403
|
+
cwd,
|
|
2404
|
+
env: {
|
|
2405
|
+
...process.env,
|
|
2406
|
+
...bunCli.env
|
|
2407
|
+
},
|
|
2408
|
+
stdout: "pipe",
|
|
2409
|
+
stderr: "pipe"
|
|
2410
|
+
});
|
|
2411
|
+
const exitCode = await proc.exited;
|
|
2412
|
+
return {
|
|
2413
|
+
exitCode,
|
|
2414
|
+
stdout: await new Response(proc.stdout).text(),
|
|
2415
|
+
stderr: await new Response(proc.stderr).text()
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
async function runProtoQualityGate(monorepoRoot) {
|
|
2419
|
+
const protosDir = resolve2(monorepoRoot, "packages", "protos");
|
|
2420
|
+
if (!existsSync2(protosDir)) {
|
|
2421
|
+
console.log(`FAIL: Proto workspace not found at ${protosDir}`);
|
|
2422
|
+
return false;
|
|
2423
|
+
}
|
|
2424
|
+
let ok = true;
|
|
2425
|
+
const bufBuild = await runBunTool(["x", "buf", "build"], protosDir);
|
|
2426
|
+
if (bufBuild.exitCode !== 0) {
|
|
2427
|
+
console.log("FAIL: buf build failed");
|
|
2428
|
+
console.log(bufBuild.stderr || bufBuild.stdout);
|
|
2429
|
+
ok = false;
|
|
2430
|
+
} else {
|
|
2431
|
+
console.log("OK: buf build passes");
|
|
2432
|
+
}
|
|
2433
|
+
const bufLint = await runBunTool(["x", "buf", "lint"], protosDir);
|
|
2434
|
+
if (bufLint.exitCode !== 0) {
|
|
2435
|
+
console.log("FAIL: buf lint failed");
|
|
2436
|
+
console.log(bufLint.stderr || bufLint.stdout);
|
|
2437
|
+
ok = false;
|
|
2438
|
+
} else {
|
|
2439
|
+
console.log("OK: buf lint passes");
|
|
2440
|
+
}
|
|
2441
|
+
const generate = await runBunTool(["run", "generate"], protosDir);
|
|
2442
|
+
if (generate.exitCode !== 0) {
|
|
2443
|
+
console.log("FAIL: proto generation failed");
|
|
2444
|
+
console.log(generate.stderr || generate.stdout);
|
|
2445
|
+
ok = false;
|
|
2446
|
+
} else {
|
|
2447
|
+
const drift = runCapture2(["git", "-C", protosDir, "status", "--porcelain", "--", "gen/ts"], monorepoRoot);
|
|
2448
|
+
if (drift.exitCode !== 0) {
|
|
2449
|
+
console.log("FAIL: Could not inspect generated proto drift");
|
|
2450
|
+
console.log(drift.stderr || drift.stdout);
|
|
2451
|
+
ok = false;
|
|
2452
|
+
} else if (drift.stdout.trim()) {
|
|
2453
|
+
console.log("FAIL: Generated TypeScript stubs are out of sync with proto sources");
|
|
2454
|
+
console.log(drift.stdout.trim());
|
|
2455
|
+
ok = false;
|
|
2456
|
+
} else {
|
|
2457
|
+
console.log("OK: Generated TypeScript stubs are in sync");
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
const typecheck = await runBunTool(["x", "tsc", "--noEmit"], protosDir);
|
|
2461
|
+
if (typecheck.exitCode !== 0) {
|
|
2462
|
+
console.log("FAIL: Generated TypeScript does not typecheck");
|
|
2463
|
+
console.log(typecheck.stderr || typecheck.stdout);
|
|
2464
|
+
ok = false;
|
|
2465
|
+
} else {
|
|
2466
|
+
console.log("OK: Generated TypeScript compiles");
|
|
2467
|
+
}
|
|
2468
|
+
const workflowPath = resolve2(monorepoRoot, ".github", "workflows", "pull-request-gate.yml");
|
|
2469
|
+
if (!existsSync2(workflowPath)) {
|
|
2470
|
+
console.log(`FAIL: Missing workflow gate file at ${workflowPath}`);
|
|
2471
|
+
ok = false;
|
|
2472
|
+
} else {
|
|
2473
|
+
const workflow = readFileSync(workflowPath, "utf-8");
|
|
2474
|
+
if (workflow.includes("if: false && needs.detect.outputs.protos_changed == 'true'")) {
|
|
2475
|
+
console.log("FAIL: Proto quality CI gate is disabled in pull-request-gate.yml");
|
|
2476
|
+
ok = false;
|
|
2477
|
+
} else {
|
|
2478
|
+
console.log("OK: Proto quality CI gate is enabled");
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
return ok;
|
|
2482
|
+
}
|
|
2483
|
+
function repoHasRemoteRelevantCommits(projectRoot, repoRoot) {
|
|
2484
|
+
const unpushed = runCapture2(["git", "-C", repoRoot, "log", "@{u}..HEAD", "--oneline"], projectRoot);
|
|
2485
|
+
if (unpushed.exitCode === 0 && unpushed.stdout.trim().length > 0)
|
|
2486
|
+
return true;
|
|
2487
|
+
if (unpushed.exitCode !== 0) {
|
|
2488
|
+
const branch = runCapture2(["git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], projectRoot);
|
|
2489
|
+
if (branch.exitCode === 0 && branch.stdout.trim()) {
|
|
2490
|
+
const remote = runCapture2(["git", "-C", repoRoot, "ls-remote", "--exit-code", "origin", `refs/heads/${branch.stdout.trim()}`], projectRoot);
|
|
2491
|
+
if (remote.exitCode !== 0)
|
|
2492
|
+
return true;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return false;
|
|
2496
|
+
}
|
|
2497
|
+
function repoHasPublishedTaskBranch(projectRoot, repoRoot, taskId) {
|
|
2498
|
+
const branchRef = resolveTaskBranchRef(projectRoot, taskId);
|
|
2499
|
+
return runCapture2(["git", "-C", repoRoot, "ls-remote", "--exit-code", "origin", `refs/heads/${branchRef}`], projectRoot).exitCode === 0;
|
|
2500
|
+
}
|
|
2501
|
+
async function readJsonFileIfPresent(path) {
|
|
2502
|
+
if (!existsSync2(path)) {
|
|
2503
|
+
return null;
|
|
2504
|
+
}
|
|
2505
|
+
try {
|
|
2506
|
+
return await Bun.file(path).json();
|
|
2507
|
+
} catch {
|
|
2508
|
+
return null;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
async function recordVerifierFailure(projectRoot, taskId, paths) {
|
|
2512
|
+
const failedApproachesPath = paths.failedApproachesPath;
|
|
2513
|
+
const artifactDir = resolve2(paths.artifactsDir, safePathSegment(taskId, { fallback: "task", maxLength: 96 }));
|
|
2514
|
+
const reviewStatePath = resolve2(artifactDir, "review-state.json");
|
|
2515
|
+
const reviewFeedbackPath = resolve2(artifactDir, "review-feedback.md");
|
|
2516
|
+
let summary = "Verifier rejected completion. Read review-feedback.md for required fixes.";
|
|
2517
|
+
const parsedReviewState = await readJsonFileIfPresent(reviewStatePath);
|
|
2518
|
+
if (parsedReviewState) {
|
|
2519
|
+
const reasons = [...parsedReviewState.local_reasons || [], ...parsedReviewState.ai_reasons || []].filter(Boolean);
|
|
2520
|
+
if (reasons.length > 0) {
|
|
2521
|
+
summary = `Verifier rejected completion: ${reasons[0]}`;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
let attempts = 1;
|
|
2525
|
+
if (existsSync2(failedApproachesPath)) {
|
|
2526
|
+
const content = readFileSync(failedApproachesPath, "utf-8");
|
|
2527
|
+
attempts = (content.match(new RegExp(`^## ${escapeRegExp(taskId)}\\b`, "gm")) || []).length + 1;
|
|
2528
|
+
} else {
|
|
2529
|
+
mkdirSync2(resolve2(failedApproachesPath, ".."), { recursive: true });
|
|
2530
|
+
writeFileSync2(failedApproachesPath, `# Failed Approaches
|
|
2531
|
+
|
|
2532
|
+
`, "utf-8");
|
|
2533
|
+
}
|
|
2534
|
+
appendFileSync(failedApproachesPath, [
|
|
2535
|
+
"",
|
|
2536
|
+
`## ${taskId} - Attempt ${attempts} (${new Date().toISOString()})`,
|
|
2537
|
+
"",
|
|
2538
|
+
`**Reason:** ${summary}`,
|
|
2539
|
+
"",
|
|
2540
|
+
`**Review artifacts:** ${reviewFeedbackPath}`,
|
|
2541
|
+
"",
|
|
2542
|
+
"---",
|
|
2543
|
+
""
|
|
2544
|
+
].join(`
|
|
2545
|
+
`), "utf-8");
|
|
2546
|
+
}
|
|
2547
|
+
async function recordTaskRepoCommits(projectRoot, taskId, paths) {
|
|
2548
|
+
const statePaths = new Set([paths.taskRepoCommitsPath]);
|
|
2549
|
+
const hostProjectRoot = process.env.RIG_HOST_PROJECT_ROOT?.trim();
|
|
2550
|
+
if (hostProjectRoot && hostProjectRoot !== projectRoot) {
|
|
2551
|
+
statePaths.add(resolveHarnessPaths2(hostProjectRoot).taskRepoCommitsPath);
|
|
2552
|
+
}
|
|
2553
|
+
const repos = {};
|
|
2554
|
+
const monoHead = runCapture2(["git", "-C", paths.monorepoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
|
|
2555
|
+
if (monoHead) {
|
|
2556
|
+
repos["monorepo"] = monoHead;
|
|
2557
|
+
}
|
|
2558
|
+
for (const statePath of statePaths) {
|
|
2559
|
+
let state = {};
|
|
2560
|
+
const parsedState = await readJsonFileIfPresent(statePath);
|
|
2561
|
+
if (parsedState && typeof parsedState === "object" && !Array.isArray(parsedState)) {
|
|
2562
|
+
state = parsedState;
|
|
2563
|
+
}
|
|
2564
|
+
state[taskId] = {
|
|
2565
|
+
recorded_at: new Date().toISOString(),
|
|
2566
|
+
repos
|
|
2567
|
+
};
|
|
2568
|
+
mkdirSync2(resolve2(statePath, ".."), { recursive: true });
|
|
2569
|
+
writeFileSync2(statePath, `${JSON.stringify(state, null, 2)}
|
|
2570
|
+
`, "utf-8");
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
var init_completion_verification = __esm(() => {
|
|
2574
|
+
init_verifier();
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
// packages/bundle-default-lifecycle/src/control-plane/task-verify.ts
|
|
2578
|
+
var exports_task_verify = {};
|
|
2579
|
+
__export(exports_task_verify, {
|
|
2580
|
+
taskVerify: () => taskVerify
|
|
2581
|
+
});
|
|
2582
|
+
import { currentTaskId as currentTaskId2 } from "@rig/runtime/control-plane/native/task-state";
|
|
2583
|
+
async function taskVerify(projectRoot, taskId) {
|
|
2584
|
+
const activeTask = taskId || currentTaskId2(projectRoot);
|
|
2585
|
+
if (!activeTask) {
|
|
2586
|
+
throw new Error("No active task.");
|
|
2587
|
+
}
|
|
2588
|
+
const outcome = await verifyTask({ projectRoot, taskId: activeTask });
|
|
2589
|
+
if (!outcome.approved) {
|
|
2590
|
+
console.log("REJECT:");
|
|
2591
|
+
for (const reason of outcome.localReasons) {
|
|
2592
|
+
console.log(`- ${reason}`);
|
|
2593
|
+
}
|
|
2594
|
+
for (const reason of outcome.aiReasons) {
|
|
2595
|
+
console.log(`- ${reason}`);
|
|
2596
|
+
}
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
console.log("APPROVE");
|
|
2600
|
+
if (outcome.aiVerdict === "APPROVE") {
|
|
2601
|
+
console.log("- [AI Review] Greptile approved.");
|
|
2602
|
+
}
|
|
2603
|
+
for (const warning of outcome.aiWarnings) {
|
|
2604
|
+
console.log(`- ${warning}`);
|
|
2605
|
+
}
|
|
2606
|
+
return true;
|
|
2607
|
+
}
|
|
2608
|
+
var init_task_verify = __esm(() => {
|
|
2609
|
+
init_verifier();
|
|
2610
|
+
});
|
|
2611
|
+
|
|
4
2612
|
// packages/bundle-default-lifecycle/src/pipelineCloseout.ts
|
|
5
|
-
import { resolve } from "path";
|
|
6
|
-
import { loadConfig } from "@rig/core/load-config";
|
|
7
|
-
import {
|
|
2613
|
+
import { resolve as resolve3 } from "path";
|
|
2614
|
+
import { loadConfig as loadConfig2 } from "@rig/core/load-config";
|
|
2615
|
+
import { resolvePluginHost } from "@rig/core/project-plugins";
|
|
8
2616
|
import { createDefaultKernel } from "@rig/kernel/default-kernel";
|
|
2617
|
+
import { buildPluginHostContext as buildPluginHostContext3 } from "@rig/runtime/control-plane/plugin-host-context";
|
|
2618
|
+
|
|
2619
|
+
// packages/bundle-default-lifecycle/src/native/in-process-closeout.ts
|
|
2620
|
+
init_pr_automation();
|
|
2621
|
+
import { loadConfig } from "@rig/core/load-config";
|
|
9
2622
|
import { buildPluginHostContext } from "@rig/runtime/control-plane/plugin-host-context";
|
|
10
|
-
import {
|
|
11
|
-
CloseoutValidationError
|
|
12
|
-
} from "@rig/runtime/control-plane/native/in-process-closeout";
|
|
13
2623
|
import { taskValidate } from "@rig/runtime/control-plane/native/task-ops";
|
|
2624
|
+
var CLOSEOUT_PHASES = new Set([
|
|
2625
|
+
"queued",
|
|
2626
|
+
"commit",
|
|
2627
|
+
"push",
|
|
2628
|
+
"pr-review-merge",
|
|
2629
|
+
"pr-opened",
|
|
2630
|
+
"merge",
|
|
2631
|
+
"close-source",
|
|
2632
|
+
"completed"
|
|
2633
|
+
]);
|
|
2634
|
+
|
|
2635
|
+
class CloseoutValidationError extends Error {
|
|
2636
|
+
name = "CloseoutValidationError";
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// packages/bundle-default-lifecycle/src/pipelineCloseout.ts
|
|
2640
|
+
import { taskValidate as taskValidate3 } from "@rig/runtime/control-plane/native/task-ops";
|
|
14
2641
|
|
|
15
2642
|
// packages/bundle-default-lifecycle/src/defaultPipeline.ts
|
|
16
2643
|
import { resolveKernelStages } from "@rig/kernel/resolver";
|
|
@@ -33,8 +2660,8 @@ var autoMergeStage = defineDefaultLifecycleStage({
|
|
|
33
2660
|
calls: ["runRepoDefaultMerge"]
|
|
34
2661
|
});
|
|
35
2662
|
async function runAutoMergeStage(input) {
|
|
36
|
-
const { runRepoDefaultMerge } = await
|
|
37
|
-
await
|
|
2663
|
+
const { runRepoDefaultMerge: runRepoDefaultMerge2 } = await Promise.resolve().then(() => (init_pr_automation(), exports_pr_automation));
|
|
2664
|
+
await runRepoDefaultMerge2(input);
|
|
38
2665
|
}
|
|
39
2666
|
|
|
40
2667
|
// packages/bundle-default-lifecycle/src/stages/commit.ts
|
|
@@ -45,8 +2672,8 @@ var commitStage = defineDefaultLifecycleStage({
|
|
|
45
2672
|
calls: ["commitRunChanges"]
|
|
46
2673
|
});
|
|
47
2674
|
async function runCommitStage(input) {
|
|
48
|
-
const { commitRunChanges } = await
|
|
49
|
-
return await
|
|
2675
|
+
const { commitRunChanges: commitRunChanges2 } = await Promise.resolve().then(() => (init_pr_automation(), exports_pr_automation));
|
|
2676
|
+
return await commitRunChanges2(input);
|
|
50
2677
|
}
|
|
51
2678
|
|
|
52
2679
|
// packages/bundle-default-lifecycle/src/stages/isolation.ts
|
|
@@ -73,8 +2700,8 @@ var mergeGateStage = defineDefaultLifecycleStage({
|
|
|
73
2700
|
calls: ["runStrictPrMergeGate"]
|
|
74
2701
|
});
|
|
75
2702
|
async function runMergeGateStage(input) {
|
|
76
|
-
const { runStrictPrMergeGate } = await import("@rig/
|
|
77
|
-
return await
|
|
2703
|
+
const { runStrictPrMergeGate: runStrictPrMergeGate2 } = await import("@rig/pr-review-plugin");
|
|
2704
|
+
return await runStrictPrMergeGate2({
|
|
78
2705
|
projectRoot: input.projectRoot,
|
|
79
2706
|
prUrl: input.prUrl,
|
|
80
2707
|
taskId: input.taskId,
|
|
@@ -97,8 +2724,8 @@ var openPrStage = defineDefaultLifecycleStage({
|
|
|
97
2724
|
calls: ["runPrAutomation"]
|
|
98
2725
|
});
|
|
99
2726
|
async function runOpenPrStage(input) {
|
|
100
|
-
const { runPrAutomation } = await
|
|
101
|
-
return await
|
|
2727
|
+
const { runPrAutomation: runPrAutomation2 } = await Promise.resolve().then(() => (init_pr_automation(), exports_pr_automation));
|
|
2728
|
+
return await runPrAutomation2({
|
|
102
2729
|
projectRoot: input.workspace,
|
|
103
2730
|
taskId: input.taskId,
|
|
104
2731
|
runId: input.runId,
|
|
@@ -123,8 +2750,8 @@ var pushStage = defineDefaultLifecycleStage({
|
|
|
123
2750
|
calls: ["pushBranchSyncedWithOrigin"]
|
|
124
2751
|
});
|
|
125
2752
|
async function runPushStage(input) {
|
|
126
|
-
const { pushBranchSyncedWithOrigin } = await
|
|
127
|
-
await
|
|
2753
|
+
const { pushBranchSyncedWithOrigin: pushBranchSyncedWithOrigin2 } = await Promise.resolve().then(() => (init_pr_automation(), exports_pr_automation));
|
|
2754
|
+
await pushBranchSyncedWithOrigin2(input);
|
|
128
2755
|
}
|
|
129
2756
|
|
|
130
2757
|
// packages/bundle-default-lifecycle/src/stages/source-closeout.ts
|
|
@@ -137,8 +2764,8 @@ var sourceCloseoutStage = defineDefaultLifecycleStage({
|
|
|
137
2764
|
async function runSourceCloseoutStage(input) {
|
|
138
2765
|
if (input.pr.status !== "merged" || !input.pr.prUrl)
|
|
139
2766
|
return;
|
|
140
|
-
const { closeIssueAfterMergedPr } = await
|
|
141
|
-
await
|
|
2767
|
+
const { closeIssueAfterMergedPr: closeIssueAfterMergedPr2 } = await Promise.resolve().then(() => (init_pr_automation(), exports_pr_automation));
|
|
2768
|
+
await closeIssueAfterMergedPr2({
|
|
142
2769
|
projectRoot: input.projectRoot,
|
|
143
2770
|
taskId: input.taskId,
|
|
144
2771
|
runId: input.runId,
|
|
@@ -223,11 +2850,11 @@ function defaultKernelStatusData() {
|
|
|
223
2850
|
const plugin = createDefaultKernelPlugin();
|
|
224
2851
|
const resolved = resolveDefaultLifecycle();
|
|
225
2852
|
const capabilities = {};
|
|
226
|
-
for (const capability of plugin.provides)
|
|
227
|
-
capabilities[capability] = plugin.
|
|
2853
|
+
for (const capability of plugin.provides ?? [])
|
|
2854
|
+
capabilities[capability] = plugin.name;
|
|
228
2855
|
return {
|
|
229
|
-
kernelProviderId: plugin.
|
|
230
|
-
kernelVersion: plugin.
|
|
2856
|
+
kernelProviderId: plugin.name,
|
|
2857
|
+
kernelVersion: plugin.version ?? "0.0.0-alpha.1",
|
|
231
2858
|
capabilities,
|
|
232
2859
|
pipelineStageCount: resolved.order.length,
|
|
233
2860
|
stageOrder: [...resolved.order]
|
|
@@ -266,6 +2893,10 @@ function formatDefaultPipelineShow(input = {}) {
|
|
|
266
2893
|
|
|
267
2894
|
// packages/bundle-default-lifecycle/src/plugin.ts
|
|
268
2895
|
import { definePlugin } from "@rig/core/config";
|
|
2896
|
+
import {
|
|
2897
|
+
COMPLETION_VERIFICATION_CAPABILITY_ID,
|
|
2898
|
+
TASK_VERIFY_CAPABILITY_ID
|
|
2899
|
+
} from "@rig/contracts";
|
|
269
2900
|
|
|
270
2901
|
// packages/bundle-default-lifecycle/src/cli.ts
|
|
271
2902
|
var DEFAULT_PIPELINE_CLI_ID = "default-lifecycle.pipeline";
|
|
@@ -341,33 +2972,63 @@ var defaultLifecycleCliCommands = [
|
|
|
341
2972
|
|
|
342
2973
|
// packages/bundle-default-lifecycle/src/plugin.ts
|
|
343
2974
|
var DEFAULT_LIFECYCLE_PLUGIN_ID = "@rig/bundle-default-lifecycle";
|
|
2975
|
+
var COMPLETION_VERIFICATION_HOOK_ID = "@rig/bundle-default-lifecycle:completion-verification";
|
|
2976
|
+
async function runCompletionGate(projectRoot) {
|
|
2977
|
+
const mod = await Promise.resolve().then(() => (init_completion_verification(), exports_completion_verification));
|
|
2978
|
+
return mod.runCompletionVerificationGate(projectRoot);
|
|
2979
|
+
}
|
|
2980
|
+
async function completionVerificationStopHandler(ctx) {
|
|
2981
|
+
const { ok } = await runCompletionGate(ctx.projectRoot);
|
|
2982
|
+
if (ok) {
|
|
2983
|
+
return { decision: "allow" };
|
|
2984
|
+
}
|
|
2985
|
+
const { formatCompletionBlockedMessage: formatCompletionBlockedMessage2 } = await Promise.resolve().then(() => (init_completion_verification(), exports_completion_verification));
|
|
2986
|
+
return { decision: "block", reason: formatCompletionBlockedMessage2(ctx.taskId || "task") };
|
|
2987
|
+
}
|
|
2988
|
+
var LIFECYCLE_HOOKS = [
|
|
2989
|
+
{
|
|
2990
|
+
id: COMPLETION_VERIFICATION_HOOK_ID,
|
|
2991
|
+
event: "Stop",
|
|
2992
|
+
matcher: { kind: "all" },
|
|
2993
|
+
description: "Runs the completion-verification gate (validate, verify, commit, PR, merge) before the agent stops.",
|
|
2994
|
+
handler: completionVerificationStopHandler
|
|
2995
|
+
}
|
|
2996
|
+
];
|
|
2997
|
+
var LIFECYCLE_CAPABILITIES = [
|
|
2998
|
+
{ id: "default-lifecycle.pipeline", title: "Default lifecycle pipeline", commandId: DEFAULT_PIPELINE_CLI_ID },
|
|
2999
|
+
{ id: "default-lifecycle.kernel-status", title: "Default kernel status", commandId: DEFAULT_KERNEL_CLI_ID },
|
|
3000
|
+
{
|
|
3001
|
+
id: TASK_VERIFY_CAPABILITY_ID,
|
|
3002
|
+
title: "Task verification",
|
|
3003
|
+
description: "Verify a task's changes (local checks + AI review) and report approval.",
|
|
3004
|
+
run: async (input) => {
|
|
3005
|
+
const { projectRoot, taskId } = input;
|
|
3006
|
+
const { taskVerify: taskVerify2 } = await Promise.resolve().then(() => (init_task_verify(), exports_task_verify));
|
|
3007
|
+
return taskVerify2(projectRoot, taskId);
|
|
3008
|
+
}
|
|
3009
|
+
},
|
|
3010
|
+
{
|
|
3011
|
+
id: COMPLETION_VERIFICATION_CAPABILITY_ID,
|
|
3012
|
+
title: "Completion verification gate",
|
|
3013
|
+
description: "Run the full completion gate for the active task and report whether it passed.",
|
|
3014
|
+
run: async (input) => {
|
|
3015
|
+
const { projectRoot } = input;
|
|
3016
|
+
return runCompletionGate(projectRoot);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
];
|
|
344
3020
|
function createDefaultLifecyclePlugin(stages = {}) {
|
|
345
|
-
|
|
3021
|
+
return definePlugin({
|
|
346
3022
|
name: DEFAULT_LIFECYCLE_PLUGIN_ID,
|
|
347
3023
|
version: "0.0.0-alpha.1",
|
|
348
3024
|
provides: [],
|
|
349
3025
|
contributes: {
|
|
350
|
-
stages: defaultLifecycleStages,
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
],
|
|
355
|
-
cliCommands: defaultLifecycleCliCommands.map(({ run: _run, ...metadata }) => metadata)
|
|
3026
|
+
stages: defaultLifecycleStages.map((stage) => stages[stage.id] ? { ...stage, run: stages[stage.id] } : stage),
|
|
3027
|
+
hooks: LIFECYCLE_HOOKS,
|
|
3028
|
+
capabilities: LIFECYCLE_CAPABILITIES,
|
|
3029
|
+
cliCommands: defaultLifecycleCliCommands
|
|
356
3030
|
}
|
|
357
|
-
}, {
|
|
358
|
-
stages,
|
|
359
|
-
featureCapabilities: [
|
|
360
|
-
{ id: "default-lifecycle.pipeline", title: "Default lifecycle pipeline", commandId: DEFAULT_PIPELINE_CLI_ID },
|
|
361
|
-
{ id: "default-lifecycle.kernel-status", title: "Default kernel status", commandId: DEFAULT_KERNEL_CLI_ID }
|
|
362
|
-
],
|
|
363
|
-
cliCommands: defaultLifecycleCliCommands
|
|
364
3031
|
});
|
|
365
|
-
return {
|
|
366
|
-
...plugin,
|
|
367
|
-
meta: { id: DEFAULT_LIFECYCLE_PLUGIN_ID, name: "Default Rig Lifecycle", version: "0.0.0-alpha.1" },
|
|
368
|
-
contributes: { ...plugin.contributes ?? {}, stages: defaultLifecycleStages },
|
|
369
|
-
runtime: { stages }
|
|
370
|
-
};
|
|
371
3032
|
}
|
|
372
3033
|
var defaultLifecyclePlugin = createDefaultLifecyclePlugin();
|
|
373
3034
|
|
|
@@ -388,19 +3049,18 @@ function closeoutOutcome(status) {
|
|
|
388
3049
|
}
|
|
389
3050
|
}
|
|
390
3051
|
async function loadRigAutomationConfig(projectRoot) {
|
|
391
|
-
return await
|
|
3052
|
+
return await loadConfig2(projectRoot);
|
|
392
3053
|
}
|
|
393
3054
|
async function runRigProjectValidation({ projectRoot, taskId }) {
|
|
394
|
-
const pluginHostCtx = await
|
|
395
|
-
return
|
|
3055
|
+
const pluginHostCtx = await buildPluginHostContext3(projectRoot);
|
|
3056
|
+
return taskValidate3(projectRoot, taskId, pluginHostCtx?.validatorRegistry ?? undefined);
|
|
396
3057
|
}
|
|
397
|
-
function
|
|
3058
|
+
function shouldAttemptRigMerge2(config) {
|
|
398
3059
|
const mode = config.merge?.mode;
|
|
399
3060
|
return mode !== "off" && mode !== "pr-ready";
|
|
400
3061
|
}
|
|
401
3062
|
async function loadPluginStageContributions(projectRoot) {
|
|
402
|
-
const
|
|
403
|
-
const host = createPluginHost(config.plugins ?? []);
|
|
3063
|
+
const { host } = await resolvePluginHost(projectRoot);
|
|
404
3064
|
return { executors: host.listStageExecutors(), mutations: host.listStageMutations() };
|
|
405
3065
|
}
|
|
406
3066
|
async function runPipelineCloseout(input) {
|
|
@@ -420,9 +3080,9 @@ async function runPipelineCloseout(input) {
|
|
|
420
3080
|
...effectiveConfig,
|
|
421
3081
|
merge: { ...effectiveConfig.merge ?? {}, mode: "pr-ready" }
|
|
422
3082
|
};
|
|
423
|
-
const shouldMerge =
|
|
3083
|
+
const shouldMerge = shouldAttemptRigMerge2(effectiveConfig);
|
|
424
3084
|
const workspace = input.workspace;
|
|
425
|
-
const artifactRoot = input.artifactRoot ??
|
|
3085
|
+
const artifactRoot = input.artifactRoot ?? resolve3(input.projectRoot, "artifacts", taskId);
|
|
426
3086
|
const journal = async (phase, status, detail) => {
|
|
427
3087
|
await input.journalPhase(phase, closeoutOutcome(status), detail ?? null);
|
|
428
3088
|
};
|
|
@@ -586,8 +3246,8 @@ async function runPipelineCloseout(input) {
|
|
|
586
3246
|
};
|
|
587
3247
|
const defaultLifecyclePlugin2 = createDefaultLifecyclePlugin(executors);
|
|
588
3248
|
const pluginStages = await loadPluginStageContributions(input.projectRoot);
|
|
589
|
-
const kernel = createDefaultKernel({ ...input.kernelJournal ? { journal: input.kernelJournal } : {}, stageExecutors: { ...
|
|
590
|
-
const resolved = kernel.stageRunner.resolve(defaultLifecyclePlugin2.contributes
|
|
3249
|
+
const kernel = createDefaultKernel({ ...input.kernelJournal ? { journal: input.kernelJournal } : {}, stageExecutors: { ...executors, ...pluginStages.executors } });
|
|
3250
|
+
const resolved = kernel.stageRunner.resolve(defaultLifecyclePlugin2.contributes?.stages ?? [], pluginStages.mutations);
|
|
591
3251
|
const ctx = {
|
|
592
3252
|
runId: input.runId,
|
|
593
3253
|
taskId,
|