@h-rig/runtime 0.0.6-alpha.10 → 0.0.6-alpha.12

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.
@@ -3605,6 +3605,779 @@ ${JSON.stringify(result, null, 2)}
3605
3605
  // packages/runtime/src/control-plane/native/verifier.ts
3606
3606
  import { existsSync as existsSync19, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
3607
3607
  import { resolve as resolve22 } from "path";
3608
+
3609
+ // packages/runtime/src/control-plane/native/pr-review-gate.ts
3610
+ function parseJsonObject(value) {
3611
+ if (!value?.trim())
3612
+ return { value: {}, error: "empty JSON output" };
3613
+ try {
3614
+ const parsed = JSON.parse(value);
3615
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
3616
+ } catch (error) {
3617
+ return { value: {}, error: error instanceof Error ? error.message : String(error) };
3618
+ }
3619
+ }
3620
+ function flattenPaginatedArray(value) {
3621
+ if (!Array.isArray(value))
3622
+ return null;
3623
+ if (value.every((entry) => Array.isArray(entry))) {
3624
+ return value.flatMap((entry) => entry);
3625
+ }
3626
+ return value;
3627
+ }
3628
+ function parseJsonArray(value) {
3629
+ if (!value?.trim())
3630
+ return { value: [], error: "empty JSON output" };
3631
+ try {
3632
+ const parsed = JSON.parse(value);
3633
+ const flattened = flattenPaginatedArray(parsed);
3634
+ return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
3635
+ } catch (error) {
3636
+ return { value: [], error: error instanceof Error ? error.message : String(error) };
3637
+ }
3638
+ }
3639
+ function parseGithubPrUrl(prUrl) {
3640
+ const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
3641
+ if (!match)
3642
+ return null;
3643
+ const prNumber = Number.parseInt(match[3], 10);
3644
+ if (!Number.isFinite(prNumber))
3645
+ return null;
3646
+ return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
3647
+ }
3648
+ function checkName(check) {
3649
+ return String(check.name ?? check.context ?? "").trim();
3650
+ }
3651
+ function checkState(check) {
3652
+ return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
3653
+ }
3654
+ function isGreptileLabel(value) {
3655
+ return String(value ?? "").toLowerCase().includes("greptile");
3656
+ }
3657
+ function isGreptileGithubLogin(value) {
3658
+ const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
3659
+ return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
3660
+ }
3661
+ function isPassingCheck(check) {
3662
+ const state = checkState(check);
3663
+ return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
3664
+ }
3665
+ function isPendingCheck(check) {
3666
+ const state = checkState(check);
3667
+ return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
3668
+ }
3669
+ function isFailingCheck(check) {
3670
+ const state = checkState(check);
3671
+ return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
3672
+ }
3673
+ function wildcardToRegExp(pattern) {
3674
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3675
+ return new RegExp(`^${escaped}$`, "i");
3676
+ }
3677
+ function isAllowedFailure(name, allowedFailures) {
3678
+ return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
3679
+ }
3680
+ function greptileScorePatterns() {
3681
+ return [
3682
+ /\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
3683
+ /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
3684
+ /\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
3685
+ ];
3686
+ }
3687
+ function parseGreptileScores(input) {
3688
+ const text = stripHtml(input);
3689
+ const seen = new Set;
3690
+ const scores = [];
3691
+ for (const pattern of greptileScorePatterns()) {
3692
+ for (const match of text.matchAll(pattern)) {
3693
+ const value = Number.parseInt(match[1] || "", 10);
3694
+ const scale = Number.parseInt(match[2] || "", 10);
3695
+ if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
3696
+ continue;
3697
+ const raw = match[0] || `${value}/${scale}`;
3698
+ const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
3699
+ if (seen.has(key))
3700
+ continue;
3701
+ seen.add(key);
3702
+ scores.push({ value, scale, raw });
3703
+ }
3704
+ }
3705
+ return scores;
3706
+ }
3707
+ function parseGreptileScore(input) {
3708
+ return parseGreptileScores(input)[0] ?? null;
3709
+ }
3710
+ function stripHtml(input) {
3711
+ return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
3712
+
3713
+ `).trim();
3714
+ }
3715
+ function containsBlockerText(input) {
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);
3724
+ }
3725
+ function isStrictFiveOfFive(score) {
3726
+ return score.value === 5 && score.scale === 5;
3727
+ }
3728
+ function containsConflictingScoreText(input) {
3729
+ return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3730
+ }
3731
+ function firstString(record, keys) {
3732
+ for (const key of keys) {
3733
+ const value = record[key];
3734
+ if (typeof value === "string")
3735
+ return value;
3736
+ }
3737
+ return "";
3738
+ }
3739
+ function arrayField(record, key) {
3740
+ const value = record[key];
3741
+ return Array.isArray(value) ? value : [];
3742
+ }
3743
+ async function runJsonArray(command, args, cwd) {
3744
+ const result = await command(args, { cwd });
3745
+ const label = `gh ${args.join(" ")}`;
3746
+ if (result.exitCode !== 0) {
3747
+ return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
3748
+ }
3749
+ const parsed = parseJsonArray(result.stdout);
3750
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
3751
+ }
3752
+ async function runJsonObject(command, args, cwd) {
3753
+ const result = await command(args, { cwd });
3754
+ const label = `gh ${args.join(" ")}`;
3755
+ if (result.exitCode !== 0) {
3756
+ return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
3757
+ }
3758
+ const parsed = parseJsonObject(result.stdout);
3759
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
3760
+ }
3761
+ function normalizeStatusCheck(entry) {
3762
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3763
+ return null;
3764
+ const record = entry;
3765
+ const name = firstString(record, ["name", "context"]);
3766
+ if (!name.trim())
3767
+ return null;
3768
+ const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
3769
+ const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
3770
+ return {
3771
+ __typename: typeof record.__typename === "string" ? record.__typename : null,
3772
+ name,
3773
+ context: typeof record.context === "string" ? record.context : null,
3774
+ status: typeof record.status === "string" ? record.status : null,
3775
+ state: typeof record.state === "string" ? record.state : null,
3776
+ conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
3777
+ detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.details_url === "string" ? record.details_url : typeof record.html_url === "string" ? record.html_url : typeof record.link === "string" ? record.link : null,
3778
+ link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
3779
+ headSha: typeof record.headSha === "string" ? record.headSha : null,
3780
+ head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
3781
+ output: output ? {
3782
+ title: typeof output.title === "string" ? output.title : null,
3783
+ summary: typeof output.summary === "string" ? output.summary : null,
3784
+ text: typeof output.text === "string" ? output.text : null
3785
+ } : null,
3786
+ app: app ? {
3787
+ slug: typeof app.slug === "string" ? app.slug : null,
3788
+ name: typeof app.name === "string" ? app.name : null,
3789
+ owner: app.owner && typeof app.owner === "object" ? app.owner : null
3790
+ } : null
3791
+ };
3792
+ }
3793
+ function normalizeReview(entry) {
3794
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3795
+ return null;
3796
+ const record = entry;
3797
+ return {
3798
+ id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
3799
+ state: typeof record.state === "string" ? record.state : null,
3800
+ body: typeof record.body === "string" ? record.body : null,
3801
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : typeof record.commitId === "string" ? record.commitId : record.commit && typeof record.commit === "object" && typeof record.commit.oid === "string" ? record.commit.oid : null,
3802
+ html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
3803
+ author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
3804
+ };
3805
+ }
3806
+ function normalizeReviewComment(entry) {
3807
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3808
+ return null;
3809
+ const record = entry;
3810
+ const body = typeof record.body === "string" ? record.body : null;
3811
+ const path = typeof record.path === "string" ? record.path : null;
3812
+ if (!body && !path)
3813
+ return null;
3814
+ return {
3815
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
3816
+ user: record.user && typeof record.user === "object" ? record.user : null,
3817
+ author: record.author && typeof record.author === "object" ? record.author : null,
3818
+ body,
3819
+ path,
3820
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
3821
+ url: typeof record.url === "string" ? record.url : null,
3822
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
3823
+ original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
3824
+ };
3825
+ }
3826
+ function normalizeIssueComment(entry) {
3827
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3828
+ return null;
3829
+ const record = entry;
3830
+ const body = typeof record.body === "string" ? record.body : null;
3831
+ if (!body)
3832
+ return null;
3833
+ return {
3834
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
3835
+ user: record.user && typeof record.user === "object" ? record.user : null,
3836
+ author: record.author && typeof record.author === "object" ? record.author : null,
3837
+ body,
3838
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
3839
+ url: typeof record.url === "string" ? record.url : null,
3840
+ created_at: typeof record.created_at === "string" ? record.created_at : null
3841
+ };
3842
+ }
3843
+ function normalizeReviewThread(entry) {
3844
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3845
+ return null;
3846
+ const record = entry;
3847
+ return {
3848
+ id: typeof record.id === "string" ? record.id : null,
3849
+ isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
3850
+ isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
3851
+ comments: record.comments && typeof record.comments === "object" ? record.comments : null
3852
+ };
3853
+ }
3854
+ function relevantIssueComment(comment) {
3855
+ const login = comment.user?.login ?? comment.author?.login ?? "";
3856
+ 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);
3858
+ }
3859
+ function latestThreadComment(thread) {
3860
+ const nodes = thread.comments?.nodes ?? [];
3861
+ return nodes.length > 0 ? nodes[nodes.length - 1] : null;
3862
+ }
3863
+ function unresolvedThreadSummaries(threads) {
3864
+ return threads.flatMap((thread) => {
3865
+ if (thread.isResolved === true || thread.isOutdated === true)
3866
+ return [];
3867
+ const latest = latestThreadComment(thread);
3868
+ if (!latest)
3869
+ return ["Unresolved review thread"];
3870
+ const path = latest.path ? ` on ${latest.path}` : "";
3871
+ return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
3872
+ });
3873
+ }
3874
+ function collectBodies(evidence) {
3875
+ return [
3876
+ evidence.title ?? "",
3877
+ evidence.body,
3878
+ ...evidence.reviews.map((review) => review.body ?? ""),
3879
+ ...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
3880
+ ...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
3881
+ ...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
3882
+ ...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
3883
+ ].filter((body) => body.trim().length > 0);
3884
+ }
3885
+ function bodyExcerpt(body) {
3886
+ const text = stripHtml(body).replace(/\s+/g, " ").trim();
3887
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
3888
+ }
3889
+ function makeGreptileSignal(input) {
3890
+ const scores = parseGreptileScores(input.body);
3891
+ const reviewedSha = input.reviewedSha?.trim() || null;
3892
+ const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
3893
+ const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
3894
+ const explicitApproval = input.explicitApproval ?? false;
3895
+ return {
3896
+ source: input.source,
3897
+ trusted: input.trusted,
3898
+ authorLogin: input.authorLogin ?? null,
3899
+ reviewedSha,
3900
+ current,
3901
+ stale: current === false,
3902
+ score: scores[0] ?? null,
3903
+ scores,
3904
+ explicitApproval,
3905
+ blocker,
3906
+ actionable: input.actionable ?? blocker,
3907
+ bodyExcerpt: bodyExcerpt(input.body),
3908
+ body: input.body,
3909
+ allScores: scores
3910
+ };
3911
+ }
3912
+ function reviewAuthorLogin(review) {
3913
+ return review.author?.login ?? null;
3914
+ }
3915
+ function commentAuthorLogin(comment) {
3916
+ return comment.user?.login ?? comment.author?.login ?? null;
3917
+ }
3918
+ function collectGreptileSignals(evidence) {
3919
+ const signals = [];
3920
+ const contextSources = [
3921
+ { source: "pr-title", body: evidence.title ?? "" },
3922
+ { source: "pr-body", body: evidence.body }
3923
+ ];
3924
+ for (const context of contextSources) {
3925
+ if (!context.body.trim())
3926
+ 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
+ const contextBlocker = containsBlockerText(context.body);
3930
+ signals.push(makeGreptileSignal({
3931
+ source: context.source,
3932
+ body: context.body,
3933
+ currentHeadSha: evidence.currentHeadSha,
3934
+ trusted: false,
3935
+ blocker: contextBlocker,
3936
+ actionable: contextBlocker
3937
+ }));
3938
+ }
3939
+ for (const apiSignal of evidence.apiSignals ?? []) {
3940
+ const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
3941
+
3942
+ `);
3943
+ if (!body.trim())
3944
+ continue;
3945
+ signals.push(makeGreptileSignal({
3946
+ source: "api",
3947
+ body,
3948
+ currentHeadSha: evidence.currentHeadSha,
3949
+ trusted: true,
3950
+ reviewedSha: apiSignal.reviewedSha ?? null,
3951
+ explicitApproval: false
3952
+ }));
3953
+ }
3954
+ for (const review of evidence.reviews) {
3955
+ const login = reviewAuthorLogin(review);
3956
+ if (!isGreptileGithubLogin(login))
3957
+ continue;
3958
+ const state = String(review.state ?? "").toUpperCase();
3959
+ const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
3960
+
3961
+ `);
3962
+ if (!body.trim())
3963
+ continue;
3964
+ const dismissed = state === "DISMISSED";
3965
+ signals.push(makeGreptileSignal({
3966
+ source: "github-review",
3967
+ body,
3968
+ currentHeadSha: evidence.currentHeadSha,
3969
+ trusted: !dismissed,
3970
+ authorLogin: login,
3971
+ reviewedSha: review.commit_id ?? null,
3972
+ explicitApproval: undefined,
3973
+ blocker: state === "CHANGES_REQUESTED" || undefined
3974
+ }));
3975
+ }
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
+ for (const comment of evidence.relevantIssueComments) {
3991
+ const login = commentAuthorLogin(comment);
3992
+ const body = comment.body ?? "";
3993
+ if (!body.trim() || !isGreptileGithubLogin(login))
3994
+ continue;
3995
+ signals.push(makeGreptileSignal({
3996
+ source: "issue-comment",
3997
+ body,
3998
+ currentHeadSha: evidence.currentHeadSha,
3999
+ trusted: true,
4000
+ authorLogin: login
4001
+ }));
4002
+ }
4003
+ for (const thread of evidence.reviewThreads) {
4004
+ if (thread.isOutdated === true || thread.isResolved === true)
4005
+ continue;
4006
+ for (const comment of thread.comments?.nodes ?? []) {
4007
+ const login = comment.author?.login ?? null;
4008
+ const body = comment.body ?? "";
4009
+ if (!body.trim() || !isGreptileGithubLogin(login))
4010
+ continue;
4011
+ signals.push(makeGreptileSignal({
4012
+ source: "review-thread",
4013
+ body,
4014
+ currentHeadSha: evidence.currentHeadSha,
4015
+ trusted: true,
4016
+ authorLogin: login
4017
+ }));
4018
+ }
4019
+ }
4020
+ for (const check of evidence.checks) {
4021
+ if (!isGreptileLabel(checkName(check)))
4022
+ continue;
4023
+ const reviewedSha = check.headSha ?? check.head_sha ?? null;
4024
+ const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
4025
+ const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
4026
+
4027
+ `);
4028
+ signals.push(makeGreptileSignal({
4029
+ source: "github-check",
4030
+ body,
4031
+ currentHeadSha: evidence.currentHeadSha,
4032
+ trusted: false,
4033
+ reviewedSha,
4034
+ explicitApproval: false,
4035
+ blocker: isFailingCheck(check),
4036
+ actionable: isFailingCheck(check)
4037
+ }));
4038
+ }
4039
+ return signals;
4040
+ }
4041
+ function unresolvedGreptileThreadSummaries(threads) {
4042
+ return threads.flatMap((thread) => {
4043
+ if (thread.isResolved === true || thread.isOutdated === true)
4044
+ return [];
4045
+ const comments = thread.comments?.nodes ?? [];
4046
+ if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
4047
+ return [];
4048
+ const latest = latestThreadComment(thread);
4049
+ if (!latest)
4050
+ return ["Unresolved Greptile review thread"];
4051
+ const path = latest.path ? ` on ${latest.path}` : "";
4052
+ return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4053
+ });
4054
+ }
4055
+ function issueLevelBlockerSummaries(comments) {
4056
+ return comments.flatMap((comment) => {
4057
+ const body = comment.body?.trim() ?? "";
4058
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4059
+ return [];
4060
+ const login = commentAuthorLogin(comment) ?? "unknown";
4061
+ const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
4062
+ return [`${author}: ${body}`];
4063
+ });
4064
+ }
4065
+ function reviewBodyBlockerSummaries(reviews) {
4066
+ return reviews.flatMap((review) => {
4067
+ const login = reviewAuthorLogin(review) ?? "unknown";
4068
+ if (isGreptileGithubLogin(login))
4069
+ return [];
4070
+ const body = review.body?.trim() ?? "";
4071
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4072
+ return [];
4073
+ const state = review.state ? ` (${review.state})` : "";
4074
+ return [`PR review summary by ${login}${state}: ${body}`];
4075
+ });
4076
+ }
4077
+ function signalLabel(signal) {
4078
+ const source = signal.source.replace(/-/g, " ");
4079
+ const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
4080
+ const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
4081
+ return `${source}${author}${sha}`;
4082
+ }
4083
+ function deriveGreptileEvidence(input) {
4084
+ const rawBodies = collectBodies(input);
4085
+ const signals = collectGreptileSignals(input);
4086
+ const trustedSignals = signals.filter((signal) => signal.trusted);
4087
+ const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4088
+ const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4089
+ const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
4090
+ const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
4091
+ const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4092
+ const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4093
+ 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;
4095
+ const approvedByScore = !!approvingScoreEntry;
4096
+ const approvedByExplicitMapping = false;
4097
+ const approvingSignal = approvingScoreEntry?.signal ?? null;
4098
+ const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4099
+ const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4100
+ 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));
4102
+ const staleBlockingSignals = [];
4103
+ const blockers = [
4104
+ ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
4105
+ ...reviewBodyBlockerSummaries(input.reviews),
4106
+ ...issueLevelBlockerSummaries(input.relevantIssueComments),
4107
+ ...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
4108
+ ...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
4109
+ ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4110
+ ];
4111
+ const unresolvedComments = [
4112
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4113
+ ];
4114
+ const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4115
+ const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
4116
+ const completedGreptileCheck = greptileChecks.some((check) => {
4117
+ const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
4118
+ return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
4119
+ });
4120
+ const completedGreptileReview = greptileReviews.some((review) => {
4121
+ const state = String(review.state ?? "").toUpperCase();
4122
+ const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4123
+ return completedState && review.commit_id === input.currentHeadSha;
4124
+ });
4125
+ const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4126
+ const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4127
+ const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4128
+ const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4129
+ 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";
4132
+ 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
+ return {
4134
+ source,
4135
+ currentHeadSha: input.currentHeadSha,
4136
+ reviewedSha,
4137
+ fresh,
4138
+ completed,
4139
+ approved,
4140
+ score,
4141
+ explicitApproval: approvedByExplicitMapping,
4142
+ blockers,
4143
+ unresolvedComments,
4144
+ rawBodies,
4145
+ signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
4146
+ mapping
4147
+ };
4148
+ }
4149
+ function isGreptileCheckDetail(check) {
4150
+ return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
4151
+ }
4152
+ async function collectGreptileCheckDetails(input) {
4153
+ const checkRunsRead = await runJsonArray(input.command, [
4154
+ "api",
4155
+ `repos/${input.repoName}/commits/${input.headSha}/check-runs`,
4156
+ "--paginate",
4157
+ "--slurp",
4158
+ "--jq",
4159
+ "map(.check_runs // []) | add // []"
4160
+ ], input.projectRoot);
4161
+ const checkRuns = checkRunsRead.value.map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
4162
+ return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
4163
+ }
4164
+ async function collectReviewThreads(input) {
4165
+ const reviewThreads = [];
4166
+ let afterCursor = null;
4167
+ for (let page = 0;page < 100; page += 1) {
4168
+ const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
4169
+ const threadsResponse = await runJsonObject(input.command, [
4170
+ "api",
4171
+ "graphql",
4172
+ "-F",
4173
+ `owner=${input.owner}`,
4174
+ "-F",
4175
+ `name=${input.name}`,
4176
+ "-F",
4177
+ `prNumber=${input.prNumber}`,
4178
+ "-f",
4179
+ `query=query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$prNumber) { reviewThreads(first: 100, after: ${afterLiteral}) { nodes { id isResolved isOutdated comments(first: 100) { nodes { author { login } body path url createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`
4180
+ ], input.projectRoot);
4181
+ if (threadsResponse.error) {
4182
+ return { value: reviewThreads, error: threadsResponse.error };
4183
+ }
4184
+ const data = threadsResponse.value.data;
4185
+ const repository = data?.repository;
4186
+ const pullRequest = repository?.pullRequest;
4187
+ const threads = pullRequest?.reviewThreads;
4188
+ const nodes = threads?.nodes;
4189
+ if (!Array.isArray(nodes)) {
4190
+ return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
4191
+ }
4192
+ const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
4193
+ reviewThreads.push(...normalized);
4194
+ const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
4195
+ if (truncatedCommentThread) {
4196
+ return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
4197
+ }
4198
+ const pageInfo = threads?.pageInfo;
4199
+ if (!pageInfo) {
4200
+ if (nodes.length >= 100) {
4201
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
4202
+ }
4203
+ return { value: reviewThreads };
4204
+ }
4205
+ if (pageInfo.hasNextPage !== true) {
4206
+ return { value: reviewThreads };
4207
+ }
4208
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
4209
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
4210
+ }
4211
+ afterCursor = pageInfo.endCursor;
4212
+ }
4213
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
4214
+ }
4215
+ async function collectPrReviewEvidence(input) {
4216
+ const parsed = parseGithubPrUrl(input.prUrl);
4217
+ if (!parsed) {
4218
+ throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
4219
+ }
4220
+ const readErrors = [];
4221
+ const viewRead = await runJsonObject(input.command, [
4222
+ "pr",
4223
+ "view",
4224
+ input.prUrl,
4225
+ "--json",
4226
+ "title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
4227
+ ], input.projectRoot);
4228
+ if (viewRead.error)
4229
+ readErrors.push(viewRead.error);
4230
+ const view = viewRead.value;
4231
+ if (!Array.isArray(view.statusCheckRollup)) {
4232
+ readErrors.push("gh pr view did not return required statusCheckRollup array");
4233
+ }
4234
+ if (!Array.isArray(view.reviews)) {
4235
+ readErrors.push("gh pr view did not return required reviews array");
4236
+ }
4237
+ const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4238
+ const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4239
+ const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4240
+ const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4241
+ if (reviewCommentsRead.error)
4242
+ readErrors.push(reviewCommentsRead.error);
4243
+ const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
4244
+ const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4245
+ if (issueCommentsRead.error)
4246
+ readErrors.push(issueCommentsRead.error);
4247
+ const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
4248
+ const reviewThreadsRead = await collectReviewThreads({
4249
+ command: input.command,
4250
+ projectRoot: input.projectRoot,
4251
+ owner: parsed.owner,
4252
+ name: parsed.repo,
4253
+ prNumber: parsed.prNumber
4254
+ });
4255
+ if (reviewThreadsRead.error)
4256
+ readErrors.push(reviewThreadsRead.error);
4257
+ const reviewThreads = reviewThreadsRead.value;
4258
+ const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
4259
+ let greptileCheckDetails = [];
4260
+ if (headSha && greptileRollupChecks.length > 0) {
4261
+ const checkDetailsRead = await collectGreptileCheckDetails({
4262
+ command: input.command,
4263
+ projectRoot: input.projectRoot,
4264
+ repoName: parsed.repoName,
4265
+ headSha
4266
+ });
4267
+ if (checkDetailsRead.error)
4268
+ readErrors.push(checkDetailsRead.error);
4269
+ greptileCheckDetails = checkDetailsRead.value;
4270
+ if (!checkDetailsRead.error && greptileCheckDetails.length === 0) {
4271
+ readErrors.push("Greptile check details could not be found for the current PR head");
4272
+ }
4273
+ }
4274
+ const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4275
+ 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
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4277
+ const evidenceBase = {
4278
+ title: firstString(view, ["title"]),
4279
+ body: firstString(view, ["body"]),
4280
+ reviews,
4281
+ changedFileReviewComments: reviewComments,
4282
+ relevantIssueComments: issueComments,
4283
+ reviewThreads,
4284
+ checks: checksWithGreptileDetails,
4285
+ currentHeadSha: headSha,
4286
+ apiSignals: input.apiSignals ?? []
4287
+ };
4288
+ const greptile = deriveGreptileEvidence(evidenceBase);
4289
+ return {
4290
+ prUrl: input.prUrl,
4291
+ prNumber: parsed.prNumber,
4292
+ repoName: parsed.repoName,
4293
+ title: evidenceBase.title,
4294
+ body: evidenceBase.body,
4295
+ headSha,
4296
+ headRefName: firstString(view, ["headRefName"]),
4297
+ baseRefName: firstString(view, ["baseRefName"]),
4298
+ state: firstString(view, ["state"]),
4299
+ isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4300
+ mergeable: firstString(view, ["mergeable"]),
4301
+ mergeStateStatus: firstString(view, ["mergeStateStatus"]),
4302
+ reviewDecision: firstString(view, ["reviewDecision"]),
4303
+ reviews,
4304
+ reviewThreads,
4305
+ changedFileReviewComments: reviewComments,
4306
+ relevantIssueComments: issueComments,
4307
+ statusCheckRollup: checksWithGreptileDetails,
4308
+ checkFailures,
4309
+ pendingChecks,
4310
+ readErrors,
4311
+ greptile
4312
+ };
4313
+ }
4314
+ function evaluateEvidence(evidence) {
4315
+ const reasons = [];
4316
+ 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);
4328
+ }
4329
+ const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4330
+ if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4331
+ reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4332
+ }
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
+ 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"}).`);
4342
+ }
4343
+ if (!greptile.completed) {
4344
+ pending = true;
4345
+ reasons.push("Greptile check/review has not completed for the current PR head.");
4346
+ }
4347
+ if (!greptile.fresh)
4348
+ reasons.push("Greptile approval is not tied to the current PR head SHA.");
4349
+ 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.`);
4351
+ }
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.");
4354
+ }
4355
+ if (greptile.mapping === "unproven") {
4356
+ reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4357
+ }
4358
+ if (greptile.blockers.length > 0) {
4359
+ reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4360
+ }
4361
+ if (greptile.unresolvedComments.length > 0)
4362
+ reasons.push(...greptile.unresolvedComments);
4363
+ if (!greptile.approved)
4364
+ warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4365
+ return { reasons: Array.from(new Set(reasons)), warnings, pending };
4366
+ }
4367
+ function evaluateStrictPrMergeGate(evidence) {
4368
+ const evaluated = evaluateEvidence(evidence);
4369
+ const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4370
+ return {
4371
+ approved,
4372
+ pending: evaluated.pending,
4373
+ reasons: evaluated.reasons,
4374
+ warnings: evaluated.warnings,
4375
+ actionableFeedback: evaluated.reasons,
4376
+ evidence
4377
+ };
4378
+ }
4379
+
4380
+ // packages/runtime/src/control-plane/native/verifier.ts
3608
4381
  async function verifyTask(options) {
3609
4382
  const paths = resolveHarnessPaths(options.projectRoot);
3610
4383
  const taskId = options.taskId;
@@ -4400,7 +5173,8 @@ async function runGreptileReviewForPr(options) {
4400
5173
  }
4401
5174
  };
4402
5175
  }
4403
- if (/not safe to merge|unsafe to merge|do not merge|blocker/i.test(reviewBody)) {
5176
+ 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)) {
4404
5178
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
4405
5179
  return {
4406
5180
  verdict: "REJECT",
@@ -4416,44 +5190,78 @@ async function runGreptileReviewForPr(options) {
4416
5190
  }
4417
5191
  };
4418
5192
  }
4419
- if (score) {
4420
- if (score.scale === 5 && score.value < 5 && options.reviewMode === "required") {
4421
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; required mode needs 5/5 before merge.`);
4422
- return {
4423
- verdict: "REJECT",
4424
- feedback,
4425
- reasons,
4426
- warnings,
4427
- rawPayload: {
4428
- pr: options.prState,
4429
- codeReviews: reviewsPayload,
4430
- selectedReview,
4431
- reviewDetails,
4432
- comments: commentsPayload,
4433
- score
4434
- }
4435
- };
4436
- }
4437
- if (score.scale === 5 && score.value <= 2) {
4438
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; this requires rework before merge.`);
4439
- return {
4440
- verdict: "REJECT",
4441
- feedback,
4442
- reasons,
4443
- warnings,
4444
- rawPayload: {
4445
- pr: options.prState,
4446
- codeReviews: reviewsPayload,
4447
- selectedReview,
4448
- reviewDetails,
4449
- comments: commentsPayload,
4450
- score
5193
+ if (score?.scale === 5 && score.value < 5) {
5194
+ reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
5195
+ return {
5196
+ verdict: "REJECT",
5197
+ feedback,
5198
+ reasons,
5199
+ warnings,
5200
+ rawPayload: {
5201
+ pr: options.prState,
5202
+ codeReviews: reviewsPayload,
5203
+ selectedReview,
5204
+ reviewDetails,
5205
+ comments: commentsPayload,
5206
+ score
5207
+ }
5208
+ };
5209
+ }
5210
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
5211
+ let strictGate = null;
5212
+ try {
5213
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
5214
+ projectRoot: options.projectRoot,
5215
+ taskId: options.taskId,
5216
+ prUrl,
5217
+ apiSignals: [{
5218
+ id: selectedReview.id,
5219
+ body: reviewBody,
5220
+ reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
5221
+ status: selectedReview.status
5222
+ }]
5223
+ });
5224
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
5225
+ } catch (error) {
5226
+ reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
5227
+ return {
5228
+ verdict: "REJECT",
5229
+ feedback,
5230
+ reasons,
5231
+ warnings,
5232
+ rawPayload: {
5233
+ pr: options.prState,
5234
+ codeReviews: reviewsPayload,
5235
+ selectedReview,
5236
+ reviewDetails,
5237
+ comments: commentsPayload,
5238
+ score
5239
+ }
5240
+ };
5241
+ }
5242
+ if (!strictGate.approved) {
5243
+ return {
5244
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
5245
+ feedback,
5246
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
5247
+ warnings: [...warnings, ...strictGate.warnings],
5248
+ rawPayload: {
5249
+ pr: options.prState,
5250
+ codeReviews: reviewsPayload,
5251
+ selectedReview,
5252
+ reviewDetails,
5253
+ comments: commentsPayload,
5254
+ score,
5255
+ strictGate: {
5256
+ approved: strictGate.approved,
5257
+ pending: strictGate.pending,
5258
+ reasons: strictGate.reasons,
5259
+ warnings: strictGate.warnings,
5260
+ greptile: strictGate.evidence.greptile,
5261
+ readErrors: strictGate.evidence.readErrors
4451
5262
  }
4452
- };
4453
- }
4454
- if (score.scale === 5 && score.value < 5) {
4455
- warnings.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; continue only after reviewing remaining risk.`);
4456
- }
5263
+ }
5264
+ };
4457
5265
  }
4458
5266
  return {
4459
5267
  verdict: "APPROVE",
@@ -4465,7 +5273,15 @@ async function runGreptileReviewForPr(options) {
4465
5273
  codeReviews: reviewsPayload,
4466
5274
  selectedReview,
4467
5275
  reviewDetails,
4468
- comments: commentsPayload
5276
+ comments: commentsPayload,
5277
+ strictGate: {
5278
+ approved: strictGate.approved,
5279
+ pending: strictGate.pending,
5280
+ reasons: strictGate.reasons,
5281
+ warnings: strictGate.warnings,
5282
+ greptile: strictGate.evidence.greptile,
5283
+ readErrors: strictGate.evidence.readErrors
5284
+ }
4469
5285
  }
4470
5286
  };
4471
5287
  }
@@ -4489,7 +5305,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4489
5305
  let threads = [];
4490
5306
  let actionableThreads = [];
4491
5307
  let checkRollup = [];
4492
- let checkState = { pending: false, completed: false };
5308
+ let checkState2 = { pending: false, completed: false };
4493
5309
  for (let attempt = 0;; attempt += 1) {
4494
5310
  reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
4495
5311
  selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
@@ -4498,15 +5314,15 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4498
5314
  threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
4499
5315
  actionableThreads = filterActionableGithubGreptileThreads(threads);
4500
5316
  checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
4501
- checkState = classifyGithubGreptileCheckState(checkRollup);
4502
- const approvedViaReviewedAncestor2 = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
5317
+ checkState2 = classifyGithubGreptileCheckState(checkRollup);
5318
+ const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
4503
5319
  if (!shouldContinueGithubGreptileFallbackPolling({
4504
5320
  attempt,
4505
5321
  pollAttempts: options.pollAttempts,
4506
- checkState,
5322
+ checkState: checkState2,
4507
5323
  fallbackReview,
4508
5324
  selectedReview,
4509
- approvedViaReviewedAncestor: approvedViaReviewedAncestor2
5325
+ approvedViaReviewedAncestor
4510
5326
  })) {
4511
5327
  break;
4512
5328
  }
@@ -4534,7 +5350,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4534
5350
  ].filter(Boolean).join(`
4535
5351
  `);
4536
5352
  const warnings = buildGithubGreptileFallbackWarnings(options);
4537
- if (checkState.pending) {
5353
+ if (checkState2.pending) {
4538
5354
  return {
4539
5355
  verdict: "SKIP",
4540
5356
  feedback,
@@ -4545,34 +5361,20 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4545
5361
  rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4546
5362
  };
4547
5363
  }
4548
- const approvedViaCompletedCheck = isGithubGreptileCheckApproved(checkRollup);
4549
- if (!fallbackReview) {
4550
- if (approvedViaCompletedCheck) {
4551
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no GitHub Greptile review object, but the Greptile check completed successfully and no unresolved Greptile threads remain.`);
4552
- return {
4553
- verdict: "APPROVE",
4554
- feedback,
4555
- reasons: [],
4556
- warnings,
4557
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4558
- };
4559
- }
4560
- return {
4561
- verdict: "SKIP",
4562
- feedback,
4563
- reasons: [
4564
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available.`
4565
- ],
4566
- warnings,
4567
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4568
- };
4569
- }
4570
- const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
4571
- if (actionableThreads.length > 0) {
5364
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
5365
+ let strictGate;
5366
+ try {
5367
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
5368
+ projectRoot: options.projectRoot,
5369
+ taskId: options.taskId,
5370
+ prUrl
5371
+ });
5372
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
5373
+ } catch (error) {
4572
5374
  return {
4573
5375
  verdict: "REJECT",
4574
5376
  feedback,
4575
- reasons: actionableThreads.map((comment) => `[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.path}: ${summarizeComment(comment.body || "")}`),
5377
+ reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
4576
5378
  warnings,
4577
5379
  rawPayload: {
4578
5380
  pr: options.prState,
@@ -4585,44 +5387,30 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4585
5387
  }
4586
5388
  };
4587
5389
  }
4588
- if (!selectedReview && !approvedViaReviewedAncestor) {
4589
- if (approvedViaCompletedCheck) {
4590
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no fresh Greptile GitHub review on the current head, but the Greptile check completed successfully and all Greptile threads are resolved.`);
4591
- return {
4592
- verdict: "APPROVE",
4593
- feedback,
4594
- reasons: [],
4595
- warnings,
4596
- rawPayload: {
4597
- pr: options.prState,
4598
- selectedReview: fallbackReview,
4599
- reviews,
4600
- threads,
4601
- checkRollup,
4602
- ...buildGithubGreptileFallbackRawPayload(options)
4603
- }
4604
- };
4605
- }
5390
+ if (!strictGate.approved) {
4606
5391
  return {
4607
- verdict: "SKIP",
5392
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
4608
5393
  feedback,
4609
- reasons: [
4610
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available for the current head.`
4611
- ],
4612
- warnings,
5394
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
5395
+ warnings: [...warnings, ...strictGate.warnings],
4613
5396
  rawPayload: {
4614
5397
  pr: options.prState,
4615
5398
  selectedReview: fallbackReview,
4616
5399
  reviews,
4617
5400
  threads,
4618
5401
  checkRollup,
5402
+ actionableThreads,
5403
+ strictGate: {
5404
+ approved: strictGate.approved,
5405
+ pending: strictGate.pending,
5406
+ reasons: strictGate.reasons,
5407
+ warnings: strictGate.warnings,
5408
+ greptile: strictGate.evidence.greptile
5409
+ },
4619
5410
  ...buildGithubGreptileFallbackRawPayload(options)
4620
5411
  }
4621
5412
  };
4622
5413
  }
4623
- if (approvedViaReviewedAncestor) {
4624
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no fresh Greptile review on the current head, but the latest reviewed commit is an ancestor and all Greptile threads are resolved.`);
4625
- }
4626
5414
  return {
4627
5415
  verdict: "APPROVE",
4628
5416
  feedback,
@@ -4634,6 +5422,13 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4634
5422
  reviews,
4635
5423
  threads,
4636
5424
  checkRollup,
5425
+ strictGate: {
5426
+ approved: strictGate.approved,
5427
+ pending: strictGate.pending,
5428
+ reasons: strictGate.reasons,
5429
+ warnings: strictGate.warnings,
5430
+ greptile: strictGate.evidence.greptile
5431
+ },
4637
5432
  ...buildGithubGreptileFallbackRawPayload(options)
4638
5433
  }
4639
5434
  };
@@ -4746,21 +5541,25 @@ function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
4746
5541
  if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
4747
5542
  return true;
4748
5543
  }
4749
- return isGreptileReviewTerminal(existingReview.status);
5544
+ return false;
4750
5545
  }
4751
5546
  function shouldContinueGreptileMcpPolling(options) {
4752
5547
  if (options.githubCheckState.completed) {
4753
5548
  return false;
4754
5549
  }
5550
+ const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
5551
+ if (!hasRemainingBudget) {
5552
+ return false;
5553
+ }
4755
5554
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
4756
5555
  return true;
4757
5556
  }
4758
- return options.attempt + 1 < options.pollAttempts;
5557
+ return true;
4759
5558
  }
4760
5559
  function shouldContinueGithubGreptileFallbackPolling(options) {
4761
5560
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
4762
5561
  if (waitingForVisiblePendingReview) {
4763
- return true;
5562
+ return options.attempt + 1 < options.pollAttempts;
4764
5563
  }
4765
5564
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
4766
5565
  if (reviewNotVisibleYet) {
@@ -4819,6 +5618,20 @@ function runGhJson(projectRoot, args) {
4819
5618
  throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
4820
5619
  }
4821
5620
  }
5621
+ async function collectStrictPrEvidenceForVerifier(input) {
5622
+ return collectPrReviewEvidence({
5623
+ projectRoot: input.projectRoot,
5624
+ prUrl: input.prUrl,
5625
+ taskId: input.taskId,
5626
+ runId: "verifier",
5627
+ cycle: 0,
5628
+ apiSignals: input.apiSignals ?? [],
5629
+ command: async (args, options) => {
5630
+ const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
5631
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
5632
+ }
5633
+ });
5634
+ }
4822
5635
  function deriveRepoName(projectRoot, prState) {
4823
5636
  const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
4824
5637
  if (fromUrl?.[1]) {
@@ -4833,8 +5646,9 @@ function resolvePrHeadSha(projectRoot, prState) {
4833
5646
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
4834
5647
  return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
4835
5648
  }
4836
- function isGreptileGithubLogin(login) {
4837
- return (login || "").replace(/\[bot\]$/, "") === "greptile-apps";
5649
+ function isGreptileGithubLogin2(login) {
5650
+ const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
5651
+ return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
4838
5652
  }
4839
5653
  function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
4840
5654
  const matching = sortGithubGreptileReviews(reviews);
@@ -4851,7 +5665,7 @@ function pickLatestGithubGreptileReview(reviews) {
4851
5665
  return sortGithubGreptileReviews(reviews)[0] || null;
4852
5666
  }
4853
5667
  function sortGithubGreptileReviews(reviews) {
4854
- return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
5668
+ return reviews.filter((review) => isGreptileGithubLogin2(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
4855
5669
  }
4856
5670
  function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
4857
5671
  const response = runGhJson(projectRoot, [
@@ -4924,32 +5738,6 @@ function classifyGithubGreptileCheckState(checks) {
4924
5738
  }
4925
5739
  return { pending: false, completed: false };
4926
5740
  }
4927
- function isGithubGreptileCheckApproved(checks) {
4928
- const greptileChecks = checks.filter((check) => {
4929
- const label = (check.name || check.context || "").toLowerCase();
4930
- return label.includes("greptile");
4931
- });
4932
- if (greptileChecks.length === 0) {
4933
- return false;
4934
- }
4935
- for (const check of greptileChecks) {
4936
- if ((check.__typename || "") === "CheckRun") {
4937
- if ((check.status || "").toUpperCase() !== "COMPLETED") {
4938
- return false;
4939
- }
4940
- const conclusion = (check.conclusion || "").toUpperCase();
4941
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) {
4942
- return false;
4943
- }
4944
- continue;
4945
- }
4946
- const state = (check.state || "").toUpperCase();
4947
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state)) {
4948
- return false;
4949
- }
4950
- }
4951
- return true;
4952
- }
4953
5741
  function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
4954
5742
  const [owner, name] = repoName.split("/");
4955
5743
  if (!owner || !name) {
@@ -4975,7 +5763,7 @@ function filterActionableGithubGreptileThreads(threads) {
4975
5763
  return [];
4976
5764
  }
4977
5765
  const comments = thread.comments?.nodes || [];
4978
- const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
5766
+ const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin2(comment.author?.login));
4979
5767
  if (!latestGreptileComment?.path?.trim()) {
4980
5768
  return [];
4981
5769
  }
@@ -4997,11 +5785,6 @@ function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headComm
4997
5785
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
4998
5786
  return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
4999
5787
  }
5000
- function stripHtml(input) {
5001
- return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
5002
-
5003
- `).trim();
5004
- }
5005
5788
  function summarizeComment(input) {
5006
5789
  const text = stripHtml(input).replace(/\s+/g, " ").trim();
5007
5790
  return text.length > 160 ? `${text.slice(0, 157)}...` : text;
@@ -5010,31 +5793,14 @@ function asGreptileInfrastructureWarning(reason) {
5010
5793
  return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
5011
5794
  }
5012
5795
  function isAiReviewApproved(input) {
5796
+ if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
5797
+ return false;
5798
+ }
5013
5799
  if (input.reviewMode !== "required") {
5014
5800
  return true;
5015
5801
  }
5016
5802
  return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
5017
5803
  }
5018
- function parseGreptileScore(input) {
5019
- const text = stripHtml(input);
5020
- const patterns = [
5021
- /confidence score:\s*(\d+)\s*\/\s*(\d+)/i,
5022
- /\bscore:\s*(\d+)\s*\/\s*(\d+)/i,
5023
- /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|score)/i
5024
- ];
5025
- for (const pattern of patterns) {
5026
- const match = pattern.exec(text);
5027
- if (!match) {
5028
- continue;
5029
- }
5030
- const value = Number.parseInt(match[1] || "", 10);
5031
- const scale = Number.parseInt(match[2] || "", 10);
5032
- if (Number.isFinite(value) && Number.isFinite(scale) && scale > 0) {
5033
- return { value, scale };
5034
- }
5035
- }
5036
- return null;
5037
- }
5038
5804
 
5039
5805
  // packages/runtime/src/control-plane/provider/runtime-instructions.ts
5040
5806
  var CLAUDE_ROUTER_TOOL_NAMES = [
@@ -6347,8 +7113,9 @@ function defaultPrCloseoutLine(taskId, repoNameWithOwner) {
6347
7113
  const sourceIssueId = loadRuntimeContextFromEnv()?.sourceTask?.sourceIssueId;
6348
7114
  if (sourceIssueId) {
6349
7115
  const match = sourceIssueId.match(/^([^#]+)#(\d+)$/);
6350
- if (match) {
6351
- const [, sourceRepo, issueNumber] = match;
7116
+ if (match?.[1] && match[2]) {
7117
+ const sourceRepo = match[1];
7118
+ const issueNumber = match[2];
6352
7119
  return sourceRepo.toLowerCase() === repoNameWithOwner.toLowerCase() ? `Closes #${issueNumber}` : `Closes ${sourceRepo}#${issueNumber}`;
6353
7120
  }
6354
7121
  }