@h-rig/runtime 0.0.6-alpha.13 → 0.0.6-alpha.15

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.
@@ -2,6 +2,51 @@
2
2
  // packages/runtime/src/control-plane/native/pr-review-gate.ts
3
3
  import { mkdirSync, writeFileSync } from "fs";
4
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
5
50
  function parseJsonObject(value) {
6
51
  if (!value?.trim())
7
52
  return { value: {}, error: "empty JSON output" };
@@ -109,7 +154,7 @@ function stripHtml(input) {
109
154
  }
110
155
  function containsBlockerText(input) {
111
156
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
112
- return /not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this/i.test(text);
157
+ 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);
113
158
  }
114
159
  function isStrictFiveOfFive(score) {
115
160
  return score.value === 5 && score.scale === 5;
@@ -117,6 +162,189 @@ function isStrictFiveOfFive(score) {
117
162
  function containsConflictingScoreText(input) {
118
163
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
119
164
  }
165
+ function greptileStatusVerdict(status) {
166
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
167
+ if (!normalized)
168
+ return null;
169
+ if (["APPROVE", "APPROVED"].includes(normalized))
170
+ return "approved";
171
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
172
+ return "rejected";
173
+ if (["SKIP", "SKIPPED"].includes(normalized))
174
+ return "skipped";
175
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
176
+ return "failed";
177
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
178
+ return "pending";
179
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
180
+ return "completed";
181
+ return null;
182
+ }
183
+ function isBlockingGreptileVerdict(verdict) {
184
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
185
+ }
186
+ function greptileRequestTimeoutMs(env) {
187
+ const fallback = 30000;
188
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
189
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
190
+ }
191
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
192
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
193
+ return null;
194
+ const record = entry;
195
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
196
+ if (!id)
197
+ return null;
198
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
199
+ return {
200
+ id,
201
+ status: typeof record.status === "string" ? record.status : null,
202
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
203
+ body: typeof record.body === "string" ? record.body : null,
204
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
205
+ };
206
+ }
207
+ function uniqueGreptileCodeReviews(reviews) {
208
+ const seen = new Set;
209
+ const unique = [];
210
+ for (const review of reviews) {
211
+ if (seen.has(review.id))
212
+ continue;
213
+ seen.add(review.id);
214
+ unique.push(review);
215
+ }
216
+ return unique;
217
+ }
218
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
219
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
220
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
221
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
222
+ const latest = sorted.slice(0, 1);
223
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
224
+ }
225
+ function greptileApiSignalFromCodeReview(review, details) {
226
+ const selected = details ?? review;
227
+ return {
228
+ id: selected.id || review.id,
229
+ body: selected.body ?? review.body ?? null,
230
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
231
+ status: selected.status ?? review.status ?? null
232
+ };
233
+ }
234
+ async function callGreptileMcpToolForGate(input) {
235
+ const controller = new AbortController;
236
+ const timeoutId = setTimeout(() => {
237
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
238
+ }, input.timeoutMs);
239
+ let response;
240
+ try {
241
+ response = await input.fetchFn(input.apiBase, {
242
+ method: "POST",
243
+ headers: {
244
+ Authorization: `Bearer ${input.apiKey}`,
245
+ "Content-Type": "application/json"
246
+ },
247
+ body: JSON.stringify({
248
+ jsonrpc: "2.0",
249
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
250
+ method: "tools/call",
251
+ params: { name: input.name, arguments: input.args }
252
+ }),
253
+ signal: controller.signal
254
+ });
255
+ } catch (error) {
256
+ if (controller.signal.aborted) {
257
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
258
+ }
259
+ throw error;
260
+ } finally {
261
+ clearTimeout(timeoutId);
262
+ }
263
+ const raw = await response.text();
264
+ if (!response.ok) {
265
+ throw new Error(`HTTP ${response.status}: ${raw}`);
266
+ }
267
+ let envelope;
268
+ try {
269
+ envelope = JSON.parse(raw);
270
+ } catch {
271
+ throw new Error(`Malformed MCP response: ${raw}`);
272
+ }
273
+ if (envelope.error?.message) {
274
+ throw new Error(envelope.error.message);
275
+ }
276
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
277
+ `).trim();
278
+ if (!text) {
279
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
280
+ }
281
+ return text;
282
+ }
283
+ async function callGreptileMcpToolJsonForGate(input) {
284
+ const text = await callGreptileMcpToolForGate(input);
285
+ try {
286
+ return JSON.parse(text);
287
+ } catch {
288
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
289
+ }
290
+ }
291
+ async function collectConfiguredGreptileApiSignals(input) {
292
+ if (!input.enabled || input.options?.enabled === false) {
293
+ return { signals: [], errors: [] };
294
+ }
295
+ const env = input.options?.env ?? process.env;
296
+ const secrets = resolveRuntimeSecrets(env);
297
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
298
+ if (!apiKey) {
299
+ return { signals: [], errors: [] };
300
+ }
301
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
302
+ if (typeof fetchFn !== "function") {
303
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
304
+ }
305
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
306
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
307
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
308
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
309
+ const timeoutMs = greptileRequestTimeoutMs(env);
310
+ try {
311
+ const listPayload = await callGreptileMcpToolJsonForGate({
312
+ apiBase,
313
+ apiKey,
314
+ name: "list_code_reviews",
315
+ args: {
316
+ name: repository,
317
+ remote,
318
+ defaultBranch,
319
+ prNumber: input.prNumber,
320
+ limit: 20
321
+ },
322
+ timeoutMs,
323
+ fetchFn
324
+ });
325
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
326
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
327
+ const signals = [];
328
+ for (const review of selectedReviews) {
329
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
330
+ apiBase,
331
+ apiKey,
332
+ name: "get_code_review",
333
+ args: { codeReviewId: review.id },
334
+ timeoutMs,
335
+ fetchFn
336
+ });
337
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
338
+ signals.push(greptileApiSignalFromCodeReview(review, details));
339
+ }
340
+ return { signals, errors: [] };
341
+ } catch (error) {
342
+ return {
343
+ signals: [],
344
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
345
+ };
346
+ }
347
+ }
120
348
  function firstString(record, keys) {
121
349
  for (const key of keys) {
122
350
  const value = record[key];
@@ -243,7 +471,7 @@ function normalizeReviewThread(entry) {
243
471
  function relevantIssueComment(comment) {
244
472
  const login = comment.user?.login ?? comment.author?.login ?? "";
245
473
  const body = comment.body ?? "";
246
- return isGreptileGithubLogin(login) || /greptile|blocker|unsafe|not safe|do not merge|must fix|please fix|changes requested|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
474
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
247
475
  }
248
476
  function latestThreadComment(thread) {
249
477
  const nodes = thread.comments?.nodes ?? [];
@@ -279,7 +507,8 @@ function makeGreptileSignal(input) {
279
507
  const scores = parseGreptileScores(input.body);
280
508
  const reviewedSha = input.reviewedSha?.trim() || null;
281
509
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
282
- const blocker = input.blocker ?? containsBlockerText(input.body);
510
+ const verdict = input.verdict ?? null;
511
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
283
512
  const explicitApproval = input.explicitApproval ?? false;
284
513
  return {
285
514
  source: input.source,
@@ -291,6 +520,7 @@ function makeGreptileSignal(input) {
291
520
  score: scores[0] ?? null,
292
521
  scores,
293
522
  explicitApproval,
523
+ verdict,
294
524
  blocker,
295
525
  actionable: input.actionable ?? blocker,
296
526
  bodyExcerpt: bodyExcerpt(input.body),
@@ -313,9 +543,9 @@ function collectGreptileSignals(evidence) {
313
543
  for (const context of contextSources) {
314
544
  if (!context.body.trim())
315
545
  continue;
316
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
317
- continue;
318
546
  const contextBlocker = containsBlockerText(context.body);
547
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
548
+ continue;
319
549
  signals.push(makeGreptileSignal({
320
550
  source: context.source,
321
551
  body: context.body,
@@ -328,16 +558,16 @@ function collectGreptileSignals(evidence) {
328
558
  for (const apiSignal of evidence.apiSignals ?? []) {
329
559
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
330
560
 
331
- `);
332
- if (!body.trim())
333
- continue;
561
+ `) || "Status: UNKNOWN";
562
+ const verdict = greptileStatusVerdict(apiSignal.status);
334
563
  signals.push(makeGreptileSignal({
335
564
  source: "api",
336
565
  body,
337
566
  currentHeadSha: evidence.currentHeadSha,
338
567
  trusted: true,
339
568
  reviewedSha: apiSignal.reviewedSha ?? null,
340
- explicitApproval: false
569
+ explicitApproval: verdict === "approved",
570
+ verdict
341
571
  }));
342
572
  }
343
573
  for (const review of evidence.reviews) {
@@ -362,20 +592,6 @@ function collectGreptileSignals(evidence) {
362
592
  blocker: state === "CHANGES_REQUESTED" || undefined
363
593
  }));
364
594
  }
365
- for (const comment of evidence.changedFileReviewComments) {
366
- const login = commentAuthorLogin(comment);
367
- const body = comment.body ?? "";
368
- if (!body.trim() || !isGreptileGithubLogin(login))
369
- continue;
370
- signals.push(makeGreptileSignal({
371
- source: "changed-file-comment",
372
- body,
373
- currentHeadSha: evidence.currentHeadSha,
374
- trusted: true,
375
- authorLogin: login,
376
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
377
- }));
378
- }
379
595
  for (const comment of evidence.relevantIssueComments) {
380
596
  const login = commentAuthorLogin(comment);
381
597
  const body = comment.body ?? "";
@@ -483,10 +699,17 @@ function deriveGreptileEvidence(input) {
483
699
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
484
700
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
485
701
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
486
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
702
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
703
+ const signalCanApproveByScore = (signal) => {
704
+ if (signal.source === "api")
705
+ return signal.verdict === "approved" || signal.verdict === "completed";
706
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
707
+ };
708
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
709
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
487
710
  const approvedByScore = !!approvingScoreEntry;
488
- const approvedByExplicitMapping = false;
489
- const approvingSignal = approvingScoreEntry?.signal ?? null;
711
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
712
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
490
713
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
491
714
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
492
715
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
@@ -515,13 +738,14 @@ function deriveGreptileEvidence(input) {
515
738
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
516
739
  return completedState && review.commit_id === input.currentHeadSha;
517
740
  });
741
+ 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"));
518
742
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
519
743
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
520
744
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
521
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
745
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
522
746
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
523
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
524
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
747
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
748
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
525
749
  const source = approvingSignal?.source === "api" ? "api" : approvingSignal?.source === "github-review" ? "github-review" : approvingSignal?.source === "changed-file-comment" || approvingSignal?.source === "issue-comment" || approvingSignal?.source === "review-thread" ? "github-comment" : greptileReviews.length > 0 && greptileChecks.length > 0 ? "combined" : greptileReviews.length > 0 ? "github-review" : greptileChecks.length > 0 ? "github-check" : signals.some((signal) => signal.source === "pr-body" || signal.source === "pr-title") ? "pr-body" : "missing";
526
750
  return {
527
751
  source,
@@ -628,6 +852,7 @@ async function collectPrReviewEvidence(input) {
628
852
  readErrors.push("gh pr view did not return required reviews array");
629
853
  }
630
854
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
855
+ const baseRefName = firstString(view, ["baseRefName"]);
631
856
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
632
857
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
633
858
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -665,8 +890,19 @@ async function collectPrReviewEvidence(input) {
665
890
  }
666
891
  }
667
892
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
893
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
894
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
895
+ enabled: shouldCollectConfiguredGreptileApi,
896
+ options: input.greptileApi,
897
+ repoName: parsed.repoName,
898
+ prNumber: parsed.prNumber,
899
+ headSha,
900
+ baseRefName
901
+ });
902
+ readErrors.push(...configuredGreptileApiRead.errors);
903
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
668
904
  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})` : ""}`);
669
- const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
905
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
670
906
  const evidenceBase = {
671
907
  title: firstString(view, ["title"]),
672
908
  body: firstString(view, ["body"]),
@@ -676,7 +912,7 @@ async function collectPrReviewEvidence(input) {
676
912
  reviewThreads,
677
913
  checks: checksWithGreptileDetails,
678
914
  currentHeadSha: headSha,
679
- apiSignals: input.apiSignals ?? []
915
+ apiSignals
680
916
  };
681
917
  const greptile = deriveGreptileEvidence(evidenceBase);
682
918
  return {
@@ -687,7 +923,7 @@ async function collectPrReviewEvidence(input) {
687
923
  body: evidenceBase.body,
688
924
  headSha,
689
925
  headRefName: firstString(view, ["headRefName"]),
690
- baseRefName: firstString(view, ["baseRefName"]),
926
+ baseRefName,
691
927
  state: firstString(view, ["state"]),
692
928
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
693
929
  mergeable: firstString(view, ["mergeable"]),
@@ -704,71 +940,251 @@ async function collectPrReviewEvidence(input) {
704
940
  greptile
705
941
  };
706
942
  }
943
+ function capGateMessage(value, maxChars = 1200) {
944
+ const normalized = value.trim();
945
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
946
+ [truncated for gate summary; see full evidence artifact]` : normalized;
947
+ }
707
948
  function evaluateEvidence(evidence) {
708
- const reasons = [];
949
+ const reasonDetails = [];
709
950
  const warnings = [];
710
- let pending = false;
711
- if (evidence.readErrors.length > 0) {
712
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
713
- }
714
- if (!evidence.headSha)
715
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
716
- if (evidence.checkFailures.length > 0)
717
- reasons.push(...evidence.checkFailures);
718
- if (evidence.pendingChecks.length > 0) {
719
- pending = true;
720
- reasons.push(...evidence.pendingChecks);
951
+ const seen = new Set;
952
+ const addReason = (reason) => {
953
+ const capped = { ...reason, message: capGateMessage(reason.message) };
954
+ const key = `${capped.code}:${capped.message}`;
955
+ if (seen.has(key))
956
+ return;
957
+ seen.add(key);
958
+ reasonDetails.push(capped);
959
+ };
960
+ const greptile = evidence.greptile;
961
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
962
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
963
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
964
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
965
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
966
+ for (const error of evidence.readErrors) {
967
+ addReason({
968
+ code: "read_error",
969
+ reasonClass: "reject",
970
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
971
+ suggestedAction: "needs_attention",
972
+ message: `Required PR evidence surface could not be read completely: ${error}`,
973
+ headSha: evidence.headSha || null
974
+ });
975
+ }
976
+ if (!evidence.headSha) {
977
+ addReason({
978
+ code: "missing_head_sha",
979
+ reasonClass: "reject",
980
+ surface: "github",
981
+ suggestedAction: "needs_attention",
982
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
983
+ headSha: null
984
+ });
985
+ }
986
+ for (const failure of evidence.checkFailures) {
987
+ addReason({
988
+ code: "ci_failed",
989
+ reasonClass: "reject",
990
+ surface: "ci",
991
+ suggestedAction: "fix",
992
+ message: failure,
993
+ headSha: evidence.headSha || null
994
+ });
995
+ }
996
+ for (const pendingCheck of evidence.pendingChecks) {
997
+ addReason({
998
+ code: "check_pending",
999
+ reasonClass: "pending",
1000
+ surface: "ci",
1001
+ suggestedAction: "wait",
1002
+ message: pendingCheck,
1003
+ headSha: evidence.headSha || null
1004
+ });
721
1005
  }
722
1006
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
723
1007
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
724
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
1008
+ addReason({
1009
+ code: "review_decision_blocking",
1010
+ reasonClass: "reject",
1011
+ surface: "review",
1012
+ suggestedAction: "fix",
1013
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
1014
+ headSha: evidence.headSha || null
1015
+ });
1016
+ }
1017
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
1018
+ addReason({
1019
+ code: "review_thread_unresolved",
1020
+ reasonClass: "reject",
1021
+ surface: "review",
1022
+ suggestedAction: "fix",
1023
+ message: thread,
1024
+ headSha: evidence.headSha || null
1025
+ });
1026
+ }
1027
+ if (greptile.mapping === "missing") {
1028
+ addReason({
1029
+ code: "greptile_missing",
1030
+ reasonClass: "pending",
1031
+ surface: "greptile",
1032
+ suggestedAction: "wait",
1033
+ message: "Missing Greptile check/review evidence for this PR.",
1034
+ headSha: evidence.headSha || null
1035
+ });
725
1036
  }
726
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
727
- if (unresolvedThreads.length > 0)
728
- reasons.push(...unresolvedThreads);
729
- const greptile = evidence.greptile;
730
- if (greptile.mapping === "missing")
731
- reasons.push("Missing Greptile check/review evidence for this PR.");
732
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
733
1037
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
734
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
1038
+ addReason({
1039
+ code: "greptile_stale",
1040
+ reasonClass: "pending",
1041
+ surface: "greptile",
1042
+ suggestedAction: "wait",
1043
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
1044
+ headSha: evidence.headSha || null,
1045
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
1046
+ });
1047
+ }
1048
+ for (const signal of pendingGreptileApiSignals) {
1049
+ addReason({
1050
+ code: "greptile_pending",
1051
+ reasonClass: "pending",
1052
+ surface: "greptile",
1053
+ suggestedAction: "wait",
1054
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
1055
+ headSha: evidence.headSha || null,
1056
+ reviewedSha: signal.reviewedSha ?? null
1057
+ });
1058
+ }
1059
+ for (const signal of unknownGreptileApiSignals) {
1060
+ addReason({
1061
+ code: "greptile_api_status_unknown",
1062
+ reasonClass: "reject",
1063
+ surface: "greptile",
1064
+ suggestedAction: "needs_attention",
1065
+ 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}` : "."}`,
1066
+ headSha: evidence.headSha || null,
1067
+ reviewedSha: signal.reviewedSha ?? null
1068
+ });
735
1069
  }
736
1070
  if (!greptile.completed) {
737
- pending = true;
738
- reasons.push("Greptile check/review has not completed for the current PR head.");
1071
+ addReason({
1072
+ code: "greptile_pending",
1073
+ reasonClass: "pending",
1074
+ surface: "greptile",
1075
+ suggestedAction: "wait",
1076
+ message: "Greptile check/review has not completed for the current PR head.",
1077
+ headSha: evidence.headSha || null,
1078
+ reviewedSha: greptile.reviewedSha ?? null
1079
+ });
1080
+ }
1081
+ if (!greptile.fresh) {
1082
+ addReason({
1083
+ code: "greptile_not_current_head",
1084
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1085
+ surface: "greptile",
1086
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1087
+ message: "Greptile approval is not tied to the current PR head SHA.",
1088
+ headSha: evidence.headSha || null,
1089
+ reviewedSha: greptile.reviewedSha ?? null
1090
+ });
739
1091
  }
740
- if (!greptile.fresh)
741
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
742
1092
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
743
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
1093
+ addReason({
1094
+ code: "greptile_score_not_5",
1095
+ reasonClass: "reject",
1096
+ surface: "greptile",
1097
+ suggestedAction: "fix",
1098
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
1099
+ headSha: evidence.headSha || null,
1100
+ reviewedSha: greptile.reviewedSha ?? null
1101
+ });
744
1102
  }
745
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
746
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
1103
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
1104
+ if (!greptile.score && !hasApprovedMapping) {
1105
+ addReason({
1106
+ code: "greptile_score_missing",
1107
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1108
+ surface: "greptile",
1109
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1110
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
1111
+ headSha: evidence.headSha || null,
1112
+ reviewedSha: greptile.reviewedSha ?? null
1113
+ });
747
1114
  }
748
1115
  if (greptile.mapping === "unproven") {
749
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
1116
+ addReason({
1117
+ code: "greptile_mapping_unproven",
1118
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1119
+ surface: "greptile",
1120
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1121
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
1122
+ headSha: evidence.headSha || null,
1123
+ reviewedSha: greptile.reviewedSha ?? null
1124
+ });
750
1125
  }
751
- if (greptile.blockers.length > 0) {
752
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
1126
+ for (const blocker of greptile.blockers) {
1127
+ addReason({
1128
+ code: "greptile_blocker_text",
1129
+ reasonClass: "reject",
1130
+ surface: "greptile",
1131
+ suggestedAction: "fix",
1132
+ message: `Greptile/blocker text: ${blocker}`,
1133
+ headSha: evidence.headSha || null,
1134
+ reviewedSha: greptile.reviewedSha ?? null
1135
+ });
1136
+ }
1137
+ for (const comment of greptile.unresolvedComments) {
1138
+ addReason({
1139
+ code: "greptile_unresolved_comment",
1140
+ reasonClass: "reject",
1141
+ surface: "greptile",
1142
+ suggestedAction: "fix",
1143
+ message: comment,
1144
+ headSha: evidence.headSha || null,
1145
+ reviewedSha: greptile.reviewedSha ?? null
1146
+ });
753
1147
  }
754
- if (greptile.unresolvedComments.length > 0)
755
- reasons.push(...greptile.unresolvedComments);
756
1148
  if (!greptile.approved)
757
1149
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
758
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
1150
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
1151
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
759
1152
  }
760
1153
  function evaluateStrictPrMergeGate(evidence) {
761
1154
  const evaluated = evaluateEvidence(evidence);
762
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
1155
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
763
1156
  return {
764
1157
  approved,
765
1158
  pending: evaluated.pending,
766
1159
  reasons: evaluated.reasons,
1160
+ reasonDetails: evaluated.reasonDetails,
767
1161
  warnings: evaluated.warnings,
768
1162
  actionableFeedback: evaluated.reasons,
769
1163
  evidence
770
1164
  };
771
1165
  }
1166
+ function strictMergeHeadShaFromGate(result, prUrl) {
1167
+ if (!result.approved) {
1168
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate is not approved.`);
1169
+ }
1170
+ if (result.evidence.prUrl !== prUrl) {
1171
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate evidence belongs to ${result.evidence.prUrl}.`);
1172
+ }
1173
+ const headSha = result.evidence.headSha?.trim();
1174
+ if (!headSha) {
1175
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate did not provide a current head SHA.`);
1176
+ }
1177
+ if (!/^[0-9a-f]{40}$/i.test(headSha)) {
1178
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate head is not a raw 40-character commit SHA.`);
1179
+ }
1180
+ if (!result.evidence.greptile.fresh || result.evidence.greptile.currentHeadSha !== headSha) {
1181
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate approval is not tied to head ${headSha}.`);
1182
+ }
1183
+ if (result.evidence.greptile.mapping !== "score-5-of-5" && result.evidence.greptile.mapping !== "explicit-approved") {
1184
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate mapping is ${result.evidence.greptile.mapping}.`);
1185
+ }
1186
+ return headSha;
1187
+ }
772
1188
  function promptExcerpt(value, maxChars = 4000) {
773
1189
  return value.length > maxChars ? `${value.slice(0, maxChars)}
774
1190
 
@@ -780,6 +1196,10 @@ function promptJsonExcerpt(value, maxChars = 6000) {
780
1196
  function buildStrictPrGateSteeringPrompt(result) {
781
1197
  const evidence = result.evidence;
782
1198
  const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
1199
+ const displayedReasons = result.reasons.slice(0, 20).map((reason) => `- ${promptExcerpt(reason, 1200)}`);
1200
+ if (result.reasons.length > displayedReasons.length) {
1201
+ displayedReasons.push(`- ${result.reasons.length - displayedReasons.length} additional gate reasons omitted from prompt; see merge-gate-result.json.`);
1202
+ }
783
1203
  const lines = [
784
1204
  `Strict PR merge gate blocked ${evidence.prUrl}.`,
785
1205
  `PR title: ${evidence.title || "(empty)"}`,
@@ -788,10 +1208,13 @@ function buildStrictPrGateSteeringPrompt(result) {
788
1208
  evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
789
1209
  "",
790
1210
  "Gate reasons:",
791
- ...result.reasons.length ? result.reasons.map((reason) => `- ${reason}`) : ["- No reasons recorded"],
1211
+ ...displayedReasons.length ? displayedReasons : ["- No reasons recorded"],
1212
+ "",
1213
+ "Structured gate reason details:",
1214
+ result.reasonDetails.length ? promptJsonExcerpt(result.reasonDetails, 4000) : "[]",
792
1215
  "",
793
1216
  "Required evidence read status:",
794
- evidence.readErrors.length ? JSON.stringify(evidence.readErrors, null, 2) : "All required PR evidence surfaces were read completely.",
1217
+ evidence.readErrors.length ? promptJsonExcerpt(evidence.readErrors, 2000) : "All required PR evidence surfaces were read completely.",
795
1218
  "",
796
1219
  "Full PR title:",
797
1220
  evidence.title || "(empty)",
@@ -855,6 +1278,7 @@ function persistPrReviewCycleArtifacts(input) {
855
1278
  approved: input.result.approved,
856
1279
  pending: input.result.pending,
857
1280
  reasons: input.result.reasons,
1281
+ reasonDetails: input.result.reasonDetails,
858
1282
  warnings: input.result.warnings,
859
1283
  actionableFeedback: input.result.actionableFeedback,
860
1284
  prUrl: input.result.evidence.prUrl,
@@ -893,6 +1317,7 @@ async function runStrictPrMergeGate(input) {
893
1317
  }
894
1318
  export {
895
1319
  stripHtml,
1320
+ strictMergeHeadShaFromGate,
896
1321
  runStrictPrMergeGate,
897
1322
  persistPrReviewCycleArtifacts,
898
1323
  parseGreptileScore,