@h-rig/runtime 0.0.6-alpha.11 → 0.0.6-alpha.13

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.
@@ -3775,6 +3775,775 @@ function isGitOpenPrResult(value) {
3775
3775
  return typeof record.url === "string" && typeof record.branch === "string" && typeof record.base === "string" && (record.target === "project" || record.target === "monorepo") && typeof record.repoLabel === "string";
3776
3776
  }
3777
3777
 
3778
+ // packages/runtime/src/control-plane/native/pr-review-gate.ts
3779
+ function parseJsonObject(value) {
3780
+ if (!value?.trim())
3781
+ return { value: {}, error: "empty JSON output" };
3782
+ try {
3783
+ const parsed = JSON.parse(value);
3784
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
3785
+ } catch (error) {
3786
+ return { value: {}, error: error instanceof Error ? error.message : String(error) };
3787
+ }
3788
+ }
3789
+ function flattenPaginatedArray(value) {
3790
+ if (!Array.isArray(value))
3791
+ return null;
3792
+ if (value.every((entry) => Array.isArray(entry))) {
3793
+ return value.flatMap((entry) => entry);
3794
+ }
3795
+ return value;
3796
+ }
3797
+ function parseJsonArray(value) {
3798
+ if (!value?.trim())
3799
+ return { value: [], error: "empty JSON output" };
3800
+ try {
3801
+ const parsed = JSON.parse(value);
3802
+ const flattened = flattenPaginatedArray(parsed);
3803
+ return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
3804
+ } catch (error) {
3805
+ return { value: [], error: error instanceof Error ? error.message : String(error) };
3806
+ }
3807
+ }
3808
+ function parseGithubPrUrl(prUrl) {
3809
+ const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
3810
+ if (!match)
3811
+ return null;
3812
+ const prNumber = Number.parseInt(match[3], 10);
3813
+ if (!Number.isFinite(prNumber))
3814
+ return null;
3815
+ return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
3816
+ }
3817
+ function checkName(check) {
3818
+ return String(check.name ?? check.context ?? "").trim();
3819
+ }
3820
+ function checkState(check) {
3821
+ return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
3822
+ }
3823
+ function isGreptileLabel(value) {
3824
+ return String(value ?? "").toLowerCase().includes("greptile");
3825
+ }
3826
+ function isGreptileGithubLogin(value) {
3827
+ const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
3828
+ return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
3829
+ }
3830
+ function isPassingCheck(check) {
3831
+ const state = checkState(check);
3832
+ return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
3833
+ }
3834
+ function isPendingCheck(check) {
3835
+ const state = checkState(check);
3836
+ return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
3837
+ }
3838
+ function isFailingCheck(check) {
3839
+ const state = checkState(check);
3840
+ return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
3841
+ }
3842
+ function wildcardToRegExp(pattern) {
3843
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3844
+ return new RegExp(`^${escaped}$`, "i");
3845
+ }
3846
+ function isAllowedFailure(name, allowedFailures) {
3847
+ return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
3848
+ }
3849
+ function greptileScorePatterns() {
3850
+ return [
3851
+ /\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
3852
+ /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
3853
+ /\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
3854
+ ];
3855
+ }
3856
+ function parseGreptileScores(input) {
3857
+ const text = stripHtml(input);
3858
+ const seen = new Set;
3859
+ const scores = [];
3860
+ for (const pattern of greptileScorePatterns()) {
3861
+ for (const match of text.matchAll(pattern)) {
3862
+ const value = Number.parseInt(match[1] || "", 10);
3863
+ const scale = Number.parseInt(match[2] || "", 10);
3864
+ if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
3865
+ continue;
3866
+ const raw = match[0] || `${value}/${scale}`;
3867
+ const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
3868
+ if (seen.has(key))
3869
+ continue;
3870
+ seen.add(key);
3871
+ scores.push({ value, scale, raw });
3872
+ }
3873
+ }
3874
+ return scores;
3875
+ }
3876
+ function parseGreptileScore(input) {
3877
+ return parseGreptileScores(input)[0] ?? null;
3878
+ }
3879
+ function stripHtml(input) {
3880
+ return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
3881
+
3882
+ `).trim();
3883
+ }
3884
+ function containsBlockerText(input) {
3885
+ const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
3886
+ 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);
3887
+ }
3888
+ function isStrictFiveOfFive(score) {
3889
+ return score.value === 5 && score.scale === 5;
3890
+ }
3891
+ function containsConflictingScoreText(input) {
3892
+ return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3893
+ }
3894
+ function firstString(record, keys) {
3895
+ for (const key of keys) {
3896
+ const value = record[key];
3897
+ if (typeof value === "string")
3898
+ return value;
3899
+ }
3900
+ return "";
3901
+ }
3902
+ function arrayField(record, key) {
3903
+ const value = record[key];
3904
+ return Array.isArray(value) ? value : [];
3905
+ }
3906
+ async function runJsonArray(command, args, cwd) {
3907
+ const result = await command(args, { cwd });
3908
+ const label = `gh ${args.join(" ")}`;
3909
+ if (result.exitCode !== 0) {
3910
+ return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
3911
+ }
3912
+ const parsed = parseJsonArray(result.stdout);
3913
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
3914
+ }
3915
+ async function runJsonObject(command, args, cwd) {
3916
+ const result = await command(args, { cwd });
3917
+ const label = `gh ${args.join(" ")}`;
3918
+ if (result.exitCode !== 0) {
3919
+ return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
3920
+ }
3921
+ const parsed = parseJsonObject(result.stdout);
3922
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
3923
+ }
3924
+ function normalizeStatusCheck(entry) {
3925
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3926
+ return null;
3927
+ const record = entry;
3928
+ const name = firstString(record, ["name", "context"]);
3929
+ if (!name.trim())
3930
+ return null;
3931
+ const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
3932
+ const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
3933
+ return {
3934
+ __typename: typeof record.__typename === "string" ? record.__typename : null,
3935
+ name,
3936
+ context: typeof record.context === "string" ? record.context : null,
3937
+ status: typeof record.status === "string" ? record.status : null,
3938
+ state: typeof record.state === "string" ? record.state : null,
3939
+ conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
3940
+ 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,
3941
+ link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
3942
+ headSha: typeof record.headSha === "string" ? record.headSha : null,
3943
+ head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
3944
+ output: output ? {
3945
+ title: typeof output.title === "string" ? output.title : null,
3946
+ summary: typeof output.summary === "string" ? output.summary : null,
3947
+ text: typeof output.text === "string" ? output.text : null
3948
+ } : null,
3949
+ app: app ? {
3950
+ slug: typeof app.slug === "string" ? app.slug : null,
3951
+ name: typeof app.name === "string" ? app.name : null,
3952
+ owner: app.owner && typeof app.owner === "object" ? app.owner : null
3953
+ } : null
3954
+ };
3955
+ }
3956
+ function normalizeReview(entry) {
3957
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3958
+ return null;
3959
+ const record = entry;
3960
+ return {
3961
+ id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
3962
+ state: typeof record.state === "string" ? record.state : null,
3963
+ body: typeof record.body === "string" ? record.body : null,
3964
+ 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,
3965
+ html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
3966
+ author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
3967
+ };
3968
+ }
3969
+ function normalizeReviewComment(entry) {
3970
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3971
+ return null;
3972
+ const record = entry;
3973
+ const body = typeof record.body === "string" ? record.body : null;
3974
+ const path = typeof record.path === "string" ? record.path : null;
3975
+ if (!body && !path)
3976
+ return null;
3977
+ return {
3978
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
3979
+ user: record.user && typeof record.user === "object" ? record.user : null,
3980
+ author: record.author && typeof record.author === "object" ? record.author : null,
3981
+ body,
3982
+ path,
3983
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
3984
+ url: typeof record.url === "string" ? record.url : null,
3985
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
3986
+ original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
3987
+ };
3988
+ }
3989
+ function normalizeIssueComment(entry) {
3990
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3991
+ return null;
3992
+ const record = entry;
3993
+ const body = typeof record.body === "string" ? record.body : null;
3994
+ if (!body)
3995
+ return null;
3996
+ return {
3997
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
3998
+ user: record.user && typeof record.user === "object" ? record.user : null,
3999
+ author: record.author && typeof record.author === "object" ? record.author : null,
4000
+ body,
4001
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
4002
+ url: typeof record.url === "string" ? record.url : null,
4003
+ created_at: typeof record.created_at === "string" ? record.created_at : null
4004
+ };
4005
+ }
4006
+ function normalizeReviewThread(entry) {
4007
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4008
+ return null;
4009
+ const record = entry;
4010
+ return {
4011
+ id: typeof record.id === "string" ? record.id : null,
4012
+ isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
4013
+ isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
4014
+ comments: record.comments && typeof record.comments === "object" ? record.comments : null
4015
+ };
4016
+ }
4017
+ function relevantIssueComment(comment) {
4018
+ const login = comment.user?.login ?? comment.author?.login ?? "";
4019
+ const body = comment.body ?? "";
4020
+ 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);
4021
+ }
4022
+ function latestThreadComment(thread) {
4023
+ const nodes = thread.comments?.nodes ?? [];
4024
+ return nodes.length > 0 ? nodes[nodes.length - 1] : null;
4025
+ }
4026
+ function unresolvedThreadSummaries(threads) {
4027
+ return threads.flatMap((thread) => {
4028
+ if (thread.isResolved === true || thread.isOutdated === true)
4029
+ return [];
4030
+ const latest = latestThreadComment(thread);
4031
+ if (!latest)
4032
+ return ["Unresolved review thread"];
4033
+ const path = latest.path ? ` on ${latest.path}` : "";
4034
+ return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4035
+ });
4036
+ }
4037
+ function collectBodies(evidence) {
4038
+ return [
4039
+ evidence.title ?? "",
4040
+ evidence.body,
4041
+ ...evidence.reviews.map((review) => review.body ?? ""),
4042
+ ...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
4043
+ ...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
4044
+ ...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
4045
+ ...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
4046
+ ].filter((body) => body.trim().length > 0);
4047
+ }
4048
+ function bodyExcerpt(body) {
4049
+ const text = stripHtml(body).replace(/\s+/g, " ").trim();
4050
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
4051
+ }
4052
+ function makeGreptileSignal(input) {
4053
+ const scores = parseGreptileScores(input.body);
4054
+ const reviewedSha = input.reviewedSha?.trim() || null;
4055
+ const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
4056
+ const blocker = input.blocker ?? containsBlockerText(input.body);
4057
+ const explicitApproval = input.explicitApproval ?? false;
4058
+ return {
4059
+ source: input.source,
4060
+ trusted: input.trusted,
4061
+ authorLogin: input.authorLogin ?? null,
4062
+ reviewedSha,
4063
+ current,
4064
+ stale: current === false,
4065
+ score: scores[0] ?? null,
4066
+ scores,
4067
+ explicitApproval,
4068
+ blocker,
4069
+ actionable: input.actionable ?? blocker,
4070
+ bodyExcerpt: bodyExcerpt(input.body),
4071
+ body: input.body,
4072
+ allScores: scores
4073
+ };
4074
+ }
4075
+ function reviewAuthorLogin(review) {
4076
+ return review.author?.login ?? null;
4077
+ }
4078
+ function commentAuthorLogin(comment) {
4079
+ return comment.user?.login ?? comment.author?.login ?? null;
4080
+ }
4081
+ function collectGreptileSignals(evidence) {
4082
+ const signals = [];
4083
+ const contextSources = [
4084
+ { source: "pr-title", body: evidence.title ?? "" },
4085
+ { source: "pr-body", body: evidence.body }
4086
+ ];
4087
+ for (const context of contextSources) {
4088
+ if (!context.body.trim())
4089
+ continue;
4090
+ if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
4091
+ continue;
4092
+ const contextBlocker = containsBlockerText(context.body);
4093
+ signals.push(makeGreptileSignal({
4094
+ source: context.source,
4095
+ body: context.body,
4096
+ currentHeadSha: evidence.currentHeadSha,
4097
+ trusted: false,
4098
+ blocker: contextBlocker,
4099
+ actionable: contextBlocker
4100
+ }));
4101
+ }
4102
+ for (const apiSignal of evidence.apiSignals ?? []) {
4103
+ const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
4104
+
4105
+ `);
4106
+ if (!body.trim())
4107
+ continue;
4108
+ signals.push(makeGreptileSignal({
4109
+ source: "api",
4110
+ body,
4111
+ currentHeadSha: evidence.currentHeadSha,
4112
+ trusted: true,
4113
+ reviewedSha: apiSignal.reviewedSha ?? null,
4114
+ explicitApproval: false
4115
+ }));
4116
+ }
4117
+ for (const review of evidence.reviews) {
4118
+ const login = reviewAuthorLogin(review);
4119
+ if (!isGreptileGithubLogin(login))
4120
+ continue;
4121
+ const state = String(review.state ?? "").toUpperCase();
4122
+ const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
4123
+
4124
+ `);
4125
+ if (!body.trim())
4126
+ continue;
4127
+ const dismissed = state === "DISMISSED";
4128
+ signals.push(makeGreptileSignal({
4129
+ source: "github-review",
4130
+ body,
4131
+ currentHeadSha: evidence.currentHeadSha,
4132
+ trusted: !dismissed,
4133
+ authorLogin: login,
4134
+ reviewedSha: review.commit_id ?? null,
4135
+ explicitApproval: undefined,
4136
+ blocker: state === "CHANGES_REQUESTED" || undefined
4137
+ }));
4138
+ }
4139
+ for (const comment of evidence.changedFileReviewComments) {
4140
+ const login = commentAuthorLogin(comment);
4141
+ const body = comment.body ?? "";
4142
+ if (!body.trim() || !isGreptileGithubLogin(login))
4143
+ continue;
4144
+ signals.push(makeGreptileSignal({
4145
+ source: "changed-file-comment",
4146
+ body,
4147
+ currentHeadSha: evidence.currentHeadSha,
4148
+ trusted: true,
4149
+ authorLogin: login,
4150
+ reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
4151
+ }));
4152
+ }
4153
+ for (const comment of evidence.relevantIssueComments) {
4154
+ const login = commentAuthorLogin(comment);
4155
+ const body = comment.body ?? "";
4156
+ if (!body.trim() || !isGreptileGithubLogin(login))
4157
+ continue;
4158
+ signals.push(makeGreptileSignal({
4159
+ source: "issue-comment",
4160
+ body,
4161
+ currentHeadSha: evidence.currentHeadSha,
4162
+ trusted: true,
4163
+ authorLogin: login
4164
+ }));
4165
+ }
4166
+ for (const thread of evidence.reviewThreads) {
4167
+ if (thread.isOutdated === true || thread.isResolved === true)
4168
+ continue;
4169
+ for (const comment of thread.comments?.nodes ?? []) {
4170
+ const login = comment.author?.login ?? null;
4171
+ const body = comment.body ?? "";
4172
+ if (!body.trim() || !isGreptileGithubLogin(login))
4173
+ continue;
4174
+ signals.push(makeGreptileSignal({
4175
+ source: "review-thread",
4176
+ body,
4177
+ currentHeadSha: evidence.currentHeadSha,
4178
+ trusted: true,
4179
+ authorLogin: login
4180
+ }));
4181
+ }
4182
+ }
4183
+ for (const check of evidence.checks) {
4184
+ if (!isGreptileLabel(checkName(check)))
4185
+ continue;
4186
+ const reviewedSha = check.headSha ?? check.head_sha ?? null;
4187
+ const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
4188
+ const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
4189
+
4190
+ `);
4191
+ signals.push(makeGreptileSignal({
4192
+ source: "github-check",
4193
+ body,
4194
+ currentHeadSha: evidence.currentHeadSha,
4195
+ trusted: false,
4196
+ reviewedSha,
4197
+ explicitApproval: false,
4198
+ blocker: isFailingCheck(check),
4199
+ actionable: isFailingCheck(check)
4200
+ }));
4201
+ }
4202
+ return signals;
4203
+ }
4204
+ function unresolvedGreptileThreadSummaries(threads) {
4205
+ return threads.flatMap((thread) => {
4206
+ if (thread.isResolved === true || thread.isOutdated === true)
4207
+ return [];
4208
+ const comments = thread.comments?.nodes ?? [];
4209
+ if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
4210
+ return [];
4211
+ const latest = latestThreadComment(thread);
4212
+ if (!latest)
4213
+ return ["Unresolved Greptile review thread"];
4214
+ const path = latest.path ? ` on ${latest.path}` : "";
4215
+ return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4216
+ });
4217
+ }
4218
+ function actionableChangedFileCommentSummaries(_comments) {
4219
+ return [];
4220
+ }
4221
+ function issueLevelBlockerSummaries(comments) {
4222
+ return comments.flatMap((comment) => {
4223
+ const body = comment.body?.trim() ?? "";
4224
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4225
+ return [];
4226
+ const login = commentAuthorLogin(comment) ?? "unknown";
4227
+ const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
4228
+ return [`${author}: ${body}`];
4229
+ });
4230
+ }
4231
+ function reviewBodyBlockerSummaries(reviews) {
4232
+ return reviews.flatMap((review) => {
4233
+ const login = reviewAuthorLogin(review) ?? "unknown";
4234
+ if (isGreptileGithubLogin(login))
4235
+ return [];
4236
+ const body = review.body?.trim() ?? "";
4237
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4238
+ return [];
4239
+ const state = review.state ? ` (${review.state})` : "";
4240
+ return [`PR review summary by ${login}${state}: ${body}`];
4241
+ });
4242
+ }
4243
+ function signalLabel(signal) {
4244
+ const source = signal.source.replace(/-/g, " ");
4245
+ const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
4246
+ const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
4247
+ return `${source}${author}${sha}`;
4248
+ }
4249
+ function deriveGreptileEvidence(input) {
4250
+ const rawBodies = collectBodies(input);
4251
+ const signals = collectGreptileSignals(input);
4252
+ const trustedSignals = signals.filter((signal) => signal.trusted);
4253
+ const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4254
+ const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4255
+ const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
4256
+ const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
4257
+ const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4258
+ const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4259
+ const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4260
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4261
+ const approvedByScore = !!approvingScoreEntry;
4262
+ const approvedByExplicitMapping = false;
4263
+ const approvingSignal = approvingScoreEntry?.signal ?? null;
4264
+ const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4265
+ const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4266
+ const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4267
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4268
+ const staleBlockingSignals = [];
4269
+ const blockers = [
4270
+ ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
4271
+ ...reviewBodyBlockerSummaries(input.reviews),
4272
+ ...issueLevelBlockerSummaries(input.relevantIssueComments),
4273
+ ...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
4274
+ ...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
4275
+ ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4276
+ ];
4277
+ const unresolvedComments = [
4278
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
4279
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
4280
+ ];
4281
+ const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4282
+ const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
4283
+ const completedGreptileCheck = greptileChecks.some((check) => {
4284
+ const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
4285
+ return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
4286
+ });
4287
+ const completedGreptileReview = greptileReviews.some((review) => {
4288
+ const state = String(review.state ?? "").toUpperCase();
4289
+ const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4290
+ return completedState && review.commit_id === input.currentHeadSha;
4291
+ });
4292
+ const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4293
+ const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4294
+ const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4295
+ const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4296
+ const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4297
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4298
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4299
+ 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";
4300
+ return {
4301
+ source,
4302
+ currentHeadSha: input.currentHeadSha,
4303
+ reviewedSha,
4304
+ fresh,
4305
+ completed,
4306
+ approved,
4307
+ score,
4308
+ explicitApproval: approvedByExplicitMapping,
4309
+ blockers,
4310
+ unresolvedComments,
4311
+ rawBodies,
4312
+ signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
4313
+ mapping
4314
+ };
4315
+ }
4316
+ function isGreptileCheckDetail(check) {
4317
+ return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
4318
+ }
4319
+ async function collectGreptileCheckDetails(input) {
4320
+ const checkRunsRead = await runJsonArray(input.command, [
4321
+ "api",
4322
+ `repos/${input.repoName}/commits/${input.headSha}/check-runs`,
4323
+ "--paginate",
4324
+ "--slurp",
4325
+ "--jq",
4326
+ "map(.check_runs // []) | add // []"
4327
+ ], input.projectRoot);
4328
+ const checkRuns = checkRunsRead.value.map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
4329
+ return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
4330
+ }
4331
+ async function collectReviewThreads(input) {
4332
+ const reviewThreads = [];
4333
+ let afterCursor = null;
4334
+ for (let page = 0;page < 100; page += 1) {
4335
+ const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
4336
+ const threadsResponse = await runJsonObject(input.command, [
4337
+ "api",
4338
+ "graphql",
4339
+ "-F",
4340
+ `owner=${input.owner}`,
4341
+ "-F",
4342
+ `name=${input.name}`,
4343
+ "-F",
4344
+ `prNumber=${input.prNumber}`,
4345
+ "-f",
4346
+ `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 } } } } }`
4347
+ ], input.projectRoot);
4348
+ if (threadsResponse.error) {
4349
+ return { value: reviewThreads, error: threadsResponse.error };
4350
+ }
4351
+ const data = threadsResponse.value.data;
4352
+ const repository = data?.repository;
4353
+ const pullRequest = repository?.pullRequest;
4354
+ const threads = pullRequest?.reviewThreads;
4355
+ const nodes = threads?.nodes;
4356
+ if (!Array.isArray(nodes)) {
4357
+ return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
4358
+ }
4359
+ const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
4360
+ reviewThreads.push(...normalized);
4361
+ const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
4362
+ if (truncatedCommentThread) {
4363
+ return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
4364
+ }
4365
+ const pageInfo = threads?.pageInfo;
4366
+ if (!pageInfo) {
4367
+ if (nodes.length >= 100) {
4368
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
4369
+ }
4370
+ return { value: reviewThreads };
4371
+ }
4372
+ if (pageInfo.hasNextPage !== true) {
4373
+ return { value: reviewThreads };
4374
+ }
4375
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
4376
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
4377
+ }
4378
+ afterCursor = pageInfo.endCursor;
4379
+ }
4380
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
4381
+ }
4382
+ async function collectPrReviewEvidence(input) {
4383
+ const parsed = parseGithubPrUrl(input.prUrl);
4384
+ if (!parsed) {
4385
+ throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
4386
+ }
4387
+ const readErrors = [];
4388
+ const viewRead = await runJsonObject(input.command, [
4389
+ "pr",
4390
+ "view",
4391
+ input.prUrl,
4392
+ "--json",
4393
+ "title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
4394
+ ], input.projectRoot);
4395
+ if (viewRead.error)
4396
+ readErrors.push(viewRead.error);
4397
+ const view = viewRead.value;
4398
+ if (!Array.isArray(view.statusCheckRollup)) {
4399
+ readErrors.push("gh pr view did not return required statusCheckRollup array");
4400
+ }
4401
+ if (!Array.isArray(view.reviews)) {
4402
+ readErrors.push("gh pr view did not return required reviews array");
4403
+ }
4404
+ const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4405
+ const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4406
+ const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4407
+ const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4408
+ if (reviewCommentsRead.error)
4409
+ readErrors.push(reviewCommentsRead.error);
4410
+ const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
4411
+ const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4412
+ if (issueCommentsRead.error)
4413
+ readErrors.push(issueCommentsRead.error);
4414
+ const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
4415
+ const reviewThreadsRead = await collectReviewThreads({
4416
+ command: input.command,
4417
+ projectRoot: input.projectRoot,
4418
+ owner: parsed.owner,
4419
+ name: parsed.repo,
4420
+ prNumber: parsed.prNumber
4421
+ });
4422
+ if (reviewThreadsRead.error)
4423
+ readErrors.push(reviewThreadsRead.error);
4424
+ const reviewThreads = reviewThreadsRead.value;
4425
+ const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
4426
+ let greptileCheckDetails = [];
4427
+ if (headSha && greptileRollupChecks.length > 0) {
4428
+ const checkDetailsRead = await collectGreptileCheckDetails({
4429
+ command: input.command,
4430
+ projectRoot: input.projectRoot,
4431
+ repoName: parsed.repoName,
4432
+ headSha
4433
+ });
4434
+ if (checkDetailsRead.error)
4435
+ readErrors.push(checkDetailsRead.error);
4436
+ greptileCheckDetails = checkDetailsRead.value;
4437
+ if (!checkDetailsRead.error && greptileCheckDetails.length === 0) {
4438
+ readErrors.push("Greptile check details could not be found for the current PR head");
4439
+ }
4440
+ }
4441
+ const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4442
+ 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})` : ""}`);
4443
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
4444
+ const evidenceBase = {
4445
+ title: firstString(view, ["title"]),
4446
+ body: firstString(view, ["body"]),
4447
+ reviews,
4448
+ changedFileReviewComments: reviewComments,
4449
+ relevantIssueComments: issueComments,
4450
+ reviewThreads,
4451
+ checks: checksWithGreptileDetails,
4452
+ currentHeadSha: headSha,
4453
+ apiSignals: input.apiSignals ?? []
4454
+ };
4455
+ const greptile = deriveGreptileEvidence(evidenceBase);
4456
+ return {
4457
+ prUrl: input.prUrl,
4458
+ prNumber: parsed.prNumber,
4459
+ repoName: parsed.repoName,
4460
+ title: evidenceBase.title,
4461
+ body: evidenceBase.body,
4462
+ headSha,
4463
+ headRefName: firstString(view, ["headRefName"]),
4464
+ baseRefName: firstString(view, ["baseRefName"]),
4465
+ state: firstString(view, ["state"]),
4466
+ isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4467
+ mergeable: firstString(view, ["mergeable"]),
4468
+ mergeStateStatus: firstString(view, ["mergeStateStatus"]),
4469
+ reviewDecision: firstString(view, ["reviewDecision"]),
4470
+ reviews,
4471
+ reviewThreads,
4472
+ changedFileReviewComments: reviewComments,
4473
+ relevantIssueComments: issueComments,
4474
+ statusCheckRollup: checksWithGreptileDetails,
4475
+ checkFailures,
4476
+ pendingChecks,
4477
+ readErrors,
4478
+ greptile
4479
+ };
4480
+ }
4481
+ function evaluateEvidence(evidence) {
4482
+ const reasons = [];
4483
+ const warnings = [];
4484
+ let pending = false;
4485
+ if (evidence.readErrors.length > 0) {
4486
+ reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4487
+ }
4488
+ if (!evidence.headSha)
4489
+ reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4490
+ if (evidence.checkFailures.length > 0)
4491
+ reasons.push(...evidence.checkFailures);
4492
+ if (evidence.pendingChecks.length > 0) {
4493
+ pending = true;
4494
+ reasons.push(...evidence.pendingChecks);
4495
+ }
4496
+ const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4497
+ if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4498
+ reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4499
+ }
4500
+ const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4501
+ if (unresolvedThreads.length > 0)
4502
+ reasons.push(...unresolvedThreads);
4503
+ const greptile = evidence.greptile;
4504
+ if (greptile.mapping === "missing")
4505
+ reasons.push("Missing Greptile check/review evidence for this PR.");
4506
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4507
+ if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4508
+ reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
4509
+ }
4510
+ if (!greptile.completed) {
4511
+ pending = true;
4512
+ reasons.push("Greptile check/review has not completed for the current PR head.");
4513
+ }
4514
+ if (!greptile.fresh)
4515
+ reasons.push("Greptile approval is not tied to the current PR head SHA.");
4516
+ if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4517
+ reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
4518
+ }
4519
+ if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4520
+ reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
4521
+ }
4522
+ if (greptile.mapping === "unproven") {
4523
+ reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4524
+ }
4525
+ if (greptile.blockers.length > 0) {
4526
+ reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4527
+ }
4528
+ if (greptile.unresolvedComments.length > 0)
4529
+ reasons.push(...greptile.unresolvedComments);
4530
+ if (!greptile.approved)
4531
+ warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4532
+ return { reasons: Array.from(new Set(reasons)), warnings, pending };
4533
+ }
4534
+ function evaluateStrictPrMergeGate(evidence) {
4535
+ const evaluated = evaluateEvidence(evidence);
4536
+ const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4537
+ return {
4538
+ approved,
4539
+ pending: evaluated.pending,
4540
+ reasons: evaluated.reasons,
4541
+ warnings: evaluated.warnings,
4542
+ actionableFeedback: evaluated.reasons,
4543
+ evidence
4544
+ };
4545
+ }
4546
+
3778
4547
  // packages/runtime/src/control-plane/native/verifier.ts
3779
4548
  async function verifyTask(options) {
3780
4549
  const paths = resolveHarnessPaths(options.projectRoot);
@@ -4571,7 +5340,8 @@ async function runGreptileReviewForPr(options) {
4571
5340
  }
4572
5341
  };
4573
5342
  }
4574
- if (/not safe to merge|unsafe to merge|do not merge|blocker/i.test(reviewBody)) {
5343
+ const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5344
+ 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)) {
4575
5345
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
4576
5346
  return {
4577
5347
  verdict: "REJECT",
@@ -4587,44 +5357,78 @@ async function runGreptileReviewForPr(options) {
4587
5357
  }
4588
5358
  };
4589
5359
  }
4590
- if (score) {
4591
- if (score.scale === 5 && score.value < 5 && options.reviewMode === "required") {
4592
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; required mode needs 5/5 before merge.`);
4593
- return {
4594
- verdict: "REJECT",
4595
- feedback,
4596
- reasons,
4597
- warnings,
4598
- rawPayload: {
4599
- pr: options.prState,
4600
- codeReviews: reviewsPayload,
4601
- selectedReview,
4602
- reviewDetails,
4603
- comments: commentsPayload,
4604
- score
4605
- }
4606
- };
4607
- }
4608
- if (score.scale === 5 && score.value <= 2) {
4609
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; this requires rework before merge.`);
4610
- return {
4611
- verdict: "REJECT",
4612
- feedback,
4613
- reasons,
4614
- warnings,
4615
- rawPayload: {
4616
- pr: options.prState,
4617
- codeReviews: reviewsPayload,
4618
- selectedReview,
4619
- reviewDetails,
4620
- comments: commentsPayload,
4621
- score
5360
+ if (score?.scale === 5 && score.value < 5) {
5361
+ reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
5362
+ return {
5363
+ verdict: "REJECT",
5364
+ feedback,
5365
+ reasons,
5366
+ warnings,
5367
+ rawPayload: {
5368
+ pr: options.prState,
5369
+ codeReviews: reviewsPayload,
5370
+ selectedReview,
5371
+ reviewDetails,
5372
+ comments: commentsPayload,
5373
+ score
5374
+ }
5375
+ };
5376
+ }
5377
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
5378
+ let strictGate = null;
5379
+ try {
5380
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
5381
+ projectRoot: options.projectRoot,
5382
+ taskId: options.taskId,
5383
+ prUrl,
5384
+ apiSignals: [{
5385
+ id: selectedReview.id,
5386
+ body: reviewBody,
5387
+ reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
5388
+ status: selectedReview.status
5389
+ }]
5390
+ });
5391
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
5392
+ } catch (error) {
5393
+ reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
5394
+ return {
5395
+ verdict: "REJECT",
5396
+ feedback,
5397
+ reasons,
5398
+ warnings,
5399
+ rawPayload: {
5400
+ pr: options.prState,
5401
+ codeReviews: reviewsPayload,
5402
+ selectedReview,
5403
+ reviewDetails,
5404
+ comments: commentsPayload,
5405
+ score
5406
+ }
5407
+ };
5408
+ }
5409
+ if (!strictGate.approved) {
5410
+ return {
5411
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
5412
+ feedback,
5413
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
5414
+ warnings: [...warnings, ...strictGate.warnings],
5415
+ rawPayload: {
5416
+ pr: options.prState,
5417
+ codeReviews: reviewsPayload,
5418
+ selectedReview,
5419
+ reviewDetails,
5420
+ comments: commentsPayload,
5421
+ score,
5422
+ strictGate: {
5423
+ approved: strictGate.approved,
5424
+ pending: strictGate.pending,
5425
+ reasons: strictGate.reasons,
5426
+ warnings: strictGate.warnings,
5427
+ greptile: strictGate.evidence.greptile,
5428
+ readErrors: strictGate.evidence.readErrors
4622
5429
  }
4623
- };
4624
- }
4625
- if (score.scale === 5 && score.value < 5) {
4626
- warnings.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; continue only after reviewing remaining risk.`);
4627
- }
5430
+ }
5431
+ };
4628
5432
  }
4629
5433
  return {
4630
5434
  verdict: "APPROVE",
@@ -4636,7 +5440,15 @@ async function runGreptileReviewForPr(options) {
4636
5440
  codeReviews: reviewsPayload,
4637
5441
  selectedReview,
4638
5442
  reviewDetails,
4639
- comments: commentsPayload
5443
+ comments: commentsPayload,
5444
+ strictGate: {
5445
+ approved: strictGate.approved,
5446
+ pending: strictGate.pending,
5447
+ reasons: strictGate.reasons,
5448
+ warnings: strictGate.warnings,
5449
+ greptile: strictGate.evidence.greptile,
5450
+ readErrors: strictGate.evidence.readErrors
5451
+ }
4640
5452
  }
4641
5453
  };
4642
5454
  }
@@ -4660,7 +5472,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4660
5472
  let threads = [];
4661
5473
  let actionableThreads = [];
4662
5474
  let checkRollup = [];
4663
- let checkState = { pending: false, completed: false };
5475
+ let checkState2 = { pending: false, completed: false };
4664
5476
  for (let attempt = 0;; attempt += 1) {
4665
5477
  reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
4666
5478
  selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
@@ -4669,15 +5481,15 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4669
5481
  threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
4670
5482
  actionableThreads = filterActionableGithubGreptileThreads(threads);
4671
5483
  checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
4672
- checkState = classifyGithubGreptileCheckState(checkRollup);
4673
- const approvedViaReviewedAncestor2 = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
5484
+ checkState2 = classifyGithubGreptileCheckState(checkRollup);
5485
+ const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
4674
5486
  if (!shouldContinueGithubGreptileFallbackPolling({
4675
5487
  attempt,
4676
5488
  pollAttempts: options.pollAttempts,
4677
- checkState,
5489
+ checkState: checkState2,
4678
5490
  fallbackReview,
4679
5491
  selectedReview,
4680
- approvedViaReviewedAncestor: approvedViaReviewedAncestor2
5492
+ approvedViaReviewedAncestor
4681
5493
  })) {
4682
5494
  break;
4683
5495
  }
@@ -4705,7 +5517,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4705
5517
  ].filter(Boolean).join(`
4706
5518
  `);
4707
5519
  const warnings = buildGithubGreptileFallbackWarnings(options);
4708
- if (checkState.pending) {
5520
+ if (checkState2.pending) {
4709
5521
  return {
4710
5522
  verdict: "SKIP",
4711
5523
  feedback,
@@ -4716,34 +5528,20 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4716
5528
  rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4717
5529
  };
4718
5530
  }
4719
- const approvedViaCompletedCheck = isGithubGreptileCheckApproved(checkRollup);
4720
- if (!fallbackReview) {
4721
- if (approvedViaCompletedCheck) {
4722
- 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.`);
4723
- return {
4724
- verdict: "APPROVE",
4725
- feedback,
4726
- reasons: [],
4727
- warnings,
4728
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4729
- };
4730
- }
4731
- return {
4732
- verdict: "SKIP",
4733
- feedback,
4734
- reasons: [
4735
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available.`
4736
- ],
4737
- warnings,
4738
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
4739
- };
4740
- }
4741
- const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
4742
- if (actionableThreads.length > 0) {
5531
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
5532
+ let strictGate;
5533
+ try {
5534
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
5535
+ projectRoot: options.projectRoot,
5536
+ taskId: options.taskId,
5537
+ prUrl
5538
+ });
5539
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
5540
+ } catch (error) {
4743
5541
  return {
4744
5542
  verdict: "REJECT",
4745
5543
  feedback,
4746
- reasons: actionableThreads.map((comment) => `[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.path}: ${summarizeComment(comment.body || "")}`),
5544
+ reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
4747
5545
  warnings,
4748
5546
  rawPayload: {
4749
5547
  pr: options.prState,
@@ -4756,44 +5554,30 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4756
5554
  }
4757
5555
  };
4758
5556
  }
4759
- if (!selectedReview && !approvedViaReviewedAncestor) {
4760
- if (approvedViaCompletedCheck) {
4761
- 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.`);
4762
- return {
4763
- verdict: "APPROVE",
4764
- feedback,
4765
- reasons: [],
4766
- warnings,
4767
- rawPayload: {
4768
- pr: options.prState,
4769
- selectedReview: fallbackReview,
4770
- reviews,
4771
- threads,
4772
- checkRollup,
4773
- ...buildGithubGreptileFallbackRawPayload(options)
4774
- }
4775
- };
4776
- }
5557
+ if (!strictGate.approved) {
4777
5558
  return {
4778
- verdict: "SKIP",
5559
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
4779
5560
  feedback,
4780
- reasons: [
4781
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available for the current head.`
4782
- ],
4783
- warnings,
5561
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
5562
+ warnings: [...warnings, ...strictGate.warnings],
4784
5563
  rawPayload: {
4785
5564
  pr: options.prState,
4786
5565
  selectedReview: fallbackReview,
4787
5566
  reviews,
4788
5567
  threads,
4789
5568
  checkRollup,
5569
+ actionableThreads,
5570
+ strictGate: {
5571
+ approved: strictGate.approved,
5572
+ pending: strictGate.pending,
5573
+ reasons: strictGate.reasons,
5574
+ warnings: strictGate.warnings,
5575
+ greptile: strictGate.evidence.greptile
5576
+ },
4790
5577
  ...buildGithubGreptileFallbackRawPayload(options)
4791
5578
  }
4792
5579
  };
4793
5580
  }
4794
- if (approvedViaReviewedAncestor) {
4795
- 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.`);
4796
- }
4797
5581
  return {
4798
5582
  verdict: "APPROVE",
4799
5583
  feedback,
@@ -4805,6 +5589,13 @@ async function runGithubGreptileFallbackReviewForPr(options) {
4805
5589
  reviews,
4806
5590
  threads,
4807
5591
  checkRollup,
5592
+ strictGate: {
5593
+ approved: strictGate.approved,
5594
+ pending: strictGate.pending,
5595
+ reasons: strictGate.reasons,
5596
+ warnings: strictGate.warnings,
5597
+ greptile: strictGate.evidence.greptile
5598
+ },
4808
5599
  ...buildGithubGreptileFallbackRawPayload(options)
4809
5600
  }
4810
5601
  };
@@ -4990,6 +5781,20 @@ function runGhJson(projectRoot, args) {
4990
5781
  throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
4991
5782
  }
4992
5783
  }
5784
+ async function collectStrictPrEvidenceForVerifier(input) {
5785
+ return collectPrReviewEvidence({
5786
+ projectRoot: input.projectRoot,
5787
+ prUrl: input.prUrl,
5788
+ taskId: input.taskId,
5789
+ runId: "verifier",
5790
+ cycle: 0,
5791
+ apiSignals: input.apiSignals ?? [],
5792
+ command: async (args, options) => {
5793
+ const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
5794
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
5795
+ }
5796
+ });
5797
+ }
4993
5798
  function deriveRepoName(projectRoot, prState) {
4994
5799
  const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
4995
5800
  if (fromUrl?.[1]) {
@@ -5004,8 +5809,9 @@ function resolvePrHeadSha(projectRoot, prState) {
5004
5809
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
5005
5810
  return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
5006
5811
  }
5007
- function isGreptileGithubLogin(login) {
5008
- return (login || "").replace(/\[bot\]$/, "") === "greptile-apps";
5812
+ function isGreptileGithubLogin2(login) {
5813
+ const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
5814
+ return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
5009
5815
  }
5010
5816
  function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
5011
5817
  const matching = sortGithubGreptileReviews(reviews);
@@ -5022,7 +5828,7 @@ function pickLatestGithubGreptileReview(reviews) {
5022
5828
  return sortGithubGreptileReviews(reviews)[0] || null;
5023
5829
  }
5024
5830
  function sortGithubGreptileReviews(reviews) {
5025
- return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
5831
+ return reviews.filter((review) => isGreptileGithubLogin2(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
5026
5832
  }
5027
5833
  function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
5028
5834
  const response = runGhJson(projectRoot, [
@@ -5095,32 +5901,6 @@ function classifyGithubGreptileCheckState(checks) {
5095
5901
  }
5096
5902
  return { pending: false, completed: false };
5097
5903
  }
5098
- function isGithubGreptileCheckApproved(checks) {
5099
- const greptileChecks = checks.filter((check) => {
5100
- const label = (check.name || check.context || "").toLowerCase();
5101
- return label.includes("greptile");
5102
- });
5103
- if (greptileChecks.length === 0) {
5104
- return false;
5105
- }
5106
- for (const check of greptileChecks) {
5107
- if ((check.__typename || "") === "CheckRun") {
5108
- if ((check.status || "").toUpperCase() !== "COMPLETED") {
5109
- return false;
5110
- }
5111
- const conclusion = (check.conclusion || "").toUpperCase();
5112
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) {
5113
- return false;
5114
- }
5115
- continue;
5116
- }
5117
- const state = (check.state || "").toUpperCase();
5118
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state)) {
5119
- return false;
5120
- }
5121
- }
5122
- return true;
5123
- }
5124
5904
  function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
5125
5905
  const [owner, name] = repoName.split("/");
5126
5906
  if (!owner || !name) {
@@ -5146,7 +5926,7 @@ function filterActionableGithubGreptileThreads(threads) {
5146
5926
  return [];
5147
5927
  }
5148
5928
  const comments = thread.comments?.nodes || [];
5149
- const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
5929
+ const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin2(comment.author?.login));
5150
5930
  if (!latestGreptileComment?.path?.trim()) {
5151
5931
  return [];
5152
5932
  }
@@ -5168,11 +5948,6 @@ function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headComm
5168
5948
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
5169
5949
  return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
5170
5950
  }
5171
- function stripHtml(input) {
5172
- return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
5173
-
5174
- `).trim();
5175
- }
5176
5951
  function summarizeComment(input) {
5177
5952
  const text = stripHtml(input).replace(/\s+/g, " ").trim();
5178
5953
  return text.length > 160 ? `${text.slice(0, 157)}...` : text;
@@ -5181,31 +5956,14 @@ function asGreptileInfrastructureWarning(reason) {
5181
5956
  return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
5182
5957
  }
5183
5958
  function isAiReviewApproved(input) {
5959
+ if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
5960
+ return false;
5961
+ }
5184
5962
  if (input.reviewMode !== "required") {
5185
5963
  return true;
5186
5964
  }
5187
5965
  return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
5188
5966
  }
5189
- function parseGreptileScore(input) {
5190
- const text = stripHtml(input);
5191
- const patterns = [
5192
- /confidence score:\s*(\d+)\s*\/\s*(\d+)/i,
5193
- /\bscore:\s*(\d+)\s*\/\s*(\d+)/i,
5194
- /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|score)/i
5195
- ];
5196
- for (const pattern of patterns) {
5197
- const match = pattern.exec(text);
5198
- if (!match) {
5199
- continue;
5200
- }
5201
- const value = Number.parseInt(match[1] || "", 10);
5202
- const scale = Number.parseInt(match[2] || "", 10);
5203
- if (Number.isFinite(value) && Number.isFinite(scale) && scale > 0) {
5204
- return { value, scale };
5205
- }
5206
- }
5207
- return null;
5208
- }
5209
5967
 
5210
5968
  // packages/runtime/src/control-plane/provider/runtime-instructions.ts
5211
5969
  var CLAUDE_ROUTER_TOOL_NAMES = [