@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.
@@ -3720,7 +3720,7 @@ function stripHtml(input) {
3720
3720
  }
3721
3721
  function containsBlockerText(input) {
3722
3722
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
3723
- 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);
3723
+ 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);
3724
3724
  }
3725
3725
  function isStrictFiveOfFive(score) {
3726
3726
  return score.value === 5 && score.scale === 5;
@@ -3728,6 +3728,189 @@ function isStrictFiveOfFive(score) {
3728
3728
  function containsConflictingScoreText(input) {
3729
3729
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3730
3730
  }
3731
+ function greptileStatusVerdict(status) {
3732
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
3733
+ if (!normalized)
3734
+ return null;
3735
+ if (["APPROVE", "APPROVED"].includes(normalized))
3736
+ return "approved";
3737
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
3738
+ return "rejected";
3739
+ if (["SKIP", "SKIPPED"].includes(normalized))
3740
+ return "skipped";
3741
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
3742
+ return "failed";
3743
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
3744
+ return "pending";
3745
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
3746
+ return "completed";
3747
+ return null;
3748
+ }
3749
+ function isBlockingGreptileVerdict(verdict) {
3750
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
3751
+ }
3752
+ function greptileRequestTimeoutMs(env) {
3753
+ const fallback = 30000;
3754
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
3755
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
3756
+ }
3757
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
3758
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3759
+ return null;
3760
+ const record = entry;
3761
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
3762
+ if (!id)
3763
+ return null;
3764
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
3765
+ return {
3766
+ id,
3767
+ status: typeof record.status === "string" ? record.status : null,
3768
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
3769
+ body: typeof record.body === "string" ? record.body : null,
3770
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
3771
+ };
3772
+ }
3773
+ function uniqueGreptileCodeReviews(reviews) {
3774
+ const seen = new Set;
3775
+ const unique2 = [];
3776
+ for (const review of reviews) {
3777
+ if (seen.has(review.id))
3778
+ continue;
3779
+ seen.add(review.id);
3780
+ unique2.push(review);
3781
+ }
3782
+ return unique2;
3783
+ }
3784
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
3785
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
3786
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
3787
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
3788
+ const latest = sorted.slice(0, 1);
3789
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
3790
+ }
3791
+ function greptileApiSignalFromCodeReview(review, details) {
3792
+ const selected = details ?? review;
3793
+ return {
3794
+ id: selected.id || review.id,
3795
+ body: selected.body ?? review.body ?? null,
3796
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
3797
+ status: selected.status ?? review.status ?? null
3798
+ };
3799
+ }
3800
+ async function callGreptileMcpToolForGate(input) {
3801
+ const controller = new AbortController;
3802
+ const timeoutId = setTimeout(() => {
3803
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
3804
+ }, input.timeoutMs);
3805
+ let response;
3806
+ try {
3807
+ response = await input.fetchFn(input.apiBase, {
3808
+ method: "POST",
3809
+ headers: {
3810
+ Authorization: `Bearer ${input.apiKey}`,
3811
+ "Content-Type": "application/json"
3812
+ },
3813
+ body: JSON.stringify({
3814
+ jsonrpc: "2.0",
3815
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
3816
+ method: "tools/call",
3817
+ params: { name: input.name, arguments: input.args }
3818
+ }),
3819
+ signal: controller.signal
3820
+ });
3821
+ } catch (error) {
3822
+ if (controller.signal.aborted) {
3823
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
3824
+ }
3825
+ throw error;
3826
+ } finally {
3827
+ clearTimeout(timeoutId);
3828
+ }
3829
+ const raw = await response.text();
3830
+ if (!response.ok) {
3831
+ throw new Error(`HTTP ${response.status}: ${raw}`);
3832
+ }
3833
+ let envelope;
3834
+ try {
3835
+ envelope = JSON.parse(raw);
3836
+ } catch {
3837
+ throw new Error(`Malformed MCP response: ${raw}`);
3838
+ }
3839
+ if (envelope.error?.message) {
3840
+ throw new Error(envelope.error.message);
3841
+ }
3842
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
3843
+ `).trim();
3844
+ if (!text) {
3845
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
3846
+ }
3847
+ return text;
3848
+ }
3849
+ async function callGreptileMcpToolJsonForGate(input) {
3850
+ const text = await callGreptileMcpToolForGate(input);
3851
+ try {
3852
+ return JSON.parse(text);
3853
+ } catch {
3854
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
3855
+ }
3856
+ }
3857
+ async function collectConfiguredGreptileApiSignals(input) {
3858
+ if (!input.enabled || input.options?.enabled === false) {
3859
+ return { signals: [], errors: [] };
3860
+ }
3861
+ const env = input.options?.env ?? process.env;
3862
+ const secrets = resolveRuntimeSecrets(env);
3863
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
3864
+ if (!apiKey) {
3865
+ return { signals: [], errors: [] };
3866
+ }
3867
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
3868
+ if (typeof fetchFn !== "function") {
3869
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
3870
+ }
3871
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
3872
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
3873
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
3874
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
3875
+ const timeoutMs = greptileRequestTimeoutMs(env);
3876
+ try {
3877
+ const listPayload = await callGreptileMcpToolJsonForGate({
3878
+ apiBase,
3879
+ apiKey,
3880
+ name: "list_code_reviews",
3881
+ args: {
3882
+ name: repository,
3883
+ remote,
3884
+ defaultBranch,
3885
+ prNumber: input.prNumber,
3886
+ limit: 20
3887
+ },
3888
+ timeoutMs,
3889
+ fetchFn
3890
+ });
3891
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
3892
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
3893
+ const signals = [];
3894
+ for (const review of selectedReviews) {
3895
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
3896
+ apiBase,
3897
+ apiKey,
3898
+ name: "get_code_review",
3899
+ args: { codeReviewId: review.id },
3900
+ timeoutMs,
3901
+ fetchFn
3902
+ });
3903
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
3904
+ signals.push(greptileApiSignalFromCodeReview(review, details));
3905
+ }
3906
+ return { signals, errors: [] };
3907
+ } catch (error) {
3908
+ return {
3909
+ signals: [],
3910
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
3911
+ };
3912
+ }
3913
+ }
3731
3914
  function firstString(record, keys) {
3732
3915
  for (const key of keys) {
3733
3916
  const value = record[key];
@@ -3854,7 +4037,7 @@ function normalizeReviewThread(entry) {
3854
4037
  function relevantIssueComment(comment) {
3855
4038
  const login = comment.user?.login ?? comment.author?.login ?? "";
3856
4039
  const body = comment.body ?? "";
3857
- 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);
4040
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
3858
4041
  }
3859
4042
  function latestThreadComment(thread) {
3860
4043
  const nodes = thread.comments?.nodes ?? [];
@@ -3890,7 +4073,8 @@ function makeGreptileSignal(input) {
3890
4073
  const scores = parseGreptileScores(input.body);
3891
4074
  const reviewedSha = input.reviewedSha?.trim() || null;
3892
4075
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
3893
- const blocker = input.blocker ?? containsBlockerText(input.body);
4076
+ const verdict = input.verdict ?? null;
4077
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
3894
4078
  const explicitApproval = input.explicitApproval ?? false;
3895
4079
  return {
3896
4080
  source: input.source,
@@ -3902,6 +4086,7 @@ function makeGreptileSignal(input) {
3902
4086
  score: scores[0] ?? null,
3903
4087
  scores,
3904
4088
  explicitApproval,
4089
+ verdict,
3905
4090
  blocker,
3906
4091
  actionable: input.actionable ?? blocker,
3907
4092
  bodyExcerpt: bodyExcerpt(input.body),
@@ -3924,9 +4109,9 @@ function collectGreptileSignals(evidence) {
3924
4109
  for (const context of contextSources) {
3925
4110
  if (!context.body.trim())
3926
4111
  continue;
3927
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
3928
- continue;
3929
4112
  const contextBlocker = containsBlockerText(context.body);
4113
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4114
+ continue;
3930
4115
  signals.push(makeGreptileSignal({
3931
4116
  source: context.source,
3932
4117
  body: context.body,
@@ -3939,16 +4124,16 @@ function collectGreptileSignals(evidence) {
3939
4124
  for (const apiSignal of evidence.apiSignals ?? []) {
3940
4125
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
3941
4126
 
3942
- `);
3943
- if (!body.trim())
3944
- continue;
4127
+ `) || "Status: UNKNOWN";
4128
+ const verdict = greptileStatusVerdict(apiSignal.status);
3945
4129
  signals.push(makeGreptileSignal({
3946
4130
  source: "api",
3947
4131
  body,
3948
4132
  currentHeadSha: evidence.currentHeadSha,
3949
4133
  trusted: true,
3950
4134
  reviewedSha: apiSignal.reviewedSha ?? null,
3951
- explicitApproval: false
4135
+ explicitApproval: verdict === "approved",
4136
+ verdict
3952
4137
  }));
3953
4138
  }
3954
4139
  for (const review of evidence.reviews) {
@@ -3973,20 +4158,6 @@ function collectGreptileSignals(evidence) {
3973
4158
  blocker: state === "CHANGES_REQUESTED" || undefined
3974
4159
  }));
3975
4160
  }
3976
- for (const comment of evidence.changedFileReviewComments) {
3977
- const login = commentAuthorLogin(comment);
3978
- const body = comment.body ?? "";
3979
- if (!body.trim() || !isGreptileGithubLogin(login))
3980
- continue;
3981
- signals.push(makeGreptileSignal({
3982
- source: "changed-file-comment",
3983
- body,
3984
- currentHeadSha: evidence.currentHeadSha,
3985
- trusted: true,
3986
- authorLogin: login,
3987
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
3988
- }));
3989
- }
3990
4161
  for (const comment of evidence.relevantIssueComments) {
3991
4162
  const login = commentAuthorLogin(comment);
3992
4163
  const body = comment.body ?? "";
@@ -4094,10 +4265,17 @@ function deriveGreptileEvidence(input) {
4094
4265
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4095
4266
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4096
4267
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4097
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4268
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
4269
+ const signalCanApproveByScore = (signal) => {
4270
+ if (signal.source === "api")
4271
+ return signal.verdict === "approved" || signal.verdict === "completed";
4272
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
4273
+ };
4274
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
4275
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
4098
4276
  const approvedByScore = !!approvingScoreEntry;
4099
- const approvedByExplicitMapping = false;
4100
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4277
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4278
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4101
4279
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4102
4280
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4103
4281
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
@@ -4126,13 +4304,14 @@ function deriveGreptileEvidence(input) {
4126
4304
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4127
4305
  return completedState && review.commit_id === input.currentHeadSha;
4128
4306
  });
4307
+ 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"));
4129
4308
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4130
4309
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4131
4310
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4132
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4311
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4133
4312
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4134
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4135
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4313
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
4314
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
4136
4315
  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";
4137
4316
  return {
4138
4317
  source,
@@ -4239,6 +4418,7 @@ async function collectPrReviewEvidence(input) {
4239
4418
  readErrors.push("gh pr view did not return required reviews array");
4240
4419
  }
4241
4420
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4421
+ const baseRefName = firstString(view, ["baseRefName"]);
4242
4422
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4243
4423
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4244
4424
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4276,8 +4456,19 @@ async function collectPrReviewEvidence(input) {
4276
4456
  }
4277
4457
  }
4278
4458
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4459
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
4460
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
4461
+ enabled: shouldCollectConfiguredGreptileApi,
4462
+ options: input.greptileApi,
4463
+ repoName: parsed.repoName,
4464
+ prNumber: parsed.prNumber,
4465
+ headSha,
4466
+ baseRefName
4467
+ });
4468
+ readErrors.push(...configuredGreptileApiRead.errors);
4469
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
4279
4470
  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})` : ""}`);
4280
- const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
4471
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4281
4472
  const evidenceBase = {
4282
4473
  title: firstString(view, ["title"]),
4283
4474
  body: firstString(view, ["body"]),
@@ -4287,7 +4478,7 @@ async function collectPrReviewEvidence(input) {
4287
4478
  reviewThreads,
4288
4479
  checks: checksWithGreptileDetails,
4289
4480
  currentHeadSha: headSha,
4290
- apiSignals: input.apiSignals ?? []
4481
+ apiSignals
4291
4482
  };
4292
4483
  const greptile = deriveGreptileEvidence(evidenceBase);
4293
4484
  return {
@@ -4298,7 +4489,7 @@ async function collectPrReviewEvidence(input) {
4298
4489
  body: evidenceBase.body,
4299
4490
  headSha,
4300
4491
  headRefName: firstString(view, ["headRefName"]),
4301
- baseRefName: firstString(view, ["baseRefName"]),
4492
+ baseRefName,
4302
4493
  state: firstString(view, ["state"]),
4303
4494
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4304
4495
  mergeable: firstString(view, ["mergeable"]),
@@ -4315,66 +4506,224 @@ async function collectPrReviewEvidence(input) {
4315
4506
  greptile
4316
4507
  };
4317
4508
  }
4509
+ function capGateMessage(value, maxChars = 1200) {
4510
+ const normalized = value.trim();
4511
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
4512
+ [truncated for gate summary; see full evidence artifact]` : normalized;
4513
+ }
4318
4514
  function evaluateEvidence(evidence) {
4319
- const reasons = [];
4515
+ const reasonDetails = [];
4320
4516
  const warnings = [];
4321
- let pending = false;
4322
- if (evidence.readErrors.length > 0) {
4323
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4324
- }
4325
- if (!evidence.headSha)
4326
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4327
- if (evidence.checkFailures.length > 0)
4328
- reasons.push(...evidence.checkFailures);
4329
- if (evidence.pendingChecks.length > 0) {
4330
- pending = true;
4331
- reasons.push(...evidence.pendingChecks);
4517
+ const seen = new Set;
4518
+ const addReason = (reason) => {
4519
+ const capped = { ...reason, message: capGateMessage(reason.message) };
4520
+ const key = `${capped.code}:${capped.message}`;
4521
+ if (seen.has(key))
4522
+ return;
4523
+ seen.add(key);
4524
+ reasonDetails.push(capped);
4525
+ };
4526
+ const greptile = evidence.greptile;
4527
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4528
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
4529
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4530
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4531
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
4532
+ for (const error of evidence.readErrors) {
4533
+ addReason({
4534
+ code: "read_error",
4535
+ reasonClass: "reject",
4536
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
4537
+ suggestedAction: "needs_attention",
4538
+ message: `Required PR evidence surface could not be read completely: ${error}`,
4539
+ headSha: evidence.headSha || null
4540
+ });
4541
+ }
4542
+ if (!evidence.headSha) {
4543
+ addReason({
4544
+ code: "missing_head_sha",
4545
+ reasonClass: "reject",
4546
+ surface: "github",
4547
+ suggestedAction: "needs_attention",
4548
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
4549
+ headSha: null
4550
+ });
4551
+ }
4552
+ for (const failure of evidence.checkFailures) {
4553
+ addReason({
4554
+ code: "ci_failed",
4555
+ reasonClass: "reject",
4556
+ surface: "ci",
4557
+ suggestedAction: "fix",
4558
+ message: failure,
4559
+ headSha: evidence.headSha || null
4560
+ });
4561
+ }
4562
+ for (const pendingCheck of evidence.pendingChecks) {
4563
+ addReason({
4564
+ code: "check_pending",
4565
+ reasonClass: "pending",
4566
+ surface: "ci",
4567
+ suggestedAction: "wait",
4568
+ message: pendingCheck,
4569
+ headSha: evidence.headSha || null
4570
+ });
4332
4571
  }
4333
4572
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4334
4573
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4335
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4574
+ addReason({
4575
+ code: "review_decision_blocking",
4576
+ reasonClass: "reject",
4577
+ surface: "review",
4578
+ suggestedAction: "fix",
4579
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
4580
+ headSha: evidence.headSha || null
4581
+ });
4582
+ }
4583
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
4584
+ addReason({
4585
+ code: "review_thread_unresolved",
4586
+ reasonClass: "reject",
4587
+ surface: "review",
4588
+ suggestedAction: "fix",
4589
+ message: thread,
4590
+ headSha: evidence.headSha || null
4591
+ });
4592
+ }
4593
+ if (greptile.mapping === "missing") {
4594
+ addReason({
4595
+ code: "greptile_missing",
4596
+ reasonClass: "pending",
4597
+ surface: "greptile",
4598
+ suggestedAction: "wait",
4599
+ message: "Missing Greptile check/review evidence for this PR.",
4600
+ headSha: evidence.headSha || null
4601
+ });
4336
4602
  }
4337
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4338
- if (unresolvedThreads.length > 0)
4339
- reasons.push(...unresolvedThreads);
4340
- const greptile = evidence.greptile;
4341
- if (greptile.mapping === "missing")
4342
- reasons.push("Missing Greptile check/review evidence for this PR.");
4343
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4344
4603
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4345
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
4604
+ addReason({
4605
+ code: "greptile_stale",
4606
+ reasonClass: "pending",
4607
+ surface: "greptile",
4608
+ suggestedAction: "wait",
4609
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
4610
+ headSha: evidence.headSha || null,
4611
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
4612
+ });
4613
+ }
4614
+ for (const signal of pendingGreptileApiSignals) {
4615
+ addReason({
4616
+ code: "greptile_pending",
4617
+ reasonClass: "pending",
4618
+ surface: "greptile",
4619
+ suggestedAction: "wait",
4620
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
4621
+ headSha: evidence.headSha || null,
4622
+ reviewedSha: signal.reviewedSha ?? null
4623
+ });
4624
+ }
4625
+ for (const signal of unknownGreptileApiSignals) {
4626
+ addReason({
4627
+ code: "greptile_api_status_unknown",
4628
+ reasonClass: "reject",
4629
+ surface: "greptile",
4630
+ suggestedAction: "needs_attention",
4631
+ 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}` : "."}`,
4632
+ headSha: evidence.headSha || null,
4633
+ reviewedSha: signal.reviewedSha ?? null
4634
+ });
4346
4635
  }
4347
4636
  if (!greptile.completed) {
4348
- pending = true;
4349
- reasons.push("Greptile check/review has not completed for the current PR head.");
4637
+ addReason({
4638
+ code: "greptile_pending",
4639
+ reasonClass: "pending",
4640
+ surface: "greptile",
4641
+ suggestedAction: "wait",
4642
+ message: "Greptile check/review has not completed for the current PR head.",
4643
+ headSha: evidence.headSha || null,
4644
+ reviewedSha: greptile.reviewedSha ?? null
4645
+ });
4646
+ }
4647
+ if (!greptile.fresh) {
4648
+ addReason({
4649
+ code: "greptile_not_current_head",
4650
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4651
+ surface: "greptile",
4652
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4653
+ message: "Greptile approval is not tied to the current PR head SHA.",
4654
+ headSha: evidence.headSha || null,
4655
+ reviewedSha: greptile.reviewedSha ?? null
4656
+ });
4350
4657
  }
4351
- if (!greptile.fresh)
4352
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4353
4658
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4354
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
4659
+ addReason({
4660
+ code: "greptile_score_not_5",
4661
+ reasonClass: "reject",
4662
+ surface: "greptile",
4663
+ suggestedAction: "fix",
4664
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
4665
+ headSha: evidence.headSha || null,
4666
+ reviewedSha: greptile.reviewedSha ?? null
4667
+ });
4355
4668
  }
4356
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4357
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
4669
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
4670
+ if (!greptile.score && !hasApprovedMapping) {
4671
+ addReason({
4672
+ code: "greptile_score_missing",
4673
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4674
+ surface: "greptile",
4675
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4676
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
4677
+ headSha: evidence.headSha || null,
4678
+ reviewedSha: greptile.reviewedSha ?? null
4679
+ });
4358
4680
  }
4359
4681
  if (greptile.mapping === "unproven") {
4360
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4682
+ addReason({
4683
+ code: "greptile_mapping_unproven",
4684
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4685
+ surface: "greptile",
4686
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4687
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
4688
+ headSha: evidence.headSha || null,
4689
+ reviewedSha: greptile.reviewedSha ?? null
4690
+ });
4361
4691
  }
4362
- if (greptile.blockers.length > 0) {
4363
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4692
+ for (const blocker of greptile.blockers) {
4693
+ addReason({
4694
+ code: "greptile_blocker_text",
4695
+ reasonClass: "reject",
4696
+ surface: "greptile",
4697
+ suggestedAction: "fix",
4698
+ message: `Greptile/blocker text: ${blocker}`,
4699
+ headSha: evidence.headSha || null,
4700
+ reviewedSha: greptile.reviewedSha ?? null
4701
+ });
4702
+ }
4703
+ for (const comment of greptile.unresolvedComments) {
4704
+ addReason({
4705
+ code: "greptile_unresolved_comment",
4706
+ reasonClass: "reject",
4707
+ surface: "greptile",
4708
+ suggestedAction: "fix",
4709
+ message: comment,
4710
+ headSha: evidence.headSha || null,
4711
+ reviewedSha: greptile.reviewedSha ?? null
4712
+ });
4364
4713
  }
4365
- if (greptile.unresolvedComments.length > 0)
4366
- reasons.push(...greptile.unresolvedComments);
4367
4714
  if (!greptile.approved)
4368
4715
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4369
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
4716
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
4717
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
4370
4718
  }
4371
4719
  function evaluateStrictPrMergeGate(evidence) {
4372
4720
  const evaluated = evaluateEvidence(evidence);
4373
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4721
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
4374
4722
  return {
4375
4723
  approved,
4376
4724
  pending: evaluated.pending,
4377
4725
  reasons: evaluated.reasons,
4726
+ reasonDetails: evaluated.reasonDetails,
4378
4727
  warnings: evaluated.warnings,
4379
4728
  actionableFeedback: evaluated.reasons,
4380
4729
  evidence
@@ -5178,7 +5527,7 @@ async function runGreptileReviewForPr(options) {
5178
5527
  };
5179
5528
  }
5180
5529
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5181
- 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)) {
5530
+ 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)) {
5182
5531
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5183
5532
  return {
5184
5533
  verdict: "REJECT",
@@ -5260,6 +5609,7 @@ async function runGreptileReviewForPr(options) {
5260
5609
  approved: strictGate.approved,
5261
5610
  pending: strictGate.pending,
5262
5611
  reasons: strictGate.reasons,
5612
+ reasonDetails: strictGate.reasonDetails,
5263
5613
  warnings: strictGate.warnings,
5264
5614
  greptile: strictGate.evidence.greptile,
5265
5615
  readErrors: strictGate.evidence.readErrors
@@ -5282,6 +5632,7 @@ async function runGreptileReviewForPr(options) {
5282
5632
  approved: strictGate.approved,
5283
5633
  pending: strictGate.pending,
5284
5634
  reasons: strictGate.reasons,
5635
+ reasonDetails: strictGate.reasonDetails,
5285
5636
  warnings: strictGate.warnings,
5286
5637
  greptile: strictGate.evidence.greptile,
5287
5638
  readErrors: strictGate.evidence.readErrors
@@ -5408,6 +5759,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5408
5759
  approved: strictGate.approved,
5409
5760
  pending: strictGate.pending,
5410
5761
  reasons: strictGate.reasons,
5762
+ reasonDetails: strictGate.reasonDetails,
5411
5763
  warnings: strictGate.warnings,
5412
5764
  greptile: strictGate.evidence.greptile
5413
5765
  },
@@ -5430,6 +5782,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5430
5782
  approved: strictGate.approved,
5431
5783
  pending: strictGate.pending,
5432
5784
  reasons: strictGate.reasons,
5785
+ reasonDetails: strictGate.reasonDetails,
5433
5786
  warnings: strictGate.warnings,
5434
5787
  greptile: strictGate.evidence.greptile
5435
5788
  },
@@ -5545,19 +5898,25 @@ function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
5545
5898
  if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
5546
5899
  return true;
5547
5900
  }
5548
- return isGreptileReviewTerminal(existingReview.status);
5901
+ return false;
5549
5902
  }
5550
5903
  function shouldContinueGreptileMcpPolling(options) {
5551
5904
  if (options.githubCheckState.completed) {
5552
5905
  return false;
5553
5906
  }
5907
+ if (options.attempt + 1 >= options.pollAttempts) {
5908
+ return false;
5909
+ }
5554
5910
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
5555
5911
  return true;
5556
5912
  }
5557
- return options.attempt + 1 < options.pollAttempts;
5913
+ return true;
5558
5914
  }
5559
5915
  function shouldContinueGithubGreptileFallbackPolling(options) {
5560
5916
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
5917
+ if (options.attempt + 1 >= options.pollAttempts) {
5918
+ return false;
5919
+ }
5561
5920
  if (waitingForVisiblePendingReview) {
5562
5921
  return true;
5563
5922
  }
@@ -7009,7 +7368,7 @@ function gitOpenPr(options) {
7009
7368
  "",
7010
7369
  "## Review",
7011
7370
  "- Completion verification will run validation, verifier review, and PR policy checks.",
7012
- "- When repository policy allows it, Rig enables GitHub auto-merge after approval."
7371
+ "- When repository policy allows it, Rig attempts an immediate strict-gated, head-locked merge after approval."
7013
7372
  ].join(`
7014
7373
  `);
7015
7374
  const preCheck = runCapture2(withGhRepo([gh, "pr", "list", "--state", "merged", "--head", branch, "--json", "url,mergedAt", "--jq", ".[0]"], repoNameWithOwner), repoRoot);