@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.
@@ -4352,13 +4352,7 @@ function stripHtml(input) {
4352
4352
  }
4353
4353
  function containsBlockerText(input) {
4354
4354
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
4355
- 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);
4356
- }
4357
- function containsGreptileNegativeVerdict(input) {
4358
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
4359
- if (!text)
4360
- return false;
4361
- 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);
4355
+ 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);
4362
4356
  }
4363
4357
  function isStrictFiveOfFive(score) {
4364
4358
  return score.value === 5 && score.scale === 5;
@@ -4366,6 +4360,189 @@ function isStrictFiveOfFive(score) {
4366
4360
  function containsConflictingScoreText(input) {
4367
4361
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
4368
4362
  }
4363
+ function greptileStatusVerdict(status) {
4364
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
4365
+ if (!normalized)
4366
+ return null;
4367
+ if (["APPROVE", "APPROVED"].includes(normalized))
4368
+ return "approved";
4369
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
4370
+ return "rejected";
4371
+ if (["SKIP", "SKIPPED"].includes(normalized))
4372
+ return "skipped";
4373
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
4374
+ return "failed";
4375
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
4376
+ return "pending";
4377
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
4378
+ return "completed";
4379
+ return null;
4380
+ }
4381
+ function isBlockingGreptileVerdict(verdict) {
4382
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
4383
+ }
4384
+ function greptileRequestTimeoutMs(env) {
4385
+ const fallback = 30000;
4386
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
4387
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
4388
+ }
4389
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
4390
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4391
+ return null;
4392
+ const record = entry;
4393
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
4394
+ if (!id)
4395
+ return null;
4396
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
4397
+ return {
4398
+ id,
4399
+ status: typeof record.status === "string" ? record.status : null,
4400
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
4401
+ body: typeof record.body === "string" ? record.body : null,
4402
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
4403
+ };
4404
+ }
4405
+ function uniqueGreptileCodeReviews(reviews) {
4406
+ const seen = new Set;
4407
+ const unique2 = [];
4408
+ for (const review of reviews) {
4409
+ if (seen.has(review.id))
4410
+ continue;
4411
+ seen.add(review.id);
4412
+ unique2.push(review);
4413
+ }
4414
+ return unique2;
4415
+ }
4416
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
4417
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
4418
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
4419
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
4420
+ const latest = sorted.slice(0, 1);
4421
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
4422
+ }
4423
+ function greptileApiSignalFromCodeReview(review, details) {
4424
+ const selected = details ?? review;
4425
+ return {
4426
+ id: selected.id || review.id,
4427
+ body: selected.body ?? review.body ?? null,
4428
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
4429
+ status: selected.status ?? review.status ?? null
4430
+ };
4431
+ }
4432
+ async function callGreptileMcpToolForGate(input) {
4433
+ const controller = new AbortController;
4434
+ const timeoutId = setTimeout(() => {
4435
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
4436
+ }, input.timeoutMs);
4437
+ let response;
4438
+ try {
4439
+ response = await input.fetchFn(input.apiBase, {
4440
+ method: "POST",
4441
+ headers: {
4442
+ Authorization: `Bearer ${input.apiKey}`,
4443
+ "Content-Type": "application/json"
4444
+ },
4445
+ body: JSON.stringify({
4446
+ jsonrpc: "2.0",
4447
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
4448
+ method: "tools/call",
4449
+ params: { name: input.name, arguments: input.args }
4450
+ }),
4451
+ signal: controller.signal
4452
+ });
4453
+ } catch (error) {
4454
+ if (controller.signal.aborted) {
4455
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
4456
+ }
4457
+ throw error;
4458
+ } finally {
4459
+ clearTimeout(timeoutId);
4460
+ }
4461
+ const raw = await response.text();
4462
+ if (!response.ok) {
4463
+ throw new Error(`HTTP ${response.status}: ${raw}`);
4464
+ }
4465
+ let envelope;
4466
+ try {
4467
+ envelope = JSON.parse(raw);
4468
+ } catch {
4469
+ throw new Error(`Malformed MCP response: ${raw}`);
4470
+ }
4471
+ if (envelope.error?.message) {
4472
+ throw new Error(envelope.error.message);
4473
+ }
4474
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
4475
+ `).trim();
4476
+ if (!text) {
4477
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
4478
+ }
4479
+ return text;
4480
+ }
4481
+ async function callGreptileMcpToolJsonForGate(input) {
4482
+ const text = await callGreptileMcpToolForGate(input);
4483
+ try {
4484
+ return JSON.parse(text);
4485
+ } catch {
4486
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
4487
+ }
4488
+ }
4489
+ async function collectConfiguredGreptileApiSignals(input) {
4490
+ if (!input.enabled || input.options?.enabled === false) {
4491
+ return { signals: [], errors: [] };
4492
+ }
4493
+ const env = input.options?.env ?? process.env;
4494
+ const secrets = resolveRuntimeSecrets(env);
4495
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
4496
+ if (!apiKey) {
4497
+ return { signals: [], errors: [] };
4498
+ }
4499
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
4500
+ if (typeof fetchFn !== "function") {
4501
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
4502
+ }
4503
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
4504
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
4505
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
4506
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
4507
+ const timeoutMs = greptileRequestTimeoutMs(env);
4508
+ try {
4509
+ const listPayload = await callGreptileMcpToolJsonForGate({
4510
+ apiBase,
4511
+ apiKey,
4512
+ name: "list_code_reviews",
4513
+ args: {
4514
+ name: repository,
4515
+ remote,
4516
+ defaultBranch,
4517
+ prNumber: input.prNumber,
4518
+ limit: 20
4519
+ },
4520
+ timeoutMs,
4521
+ fetchFn
4522
+ });
4523
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
4524
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
4525
+ const signals = [];
4526
+ for (const review of selectedReviews) {
4527
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
4528
+ apiBase,
4529
+ apiKey,
4530
+ name: "get_code_review",
4531
+ args: { codeReviewId: review.id },
4532
+ timeoutMs,
4533
+ fetchFn
4534
+ });
4535
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
4536
+ signals.push(greptileApiSignalFromCodeReview(review, details));
4537
+ }
4538
+ return { signals, errors: [] };
4539
+ } catch (error) {
4540
+ return {
4541
+ signals: [],
4542
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
4543
+ };
4544
+ }
4545
+ }
4369
4546
  function firstString(record, keys) {
4370
4547
  for (const key of keys) {
4371
4548
  const value = record[key];
@@ -4492,7 +4669,7 @@ function normalizeReviewThread(entry) {
4492
4669
  function relevantIssueComment(comment) {
4493
4670
  const login = comment.user?.login ?? comment.author?.login ?? "";
4494
4671
  const body = comment.body ?? "";
4495
- 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);
4672
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
4496
4673
  }
4497
4674
  function latestThreadComment(thread) {
4498
4675
  const nodes = thread.comments?.nodes ?? [];
@@ -4528,7 +4705,8 @@ function makeGreptileSignal(input) {
4528
4705
  const scores = parseGreptileScores(input.body);
4529
4706
  const reviewedSha = input.reviewedSha?.trim() || null;
4530
4707
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
4531
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
4708
+ const verdict = input.verdict ?? null;
4709
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
4532
4710
  const explicitApproval = input.explicitApproval ?? false;
4533
4711
  return {
4534
4712
  source: input.source,
@@ -4540,6 +4718,7 @@ function makeGreptileSignal(input) {
4540
4718
  score: scores[0] ?? null,
4541
4719
  scores,
4542
4720
  explicitApproval,
4721
+ verdict,
4543
4722
  blocker,
4544
4723
  actionable: input.actionable ?? blocker,
4545
4724
  bodyExcerpt: bodyExcerpt(input.body),
@@ -4562,9 +4741,9 @@ function collectGreptileSignals(evidence) {
4562
4741
  for (const context of contextSources) {
4563
4742
  if (!context.body.trim())
4564
4743
  continue;
4565
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
4566
- continue;
4567
4744
  const contextBlocker = containsBlockerText(context.body);
4745
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4746
+ continue;
4568
4747
  signals.push(makeGreptileSignal({
4569
4748
  source: context.source,
4570
4749
  body: context.body,
@@ -4577,16 +4756,16 @@ function collectGreptileSignals(evidence) {
4577
4756
  for (const apiSignal of evidence.apiSignals ?? []) {
4578
4757
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
4579
4758
 
4580
- `);
4581
- if (!body.trim())
4582
- continue;
4759
+ `) || "Status: UNKNOWN";
4760
+ const verdict = greptileStatusVerdict(apiSignal.status);
4583
4761
  signals.push(makeGreptileSignal({
4584
4762
  source: "api",
4585
4763
  body,
4586
4764
  currentHeadSha: evidence.currentHeadSha,
4587
4765
  trusted: true,
4588
4766
  reviewedSha: apiSignal.reviewedSha ?? null,
4589
- explicitApproval: false
4767
+ explicitApproval: verdict === "approved",
4768
+ verdict
4590
4769
  }));
4591
4770
  }
4592
4771
  for (const review of evidence.reviews) {
@@ -4611,20 +4790,6 @@ function collectGreptileSignals(evidence) {
4611
4790
  blocker: state === "CHANGES_REQUESTED" || undefined
4612
4791
  }));
4613
4792
  }
4614
- for (const comment of evidence.changedFileReviewComments) {
4615
- const login = commentAuthorLogin(comment);
4616
- const body = comment.body ?? "";
4617
- if (!body.trim() || !isGreptileGithubLogin(login))
4618
- continue;
4619
- signals.push(makeGreptileSignal({
4620
- source: "changed-file-comment",
4621
- body,
4622
- currentHeadSha: evidence.currentHeadSha,
4623
- trusted: true,
4624
- authorLogin: login,
4625
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
4626
- }));
4627
- }
4628
4793
  for (const comment of evidence.relevantIssueComments) {
4629
4794
  const login = commentAuthorLogin(comment);
4630
4795
  const body = comment.body ?? "";
@@ -4690,6 +4855,9 @@ function unresolvedGreptileThreadSummaries(threads) {
4690
4855
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4691
4856
  });
4692
4857
  }
4858
+ function actionableChangedFileCommentSummaries(_comments) {
4859
+ return [];
4860
+ }
4693
4861
  function issueLevelBlockerSummaries(comments) {
4694
4862
  return comments.flatMap((comment) => {
4695
4863
  const body = comment.body?.trim() ?? "";
@@ -4729,14 +4897,21 @@ function deriveGreptileEvidence(input) {
4729
4897
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4730
4898
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4731
4899
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4732
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4900
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
4901
+ const signalCanApproveByScore = (signal) => {
4902
+ if (signal.source === "api")
4903
+ return signal.verdict === "approved" || signal.verdict === "completed";
4904
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
4905
+ };
4906
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
4907
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
4733
4908
  const approvedByScore = !!approvingScoreEntry;
4734
- const approvedByExplicitMapping = false;
4735
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4909
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4910
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4736
4911
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4737
4912
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4738
4913
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4739
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4914
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4740
4915
  const staleBlockingSignals = [];
4741
4916
  const blockers = [
4742
4917
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -4747,7 +4922,8 @@ function deriveGreptileEvidence(input) {
4747
4922
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4748
4923
  ];
4749
4924
  const unresolvedComments = [
4750
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4925
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
4926
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
4751
4927
  ];
4752
4928
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4753
4929
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -4760,13 +4936,14 @@ function deriveGreptileEvidence(input) {
4760
4936
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4761
4937
  return completedState && review.commit_id === input.currentHeadSha;
4762
4938
  });
4939
+ 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"));
4763
4940
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4764
4941
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4765
4942
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4766
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4943
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4767
4944
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4768
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4769
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4945
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
4946
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
4770
4947
  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";
4771
4948
  return {
4772
4949
  source,
@@ -4873,6 +5050,7 @@ async function collectPrReviewEvidence(input) {
4873
5050
  readErrors.push("gh pr view did not return required reviews array");
4874
5051
  }
4875
5052
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
5053
+ const baseRefName = firstString(view, ["baseRefName"]);
4876
5054
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4877
5055
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4878
5056
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4910,6 +5088,17 @@ async function collectPrReviewEvidence(input) {
4910
5088
  }
4911
5089
  }
4912
5090
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
5091
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
5092
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
5093
+ enabled: shouldCollectConfiguredGreptileApi,
5094
+ options: input.greptileApi,
5095
+ repoName: parsed.repoName,
5096
+ prNumber: parsed.prNumber,
5097
+ headSha,
5098
+ baseRefName
5099
+ });
5100
+ readErrors.push(...configuredGreptileApiRead.errors);
5101
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
4913
5102
  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})` : ""}`);
4914
5103
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4915
5104
  const evidenceBase = {
@@ -4921,7 +5110,7 @@ async function collectPrReviewEvidence(input) {
4921
5110
  reviewThreads,
4922
5111
  checks: checksWithGreptileDetails,
4923
5112
  currentHeadSha: headSha,
4924
- apiSignals: input.apiSignals ?? []
5113
+ apiSignals
4925
5114
  };
4926
5115
  const greptile = deriveGreptileEvidence(evidenceBase);
4927
5116
  return {
@@ -4932,7 +5121,7 @@ async function collectPrReviewEvidence(input) {
4932
5121
  body: evidenceBase.body,
4933
5122
  headSha,
4934
5123
  headRefName: firstString(view, ["headRefName"]),
4935
- baseRefName: firstString(view, ["baseRefName"]),
5124
+ baseRefName,
4936
5125
  state: firstString(view, ["state"]),
4937
5126
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4938
5127
  mergeable: firstString(view, ["mergeable"]),
@@ -4949,71 +5138,251 @@ async function collectPrReviewEvidence(input) {
4949
5138
  greptile
4950
5139
  };
4951
5140
  }
5141
+ function capGateMessage(value, maxChars = 1200) {
5142
+ const normalized = value.trim();
5143
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
5144
+ [truncated for gate summary; see full evidence artifact]` : normalized;
5145
+ }
4952
5146
  function evaluateEvidence(evidence) {
4953
- const reasons = [];
5147
+ const reasonDetails = [];
4954
5148
  const warnings = [];
4955
- let pending = false;
4956
- if (evidence.readErrors.length > 0) {
4957
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4958
- }
4959
- if (!evidence.headSha)
4960
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4961
- if (evidence.checkFailures.length > 0)
4962
- reasons.push(...evidence.checkFailures);
4963
- if (evidence.pendingChecks.length > 0) {
4964
- pending = true;
4965
- reasons.push(...evidence.pendingChecks);
5149
+ const seen = new Set;
5150
+ const addReason = (reason) => {
5151
+ const capped = { ...reason, message: capGateMessage(reason.message) };
5152
+ const key = `${capped.code}:${capped.message}`;
5153
+ if (seen.has(key))
5154
+ return;
5155
+ seen.add(key);
5156
+ reasonDetails.push(capped);
5157
+ };
5158
+ const greptile = evidence.greptile;
5159
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
5160
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
5161
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
5162
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
5163
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
5164
+ for (const error of evidence.readErrors) {
5165
+ addReason({
5166
+ code: "read_error",
5167
+ reasonClass: "reject",
5168
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
5169
+ suggestedAction: "needs_attention",
5170
+ message: `Required PR evidence surface could not be read completely: ${error}`,
5171
+ headSha: evidence.headSha || null
5172
+ });
5173
+ }
5174
+ if (!evidence.headSha) {
5175
+ addReason({
5176
+ code: "missing_head_sha",
5177
+ reasonClass: "reject",
5178
+ surface: "github",
5179
+ suggestedAction: "needs_attention",
5180
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
5181
+ headSha: null
5182
+ });
5183
+ }
5184
+ for (const failure of evidence.checkFailures) {
5185
+ addReason({
5186
+ code: "ci_failed",
5187
+ reasonClass: "reject",
5188
+ surface: "ci",
5189
+ suggestedAction: "fix",
5190
+ message: failure,
5191
+ headSha: evidence.headSha || null
5192
+ });
5193
+ }
5194
+ for (const pendingCheck of evidence.pendingChecks) {
5195
+ addReason({
5196
+ code: "check_pending",
5197
+ reasonClass: "pending",
5198
+ surface: "ci",
5199
+ suggestedAction: "wait",
5200
+ message: pendingCheck,
5201
+ headSha: evidence.headSha || null
5202
+ });
4966
5203
  }
4967
5204
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4968
5205
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4969
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
5206
+ addReason({
5207
+ code: "review_decision_blocking",
5208
+ reasonClass: "reject",
5209
+ surface: "review",
5210
+ suggestedAction: "fix",
5211
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
5212
+ headSha: evidence.headSha || null
5213
+ });
5214
+ }
5215
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
5216
+ addReason({
5217
+ code: "review_thread_unresolved",
5218
+ reasonClass: "reject",
5219
+ surface: "review",
5220
+ suggestedAction: "fix",
5221
+ message: thread,
5222
+ headSha: evidence.headSha || null
5223
+ });
5224
+ }
5225
+ if (greptile.mapping === "missing") {
5226
+ addReason({
5227
+ code: "greptile_missing",
5228
+ reasonClass: "pending",
5229
+ surface: "greptile",
5230
+ suggestedAction: "wait",
5231
+ message: "Missing Greptile check/review evidence for this PR.",
5232
+ headSha: evidence.headSha || null
5233
+ });
4970
5234
  }
4971
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4972
- if (unresolvedThreads.length > 0)
4973
- reasons.push(...unresolvedThreads);
4974
- const greptile = evidence.greptile;
4975
- if (greptile.mapping === "missing")
4976
- reasons.push("Missing Greptile check/review evidence for this PR.");
4977
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4978
5235
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4979
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
5236
+ addReason({
5237
+ code: "greptile_stale",
5238
+ reasonClass: "pending",
5239
+ surface: "greptile",
5240
+ suggestedAction: "wait",
5241
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
5242
+ headSha: evidence.headSha || null,
5243
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
5244
+ });
5245
+ }
5246
+ for (const signal of pendingGreptileApiSignals) {
5247
+ addReason({
5248
+ code: "greptile_pending",
5249
+ reasonClass: "pending",
5250
+ surface: "greptile",
5251
+ suggestedAction: "wait",
5252
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
5253
+ headSha: evidence.headSha || null,
5254
+ reviewedSha: signal.reviewedSha ?? null
5255
+ });
5256
+ }
5257
+ for (const signal of unknownGreptileApiSignals) {
5258
+ addReason({
5259
+ code: "greptile_api_status_unknown",
5260
+ reasonClass: "reject",
5261
+ surface: "greptile",
5262
+ suggestedAction: "needs_attention",
5263
+ 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}` : "."}`,
5264
+ headSha: evidence.headSha || null,
5265
+ reviewedSha: signal.reviewedSha ?? null
5266
+ });
4980
5267
  }
4981
5268
  if (!greptile.completed) {
4982
- pending = true;
4983
- reasons.push("Greptile check/review has not completed for the current PR head.");
5269
+ addReason({
5270
+ code: "greptile_pending",
5271
+ reasonClass: "pending",
5272
+ surface: "greptile",
5273
+ suggestedAction: "wait",
5274
+ message: "Greptile check/review has not completed for the current PR head.",
5275
+ headSha: evidence.headSha || null,
5276
+ reviewedSha: greptile.reviewedSha ?? null
5277
+ });
5278
+ }
5279
+ if (!greptile.fresh) {
5280
+ addReason({
5281
+ code: "greptile_not_current_head",
5282
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
5283
+ surface: "greptile",
5284
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
5285
+ message: "Greptile approval is not tied to the current PR head SHA.",
5286
+ headSha: evidence.headSha || null,
5287
+ reviewedSha: greptile.reviewedSha ?? null
5288
+ });
4984
5289
  }
4985
- if (!greptile.fresh)
4986
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4987
5290
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4988
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
5291
+ addReason({
5292
+ code: "greptile_score_not_5",
5293
+ reasonClass: "reject",
5294
+ surface: "greptile",
5295
+ suggestedAction: "fix",
5296
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
5297
+ headSha: evidence.headSha || null,
5298
+ reviewedSha: greptile.reviewedSha ?? null
5299
+ });
4989
5300
  }
4990
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4991
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
5301
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
5302
+ if (!greptile.score && !hasApprovedMapping) {
5303
+ addReason({
5304
+ code: "greptile_score_missing",
5305
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
5306
+ surface: "greptile",
5307
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
5308
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
5309
+ headSha: evidence.headSha || null,
5310
+ reviewedSha: greptile.reviewedSha ?? null
5311
+ });
4992
5312
  }
4993
5313
  if (greptile.mapping === "unproven") {
4994
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
5314
+ addReason({
5315
+ code: "greptile_mapping_unproven",
5316
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
5317
+ surface: "greptile",
5318
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
5319
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
5320
+ headSha: evidence.headSha || null,
5321
+ reviewedSha: greptile.reviewedSha ?? null
5322
+ });
5323
+ }
5324
+ for (const blocker of greptile.blockers) {
5325
+ addReason({
5326
+ code: "greptile_blocker_text",
5327
+ reasonClass: "reject",
5328
+ surface: "greptile",
5329
+ suggestedAction: "fix",
5330
+ message: `Greptile/blocker text: ${blocker}`,
5331
+ headSha: evidence.headSha || null,
5332
+ reviewedSha: greptile.reviewedSha ?? null
5333
+ });
4995
5334
  }
4996
- if (greptile.blockers.length > 0) {
4997
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
5335
+ for (const comment of greptile.unresolvedComments) {
5336
+ addReason({
5337
+ code: "greptile_unresolved_comment",
5338
+ reasonClass: "reject",
5339
+ surface: "greptile",
5340
+ suggestedAction: "fix",
5341
+ message: comment,
5342
+ headSha: evidence.headSha || null,
5343
+ reviewedSha: greptile.reviewedSha ?? null
5344
+ });
4998
5345
  }
4999
- if (greptile.unresolvedComments.length > 0)
5000
- reasons.push(...greptile.unresolvedComments);
5001
5346
  if (!greptile.approved)
5002
5347
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
5003
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
5348
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
5349
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
5004
5350
  }
5005
5351
  function evaluateStrictPrMergeGate(evidence) {
5006
5352
  const evaluated = evaluateEvidence(evidence);
5007
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
5353
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
5008
5354
  return {
5009
5355
  approved,
5010
5356
  pending: evaluated.pending,
5011
5357
  reasons: evaluated.reasons,
5358
+ reasonDetails: evaluated.reasonDetails,
5012
5359
  warnings: evaluated.warnings,
5013
5360
  actionableFeedback: evaluated.reasons,
5014
5361
  evidence
5015
5362
  };
5016
5363
  }
5364
+ function strictMergeHeadShaFromGate(result, prUrl) {
5365
+ if (!result.approved) {
5366
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate is not approved.`);
5367
+ }
5368
+ if (result.evidence.prUrl !== prUrl) {
5369
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate evidence belongs to ${result.evidence.prUrl}.`);
5370
+ }
5371
+ const headSha = result.evidence.headSha?.trim();
5372
+ if (!headSha) {
5373
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate did not provide a current head SHA.`);
5374
+ }
5375
+ if (!/^[0-9a-f]{40}$/i.test(headSha)) {
5376
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate head is not a raw 40-character commit SHA.`);
5377
+ }
5378
+ if (!result.evidence.greptile.fresh || result.evidence.greptile.currentHeadSha !== headSha) {
5379
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate approval is not tied to head ${headSha}.`);
5380
+ }
5381
+ if (result.evidence.greptile.mapping !== "score-5-of-5" && result.evidence.greptile.mapping !== "explicit-approved") {
5382
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate mapping is ${result.evidence.greptile.mapping}.`);
5383
+ }
5384
+ return headSha;
5385
+ }
5017
5386
  function promptExcerpt(value, maxChars = 4000) {
5018
5387
  return value.length > maxChars ? `${value.slice(0, maxChars)}
5019
5388
 
@@ -5025,6 +5394,10 @@ function promptJsonExcerpt(value, maxChars = 6000) {
5025
5394
  function buildStrictPrGateSteeringPrompt(result) {
5026
5395
  const evidence = result.evidence;
5027
5396
  const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
5397
+ const displayedReasons = result.reasons.slice(0, 20).map((reason) => `- ${promptExcerpt(reason, 1200)}`);
5398
+ if (result.reasons.length > displayedReasons.length) {
5399
+ displayedReasons.push(`- ${result.reasons.length - displayedReasons.length} additional gate reasons omitted from prompt; see merge-gate-result.json.`);
5400
+ }
5028
5401
  const lines = [
5029
5402
  `Strict PR merge gate blocked ${evidence.prUrl}.`,
5030
5403
  `PR title: ${evidence.title || "(empty)"}`,
@@ -5033,10 +5406,13 @@ function buildStrictPrGateSteeringPrompt(result) {
5033
5406
  evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
5034
5407
  "",
5035
5408
  "Gate reasons:",
5036
- ...result.reasons.length ? result.reasons.map((reason) => `- ${reason}`) : ["- No reasons recorded"],
5409
+ ...displayedReasons.length ? displayedReasons : ["- No reasons recorded"],
5410
+ "",
5411
+ "Structured gate reason details:",
5412
+ result.reasonDetails.length ? promptJsonExcerpt(result.reasonDetails, 4000) : "[]",
5037
5413
  "",
5038
5414
  "Required evidence read status:",
5039
- evidence.readErrors.length ? JSON.stringify(evidence.readErrors, null, 2) : "All required PR evidence surfaces were read completely.",
5415
+ evidence.readErrors.length ? promptJsonExcerpt(evidence.readErrors, 2000) : "All required PR evidence surfaces were read completely.",
5040
5416
  "",
5041
5417
  "Full PR title:",
5042
5418
  evidence.title || "(empty)",
@@ -5100,6 +5476,7 @@ function persistPrReviewCycleArtifacts(input) {
5100
5476
  approved: input.result.approved,
5101
5477
  pending: input.result.pending,
5102
5478
  reasons: input.result.reasons,
5479
+ reasonDetails: input.result.reasonDetails,
5103
5480
  warnings: input.result.warnings,
5104
5481
  actionableFeedback: input.result.actionableFeedback,
5105
5482
  prUrl: input.result.evidence.prUrl,
@@ -5934,7 +6311,7 @@ async function runGreptileReviewForPr(options) {
5934
6311
  };
5935
6312
  }
5936
6313
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5937
- 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)) {
6314
+ 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)) {
5938
6315
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5939
6316
  return {
5940
6317
  verdict: "REJECT",
@@ -6016,6 +6393,7 @@ async function runGreptileReviewForPr(options) {
6016
6393
  approved: strictGate.approved,
6017
6394
  pending: strictGate.pending,
6018
6395
  reasons: strictGate.reasons,
6396
+ reasonDetails: strictGate.reasonDetails,
6019
6397
  warnings: strictGate.warnings,
6020
6398
  greptile: strictGate.evidence.greptile,
6021
6399
  readErrors: strictGate.evidence.readErrors
@@ -6038,6 +6416,7 @@ async function runGreptileReviewForPr(options) {
6038
6416
  approved: strictGate.approved,
6039
6417
  pending: strictGate.pending,
6040
6418
  reasons: strictGate.reasons,
6419
+ reasonDetails: strictGate.reasonDetails,
6041
6420
  warnings: strictGate.warnings,
6042
6421
  greptile: strictGate.evidence.greptile,
6043
6422
  readErrors: strictGate.evidence.readErrors
@@ -6164,6 +6543,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
6164
6543
  approved: strictGate.approved,
6165
6544
  pending: strictGate.pending,
6166
6545
  reasons: strictGate.reasons,
6546
+ reasonDetails: strictGate.reasonDetails,
6167
6547
  warnings: strictGate.warnings,
6168
6548
  greptile: strictGate.evidence.greptile
6169
6549
  },
@@ -6186,6 +6566,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
6186
6566
  approved: strictGate.approved,
6187
6567
  pending: strictGate.pending,
6188
6568
  reasons: strictGate.reasons,
6569
+ reasonDetails: strictGate.reasonDetails,
6189
6570
  warnings: strictGate.warnings,
6190
6571
  greptile: strictGate.evidence.greptile
6191
6572
  },
@@ -6307,8 +6688,7 @@ function shouldContinueGreptileMcpPolling(options) {
6307
6688
  if (options.githubCheckState.completed) {
6308
6689
  return false;
6309
6690
  }
6310
- const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
6311
- if (!hasRemainingBudget) {
6691
+ if (options.attempt + 1 >= options.pollAttempts) {
6312
6692
  return false;
6313
6693
  }
6314
6694
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
@@ -6318,8 +6698,11 @@ function shouldContinueGreptileMcpPolling(options) {
6318
6698
  }
6319
6699
  function shouldContinueGithubGreptileFallbackPolling(options) {
6320
6700
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
6701
+ if (options.attempt + 1 >= options.pollAttempts) {
6702
+ return false;
6703
+ }
6321
6704
  if (waitingForVisiblePendingReview) {
6322
- return options.attempt + 1 < options.pollAttempts;
6705
+ return true;
6323
6706
  }
6324
6707
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
6325
6708
  if (reviewNotVisibleYet) {
@@ -7053,7 +7436,7 @@ function gitOpenPr(options) {
7053
7436
  "",
7054
7437
  "## Review",
7055
7438
  "- Completion verification will run validation, verifier review, and PR policy checks.",
7056
- "- When repository policy allows it, Rig enables GitHub auto-merge after approval."
7439
+ "- When repository policy allows it, Rig attempts an immediate strict-gated, head-locked merge after approval."
7057
7440
  ].join(`
7058
7441
  `);
7059
7442
  const preCheck = runCapture2(withGhRepo([gh, "pr", "list", "--state", "merged", "--head", branch, "--json", "url,mergedAt", "--jq", ".[0]"], repoNameWithOwner), repoRoot);
@@ -7249,15 +7632,12 @@ function gitMergePr(options) {
7249
7632
  return { status: "already-merged", url: options.pr.url };
7250
7633
  }
7251
7634
  if (state !== "OPEN") {
7252
- throw new Error(`Cannot auto-merge PR ${options.pr.url}: state is ${state}.`);
7635
+ throw new Error(`Cannot merge PR ${options.pr.url}: state is ${state}.`);
7253
7636
  }
7254
7637
  if (isDraft) {
7255
- throw new Error(`Cannot auto-merge draft PR ${options.pr.url}.`);
7256
- }
7257
- const strictGateHeadSha = options.strictGateHeadSha?.trim();
7258
- if (!strictGateHeadSha) {
7259
- throw new Error(`Refusing to merge PR ${options.pr.url}: strict merge gate did not provide a current head SHA.`);
7638
+ throw new Error(`Cannot merge draft PR ${options.pr.url}.`);
7260
7639
  }
7640
+ const strictGateHeadSha = strictMergeHeadShaFromGate(options.strictGate, options.pr.url);
7261
7641
  const mergeArgs = withGhRepo([gh, "pr", "merge", options.pr.url], repoNameWithOwner);
7262
7642
  const method = options.method || "squash";
7263
7643
  mergeArgs.push(method === "merge" ? "--merge" : method === "rebase" ? "--rebase" : "--squash");
@@ -8248,7 +8628,6 @@ async function main() {
8248
8628
  if (prs.length === 0) {
8249
8629
  console.log("Auto-merge: skipped (no PR metadata found)");
8250
8630
  } else {
8251
- let mergePending = false;
8252
8631
  let cycle = 0;
8253
8632
  for (const pr of prs) {
8254
8633
  cycle += 1;
@@ -8277,16 +8656,13 @@ async function main() {
8277
8656
  pr,
8278
8657
  method: "squash",
8279
8658
  deleteBranch: true,
8280
- strictGateHeadSha: gate.evidence.headSha
8659
+ strictGate: gate
8281
8660
  });
8282
- if (mergeResult.status === "auto-merge-enabled") {
8283
- mergePending = true;
8284
- console.log(`WAIT: Auto-merge enabled but PR is still open (${pr.repoLabel}): ${pr.url}`);
8661
+ if (mergeResult.status === "merged" || mergeResult.status === "already-merged") {
8662
+ console.log(`OK: PR merge confirmed (${pr.repoLabel}): ${pr.url}`);
8285
8663
  }
8286
8664
  }
8287
- if (mergePending) {
8288
- failed = true;
8289
- } else if (!failed) {
8665
+ if (!failed) {
8290
8666
  sourceCloseoutAllowed = true;
8291
8667
  console.log("OK: Auto-merge complete");
8292
8668
  }