@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.
@@ -3714,13 +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);
3718
- }
3719
- function containsGreptileNegativeVerdict(input) {
3720
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
3721
- if (!text)
3722
- return false;
3723
- 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);
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);
3724
3718
  }
3725
3719
  function isStrictFiveOfFive(score) {
3726
3720
  return score.value === 5 && score.scale === 5;
@@ -3728,6 +3722,189 @@ function isStrictFiveOfFive(score) {
3728
3722
  function containsConflictingScoreText(input) {
3729
3723
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3730
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
+ }
3731
3908
  function firstString(record, keys) {
3732
3909
  for (const key of keys) {
3733
3910
  const value = record[key];
@@ -3854,7 +4031,7 @@ function normalizeReviewThread(entry) {
3854
4031
  function relevantIssueComment(comment) {
3855
4032
  const login = comment.user?.login ?? comment.author?.login ?? "";
3856
4033
  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);
4034
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
3858
4035
  }
3859
4036
  function latestThreadComment(thread) {
3860
4037
  const nodes = thread.comments?.nodes ?? [];
@@ -3890,7 +4067,8 @@ function makeGreptileSignal(input) {
3890
4067
  const scores = parseGreptileScores(input.body);
3891
4068
  const reviewedSha = input.reviewedSha?.trim() || null;
3892
4069
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
3893
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
4070
+ const verdict = input.verdict ?? null;
4071
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
3894
4072
  const explicitApproval = input.explicitApproval ?? false;
3895
4073
  return {
3896
4074
  source: input.source,
@@ -3902,6 +4080,7 @@ function makeGreptileSignal(input) {
3902
4080
  score: scores[0] ?? null,
3903
4081
  scores,
3904
4082
  explicitApproval,
4083
+ verdict,
3905
4084
  blocker,
3906
4085
  actionable: input.actionable ?? blocker,
3907
4086
  bodyExcerpt: bodyExcerpt(input.body),
@@ -3924,9 +4103,9 @@ function collectGreptileSignals(evidence) {
3924
4103
  for (const context of contextSources) {
3925
4104
  if (!context.body.trim())
3926
4105
  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
4106
  const contextBlocker = containsBlockerText(context.body);
4107
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4108
+ continue;
3930
4109
  signals.push(makeGreptileSignal({
3931
4110
  source: context.source,
3932
4111
  body: context.body,
@@ -3939,16 +4118,16 @@ function collectGreptileSignals(evidence) {
3939
4118
  for (const apiSignal of evidence.apiSignals ?? []) {
3940
4119
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
3941
4120
 
3942
- `);
3943
- if (!body.trim())
3944
- continue;
4121
+ `) || "Status: UNKNOWN";
4122
+ const verdict = greptileStatusVerdict(apiSignal.status);
3945
4123
  signals.push(makeGreptileSignal({
3946
4124
  source: "api",
3947
4125
  body,
3948
4126
  currentHeadSha: evidence.currentHeadSha,
3949
4127
  trusted: true,
3950
4128
  reviewedSha: apiSignal.reviewedSha ?? null,
3951
- explicitApproval: false
4129
+ explicitApproval: verdict === "approved",
4130
+ verdict
3952
4131
  }));
3953
4132
  }
3954
4133
  for (const review of evidence.reviews) {
@@ -3973,20 +4152,6 @@ function collectGreptileSignals(evidence) {
3973
4152
  blocker: state === "CHANGES_REQUESTED" || undefined
3974
4153
  }));
3975
4154
  }
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
4155
  for (const comment of evidence.relevantIssueComments) {
3991
4156
  const login = commentAuthorLogin(comment);
3992
4157
  const body = comment.body ?? "";
@@ -4052,6 +4217,9 @@ function unresolvedGreptileThreadSummaries(threads) {
4052
4217
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4053
4218
  });
4054
4219
  }
4220
+ function actionableChangedFileCommentSummaries(_comments) {
4221
+ return [];
4222
+ }
4055
4223
  function issueLevelBlockerSummaries(comments) {
4056
4224
  return comments.flatMap((comment) => {
4057
4225
  const body = comment.body?.trim() ?? "";
@@ -4091,14 +4259,21 @@ function deriveGreptileEvidence(input) {
4091
4259
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4092
4260
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4093
4261
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4094
- 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;
4095
4270
  const approvedByScore = !!approvingScoreEntry;
4096
- const approvedByExplicitMapping = false;
4097
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4271
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4272
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4098
4273
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4099
4274
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4100
4275
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4101
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4276
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4102
4277
  const staleBlockingSignals = [];
4103
4278
  const blockers = [
4104
4279
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -4109,7 +4284,8 @@ function deriveGreptileEvidence(input) {
4109
4284
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4110
4285
  ];
4111
4286
  const unresolvedComments = [
4112
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4287
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
4288
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
4113
4289
  ];
4114
4290
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4115
4291
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -4122,13 +4298,14 @@ function deriveGreptileEvidence(input) {
4122
4298
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4123
4299
  return completedState && review.commit_id === input.currentHeadSha;
4124
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"));
4125
4302
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4126
4303
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4127
4304
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4128
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4305
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4129
4306
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4130
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4131
- 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";
4132
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";
4133
4310
  return {
4134
4311
  source,
@@ -4235,6 +4412,7 @@ async function collectPrReviewEvidence(input) {
4235
4412
  readErrors.push("gh pr view did not return required reviews array");
4236
4413
  }
4237
4414
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4415
+ const baseRefName = firstString(view, ["baseRefName"]);
4238
4416
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4239
4417
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4240
4418
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4272,6 +4450,17 @@ async function collectPrReviewEvidence(input) {
4272
4450
  }
4273
4451
  }
4274
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];
4275
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})` : ""}`);
4276
4465
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4277
4466
  const evidenceBase = {
@@ -4283,7 +4472,7 @@ async function collectPrReviewEvidence(input) {
4283
4472
  reviewThreads,
4284
4473
  checks: checksWithGreptileDetails,
4285
4474
  currentHeadSha: headSha,
4286
- apiSignals: input.apiSignals ?? []
4475
+ apiSignals
4287
4476
  };
4288
4477
  const greptile = deriveGreptileEvidence(evidenceBase);
4289
4478
  return {
@@ -4294,7 +4483,7 @@ async function collectPrReviewEvidence(input) {
4294
4483
  body: evidenceBase.body,
4295
4484
  headSha,
4296
4485
  headRefName: firstString(view, ["headRefName"]),
4297
- baseRefName: firstString(view, ["baseRefName"]),
4486
+ baseRefName,
4298
4487
  state: firstString(view, ["state"]),
4299
4488
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4300
4489
  mergeable: firstString(view, ["mergeable"]),
@@ -4311,66 +4500,224 @@ async function collectPrReviewEvidence(input) {
4311
4500
  greptile
4312
4501
  };
4313
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
+ }
4314
4508
  function evaluateEvidence(evidence) {
4315
- const reasons = [];
4509
+ const reasonDetails = [];
4316
4510
  const warnings = [];
4317
- let pending = false;
4318
- if (evidence.readErrors.length > 0) {
4319
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4320
- }
4321
- if (!evidence.headSha)
4322
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4323
- if (evidence.checkFailures.length > 0)
4324
- reasons.push(...evidence.checkFailures);
4325
- if (evidence.pendingChecks.length > 0) {
4326
- pending = true;
4327
- 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
+ });
4328
4565
  }
4329
4566
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4330
4567
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4331
- 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
+ });
4332
4596
  }
4333
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4334
- if (unresolvedThreads.length > 0)
4335
- reasons.push(...unresolvedThreads);
4336
- const greptile = evidence.greptile;
4337
- if (greptile.mapping === "missing")
4338
- reasons.push("Missing Greptile check/review evidence for this PR.");
4339
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4340
4597
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4341
- 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
+ });
4342
4629
  }
4343
4630
  if (!greptile.completed) {
4344
- pending = true;
4345
- 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
+ });
4346
4651
  }
4347
- if (!greptile.fresh)
4348
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4349
4652
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4350
- 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
+ });
4351
4662
  }
4352
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4353
- 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
+ });
4354
4674
  }
4355
4675
  if (greptile.mapping === "unproven") {
4356
- 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
+ });
4357
4685
  }
4358
- if (greptile.blockers.length > 0) {
4359
- 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
+ });
4360
4707
  }
4361
- if (greptile.unresolvedComments.length > 0)
4362
- reasons.push(...greptile.unresolvedComments);
4363
4708
  if (!greptile.approved)
4364
4709
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4365
- 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 };
4366
4712
  }
4367
4713
  function evaluateStrictPrMergeGate(evidence) {
4368
4714
  const evaluated = evaluateEvidence(evidence);
4369
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4715
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
4370
4716
  return {
4371
4717
  approved,
4372
4718
  pending: evaluated.pending,
4373
4719
  reasons: evaluated.reasons,
4720
+ reasonDetails: evaluated.reasonDetails,
4374
4721
  warnings: evaluated.warnings,
4375
4722
  actionableFeedback: evaluated.reasons,
4376
4723
  evidence
@@ -5174,7 +5521,7 @@ async function runGreptileReviewForPr(options) {
5174
5521
  };
5175
5522
  }
5176
5523
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5177
- 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)) {
5178
5525
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5179
5526
  return {
5180
5527
  verdict: "REJECT",
@@ -5256,6 +5603,7 @@ async function runGreptileReviewForPr(options) {
5256
5603
  approved: strictGate.approved,
5257
5604
  pending: strictGate.pending,
5258
5605
  reasons: strictGate.reasons,
5606
+ reasonDetails: strictGate.reasonDetails,
5259
5607
  warnings: strictGate.warnings,
5260
5608
  greptile: strictGate.evidence.greptile,
5261
5609
  readErrors: strictGate.evidence.readErrors
@@ -5278,6 +5626,7 @@ async function runGreptileReviewForPr(options) {
5278
5626
  approved: strictGate.approved,
5279
5627
  pending: strictGate.pending,
5280
5628
  reasons: strictGate.reasons,
5629
+ reasonDetails: strictGate.reasonDetails,
5281
5630
  warnings: strictGate.warnings,
5282
5631
  greptile: strictGate.evidence.greptile,
5283
5632
  readErrors: strictGate.evidence.readErrors
@@ -5404,6 +5753,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5404
5753
  approved: strictGate.approved,
5405
5754
  pending: strictGate.pending,
5406
5755
  reasons: strictGate.reasons,
5756
+ reasonDetails: strictGate.reasonDetails,
5407
5757
  warnings: strictGate.warnings,
5408
5758
  greptile: strictGate.evidence.greptile
5409
5759
  },
@@ -5426,6 +5776,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5426
5776
  approved: strictGate.approved,
5427
5777
  pending: strictGate.pending,
5428
5778
  reasons: strictGate.reasons,
5779
+ reasonDetails: strictGate.reasonDetails,
5429
5780
  warnings: strictGate.warnings,
5430
5781
  greptile: strictGate.evidence.greptile
5431
5782
  },
@@ -5547,8 +5898,7 @@ function shouldContinueGreptileMcpPolling(options) {
5547
5898
  if (options.githubCheckState.completed) {
5548
5899
  return false;
5549
5900
  }
5550
- const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
5551
- if (!hasRemainingBudget) {
5901
+ if (options.attempt + 1 >= options.pollAttempts) {
5552
5902
  return false;
5553
5903
  }
5554
5904
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
@@ -5558,8 +5908,11 @@ function shouldContinueGreptileMcpPolling(options) {
5558
5908
  }
5559
5909
  function shouldContinueGithubGreptileFallbackPolling(options) {
5560
5910
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
5911
+ if (options.attempt + 1 >= options.pollAttempts) {
5912
+ return false;
5913
+ }
5561
5914
  if (waitingForVisiblePendingReview) {
5562
- return options.attempt + 1 < options.pollAttempts;
5915
+ return true;
5563
5916
  }
5564
5917
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
5565
5918
  if (reviewNotVisibleYet) {
@@ -7009,7 +7362,7 @@ function gitOpenPr(options) {
7009
7362
  "",
7010
7363
  "## Review",
7011
7364
  "- Completion verification will run validation, verifier review, and PR policy checks.",
7012
- "- 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."
7013
7366
  ].join(`
7014
7367
  `);
7015
7368
  const preCheck = runCapture2(withGhRepo([gh, "pr", "list", "--state", "merged", "--head", branch, "--json", "url,mergedAt", "--jq", ".[0]"], repoNameWithOwner), repoRoot);