@h-rig/runtime 0.0.6-alpha.11 → 0.0.6-alpha.13
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/bin/rig-agent-dispatch.js +5 -313
- package/dist/bin/rig-agent.js +3 -2
- package/dist/src/control-plane/agent-wrapper.js +10 -15
- package/dist/src/control-plane/harness-main.js +914 -153
- package/dist/src/control-plane/hooks/completion-verification.js +1182 -281
- package/dist/src/control-plane/native/git-ops.js +31 -43
- package/dist/src/control-plane/native/harness-cli.js +914 -153
- package/dist/src/control-plane/native/pr-automation.js +1008 -38
- package/dist/src/control-plane/native/pr-review-gate.js +905 -0
- package/dist/src/control-plane/native/task-ops.js +909 -151
- package/dist/src/control-plane/native/verifier.js +911 -150
- package/native/darwin-arm64/rig-git +0 -0
- package/native/darwin-arm64/rig-git.build-manifest.json +1 -1
- package/native/darwin-arm64/rig-shell +0 -0
- package/native/darwin-arm64/rig-shell.build-manifest.json +1 -1
- package/native/darwin-arm64/rig-tools +0 -0
- package/native/darwin-arm64/rig-tools.build-manifest.json +1 -1
- package/native/darwin-arm64/runtime-native.dylib +0 -0
- package/package.json +6 -6
|
@@ -1,4 +1,894 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// packages/runtime/src/control-plane/native/pr-review-gate.ts
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
function parseJsonObject(value) {
|
|
6
|
+
if (!value?.trim())
|
|
7
|
+
return { value: {}, error: "empty JSON output" };
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(value);
|
|
10
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return { value: {}, error: error instanceof Error ? error.message : String(error) };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function flattenPaginatedArray(value) {
|
|
16
|
+
if (!Array.isArray(value))
|
|
17
|
+
return null;
|
|
18
|
+
if (value.every((entry) => Array.isArray(entry))) {
|
|
19
|
+
return value.flatMap((entry) => entry);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function parseJsonArray(value) {
|
|
24
|
+
if (!value?.trim())
|
|
25
|
+
return { value: [], error: "empty JSON output" };
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(value);
|
|
28
|
+
const flattened = flattenPaginatedArray(parsed);
|
|
29
|
+
return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { value: [], error: error instanceof Error ? error.message : String(error) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function parseGithubPrUrl(prUrl) {
|
|
35
|
+
const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
|
|
36
|
+
if (!match)
|
|
37
|
+
return null;
|
|
38
|
+
const prNumber = Number.parseInt(match[3], 10);
|
|
39
|
+
if (!Number.isFinite(prNumber))
|
|
40
|
+
return null;
|
|
41
|
+
return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
|
|
42
|
+
}
|
|
43
|
+
function checkName(check) {
|
|
44
|
+
return String(check.name ?? check.context ?? "").trim();
|
|
45
|
+
}
|
|
46
|
+
function checkState(check) {
|
|
47
|
+
return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
function isGreptileLabel(value) {
|
|
50
|
+
return String(value ?? "").toLowerCase().includes("greptile");
|
|
51
|
+
}
|
|
52
|
+
function isGreptileGithubLogin(value) {
|
|
53
|
+
const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
|
|
54
|
+
return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
|
|
55
|
+
}
|
|
56
|
+
function isPassingCheck(check) {
|
|
57
|
+
const state = checkState(check);
|
|
58
|
+
return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
|
|
59
|
+
}
|
|
60
|
+
function isPendingCheck(check) {
|
|
61
|
+
const state = checkState(check);
|
|
62
|
+
return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
|
|
63
|
+
}
|
|
64
|
+
function isFailingCheck(check) {
|
|
65
|
+
const state = checkState(check);
|
|
66
|
+
return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
|
|
67
|
+
}
|
|
68
|
+
function wildcardToRegExp(pattern) {
|
|
69
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
70
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
71
|
+
}
|
|
72
|
+
function isAllowedFailure(name, allowedFailures) {
|
|
73
|
+
return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
|
|
74
|
+
}
|
|
75
|
+
function greptileScorePatterns() {
|
|
76
|
+
return [
|
|
77
|
+
/\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
|
|
78
|
+
/\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
|
|
79
|
+
/\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
function parseGreptileScores(input) {
|
|
83
|
+
const text = stripHtml(input);
|
|
84
|
+
const seen = new Set;
|
|
85
|
+
const scores = [];
|
|
86
|
+
for (const pattern of greptileScorePatterns()) {
|
|
87
|
+
for (const match of text.matchAll(pattern)) {
|
|
88
|
+
const value = Number.parseInt(match[1] || "", 10);
|
|
89
|
+
const scale = Number.parseInt(match[2] || "", 10);
|
|
90
|
+
if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
|
|
91
|
+
continue;
|
|
92
|
+
const raw = match[0] || `${value}/${scale}`;
|
|
93
|
+
const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
|
|
94
|
+
if (seen.has(key))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(key);
|
|
97
|
+
scores.push({ value, scale, raw });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return scores;
|
|
101
|
+
}
|
|
102
|
+
function stripHtml(input) {
|
|
103
|
+
return input.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
|
|
104
|
+
|
|
105
|
+
`).trim();
|
|
106
|
+
}
|
|
107
|
+
function containsBlockerText(input) {
|
|
108
|
+
const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
|
|
109
|
+
return /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/i.test(text);
|
|
110
|
+
}
|
|
111
|
+
function isStrictFiveOfFive(score) {
|
|
112
|
+
return score.value === 5 && score.scale === 5;
|
|
113
|
+
}
|
|
114
|
+
function containsConflictingScoreText(input) {
|
|
115
|
+
return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
|
|
116
|
+
}
|
|
117
|
+
function firstString(record, keys) {
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
const value = record[key];
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
function arrayField(record, key) {
|
|
126
|
+
const value = record[key];
|
|
127
|
+
return Array.isArray(value) ? value : [];
|
|
128
|
+
}
|
|
129
|
+
async function runJsonArray(command, args, cwd) {
|
|
130
|
+
const result = await command(args, { cwd });
|
|
131
|
+
const label = `gh ${args.join(" ")}`;
|
|
132
|
+
if (result.exitCode !== 0) {
|
|
133
|
+
return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
|
|
134
|
+
}
|
|
135
|
+
const parsed = parseJsonArray(result.stdout);
|
|
136
|
+
return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
|
|
137
|
+
}
|
|
138
|
+
async function runJsonObject(command, args, cwd) {
|
|
139
|
+
const result = await command(args, { cwd });
|
|
140
|
+
const label = `gh ${args.join(" ")}`;
|
|
141
|
+
if (result.exitCode !== 0) {
|
|
142
|
+
return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
|
|
143
|
+
}
|
|
144
|
+
const parsed = parseJsonObject(result.stdout);
|
|
145
|
+
return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
|
|
146
|
+
}
|
|
147
|
+
function normalizeStatusCheck(entry) {
|
|
148
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
149
|
+
return null;
|
|
150
|
+
const record = entry;
|
|
151
|
+
const name = firstString(record, ["name", "context"]);
|
|
152
|
+
if (!name.trim())
|
|
153
|
+
return null;
|
|
154
|
+
const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
|
|
155
|
+
const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
|
|
156
|
+
return {
|
|
157
|
+
__typename: typeof record.__typename === "string" ? record.__typename : null,
|
|
158
|
+
name,
|
|
159
|
+
context: typeof record.context === "string" ? record.context : null,
|
|
160
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
161
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
162
|
+
conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
|
|
163
|
+
detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.details_url === "string" ? record.details_url : typeof record.html_url === "string" ? record.html_url : typeof record.link === "string" ? record.link : null,
|
|
164
|
+
link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
|
|
165
|
+
headSha: typeof record.headSha === "string" ? record.headSha : null,
|
|
166
|
+
head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
|
|
167
|
+
output: output ? {
|
|
168
|
+
title: typeof output.title === "string" ? output.title : null,
|
|
169
|
+
summary: typeof output.summary === "string" ? output.summary : null,
|
|
170
|
+
text: typeof output.text === "string" ? output.text : null
|
|
171
|
+
} : null,
|
|
172
|
+
app: app ? {
|
|
173
|
+
slug: typeof app.slug === "string" ? app.slug : null,
|
|
174
|
+
name: typeof app.name === "string" ? app.name : null,
|
|
175
|
+
owner: app.owner && typeof app.owner === "object" ? app.owner : null
|
|
176
|
+
} : null
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function normalizeReview(entry) {
|
|
180
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
181
|
+
return null;
|
|
182
|
+
const record = entry;
|
|
183
|
+
return {
|
|
184
|
+
id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
|
|
185
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
186
|
+
body: typeof record.body === "string" ? record.body : null,
|
|
187
|
+
commit_id: typeof record.commit_id === "string" ? record.commit_id : typeof record.commitId === "string" ? record.commitId : record.commit && typeof record.commit === "object" && typeof record.commit.oid === "string" ? record.commit.oid : null,
|
|
188
|
+
html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
|
|
189
|
+
author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function normalizeReviewComment(entry) {
|
|
193
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
194
|
+
return null;
|
|
195
|
+
const record = entry;
|
|
196
|
+
const body = typeof record.body === "string" ? record.body : null;
|
|
197
|
+
const path = typeof record.path === "string" ? record.path : null;
|
|
198
|
+
if (!body && !path)
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
|
|
202
|
+
user: record.user && typeof record.user === "object" ? record.user : null,
|
|
203
|
+
author: record.author && typeof record.author === "object" ? record.author : null,
|
|
204
|
+
body,
|
|
205
|
+
path,
|
|
206
|
+
html_url: typeof record.html_url === "string" ? record.html_url : null,
|
|
207
|
+
url: typeof record.url === "string" ? record.url : null,
|
|
208
|
+
commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
|
|
209
|
+
original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function normalizeIssueComment(entry) {
|
|
213
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
214
|
+
return null;
|
|
215
|
+
const record = entry;
|
|
216
|
+
const body = typeof record.body === "string" ? record.body : null;
|
|
217
|
+
if (!body)
|
|
218
|
+
return null;
|
|
219
|
+
return {
|
|
220
|
+
id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
|
|
221
|
+
user: record.user && typeof record.user === "object" ? record.user : null,
|
|
222
|
+
author: record.author && typeof record.author === "object" ? record.author : null,
|
|
223
|
+
body,
|
|
224
|
+
html_url: typeof record.html_url === "string" ? record.html_url : null,
|
|
225
|
+
url: typeof record.url === "string" ? record.url : null,
|
|
226
|
+
created_at: typeof record.created_at === "string" ? record.created_at : null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function normalizeReviewThread(entry) {
|
|
230
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
231
|
+
return null;
|
|
232
|
+
const record = entry;
|
|
233
|
+
return {
|
|
234
|
+
id: typeof record.id === "string" ? record.id : null,
|
|
235
|
+
isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
|
|
236
|
+
isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
|
|
237
|
+
comments: record.comments && typeof record.comments === "object" ? record.comments : null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function relevantIssueComment(comment) {
|
|
241
|
+
const login = comment.user?.login ?? comment.author?.login ?? "";
|
|
242
|
+
const body = comment.body ?? "";
|
|
243
|
+
return isGreptileGithubLogin(login) || /greptile|blocker|unsafe|not safe|do not merge|must fix|please fix|changes requested|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
|
|
244
|
+
}
|
|
245
|
+
function latestThreadComment(thread) {
|
|
246
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
247
|
+
return nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
|
248
|
+
}
|
|
249
|
+
function unresolvedThreadSummaries(threads) {
|
|
250
|
+
return threads.flatMap((thread) => {
|
|
251
|
+
if (thread.isResolved === true || thread.isOutdated === true)
|
|
252
|
+
return [];
|
|
253
|
+
const latest = latestThreadComment(thread);
|
|
254
|
+
if (!latest)
|
|
255
|
+
return ["Unresolved review thread"];
|
|
256
|
+
const path = latest.path ? ` on ${latest.path}` : "";
|
|
257
|
+
return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function collectBodies(evidence) {
|
|
261
|
+
return [
|
|
262
|
+
evidence.title ?? "",
|
|
263
|
+
evidence.body,
|
|
264
|
+
...evidence.reviews.map((review) => review.body ?? ""),
|
|
265
|
+
...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
|
|
266
|
+
...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
|
|
267
|
+
...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
|
|
268
|
+
...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
|
|
269
|
+
].filter((body) => body.trim().length > 0);
|
|
270
|
+
}
|
|
271
|
+
function bodyExcerpt(body) {
|
|
272
|
+
const text = stripHtml(body).replace(/\s+/g, " ").trim();
|
|
273
|
+
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
|
274
|
+
}
|
|
275
|
+
function makeGreptileSignal(input) {
|
|
276
|
+
const scores = parseGreptileScores(input.body);
|
|
277
|
+
const reviewedSha = input.reviewedSha?.trim() || null;
|
|
278
|
+
const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
|
|
279
|
+
const blocker = input.blocker ?? containsBlockerText(input.body);
|
|
280
|
+
const explicitApproval = input.explicitApproval ?? false;
|
|
281
|
+
return {
|
|
282
|
+
source: input.source,
|
|
283
|
+
trusted: input.trusted,
|
|
284
|
+
authorLogin: input.authorLogin ?? null,
|
|
285
|
+
reviewedSha,
|
|
286
|
+
current,
|
|
287
|
+
stale: current === false,
|
|
288
|
+
score: scores[0] ?? null,
|
|
289
|
+
scores,
|
|
290
|
+
explicitApproval,
|
|
291
|
+
blocker,
|
|
292
|
+
actionable: input.actionable ?? blocker,
|
|
293
|
+
bodyExcerpt: bodyExcerpt(input.body),
|
|
294
|
+
body: input.body,
|
|
295
|
+
allScores: scores
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function reviewAuthorLogin(review) {
|
|
299
|
+
return review.author?.login ?? null;
|
|
300
|
+
}
|
|
301
|
+
function commentAuthorLogin(comment) {
|
|
302
|
+
return comment.user?.login ?? comment.author?.login ?? null;
|
|
303
|
+
}
|
|
304
|
+
function collectGreptileSignals(evidence) {
|
|
305
|
+
const signals = [];
|
|
306
|
+
const contextSources = [
|
|
307
|
+
{ source: "pr-title", body: evidence.title ?? "" },
|
|
308
|
+
{ source: "pr-body", body: evidence.body }
|
|
309
|
+
];
|
|
310
|
+
for (const context of contextSources) {
|
|
311
|
+
if (!context.body.trim())
|
|
312
|
+
continue;
|
|
313
|
+
if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
|
|
314
|
+
continue;
|
|
315
|
+
const contextBlocker = containsBlockerText(context.body);
|
|
316
|
+
signals.push(makeGreptileSignal({
|
|
317
|
+
source: context.source,
|
|
318
|
+
body: context.body,
|
|
319
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
320
|
+
trusted: false,
|
|
321
|
+
blocker: contextBlocker,
|
|
322
|
+
actionable: contextBlocker
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
for (const apiSignal of evidence.apiSignals ?? []) {
|
|
326
|
+
const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
|
|
327
|
+
|
|
328
|
+
`);
|
|
329
|
+
if (!body.trim())
|
|
330
|
+
continue;
|
|
331
|
+
signals.push(makeGreptileSignal({
|
|
332
|
+
source: "api",
|
|
333
|
+
body,
|
|
334
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
335
|
+
trusted: true,
|
|
336
|
+
reviewedSha: apiSignal.reviewedSha ?? null,
|
|
337
|
+
explicitApproval: false
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
for (const review of evidence.reviews) {
|
|
341
|
+
const login = reviewAuthorLogin(review);
|
|
342
|
+
if (!isGreptileGithubLogin(login))
|
|
343
|
+
continue;
|
|
344
|
+
const state = String(review.state ?? "").toUpperCase();
|
|
345
|
+
const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
|
|
346
|
+
|
|
347
|
+
`);
|
|
348
|
+
if (!body.trim())
|
|
349
|
+
continue;
|
|
350
|
+
const dismissed = state === "DISMISSED";
|
|
351
|
+
signals.push(makeGreptileSignal({
|
|
352
|
+
source: "github-review",
|
|
353
|
+
body,
|
|
354
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
355
|
+
trusted: !dismissed,
|
|
356
|
+
authorLogin: login,
|
|
357
|
+
reviewedSha: review.commit_id ?? null,
|
|
358
|
+
explicitApproval: undefined,
|
|
359
|
+
blocker: state === "CHANGES_REQUESTED" || undefined
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
for (const comment of evidence.changedFileReviewComments) {
|
|
363
|
+
const login = commentAuthorLogin(comment);
|
|
364
|
+
const body = comment.body ?? "";
|
|
365
|
+
if (!body.trim() || !isGreptileGithubLogin(login))
|
|
366
|
+
continue;
|
|
367
|
+
signals.push(makeGreptileSignal({
|
|
368
|
+
source: "changed-file-comment",
|
|
369
|
+
body,
|
|
370
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
371
|
+
trusted: true,
|
|
372
|
+
authorLogin: login,
|
|
373
|
+
reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
for (const comment of evidence.relevantIssueComments) {
|
|
377
|
+
const login = commentAuthorLogin(comment);
|
|
378
|
+
const body = comment.body ?? "";
|
|
379
|
+
if (!body.trim() || !isGreptileGithubLogin(login))
|
|
380
|
+
continue;
|
|
381
|
+
signals.push(makeGreptileSignal({
|
|
382
|
+
source: "issue-comment",
|
|
383
|
+
body,
|
|
384
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
385
|
+
trusted: true,
|
|
386
|
+
authorLogin: login
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
for (const thread of evidence.reviewThreads) {
|
|
390
|
+
if (thread.isOutdated === true || thread.isResolved === true)
|
|
391
|
+
continue;
|
|
392
|
+
for (const comment of thread.comments?.nodes ?? []) {
|
|
393
|
+
const login = comment.author?.login ?? null;
|
|
394
|
+
const body = comment.body ?? "";
|
|
395
|
+
if (!body.trim() || !isGreptileGithubLogin(login))
|
|
396
|
+
continue;
|
|
397
|
+
signals.push(makeGreptileSignal({
|
|
398
|
+
source: "review-thread",
|
|
399
|
+
body,
|
|
400
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
401
|
+
trusted: true,
|
|
402
|
+
authorLogin: login
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const check of evidence.checks) {
|
|
407
|
+
if (!isGreptileLabel(checkName(check)))
|
|
408
|
+
continue;
|
|
409
|
+
const reviewedSha = check.headSha ?? check.head_sha ?? null;
|
|
410
|
+
const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
|
|
411
|
+
const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
|
|
412
|
+
|
|
413
|
+
`);
|
|
414
|
+
signals.push(makeGreptileSignal({
|
|
415
|
+
source: "github-check",
|
|
416
|
+
body,
|
|
417
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
418
|
+
trusted: false,
|
|
419
|
+
reviewedSha,
|
|
420
|
+
explicitApproval: false,
|
|
421
|
+
blocker: isFailingCheck(check),
|
|
422
|
+
actionable: isFailingCheck(check)
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
return signals;
|
|
426
|
+
}
|
|
427
|
+
function unresolvedGreptileThreadSummaries(threads) {
|
|
428
|
+
return threads.flatMap((thread) => {
|
|
429
|
+
if (thread.isResolved === true || thread.isOutdated === true)
|
|
430
|
+
return [];
|
|
431
|
+
const comments = thread.comments?.nodes ?? [];
|
|
432
|
+
if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
|
|
433
|
+
return [];
|
|
434
|
+
const latest = latestThreadComment(thread);
|
|
435
|
+
if (!latest)
|
|
436
|
+
return ["Unresolved Greptile review thread"];
|
|
437
|
+
const path = latest.path ? ` on ${latest.path}` : "";
|
|
438
|
+
return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function actionableChangedFileCommentSummaries(_comments) {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
function issueLevelBlockerSummaries(comments) {
|
|
445
|
+
return comments.flatMap((comment) => {
|
|
446
|
+
const body = comment.body?.trim() ?? "";
|
|
447
|
+
if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
|
|
448
|
+
return [];
|
|
449
|
+
const login = commentAuthorLogin(comment) ?? "unknown";
|
|
450
|
+
const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
|
|
451
|
+
return [`${author}: ${body}`];
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function reviewBodyBlockerSummaries(reviews) {
|
|
455
|
+
return reviews.flatMap((review) => {
|
|
456
|
+
const login = reviewAuthorLogin(review) ?? "unknown";
|
|
457
|
+
if (isGreptileGithubLogin(login))
|
|
458
|
+
return [];
|
|
459
|
+
const body = review.body?.trim() ?? "";
|
|
460
|
+
if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
|
|
461
|
+
return [];
|
|
462
|
+
const state = review.state ? ` (${review.state})` : "";
|
|
463
|
+
return [`PR review summary by ${login}${state}: ${body}`];
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
function signalLabel(signal) {
|
|
467
|
+
const source = signal.source.replace(/-/g, " ");
|
|
468
|
+
const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
|
|
469
|
+
const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
|
|
470
|
+
return `${source}${author}${sha}`;
|
|
471
|
+
}
|
|
472
|
+
function deriveGreptileEvidence(input) {
|
|
473
|
+
const rawBodies = collectBodies(input);
|
|
474
|
+
const signals = collectGreptileSignals(input);
|
|
475
|
+
const trustedSignals = signals.filter((signal) => signal.trusted);
|
|
476
|
+
const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
|
|
477
|
+
const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
|
|
478
|
+
const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
|
|
479
|
+
const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
|
|
480
|
+
const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
|
|
481
|
+
const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
|
|
482
|
+
const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
|
|
483
|
+
const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
|
|
484
|
+
const approvedByScore = !!approvingScoreEntry;
|
|
485
|
+
const approvedByExplicitMapping = false;
|
|
486
|
+
const approvingSignal = approvingScoreEntry?.signal ?? null;
|
|
487
|
+
const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
|
|
488
|
+
const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
|
|
489
|
+
const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
|
|
490
|
+
const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
|
|
491
|
+
const staleBlockingSignals = [];
|
|
492
|
+
const blockers = [
|
|
493
|
+
...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
|
|
494
|
+
...reviewBodyBlockerSummaries(input.reviews),
|
|
495
|
+
...issueLevelBlockerSummaries(input.relevantIssueComments),
|
|
496
|
+
...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
|
|
497
|
+
...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
|
|
498
|
+
...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
|
|
499
|
+
];
|
|
500
|
+
const unresolvedComments = [
|
|
501
|
+
...unresolvedGreptileThreadSummaries(input.reviewThreads),
|
|
502
|
+
...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
|
|
503
|
+
];
|
|
504
|
+
const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
|
|
505
|
+
const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
|
|
506
|
+
const completedGreptileCheck = greptileChecks.some((check) => {
|
|
507
|
+
const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
|
|
508
|
+
return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
|
|
509
|
+
});
|
|
510
|
+
const completedGreptileReview = greptileReviews.some((review) => {
|
|
511
|
+
const state = String(review.state ?? "").toUpperCase();
|
|
512
|
+
const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
|
|
513
|
+
return completedState && review.commit_id === input.currentHeadSha;
|
|
514
|
+
});
|
|
515
|
+
const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
|
|
516
|
+
const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
|
|
517
|
+
const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
|
|
518
|
+
const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
|
|
519
|
+
const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
|
|
520
|
+
const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
|
|
521
|
+
const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
|
|
522
|
+
const source = approvingSignal?.source === "api" ? "api" : approvingSignal?.source === "github-review" ? "github-review" : approvingSignal?.source === "changed-file-comment" || approvingSignal?.source === "issue-comment" || approvingSignal?.source === "review-thread" ? "github-comment" : greptileReviews.length > 0 && greptileChecks.length > 0 ? "combined" : greptileReviews.length > 0 ? "github-review" : greptileChecks.length > 0 ? "github-check" : signals.some((signal) => signal.source === "pr-body" || signal.source === "pr-title") ? "pr-body" : "missing";
|
|
523
|
+
return {
|
|
524
|
+
source,
|
|
525
|
+
currentHeadSha: input.currentHeadSha,
|
|
526
|
+
reviewedSha,
|
|
527
|
+
fresh,
|
|
528
|
+
completed,
|
|
529
|
+
approved,
|
|
530
|
+
score,
|
|
531
|
+
explicitApproval: approvedByExplicitMapping,
|
|
532
|
+
blockers,
|
|
533
|
+
unresolvedComments,
|
|
534
|
+
rawBodies,
|
|
535
|
+
signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
|
|
536
|
+
mapping
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function isGreptileCheckDetail(check) {
|
|
540
|
+
return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
|
|
541
|
+
}
|
|
542
|
+
async function collectGreptileCheckDetails(input) {
|
|
543
|
+
const checkRunsRead = await runJsonArray(input.command, [
|
|
544
|
+
"api",
|
|
545
|
+
`repos/${input.repoName}/commits/${input.headSha}/check-runs`,
|
|
546
|
+
"--paginate",
|
|
547
|
+
"--slurp",
|
|
548
|
+
"--jq",
|
|
549
|
+
"map(.check_runs // []) | add // []"
|
|
550
|
+
], input.projectRoot);
|
|
551
|
+
const checkRuns = checkRunsRead.value.map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
|
|
552
|
+
return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
|
|
553
|
+
}
|
|
554
|
+
async function collectReviewThreads(input) {
|
|
555
|
+
const reviewThreads = [];
|
|
556
|
+
let afterCursor = null;
|
|
557
|
+
for (let page = 0;page < 100; page += 1) {
|
|
558
|
+
const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
|
|
559
|
+
const threadsResponse = await runJsonObject(input.command, [
|
|
560
|
+
"api",
|
|
561
|
+
"graphql",
|
|
562
|
+
"-F",
|
|
563
|
+
`owner=${input.owner}`,
|
|
564
|
+
"-F",
|
|
565
|
+
`name=${input.name}`,
|
|
566
|
+
"-F",
|
|
567
|
+
`prNumber=${input.prNumber}`,
|
|
568
|
+
"-f",
|
|
569
|
+
`query=query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$prNumber) { reviewThreads(first: 100, after: ${afterLiteral}) { nodes { id isResolved isOutdated comments(first: 100) { nodes { author { login } body path url createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`
|
|
570
|
+
], input.projectRoot);
|
|
571
|
+
if (threadsResponse.error) {
|
|
572
|
+
return { value: reviewThreads, error: threadsResponse.error };
|
|
573
|
+
}
|
|
574
|
+
const data = threadsResponse.value.data;
|
|
575
|
+
const repository = data?.repository;
|
|
576
|
+
const pullRequest = repository?.pullRequest;
|
|
577
|
+
const threads = pullRequest?.reviewThreads;
|
|
578
|
+
const nodes = threads?.nodes;
|
|
579
|
+
if (!Array.isArray(nodes)) {
|
|
580
|
+
return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
|
|
581
|
+
}
|
|
582
|
+
const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
|
|
583
|
+
reviewThreads.push(...normalized);
|
|
584
|
+
const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
|
|
585
|
+
if (truncatedCommentThread) {
|
|
586
|
+
return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
|
|
587
|
+
}
|
|
588
|
+
const pageInfo = threads?.pageInfo;
|
|
589
|
+
if (!pageInfo) {
|
|
590
|
+
if (nodes.length >= 100) {
|
|
591
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
|
|
592
|
+
}
|
|
593
|
+
return { value: reviewThreads };
|
|
594
|
+
}
|
|
595
|
+
if (pageInfo.hasNextPage !== true) {
|
|
596
|
+
return { value: reviewThreads };
|
|
597
|
+
}
|
|
598
|
+
if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
|
|
599
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
|
|
600
|
+
}
|
|
601
|
+
afterCursor = pageInfo.endCursor;
|
|
602
|
+
}
|
|
603
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
|
|
604
|
+
}
|
|
605
|
+
async function collectPrReviewEvidence(input) {
|
|
606
|
+
const parsed = parseGithubPrUrl(input.prUrl);
|
|
607
|
+
if (!parsed) {
|
|
608
|
+
throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
|
|
609
|
+
}
|
|
610
|
+
const readErrors = [];
|
|
611
|
+
const viewRead = await runJsonObject(input.command, [
|
|
612
|
+
"pr",
|
|
613
|
+
"view",
|
|
614
|
+
input.prUrl,
|
|
615
|
+
"--json",
|
|
616
|
+
"title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
|
|
617
|
+
], input.projectRoot);
|
|
618
|
+
if (viewRead.error)
|
|
619
|
+
readErrors.push(viewRead.error);
|
|
620
|
+
const view = viewRead.value;
|
|
621
|
+
if (!Array.isArray(view.statusCheckRollup)) {
|
|
622
|
+
readErrors.push("gh pr view did not return required statusCheckRollup array");
|
|
623
|
+
}
|
|
624
|
+
if (!Array.isArray(view.reviews)) {
|
|
625
|
+
readErrors.push("gh pr view did not return required reviews array");
|
|
626
|
+
}
|
|
627
|
+
const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
|
|
628
|
+
const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
|
|
629
|
+
const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
|
|
630
|
+
const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
|
|
631
|
+
if (reviewCommentsRead.error)
|
|
632
|
+
readErrors.push(reviewCommentsRead.error);
|
|
633
|
+
const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
|
|
634
|
+
const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
|
|
635
|
+
if (issueCommentsRead.error)
|
|
636
|
+
readErrors.push(issueCommentsRead.error);
|
|
637
|
+
const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
|
|
638
|
+
const reviewThreadsRead = await collectReviewThreads({
|
|
639
|
+
command: input.command,
|
|
640
|
+
projectRoot: input.projectRoot,
|
|
641
|
+
owner: parsed.owner,
|
|
642
|
+
name: parsed.repo,
|
|
643
|
+
prNumber: parsed.prNumber
|
|
644
|
+
});
|
|
645
|
+
if (reviewThreadsRead.error)
|
|
646
|
+
readErrors.push(reviewThreadsRead.error);
|
|
647
|
+
const reviewThreads = reviewThreadsRead.value;
|
|
648
|
+
const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
|
|
649
|
+
let greptileCheckDetails = [];
|
|
650
|
+
if (headSha && greptileRollupChecks.length > 0) {
|
|
651
|
+
const checkDetailsRead = await collectGreptileCheckDetails({
|
|
652
|
+
command: input.command,
|
|
653
|
+
projectRoot: input.projectRoot,
|
|
654
|
+
repoName: parsed.repoName,
|
|
655
|
+
headSha
|
|
656
|
+
});
|
|
657
|
+
if (checkDetailsRead.error)
|
|
658
|
+
readErrors.push(checkDetailsRead.error);
|
|
659
|
+
greptileCheckDetails = checkDetailsRead.value;
|
|
660
|
+
if (!checkDetailsRead.error && greptileCheckDetails.length === 0) {
|
|
661
|
+
readErrors.push("Greptile check details could not be found for the current PR head");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
|
|
665
|
+
const checkFailures = statusCheckRollup.filter((check) => !isGreptileLabel(checkName(check)) && isFailingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check failed: ${checkName(check)}${check.detailsUrl || check.link ? ` (${check.detailsUrl ?? check.link})` : ""}`);
|
|
666
|
+
const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
|
|
667
|
+
const evidenceBase = {
|
|
668
|
+
title: firstString(view, ["title"]),
|
|
669
|
+
body: firstString(view, ["body"]),
|
|
670
|
+
reviews,
|
|
671
|
+
changedFileReviewComments: reviewComments,
|
|
672
|
+
relevantIssueComments: issueComments,
|
|
673
|
+
reviewThreads,
|
|
674
|
+
checks: checksWithGreptileDetails,
|
|
675
|
+
currentHeadSha: headSha,
|
|
676
|
+
apiSignals: input.apiSignals ?? []
|
|
677
|
+
};
|
|
678
|
+
const greptile = deriveGreptileEvidence(evidenceBase);
|
|
679
|
+
return {
|
|
680
|
+
prUrl: input.prUrl,
|
|
681
|
+
prNumber: parsed.prNumber,
|
|
682
|
+
repoName: parsed.repoName,
|
|
683
|
+
title: evidenceBase.title,
|
|
684
|
+
body: evidenceBase.body,
|
|
685
|
+
headSha,
|
|
686
|
+
headRefName: firstString(view, ["headRefName"]),
|
|
687
|
+
baseRefName: firstString(view, ["baseRefName"]),
|
|
688
|
+
state: firstString(view, ["state"]),
|
|
689
|
+
isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
|
|
690
|
+
mergeable: firstString(view, ["mergeable"]),
|
|
691
|
+
mergeStateStatus: firstString(view, ["mergeStateStatus"]),
|
|
692
|
+
reviewDecision: firstString(view, ["reviewDecision"]),
|
|
693
|
+
reviews,
|
|
694
|
+
reviewThreads,
|
|
695
|
+
changedFileReviewComments: reviewComments,
|
|
696
|
+
relevantIssueComments: issueComments,
|
|
697
|
+
statusCheckRollup: checksWithGreptileDetails,
|
|
698
|
+
checkFailures,
|
|
699
|
+
pendingChecks,
|
|
700
|
+
readErrors,
|
|
701
|
+
greptile
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function evaluateEvidence(evidence) {
|
|
705
|
+
const reasons = [];
|
|
706
|
+
const warnings = [];
|
|
707
|
+
let pending = false;
|
|
708
|
+
if (evidence.readErrors.length > 0) {
|
|
709
|
+
reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
|
|
710
|
+
}
|
|
711
|
+
if (!evidence.headSha)
|
|
712
|
+
reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
|
|
713
|
+
if (evidence.checkFailures.length > 0)
|
|
714
|
+
reasons.push(...evidence.checkFailures);
|
|
715
|
+
if (evidence.pendingChecks.length > 0) {
|
|
716
|
+
pending = true;
|
|
717
|
+
reasons.push(...evidence.pendingChecks);
|
|
718
|
+
}
|
|
719
|
+
const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
|
|
720
|
+
if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
|
|
721
|
+
reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
|
|
722
|
+
}
|
|
723
|
+
const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
|
|
724
|
+
if (unresolvedThreads.length > 0)
|
|
725
|
+
reasons.push(...unresolvedThreads);
|
|
726
|
+
const greptile = evidence.greptile;
|
|
727
|
+
if (greptile.mapping === "missing")
|
|
728
|
+
reasons.push("Missing Greptile check/review evidence for this PR.");
|
|
729
|
+
const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
|
|
730
|
+
if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
|
|
731
|
+
reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
|
|
732
|
+
}
|
|
733
|
+
if (!greptile.completed) {
|
|
734
|
+
pending = true;
|
|
735
|
+
reasons.push("Greptile check/review has not completed for the current PR head.");
|
|
736
|
+
}
|
|
737
|
+
if (!greptile.fresh)
|
|
738
|
+
reasons.push("Greptile approval is not tied to the current PR head SHA.");
|
|
739
|
+
if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
|
|
740
|
+
reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
|
|
741
|
+
}
|
|
742
|
+
if (!greptile.score && greptile.mapping !== "score-5-of-5") {
|
|
743
|
+
reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
|
|
744
|
+
}
|
|
745
|
+
if (greptile.mapping === "unproven") {
|
|
746
|
+
reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
|
|
747
|
+
}
|
|
748
|
+
if (greptile.blockers.length > 0) {
|
|
749
|
+
reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
|
|
750
|
+
}
|
|
751
|
+
if (greptile.unresolvedComments.length > 0)
|
|
752
|
+
reasons.push(...greptile.unresolvedComments);
|
|
753
|
+
if (!greptile.approved)
|
|
754
|
+
warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
|
|
755
|
+
return { reasons: Array.from(new Set(reasons)), warnings, pending };
|
|
756
|
+
}
|
|
757
|
+
function evaluateStrictPrMergeGate(evidence) {
|
|
758
|
+
const evaluated = evaluateEvidence(evidence);
|
|
759
|
+
const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
|
|
760
|
+
return {
|
|
761
|
+
approved,
|
|
762
|
+
pending: evaluated.pending,
|
|
763
|
+
reasons: evaluated.reasons,
|
|
764
|
+
warnings: evaluated.warnings,
|
|
765
|
+
actionableFeedback: evaluated.reasons,
|
|
766
|
+
evidence
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function promptExcerpt(value, maxChars = 4000) {
|
|
770
|
+
return value.length > maxChars ? `${value.slice(0, maxChars)}
|
|
771
|
+
|
|
772
|
+
[truncated for prompt; see full evidence artifact]` : value;
|
|
773
|
+
}
|
|
774
|
+
function promptJsonExcerpt(value, maxChars = 6000) {
|
|
775
|
+
return promptExcerpt(JSON.stringify(value, null, 2), maxChars);
|
|
776
|
+
}
|
|
777
|
+
function buildStrictPrGateSteeringPrompt(result) {
|
|
778
|
+
const evidence = result.evidence;
|
|
779
|
+
const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
|
|
780
|
+
const lines = [
|
|
781
|
+
`Strict PR merge gate blocked ${evidence.prUrl}.`,
|
|
782
|
+
`PR title: ${evidence.title || "(empty)"}`,
|
|
783
|
+
`Current PR head SHA: ${evidence.headSha || "unknown"}`,
|
|
784
|
+
`Greptile mapping: ${evidence.greptile.mapping}`,
|
|
785
|
+
evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
|
|
786
|
+
"",
|
|
787
|
+
"Gate reasons:",
|
|
788
|
+
...result.reasons.length ? result.reasons.map((reason) => `- ${reason}`) : ["- No reasons recorded"],
|
|
789
|
+
"",
|
|
790
|
+
"Required evidence read status:",
|
|
791
|
+
evidence.readErrors.length ? JSON.stringify(evidence.readErrors, null, 2) : "All required PR evidence surfaces were read completely.",
|
|
792
|
+
"",
|
|
793
|
+
"Full PR title:",
|
|
794
|
+
evidence.title || "(empty)",
|
|
795
|
+
"",
|
|
796
|
+
"PR body excerpt:",
|
|
797
|
+
evidence.body ? promptExcerpt(evidence.body) : "(empty)",
|
|
798
|
+
"",
|
|
799
|
+
"All review comments on changed files:",
|
|
800
|
+
evidence.changedFileReviewComments.length ? promptJsonExcerpt(evidence.changedFileReviewComments) : "[]",
|
|
801
|
+
"",
|
|
802
|
+
"Unresolved review threads:",
|
|
803
|
+
unresolvedReviewThreads.length ? promptJsonExcerpt(unresolvedReviewThreads) : "[]",
|
|
804
|
+
"",
|
|
805
|
+
"Relevant issue-level PR comments:",
|
|
806
|
+
evidence.relevantIssueComments.length ? promptJsonExcerpt(evidence.relevantIssueComments) : "[]",
|
|
807
|
+
"",
|
|
808
|
+
"CI/check failures and pending checks:",
|
|
809
|
+
promptJsonExcerpt({ failures: evidence.checkFailures, pending: evidence.pendingChecks, rollup: evidence.statusCheckRollup }),
|
|
810
|
+
"",
|
|
811
|
+
"Greptile evidence:",
|
|
812
|
+
promptJsonExcerpt(evidence.greptile)
|
|
813
|
+
];
|
|
814
|
+
if (result.artifacts) {
|
|
815
|
+
lines.push("", "Full evidence artifacts:", JSON.stringify(result.artifacts, null, 2));
|
|
816
|
+
}
|
|
817
|
+
return lines.join(`
|
|
818
|
+
`);
|
|
819
|
+
}
|
|
820
|
+
function persistPrReviewCycleArtifacts(input) {
|
|
821
|
+
const cycleName = input.final ? `${input.cycle}-final` : String(input.cycle);
|
|
822
|
+
const taskArtifactRoot = input.artifactRoot?.trim() ? input.artifactRoot : resolve(input.projectRoot, "artifacts", input.taskId);
|
|
823
|
+
const root = resolve(taskArtifactRoot, "pr-review-cycles", cycleName);
|
|
824
|
+
mkdirSync(root, { recursive: true });
|
|
825
|
+
const finalMergeGateResultPath = input.final ? resolve(taskArtifactRoot, "merge-gate-final.json") : undefined;
|
|
826
|
+
const paths = {
|
|
827
|
+
root,
|
|
828
|
+
prTitlePath: resolve(root, "pr-title.md"),
|
|
829
|
+
prBodyPath: resolve(root, "pr-body.md"),
|
|
830
|
+
prCommentsPath: resolve(root, "pr-comments.json"),
|
|
831
|
+
reviewThreadsPath: resolve(root, "review-threads.json"),
|
|
832
|
+
reviewCommentsPath: resolve(root, "review-comments.json"),
|
|
833
|
+
checkRollupPath: resolve(root, "check-rollup.json"),
|
|
834
|
+
greptileEvidencePath: resolve(root, "greptile-evidence.json"),
|
|
835
|
+
mergeGateResultPath: resolve(root, "merge-gate-result.json"),
|
|
836
|
+
steeringPromptPath: resolve(root, "agent-steering-prompt.md"),
|
|
837
|
+
...finalMergeGateResultPath ? { finalMergeGateResultPath } : {}
|
|
838
|
+
};
|
|
839
|
+
writeFileSync(paths.prTitlePath, input.result.evidence.title || "", "utf8");
|
|
840
|
+
writeFileSync(paths.prBodyPath, input.result.evidence.body || "", "utf8");
|
|
841
|
+
writeFileSync(paths.prCommentsPath, `${JSON.stringify(input.result.evidence.relevantIssueComments, null, 2)}
|
|
842
|
+
`, "utf8");
|
|
843
|
+
writeFileSync(paths.reviewThreadsPath, `${JSON.stringify(input.result.evidence.reviewThreads, null, 2)}
|
|
844
|
+
`, "utf8");
|
|
845
|
+
writeFileSync(paths.reviewCommentsPath, `${JSON.stringify(input.result.evidence.changedFileReviewComments, null, 2)}
|
|
846
|
+
`, "utf8");
|
|
847
|
+
writeFileSync(paths.checkRollupPath, `${JSON.stringify(input.result.evidence.statusCheckRollup, null, 2)}
|
|
848
|
+
`, "utf8");
|
|
849
|
+
writeFileSync(paths.greptileEvidencePath, `${JSON.stringify(input.result.evidence.greptile, null, 2)}
|
|
850
|
+
`, "utf8");
|
|
851
|
+
const mergeGatePayload = {
|
|
852
|
+
approved: input.result.approved,
|
|
853
|
+
pending: input.result.pending,
|
|
854
|
+
reasons: input.result.reasons,
|
|
855
|
+
warnings: input.result.warnings,
|
|
856
|
+
actionableFeedback: input.result.actionableFeedback,
|
|
857
|
+
prUrl: input.result.evidence.prUrl,
|
|
858
|
+
title: input.result.evidence.title,
|
|
859
|
+
headSha: input.result.evidence.headSha,
|
|
860
|
+
readErrors: input.result.evidence.readErrors,
|
|
861
|
+
greptile: input.result.evidence.greptile,
|
|
862
|
+
evidence: input.result.evidence,
|
|
863
|
+
cycleArtifactRoot: root
|
|
864
|
+
};
|
|
865
|
+
writeFileSync(paths.mergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
|
|
866
|
+
`, "utf8");
|
|
867
|
+
if (paths.finalMergeGateResultPath) {
|
|
868
|
+
writeFileSync(paths.finalMergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
|
|
869
|
+
`, "utf8");
|
|
870
|
+
}
|
|
871
|
+
writeFileSync(paths.steeringPromptPath, input.steeringPrompt, "utf8");
|
|
872
|
+
return paths;
|
|
873
|
+
}
|
|
874
|
+
async function runStrictPrMergeGate(input) {
|
|
875
|
+
const evidence = await collectPrReviewEvidence(input);
|
|
876
|
+
const base = evaluateStrictPrMergeGate(evidence);
|
|
877
|
+
const preliminaryPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts: undefined });
|
|
878
|
+
const artifacts = persistPrReviewCycleArtifacts({
|
|
879
|
+
projectRoot: input.projectRoot,
|
|
880
|
+
taskId: input.taskId,
|
|
881
|
+
cycle: input.cycle,
|
|
882
|
+
artifactRoot: input.artifactRoot,
|
|
883
|
+
result: base,
|
|
884
|
+
steeringPrompt: preliminaryPrompt,
|
|
885
|
+
final: input.final
|
|
886
|
+
});
|
|
887
|
+
const steeringPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts });
|
|
888
|
+
writeFileSync(artifacts.steeringPromptPath, steeringPrompt, "utf8");
|
|
889
|
+
return { ...base, artifacts, steeringPrompt };
|
|
890
|
+
}
|
|
891
|
+
|
|
2
892
|
// packages/runtime/src/control-plane/native/pr-automation.ts
|
|
3
893
|
var UPLOADED_SNAPSHOT_PR_MARKER = "<!-- rig:uploaded-snapshot -->";
|
|
4
894
|
function positiveInt(value, fallback) {
|
|
@@ -6,7 +896,7 @@ function positiveInt(value, fallback) {
|
|
|
6
896
|
}
|
|
7
897
|
function resolvePrAutomationLimits(config) {
|
|
8
898
|
return {
|
|
9
|
-
maxPrFixIterations: positiveInt(config?.automation?.maxPrFixIterations,
|
|
899
|
+
maxPrFixIterations: positiveInt(config?.automation?.maxPrFixIterations, 100500)
|
|
10
900
|
};
|
|
11
901
|
}
|
|
12
902
|
function buildPrAutomationBody(input) {
|
|
@@ -24,24 +914,24 @@ function buildPrAutomationBody(input) {
|
|
|
24
914
|
return lines.join(`
|
|
25
915
|
`);
|
|
26
916
|
}
|
|
27
|
-
function
|
|
917
|
+
function wildcardToRegExp2(pattern) {
|
|
28
918
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
29
919
|
return new RegExp(`^${escaped}$`, "i");
|
|
30
920
|
}
|
|
31
|
-
function
|
|
32
|
-
return allowedFailures.some((pattern) =>
|
|
921
|
+
function isAllowedFailure2(name, allowedFailures) {
|
|
922
|
+
return allowedFailures.some((pattern) => wildcardToRegExp2(pattern).test(name));
|
|
33
923
|
}
|
|
34
|
-
function
|
|
924
|
+
function isPendingCheck2(check) {
|
|
35
925
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
36
926
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
37
927
|
return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(conclusion) || ["pending", "queued", "in_progress", "waiting", "requested", "expected"].includes(state);
|
|
38
928
|
}
|
|
39
|
-
function
|
|
929
|
+
function isPassingCheck2(check) {
|
|
40
930
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
41
931
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
42
932
|
return ["success", "successful", "passed", "neutral", "skipped"].includes(conclusion) || ["success", "successful", "passed", "completed"].includes(state);
|
|
43
933
|
}
|
|
44
|
-
function
|
|
934
|
+
function isFailingCheck2(check) {
|
|
45
935
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
46
936
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
47
937
|
return ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(conclusion) || ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(state);
|
|
@@ -51,9 +941,9 @@ function collectPendingPrChecks(input) {
|
|
|
51
941
|
const pending = [];
|
|
52
942
|
for (const check of input.checks ?? []) {
|
|
53
943
|
const name = check.name.trim();
|
|
54
|
-
if (!name ||
|
|
944
|
+
if (!name || isAllowedFailure2(name, allowedFailures))
|
|
55
945
|
continue;
|
|
56
|
-
if (
|
|
946
|
+
if (isPendingCheck2(check) && !isPassingCheck2(check))
|
|
57
947
|
pending.push(name);
|
|
58
948
|
}
|
|
59
949
|
return pending;
|
|
@@ -63,7 +953,7 @@ function collectActionablePrFeedback(input) {
|
|
|
63
953
|
const feedback = [];
|
|
64
954
|
for (const check of input.checks ?? []) {
|
|
65
955
|
const name = check.name.trim();
|
|
66
|
-
if (!name || !
|
|
956
|
+
if (!name || !isFailingCheck2(check) || isAllowedFailure2(name, allowedFailures))
|
|
67
957
|
continue;
|
|
68
958
|
feedback.push(`Check failed: ${name}${check.detailsUrl ? ` (${check.detailsUrl})` : ""}`);
|
|
69
959
|
}
|
|
@@ -77,7 +967,7 @@ function collectActionablePrFeedback(input) {
|
|
|
77
967
|
}
|
|
78
968
|
return feedback;
|
|
79
969
|
}
|
|
80
|
-
function
|
|
970
|
+
function parseJsonArray2(value) {
|
|
81
971
|
if (!value?.trim())
|
|
82
972
|
return [];
|
|
83
973
|
try {
|
|
@@ -88,7 +978,7 @@ function parseJsonArray(value) {
|
|
|
88
978
|
}
|
|
89
979
|
}
|
|
90
980
|
function parsePrChecks(value) {
|
|
91
|
-
return
|
|
981
|
+
return parseJsonArray2(value).flatMap((entry) => {
|
|
92
982
|
if (!entry || typeof entry !== "object")
|
|
93
983
|
return [];
|
|
94
984
|
const record = entry;
|
|
@@ -335,6 +1225,10 @@ async function runRepoDefaultMerge(input) {
|
|
|
335
1225
|
const merge = input.config?.merge ?? {};
|
|
336
1226
|
if (merge.mode === "off")
|
|
337
1227
|
return;
|
|
1228
|
+
const matchHeadSha = input.matchHeadSha?.trim();
|
|
1229
|
+
if (!matchHeadSha) {
|
|
1230
|
+
throw new Error(`Refusing to merge ${input.prUrl}: strict merge gate did not provide a current head SHA.`);
|
|
1231
|
+
}
|
|
338
1232
|
const method = merge.method ?? "repo-default";
|
|
339
1233
|
const args = ["pr", "merge", input.prUrl];
|
|
340
1234
|
if (method === "repo-default") {
|
|
@@ -342,6 +1236,7 @@ async function runRepoDefaultMerge(input) {
|
|
|
342
1236
|
} else {
|
|
343
1237
|
args.push(`--${method}`);
|
|
344
1238
|
}
|
|
1239
|
+
args.push("--match-head-commit", matchHeadSha);
|
|
345
1240
|
if (merge.deleteBranch === true) {
|
|
346
1241
|
args.push("--delete-branch");
|
|
347
1242
|
}
|
|
@@ -350,6 +1245,23 @@ async function runRepoDefaultMerge(input) {
|
|
|
350
1245
|
}
|
|
351
1246
|
await runChecked(input.command, args, input.cwd);
|
|
352
1247
|
}
|
|
1248
|
+
function shouldAttemptRigMerge(config) {
|
|
1249
|
+
const mode = config?.merge?.mode;
|
|
1250
|
+
return mode !== "off" && mode !== "pr-ready";
|
|
1251
|
+
}
|
|
1252
|
+
function isPendingOnlyGate(result) {
|
|
1253
|
+
if (!result.pending)
|
|
1254
|
+
return false;
|
|
1255
|
+
const evidence = result.evidence;
|
|
1256
|
+
if (evidence.readErrors.length > 0 || evidence.checkFailures.length > 0)
|
|
1257
|
+
return false;
|
|
1258
|
+
if (evidence.greptile.blockers.length > 0 || evidence.greptile.unresolvedComments.length > 0)
|
|
1259
|
+
return false;
|
|
1260
|
+
const conflictingScore = evidence.greptile.signals.some((signal) => (!signal.reviewedSha || signal.reviewedSha === evidence.headSha) && signal.score && !(signal.score.scale === 5 && signal.score.value === 5));
|
|
1261
|
+
if (conflictingScore)
|
|
1262
|
+
return false;
|
|
1263
|
+
return evidence.pendingChecks.length > 0 || !evidence.greptile.completed || evidence.greptile.mapping === "missing" || evidence.greptile.mapping === "stale" || evidence.greptile.mapping === "unproven";
|
|
1264
|
+
}
|
|
353
1265
|
async function runPrAutomation(input) {
|
|
354
1266
|
const prConfig = input.config?.pr ?? {};
|
|
355
1267
|
if (prConfig.mode === "off" || prConfig.mode === "ask") {
|
|
@@ -388,45 +1300,103 @@ ${createResult.stdout ?? ""}`) : null;
|
|
|
388
1300
|
await input.lifecycle?.onPrOpened?.({ prUrl });
|
|
389
1301
|
const { maxPrFixIterations } = resolvePrAutomationLimits(input.config);
|
|
390
1302
|
let latestFeedback = [];
|
|
1303
|
+
let pendingElapsedMs = 0;
|
|
1304
|
+
const shouldMerge = shouldAttemptRigMerge(input.config);
|
|
391
1305
|
for (let iteration = 1;iteration <= maxPrFixIterations; iteration += 1) {
|
|
392
1306
|
await input.lifecycle?.onReviewCiStarted?.({ prUrl, iteration });
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1307
|
+
if (!shouldMerge) {
|
|
1308
|
+
const checks = prConfig.watchChecks === false ? [] : await readPrChecks({ prUrl, command: input.command, cwd: input.projectRoot });
|
|
1309
|
+
const reviewThreads = prConfig.autoFixReview === false ? [] : parsePrViewReviewThreads((await runChecked(input.command, ["pr", "view", prUrl, "--json", "reviewDecision,reviews"], input.projectRoot)).stdout);
|
|
1310
|
+
latestFeedback = collectActionablePrFeedback({
|
|
1311
|
+
checks,
|
|
1312
|
+
reviewThreads,
|
|
1313
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? []
|
|
1314
|
+
});
|
|
1315
|
+
const pendingChecks = collectPendingPrChecks({ checks, allowedFailures: input.config?.merge?.allowedFailures ?? [] });
|
|
1316
|
+
if (latestFeedback.length === 0 && pendingChecks.length > 0) {
|
|
1317
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
1318
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
1319
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
1320
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: pendingChecks.map((name) => `Check still pending: ${name}`), merged: false };
|
|
1321
|
+
}
|
|
1322
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
1323
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
1324
|
+
pendingElapsedMs += sleepMs;
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
if (latestFeedback.length === 0) {
|
|
1328
|
+
pendingElapsedMs = 0;
|
|
1329
|
+
return { status: "opened", prUrl, iterations: iteration, actionableFeedback: [], merged: false };
|
|
1330
|
+
}
|
|
1331
|
+
pendingElapsedMs = 0;
|
|
1332
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
1333
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
1334
|
+
}
|
|
1335
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
1336
|
+
await input.steerPi([
|
|
1337
|
+
`PR automation found actionable feedback on ${prUrl}.`,
|
|
1338
|
+
`Fix iteration ${iteration + 1}/${maxPrFixIterations}.`,
|
|
1339
|
+
"",
|
|
1340
|
+
...latestFeedback.map((entry) => `- ${entry}`)
|
|
1341
|
+
].join(`
|
|
1342
|
+
`));
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
const gate = await runStrictPrMergeGate({
|
|
1346
|
+
projectRoot: input.projectRoot,
|
|
1347
|
+
prUrl,
|
|
1348
|
+
taskId: input.taskId,
|
|
1349
|
+
runId: input.runId,
|
|
1350
|
+
cycle: iteration,
|
|
1351
|
+
command: input.command,
|
|
1352
|
+
artifactRoot: input.artifactRoot,
|
|
398
1353
|
allowedFailures: input.config?.merge?.allowedFailures ?? []
|
|
399
1354
|
});
|
|
400
|
-
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
1355
|
+
latestFeedback = [...gate.actionableFeedback];
|
|
1356
|
+
if (gate.approved) {
|
|
1357
|
+
pendingElapsedMs = 0;
|
|
1358
|
+
const finalGate = await runStrictPrMergeGate({
|
|
1359
|
+
projectRoot: input.projectRoot,
|
|
1360
|
+
prUrl,
|
|
1361
|
+
taskId: input.taskId,
|
|
1362
|
+
runId: input.runId,
|
|
1363
|
+
cycle: iteration,
|
|
1364
|
+
command: input.command,
|
|
1365
|
+
artifactRoot: input.artifactRoot,
|
|
1366
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? [],
|
|
1367
|
+
final: true
|
|
1368
|
+
});
|
|
1369
|
+
if (finalGate.approved) {
|
|
1370
|
+
await input.lifecycle?.onMergeStarted?.({ prUrl });
|
|
1371
|
+
await runRepoDefaultMerge({ prUrl, config: input.config, command: input.command, cwd: input.projectRoot, matchHeadSha: finalGate.evidence.headSha });
|
|
1372
|
+
await input.lifecycle?.onMerged?.({ prUrl });
|
|
1373
|
+
return { status: "merged", prUrl, iterations: iteration, actionableFeedback: [], merged: true };
|
|
406
1374
|
}
|
|
407
|
-
|
|
1375
|
+
latestFeedback = [...finalGate.actionableFeedback];
|
|
1376
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
1377
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
1378
|
+
}
|
|
1379
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
1380
|
+
await input.steerPi(finalGate.steeringPrompt);
|
|
408
1381
|
continue;
|
|
409
1382
|
}
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
1383
|
+
if (isPendingOnlyGate(gate)) {
|
|
1384
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
1385
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
1386
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
1387
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
413
1388
|
}
|
|
414
|
-
|
|
415
|
-
await
|
|
416
|
-
|
|
417
|
-
|
|
1389
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
1390
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
1391
|
+
pendingElapsedMs += sleepMs;
|
|
1392
|
+
continue;
|
|
418
1393
|
}
|
|
1394
|
+
pendingElapsedMs = 0;
|
|
419
1395
|
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
420
1396
|
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
421
1397
|
}
|
|
422
1398
|
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
423
|
-
await input.steerPi(
|
|
424
|
-
`PR automation found actionable feedback on ${prUrl}.`,
|
|
425
|
-
`Fix iteration ${iteration + 1}/${maxPrFixIterations}.`,
|
|
426
|
-
"",
|
|
427
|
-
...latestFeedback.map((entry) => `- ${entry}`)
|
|
428
|
-
].join(`
|
|
429
|
-
`));
|
|
1399
|
+
await input.steerPi(gate.steeringPrompt);
|
|
430
1400
|
}
|
|
431
1401
|
return { status: "needs_attention", prUrl, iterations: maxPrFixIterations, actionableFeedback: latestFeedback, merged: false };
|
|
432
1402
|
}
|