@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.
@@ -3714,7 +3714,7 @@ function stripHtml(input) {
3714
3714
  }
3715
3715
  function containsBlockerText(input) {
3716
3716
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
3717
- 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);
3717
+ 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);
3718
3718
  }
3719
3719
  function isStrictFiveOfFive(score) {
3720
3720
  return score.value === 5 && score.scale === 5;
@@ -3722,6 +3722,189 @@ function isStrictFiveOfFive(score) {
3722
3722
  function containsConflictingScoreText(input) {
3723
3723
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3724
3724
  }
3725
+ function greptileStatusVerdict(status) {
3726
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
3727
+ if (!normalized)
3728
+ return null;
3729
+ if (["APPROVE", "APPROVED"].includes(normalized))
3730
+ return "approved";
3731
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
3732
+ return "rejected";
3733
+ if (["SKIP", "SKIPPED"].includes(normalized))
3734
+ return "skipped";
3735
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
3736
+ return "failed";
3737
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
3738
+ return "pending";
3739
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
3740
+ return "completed";
3741
+ return null;
3742
+ }
3743
+ function isBlockingGreptileVerdict(verdict) {
3744
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
3745
+ }
3746
+ function greptileRequestTimeoutMs(env) {
3747
+ const fallback = 30000;
3748
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
3749
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
3750
+ }
3751
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
3752
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3753
+ return null;
3754
+ const record = entry;
3755
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
3756
+ if (!id)
3757
+ return null;
3758
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
3759
+ return {
3760
+ id,
3761
+ status: typeof record.status === "string" ? record.status : null,
3762
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
3763
+ body: typeof record.body === "string" ? record.body : null,
3764
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
3765
+ };
3766
+ }
3767
+ function uniqueGreptileCodeReviews(reviews) {
3768
+ const seen = new Set;
3769
+ const unique2 = [];
3770
+ for (const review of reviews) {
3771
+ if (seen.has(review.id))
3772
+ continue;
3773
+ seen.add(review.id);
3774
+ unique2.push(review);
3775
+ }
3776
+ return unique2;
3777
+ }
3778
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
3779
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
3780
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
3781
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
3782
+ const latest = sorted.slice(0, 1);
3783
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
3784
+ }
3785
+ function greptileApiSignalFromCodeReview(review, details) {
3786
+ const selected = details ?? review;
3787
+ return {
3788
+ id: selected.id || review.id,
3789
+ body: selected.body ?? review.body ?? null,
3790
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
3791
+ status: selected.status ?? review.status ?? null
3792
+ };
3793
+ }
3794
+ async function callGreptileMcpToolForGate(input) {
3795
+ const controller = new AbortController;
3796
+ const timeoutId = setTimeout(() => {
3797
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
3798
+ }, input.timeoutMs);
3799
+ let response;
3800
+ try {
3801
+ response = await input.fetchFn(input.apiBase, {
3802
+ method: "POST",
3803
+ headers: {
3804
+ Authorization: `Bearer ${input.apiKey}`,
3805
+ "Content-Type": "application/json"
3806
+ },
3807
+ body: JSON.stringify({
3808
+ jsonrpc: "2.0",
3809
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
3810
+ method: "tools/call",
3811
+ params: { name: input.name, arguments: input.args }
3812
+ }),
3813
+ signal: controller.signal
3814
+ });
3815
+ } catch (error) {
3816
+ if (controller.signal.aborted) {
3817
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
3818
+ }
3819
+ throw error;
3820
+ } finally {
3821
+ clearTimeout(timeoutId);
3822
+ }
3823
+ const raw = await response.text();
3824
+ if (!response.ok) {
3825
+ throw new Error(`HTTP ${response.status}: ${raw}`);
3826
+ }
3827
+ let envelope;
3828
+ try {
3829
+ envelope = JSON.parse(raw);
3830
+ } catch {
3831
+ throw new Error(`Malformed MCP response: ${raw}`);
3832
+ }
3833
+ if (envelope.error?.message) {
3834
+ throw new Error(envelope.error.message);
3835
+ }
3836
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
3837
+ `).trim();
3838
+ if (!text) {
3839
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
3840
+ }
3841
+ return text;
3842
+ }
3843
+ async function callGreptileMcpToolJsonForGate(input) {
3844
+ const text = await callGreptileMcpToolForGate(input);
3845
+ try {
3846
+ return JSON.parse(text);
3847
+ } catch {
3848
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
3849
+ }
3850
+ }
3851
+ async function collectConfiguredGreptileApiSignals(input) {
3852
+ if (!input.enabled || input.options?.enabled === false) {
3853
+ return { signals: [], errors: [] };
3854
+ }
3855
+ const env = input.options?.env ?? process.env;
3856
+ const secrets = resolveRuntimeSecrets(env);
3857
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
3858
+ if (!apiKey) {
3859
+ return { signals: [], errors: [] };
3860
+ }
3861
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
3862
+ if (typeof fetchFn !== "function") {
3863
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
3864
+ }
3865
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
3866
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
3867
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
3868
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
3869
+ const timeoutMs = greptileRequestTimeoutMs(env);
3870
+ try {
3871
+ const listPayload = await callGreptileMcpToolJsonForGate({
3872
+ apiBase,
3873
+ apiKey,
3874
+ name: "list_code_reviews",
3875
+ args: {
3876
+ name: repository,
3877
+ remote,
3878
+ defaultBranch,
3879
+ prNumber: input.prNumber,
3880
+ limit: 20
3881
+ },
3882
+ timeoutMs,
3883
+ fetchFn
3884
+ });
3885
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
3886
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
3887
+ const signals = [];
3888
+ for (const review of selectedReviews) {
3889
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
3890
+ apiBase,
3891
+ apiKey,
3892
+ name: "get_code_review",
3893
+ args: { codeReviewId: review.id },
3894
+ timeoutMs,
3895
+ fetchFn
3896
+ });
3897
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
3898
+ signals.push(greptileApiSignalFromCodeReview(review, details));
3899
+ }
3900
+ return { signals, errors: [] };
3901
+ } catch (error) {
3902
+ return {
3903
+ signals: [],
3904
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
3905
+ };
3906
+ }
3907
+ }
3725
3908
  function firstString(record, keys) {
3726
3909
  for (const key of keys) {
3727
3910
  const value = record[key];
@@ -3848,7 +4031,7 @@ function normalizeReviewThread(entry) {
3848
4031
  function relevantIssueComment(comment) {
3849
4032
  const login = comment.user?.login ?? comment.author?.login ?? "";
3850
4033
  const body = comment.body ?? "";
3851
- 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);
4034
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
3852
4035
  }
3853
4036
  function latestThreadComment(thread) {
3854
4037
  const nodes = thread.comments?.nodes ?? [];
@@ -3884,7 +4067,8 @@ function makeGreptileSignal(input) {
3884
4067
  const scores = parseGreptileScores(input.body);
3885
4068
  const reviewedSha = input.reviewedSha?.trim() || null;
3886
4069
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
3887
- const blocker = input.blocker ?? containsBlockerText(input.body);
4070
+ const verdict = input.verdict ?? null;
4071
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
3888
4072
  const explicitApproval = input.explicitApproval ?? false;
3889
4073
  return {
3890
4074
  source: input.source,
@@ -3896,6 +4080,7 @@ function makeGreptileSignal(input) {
3896
4080
  score: scores[0] ?? null,
3897
4081
  scores,
3898
4082
  explicitApproval,
4083
+ verdict,
3899
4084
  blocker,
3900
4085
  actionable: input.actionable ?? blocker,
3901
4086
  bodyExcerpt: bodyExcerpt(input.body),
@@ -3918,9 +4103,9 @@ function collectGreptileSignals(evidence) {
3918
4103
  for (const context of contextSources) {
3919
4104
  if (!context.body.trim())
3920
4105
  continue;
3921
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
3922
- continue;
3923
4106
  const contextBlocker = containsBlockerText(context.body);
4107
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4108
+ continue;
3924
4109
  signals.push(makeGreptileSignal({
3925
4110
  source: context.source,
3926
4111
  body: context.body,
@@ -3933,16 +4118,16 @@ function collectGreptileSignals(evidence) {
3933
4118
  for (const apiSignal of evidence.apiSignals ?? []) {
3934
4119
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
3935
4120
 
3936
- `);
3937
- if (!body.trim())
3938
- continue;
4121
+ `) || "Status: UNKNOWN";
4122
+ const verdict = greptileStatusVerdict(apiSignal.status);
3939
4123
  signals.push(makeGreptileSignal({
3940
4124
  source: "api",
3941
4125
  body,
3942
4126
  currentHeadSha: evidence.currentHeadSha,
3943
4127
  trusted: true,
3944
4128
  reviewedSha: apiSignal.reviewedSha ?? null,
3945
- explicitApproval: false
4129
+ explicitApproval: verdict === "approved",
4130
+ verdict
3946
4131
  }));
3947
4132
  }
3948
4133
  for (const review of evidence.reviews) {
@@ -3967,20 +4152,6 @@ function collectGreptileSignals(evidence) {
3967
4152
  blocker: state === "CHANGES_REQUESTED" || undefined
3968
4153
  }));
3969
4154
  }
3970
- for (const comment of evidence.changedFileReviewComments) {
3971
- const login = commentAuthorLogin(comment);
3972
- const body = comment.body ?? "";
3973
- if (!body.trim() || !isGreptileGithubLogin(login))
3974
- continue;
3975
- signals.push(makeGreptileSignal({
3976
- source: "changed-file-comment",
3977
- body,
3978
- currentHeadSha: evidence.currentHeadSha,
3979
- trusted: true,
3980
- authorLogin: login,
3981
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
3982
- }));
3983
- }
3984
4155
  for (const comment of evidence.relevantIssueComments) {
3985
4156
  const login = commentAuthorLogin(comment);
3986
4157
  const body = comment.body ?? "";
@@ -4088,10 +4259,17 @@ function deriveGreptileEvidence(input) {
4088
4259
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4089
4260
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4090
4261
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4091
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4262
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
4263
+ const signalCanApproveByScore = (signal) => {
4264
+ if (signal.source === "api")
4265
+ return signal.verdict === "approved" || signal.verdict === "completed";
4266
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
4267
+ };
4268
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
4269
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
4092
4270
  const approvedByScore = !!approvingScoreEntry;
4093
- const approvedByExplicitMapping = false;
4094
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4271
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4272
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4095
4273
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4096
4274
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4097
4275
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
@@ -4120,13 +4298,14 @@ function deriveGreptileEvidence(input) {
4120
4298
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4121
4299
  return completedState && review.commit_id === input.currentHeadSha;
4122
4300
  });
4301
+ 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"));
4123
4302
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4124
4303
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4125
4304
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4126
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4305
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4127
4306
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4128
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4129
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4307
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
4308
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
4130
4309
  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";
4131
4310
  return {
4132
4311
  source,
@@ -4233,6 +4412,7 @@ async function collectPrReviewEvidence(input) {
4233
4412
  readErrors.push("gh pr view did not return required reviews array");
4234
4413
  }
4235
4414
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4415
+ const baseRefName = firstString(view, ["baseRefName"]);
4236
4416
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4237
4417
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4238
4418
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4270,8 +4450,19 @@ async function collectPrReviewEvidence(input) {
4270
4450
  }
4271
4451
  }
4272
4452
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4453
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
4454
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
4455
+ enabled: shouldCollectConfiguredGreptileApi,
4456
+ options: input.greptileApi,
4457
+ repoName: parsed.repoName,
4458
+ prNumber: parsed.prNumber,
4459
+ headSha,
4460
+ baseRefName
4461
+ });
4462
+ readErrors.push(...configuredGreptileApiRead.errors);
4463
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
4273
4464
  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})` : ""}`);
4274
- const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
4465
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4275
4466
  const evidenceBase = {
4276
4467
  title: firstString(view, ["title"]),
4277
4468
  body: firstString(view, ["body"]),
@@ -4281,7 +4472,7 @@ async function collectPrReviewEvidence(input) {
4281
4472
  reviewThreads,
4282
4473
  checks: checksWithGreptileDetails,
4283
4474
  currentHeadSha: headSha,
4284
- apiSignals: input.apiSignals ?? []
4475
+ apiSignals
4285
4476
  };
4286
4477
  const greptile = deriveGreptileEvidence(evidenceBase);
4287
4478
  return {
@@ -4292,7 +4483,7 @@ async function collectPrReviewEvidence(input) {
4292
4483
  body: evidenceBase.body,
4293
4484
  headSha,
4294
4485
  headRefName: firstString(view, ["headRefName"]),
4295
- baseRefName: firstString(view, ["baseRefName"]),
4486
+ baseRefName,
4296
4487
  state: firstString(view, ["state"]),
4297
4488
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4298
4489
  mergeable: firstString(view, ["mergeable"]),
@@ -4309,66 +4500,224 @@ async function collectPrReviewEvidence(input) {
4309
4500
  greptile
4310
4501
  };
4311
4502
  }
4503
+ function capGateMessage(value, maxChars = 1200) {
4504
+ const normalized = value.trim();
4505
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
4506
+ [truncated for gate summary; see full evidence artifact]` : normalized;
4507
+ }
4312
4508
  function evaluateEvidence(evidence) {
4313
- const reasons = [];
4509
+ const reasonDetails = [];
4314
4510
  const warnings = [];
4315
- let pending = false;
4316
- if (evidence.readErrors.length > 0) {
4317
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4318
- }
4319
- if (!evidence.headSha)
4320
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4321
- if (evidence.checkFailures.length > 0)
4322
- reasons.push(...evidence.checkFailures);
4323
- if (evidence.pendingChecks.length > 0) {
4324
- pending = true;
4325
- reasons.push(...evidence.pendingChecks);
4511
+ const seen = new Set;
4512
+ const addReason = (reason) => {
4513
+ const capped = { ...reason, message: capGateMessage(reason.message) };
4514
+ const key = `${capped.code}:${capped.message}`;
4515
+ if (seen.has(key))
4516
+ return;
4517
+ seen.add(key);
4518
+ reasonDetails.push(capped);
4519
+ };
4520
+ const greptile = evidence.greptile;
4521
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4522
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
4523
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4524
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4525
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
4526
+ for (const error of evidence.readErrors) {
4527
+ addReason({
4528
+ code: "read_error",
4529
+ reasonClass: "reject",
4530
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
4531
+ suggestedAction: "needs_attention",
4532
+ message: `Required PR evidence surface could not be read completely: ${error}`,
4533
+ headSha: evidence.headSha || null
4534
+ });
4535
+ }
4536
+ if (!evidence.headSha) {
4537
+ addReason({
4538
+ code: "missing_head_sha",
4539
+ reasonClass: "reject",
4540
+ surface: "github",
4541
+ suggestedAction: "needs_attention",
4542
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
4543
+ headSha: null
4544
+ });
4545
+ }
4546
+ for (const failure of evidence.checkFailures) {
4547
+ addReason({
4548
+ code: "ci_failed",
4549
+ reasonClass: "reject",
4550
+ surface: "ci",
4551
+ suggestedAction: "fix",
4552
+ message: failure,
4553
+ headSha: evidence.headSha || null
4554
+ });
4555
+ }
4556
+ for (const pendingCheck of evidence.pendingChecks) {
4557
+ addReason({
4558
+ code: "check_pending",
4559
+ reasonClass: "pending",
4560
+ surface: "ci",
4561
+ suggestedAction: "wait",
4562
+ message: pendingCheck,
4563
+ headSha: evidence.headSha || null
4564
+ });
4326
4565
  }
4327
4566
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4328
4567
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4329
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4568
+ addReason({
4569
+ code: "review_decision_blocking",
4570
+ reasonClass: "reject",
4571
+ surface: "review",
4572
+ suggestedAction: "fix",
4573
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
4574
+ headSha: evidence.headSha || null
4575
+ });
4576
+ }
4577
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
4578
+ addReason({
4579
+ code: "review_thread_unresolved",
4580
+ reasonClass: "reject",
4581
+ surface: "review",
4582
+ suggestedAction: "fix",
4583
+ message: thread,
4584
+ headSha: evidence.headSha || null
4585
+ });
4586
+ }
4587
+ if (greptile.mapping === "missing") {
4588
+ addReason({
4589
+ code: "greptile_missing",
4590
+ reasonClass: "pending",
4591
+ surface: "greptile",
4592
+ suggestedAction: "wait",
4593
+ message: "Missing Greptile check/review evidence for this PR.",
4594
+ headSha: evidence.headSha || null
4595
+ });
4330
4596
  }
4331
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4332
- if (unresolvedThreads.length > 0)
4333
- reasons.push(...unresolvedThreads);
4334
- const greptile = evidence.greptile;
4335
- if (greptile.mapping === "missing")
4336
- reasons.push("Missing Greptile check/review evidence for this PR.");
4337
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4338
4597
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4339
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
4598
+ addReason({
4599
+ code: "greptile_stale",
4600
+ reasonClass: "pending",
4601
+ surface: "greptile",
4602
+ suggestedAction: "wait",
4603
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
4604
+ headSha: evidence.headSha || null,
4605
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
4606
+ });
4607
+ }
4608
+ for (const signal of pendingGreptileApiSignals) {
4609
+ addReason({
4610
+ code: "greptile_pending",
4611
+ reasonClass: "pending",
4612
+ surface: "greptile",
4613
+ suggestedAction: "wait",
4614
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
4615
+ headSha: evidence.headSha || null,
4616
+ reviewedSha: signal.reviewedSha ?? null
4617
+ });
4618
+ }
4619
+ for (const signal of unknownGreptileApiSignals) {
4620
+ addReason({
4621
+ code: "greptile_api_status_unknown",
4622
+ reasonClass: "reject",
4623
+ surface: "greptile",
4624
+ suggestedAction: "needs_attention",
4625
+ 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}` : "."}`,
4626
+ headSha: evidence.headSha || null,
4627
+ reviewedSha: signal.reviewedSha ?? null
4628
+ });
4340
4629
  }
4341
4630
  if (!greptile.completed) {
4342
- pending = true;
4343
- reasons.push("Greptile check/review has not completed for the current PR head.");
4631
+ addReason({
4632
+ code: "greptile_pending",
4633
+ reasonClass: "pending",
4634
+ surface: "greptile",
4635
+ suggestedAction: "wait",
4636
+ message: "Greptile check/review has not completed for the current PR head.",
4637
+ headSha: evidence.headSha || null,
4638
+ reviewedSha: greptile.reviewedSha ?? null
4639
+ });
4640
+ }
4641
+ if (!greptile.fresh) {
4642
+ addReason({
4643
+ code: "greptile_not_current_head",
4644
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4645
+ surface: "greptile",
4646
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4647
+ message: "Greptile approval is not tied to the current PR head SHA.",
4648
+ headSha: evidence.headSha || null,
4649
+ reviewedSha: greptile.reviewedSha ?? null
4650
+ });
4344
4651
  }
4345
- if (!greptile.fresh)
4346
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4347
4652
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4348
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
4653
+ addReason({
4654
+ code: "greptile_score_not_5",
4655
+ reasonClass: "reject",
4656
+ surface: "greptile",
4657
+ suggestedAction: "fix",
4658
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
4659
+ headSha: evidence.headSha || null,
4660
+ reviewedSha: greptile.reviewedSha ?? null
4661
+ });
4349
4662
  }
4350
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4351
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
4663
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
4664
+ if (!greptile.score && !hasApprovedMapping) {
4665
+ addReason({
4666
+ code: "greptile_score_missing",
4667
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4668
+ surface: "greptile",
4669
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4670
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
4671
+ headSha: evidence.headSha || null,
4672
+ reviewedSha: greptile.reviewedSha ?? null
4673
+ });
4352
4674
  }
4353
4675
  if (greptile.mapping === "unproven") {
4354
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4676
+ addReason({
4677
+ code: "greptile_mapping_unproven",
4678
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4679
+ surface: "greptile",
4680
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4681
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
4682
+ headSha: evidence.headSha || null,
4683
+ reviewedSha: greptile.reviewedSha ?? null
4684
+ });
4355
4685
  }
4356
- if (greptile.blockers.length > 0) {
4357
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4686
+ for (const blocker of greptile.blockers) {
4687
+ addReason({
4688
+ code: "greptile_blocker_text",
4689
+ reasonClass: "reject",
4690
+ surface: "greptile",
4691
+ suggestedAction: "fix",
4692
+ message: `Greptile/blocker text: ${blocker}`,
4693
+ headSha: evidence.headSha || null,
4694
+ reviewedSha: greptile.reviewedSha ?? null
4695
+ });
4696
+ }
4697
+ for (const comment of greptile.unresolvedComments) {
4698
+ addReason({
4699
+ code: "greptile_unresolved_comment",
4700
+ reasonClass: "reject",
4701
+ surface: "greptile",
4702
+ suggestedAction: "fix",
4703
+ message: comment,
4704
+ headSha: evidence.headSha || null,
4705
+ reviewedSha: greptile.reviewedSha ?? null
4706
+ });
4358
4707
  }
4359
- if (greptile.unresolvedComments.length > 0)
4360
- reasons.push(...greptile.unresolvedComments);
4361
4708
  if (!greptile.approved)
4362
4709
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4363
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
4710
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
4711
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
4364
4712
  }
4365
4713
  function evaluateStrictPrMergeGate(evidence) {
4366
4714
  const evaluated = evaluateEvidence(evidence);
4367
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4715
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
4368
4716
  return {
4369
4717
  approved,
4370
4718
  pending: evaluated.pending,
4371
4719
  reasons: evaluated.reasons,
4720
+ reasonDetails: evaluated.reasonDetails,
4372
4721
  warnings: evaluated.warnings,
4373
4722
  actionableFeedback: evaluated.reasons,
4374
4723
  evidence
@@ -5172,7 +5521,7 @@ async function runGreptileReviewForPr(options) {
5172
5521
  };
5173
5522
  }
5174
5523
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5175
- if (/not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this/i.test(blockerScanBody)) {
5524
+ if (/not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(blockerScanBody)) {
5176
5525
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5177
5526
  return {
5178
5527
  verdict: "REJECT",
@@ -5254,6 +5603,7 @@ async function runGreptileReviewForPr(options) {
5254
5603
  approved: strictGate.approved,
5255
5604
  pending: strictGate.pending,
5256
5605
  reasons: strictGate.reasons,
5606
+ reasonDetails: strictGate.reasonDetails,
5257
5607
  warnings: strictGate.warnings,
5258
5608
  greptile: strictGate.evidence.greptile,
5259
5609
  readErrors: strictGate.evidence.readErrors
@@ -5276,6 +5626,7 @@ async function runGreptileReviewForPr(options) {
5276
5626
  approved: strictGate.approved,
5277
5627
  pending: strictGate.pending,
5278
5628
  reasons: strictGate.reasons,
5629
+ reasonDetails: strictGate.reasonDetails,
5279
5630
  warnings: strictGate.warnings,
5280
5631
  greptile: strictGate.evidence.greptile,
5281
5632
  readErrors: strictGate.evidence.readErrors
@@ -5402,6 +5753,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5402
5753
  approved: strictGate.approved,
5403
5754
  pending: strictGate.pending,
5404
5755
  reasons: strictGate.reasons,
5756
+ reasonDetails: strictGate.reasonDetails,
5405
5757
  warnings: strictGate.warnings,
5406
5758
  greptile: strictGate.evidence.greptile
5407
5759
  },
@@ -5424,6 +5776,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5424
5776
  approved: strictGate.approved,
5425
5777
  pending: strictGate.pending,
5426
5778
  reasons: strictGate.reasons,
5779
+ reasonDetails: strictGate.reasonDetails,
5427
5780
  warnings: strictGate.warnings,
5428
5781
  greptile: strictGate.evidence.greptile
5429
5782
  },
@@ -5539,19 +5892,25 @@ function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
5539
5892
  if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
5540
5893
  return true;
5541
5894
  }
5542
- return isGreptileReviewTerminal(existingReview.status);
5895
+ return false;
5543
5896
  }
5544
5897
  function shouldContinueGreptileMcpPolling(options) {
5545
5898
  if (options.githubCheckState.completed) {
5546
5899
  return false;
5547
5900
  }
5901
+ if (options.attempt + 1 >= options.pollAttempts) {
5902
+ return false;
5903
+ }
5548
5904
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
5549
5905
  return true;
5550
5906
  }
5551
- return options.attempt + 1 < options.pollAttempts;
5907
+ return true;
5552
5908
  }
5553
5909
  function shouldContinueGithubGreptileFallbackPolling(options) {
5554
5910
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
5911
+ if (options.attempt + 1 >= options.pollAttempts) {
5912
+ return false;
5913
+ }
5555
5914
  if (waitingForVisiblePendingReview) {
5556
5915
  return true;
5557
5916
  }
@@ -7003,7 +7362,7 @@ function gitOpenPr(options) {
7003
7362
  "",
7004
7363
  "## Review",
7005
7364
  "- Completion verification will run validation, verifier review, and PR policy checks.",
7006
- "- When repository policy allows it, Rig enables GitHub auto-merge after approval."
7365
+ "- When repository policy allows it, Rig attempts an immediate strict-gated, head-locked merge after approval."
7007
7366
  ].join(`
7008
7367
  `);
7009
7368
  const preCheck = runCapture2(withGhRepo([gh, "pr", "list", "--state", "merged", "--head", branch, "--json", "url,mergedAt", "--jq", ".[0]"], repoNameWithOwner), repoRoot);