@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.
@@ -1793,6 +1793,775 @@ function isGitOpenPrResult(value) {
1793
1793
  return typeof record.url === "string" && typeof record.branch === "string" && typeof record.base === "string" && (record.target === "project" || record.target === "monorepo") && typeof record.repoLabel === "string";
1794
1794
  }
1795
1795
 
1796
+ // packages/runtime/src/control-plane/native/pr-review-gate.ts
1797
+ function parseJsonObject(value) {
1798
+ if (!value?.trim())
1799
+ return { value: {}, error: "empty JSON output" };
1800
+ try {
1801
+ const parsed = JSON.parse(value);
1802
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
1803
+ } catch (error) {
1804
+ return { value: {}, error: error instanceof Error ? error.message : String(error) };
1805
+ }
1806
+ }
1807
+ function flattenPaginatedArray(value) {
1808
+ if (!Array.isArray(value))
1809
+ return null;
1810
+ if (value.every((entry) => Array.isArray(entry))) {
1811
+ return value.flatMap((entry) => entry);
1812
+ }
1813
+ return value;
1814
+ }
1815
+ function parseJsonArray(value) {
1816
+ if (!value?.trim())
1817
+ return { value: [], error: "empty JSON output" };
1818
+ try {
1819
+ const parsed = JSON.parse(value);
1820
+ const flattened = flattenPaginatedArray(parsed);
1821
+ return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
1822
+ } catch (error) {
1823
+ return { value: [], error: error instanceof Error ? error.message : String(error) };
1824
+ }
1825
+ }
1826
+ function parseGithubPrUrl(prUrl) {
1827
+ const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
1828
+ if (!match)
1829
+ return null;
1830
+ const prNumber = Number.parseInt(match[3], 10);
1831
+ if (!Number.isFinite(prNumber))
1832
+ return null;
1833
+ return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
1834
+ }
1835
+ function checkName(check) {
1836
+ return String(check.name ?? check.context ?? "").trim();
1837
+ }
1838
+ function checkState(check) {
1839
+ return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
1840
+ }
1841
+ function isGreptileLabel(value) {
1842
+ return String(value ?? "").toLowerCase().includes("greptile");
1843
+ }
1844
+ function isGreptileGithubLogin(value) {
1845
+ const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
1846
+ return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
1847
+ }
1848
+ function isPassingCheck(check) {
1849
+ const state = checkState(check);
1850
+ return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
1851
+ }
1852
+ function isPendingCheck(check) {
1853
+ const state = checkState(check);
1854
+ return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
1855
+ }
1856
+ function isFailingCheck(check) {
1857
+ const state = checkState(check);
1858
+ return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
1859
+ }
1860
+ function wildcardToRegExp(pattern) {
1861
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1862
+ return new RegExp(`^${escaped}$`, "i");
1863
+ }
1864
+ function isAllowedFailure(name, allowedFailures) {
1865
+ return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
1866
+ }
1867
+ function greptileScorePatterns() {
1868
+ return [
1869
+ /\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
1870
+ /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
1871
+ /\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
1872
+ ];
1873
+ }
1874
+ function parseGreptileScores(input) {
1875
+ const text = stripHtml(input);
1876
+ const seen = new Set;
1877
+ const scores = [];
1878
+ for (const pattern of greptileScorePatterns()) {
1879
+ for (const match of text.matchAll(pattern)) {
1880
+ const value = Number.parseInt(match[1] || "", 10);
1881
+ const scale = Number.parseInt(match[2] || "", 10);
1882
+ if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
1883
+ continue;
1884
+ const raw = match[0] || `${value}/${scale}`;
1885
+ const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
1886
+ if (seen.has(key))
1887
+ continue;
1888
+ seen.add(key);
1889
+ scores.push({ value, scale, raw });
1890
+ }
1891
+ }
1892
+ return scores;
1893
+ }
1894
+ function parseGreptileScore(input) {
1895
+ return parseGreptileScores(input)[0] ?? null;
1896
+ }
1897
+ function stripHtml(input) {
1898
+ return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
1899
+
1900
+ `).trim();
1901
+ }
1902
+ function containsBlockerText(input) {
1903
+ const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
1904
+ 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);
1905
+ }
1906
+ function isStrictFiveOfFive(score) {
1907
+ return score.value === 5 && score.scale === 5;
1908
+ }
1909
+ function containsConflictingScoreText(input) {
1910
+ return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
1911
+ }
1912
+ function firstString(record, keys) {
1913
+ for (const key of keys) {
1914
+ const value = record[key];
1915
+ if (typeof value === "string")
1916
+ return value;
1917
+ }
1918
+ return "";
1919
+ }
1920
+ function arrayField(record, key) {
1921
+ const value = record[key];
1922
+ return Array.isArray(value) ? value : [];
1923
+ }
1924
+ async function runJsonArray(command, args, cwd) {
1925
+ const result = await command(args, { cwd });
1926
+ const label = `gh ${args.join(" ")}`;
1927
+ if (result.exitCode !== 0) {
1928
+ return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
1929
+ }
1930
+ const parsed = parseJsonArray(result.stdout);
1931
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
1932
+ }
1933
+ async function runJsonObject(command, args, cwd) {
1934
+ const result = await command(args, { cwd });
1935
+ const label = `gh ${args.join(" ")}`;
1936
+ if (result.exitCode !== 0) {
1937
+ return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
1938
+ }
1939
+ const parsed = parseJsonObject(result.stdout);
1940
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
1941
+ }
1942
+ function normalizeStatusCheck(entry) {
1943
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
1944
+ return null;
1945
+ const record = entry;
1946
+ const name = firstString(record, ["name", "context"]);
1947
+ if (!name.trim())
1948
+ return null;
1949
+ const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
1950
+ const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
1951
+ return {
1952
+ __typename: typeof record.__typename === "string" ? record.__typename : null,
1953
+ name,
1954
+ context: typeof record.context === "string" ? record.context : null,
1955
+ status: typeof record.status === "string" ? record.status : null,
1956
+ state: typeof record.state === "string" ? record.state : null,
1957
+ conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
1958
+ 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,
1959
+ link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
1960
+ headSha: typeof record.headSha === "string" ? record.headSha : null,
1961
+ head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
1962
+ output: output ? {
1963
+ title: typeof output.title === "string" ? output.title : null,
1964
+ summary: typeof output.summary === "string" ? output.summary : null,
1965
+ text: typeof output.text === "string" ? output.text : null
1966
+ } : null,
1967
+ app: app ? {
1968
+ slug: typeof app.slug === "string" ? app.slug : null,
1969
+ name: typeof app.name === "string" ? app.name : null,
1970
+ owner: app.owner && typeof app.owner === "object" ? app.owner : null
1971
+ } : null
1972
+ };
1973
+ }
1974
+ function normalizeReview(entry) {
1975
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
1976
+ return null;
1977
+ const record = entry;
1978
+ return {
1979
+ id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
1980
+ state: typeof record.state === "string" ? record.state : null,
1981
+ body: typeof record.body === "string" ? record.body : null,
1982
+ 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,
1983
+ html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
1984
+ author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
1985
+ };
1986
+ }
1987
+ function normalizeReviewComment(entry) {
1988
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
1989
+ return null;
1990
+ const record = entry;
1991
+ const body = typeof record.body === "string" ? record.body : null;
1992
+ const path = typeof record.path === "string" ? record.path : null;
1993
+ if (!body && !path)
1994
+ return null;
1995
+ return {
1996
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
1997
+ user: record.user && typeof record.user === "object" ? record.user : null,
1998
+ author: record.author && typeof record.author === "object" ? record.author : null,
1999
+ body,
2000
+ path,
2001
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
2002
+ url: typeof record.url === "string" ? record.url : null,
2003
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
2004
+ original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
2005
+ };
2006
+ }
2007
+ function normalizeIssueComment(entry) {
2008
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
2009
+ return null;
2010
+ const record = entry;
2011
+ const body = typeof record.body === "string" ? record.body : null;
2012
+ if (!body)
2013
+ return null;
2014
+ return {
2015
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
2016
+ user: record.user && typeof record.user === "object" ? record.user : null,
2017
+ author: record.author && typeof record.author === "object" ? record.author : null,
2018
+ body,
2019
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
2020
+ url: typeof record.url === "string" ? record.url : null,
2021
+ created_at: typeof record.created_at === "string" ? record.created_at : null
2022
+ };
2023
+ }
2024
+ function normalizeReviewThread(entry) {
2025
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
2026
+ return null;
2027
+ const record = entry;
2028
+ return {
2029
+ id: typeof record.id === "string" ? record.id : null,
2030
+ isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
2031
+ isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
2032
+ comments: record.comments && typeof record.comments === "object" ? record.comments : null
2033
+ };
2034
+ }
2035
+ function relevantIssueComment(comment) {
2036
+ const login = comment.user?.login ?? comment.author?.login ?? "";
2037
+ const body = comment.body ?? "";
2038
+ 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);
2039
+ }
2040
+ function latestThreadComment(thread) {
2041
+ const nodes = thread.comments?.nodes ?? [];
2042
+ return nodes.length > 0 ? nodes[nodes.length - 1] : null;
2043
+ }
2044
+ function unresolvedThreadSummaries(threads) {
2045
+ return threads.flatMap((thread) => {
2046
+ if (thread.isResolved === true || thread.isOutdated === true)
2047
+ return [];
2048
+ const latest = latestThreadComment(thread);
2049
+ if (!latest)
2050
+ return ["Unresolved review thread"];
2051
+ const path = latest.path ? ` on ${latest.path}` : "";
2052
+ return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
2053
+ });
2054
+ }
2055
+ function collectBodies(evidence) {
2056
+ return [
2057
+ evidence.title ?? "",
2058
+ evidence.body,
2059
+ ...evidence.reviews.map((review) => review.body ?? ""),
2060
+ ...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
2061
+ ...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
2062
+ ...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
2063
+ ...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
2064
+ ].filter((body) => body.trim().length > 0);
2065
+ }
2066
+ function bodyExcerpt(body) {
2067
+ const text = stripHtml(body).replace(/\s+/g, " ").trim();
2068
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
2069
+ }
2070
+ function makeGreptileSignal(input) {
2071
+ const scores = parseGreptileScores(input.body);
2072
+ const reviewedSha = input.reviewedSha?.trim() || null;
2073
+ const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
2074
+ const blocker = input.blocker ?? containsBlockerText(input.body);
2075
+ const explicitApproval = input.explicitApproval ?? false;
2076
+ return {
2077
+ source: input.source,
2078
+ trusted: input.trusted,
2079
+ authorLogin: input.authorLogin ?? null,
2080
+ reviewedSha,
2081
+ current,
2082
+ stale: current === false,
2083
+ score: scores[0] ?? null,
2084
+ scores,
2085
+ explicitApproval,
2086
+ blocker,
2087
+ actionable: input.actionable ?? blocker,
2088
+ bodyExcerpt: bodyExcerpt(input.body),
2089
+ body: input.body,
2090
+ allScores: scores
2091
+ };
2092
+ }
2093
+ function reviewAuthorLogin(review) {
2094
+ return review.author?.login ?? null;
2095
+ }
2096
+ function commentAuthorLogin(comment) {
2097
+ return comment.user?.login ?? comment.author?.login ?? null;
2098
+ }
2099
+ function collectGreptileSignals(evidence) {
2100
+ const signals = [];
2101
+ const contextSources = [
2102
+ { source: "pr-title", body: evidence.title ?? "" },
2103
+ { source: "pr-body", body: evidence.body }
2104
+ ];
2105
+ for (const context of contextSources) {
2106
+ if (!context.body.trim())
2107
+ continue;
2108
+ if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
2109
+ continue;
2110
+ const contextBlocker = containsBlockerText(context.body);
2111
+ signals.push(makeGreptileSignal({
2112
+ source: context.source,
2113
+ body: context.body,
2114
+ currentHeadSha: evidence.currentHeadSha,
2115
+ trusted: false,
2116
+ blocker: contextBlocker,
2117
+ actionable: contextBlocker
2118
+ }));
2119
+ }
2120
+ for (const apiSignal of evidence.apiSignals ?? []) {
2121
+ const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
2122
+
2123
+ `);
2124
+ if (!body.trim())
2125
+ continue;
2126
+ signals.push(makeGreptileSignal({
2127
+ source: "api",
2128
+ body,
2129
+ currentHeadSha: evidence.currentHeadSha,
2130
+ trusted: true,
2131
+ reviewedSha: apiSignal.reviewedSha ?? null,
2132
+ explicitApproval: false
2133
+ }));
2134
+ }
2135
+ for (const review of evidence.reviews) {
2136
+ const login = reviewAuthorLogin(review);
2137
+ if (!isGreptileGithubLogin(login))
2138
+ continue;
2139
+ const state = String(review.state ?? "").toUpperCase();
2140
+ const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
2141
+
2142
+ `);
2143
+ if (!body.trim())
2144
+ continue;
2145
+ const dismissed = state === "DISMISSED";
2146
+ signals.push(makeGreptileSignal({
2147
+ source: "github-review",
2148
+ body,
2149
+ currentHeadSha: evidence.currentHeadSha,
2150
+ trusted: !dismissed,
2151
+ authorLogin: login,
2152
+ reviewedSha: review.commit_id ?? null,
2153
+ explicitApproval: undefined,
2154
+ blocker: state === "CHANGES_REQUESTED" || undefined
2155
+ }));
2156
+ }
2157
+ for (const comment of evidence.changedFileReviewComments) {
2158
+ const login = commentAuthorLogin(comment);
2159
+ const body = comment.body ?? "";
2160
+ if (!body.trim() || !isGreptileGithubLogin(login))
2161
+ continue;
2162
+ signals.push(makeGreptileSignal({
2163
+ source: "changed-file-comment",
2164
+ body,
2165
+ currentHeadSha: evidence.currentHeadSha,
2166
+ trusted: true,
2167
+ authorLogin: login,
2168
+ reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
2169
+ }));
2170
+ }
2171
+ for (const comment of evidence.relevantIssueComments) {
2172
+ const login = commentAuthorLogin(comment);
2173
+ const body = comment.body ?? "";
2174
+ if (!body.trim() || !isGreptileGithubLogin(login))
2175
+ continue;
2176
+ signals.push(makeGreptileSignal({
2177
+ source: "issue-comment",
2178
+ body,
2179
+ currentHeadSha: evidence.currentHeadSha,
2180
+ trusted: true,
2181
+ authorLogin: login
2182
+ }));
2183
+ }
2184
+ for (const thread of evidence.reviewThreads) {
2185
+ if (thread.isOutdated === true || thread.isResolved === true)
2186
+ continue;
2187
+ for (const comment of thread.comments?.nodes ?? []) {
2188
+ const login = comment.author?.login ?? null;
2189
+ const body = comment.body ?? "";
2190
+ if (!body.trim() || !isGreptileGithubLogin(login))
2191
+ continue;
2192
+ signals.push(makeGreptileSignal({
2193
+ source: "review-thread",
2194
+ body,
2195
+ currentHeadSha: evidence.currentHeadSha,
2196
+ trusted: true,
2197
+ authorLogin: login
2198
+ }));
2199
+ }
2200
+ }
2201
+ for (const check of evidence.checks) {
2202
+ if (!isGreptileLabel(checkName(check)))
2203
+ continue;
2204
+ const reviewedSha = check.headSha ?? check.head_sha ?? null;
2205
+ const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
2206
+ const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
2207
+
2208
+ `);
2209
+ signals.push(makeGreptileSignal({
2210
+ source: "github-check",
2211
+ body,
2212
+ currentHeadSha: evidence.currentHeadSha,
2213
+ trusted: false,
2214
+ reviewedSha,
2215
+ explicitApproval: false,
2216
+ blocker: isFailingCheck(check),
2217
+ actionable: isFailingCheck(check)
2218
+ }));
2219
+ }
2220
+ return signals;
2221
+ }
2222
+ function unresolvedGreptileThreadSummaries(threads) {
2223
+ return threads.flatMap((thread) => {
2224
+ if (thread.isResolved === true || thread.isOutdated === true)
2225
+ return [];
2226
+ const comments = thread.comments?.nodes ?? [];
2227
+ if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
2228
+ return [];
2229
+ const latest = latestThreadComment(thread);
2230
+ if (!latest)
2231
+ return ["Unresolved Greptile review thread"];
2232
+ const path = latest.path ? ` on ${latest.path}` : "";
2233
+ return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
2234
+ });
2235
+ }
2236
+ function actionableChangedFileCommentSummaries(_comments) {
2237
+ return [];
2238
+ }
2239
+ function issueLevelBlockerSummaries(comments) {
2240
+ return comments.flatMap((comment) => {
2241
+ const body = comment.body?.trim() ?? "";
2242
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
2243
+ return [];
2244
+ const login = commentAuthorLogin(comment) ?? "unknown";
2245
+ const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
2246
+ return [`${author}: ${body}`];
2247
+ });
2248
+ }
2249
+ function reviewBodyBlockerSummaries(reviews) {
2250
+ return reviews.flatMap((review) => {
2251
+ const login = reviewAuthorLogin(review) ?? "unknown";
2252
+ if (isGreptileGithubLogin(login))
2253
+ return [];
2254
+ const body = review.body?.trim() ?? "";
2255
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
2256
+ return [];
2257
+ const state = review.state ? ` (${review.state})` : "";
2258
+ return [`PR review summary by ${login}${state}: ${body}`];
2259
+ });
2260
+ }
2261
+ function signalLabel(signal) {
2262
+ const source = signal.source.replace(/-/g, " ");
2263
+ const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
2264
+ const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
2265
+ return `${source}${author}${sha}`;
2266
+ }
2267
+ function deriveGreptileEvidence(input) {
2268
+ const rawBodies = collectBodies(input);
2269
+ const signals = collectGreptileSignals(input);
2270
+ const trustedSignals = signals.filter((signal) => signal.trusted);
2271
+ const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
2272
+ const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
2273
+ const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
2274
+ const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
2275
+ const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
2276
+ const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
2277
+ const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
2278
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
2279
+ const approvedByScore = !!approvingScoreEntry;
2280
+ const approvedByExplicitMapping = false;
2281
+ const approvingSignal = approvingScoreEntry?.signal ?? null;
2282
+ const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
2283
+ const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
2284
+ const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
2285
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
2286
+ const staleBlockingSignals = [];
2287
+ const blockers = [
2288
+ ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
2289
+ ...reviewBodyBlockerSummaries(input.reviews),
2290
+ ...issueLevelBlockerSummaries(input.relevantIssueComments),
2291
+ ...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
2292
+ ...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
2293
+ ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
2294
+ ];
2295
+ const unresolvedComments = [
2296
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
2297
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
2298
+ ];
2299
+ const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
2300
+ const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
2301
+ const completedGreptileCheck = greptileChecks.some((check) => {
2302
+ const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
2303
+ return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
2304
+ });
2305
+ const completedGreptileReview = greptileReviews.some((review) => {
2306
+ const state = String(review.state ?? "").toUpperCase();
2307
+ const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
2308
+ return completedState && review.commit_id === input.currentHeadSha;
2309
+ });
2310
+ const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
2311
+ const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
2312
+ const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
2313
+ const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
2314
+ const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
2315
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
2316
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
2317
+ 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";
2318
+ return {
2319
+ source,
2320
+ currentHeadSha: input.currentHeadSha,
2321
+ reviewedSha,
2322
+ fresh,
2323
+ completed,
2324
+ approved,
2325
+ score,
2326
+ explicitApproval: approvedByExplicitMapping,
2327
+ blockers,
2328
+ unresolvedComments,
2329
+ rawBodies,
2330
+ signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
2331
+ mapping
2332
+ };
2333
+ }
2334
+ function isGreptileCheckDetail(check) {
2335
+ return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
2336
+ }
2337
+ async function collectGreptileCheckDetails(input) {
2338
+ const checkRunsRead = await runJsonArray(input.command, [
2339
+ "api",
2340
+ `repos/${input.repoName}/commits/${input.headSha}/check-runs`,
2341
+ "--paginate",
2342
+ "--slurp",
2343
+ "--jq",
2344
+ "map(.check_runs // []) | add // []"
2345
+ ], input.projectRoot);
2346
+ const checkRuns = checkRunsRead.value.map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
2347
+ return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
2348
+ }
2349
+ async function collectReviewThreads(input) {
2350
+ const reviewThreads = [];
2351
+ let afterCursor = null;
2352
+ for (let page = 0;page < 100; page += 1) {
2353
+ const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
2354
+ const threadsResponse = await runJsonObject(input.command, [
2355
+ "api",
2356
+ "graphql",
2357
+ "-F",
2358
+ `owner=${input.owner}`,
2359
+ "-F",
2360
+ `name=${input.name}`,
2361
+ "-F",
2362
+ `prNumber=${input.prNumber}`,
2363
+ "-f",
2364
+ `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 } } } } }`
2365
+ ], input.projectRoot);
2366
+ if (threadsResponse.error) {
2367
+ return { value: reviewThreads, error: threadsResponse.error };
2368
+ }
2369
+ const data = threadsResponse.value.data;
2370
+ const repository = data?.repository;
2371
+ const pullRequest = repository?.pullRequest;
2372
+ const threads = pullRequest?.reviewThreads;
2373
+ const nodes = threads?.nodes;
2374
+ if (!Array.isArray(nodes)) {
2375
+ return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
2376
+ }
2377
+ const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
2378
+ reviewThreads.push(...normalized);
2379
+ const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
2380
+ if (truncatedCommentThread) {
2381
+ return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
2382
+ }
2383
+ const pageInfo = threads?.pageInfo;
2384
+ if (!pageInfo) {
2385
+ if (nodes.length >= 100) {
2386
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
2387
+ }
2388
+ return { value: reviewThreads };
2389
+ }
2390
+ if (pageInfo.hasNextPage !== true) {
2391
+ return { value: reviewThreads };
2392
+ }
2393
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
2394
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
2395
+ }
2396
+ afterCursor = pageInfo.endCursor;
2397
+ }
2398
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
2399
+ }
2400
+ async function collectPrReviewEvidence(input) {
2401
+ const parsed = parseGithubPrUrl(input.prUrl);
2402
+ if (!parsed) {
2403
+ throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
2404
+ }
2405
+ const readErrors = [];
2406
+ const viewRead = await runJsonObject(input.command, [
2407
+ "pr",
2408
+ "view",
2409
+ input.prUrl,
2410
+ "--json",
2411
+ "title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
2412
+ ], input.projectRoot);
2413
+ if (viewRead.error)
2414
+ readErrors.push(viewRead.error);
2415
+ const view = viewRead.value;
2416
+ if (!Array.isArray(view.statusCheckRollup)) {
2417
+ readErrors.push("gh pr view did not return required statusCheckRollup array");
2418
+ }
2419
+ if (!Array.isArray(view.reviews)) {
2420
+ readErrors.push("gh pr view did not return required reviews array");
2421
+ }
2422
+ const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
2423
+ const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
2424
+ const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
2425
+ const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
2426
+ if (reviewCommentsRead.error)
2427
+ readErrors.push(reviewCommentsRead.error);
2428
+ const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
2429
+ const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
2430
+ if (issueCommentsRead.error)
2431
+ readErrors.push(issueCommentsRead.error);
2432
+ const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
2433
+ const reviewThreadsRead = await collectReviewThreads({
2434
+ command: input.command,
2435
+ projectRoot: input.projectRoot,
2436
+ owner: parsed.owner,
2437
+ name: parsed.repo,
2438
+ prNumber: parsed.prNumber
2439
+ });
2440
+ if (reviewThreadsRead.error)
2441
+ readErrors.push(reviewThreadsRead.error);
2442
+ const reviewThreads = reviewThreadsRead.value;
2443
+ const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
2444
+ let greptileCheckDetails = [];
2445
+ if (headSha && greptileRollupChecks.length > 0) {
2446
+ const checkDetailsRead = await collectGreptileCheckDetails({
2447
+ command: input.command,
2448
+ projectRoot: input.projectRoot,
2449
+ repoName: parsed.repoName,
2450
+ headSha
2451
+ });
2452
+ if (checkDetailsRead.error)
2453
+ readErrors.push(checkDetailsRead.error);
2454
+ greptileCheckDetails = checkDetailsRead.value;
2455
+ if (!checkDetailsRead.error && greptileCheckDetails.length === 0) {
2456
+ readErrors.push("Greptile check details could not be found for the current PR head");
2457
+ }
2458
+ }
2459
+ const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
2460
+ 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})` : ""}`);
2461
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check pending: ${checkName(check)}`);
2462
+ const evidenceBase = {
2463
+ title: firstString(view, ["title"]),
2464
+ body: firstString(view, ["body"]),
2465
+ reviews,
2466
+ changedFileReviewComments: reviewComments,
2467
+ relevantIssueComments: issueComments,
2468
+ reviewThreads,
2469
+ checks: checksWithGreptileDetails,
2470
+ currentHeadSha: headSha,
2471
+ apiSignals: input.apiSignals ?? []
2472
+ };
2473
+ const greptile = deriveGreptileEvidence(evidenceBase);
2474
+ return {
2475
+ prUrl: input.prUrl,
2476
+ prNumber: parsed.prNumber,
2477
+ repoName: parsed.repoName,
2478
+ title: evidenceBase.title,
2479
+ body: evidenceBase.body,
2480
+ headSha,
2481
+ headRefName: firstString(view, ["headRefName"]),
2482
+ baseRefName: firstString(view, ["baseRefName"]),
2483
+ state: firstString(view, ["state"]),
2484
+ isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
2485
+ mergeable: firstString(view, ["mergeable"]),
2486
+ mergeStateStatus: firstString(view, ["mergeStateStatus"]),
2487
+ reviewDecision: firstString(view, ["reviewDecision"]),
2488
+ reviews,
2489
+ reviewThreads,
2490
+ changedFileReviewComments: reviewComments,
2491
+ relevantIssueComments: issueComments,
2492
+ statusCheckRollup: checksWithGreptileDetails,
2493
+ checkFailures,
2494
+ pendingChecks,
2495
+ readErrors,
2496
+ greptile
2497
+ };
2498
+ }
2499
+ function evaluateEvidence(evidence) {
2500
+ const reasons = [];
2501
+ const warnings = [];
2502
+ let pending = false;
2503
+ if (evidence.readErrors.length > 0) {
2504
+ reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
2505
+ }
2506
+ if (!evidence.headSha)
2507
+ reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
2508
+ if (evidence.checkFailures.length > 0)
2509
+ reasons.push(...evidence.checkFailures);
2510
+ if (evidence.pendingChecks.length > 0) {
2511
+ pending = true;
2512
+ reasons.push(...evidence.pendingChecks);
2513
+ }
2514
+ const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
2515
+ if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
2516
+ reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
2517
+ }
2518
+ const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
2519
+ if (unresolvedThreads.length > 0)
2520
+ reasons.push(...unresolvedThreads);
2521
+ const greptile = evidence.greptile;
2522
+ if (greptile.mapping === "missing")
2523
+ reasons.push("Missing Greptile check/review evidence for this PR.");
2524
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
2525
+ if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
2526
+ reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
2527
+ }
2528
+ if (!greptile.completed) {
2529
+ pending = true;
2530
+ reasons.push("Greptile check/review has not completed for the current PR head.");
2531
+ }
2532
+ if (!greptile.fresh)
2533
+ reasons.push("Greptile approval is not tied to the current PR head SHA.");
2534
+ if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
2535
+ reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
2536
+ }
2537
+ if (!greptile.score && greptile.mapping !== "score-5-of-5") {
2538
+ reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
2539
+ }
2540
+ if (greptile.mapping === "unproven") {
2541
+ reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
2542
+ }
2543
+ if (greptile.blockers.length > 0) {
2544
+ reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
2545
+ }
2546
+ if (greptile.unresolvedComments.length > 0)
2547
+ reasons.push(...greptile.unresolvedComments);
2548
+ if (!greptile.approved)
2549
+ warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
2550
+ return { reasons: Array.from(new Set(reasons)), warnings, pending };
2551
+ }
2552
+ function evaluateStrictPrMergeGate(evidence) {
2553
+ const evaluated = evaluateEvidence(evidence);
2554
+ const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
2555
+ return {
2556
+ approved,
2557
+ pending: evaluated.pending,
2558
+ reasons: evaluated.reasons,
2559
+ warnings: evaluated.warnings,
2560
+ actionableFeedback: evaluated.reasons,
2561
+ evidence
2562
+ };
2563
+ }
2564
+
1796
2565
  // packages/runtime/src/control-plane/native/verifier.ts
1797
2566
  async function verifyTask(options) {
1798
2567
  const paths = resolveHarnessPaths(options.projectRoot);
@@ -2589,7 +3358,8 @@ async function runGreptileReviewForPr(options) {
2589
3358
  }
2590
3359
  };
2591
3360
  }
2592
- if (/not safe to merge|unsafe to merge|do not merge|blocker/i.test(reviewBody)) {
3361
+ const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
3362
+ 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)) {
2593
3363
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
2594
3364
  return {
2595
3365
  verdict: "REJECT",
@@ -2605,44 +3375,78 @@ async function runGreptileReviewForPr(options) {
2605
3375
  }
2606
3376
  };
2607
3377
  }
2608
- if (score) {
2609
- if (score.scale === 5 && score.value < 5 && options.reviewMode === "required") {
2610
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; required mode needs 5/5 before merge.`);
2611
- return {
2612
- verdict: "REJECT",
2613
- feedback,
2614
- reasons,
2615
- warnings,
2616
- rawPayload: {
2617
- pr: options.prState,
2618
- codeReviews: reviewsPayload,
2619
- selectedReview,
2620
- reviewDetails,
2621
- comments: commentsPayload,
2622
- score
2623
- }
2624
- };
2625
- }
2626
- if (score.scale === 5 && score.value <= 2) {
2627
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; this requires rework before merge.`);
2628
- return {
2629
- verdict: "REJECT",
2630
- feedback,
2631
- reasons,
2632
- warnings,
2633
- rawPayload: {
2634
- pr: options.prState,
2635
- codeReviews: reviewsPayload,
2636
- selectedReview,
2637
- reviewDetails,
2638
- comments: commentsPayload,
2639
- score
3378
+ if (score?.scale === 5 && score.value < 5) {
3379
+ reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
3380
+ return {
3381
+ verdict: "REJECT",
3382
+ feedback,
3383
+ reasons,
3384
+ warnings,
3385
+ rawPayload: {
3386
+ pr: options.prState,
3387
+ codeReviews: reviewsPayload,
3388
+ selectedReview,
3389
+ reviewDetails,
3390
+ comments: commentsPayload,
3391
+ score
3392
+ }
3393
+ };
3394
+ }
3395
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
3396
+ let strictGate = null;
3397
+ try {
3398
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
3399
+ projectRoot: options.projectRoot,
3400
+ taskId: options.taskId,
3401
+ prUrl,
3402
+ apiSignals: [{
3403
+ id: selectedReview.id,
3404
+ body: reviewBody,
3405
+ reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
3406
+ status: selectedReview.status
3407
+ }]
3408
+ });
3409
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
3410
+ } catch (error) {
3411
+ reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
3412
+ return {
3413
+ verdict: "REJECT",
3414
+ feedback,
3415
+ reasons,
3416
+ warnings,
3417
+ rawPayload: {
3418
+ pr: options.prState,
3419
+ codeReviews: reviewsPayload,
3420
+ selectedReview,
3421
+ reviewDetails,
3422
+ comments: commentsPayload,
3423
+ score
3424
+ }
3425
+ };
3426
+ }
3427
+ if (!strictGate.approved) {
3428
+ return {
3429
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
3430
+ feedback,
3431
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
3432
+ warnings: [...warnings, ...strictGate.warnings],
3433
+ rawPayload: {
3434
+ pr: options.prState,
3435
+ codeReviews: reviewsPayload,
3436
+ selectedReview,
3437
+ reviewDetails,
3438
+ comments: commentsPayload,
3439
+ score,
3440
+ strictGate: {
3441
+ approved: strictGate.approved,
3442
+ pending: strictGate.pending,
3443
+ reasons: strictGate.reasons,
3444
+ warnings: strictGate.warnings,
3445
+ greptile: strictGate.evidence.greptile,
3446
+ readErrors: strictGate.evidence.readErrors
2640
3447
  }
2641
- };
2642
- }
2643
- if (score.scale === 5 && score.value < 5) {
2644
- warnings.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; continue only after reviewing remaining risk.`);
2645
- }
3448
+ }
3449
+ };
2646
3450
  }
2647
3451
  return {
2648
3452
  verdict: "APPROVE",
@@ -2654,7 +3458,15 @@ async function runGreptileReviewForPr(options) {
2654
3458
  codeReviews: reviewsPayload,
2655
3459
  selectedReview,
2656
3460
  reviewDetails,
2657
- comments: commentsPayload
3461
+ comments: commentsPayload,
3462
+ strictGate: {
3463
+ approved: strictGate.approved,
3464
+ pending: strictGate.pending,
3465
+ reasons: strictGate.reasons,
3466
+ warnings: strictGate.warnings,
3467
+ greptile: strictGate.evidence.greptile,
3468
+ readErrors: strictGate.evidence.readErrors
3469
+ }
2658
3470
  }
2659
3471
  };
2660
3472
  }
@@ -2678,7 +3490,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2678
3490
  let threads = [];
2679
3491
  let actionableThreads = [];
2680
3492
  let checkRollup = [];
2681
- let checkState = { pending: false, completed: false };
3493
+ let checkState2 = { pending: false, completed: false };
2682
3494
  for (let attempt = 0;; attempt += 1) {
2683
3495
  reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
2684
3496
  selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
@@ -2687,15 +3499,15 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2687
3499
  threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
2688
3500
  actionableThreads = filterActionableGithubGreptileThreads(threads);
2689
3501
  checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
2690
- checkState = classifyGithubGreptileCheckState(checkRollup);
2691
- const approvedViaReviewedAncestor2 = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
3502
+ checkState2 = classifyGithubGreptileCheckState(checkRollup);
3503
+ const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
2692
3504
  if (!shouldContinueGithubGreptileFallbackPolling({
2693
3505
  attempt,
2694
3506
  pollAttempts: options.pollAttempts,
2695
- checkState,
3507
+ checkState: checkState2,
2696
3508
  fallbackReview,
2697
3509
  selectedReview,
2698
- approvedViaReviewedAncestor: approvedViaReviewedAncestor2
3510
+ approvedViaReviewedAncestor
2699
3511
  })) {
2700
3512
  break;
2701
3513
  }
@@ -2723,7 +3535,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2723
3535
  ].filter(Boolean).join(`
2724
3536
  `);
2725
3537
  const warnings = buildGithubGreptileFallbackWarnings(options);
2726
- if (checkState.pending) {
3538
+ if (checkState2.pending) {
2727
3539
  return {
2728
3540
  verdict: "SKIP",
2729
3541
  feedback,
@@ -2734,34 +3546,20 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2734
3546
  rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
2735
3547
  };
2736
3548
  }
2737
- const approvedViaCompletedCheck = isGithubGreptileCheckApproved(checkRollup);
2738
- if (!fallbackReview) {
2739
- if (approvedViaCompletedCheck) {
2740
- 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.`);
2741
- return {
2742
- verdict: "APPROVE",
2743
- feedback,
2744
- reasons: [],
2745
- warnings,
2746
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
2747
- };
2748
- }
2749
- return {
2750
- verdict: "SKIP",
2751
- feedback,
2752
- reasons: [
2753
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available.`
2754
- ],
2755
- warnings,
2756
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
2757
- };
2758
- }
2759
- const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
2760
- if (actionableThreads.length > 0) {
3549
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
3550
+ let strictGate;
3551
+ try {
3552
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
3553
+ projectRoot: options.projectRoot,
3554
+ taskId: options.taskId,
3555
+ prUrl
3556
+ });
3557
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
3558
+ } catch (error) {
2761
3559
  return {
2762
3560
  verdict: "REJECT",
2763
3561
  feedback,
2764
- reasons: actionableThreads.map((comment) => `[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.path}: ${summarizeComment(comment.body || "")}`),
3562
+ reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
2765
3563
  warnings,
2766
3564
  rawPayload: {
2767
3565
  pr: options.prState,
@@ -2774,44 +3572,30 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2774
3572
  }
2775
3573
  };
2776
3574
  }
2777
- if (!selectedReview && !approvedViaReviewedAncestor) {
2778
- if (approvedViaCompletedCheck) {
2779
- 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.`);
2780
- return {
2781
- verdict: "APPROVE",
2782
- feedback,
2783
- reasons: [],
2784
- warnings,
2785
- rawPayload: {
2786
- pr: options.prState,
2787
- selectedReview: fallbackReview,
2788
- reviews,
2789
- threads,
2790
- checkRollup,
2791
- ...buildGithubGreptileFallbackRawPayload(options)
2792
- }
2793
- };
2794
- }
3575
+ if (!strictGate.approved) {
2795
3576
  return {
2796
- verdict: "SKIP",
3577
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
2797
3578
  feedback,
2798
- reasons: [
2799
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available for the current head.`
2800
- ],
2801
- warnings,
3579
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
3580
+ warnings: [...warnings, ...strictGate.warnings],
2802
3581
  rawPayload: {
2803
3582
  pr: options.prState,
2804
3583
  selectedReview: fallbackReview,
2805
3584
  reviews,
2806
3585
  threads,
2807
3586
  checkRollup,
3587
+ actionableThreads,
3588
+ strictGate: {
3589
+ approved: strictGate.approved,
3590
+ pending: strictGate.pending,
3591
+ reasons: strictGate.reasons,
3592
+ warnings: strictGate.warnings,
3593
+ greptile: strictGate.evidence.greptile
3594
+ },
2808
3595
  ...buildGithubGreptileFallbackRawPayload(options)
2809
3596
  }
2810
3597
  };
2811
3598
  }
2812
- if (approvedViaReviewedAncestor) {
2813
- 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.`);
2814
- }
2815
3599
  return {
2816
3600
  verdict: "APPROVE",
2817
3601
  feedback,
@@ -2823,6 +3607,13 @@ async function runGithubGreptileFallbackReviewForPr(options) {
2823
3607
  reviews,
2824
3608
  threads,
2825
3609
  checkRollup,
3610
+ strictGate: {
3611
+ approved: strictGate.approved,
3612
+ pending: strictGate.pending,
3613
+ reasons: strictGate.reasons,
3614
+ warnings: strictGate.warnings,
3615
+ greptile: strictGate.evidence.greptile
3616
+ },
2826
3617
  ...buildGithubGreptileFallbackRawPayload(options)
2827
3618
  }
2828
3619
  };
@@ -3008,6 +3799,20 @@ function runGhJson(projectRoot, args) {
3008
3799
  throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
3009
3800
  }
3010
3801
  }
3802
+ async function collectStrictPrEvidenceForVerifier(input) {
3803
+ return collectPrReviewEvidence({
3804
+ projectRoot: input.projectRoot,
3805
+ prUrl: input.prUrl,
3806
+ taskId: input.taskId,
3807
+ runId: "verifier",
3808
+ cycle: 0,
3809
+ apiSignals: input.apiSignals ?? [],
3810
+ command: async (args, options) => {
3811
+ const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
3812
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
3813
+ }
3814
+ });
3815
+ }
3011
3816
  function deriveRepoName(projectRoot, prState) {
3012
3817
  const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
3013
3818
  if (fromUrl?.[1]) {
@@ -3022,8 +3827,9 @@ function resolvePrHeadSha(projectRoot, prState) {
3022
3827
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
3023
3828
  return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
3024
3829
  }
3025
- function isGreptileGithubLogin(login) {
3026
- return (login || "").replace(/\[bot\]$/, "") === "greptile-apps";
3830
+ function isGreptileGithubLogin2(login) {
3831
+ const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
3832
+ return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
3027
3833
  }
3028
3834
  function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
3029
3835
  const matching = sortGithubGreptileReviews(reviews);
@@ -3040,7 +3846,7 @@ function pickLatestGithubGreptileReview(reviews) {
3040
3846
  return sortGithubGreptileReviews(reviews)[0] || null;
3041
3847
  }
3042
3848
  function sortGithubGreptileReviews(reviews) {
3043
- return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
3849
+ return reviews.filter((review) => isGreptileGithubLogin2(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
3044
3850
  }
3045
3851
  function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
3046
3852
  const response = runGhJson(projectRoot, [
@@ -3113,31 +3919,8 @@ function classifyGithubGreptileCheckState(checks) {
3113
3919
  }
3114
3920
  return { pending: false, completed: false };
3115
3921
  }
3116
- function isGithubGreptileCheckApproved(checks) {
3117
- const greptileChecks = checks.filter((check) => {
3118
- const label = (check.name || check.context || "").toLowerCase();
3119
- return label.includes("greptile");
3120
- });
3121
- if (greptileChecks.length === 0) {
3122
- return false;
3123
- }
3124
- for (const check of greptileChecks) {
3125
- if ((check.__typename || "") === "CheckRun") {
3126
- if ((check.status || "").toUpperCase() !== "COMPLETED") {
3127
- return false;
3128
- }
3129
- const conclusion = (check.conclusion || "").toUpperCase();
3130
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) {
3131
- return false;
3132
- }
3133
- continue;
3134
- }
3135
- const state = (check.state || "").toUpperCase();
3136
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state)) {
3137
- return false;
3138
- }
3139
- }
3140
- return true;
3922
+ function isGithubGreptileCheckApproved(_checks) {
3923
+ return false;
3141
3924
  }
3142
3925
  function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
3143
3926
  const [owner, name] = repoName.split("/");
@@ -3164,7 +3947,7 @@ function filterActionableGithubGreptileThreads(threads) {
3164
3947
  return [];
3165
3948
  }
3166
3949
  const comments = thread.comments?.nodes || [];
3167
- const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
3950
+ const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin2(comment.author?.login));
3168
3951
  if (!latestGreptileComment?.path?.trim()) {
3169
3952
  return [];
3170
3953
  }
@@ -3186,11 +3969,6 @@ function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headComm
3186
3969
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
3187
3970
  return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
3188
3971
  }
3189
- function stripHtml(input) {
3190
- return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
3191
-
3192
- `).trim();
3193
- }
3194
3972
  function summarizeComment(input) {
3195
3973
  const text = stripHtml(input).replace(/\s+/g, " ").trim();
3196
3974
  return text.length > 160 ? `${text.slice(0, 157)}...` : text;
@@ -3199,31 +3977,14 @@ function asGreptileInfrastructureWarning(reason) {
3199
3977
  return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
3200
3978
  }
3201
3979
  function isAiReviewApproved(input) {
3980
+ if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
3981
+ return false;
3982
+ }
3202
3983
  if (input.reviewMode !== "required") {
3203
3984
  return true;
3204
3985
  }
3205
3986
  return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
3206
3987
  }
3207
- function parseGreptileScore(input) {
3208
- const text = stripHtml(input);
3209
- const patterns = [
3210
- /confidence score:\s*(\d+)\s*\/\s*(\d+)/i,
3211
- /\bscore:\s*(\d+)\s*\/\s*(\d+)/i,
3212
- /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|score)/i
3213
- ];
3214
- for (const pattern of patterns) {
3215
- const match = pattern.exec(text);
3216
- if (!match) {
3217
- continue;
3218
- }
3219
- const value = Number.parseInt(match[1] || "", 10);
3220
- const scale = Number.parseInt(match[2] || "", 10);
3221
- if (Number.isFinite(value) && Number.isFinite(scale) && scale > 0) {
3222
- return { value, scale };
3223
- }
3224
- }
3225
- return null;
3226
- }
3227
3988
  var __testOnly = {
3228
3989
  asGreptileInfrastructureWarning,
3229
3990
  callGreptileMcpToolWithTimeout,