@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.
@@ -3720,13 +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);
3724
- }
3725
- function containsGreptileNegativeVerdict(input) {
3726
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
3727
- if (!text)
3728
- return false;
3729
- 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);
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);
3730
3724
  }
3731
3725
  function isStrictFiveOfFive(score) {
3732
3726
  return score.value === 5 && score.scale === 5;
@@ -3734,6 +3728,189 @@ function isStrictFiveOfFive(score) {
3734
3728
  function containsConflictingScoreText(input) {
3735
3729
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3736
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
+ }
3737
3914
  function firstString(record, keys) {
3738
3915
  for (const key of keys) {
3739
3916
  const value = record[key];
@@ -3860,7 +4037,7 @@ function normalizeReviewThread(entry) {
3860
4037
  function relevantIssueComment(comment) {
3861
4038
  const login = comment.user?.login ?? comment.author?.login ?? "";
3862
4039
  const body = comment.body ?? "";
3863
- 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);
3864
4041
  }
3865
4042
  function latestThreadComment(thread) {
3866
4043
  const nodes = thread.comments?.nodes ?? [];
@@ -3896,7 +4073,8 @@ function makeGreptileSignal(input) {
3896
4073
  const scores = parseGreptileScores(input.body);
3897
4074
  const reviewedSha = input.reviewedSha?.trim() || null;
3898
4075
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
3899
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
4076
+ const verdict = input.verdict ?? null;
4077
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
3900
4078
  const explicitApproval = input.explicitApproval ?? false;
3901
4079
  return {
3902
4080
  source: input.source,
@@ -3908,6 +4086,7 @@ function makeGreptileSignal(input) {
3908
4086
  score: scores[0] ?? null,
3909
4087
  scores,
3910
4088
  explicitApproval,
4089
+ verdict,
3911
4090
  blocker,
3912
4091
  actionable: input.actionable ?? blocker,
3913
4092
  bodyExcerpt: bodyExcerpt(input.body),
@@ -3930,9 +4109,9 @@ function collectGreptileSignals(evidence) {
3930
4109
  for (const context of contextSources) {
3931
4110
  if (!context.body.trim())
3932
4111
  continue;
3933
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
3934
- continue;
3935
4112
  const contextBlocker = containsBlockerText(context.body);
4113
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4114
+ continue;
3936
4115
  signals.push(makeGreptileSignal({
3937
4116
  source: context.source,
3938
4117
  body: context.body,
@@ -3945,16 +4124,16 @@ function collectGreptileSignals(evidence) {
3945
4124
  for (const apiSignal of evidence.apiSignals ?? []) {
3946
4125
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
3947
4126
 
3948
- `);
3949
- if (!body.trim())
3950
- continue;
4127
+ `) || "Status: UNKNOWN";
4128
+ const verdict = greptileStatusVerdict(apiSignal.status);
3951
4129
  signals.push(makeGreptileSignal({
3952
4130
  source: "api",
3953
4131
  body,
3954
4132
  currentHeadSha: evidence.currentHeadSha,
3955
4133
  trusted: true,
3956
4134
  reviewedSha: apiSignal.reviewedSha ?? null,
3957
- explicitApproval: false
4135
+ explicitApproval: verdict === "approved",
4136
+ verdict
3958
4137
  }));
3959
4138
  }
3960
4139
  for (const review of evidence.reviews) {
@@ -3979,20 +4158,6 @@ function collectGreptileSignals(evidence) {
3979
4158
  blocker: state === "CHANGES_REQUESTED" || undefined
3980
4159
  }));
3981
4160
  }
3982
- for (const comment of evidence.changedFileReviewComments) {
3983
- const login = commentAuthorLogin(comment);
3984
- const body = comment.body ?? "";
3985
- if (!body.trim() || !isGreptileGithubLogin(login))
3986
- continue;
3987
- signals.push(makeGreptileSignal({
3988
- source: "changed-file-comment",
3989
- body,
3990
- currentHeadSha: evidence.currentHeadSha,
3991
- trusted: true,
3992
- authorLogin: login,
3993
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
3994
- }));
3995
- }
3996
4161
  for (const comment of evidence.relevantIssueComments) {
3997
4162
  const login = commentAuthorLogin(comment);
3998
4163
  const body = comment.body ?? "";
@@ -4058,6 +4223,9 @@ function unresolvedGreptileThreadSummaries(threads) {
4058
4223
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4059
4224
  });
4060
4225
  }
4226
+ function actionableChangedFileCommentSummaries(_comments) {
4227
+ return [];
4228
+ }
4061
4229
  function issueLevelBlockerSummaries(comments) {
4062
4230
  return comments.flatMap((comment) => {
4063
4231
  const body = comment.body?.trim() ?? "";
@@ -4097,14 +4265,21 @@ function deriveGreptileEvidence(input) {
4097
4265
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4098
4266
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4099
4267
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4100
- 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;
4101
4276
  const approvedByScore = !!approvingScoreEntry;
4102
- const approvedByExplicitMapping = false;
4103
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4277
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4278
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4104
4279
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4105
4280
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4106
4281
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4107
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4282
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4108
4283
  const staleBlockingSignals = [];
4109
4284
  const blockers = [
4110
4285
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -4115,7 +4290,8 @@ function deriveGreptileEvidence(input) {
4115
4290
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4116
4291
  ];
4117
4292
  const unresolvedComments = [
4118
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4293
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
4294
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
4119
4295
  ];
4120
4296
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4121
4297
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -4128,13 +4304,14 @@ function deriveGreptileEvidence(input) {
4128
4304
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4129
4305
  return completedState && review.commit_id === input.currentHeadSha;
4130
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"));
4131
4308
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4132
4309
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4133
4310
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4134
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4311
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4135
4312
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4136
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4137
- 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";
4138
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";
4139
4316
  return {
4140
4317
  source,
@@ -4241,6 +4418,7 @@ async function collectPrReviewEvidence(input) {
4241
4418
  readErrors.push("gh pr view did not return required reviews array");
4242
4419
  }
4243
4420
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4421
+ const baseRefName = firstString(view, ["baseRefName"]);
4244
4422
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4245
4423
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4246
4424
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4278,6 +4456,17 @@ async function collectPrReviewEvidence(input) {
4278
4456
  }
4279
4457
  }
4280
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];
4281
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})` : ""}`);
4282
4471
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4283
4472
  const evidenceBase = {
@@ -4289,7 +4478,7 @@ async function collectPrReviewEvidence(input) {
4289
4478
  reviewThreads,
4290
4479
  checks: checksWithGreptileDetails,
4291
4480
  currentHeadSha: headSha,
4292
- apiSignals: input.apiSignals ?? []
4481
+ apiSignals
4293
4482
  };
4294
4483
  const greptile = deriveGreptileEvidence(evidenceBase);
4295
4484
  return {
@@ -4300,7 +4489,7 @@ async function collectPrReviewEvidence(input) {
4300
4489
  body: evidenceBase.body,
4301
4490
  headSha,
4302
4491
  headRefName: firstString(view, ["headRefName"]),
4303
- baseRefName: firstString(view, ["baseRefName"]),
4492
+ baseRefName,
4304
4493
  state: firstString(view, ["state"]),
4305
4494
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4306
4495
  mergeable: firstString(view, ["mergeable"]),
@@ -4317,66 +4506,224 @@ async function collectPrReviewEvidence(input) {
4317
4506
  greptile
4318
4507
  };
4319
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
+ }
4320
4514
  function evaluateEvidence(evidence) {
4321
- const reasons = [];
4515
+ const reasonDetails = [];
4322
4516
  const warnings = [];
4323
- let pending = false;
4324
- if (evidence.readErrors.length > 0) {
4325
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4326
- }
4327
- if (!evidence.headSha)
4328
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4329
- if (evidence.checkFailures.length > 0)
4330
- reasons.push(...evidence.checkFailures);
4331
- if (evidence.pendingChecks.length > 0) {
4332
- pending = true;
4333
- 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
+ });
4334
4571
  }
4335
4572
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4336
4573
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4337
- 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
+ });
4338
4602
  }
4339
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4340
- if (unresolvedThreads.length > 0)
4341
- reasons.push(...unresolvedThreads);
4342
- const greptile = evidence.greptile;
4343
- if (greptile.mapping === "missing")
4344
- reasons.push("Missing Greptile check/review evidence for this PR.");
4345
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4346
4603
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4347
- 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
+ });
4348
4635
  }
4349
4636
  if (!greptile.completed) {
4350
- pending = true;
4351
- 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
+ });
4352
4657
  }
4353
- if (!greptile.fresh)
4354
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4355
4658
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4356
- 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
+ });
4357
4668
  }
4358
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4359
- 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
+ });
4360
4680
  }
4361
4681
  if (greptile.mapping === "unproven") {
4362
- 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
+ });
4363
4691
  }
4364
- if (greptile.blockers.length > 0) {
4365
- 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
+ });
4366
4713
  }
4367
- if (greptile.unresolvedComments.length > 0)
4368
- reasons.push(...greptile.unresolvedComments);
4369
4714
  if (!greptile.approved)
4370
4715
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4371
- 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 };
4372
4718
  }
4373
4719
  function evaluateStrictPrMergeGate(evidence) {
4374
4720
  const evaluated = evaluateEvidence(evidence);
4375
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4721
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
4376
4722
  return {
4377
4723
  approved,
4378
4724
  pending: evaluated.pending,
4379
4725
  reasons: evaluated.reasons,
4726
+ reasonDetails: evaluated.reasonDetails,
4380
4727
  warnings: evaluated.warnings,
4381
4728
  actionableFeedback: evaluated.reasons,
4382
4729
  evidence
@@ -5180,7 +5527,7 @@ async function runGreptileReviewForPr(options) {
5180
5527
  };
5181
5528
  }
5182
5529
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5183
- 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)) {
5184
5531
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5185
5532
  return {
5186
5533
  verdict: "REJECT",
@@ -5262,6 +5609,7 @@ async function runGreptileReviewForPr(options) {
5262
5609
  approved: strictGate.approved,
5263
5610
  pending: strictGate.pending,
5264
5611
  reasons: strictGate.reasons,
5612
+ reasonDetails: strictGate.reasonDetails,
5265
5613
  warnings: strictGate.warnings,
5266
5614
  greptile: strictGate.evidence.greptile,
5267
5615
  readErrors: strictGate.evidence.readErrors
@@ -5284,6 +5632,7 @@ async function runGreptileReviewForPr(options) {
5284
5632
  approved: strictGate.approved,
5285
5633
  pending: strictGate.pending,
5286
5634
  reasons: strictGate.reasons,
5635
+ reasonDetails: strictGate.reasonDetails,
5287
5636
  warnings: strictGate.warnings,
5288
5637
  greptile: strictGate.evidence.greptile,
5289
5638
  readErrors: strictGate.evidence.readErrors
@@ -5410,6 +5759,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5410
5759
  approved: strictGate.approved,
5411
5760
  pending: strictGate.pending,
5412
5761
  reasons: strictGate.reasons,
5762
+ reasonDetails: strictGate.reasonDetails,
5413
5763
  warnings: strictGate.warnings,
5414
5764
  greptile: strictGate.evidence.greptile
5415
5765
  },
@@ -5432,6 +5782,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5432
5782
  approved: strictGate.approved,
5433
5783
  pending: strictGate.pending,
5434
5784
  reasons: strictGate.reasons,
5785
+ reasonDetails: strictGate.reasonDetails,
5435
5786
  warnings: strictGate.warnings,
5436
5787
  greptile: strictGate.evidence.greptile
5437
5788
  },
@@ -5553,8 +5904,7 @@ function shouldContinueGreptileMcpPolling(options) {
5553
5904
  if (options.githubCheckState.completed) {
5554
5905
  return false;
5555
5906
  }
5556
- const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
5557
- if (!hasRemainingBudget) {
5907
+ if (options.attempt + 1 >= options.pollAttempts) {
5558
5908
  return false;
5559
5909
  }
5560
5910
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
@@ -5564,8 +5914,11 @@ function shouldContinueGreptileMcpPolling(options) {
5564
5914
  }
5565
5915
  function shouldContinueGithubGreptileFallbackPolling(options) {
5566
5916
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
5917
+ if (options.attempt + 1 >= options.pollAttempts) {
5918
+ return false;
5919
+ }
5567
5920
  if (waitingForVisiblePendingReview) {
5568
- return options.attempt + 1 < options.pollAttempts;
5921
+ return true;
5569
5922
  }
5570
5923
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
5571
5924
  if (reviewNotVisibleYet) {
@@ -7015,7 +7368,7 @@ function gitOpenPr(options) {
7015
7368
  "",
7016
7369
  "## Review",
7017
7370
  "- Completion verification will run validation, verifier review, and PR policy checks.",
7018
- "- 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."
7019
7372
  ].join(`
7020
7373
  `);
7021
7374
  const preCheck = runCapture2(withGhRepo([gh, "pr", "list", "--state", "merged", "--head", branch, "--json", "url,mergedAt", "--jq", ".[0]"], repoNameWithOwner), repoRoot);