@h-rig/runtime 0.0.6-alpha.3 → 0.0.6-alpha.30
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 +1165 -785
- package/dist/bin/rig-agent.js +458 -389
- package/dist/src/control-plane/agent-wrapper.js +1191 -504
- package/dist/src/control-plane/authority-files.js +12 -6
- package/dist/src/control-plane/harness-main.js +2186 -1786
- package/dist/src/control-plane/hooks/completion-verification.js +2084 -1019
- package/dist/src/control-plane/hooks/inject-context.js +193 -139
- package/dist/src/control-plane/hooks/submodule-branch.js +603 -545
- package/dist/src/control-plane/hooks/task-runtime-start.js +603 -545
- package/dist/src/control-plane/materialize-task-config.js +64 -8
- package/dist/src/control-plane/native/git-ops.js +90 -64
- package/dist/src/control-plane/native/harness-cli.js +1989 -682
- package/dist/src/control-plane/native/pr-automation.js +1657 -54
- package/dist/src/control-plane/native/pr-review-gate.js +1455 -0
- package/dist/src/control-plane/native/repo-ops.js +3 -0
- package/dist/src/control-plane/native/run-ops.js +39 -13
- package/dist/src/control-plane/native/task-ops.js +1819 -527
- package/dist/src/control-plane/native/validator.js +163 -109
- package/dist/src/control-plane/native/verifier.js +1616 -323
- package/dist/src/control-plane/native/workspace-ops.js +12 -6
- package/dist/src/control-plane/pi-sessiond/bin.js +793 -0
- package/dist/src/control-plane/pi-sessiond/client.js +41 -0
- package/dist/src/control-plane/pi-sessiond/event-hub.js +59 -0
- package/dist/src/control-plane/pi-sessiond/extension-ui-context.js +198 -0
- package/dist/src/control-plane/pi-sessiond/launcher.js +173 -0
- package/dist/src/control-plane/pi-sessiond/server.js +802 -0
- package/dist/src/control-plane/pi-sessiond/session-service.js +540 -0
- package/dist/src/control-plane/pi-sessiond/types.js +1 -0
- package/dist/src/control-plane/plugin-host-context.js +54 -0
- package/dist/src/control-plane/runtime/image/fingerprint-sidecar.js +3 -0
- package/dist/src/control-plane/runtime/image/index.js +3 -0
- package/dist/src/control-plane/runtime/image-fingerprint-sidecar.js +3 -0
- package/dist/src/control-plane/runtime/image.js +3 -0
- package/dist/src/control-plane/runtime/index.js +517 -722
- package/dist/src/control-plane/runtime/isolation/home.js +28 -6
- package/dist/src/control-plane/runtime/isolation/index.js +541 -461
- package/dist/src/control-plane/runtime/isolation/runner.js +28 -6
- package/dist/src/control-plane/runtime/isolation/shared.js +9 -6
- package/dist/src/control-plane/runtime/isolation.js +541 -461
- package/dist/src/control-plane/runtime/plugin-mode.js +3 -27
- package/dist/src/control-plane/runtime/queue.js +458 -385
- package/dist/src/control-plane/runtime/snapshot/task-run.js +3 -0
- package/dist/src/control-plane/runtime/task-run-snapshot.js +3 -0
- package/dist/src/control-plane/skill-materializer.js +46 -0
- package/dist/src/control-plane/tasks/source-aware-task-config-source.js +14 -2
- package/dist/src/control-plane/tasks/source-lifecycle.js +86 -32
- package/dist/src/index.js +27 -298
- package/dist/src/layout.js +12 -7
- package/dist/src/local-server.js +20 -14
- 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 +8 -6
- package/dist/src/control-plane/runtime/plugins.js +0 -1131
- package/dist/src/plugins.js +0 -329
|
@@ -1,4 +1,1443 @@
|
|
|
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
|
+
|
|
6
|
+
// packages/runtime/src/control-plane/runtime/baked-secrets.ts
|
|
7
|
+
var BAKED_RUNTIME_SECRETS = {
|
|
8
|
+
ANTHROPIC_API_KEY: typeof RIG_BAKED_ANTHROPIC_API_KEY !== "undefined" ? RIG_BAKED_ANTHROPIC_API_KEY : "",
|
|
9
|
+
OPENAI_API_KEY: typeof RIG_BAKED_OPENAI_API_KEY !== "undefined" ? RIG_BAKED_OPENAI_API_KEY : "",
|
|
10
|
+
OPENROUTER_API_KEY: typeof RIG_BAKED_OPENROUTER_API_KEY !== "undefined" ? RIG_BAKED_OPENROUTER_API_KEY : "",
|
|
11
|
+
AI_REVIEW_MODE: typeof RIG_BAKED_AI_REVIEW_MODE !== "undefined" ? RIG_BAKED_AI_REVIEW_MODE : "",
|
|
12
|
+
AI_REVIEW_PROVIDER: typeof RIG_BAKED_AI_REVIEW_PROVIDER !== "undefined" ? RIG_BAKED_AI_REVIEW_PROVIDER : "",
|
|
13
|
+
GREPTILE_API_BASE: typeof RIG_BAKED_GREPTILE_API_BASE !== "undefined" ? RIG_BAKED_GREPTILE_API_BASE : "",
|
|
14
|
+
GREPTILE_REMOTE: typeof RIG_BAKED_GREPTILE_REMOTE !== "undefined" ? RIG_BAKED_GREPTILE_REMOTE : "",
|
|
15
|
+
GREPTILE_REPOSITORY: typeof RIG_BAKED_GREPTILE_REPOSITORY !== "undefined" ? RIG_BAKED_GREPTILE_REPOSITORY : "",
|
|
16
|
+
GREPTILE_CONTEXT_BRANCH: typeof RIG_BAKED_GREPTILE_CONTEXT_BRANCH !== "undefined" ? RIG_BAKED_GREPTILE_CONTEXT_BRANCH : "",
|
|
17
|
+
GREPTILE_DEFAULT_BRANCH: typeof RIG_BAKED_GREPTILE_DEFAULT_BRANCH !== "undefined" ? RIG_BAKED_GREPTILE_DEFAULT_BRANCH : "",
|
|
18
|
+
GREPTILE_API_KEY: typeof RIG_BAKED_GREPTILE_API_KEY !== "undefined" ? RIG_BAKED_GREPTILE_API_KEY : "",
|
|
19
|
+
GREPTILE_GITHUB_TOKEN: typeof RIG_BAKED_GREPTILE_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GREPTILE_GITHUB_TOKEN : "",
|
|
20
|
+
GREPTILE_POLL_ATTEMPTS: typeof RIG_BAKED_GREPTILE_POLL_ATTEMPTS !== "undefined" ? RIG_BAKED_GREPTILE_POLL_ATTEMPTS : "",
|
|
21
|
+
GREPTILE_POLL_INTERVAL_MS: typeof RIG_BAKED_GREPTILE_POLL_INTERVAL_MS !== "undefined" ? RIG_BAKED_GREPTILE_POLL_INTERVAL_MS : "",
|
|
22
|
+
GH_TOKEN: typeof RIG_BAKED_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GITHUB_TOKEN : "",
|
|
23
|
+
GITHUB_TOKEN: typeof RIG_BAKED_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GITHUB_TOKEN : "",
|
|
24
|
+
GITHUB_SSH_KEY: typeof RIG_BAKED_GITHUB_SSH_KEY !== "undefined" ? RIG_BAKED_GITHUB_SSH_KEY : "",
|
|
25
|
+
AWS_ACCESS_KEY_ID: typeof RIG_BAKED_AWS_ACCESS_KEY_ID !== "undefined" ? RIG_BAKED_AWS_ACCESS_KEY_ID : "",
|
|
26
|
+
AWS_SECRET_ACCESS_KEY: typeof RIG_BAKED_AWS_SECRET_ACCESS_KEY !== "undefined" ? RIG_BAKED_AWS_SECRET_ACCESS_KEY : "",
|
|
27
|
+
AWS_REGION: typeof RIG_BAKED_AWS_REGION !== "undefined" ? RIG_BAKED_AWS_REGION : "",
|
|
28
|
+
LINEAR_API_KEY: typeof RIG_BAKED_LINEAR_API_KEY !== "undefined" ? RIG_BAKED_LINEAR_API_KEY : "",
|
|
29
|
+
LINEAR_WEBHOOK_SECRET: typeof RIG_BAKED_LINEAR_WEBHOOK_SECRET !== "undefined" ? RIG_BAKED_LINEAR_WEBHOOK_SECRET : ""
|
|
30
|
+
};
|
|
31
|
+
function resolveRuntimeSecrets(env, baked = BAKED_RUNTIME_SECRETS) {
|
|
32
|
+
const resolved = {};
|
|
33
|
+
const keys = new Set([
|
|
34
|
+
...Object.keys(BAKED_RUNTIME_SECRETS),
|
|
35
|
+
...Object.keys(baked)
|
|
36
|
+
]);
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
const envValue = env[key]?.trim();
|
|
39
|
+
const bakedValue = baked[key]?.trim();
|
|
40
|
+
if (envValue) {
|
|
41
|
+
resolved[key] = envValue;
|
|
42
|
+
} else if (bakedValue) {
|
|
43
|
+
resolved[key] = bakedValue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// packages/runtime/src/control-plane/native/pr-review-gate.ts
|
|
50
|
+
function parseJsonObject(value) {
|
|
51
|
+
if (!value?.trim())
|
|
52
|
+
return { value: {}, error: "empty JSON output" };
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(value);
|
|
55
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { value: {}, error: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function flattenPaginatedArray(value) {
|
|
61
|
+
if (!Array.isArray(value))
|
|
62
|
+
return null;
|
|
63
|
+
if (value.every((entry) => Array.isArray(entry))) {
|
|
64
|
+
return value.flatMap((entry) => entry);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function parseConcatenatedJsonValues(value) {
|
|
69
|
+
const text = value.trim();
|
|
70
|
+
const docs = [];
|
|
71
|
+
let start = null;
|
|
72
|
+
let depth = 0;
|
|
73
|
+
let inString = false;
|
|
74
|
+
let escape = false;
|
|
75
|
+
for (let index = 0;index < text.length; index += 1) {
|
|
76
|
+
const char = text[index];
|
|
77
|
+
if (start === null) {
|
|
78
|
+
if (/\s/.test(char))
|
|
79
|
+
continue;
|
|
80
|
+
start = index;
|
|
81
|
+
}
|
|
82
|
+
if (inString) {
|
|
83
|
+
if (escape) {
|
|
84
|
+
escape = false;
|
|
85
|
+
} else if (char === "\\") {
|
|
86
|
+
escape = true;
|
|
87
|
+
} else if (char === '"') {
|
|
88
|
+
inString = false;
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === '"') {
|
|
93
|
+
inString = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === "{" || char === "[") {
|
|
97
|
+
depth += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (char === "}" || char === "]") {
|
|
101
|
+
depth -= 1;
|
|
102
|
+
if (depth < 0)
|
|
103
|
+
return { value: docs, error: "unexpected JSON close delimiter" };
|
|
104
|
+
if (depth === 0 && start !== null) {
|
|
105
|
+
const segment = text.slice(start, index + 1);
|
|
106
|
+
try {
|
|
107
|
+
docs.push(JSON.parse(segment));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return { value: docs, error: error instanceof Error ? error.message : String(error) };
|
|
110
|
+
}
|
|
111
|
+
start = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (inString || depth !== 0 || start !== null)
|
|
116
|
+
return { value: docs, error: "incomplete JSON stream" };
|
|
117
|
+
return { value: docs };
|
|
118
|
+
}
|
|
119
|
+
function parseJsonArray(value) {
|
|
120
|
+
if (!value?.trim())
|
|
121
|
+
return { value: [], error: "empty JSON output" };
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(value);
|
|
124
|
+
const flattened = flattenPaginatedArray(parsed);
|
|
125
|
+
return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const streamed = parseConcatenatedJsonValues(value);
|
|
128
|
+
if (streamed.error)
|
|
129
|
+
return { value: [], error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
const flattened = streamed.value.flatMap((entry) => flattenPaginatedArray(entry) ?? []);
|
|
131
|
+
return flattened.length > 0 || streamed.value.length === 0 ? { value: flattened } : { value: [], error: "JSON output was not an array" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function parseGithubPrUrl(prUrl) {
|
|
135
|
+
const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
|
|
136
|
+
if (!match)
|
|
137
|
+
return null;
|
|
138
|
+
const prNumber = Number.parseInt(match[3], 10);
|
|
139
|
+
if (!Number.isFinite(prNumber))
|
|
140
|
+
return null;
|
|
141
|
+
return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
|
|
142
|
+
}
|
|
143
|
+
function checkName(check) {
|
|
144
|
+
return String(check.name ?? check.context ?? "").trim();
|
|
145
|
+
}
|
|
146
|
+
function checkState(check) {
|
|
147
|
+
return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
function isGreptileLabel(value) {
|
|
150
|
+
return String(value ?? "").toLowerCase().includes("greptile");
|
|
151
|
+
}
|
|
152
|
+
function isGreptileGithubLogin(value) {
|
|
153
|
+
const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
|
|
154
|
+
return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
|
|
155
|
+
}
|
|
156
|
+
function isPassingCheck(check) {
|
|
157
|
+
const state = checkState(check);
|
|
158
|
+
return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
|
|
159
|
+
}
|
|
160
|
+
function isPendingCheck(check) {
|
|
161
|
+
const state = checkState(check);
|
|
162
|
+
return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
|
|
163
|
+
}
|
|
164
|
+
function isFailingCheck(check) {
|
|
165
|
+
const state = checkState(check);
|
|
166
|
+
return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
|
|
167
|
+
}
|
|
168
|
+
function wildcardToRegExp(pattern) {
|
|
169
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
170
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
171
|
+
}
|
|
172
|
+
function isAllowedFailure(name, allowedFailures) {
|
|
173
|
+
return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
|
|
174
|
+
}
|
|
175
|
+
function greptileScorePatterns() {
|
|
176
|
+
return [
|
|
177
|
+
/\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
|
|
178
|
+
/\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
|
|
179
|
+
/\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
function parseGreptileScores(input) {
|
|
183
|
+
const text = stripHtml(input);
|
|
184
|
+
const seen = new Set;
|
|
185
|
+
const scores = [];
|
|
186
|
+
for (const pattern of greptileScorePatterns()) {
|
|
187
|
+
for (const match of text.matchAll(pattern)) {
|
|
188
|
+
const value = Number.parseInt(match[1] || "", 10);
|
|
189
|
+
const scale = Number.parseInt(match[2] || "", 10);
|
|
190
|
+
if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
|
|
191
|
+
continue;
|
|
192
|
+
const raw = match[0] || `${value}/${scale}`;
|
|
193
|
+
const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
|
|
194
|
+
if (seen.has(key))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(key);
|
|
197
|
+
scores.push({ value, scale, raw });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return scores;
|
|
201
|
+
}
|
|
202
|
+
function stripHtml(input) {
|
|
203
|
+
return input.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
|
|
204
|
+
|
|
205
|
+
`).trim();
|
|
206
|
+
}
|
|
207
|
+
function containsBlockerText(input) {
|
|
208
|
+
const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
|
|
209
|
+
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|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(text);
|
|
210
|
+
}
|
|
211
|
+
function isStrictFiveOfFive(score) {
|
|
212
|
+
return score.value === 5 && score.scale === 5;
|
|
213
|
+
}
|
|
214
|
+
function containsConflictingScoreText(input) {
|
|
215
|
+
return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
|
|
216
|
+
}
|
|
217
|
+
function extractGreptileCommentBlock(input) {
|
|
218
|
+
const match = input.match(/<!--\s*greptile_comment\s*-->([\s\S]*?)<!--\s*\/greptile_comment\s*-->/i);
|
|
219
|
+
return match?.[1]?.trim() ?? null;
|
|
220
|
+
}
|
|
221
|
+
function extractGreptileBodyReviewedSha(input) {
|
|
222
|
+
const block = extractGreptileCommentBlock(input);
|
|
223
|
+
if (!block)
|
|
224
|
+
return null;
|
|
225
|
+
const commitLink = block.match(/\/commit\/([0-9a-f]{40})(?:\b|[^0-9a-f])/i);
|
|
226
|
+
return commitLink?.[1]?.toLowerCase() ?? null;
|
|
227
|
+
}
|
|
228
|
+
function isoAtOrAfter(value, floor) {
|
|
229
|
+
if (!value || !floor)
|
|
230
|
+
return false;
|
|
231
|
+
const valueMs = Date.parse(value);
|
|
232
|
+
const floorMs = Date.parse(floor);
|
|
233
|
+
return Number.isFinite(valueMs) && Number.isFinite(floorMs) && valueMs >= floorMs;
|
|
234
|
+
}
|
|
235
|
+
function greptileStatusVerdict(status) {
|
|
236
|
+
const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
237
|
+
if (!normalized)
|
|
238
|
+
return null;
|
|
239
|
+
if (["APPROVE", "APPROVED"].includes(normalized))
|
|
240
|
+
return "approved";
|
|
241
|
+
if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
|
|
242
|
+
return "rejected";
|
|
243
|
+
if (["SKIP", "SKIPPED"].includes(normalized))
|
|
244
|
+
return "skipped";
|
|
245
|
+
if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
|
|
246
|
+
return "failed";
|
|
247
|
+
if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
|
|
248
|
+
return "pending";
|
|
249
|
+
if (["COMPLETE", "COMPLETED"].includes(normalized))
|
|
250
|
+
return "completed";
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
function isBlockingGreptileVerdict(verdict) {
|
|
254
|
+
return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
|
|
255
|
+
}
|
|
256
|
+
function greptileRequestTimeoutMs(env) {
|
|
257
|
+
const fallback = 30000;
|
|
258
|
+
const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
|
|
259
|
+
return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
|
|
260
|
+
}
|
|
261
|
+
function normalizeGreptileMcpCodeReview(entry, fallbackId) {
|
|
262
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
263
|
+
return null;
|
|
264
|
+
const record = entry;
|
|
265
|
+
const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
|
|
266
|
+
if (!id)
|
|
267
|
+
return null;
|
|
268
|
+
const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
|
|
269
|
+
return {
|
|
270
|
+
id,
|
|
271
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
272
|
+
createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
|
|
273
|
+
body: typeof record.body === "string" ? record.body : null,
|
|
274
|
+
metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function uniqueGreptileCodeReviews(reviews) {
|
|
278
|
+
const seen = new Set;
|
|
279
|
+
const unique = [];
|
|
280
|
+
for (const review of reviews) {
|
|
281
|
+
if (seen.has(review.id))
|
|
282
|
+
continue;
|
|
283
|
+
seen.add(review.id);
|
|
284
|
+
unique.push(review);
|
|
285
|
+
}
|
|
286
|
+
return unique;
|
|
287
|
+
}
|
|
288
|
+
function selectGreptileApiReviewsForGate(reviews, headSha) {
|
|
289
|
+
const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
|
|
290
|
+
const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
|
|
291
|
+
const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
|
|
292
|
+
const latest = sorted.slice(0, 1);
|
|
293
|
+
return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
|
|
294
|
+
}
|
|
295
|
+
function greptileApiSignalFromCodeReview(review, details) {
|
|
296
|
+
const selected = details ?? review;
|
|
297
|
+
return {
|
|
298
|
+
id: selected.id || review.id,
|
|
299
|
+
body: selected.body ?? review.body ?? null,
|
|
300
|
+
reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
|
|
301
|
+
status: selected.status ?? review.status ?? null
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async function callGreptileMcpToolForGate(input) {
|
|
305
|
+
const controller = new AbortController;
|
|
306
|
+
const timeoutId = setTimeout(() => {
|
|
307
|
+
controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
|
|
308
|
+
}, input.timeoutMs);
|
|
309
|
+
let response;
|
|
310
|
+
try {
|
|
311
|
+
response = await input.fetchFn(input.apiBase, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: {
|
|
314
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
315
|
+
"Content-Type": "application/json"
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
jsonrpc: "2.0",
|
|
319
|
+
id: `rig-strict-gate-${input.name}-${Date.now()}`,
|
|
320
|
+
method: "tools/call",
|
|
321
|
+
params: { name: input.name, arguments: input.args }
|
|
322
|
+
}),
|
|
323
|
+
signal: controller.signal
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (controller.signal.aborted) {
|
|
327
|
+
throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
} finally {
|
|
331
|
+
clearTimeout(timeoutId);
|
|
332
|
+
}
|
|
333
|
+
const raw = await response.text();
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
throw new Error(`HTTP ${response.status}: ${raw}`);
|
|
336
|
+
}
|
|
337
|
+
let envelope;
|
|
338
|
+
try {
|
|
339
|
+
envelope = JSON.parse(raw);
|
|
340
|
+
} catch {
|
|
341
|
+
throw new Error(`Malformed MCP response: ${raw}`);
|
|
342
|
+
}
|
|
343
|
+
if (envelope.error?.message) {
|
|
344
|
+
throw new Error(envelope.error.message);
|
|
345
|
+
}
|
|
346
|
+
const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
|
|
347
|
+
`).trim();
|
|
348
|
+
if (!text) {
|
|
349
|
+
throw new Error(`MCP tool ${input.name} returned no text payload.`);
|
|
350
|
+
}
|
|
351
|
+
return text;
|
|
352
|
+
}
|
|
353
|
+
async function callGreptileMcpToolJsonForGate(input) {
|
|
354
|
+
const text = await callGreptileMcpToolForGate(input);
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(text);
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function collectConfiguredGreptileApiSignals(input) {
|
|
362
|
+
if (!input.enabled || input.options?.enabled === false) {
|
|
363
|
+
return { signals: [], errors: [] };
|
|
364
|
+
}
|
|
365
|
+
const env = input.options?.env ?? process.env;
|
|
366
|
+
const secrets = resolveRuntimeSecrets(env);
|
|
367
|
+
const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
|
|
368
|
+
if (!apiKey) {
|
|
369
|
+
return { signals: [], errors: [] };
|
|
370
|
+
}
|
|
371
|
+
const fetchFn = input.options?.fetch ?? globalThis.fetch;
|
|
372
|
+
if (typeof fetchFn !== "function") {
|
|
373
|
+
return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
|
|
374
|
+
}
|
|
375
|
+
const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
|
|
376
|
+
const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
|
|
377
|
+
const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
|
|
378
|
+
const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
|
|
379
|
+
const timeoutMs = greptileRequestTimeoutMs(env);
|
|
380
|
+
try {
|
|
381
|
+
const listPayload = await callGreptileMcpToolJsonForGate({
|
|
382
|
+
apiBase,
|
|
383
|
+
apiKey,
|
|
384
|
+
name: "list_code_reviews",
|
|
385
|
+
args: {
|
|
386
|
+
name: repository,
|
|
387
|
+
remote,
|
|
388
|
+
defaultBranch,
|
|
389
|
+
prNumber: input.prNumber,
|
|
390
|
+
limit: 20
|
|
391
|
+
},
|
|
392
|
+
timeoutMs,
|
|
393
|
+
fetchFn
|
|
394
|
+
});
|
|
395
|
+
const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
|
|
396
|
+
const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
|
|
397
|
+
const signals = [];
|
|
398
|
+
for (const review of selectedReviews) {
|
|
399
|
+
const detailsPayload = await callGreptileMcpToolJsonForGate({
|
|
400
|
+
apiBase,
|
|
401
|
+
apiKey,
|
|
402
|
+
name: "get_code_review",
|
|
403
|
+
args: { codeReviewId: review.id },
|
|
404
|
+
timeoutMs,
|
|
405
|
+
fetchFn
|
|
406
|
+
});
|
|
407
|
+
const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
|
|
408
|
+
signals.push(greptileApiSignalFromCodeReview(review, details));
|
|
409
|
+
}
|
|
410
|
+
return { signals, errors: [] };
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return {
|
|
413
|
+
signals: [],
|
|
414
|
+
errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function firstString(record, keys) {
|
|
419
|
+
for (const key of keys) {
|
|
420
|
+
const value = record[key];
|
|
421
|
+
if (typeof value === "string")
|
|
422
|
+
return value;
|
|
423
|
+
}
|
|
424
|
+
return "";
|
|
425
|
+
}
|
|
426
|
+
function arrayField(record, key) {
|
|
427
|
+
const value = record[key];
|
|
428
|
+
return Array.isArray(value) ? value : [];
|
|
429
|
+
}
|
|
430
|
+
async function runJsonArray(command, args, cwd) {
|
|
431
|
+
const result = await command(args, { cwd });
|
|
432
|
+
const label = `gh ${args.join(" ")}`;
|
|
433
|
+
if (result.exitCode !== 0) {
|
|
434
|
+
return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
|
|
435
|
+
}
|
|
436
|
+
const parsed = parseJsonArray(result.stdout);
|
|
437
|
+
return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
|
|
438
|
+
}
|
|
439
|
+
async function runJsonObject(command, args, cwd) {
|
|
440
|
+
const result = await command(args, { cwd });
|
|
441
|
+
const label = `gh ${args.join(" ")}`;
|
|
442
|
+
if (result.exitCode !== 0) {
|
|
443
|
+
return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
|
|
444
|
+
}
|
|
445
|
+
const parsed = parseJsonObject(result.stdout);
|
|
446
|
+
return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
|
|
447
|
+
}
|
|
448
|
+
function normalizeStatusCheck(entry) {
|
|
449
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
450
|
+
return null;
|
|
451
|
+
const record = entry;
|
|
452
|
+
const name = firstString(record, ["name", "context"]);
|
|
453
|
+
if (!name.trim())
|
|
454
|
+
return null;
|
|
455
|
+
const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
|
|
456
|
+
const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
|
|
457
|
+
return {
|
|
458
|
+
__typename: typeof record.__typename === "string" ? record.__typename : null,
|
|
459
|
+
name,
|
|
460
|
+
context: typeof record.context === "string" ? record.context : null,
|
|
461
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
462
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
463
|
+
conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
|
|
464
|
+
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,
|
|
465
|
+
link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
|
|
466
|
+
headSha: typeof record.headSha === "string" ? record.headSha : null,
|
|
467
|
+
head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
|
|
468
|
+
output: output ? {
|
|
469
|
+
title: typeof output.title === "string" ? output.title : null,
|
|
470
|
+
summary: typeof output.summary === "string" ? output.summary : null,
|
|
471
|
+
text: typeof output.text === "string" ? output.text : null
|
|
472
|
+
} : null,
|
|
473
|
+
app: app ? {
|
|
474
|
+
slug: typeof app.slug === "string" ? app.slug : null,
|
|
475
|
+
name: typeof app.name === "string" ? app.name : null,
|
|
476
|
+
owner: app.owner && typeof app.owner === "object" ? app.owner : null
|
|
477
|
+
} : null
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function normalizeReview(entry) {
|
|
481
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
482
|
+
return null;
|
|
483
|
+
const record = entry;
|
|
484
|
+
return {
|
|
485
|
+
id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
|
|
486
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
487
|
+
body: typeof record.body === "string" ? record.body : null,
|
|
488
|
+
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,
|
|
489
|
+
html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
|
|
490
|
+
author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function normalizeReviewComment(entry) {
|
|
494
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
495
|
+
return null;
|
|
496
|
+
const record = entry;
|
|
497
|
+
const body = typeof record.body === "string" ? record.body : null;
|
|
498
|
+
const path = typeof record.path === "string" ? record.path : null;
|
|
499
|
+
if (!body && !path)
|
|
500
|
+
return null;
|
|
501
|
+
return {
|
|
502
|
+
id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
|
|
503
|
+
user: record.user && typeof record.user === "object" ? record.user : null,
|
|
504
|
+
author: record.author && typeof record.author === "object" ? record.author : null,
|
|
505
|
+
body,
|
|
506
|
+
path,
|
|
507
|
+
html_url: typeof record.html_url === "string" ? record.html_url : null,
|
|
508
|
+
url: typeof record.url === "string" ? record.url : null,
|
|
509
|
+
commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
|
|
510
|
+
original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function normalizeIssueComment(entry) {
|
|
514
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
515
|
+
return null;
|
|
516
|
+
const record = entry;
|
|
517
|
+
const body = typeof record.body === "string" ? record.body : null;
|
|
518
|
+
if (!body)
|
|
519
|
+
return null;
|
|
520
|
+
return {
|
|
521
|
+
id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
|
|
522
|
+
user: record.user && typeof record.user === "object" ? record.user : null,
|
|
523
|
+
author: record.author && typeof record.author === "object" ? record.author : null,
|
|
524
|
+
body,
|
|
525
|
+
html_url: typeof record.html_url === "string" ? record.html_url : null,
|
|
526
|
+
url: typeof record.url === "string" ? record.url : null,
|
|
527
|
+
created_at: typeof record.created_at === "string" ? record.created_at : null
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function normalizeReviewThread(entry) {
|
|
531
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
532
|
+
return null;
|
|
533
|
+
const record = entry;
|
|
534
|
+
return {
|
|
535
|
+
id: typeof record.id === "string" ? record.id : null,
|
|
536
|
+
isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
|
|
537
|
+
isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
|
|
538
|
+
comments: record.comments && typeof record.comments === "object" ? record.comments : null
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function relevantIssueComment(comment) {
|
|
542
|
+
const login = comment.user?.login ?? comment.author?.login ?? "";
|
|
543
|
+
const body = comment.body ?? "";
|
|
544
|
+
return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
|
|
545
|
+
}
|
|
546
|
+
function latestThreadComment(thread) {
|
|
547
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
548
|
+
return nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
|
549
|
+
}
|
|
550
|
+
function unresolvedThreadSummaries(threads) {
|
|
551
|
+
return threads.flatMap((thread) => {
|
|
552
|
+
if (thread.isResolved === true || thread.isOutdated === true)
|
|
553
|
+
return [];
|
|
554
|
+
const latest = latestThreadComment(thread);
|
|
555
|
+
if (!latest)
|
|
556
|
+
return ["Unresolved review thread"];
|
|
557
|
+
const path = latest.path ? ` on ${latest.path}` : "";
|
|
558
|
+
return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function collectBodies(evidence) {
|
|
562
|
+
return [
|
|
563
|
+
evidence.title ?? "",
|
|
564
|
+
evidence.body,
|
|
565
|
+
...evidence.reviews.map((review) => review.body ?? ""),
|
|
566
|
+
...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
|
|
567
|
+
...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
|
|
568
|
+
...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
|
|
569
|
+
...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
|
|
570
|
+
].filter((body) => body.trim().length > 0);
|
|
571
|
+
}
|
|
572
|
+
function bodyExcerpt(body) {
|
|
573
|
+
const text = stripHtml(body).replace(/\s+/g, " ").trim();
|
|
574
|
+
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
|
575
|
+
}
|
|
576
|
+
function makeGreptileSignal(input) {
|
|
577
|
+
const scores = parseGreptileScores(input.body);
|
|
578
|
+
const reviewedSha = input.reviewedSha?.trim() || null;
|
|
579
|
+
const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
|
|
580
|
+
const verdict = input.verdict ?? null;
|
|
581
|
+
const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
|
|
582
|
+
const explicitApproval = input.explicitApproval ?? false;
|
|
583
|
+
return {
|
|
584
|
+
source: input.source,
|
|
585
|
+
trusted: input.trusted,
|
|
586
|
+
authorLogin: input.authorLogin ?? null,
|
|
587
|
+
reviewedSha,
|
|
588
|
+
current,
|
|
589
|
+
stale: current === false,
|
|
590
|
+
score: scores[0] ?? null,
|
|
591
|
+
scores,
|
|
592
|
+
explicitApproval,
|
|
593
|
+
verdict,
|
|
594
|
+
blocker,
|
|
595
|
+
actionable: input.actionable ?? blocker,
|
|
596
|
+
bodyExcerpt: bodyExcerpt(input.body),
|
|
597
|
+
body: input.body,
|
|
598
|
+
allScores: scores
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function reviewAuthorLogin(review) {
|
|
602
|
+
return review.author?.login ?? null;
|
|
603
|
+
}
|
|
604
|
+
function commentAuthorLogin(comment) {
|
|
605
|
+
return comment.user?.login ?? comment.author?.login ?? null;
|
|
606
|
+
}
|
|
607
|
+
function collectGreptileSignals(evidence) {
|
|
608
|
+
const signals = [];
|
|
609
|
+
const greptileBodyReviewedSha = extractGreptileBodyReviewedSha(evidence.body);
|
|
610
|
+
const trustedGreptileBody = Boolean(greptileBodyReviewedSha && isGreptileGithubLogin(evidence.bodyEditorLogin) && isoAtOrAfter(evidence.bodyLastEditedAt, evidence.headCommittedDate));
|
|
611
|
+
const contextSources = [
|
|
612
|
+
{ source: "pr-title", body: evidence.title ?? "" },
|
|
613
|
+
{
|
|
614
|
+
source: "pr-body",
|
|
615
|
+
body: evidence.body,
|
|
616
|
+
trusted: trustedGreptileBody,
|
|
617
|
+
authorLogin: trustedGreptileBody ? evidence.bodyEditorLogin ?? null : null,
|
|
618
|
+
reviewedSha: greptileBodyReviewedSha,
|
|
619
|
+
verdict: trustedGreptileBody ? "completed" : null
|
|
620
|
+
}
|
|
621
|
+
];
|
|
622
|
+
for (const context of contextSources) {
|
|
623
|
+
if (!context.body.trim())
|
|
624
|
+
continue;
|
|
625
|
+
const contextBlocker = containsBlockerText(context.body);
|
|
626
|
+
if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
|
|
627
|
+
continue;
|
|
628
|
+
signals.push(makeGreptileSignal({
|
|
629
|
+
source: context.source,
|
|
630
|
+
body: context.body,
|
|
631
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
632
|
+
trusted: context.trusted === true,
|
|
633
|
+
authorLogin: context.authorLogin,
|
|
634
|
+
reviewedSha: context.reviewedSha,
|
|
635
|
+
verdict: context.verdict,
|
|
636
|
+
blocker: contextBlocker,
|
|
637
|
+
actionable: contextBlocker
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
for (const apiSignal of evidence.apiSignals ?? []) {
|
|
641
|
+
const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
|
|
642
|
+
|
|
643
|
+
`) || "Status: UNKNOWN";
|
|
644
|
+
const verdict = greptileStatusVerdict(apiSignal.status);
|
|
645
|
+
signals.push(makeGreptileSignal({
|
|
646
|
+
source: "api",
|
|
647
|
+
body,
|
|
648
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
649
|
+
trusted: true,
|
|
650
|
+
reviewedSha: apiSignal.reviewedSha ?? null,
|
|
651
|
+
explicitApproval: verdict === "approved",
|
|
652
|
+
verdict
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
for (const review of evidence.reviews) {
|
|
656
|
+
const login = reviewAuthorLogin(review);
|
|
657
|
+
if (!isGreptileGithubLogin(login))
|
|
658
|
+
continue;
|
|
659
|
+
const state = String(review.state ?? "").toUpperCase();
|
|
660
|
+
const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
|
|
661
|
+
|
|
662
|
+
`);
|
|
663
|
+
if (!body.trim())
|
|
664
|
+
continue;
|
|
665
|
+
const dismissed = state === "DISMISSED";
|
|
666
|
+
signals.push(makeGreptileSignal({
|
|
667
|
+
source: "github-review",
|
|
668
|
+
body,
|
|
669
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
670
|
+
trusted: !dismissed,
|
|
671
|
+
authorLogin: login,
|
|
672
|
+
reviewedSha: review.commit_id ?? null,
|
|
673
|
+
explicitApproval: undefined,
|
|
674
|
+
blocker: state === "CHANGES_REQUESTED" || undefined
|
|
675
|
+
}));
|
|
676
|
+
}
|
|
677
|
+
for (const comment of evidence.relevantIssueComments) {
|
|
678
|
+
const login = commentAuthorLogin(comment);
|
|
679
|
+
const body = comment.body ?? "";
|
|
680
|
+
if (!body.trim() || !isGreptileGithubLogin(login))
|
|
681
|
+
continue;
|
|
682
|
+
signals.push(makeGreptileSignal({
|
|
683
|
+
source: "issue-comment",
|
|
684
|
+
body,
|
|
685
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
686
|
+
trusted: true,
|
|
687
|
+
authorLogin: login
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
690
|
+
for (const thread of evidence.reviewThreads) {
|
|
691
|
+
if (thread.isOutdated === true || thread.isResolved === true)
|
|
692
|
+
continue;
|
|
693
|
+
for (const comment of thread.comments?.nodes ?? []) {
|
|
694
|
+
const login = comment.author?.login ?? null;
|
|
695
|
+
const body = comment.body ?? "";
|
|
696
|
+
if (!body.trim() || !isGreptileGithubLogin(login))
|
|
697
|
+
continue;
|
|
698
|
+
signals.push(makeGreptileSignal({
|
|
699
|
+
source: "review-thread",
|
|
700
|
+
body,
|
|
701
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
702
|
+
trusted: true,
|
|
703
|
+
authorLogin: login
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const check of evidence.checks) {
|
|
708
|
+
if (!isGreptileLabel(checkName(check)))
|
|
709
|
+
continue;
|
|
710
|
+
const reviewedSha = check.headSha ?? check.head_sha ?? null;
|
|
711
|
+
const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
|
|
712
|
+
const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
|
|
713
|
+
|
|
714
|
+
`);
|
|
715
|
+
signals.push(makeGreptileSignal({
|
|
716
|
+
source: "github-check",
|
|
717
|
+
body,
|
|
718
|
+
currentHeadSha: evidence.currentHeadSha,
|
|
719
|
+
trusted: false,
|
|
720
|
+
reviewedSha,
|
|
721
|
+
explicitApproval: false,
|
|
722
|
+
blocker: isFailingCheck(check),
|
|
723
|
+
actionable: isFailingCheck(check)
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
return signals;
|
|
727
|
+
}
|
|
728
|
+
function unresolvedGreptileThreadSummaries(threads) {
|
|
729
|
+
return threads.flatMap((thread) => {
|
|
730
|
+
if (thread.isResolved === true || thread.isOutdated === true)
|
|
731
|
+
return [];
|
|
732
|
+
const comments = thread.comments?.nodes ?? [];
|
|
733
|
+
if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
|
|
734
|
+
return [];
|
|
735
|
+
const latest = latestThreadComment(thread);
|
|
736
|
+
if (!latest)
|
|
737
|
+
return ["Unresolved Greptile review thread"];
|
|
738
|
+
const path = latest.path ? ` on ${latest.path}` : "";
|
|
739
|
+
return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
function actionableChangedFileCommentSummaries(_comments) {
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
function issueLevelBlockerSummaries(comments) {
|
|
746
|
+
return comments.flatMap((comment) => {
|
|
747
|
+
const body = comment.body?.trim() ?? "";
|
|
748
|
+
if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
|
|
749
|
+
return [];
|
|
750
|
+
const login = commentAuthorLogin(comment) ?? "unknown";
|
|
751
|
+
const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
|
|
752
|
+
return [`${author}: ${body}`];
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function reviewBodyBlockerSummaries(reviews) {
|
|
756
|
+
return reviews.flatMap((review) => {
|
|
757
|
+
const login = reviewAuthorLogin(review) ?? "unknown";
|
|
758
|
+
if (isGreptileGithubLogin(login))
|
|
759
|
+
return [];
|
|
760
|
+
const body = review.body?.trim() ?? "";
|
|
761
|
+
if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
|
|
762
|
+
return [];
|
|
763
|
+
const state = review.state ? ` (${review.state})` : "";
|
|
764
|
+
return [`PR review summary by ${login}${state}: ${body}`];
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function signalLabel(signal) {
|
|
768
|
+
const source = signal.source.replace(/-/g, " ");
|
|
769
|
+
const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
|
|
770
|
+
const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
|
|
771
|
+
return `${source}${author}${sha}`;
|
|
772
|
+
}
|
|
773
|
+
function deriveGreptileEvidence(input) {
|
|
774
|
+
const rawBodies = collectBodies(input);
|
|
775
|
+
const signals = collectGreptileSignals(input);
|
|
776
|
+
const trustedSignals = signals.filter((signal) => signal.trusted);
|
|
777
|
+
const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
|
|
778
|
+
const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
|
|
779
|
+
const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
|
|
780
|
+
const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
|
|
781
|
+
const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
|
|
782
|
+
const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
|
|
783
|
+
const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
|
|
784
|
+
const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
|
|
785
|
+
const signalCanApproveByScore = (signal) => {
|
|
786
|
+
if (signal.source === "api")
|
|
787
|
+
return signal.verdict === "approved" || signal.verdict === "completed";
|
|
788
|
+
return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
|
|
789
|
+
};
|
|
790
|
+
const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
|
|
791
|
+
const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
|
|
792
|
+
const approvedByScore = !!approvingScoreEntry;
|
|
793
|
+
const approvedByExplicitMapping = !!approvingExplicitSignal;
|
|
794
|
+
const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
|
|
795
|
+
const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
|
|
796
|
+
const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
|
|
797
|
+
const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
|
|
798
|
+
const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
|
|
799
|
+
const staleBlockingSignals = [];
|
|
800
|
+
const blockers = [
|
|
801
|
+
...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
|
|
802
|
+
...reviewBodyBlockerSummaries(input.reviews),
|
|
803
|
+
...issueLevelBlockerSummaries(input.relevantIssueComments),
|
|
804
|
+
...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
|
|
805
|
+
...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
|
|
806
|
+
...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
|
|
807
|
+
];
|
|
808
|
+
const unresolvedComments = [
|
|
809
|
+
...unresolvedGreptileThreadSummaries(input.reviewThreads),
|
|
810
|
+
...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
|
|
811
|
+
];
|
|
812
|
+
const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
|
|
813
|
+
const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
|
|
814
|
+
const completedGreptileCheck = greptileChecks.some((check) => {
|
|
815
|
+
const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
|
|
816
|
+
return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
|
|
817
|
+
});
|
|
818
|
+
const completedGreptileReview = greptileReviews.some((review) => {
|
|
819
|
+
const state = String(review.state ?? "").toUpperCase();
|
|
820
|
+
const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
|
|
821
|
+
return completedState && review.commit_id === input.currentHeadSha;
|
|
822
|
+
});
|
|
823
|
+
const completedGreptileApi = trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && (signal.verdict === "approved" || signal.verdict === "rejected" || signal.verdict === "skipped" || signal.verdict === "failed" || signal.verdict === "completed"));
|
|
824
|
+
const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
|
|
825
|
+
const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
|
|
826
|
+
const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
|
|
827
|
+
const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
|
|
828
|
+
const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
|
|
829
|
+
const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
|
|
830
|
+
const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
|
|
831
|
+
const source = approvingSignal?.source === "api" ? "api" : approvingSignal?.source === "github-review" ? "github-review" : approvingSignal?.source === "pr-body" || approvingSignal?.source === "pr-title" ? "pr-body" : 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";
|
|
832
|
+
return {
|
|
833
|
+
source,
|
|
834
|
+
currentHeadSha: input.currentHeadSha,
|
|
835
|
+
reviewedSha,
|
|
836
|
+
fresh,
|
|
837
|
+
completed,
|
|
838
|
+
approved,
|
|
839
|
+
score,
|
|
840
|
+
explicitApproval: approvedByExplicitMapping,
|
|
841
|
+
blockers,
|
|
842
|
+
unresolvedComments,
|
|
843
|
+
rawBodies,
|
|
844
|
+
signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
|
|
845
|
+
mapping
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function isGreptileCheckDetail(check) {
|
|
849
|
+
return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
|
|
850
|
+
}
|
|
851
|
+
async function collectGreptileCheckDetails(input) {
|
|
852
|
+
const checkRunsRead = await runJsonObject(input.command, [
|
|
853
|
+
"api",
|
|
854
|
+
`repos/${input.repoName}/commits/${input.headSha}/check-runs`,
|
|
855
|
+
"-F",
|
|
856
|
+
"per_page=100"
|
|
857
|
+
], input.projectRoot);
|
|
858
|
+
const checkRuns = arrayField(checkRunsRead.value, "check_runs").map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
|
|
859
|
+
return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
|
|
860
|
+
}
|
|
861
|
+
async function collectPullRequestProvenance(input) {
|
|
862
|
+
const response = await runJsonObject(input.command, [
|
|
863
|
+
"api",
|
|
864
|
+
"graphql",
|
|
865
|
+
"-F",
|
|
866
|
+
`owner=${input.owner}`,
|
|
867
|
+
"-F",
|
|
868
|
+
`name=${input.name}`,
|
|
869
|
+
"-F",
|
|
870
|
+
`prNumber=${input.prNumber}`,
|
|
871
|
+
"-f",
|
|
872
|
+
"query=query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$prNumber) { lastEditedAt editor { login } commits(last: 1) { nodes { commit { oid committedDate } } } } } }"
|
|
873
|
+
], input.projectRoot);
|
|
874
|
+
if (response.error)
|
|
875
|
+
return { value: {}, error: response.error };
|
|
876
|
+
const data = response.value.data;
|
|
877
|
+
const repository = data?.repository;
|
|
878
|
+
const pullRequest = repository?.pullRequest;
|
|
879
|
+
if (!pullRequest)
|
|
880
|
+
return { value: {}, error: "GitHub pullRequest provenance response did not include a pullRequest object" };
|
|
881
|
+
const editor = pullRequest.editor;
|
|
882
|
+
const commits = pullRequest.commits;
|
|
883
|
+
const nodes = Array.isArray(commits?.nodes) ? commits.nodes : [];
|
|
884
|
+
const latestCommitNode = nodes[nodes.length - 1];
|
|
885
|
+
const latestCommit = latestCommitNode?.commit;
|
|
886
|
+
return {
|
|
887
|
+
value: {
|
|
888
|
+
bodyEditorLogin: typeof editor?.login === "string" ? editor.login : null,
|
|
889
|
+
bodyLastEditedAt: typeof pullRequest.lastEditedAt === "string" ? pullRequest.lastEditedAt : null,
|
|
890
|
+
headCommittedDate: typeof latestCommit?.committedDate === "string" ? latestCommit.committedDate : null
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
async function collectReviewThreads(input) {
|
|
895
|
+
const reviewThreads = [];
|
|
896
|
+
let afterCursor = null;
|
|
897
|
+
for (let page = 0;page < 100; page += 1) {
|
|
898
|
+
const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
|
|
899
|
+
const threadsResponse = await runJsonObject(input.command, [
|
|
900
|
+
"api",
|
|
901
|
+
"graphql",
|
|
902
|
+
"-F",
|
|
903
|
+
`owner=${input.owner}`,
|
|
904
|
+
"-F",
|
|
905
|
+
`name=${input.name}`,
|
|
906
|
+
"-F",
|
|
907
|
+
`prNumber=${input.prNumber}`,
|
|
908
|
+
"-f",
|
|
909
|
+
`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 } } } } }`
|
|
910
|
+
], input.projectRoot);
|
|
911
|
+
if (threadsResponse.error) {
|
|
912
|
+
return { value: reviewThreads, error: threadsResponse.error };
|
|
913
|
+
}
|
|
914
|
+
const data = threadsResponse.value.data;
|
|
915
|
+
const repository = data?.repository;
|
|
916
|
+
const pullRequest = repository?.pullRequest;
|
|
917
|
+
const threads = pullRequest?.reviewThreads;
|
|
918
|
+
const nodes = threads?.nodes;
|
|
919
|
+
if (!Array.isArray(nodes)) {
|
|
920
|
+
return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
|
|
921
|
+
}
|
|
922
|
+
const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
|
|
923
|
+
reviewThreads.push(...normalized);
|
|
924
|
+
const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
|
|
925
|
+
if (truncatedCommentThread) {
|
|
926
|
+
return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
|
|
927
|
+
}
|
|
928
|
+
const pageInfo = threads?.pageInfo;
|
|
929
|
+
if (!pageInfo) {
|
|
930
|
+
if (nodes.length >= 100) {
|
|
931
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
|
|
932
|
+
}
|
|
933
|
+
return { value: reviewThreads };
|
|
934
|
+
}
|
|
935
|
+
if (pageInfo.hasNextPage !== true) {
|
|
936
|
+
return { value: reviewThreads };
|
|
937
|
+
}
|
|
938
|
+
if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
|
|
939
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
|
|
940
|
+
}
|
|
941
|
+
afterCursor = pageInfo.endCursor;
|
|
942
|
+
}
|
|
943
|
+
return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
|
|
944
|
+
}
|
|
945
|
+
async function collectPrReviewEvidence(input) {
|
|
946
|
+
const parsed = parseGithubPrUrl(input.prUrl);
|
|
947
|
+
if (!parsed) {
|
|
948
|
+
throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
|
|
949
|
+
}
|
|
950
|
+
const readErrors = [];
|
|
951
|
+
const viewRead = await runJsonObject(input.command, [
|
|
952
|
+
"pr",
|
|
953
|
+
"view",
|
|
954
|
+
input.prUrl,
|
|
955
|
+
"--json",
|
|
956
|
+
"title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
|
|
957
|
+
], input.projectRoot);
|
|
958
|
+
if (viewRead.error)
|
|
959
|
+
readErrors.push(viewRead.error);
|
|
960
|
+
const view = viewRead.value;
|
|
961
|
+
if (!Array.isArray(view.statusCheckRollup)) {
|
|
962
|
+
readErrors.push("gh pr view did not return required statusCheckRollup array");
|
|
963
|
+
}
|
|
964
|
+
if (!Array.isArray(view.reviews)) {
|
|
965
|
+
readErrors.push("gh pr view did not return required reviews array");
|
|
966
|
+
}
|
|
967
|
+
const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
|
|
968
|
+
const baseRefName = firstString(view, ["baseRefName"]);
|
|
969
|
+
const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
|
|
970
|
+
const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
|
|
971
|
+
const provenanceRead = await collectPullRequestProvenance({
|
|
972
|
+
command: input.command,
|
|
973
|
+
projectRoot: input.projectRoot,
|
|
974
|
+
owner: parsed.owner,
|
|
975
|
+
name: parsed.repo,
|
|
976
|
+
prNumber: parsed.prNumber
|
|
977
|
+
});
|
|
978
|
+
const provenance = provenanceRead.value;
|
|
979
|
+
const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate"], input.projectRoot);
|
|
980
|
+
if (reviewCommentsRead.error)
|
|
981
|
+
readErrors.push(reviewCommentsRead.error);
|
|
982
|
+
const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
|
|
983
|
+
const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate"], input.projectRoot);
|
|
984
|
+
if (issueCommentsRead.error)
|
|
985
|
+
readErrors.push(issueCommentsRead.error);
|
|
986
|
+
const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
|
|
987
|
+
const reviewThreadsRead = await collectReviewThreads({
|
|
988
|
+
command: input.command,
|
|
989
|
+
projectRoot: input.projectRoot,
|
|
990
|
+
owner: parsed.owner,
|
|
991
|
+
name: parsed.repo,
|
|
992
|
+
prNumber: parsed.prNumber
|
|
993
|
+
});
|
|
994
|
+
if (reviewThreadsRead.error)
|
|
995
|
+
readErrors.push(reviewThreadsRead.error);
|
|
996
|
+
const reviewThreads = reviewThreadsRead.value;
|
|
997
|
+
const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
|
|
998
|
+
let greptileCheckDetails = [];
|
|
999
|
+
if (headSha && greptileRollupChecks.length > 0) {
|
|
1000
|
+
const checkDetailsRead = await collectGreptileCheckDetails({
|
|
1001
|
+
command: input.command,
|
|
1002
|
+
projectRoot: input.projectRoot,
|
|
1003
|
+
repoName: parsed.repoName,
|
|
1004
|
+
headSha
|
|
1005
|
+
});
|
|
1006
|
+
greptileCheckDetails = checkDetailsRead.value;
|
|
1007
|
+
}
|
|
1008
|
+
const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
|
|
1009
|
+
const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
|
|
1010
|
+
const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
|
|
1011
|
+
enabled: shouldCollectConfiguredGreptileApi,
|
|
1012
|
+
options: input.greptileApi,
|
|
1013
|
+
repoName: parsed.repoName,
|
|
1014
|
+
prNumber: parsed.prNumber,
|
|
1015
|
+
headSha,
|
|
1016
|
+
baseRefName
|
|
1017
|
+
});
|
|
1018
|
+
readErrors.push(...configuredGreptileApiRead.errors);
|
|
1019
|
+
const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
|
|
1020
|
+
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})` : ""}`);
|
|
1021
|
+
const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
|
|
1022
|
+
const evidenceBase = {
|
|
1023
|
+
title: firstString(view, ["title"]),
|
|
1024
|
+
body: firstString(view, ["body"]),
|
|
1025
|
+
bodyEditorLogin: provenance.bodyEditorLogin ?? null,
|
|
1026
|
+
bodyLastEditedAt: provenance.bodyLastEditedAt ?? null,
|
|
1027
|
+
headCommittedDate: provenance.headCommittedDate ?? null,
|
|
1028
|
+
reviews,
|
|
1029
|
+
changedFileReviewComments: reviewComments,
|
|
1030
|
+
relevantIssueComments: issueComments,
|
|
1031
|
+
reviewThreads,
|
|
1032
|
+
checks: checksWithGreptileDetails,
|
|
1033
|
+
currentHeadSha: headSha,
|
|
1034
|
+
apiSignals
|
|
1035
|
+
};
|
|
1036
|
+
const greptile = deriveGreptileEvidence(evidenceBase);
|
|
1037
|
+
return {
|
|
1038
|
+
prUrl: input.prUrl,
|
|
1039
|
+
prNumber: parsed.prNumber,
|
|
1040
|
+
repoName: parsed.repoName,
|
|
1041
|
+
title: evidenceBase.title,
|
|
1042
|
+
body: evidenceBase.body,
|
|
1043
|
+
bodyEditorLogin: evidenceBase.bodyEditorLogin,
|
|
1044
|
+
bodyLastEditedAt: evidenceBase.bodyLastEditedAt,
|
|
1045
|
+
headCommittedDate: evidenceBase.headCommittedDate,
|
|
1046
|
+
headSha,
|
|
1047
|
+
headRefName: firstString(view, ["headRefName"]),
|
|
1048
|
+
baseRefName,
|
|
1049
|
+
state: firstString(view, ["state"]),
|
|
1050
|
+
isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
|
|
1051
|
+
mergeable: firstString(view, ["mergeable"]),
|
|
1052
|
+
mergeStateStatus: firstString(view, ["mergeStateStatus"]),
|
|
1053
|
+
reviewDecision: firstString(view, ["reviewDecision"]),
|
|
1054
|
+
reviews,
|
|
1055
|
+
reviewThreads,
|
|
1056
|
+
changedFileReviewComments: reviewComments,
|
|
1057
|
+
relevantIssueComments: issueComments,
|
|
1058
|
+
statusCheckRollup: checksWithGreptileDetails,
|
|
1059
|
+
checkFailures,
|
|
1060
|
+
pendingChecks,
|
|
1061
|
+
readErrors,
|
|
1062
|
+
greptile
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function capGateMessage(value, maxChars = 1200) {
|
|
1066
|
+
const normalized = value.trim();
|
|
1067
|
+
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
|
|
1068
|
+
[truncated for gate summary; see full evidence artifact]` : normalized;
|
|
1069
|
+
}
|
|
1070
|
+
function evaluateEvidence(evidence) {
|
|
1071
|
+
const reasonDetails = [];
|
|
1072
|
+
const warnings = [];
|
|
1073
|
+
const seen = new Set;
|
|
1074
|
+
const addReason = (reason) => {
|
|
1075
|
+
const capped = { ...reason, message: capGateMessage(reason.message) };
|
|
1076
|
+
const key = `${capped.code}:${capped.message}`;
|
|
1077
|
+
if (seen.has(key))
|
|
1078
|
+
return;
|
|
1079
|
+
seen.add(key);
|
|
1080
|
+
reasonDetails.push(capped);
|
|
1081
|
+
};
|
|
1082
|
+
const greptile = evidence.greptile;
|
|
1083
|
+
const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
|
|
1084
|
+
const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
|
|
1085
|
+
const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
|
|
1086
|
+
const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
|
|
1087
|
+
const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
|
|
1088
|
+
for (const error of evidence.readErrors) {
|
|
1089
|
+
addReason({
|
|
1090
|
+
code: "read_error",
|
|
1091
|
+
reasonClass: "reject",
|
|
1092
|
+
surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
|
|
1093
|
+
suggestedAction: "needs_attention",
|
|
1094
|
+
message: `Required PR evidence surface could not be read completely: ${error}`,
|
|
1095
|
+
headSha: evidence.headSha || null
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (!evidence.headSha) {
|
|
1099
|
+
addReason({
|
|
1100
|
+
code: "missing_head_sha",
|
|
1101
|
+
reasonClass: "reject",
|
|
1102
|
+
surface: "github",
|
|
1103
|
+
suggestedAction: "needs_attention",
|
|
1104
|
+
message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
|
|
1105
|
+
headSha: null
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
for (const failure of evidence.checkFailures) {
|
|
1109
|
+
addReason({
|
|
1110
|
+
code: "ci_failed",
|
|
1111
|
+
reasonClass: "reject",
|
|
1112
|
+
surface: "ci",
|
|
1113
|
+
suggestedAction: "fix",
|
|
1114
|
+
message: failure,
|
|
1115
|
+
headSha: evidence.headSha || null
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
for (const pendingCheck of evidence.pendingChecks) {
|
|
1119
|
+
addReason({
|
|
1120
|
+
code: "check_pending",
|
|
1121
|
+
reasonClass: "pending",
|
|
1122
|
+
surface: "ci",
|
|
1123
|
+
suggestedAction: "wait",
|
|
1124
|
+
message: pendingCheck,
|
|
1125
|
+
headSha: evidence.headSha || null
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
|
|
1129
|
+
if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
|
|
1130
|
+
addReason({
|
|
1131
|
+
code: "review_decision_blocking",
|
|
1132
|
+
reasonClass: "reject",
|
|
1133
|
+
surface: "review",
|
|
1134
|
+
suggestedAction: "fix",
|
|
1135
|
+
message: `Required review is unresolved (${evidence.reviewDecision}).`,
|
|
1136
|
+
headSha: evidence.headSha || null
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
|
|
1140
|
+
addReason({
|
|
1141
|
+
code: "review_thread_unresolved",
|
|
1142
|
+
reasonClass: "reject",
|
|
1143
|
+
surface: "review",
|
|
1144
|
+
suggestedAction: "fix",
|
|
1145
|
+
message: thread,
|
|
1146
|
+
headSha: evidence.headSha || null
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
if (greptile.mapping === "missing") {
|
|
1150
|
+
addReason({
|
|
1151
|
+
code: "greptile_missing",
|
|
1152
|
+
reasonClass: "pending",
|
|
1153
|
+
surface: "greptile",
|
|
1154
|
+
suggestedAction: "wait",
|
|
1155
|
+
message: "Missing Greptile check/review evidence for this PR.",
|
|
1156
|
+
headSha: evidence.headSha || null
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
|
|
1160
|
+
addReason({
|
|
1161
|
+
code: "greptile_stale",
|
|
1162
|
+
reasonClass: "pending",
|
|
1163
|
+
surface: "greptile",
|
|
1164
|
+
suggestedAction: "wait",
|
|
1165
|
+
message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
|
|
1166
|
+
headSha: evidence.headSha || null,
|
|
1167
|
+
reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
for (const signal of pendingGreptileApiSignals) {
|
|
1171
|
+
addReason({
|
|
1172
|
+
code: "greptile_pending",
|
|
1173
|
+
reasonClass: "pending",
|
|
1174
|
+
surface: "greptile",
|
|
1175
|
+
suggestedAction: "wait",
|
|
1176
|
+
message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
|
|
1177
|
+
headSha: evidence.headSha || null,
|
|
1178
|
+
reviewedSha: signal.reviewedSha ?? null
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
for (const signal of unknownGreptileApiSignals) {
|
|
1182
|
+
addReason({
|
|
1183
|
+
code: "greptile_api_status_unknown",
|
|
1184
|
+
reasonClass: "reject",
|
|
1185
|
+
surface: "greptile",
|
|
1186
|
+
suggestedAction: "needs_attention",
|
|
1187
|
+
message: `Greptile API/MCP review status is unknown; merge requires a known terminal APPROVED/COMPLETED 5/5 result or a known conservative status${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
|
|
1188
|
+
headSha: evidence.headSha || null,
|
|
1189
|
+
reviewedSha: signal.reviewedSha ?? null
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
if (!greptile.completed) {
|
|
1193
|
+
addReason({
|
|
1194
|
+
code: "greptile_pending",
|
|
1195
|
+
reasonClass: "pending",
|
|
1196
|
+
surface: "greptile",
|
|
1197
|
+
suggestedAction: "wait",
|
|
1198
|
+
message: "Greptile check/review has not completed for the current PR head.",
|
|
1199
|
+
headSha: evidence.headSha || null,
|
|
1200
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (!greptile.fresh) {
|
|
1204
|
+
addReason({
|
|
1205
|
+
code: "greptile_not_current_head",
|
|
1206
|
+
reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
|
|
1207
|
+
surface: "greptile",
|
|
1208
|
+
suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
|
|
1209
|
+
message: "Greptile approval is not tied to the current PR head SHA.",
|
|
1210
|
+
headSha: evidence.headSha || null,
|
|
1211
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
|
|
1215
|
+
addReason({
|
|
1216
|
+
code: "greptile_score_not_5",
|
|
1217
|
+
reasonClass: "reject",
|
|
1218
|
+
surface: "greptile",
|
|
1219
|
+
suggestedAction: "fix",
|
|
1220
|
+
message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
|
|
1221
|
+
headSha: evidence.headSha || null,
|
|
1222
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
|
|
1226
|
+
if (!greptile.score && !hasApprovedMapping) {
|
|
1227
|
+
addReason({
|
|
1228
|
+
code: "greptile_score_missing",
|
|
1229
|
+
reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
|
|
1230
|
+
surface: "greptile",
|
|
1231
|
+
suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
|
|
1232
|
+
message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
|
|
1233
|
+
headSha: evidence.headSha || null,
|
|
1234
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
if (greptile.mapping === "unproven") {
|
|
1238
|
+
addReason({
|
|
1239
|
+
code: "greptile_mapping_unproven",
|
|
1240
|
+
reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
|
|
1241
|
+
surface: "greptile",
|
|
1242
|
+
suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
|
|
1243
|
+
message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
|
|
1244
|
+
headSha: evidence.headSha || null,
|
|
1245
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
for (const blocker of greptile.blockers) {
|
|
1249
|
+
addReason({
|
|
1250
|
+
code: "greptile_blocker_text",
|
|
1251
|
+
reasonClass: "reject",
|
|
1252
|
+
surface: "greptile",
|
|
1253
|
+
suggestedAction: "fix",
|
|
1254
|
+
message: `Greptile/blocker text: ${blocker}`,
|
|
1255
|
+
headSha: evidence.headSha || null,
|
|
1256
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
for (const comment of greptile.unresolvedComments) {
|
|
1260
|
+
addReason({
|
|
1261
|
+
code: "greptile_unresolved_comment",
|
|
1262
|
+
reasonClass: "reject",
|
|
1263
|
+
surface: "greptile",
|
|
1264
|
+
suggestedAction: "fix",
|
|
1265
|
+
message: comment,
|
|
1266
|
+
headSha: evidence.headSha || null,
|
|
1267
|
+
reviewedSha: greptile.reviewedSha ?? null
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
if (!greptile.approved)
|
|
1271
|
+
warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
|
|
1272
|
+
const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
|
|
1273
|
+
return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
|
|
1274
|
+
}
|
|
1275
|
+
function evaluateStrictPrMergeGate(evidence) {
|
|
1276
|
+
const evaluated = evaluateEvidence(evidence);
|
|
1277
|
+
const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
|
|
1278
|
+
return {
|
|
1279
|
+
approved,
|
|
1280
|
+
pending: evaluated.pending,
|
|
1281
|
+
reasons: evaluated.reasons,
|
|
1282
|
+
reasonDetails: evaluated.reasonDetails,
|
|
1283
|
+
warnings: evaluated.warnings,
|
|
1284
|
+
actionableFeedback: evaluated.reasons,
|
|
1285
|
+
evidence
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function strictMergeHeadShaFromGate(result, prUrl) {
|
|
1289
|
+
if (!result.approved) {
|
|
1290
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate is not approved.`);
|
|
1291
|
+
}
|
|
1292
|
+
if (result.evidence.prUrl !== prUrl) {
|
|
1293
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate evidence belongs to ${result.evidence.prUrl}.`);
|
|
1294
|
+
}
|
|
1295
|
+
const headSha = result.evidence.headSha?.trim();
|
|
1296
|
+
if (!headSha) {
|
|
1297
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate did not provide a current head SHA.`);
|
|
1298
|
+
}
|
|
1299
|
+
if (!/^[0-9a-f]{40}$/i.test(headSha)) {
|
|
1300
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate head is not a raw 40-character commit SHA.`);
|
|
1301
|
+
}
|
|
1302
|
+
if (!result.evidence.greptile.fresh || result.evidence.greptile.currentHeadSha !== headSha) {
|
|
1303
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate approval is not tied to head ${headSha}.`);
|
|
1304
|
+
}
|
|
1305
|
+
if (result.evidence.greptile.mapping !== "score-5-of-5" && result.evidence.greptile.mapping !== "explicit-approved") {
|
|
1306
|
+
throw new Error(`Refusing to merge ${prUrl}: strict merge gate mapping is ${result.evidence.greptile.mapping}.`);
|
|
1307
|
+
}
|
|
1308
|
+
return headSha;
|
|
1309
|
+
}
|
|
1310
|
+
function promptExcerpt(value, maxChars = 4000) {
|
|
1311
|
+
return value.length > maxChars ? `${value.slice(0, maxChars)}
|
|
1312
|
+
|
|
1313
|
+
[truncated for prompt; see full evidence artifact]` : value;
|
|
1314
|
+
}
|
|
1315
|
+
function promptJsonExcerpt(value, maxChars = 6000) {
|
|
1316
|
+
return promptExcerpt(JSON.stringify(value, null, 2), maxChars);
|
|
1317
|
+
}
|
|
1318
|
+
function buildStrictPrGateSteeringPrompt(result) {
|
|
1319
|
+
const evidence = result.evidence;
|
|
1320
|
+
const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
|
|
1321
|
+
const displayedReasons = result.reasons.slice(0, 20).map((reason) => `- ${promptExcerpt(reason, 1200)}`);
|
|
1322
|
+
if (result.reasons.length > displayedReasons.length) {
|
|
1323
|
+
displayedReasons.push(`- ${result.reasons.length - displayedReasons.length} additional gate reasons omitted from prompt; see merge-gate-result.json.`);
|
|
1324
|
+
}
|
|
1325
|
+
const lines = [
|
|
1326
|
+
`Strict PR merge gate blocked ${evidence.prUrl}.`,
|
|
1327
|
+
`PR title: ${evidence.title || "(empty)"}`,
|
|
1328
|
+
`Current PR head SHA: ${evidence.headSha || "unknown"}`,
|
|
1329
|
+
`Greptile mapping: ${evidence.greptile.mapping}`,
|
|
1330
|
+
evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
|
|
1331
|
+
"",
|
|
1332
|
+
"Gate reasons:",
|
|
1333
|
+
...displayedReasons.length ? displayedReasons : ["- No reasons recorded"],
|
|
1334
|
+
"",
|
|
1335
|
+
"Structured gate reason details:",
|
|
1336
|
+
result.reasonDetails.length ? promptJsonExcerpt(result.reasonDetails, 4000) : "[]",
|
|
1337
|
+
"",
|
|
1338
|
+
"Required evidence read status:",
|
|
1339
|
+
evidence.readErrors.length ? promptJsonExcerpt(evidence.readErrors, 2000) : "All required PR evidence surfaces were read completely.",
|
|
1340
|
+
"",
|
|
1341
|
+
"Full PR title:",
|
|
1342
|
+
evidence.title || "(empty)",
|
|
1343
|
+
"",
|
|
1344
|
+
"PR body excerpt:",
|
|
1345
|
+
evidence.body ? promptExcerpt(evidence.body) : "(empty)",
|
|
1346
|
+
"",
|
|
1347
|
+
"All review comments on changed files:",
|
|
1348
|
+
evidence.changedFileReviewComments.length ? promptJsonExcerpt(evidence.changedFileReviewComments) : "[]",
|
|
1349
|
+
"",
|
|
1350
|
+
"Unresolved review threads:",
|
|
1351
|
+
unresolvedReviewThreads.length ? promptJsonExcerpt(unresolvedReviewThreads) : "[]",
|
|
1352
|
+
"",
|
|
1353
|
+
"Relevant issue-level PR comments:",
|
|
1354
|
+
evidence.relevantIssueComments.length ? promptJsonExcerpt(evidence.relevantIssueComments) : "[]",
|
|
1355
|
+
"",
|
|
1356
|
+
"CI/check failures and pending checks:",
|
|
1357
|
+
promptJsonExcerpt({ failures: evidence.checkFailures, pending: evidence.pendingChecks, rollup: evidence.statusCheckRollup }),
|
|
1358
|
+
"",
|
|
1359
|
+
"Greptile evidence:",
|
|
1360
|
+
promptJsonExcerpt(evidence.greptile)
|
|
1361
|
+
];
|
|
1362
|
+
if (result.artifacts) {
|
|
1363
|
+
lines.push("", "Full evidence artifacts:", JSON.stringify(result.artifacts, null, 2));
|
|
1364
|
+
}
|
|
1365
|
+
return lines.join(`
|
|
1366
|
+
`);
|
|
1367
|
+
}
|
|
1368
|
+
function persistPrReviewCycleArtifacts(input) {
|
|
1369
|
+
const cycleName = input.final ? `${input.cycle}-final` : String(input.cycle);
|
|
1370
|
+
const taskArtifactRoot = input.artifactRoot?.trim() ? input.artifactRoot : resolve(input.projectRoot, "artifacts", input.taskId);
|
|
1371
|
+
const root = resolve(taskArtifactRoot, "pr-review-cycles", cycleName);
|
|
1372
|
+
mkdirSync(root, { recursive: true });
|
|
1373
|
+
const finalMergeGateResultPath = input.final ? resolve(taskArtifactRoot, "merge-gate-final.json") : undefined;
|
|
1374
|
+
const paths = {
|
|
1375
|
+
root,
|
|
1376
|
+
prTitlePath: resolve(root, "pr-title.md"),
|
|
1377
|
+
prBodyPath: resolve(root, "pr-body.md"),
|
|
1378
|
+
prCommentsPath: resolve(root, "pr-comments.json"),
|
|
1379
|
+
reviewThreadsPath: resolve(root, "review-threads.json"),
|
|
1380
|
+
reviewCommentsPath: resolve(root, "review-comments.json"),
|
|
1381
|
+
checkRollupPath: resolve(root, "check-rollup.json"),
|
|
1382
|
+
greptileEvidencePath: resolve(root, "greptile-evidence.json"),
|
|
1383
|
+
mergeGateResultPath: resolve(root, "merge-gate-result.json"),
|
|
1384
|
+
steeringPromptPath: resolve(root, "agent-steering-prompt.md"),
|
|
1385
|
+
...finalMergeGateResultPath ? { finalMergeGateResultPath } : {}
|
|
1386
|
+
};
|
|
1387
|
+
writeFileSync(paths.prTitlePath, input.result.evidence.title || "", "utf8");
|
|
1388
|
+
writeFileSync(paths.prBodyPath, input.result.evidence.body || "", "utf8");
|
|
1389
|
+
writeFileSync(paths.prCommentsPath, `${JSON.stringify(input.result.evidence.relevantIssueComments, null, 2)}
|
|
1390
|
+
`, "utf8");
|
|
1391
|
+
writeFileSync(paths.reviewThreadsPath, `${JSON.stringify(input.result.evidence.reviewThreads, null, 2)}
|
|
1392
|
+
`, "utf8");
|
|
1393
|
+
writeFileSync(paths.reviewCommentsPath, `${JSON.stringify(input.result.evidence.changedFileReviewComments, null, 2)}
|
|
1394
|
+
`, "utf8");
|
|
1395
|
+
writeFileSync(paths.checkRollupPath, `${JSON.stringify(input.result.evidence.statusCheckRollup, null, 2)}
|
|
1396
|
+
`, "utf8");
|
|
1397
|
+
writeFileSync(paths.greptileEvidencePath, `${JSON.stringify(input.result.evidence.greptile, null, 2)}
|
|
1398
|
+
`, "utf8");
|
|
1399
|
+
const mergeGatePayload = {
|
|
1400
|
+
approved: input.result.approved,
|
|
1401
|
+
pending: input.result.pending,
|
|
1402
|
+
reasons: input.result.reasons,
|
|
1403
|
+
reasonDetails: input.result.reasonDetails,
|
|
1404
|
+
warnings: input.result.warnings,
|
|
1405
|
+
actionableFeedback: input.result.actionableFeedback,
|
|
1406
|
+
prUrl: input.result.evidence.prUrl,
|
|
1407
|
+
title: input.result.evidence.title,
|
|
1408
|
+
headSha: input.result.evidence.headSha,
|
|
1409
|
+
readErrors: input.result.evidence.readErrors,
|
|
1410
|
+
greptile: input.result.evidence.greptile,
|
|
1411
|
+
evidence: input.result.evidence,
|
|
1412
|
+
cycleArtifactRoot: root
|
|
1413
|
+
};
|
|
1414
|
+
writeFileSync(paths.mergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
|
|
1415
|
+
`, "utf8");
|
|
1416
|
+
if (paths.finalMergeGateResultPath) {
|
|
1417
|
+
writeFileSync(paths.finalMergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
|
|
1418
|
+
`, "utf8");
|
|
1419
|
+
}
|
|
1420
|
+
writeFileSync(paths.steeringPromptPath, input.steeringPrompt, "utf8");
|
|
1421
|
+
return paths;
|
|
1422
|
+
}
|
|
1423
|
+
async function runStrictPrMergeGate(input) {
|
|
1424
|
+
const evidence = await collectPrReviewEvidence(input);
|
|
1425
|
+
const base = evaluateStrictPrMergeGate(evidence);
|
|
1426
|
+
const preliminaryPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts: undefined });
|
|
1427
|
+
const artifacts = persistPrReviewCycleArtifacts({
|
|
1428
|
+
projectRoot: input.projectRoot,
|
|
1429
|
+
taskId: input.taskId,
|
|
1430
|
+
cycle: input.cycle,
|
|
1431
|
+
artifactRoot: input.artifactRoot,
|
|
1432
|
+
result: base,
|
|
1433
|
+
steeringPrompt: preliminaryPrompt,
|
|
1434
|
+
final: input.final
|
|
1435
|
+
});
|
|
1436
|
+
const steeringPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts });
|
|
1437
|
+
writeFileSync(artifacts.steeringPromptPath, steeringPrompt, "utf8");
|
|
1438
|
+
return { ...base, artifacts, steeringPrompt };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
2
1441
|
// packages/runtime/src/control-plane/native/pr-automation.ts
|
|
3
1442
|
var UPLOADED_SNAPSHOT_PR_MARKER = "<!-- rig:uploaded-snapshot -->";
|
|
4
1443
|
function positiveInt(value, fallback) {
|
|
@@ -6,7 +1445,7 @@ function positiveInt(value, fallback) {
|
|
|
6
1445
|
}
|
|
7
1446
|
function resolvePrAutomationLimits(config) {
|
|
8
1447
|
return {
|
|
9
|
-
maxPrFixIterations: positiveInt(config?.automation?.maxPrFixIterations,
|
|
1448
|
+
maxPrFixIterations: positiveInt(config?.automation?.maxPrFixIterations, 100500)
|
|
10
1449
|
};
|
|
11
1450
|
}
|
|
12
1451
|
function buildPrAutomationBody(input) {
|
|
@@ -24,24 +1463,24 @@ function buildPrAutomationBody(input) {
|
|
|
24
1463
|
return lines.join(`
|
|
25
1464
|
`);
|
|
26
1465
|
}
|
|
27
|
-
function
|
|
1466
|
+
function wildcardToRegExp2(pattern) {
|
|
28
1467
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
29
1468
|
return new RegExp(`^${escaped}$`, "i");
|
|
30
1469
|
}
|
|
31
|
-
function
|
|
32
|
-
return allowedFailures.some((pattern) =>
|
|
1470
|
+
function isAllowedFailure2(name, allowedFailures) {
|
|
1471
|
+
return allowedFailures.some((pattern) => wildcardToRegExp2(pattern).test(name));
|
|
33
1472
|
}
|
|
34
|
-
function
|
|
1473
|
+
function isPendingCheck2(check) {
|
|
35
1474
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
36
1475
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
37
1476
|
return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(conclusion) || ["pending", "queued", "in_progress", "waiting", "requested", "expected"].includes(state);
|
|
38
1477
|
}
|
|
39
|
-
function
|
|
1478
|
+
function isPassingCheck2(check) {
|
|
40
1479
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
41
1480
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
42
1481
|
return ["success", "successful", "passed", "neutral", "skipped"].includes(conclusion) || ["success", "successful", "passed", "completed"].includes(state);
|
|
43
1482
|
}
|
|
44
|
-
function
|
|
1483
|
+
function isFailingCheck2(check) {
|
|
45
1484
|
const conclusion = String(check.conclusion ?? "").toLowerCase();
|
|
46
1485
|
const state = String(check.state ?? check.status ?? "").toLowerCase();
|
|
47
1486
|
return ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(conclusion) || ["failure", "failed", "timed_out", "action_required", "cancelled", "error"].includes(state);
|
|
@@ -51,9 +1490,9 @@ function collectPendingPrChecks(input) {
|
|
|
51
1490
|
const pending = [];
|
|
52
1491
|
for (const check of input.checks ?? []) {
|
|
53
1492
|
const name = check.name.trim();
|
|
54
|
-
if (!name ||
|
|
1493
|
+
if (!name || isAllowedFailure2(name, allowedFailures))
|
|
55
1494
|
continue;
|
|
56
|
-
if (
|
|
1495
|
+
if (isPendingCheck2(check) && !isPassingCheck2(check))
|
|
57
1496
|
pending.push(name);
|
|
58
1497
|
}
|
|
59
1498
|
return pending;
|
|
@@ -63,7 +1502,7 @@ function collectActionablePrFeedback(input) {
|
|
|
63
1502
|
const feedback = [];
|
|
64
1503
|
for (const check of input.checks ?? []) {
|
|
65
1504
|
const name = check.name.trim();
|
|
66
|
-
if (!name || !
|
|
1505
|
+
if (!name || !isFailingCheck2(check) || isAllowedFailure2(name, allowedFailures))
|
|
67
1506
|
continue;
|
|
68
1507
|
feedback.push(`Check failed: ${name}${check.detailsUrl ? ` (${check.detailsUrl})` : ""}`);
|
|
69
1508
|
}
|
|
@@ -77,7 +1516,7 @@ function collectActionablePrFeedback(input) {
|
|
|
77
1516
|
}
|
|
78
1517
|
return feedback;
|
|
79
1518
|
}
|
|
80
|
-
function
|
|
1519
|
+
function parseJsonArray2(value) {
|
|
81
1520
|
if (!value?.trim())
|
|
82
1521
|
return [];
|
|
83
1522
|
try {
|
|
@@ -88,7 +1527,7 @@ function parseJsonArray(value) {
|
|
|
88
1527
|
}
|
|
89
1528
|
}
|
|
90
1529
|
function parsePrChecks(value) {
|
|
91
|
-
return
|
|
1530
|
+
return parseJsonArray2(value).flatMap((entry) => {
|
|
92
1531
|
if (!entry || typeof entry !== "object")
|
|
93
1532
|
return [];
|
|
94
1533
|
const record = entry;
|
|
@@ -104,6 +1543,44 @@ function parsePrChecks(value) {
|
|
|
104
1543
|
}];
|
|
105
1544
|
});
|
|
106
1545
|
}
|
|
1546
|
+
function parsePrViewStatusCheckRollup(value) {
|
|
1547
|
+
if (!value?.trim())
|
|
1548
|
+
return [];
|
|
1549
|
+
try {
|
|
1550
|
+
const parsed = JSON.parse(value);
|
|
1551
|
+
const rollup = Array.isArray(parsed.statusCheckRollup) ? parsed.statusCheckRollup : [];
|
|
1552
|
+
return rollup.flatMap((entry) => {
|
|
1553
|
+
if (!entry || typeof entry !== "object")
|
|
1554
|
+
return [];
|
|
1555
|
+
const record = entry;
|
|
1556
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
1557
|
+
if (!name.trim())
|
|
1558
|
+
return [];
|
|
1559
|
+
return [{
|
|
1560
|
+
name,
|
|
1561
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
1562
|
+
state: typeof record.state === "string" ? record.state : null,
|
|
1563
|
+
conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
|
|
1564
|
+
detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.link === "string" ? record.link : null
|
|
1565
|
+
}];
|
|
1566
|
+
});
|
|
1567
|
+
} catch {
|
|
1568
|
+
return [];
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async function readPrChecks(input) {
|
|
1572
|
+
const checks = await input.command(["pr", "checks", input.prUrl, "--json", "name,state,link"], input.cwd ? { cwd: input.cwd } : undefined);
|
|
1573
|
+
if (checks.exitCode === 0) {
|
|
1574
|
+
return parsePrChecks(checks.stdout);
|
|
1575
|
+
}
|
|
1576
|
+
const combined = `${checks.stderr ?? ""}
|
|
1577
|
+
${checks.stdout ?? ""}`;
|
|
1578
|
+
if (!/unknown flag.*--json|unknown flag: --json|unknown shorthand flag/i.test(combined)) {
|
|
1579
|
+
throw new Error(`gh pr checks ${input.prUrl} --json name,state,link failed (${checks.exitCode}): ${checks.stderr ?? checks.stdout ?? ""}`.trim());
|
|
1580
|
+
}
|
|
1581
|
+
const view = await runChecked(input.command, ["pr", "view", input.prUrl, "--json", "statusCheckRollup"], input.cwd, "gh");
|
|
1582
|
+
return parsePrViewStatusCheckRollup(view.stdout);
|
|
1583
|
+
}
|
|
107
1584
|
function parsePrViewReviewThreads(value) {
|
|
108
1585
|
if (!value?.trim())
|
|
109
1586
|
return [];
|
|
@@ -164,6 +1641,30 @@ function normalizePrUrl(stdout) {
|
|
|
164
1641
|
throw new Error("gh pr create did not return a PR URL");
|
|
165
1642
|
return url;
|
|
166
1643
|
}
|
|
1644
|
+
async function ensureExistingPrBodyHasRigMarkers(input) {
|
|
1645
|
+
const view = await input.command(["pr", "view", input.prUrl, "--json", "body"], input.cwd ? { cwd: input.cwd } : undefined);
|
|
1646
|
+
if (view.exitCode !== 0) {
|
|
1647
|
+
throw new Error(`gh pr view ${input.prUrl} --json body failed (${view.exitCode}): ${view.stderr ?? view.stdout ?? ""}`.trim());
|
|
1648
|
+
}
|
|
1649
|
+
let currentBody = "";
|
|
1650
|
+
try {
|
|
1651
|
+
const parsed = JSON.parse(view.stdout ?? "{}");
|
|
1652
|
+
currentBody = typeof parsed.body === "string" ? parsed.body : "";
|
|
1653
|
+
} catch {
|
|
1654
|
+
currentBody = "";
|
|
1655
|
+
}
|
|
1656
|
+
const requiredBlocks = input.body.split(/\n{2,}/).map((block) => block.trim()).filter((block) => /^Run: /i.test(block) || /^Closes #\d+/i.test(block));
|
|
1657
|
+
const missing = requiredBlocks.filter((block) => !currentBody.includes(block));
|
|
1658
|
+
if (missing.length === 0)
|
|
1659
|
+
return;
|
|
1660
|
+
const nextBody = [currentBody.trim(), ...missing].filter(Boolean).join(`
|
|
1661
|
+
|
|
1662
|
+
`);
|
|
1663
|
+
const edit = await input.command(["pr", "edit", input.prUrl, "--body", nextBody], input.cwd ? { cwd: input.cwd } : undefined);
|
|
1664
|
+
if (edit.exitCode !== 0) {
|
|
1665
|
+
throw new Error(`gh pr edit ${input.prUrl} --body failed (${edit.exitCode}): ${edit.stderr ?? edit.stdout ?? ""}`.trim());
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
167
1668
|
async function runChecked(command, args, cwd, label = "gh") {
|
|
168
1669
|
const result = await command(args, cwd ? { cwd } : undefined);
|
|
169
1670
|
if (result.exitCode !== 0) {
|
|
@@ -188,27 +1689,33 @@ function statusPathFromShortLine(line) {
|
|
|
188
1689
|
}
|
|
189
1690
|
return renamedPath;
|
|
190
1691
|
}
|
|
1692
|
+
function isRuntimeCommitExcludedPath(path) {
|
|
1693
|
+
const normalized = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
1694
|
+
return RIG_RUNTIME_COMMIT_EXCLUDES.some((excluded) => normalized === excluded || normalized.startsWith(`${excluded}/`));
|
|
1695
|
+
}
|
|
1696
|
+
function committableRunChangePaths(statusText) {
|
|
1697
|
+
const seen = new Set;
|
|
1698
|
+
const paths = [];
|
|
1699
|
+
for (const line of statusText.split(/\r?\n/)) {
|
|
1700
|
+
const path = statusPathFromShortLine(line);
|
|
1701
|
+
if (!path || isRuntimeCommitExcludedPath(path) || seen.has(path))
|
|
1702
|
+
continue;
|
|
1703
|
+
seen.add(path);
|
|
1704
|
+
paths.push(path);
|
|
1705
|
+
}
|
|
1706
|
+
return paths;
|
|
1707
|
+
}
|
|
191
1708
|
function hasCommittableRunChanges(statusText) {
|
|
192
|
-
return statusText
|
|
1709
|
+
return committableRunChangePaths(statusText).length > 0;
|
|
193
1710
|
}
|
|
194
1711
|
async function commitRunChanges(input) {
|
|
195
|
-
const status = await runChecked(input.command, ["status", "--short"], input.cwd, "git");
|
|
1712
|
+
const status = await runChecked(input.command, ["status", "--short", "--untracked-files=all"], input.cwd, "git");
|
|
196
1713
|
const statusText = status.stdout ?? "";
|
|
197
|
-
|
|
1714
|
+
const committablePaths = committableRunChangePaths(statusText);
|
|
1715
|
+
if (!statusText.trim() || committablePaths.length === 0) {
|
|
198
1716
|
return { committed: false, status: statusText };
|
|
199
1717
|
}
|
|
200
|
-
await runChecked(input.command, [
|
|
201
|
-
"add",
|
|
202
|
-
"-A",
|
|
203
|
-
"--",
|
|
204
|
-
".",
|
|
205
|
-
":(exclude).rig",
|
|
206
|
-
":(exclude).rig/**",
|
|
207
|
-
":(exclude)artifacts",
|
|
208
|
-
":(exclude)artifacts/**",
|
|
209
|
-
":(exclude)node_modules",
|
|
210
|
-
":(exclude)node_modules/**"
|
|
211
|
-
], input.cwd, "git");
|
|
1718
|
+
await runChecked(input.command, ["add", "-A", "--", ...committablePaths], input.cwd, "git");
|
|
212
1719
|
const staged = await input.command(["diff", "--cached", "--quiet"], { cwd: input.cwd });
|
|
213
1720
|
if (staged.exitCode === 0) {
|
|
214
1721
|
return { committed: false, status: statusText };
|
|
@@ -267,6 +1774,7 @@ async function runRepoDefaultMerge(input) {
|
|
|
267
1774
|
const merge = input.config?.merge ?? {};
|
|
268
1775
|
if (merge.mode === "off")
|
|
269
1776
|
return;
|
|
1777
|
+
const matchHeadSha = strictMergeHeadShaFromGate(input.strictGate, input.prUrl);
|
|
270
1778
|
const method = merge.method ?? "repo-default";
|
|
271
1779
|
const args = ["pr", "merge", input.prUrl];
|
|
272
1780
|
if (method === "repo-default") {
|
|
@@ -274,6 +1782,7 @@ async function runRepoDefaultMerge(input) {
|
|
|
274
1782
|
} else {
|
|
275
1783
|
args.push(`--${method}`);
|
|
276
1784
|
}
|
|
1785
|
+
args.push("--match-head-commit", matchHeadSha);
|
|
277
1786
|
if (merge.deleteBranch === true) {
|
|
278
1787
|
args.push("--delete-branch");
|
|
279
1788
|
}
|
|
@@ -282,6 +1791,23 @@ async function runRepoDefaultMerge(input) {
|
|
|
282
1791
|
}
|
|
283
1792
|
await runChecked(input.command, args, input.cwd);
|
|
284
1793
|
}
|
|
1794
|
+
function shouldAttemptRigMerge(config) {
|
|
1795
|
+
const mode = config?.merge?.mode;
|
|
1796
|
+
return mode !== "off" && mode !== "pr-ready";
|
|
1797
|
+
}
|
|
1798
|
+
function isPendingOnlyGate(result) {
|
|
1799
|
+
return result.pending && result.reasonDetails.length > 0 && result.reasonDetails.every((reason) => reason.reasonClass === "pending" && reason.suggestedAction === "wait");
|
|
1800
|
+
}
|
|
1801
|
+
async function syncBranchAfterPrFeedback(input) {
|
|
1802
|
+
if (!input.gitCommand)
|
|
1803
|
+
return;
|
|
1804
|
+
await commitRunChanges({
|
|
1805
|
+
cwd: input.projectRoot,
|
|
1806
|
+
message: `rig: address PR feedback for task ${input.taskId}`,
|
|
1807
|
+
command: input.gitCommand
|
|
1808
|
+
});
|
|
1809
|
+
await runChecked(input.gitCommand, ["push", "--set-upstream", "origin", input.branch], input.projectRoot, "git");
|
|
1810
|
+
}
|
|
285
1811
|
async function runPrAutomation(input) {
|
|
286
1812
|
const prConfig = input.config?.pr ?? {};
|
|
287
1813
|
if (prConfig.mode === "off" || prConfig.mode === "ask") {
|
|
@@ -314,48 +1840,125 @@ ${createResult.stdout ?? ""}`) : null;
|
|
|
314
1840
|
throw new Error(`gh ${createArgs.join(" ")} failed (${createResult.exitCode}): ${createResult.stderr ?? createResult.stdout ?? ""}`.trim());
|
|
315
1841
|
}
|
|
316
1842
|
const prUrl = existingPrUrl ?? normalizePrUrl(createResult.stdout);
|
|
1843
|
+
if (existingPrUrl) {
|
|
1844
|
+
await ensureExistingPrBodyHasRigMarkers({ prUrl, body, command: input.command, cwd: input.projectRoot });
|
|
1845
|
+
}
|
|
317
1846
|
await input.lifecycle?.onPrOpened?.({ prUrl });
|
|
318
1847
|
const { maxPrFixIterations } = resolvePrAutomationLimits(input.config);
|
|
319
1848
|
let latestFeedback = [];
|
|
1849
|
+
let pendingElapsedMs = 0;
|
|
1850
|
+
const shouldMerge = shouldAttemptRigMerge(input.config);
|
|
320
1851
|
for (let iteration = 1;iteration <= maxPrFixIterations; iteration += 1) {
|
|
321
1852
|
await input.lifecycle?.onReviewCiStarted?.({ prUrl, iteration });
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1853
|
+
if (!shouldMerge) {
|
|
1854
|
+
const checks = prConfig.watchChecks === false ? [] : await readPrChecks({ prUrl, command: input.command, cwd: input.projectRoot });
|
|
1855
|
+
const reviewThreads = prConfig.autoFixReview === false ? [] : parsePrViewReviewThreads((await runChecked(input.command, ["pr", "view", prUrl, "--json", "reviewDecision,reviews"], input.projectRoot)).stdout);
|
|
1856
|
+
latestFeedback = collectActionablePrFeedback({
|
|
1857
|
+
checks,
|
|
1858
|
+
reviewThreads,
|
|
1859
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? []
|
|
1860
|
+
});
|
|
1861
|
+
const pendingChecks = collectPendingPrChecks({ checks, allowedFailures: input.config?.merge?.allowedFailures ?? [] });
|
|
1862
|
+
if (latestFeedback.length === 0 && pendingChecks.length > 0) {
|
|
1863
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
1864
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
1865
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
1866
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: pendingChecks.map((name) => `Check still pending: ${name}`), merged: false };
|
|
1867
|
+
}
|
|
1868
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
1869
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
1870
|
+
pendingElapsedMs += sleepMs;
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
if (latestFeedback.length === 0) {
|
|
1874
|
+
pendingElapsedMs = 0;
|
|
1875
|
+
return { status: "opened", prUrl, iterations: iteration, actionableFeedback: [], merged: false };
|
|
1876
|
+
}
|
|
1877
|
+
pendingElapsedMs = 0;
|
|
1878
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
1879
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
1880
|
+
}
|
|
1881
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
1882
|
+
await input.steerPi([
|
|
1883
|
+
`PR automation found actionable feedback on ${prUrl}.`,
|
|
1884
|
+
`Fix iteration ${iteration + 1}/${maxPrFixIterations}.`,
|
|
1885
|
+
"",
|
|
1886
|
+
...latestFeedback.map((entry) => `- ${entry}`)
|
|
1887
|
+
].join(`
|
|
1888
|
+
`));
|
|
1889
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
const gate = await runStrictPrMergeGate({
|
|
1893
|
+
projectRoot: input.projectRoot,
|
|
1894
|
+
prUrl,
|
|
1895
|
+
taskId: input.taskId,
|
|
1896
|
+
runId: input.runId,
|
|
1897
|
+
cycle: iteration,
|
|
1898
|
+
command: input.command,
|
|
1899
|
+
artifactRoot: input.artifactRoot,
|
|
1900
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? [],
|
|
1901
|
+
greptileApi: input.greptileApi
|
|
328
1902
|
});
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
1903
|
+
latestFeedback = [...gate.actionableFeedback];
|
|
1904
|
+
if (gate.approved) {
|
|
1905
|
+
pendingElapsedMs = 0;
|
|
1906
|
+
const finalGate = await runStrictPrMergeGate({
|
|
1907
|
+
projectRoot: input.projectRoot,
|
|
1908
|
+
prUrl,
|
|
1909
|
+
taskId: input.taskId,
|
|
1910
|
+
runId: input.runId,
|
|
1911
|
+
cycle: iteration,
|
|
1912
|
+
command: input.command,
|
|
1913
|
+
artifactRoot: input.artifactRoot,
|
|
1914
|
+
allowedFailures: input.config?.merge?.allowedFailures ?? [],
|
|
1915
|
+
greptileApi: input.greptileApi,
|
|
1916
|
+
final: true
|
|
1917
|
+
});
|
|
1918
|
+
if (finalGate.approved) {
|
|
1919
|
+
await input.lifecycle?.onMergeStarted?.({ prUrl });
|
|
1920
|
+
await runRepoDefaultMerge({ prUrl, config: input.config, command: input.command, cwd: input.projectRoot, strictGate: finalGate });
|
|
1921
|
+
await input.lifecycle?.onMerged?.({ prUrl });
|
|
1922
|
+
return { status: "merged", prUrl, iterations: iteration, actionableFeedback: [], merged: true };
|
|
1923
|
+
}
|
|
1924
|
+
latestFeedback = [...finalGate.actionableFeedback];
|
|
1925
|
+
if (isPendingOnlyGate(finalGate)) {
|
|
1926
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
1927
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
1928
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
1929
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
1930
|
+
}
|
|
1931
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
1932
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
1933
|
+
pendingElapsedMs += sleepMs;
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
1937
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
335
1938
|
}
|
|
336
|
-
await
|
|
1939
|
+
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
1940
|
+
await input.steerPi(finalGate.steeringPrompt);
|
|
1941
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
|
|
337
1942
|
continue;
|
|
338
1943
|
}
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
1944
|
+
if (isPendingOnlyGate(gate)) {
|
|
1945
|
+
const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
|
|
1946
|
+
const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
|
|
1947
|
+
if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
|
|
1948
|
+
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
342
1949
|
}
|
|
343
|
-
|
|
344
|
-
await
|
|
345
|
-
|
|
346
|
-
|
|
1950
|
+
const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
|
|
1951
|
+
await (input.sleep ?? Bun.sleep)(sleepMs);
|
|
1952
|
+
pendingElapsedMs += sleepMs;
|
|
1953
|
+
continue;
|
|
347
1954
|
}
|
|
1955
|
+
pendingElapsedMs = 0;
|
|
348
1956
|
if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
|
|
349
1957
|
return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
|
|
350
1958
|
}
|
|
351
1959
|
await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
|
|
352
|
-
await input.steerPi(
|
|
353
|
-
|
|
354
|
-
`Fix iteration ${iteration + 1}/${maxPrFixIterations}.`,
|
|
355
|
-
"",
|
|
356
|
-
...latestFeedback.map((entry) => `- ${entry}`)
|
|
357
|
-
].join(`
|
|
358
|
-
`));
|
|
1960
|
+
await input.steerPi(gate.steeringPrompt);
|
|
1961
|
+
await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
|
|
359
1962
|
}
|
|
360
1963
|
return { status: "needs_attention", prUrl, iterations: maxPrFixIterations, actionableFeedback: latestFeedback, merged: false };
|
|
361
1964
|
}
|