@h-rig/runtime 0.0.6-alpha.12 → 0.0.6-alpha.14

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,13 +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);
113
- }
114
- function containsGreptileNegativeVerdict(input) {
115
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
116
- if (!text)
117
- return false;
118
- return /\b(?:status|verdict|review state|state|conclusion|result)\s*:?\s*(?:reject(?:ed|ion)?|skip(?:ped)?|fail(?:ed|ure)?|changes[_ ]requested|not approved)\b/i.test(text) || /\bgreptile\b.{0,160}\b(?:reject(?:ed|s|ion)?|skip(?:ped|s)?|fail(?:ed|s|ure)?|changes requested|did not approve|not approved)\b/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);
119
158
  }
120
159
  function isStrictFiveOfFive(score) {
121
160
  return score.value === 5 && score.scale === 5;
@@ -123,6 +162,189 @@ function isStrictFiveOfFive(score) {
123
162
  function containsConflictingScoreText(input) {
124
163
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
125
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
+ }
126
348
  function firstString(record, keys) {
127
349
  for (const key of keys) {
128
350
  const value = record[key];
@@ -249,7 +471,7 @@ function normalizeReviewThread(entry) {
249
471
  function relevantIssueComment(comment) {
250
472
  const login = comment.user?.login ?? comment.author?.login ?? "";
251
473
  const body = comment.body ?? "";
252
- 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);
253
475
  }
254
476
  function latestThreadComment(thread) {
255
477
  const nodes = thread.comments?.nodes ?? [];
@@ -285,7 +507,8 @@ function makeGreptileSignal(input) {
285
507
  const scores = parseGreptileScores(input.body);
286
508
  const reviewedSha = input.reviewedSha?.trim() || null;
287
509
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
288
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
510
+ const verdict = input.verdict ?? null;
511
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
289
512
  const explicitApproval = input.explicitApproval ?? false;
290
513
  return {
291
514
  source: input.source,
@@ -297,6 +520,7 @@ function makeGreptileSignal(input) {
297
520
  score: scores[0] ?? null,
298
521
  scores,
299
522
  explicitApproval,
523
+ verdict,
300
524
  blocker,
301
525
  actionable: input.actionable ?? blocker,
302
526
  bodyExcerpt: bodyExcerpt(input.body),
@@ -319,9 +543,9 @@ function collectGreptileSignals(evidence) {
319
543
  for (const context of contextSources) {
320
544
  if (!context.body.trim())
321
545
  continue;
322
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
323
- continue;
324
546
  const contextBlocker = containsBlockerText(context.body);
547
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
548
+ continue;
325
549
  signals.push(makeGreptileSignal({
326
550
  source: context.source,
327
551
  body: context.body,
@@ -334,16 +558,16 @@ function collectGreptileSignals(evidence) {
334
558
  for (const apiSignal of evidence.apiSignals ?? []) {
335
559
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
336
560
 
337
- `);
338
- if (!body.trim())
339
- continue;
561
+ `) || "Status: UNKNOWN";
562
+ const verdict = greptileStatusVerdict(apiSignal.status);
340
563
  signals.push(makeGreptileSignal({
341
564
  source: "api",
342
565
  body,
343
566
  currentHeadSha: evidence.currentHeadSha,
344
567
  trusted: true,
345
568
  reviewedSha: apiSignal.reviewedSha ?? null,
346
- explicitApproval: false
569
+ explicitApproval: verdict === "approved",
570
+ verdict
347
571
  }));
348
572
  }
349
573
  for (const review of evidence.reviews) {
@@ -368,20 +592,6 @@ function collectGreptileSignals(evidence) {
368
592
  blocker: state === "CHANGES_REQUESTED" || undefined
369
593
  }));
370
594
  }
371
- for (const comment of evidence.changedFileReviewComments) {
372
- const login = commentAuthorLogin(comment);
373
- const body = comment.body ?? "";
374
- if (!body.trim() || !isGreptileGithubLogin(login))
375
- continue;
376
- signals.push(makeGreptileSignal({
377
- source: "changed-file-comment",
378
- body,
379
- currentHeadSha: evidence.currentHeadSha,
380
- trusted: true,
381
- authorLogin: login,
382
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
383
- }));
384
- }
385
595
  for (const comment of evidence.relevantIssueComments) {
386
596
  const login = commentAuthorLogin(comment);
387
597
  const body = comment.body ?? "";
@@ -447,6 +657,9 @@ function unresolvedGreptileThreadSummaries(threads) {
447
657
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
448
658
  });
449
659
  }
660
+ function actionableChangedFileCommentSummaries(_comments) {
661
+ return [];
662
+ }
450
663
  function issueLevelBlockerSummaries(comments) {
451
664
  return comments.flatMap((comment) => {
452
665
  const body = comment.body?.trim() ?? "";
@@ -486,14 +699,21 @@ function deriveGreptileEvidence(input) {
486
699
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
487
700
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
488
701
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
489
- 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;
490
710
  const approvedByScore = !!approvingScoreEntry;
491
- const approvedByExplicitMapping = false;
492
- const approvingSignal = approvingScoreEntry?.signal ?? null;
711
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
712
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
493
713
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
494
714
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
495
715
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
496
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
716
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
497
717
  const staleBlockingSignals = [];
498
718
  const blockers = [
499
719
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -504,7 +724,8 @@ function deriveGreptileEvidence(input) {
504
724
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
505
725
  ];
506
726
  const unresolvedComments = [
507
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
727
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
728
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
508
729
  ];
509
730
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
510
731
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -517,13 +738,14 @@ function deriveGreptileEvidence(input) {
517
738
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
518
739
  return completedState && review.commit_id === input.currentHeadSha;
519
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"));
520
742
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
521
743
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
522
744
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
523
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
745
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
524
746
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
525
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
526
- 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";
527
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";
528
750
  return {
529
751
  source,
@@ -630,6 +852,7 @@ async function collectPrReviewEvidence(input) {
630
852
  readErrors.push("gh pr view did not return required reviews array");
631
853
  }
632
854
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
855
+ const baseRefName = firstString(view, ["baseRefName"]);
633
856
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
634
857
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
635
858
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -667,6 +890,17 @@ async function collectPrReviewEvidence(input) {
667
890
  }
668
891
  }
669
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];
670
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})` : ""}`);
671
905
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
672
906
  const evidenceBase = {
@@ -678,7 +912,7 @@ async function collectPrReviewEvidence(input) {
678
912
  reviewThreads,
679
913
  checks: checksWithGreptileDetails,
680
914
  currentHeadSha: headSha,
681
- apiSignals: input.apiSignals ?? []
915
+ apiSignals
682
916
  };
683
917
  const greptile = deriveGreptileEvidence(evidenceBase);
684
918
  return {
@@ -689,7 +923,7 @@ async function collectPrReviewEvidence(input) {
689
923
  body: evidenceBase.body,
690
924
  headSha,
691
925
  headRefName: firstString(view, ["headRefName"]),
692
- baseRefName: firstString(view, ["baseRefName"]),
926
+ baseRefName,
693
927
  state: firstString(view, ["state"]),
694
928
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
695
929
  mergeable: firstString(view, ["mergeable"]),
@@ -706,71 +940,251 @@ async function collectPrReviewEvidence(input) {
706
940
  greptile
707
941
  };
708
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
+ }
709
948
  function evaluateEvidence(evidence) {
710
- const reasons = [];
949
+ const reasonDetails = [];
711
950
  const warnings = [];
712
- let pending = false;
713
- if (evidence.readErrors.length > 0) {
714
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
715
- }
716
- if (!evidence.headSha)
717
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
718
- if (evidence.checkFailures.length > 0)
719
- reasons.push(...evidence.checkFailures);
720
- if (evidence.pendingChecks.length > 0) {
721
- pending = true;
722
- 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
+ });
723
1005
  }
724
1006
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
725
1007
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
726
- 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
+ });
727
1036
  }
728
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
729
- if (unresolvedThreads.length > 0)
730
- reasons.push(...unresolvedThreads);
731
- const greptile = evidence.greptile;
732
- if (greptile.mapping === "missing")
733
- reasons.push("Missing Greptile check/review evidence for this PR.");
734
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
735
1037
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
736
- 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
+ });
737
1069
  }
738
1070
  if (!greptile.completed) {
739
- pending = true;
740
- 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
+ });
741
1091
  }
742
- if (!greptile.fresh)
743
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
744
1092
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
745
- 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
+ });
746
1102
  }
747
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
748
- 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
+ });
749
1114
  }
750
1115
  if (greptile.mapping === "unproven") {
751
- 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
+ });
752
1125
  }
753
- if (greptile.blockers.length > 0) {
754
- 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
+ });
755
1147
  }
756
- if (greptile.unresolvedComments.length > 0)
757
- reasons.push(...greptile.unresolvedComments);
758
1148
  if (!greptile.approved)
759
1149
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
760
- 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 };
761
1152
  }
762
1153
  function evaluateStrictPrMergeGate(evidence) {
763
1154
  const evaluated = evaluateEvidence(evidence);
764
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
1155
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
765
1156
  return {
766
1157
  approved,
767
1158
  pending: evaluated.pending,
768
1159
  reasons: evaluated.reasons,
1160
+ reasonDetails: evaluated.reasonDetails,
769
1161
  warnings: evaluated.warnings,
770
1162
  actionableFeedback: evaluated.reasons,
771
1163
  evidence
772
1164
  };
773
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
+ }
774
1188
  function promptExcerpt(value, maxChars = 4000) {
775
1189
  return value.length > maxChars ? `${value.slice(0, maxChars)}
776
1190
 
@@ -782,6 +1196,10 @@ function promptJsonExcerpt(value, maxChars = 6000) {
782
1196
  function buildStrictPrGateSteeringPrompt(result) {
783
1197
  const evidence = result.evidence;
784
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
+ }
785
1203
  const lines = [
786
1204
  `Strict PR merge gate blocked ${evidence.prUrl}.`,
787
1205
  `PR title: ${evidence.title || "(empty)"}`,
@@ -790,10 +1208,13 @@ function buildStrictPrGateSteeringPrompt(result) {
790
1208
  evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
791
1209
  "",
792
1210
  "Gate reasons:",
793
- ...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) : "[]",
794
1215
  "",
795
1216
  "Required evidence read status:",
796
- 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.",
797
1218
  "",
798
1219
  "Full PR title:",
799
1220
  evidence.title || "(empty)",
@@ -857,6 +1278,7 @@ function persistPrReviewCycleArtifacts(input) {
857
1278
  approved: input.result.approved,
858
1279
  pending: input.result.pending,
859
1280
  reasons: input.result.reasons,
1281
+ reasonDetails: input.result.reasonDetails,
860
1282
  warnings: input.result.warnings,
861
1283
  actionableFeedback: input.result.actionableFeedback,
862
1284
  prUrl: input.result.evidence.prUrl,
@@ -895,6 +1317,7 @@ async function runStrictPrMergeGate(input) {
895
1317
  }
896
1318
  export {
897
1319
  stripHtml,
1320
+ strictMergeHeadShaFromGate,
898
1321
  runStrictPrMergeGate,
899
1322
  persistPrReviewCycleArtifacts,
900
1323
  parseGreptileScore,