mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 04:17:33 +01:00
- lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs. - lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test. - test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment).
212 lines
12 KiB
TypeScript
212 lines
12 KiB
TypeScript
/**
|
|
* review-bots: the PR review-bot comments /review Step 2.5 and /ship Step 10 triage (#3122).
|
|
*
|
|
* The bots are the two gstack-pr-wait lists (`pr_wait_gating_bots`, `pr_wait_advisory_bots`;
|
|
* `none` in both turns triage off), owned by login with gstack-pr-wait's rule (`botOwning`),
|
|
* and only Bot accounts count, so a human named `greptile-ci` is never triaged.
|
|
*
|
|
* fetch [--pr <n>] every root review comment, conversation comment and the newest review
|
|
* body of each configured bot, all pages; prints REVIEW_BOTS: skip|error
|
|
* (<reason>) or REVIEW_BOTS_DIR: <dir> with comments.jsonl and the counts
|
|
* record --dir D --id N --type fix|fp|already-fixed --category C
|
|
* append a triage outcome to review-bot-history.md (project and global); the bot
|
|
* and path come from comment N in D/comments.jsonl, never from a shell argument,
|
|
* so a contributor-named file cannot reach the command line
|
|
*
|
|
* fetch marks each record with the false-positive rows that could suppress it: same repo, same
|
|
* bot, matching file. Rows in the old greptile-history.md count as Greptile's.
|
|
* bin/gstack-review-bots is the CLI; tests drive `runReviewBots` with a fake runner.
|
|
*/
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { botLists, botOwning } from "./pr-wait";
|
|
import { remoteSlug } from "./remote-identity";
|
|
import { resolveStateRoot, type StateRootEnv } from "./state-root";
|
|
|
|
export interface ReviewBotsEnv {
|
|
run(cmd: string, args: string[]): { status: number; stdout: string; stderr: string };
|
|
vars: StateRootEnv;
|
|
/** Directory holding gstack-slug. */
|
|
binDir: string;
|
|
/** Parent directory for the private fetch directory. */
|
|
tmpDir: string;
|
|
now(): number;
|
|
out(text: string): void;
|
|
err(text: string): void;
|
|
}
|
|
|
|
export const HISTORY_FILE = "review-bot-history.md";
|
|
export const LEGACY_HISTORY_FILE = "greptile-history.md";
|
|
export const HISTORY_TYPES = ["fix", "fp", "already-fixed"] as const;
|
|
|
|
export interface BotComment {
|
|
id: number;
|
|
bot: string;
|
|
source: "line" | "top" | "review";
|
|
path?: string;
|
|
line?: number | null;
|
|
url: string;
|
|
body: string;
|
|
suppress_candidates: string[];
|
|
}
|
|
|
|
interface GhUser { login?: string; type?: string }
|
|
interface GhItem {
|
|
id: number; user?: GhUser | null; body?: string | null; html_url?: string; path?: string;
|
|
line?: number | null; position?: number | null; in_reply_to_id?: number | null; submitted_at?: string | null; subject_type?: string | null;
|
|
}
|
|
interface HistoryRow { repo: string; type: string; pattern: string; category: string; bot: string }
|
|
|
|
const firstLine = (text: string) => text.trim().split("\n")[0] || "no detail";
|
|
/** gh's answers when the checkout has no GitHub remote at all; any other repo lookup failure is an error, never a skip. */
|
|
const NO_GITHUB_REMOTE = /no git remotes found|none of the git remotes configured for this repository point to a known GitHub host|not a git repository/i;
|
|
/** The issue types a triage outcome may carry (review/greptile-triage.md, Suppressions Check). */
|
|
export const CATEGORIES = ["race-condition", "null-check", "error-handling", "style", "type-safety", "security", "performance", "correctness", "other"] as const;
|
|
|
|
/** The bot that owns an item's author, or null for humans, other bots and deleted accounts. */
|
|
function ownerOf(item: GhItem, bots: readonly string[]): string | null {
|
|
return item.user?.type === "Bot" ? botOwning(item.user.login, bots) : null;
|
|
}
|
|
|
|
function slug(env: ReviewBotsEnv): string {
|
|
const s = env.run(path.join(env.binDir, "gstack-slug"), ["--get", "SLUG"]);
|
|
if (s.status === 0 && s.stdout.trim()) return s.stdout.trim();
|
|
const origin = env.run("git", ["remote", "get-url", "origin"]);
|
|
return origin.status === 0 ? remoteSlug(origin.stdout.trim()) : "";
|
|
}
|
|
|
|
function historyFiles(env: ReviewBotsEnv, name: string): string[] {
|
|
const root = resolveStateRoot(env.vars);
|
|
const id = slug(env);
|
|
return [...(id ? [path.join(root, "projects", id, name)] : []), path.join(root, name)];
|
|
}
|
|
|
|
/** Rows of one history file; a five-column row is Greptile's (the old format). */
|
|
export function parseHistory(text: string): HistoryRow[] {
|
|
return text.split("\n").flatMap(line => {
|
|
const cells = line.split(" | ").map(c => c.trim());
|
|
if (cells.length !== 5 && cells.length !== 6) return [];
|
|
const [, repo, type, pattern, category, bot] = cells as [string, string, string, string, string, string?];
|
|
return repo && type && pattern && category ? [{ repo, type, pattern, category, bot: (bot || "greptile").toLowerCase() }] : [];
|
|
});
|
|
}
|
|
|
|
/** An exact path, or a glob where `*` stays inside one directory and `**` crosses them. */
|
|
export function patternMatches(pattern: string, file: string): boolean {
|
|
if (pattern === file) return true;
|
|
const re = pattern.split("**").map(part => part.split("*").map(s => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")).join(".*");
|
|
return new RegExp(`^${re}$`).test(file);
|
|
}
|
|
|
|
function readOr(file: string): string {
|
|
try { return fs.readFileSync(file, "utf8"); } catch { return ""; }
|
|
}
|
|
|
|
/** All pages of one REST list, one item per line (no --slurp: gh before 2.48 lacks it), or the reason it could not be read whole. */
|
|
function pages(env: ReviewBotsEnv, endpoint: string): GhItem[] | string {
|
|
const r = env.run("gh", ["api", "--paginate", `${endpoint}?per_page=100`, "--jq", ".[] | tojson"]);
|
|
if (r.status !== 0) return `${endpoint}: ${firstLine(r.stderr)}`;
|
|
try {
|
|
const items: unknown[] = r.stdout.split("\n").filter(l => l.trim()).map(l => JSON.parse(l));
|
|
if (!items.every(i => i !== null && typeof i === "object" && !Array.isArray(i))) return `${endpoint}: not a list of objects`;
|
|
return items as GhItem[];
|
|
} catch { return `${endpoint}: invalid JSON`; }
|
|
}
|
|
|
|
function fetch(env: ReviewBotsEnv, args: string[]): number {
|
|
const skip = (reason: string) => { env.out(`REVIEW_BOTS: skip (${reason})\n`); return 0; };
|
|
const error = (reason: string) => { env.out(`REVIEW_BOTS: error (${reason})\n`); return 0; };
|
|
const lists = botLists(env.vars);
|
|
const bots = [...new Set([...lists.gating, ...lists.advisory])];
|
|
if (bots.length === 0) return skip("pr_wait_gating_bots and pr_wait_advisory_bots are both none");
|
|
if (env.run("gh", ["--version"]).status !== 0) return skip("gh not installed");
|
|
if (env.run("gh", ["auth", "status"]).status !== 0) return skip("gh not logged in");
|
|
const repoRun = env.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
const repo = repoRun.stdout.trim();
|
|
if (repoRun.status !== 0 || !repo) {
|
|
const why = firstLine(repoRun.stderr);
|
|
return NO_GITHUB_REMOTE.test(repoRun.stderr) ? skip(`not a GitHub remote: ${why}`) : error(`repository lookup failed: ${why}`);
|
|
}
|
|
const i = args.indexOf("--pr");
|
|
let pr = i >= 0 ? args[i + 1] ?? "" : "";
|
|
if (!pr) {
|
|
const view = env.run("gh", ["pr", "view", "--json", "number", "--jq", ".number"]);
|
|
if (view.status !== 0) return /no pull requests found/.test(view.stderr) ? skip("no PR yet") : error(`PR lookup failed: ${firstLine(view.stderr)}`);
|
|
pr = view.stdout.trim();
|
|
}
|
|
if (!/^[1-9][0-9]*$/.test(pr)) return error(`'${pr}' is not a PR number`);
|
|
const line = pages(env, `repos/${repo}/pulls/${pr}/comments`);
|
|
const top = pages(env, `repos/${repo}/issues/${pr}/comments`);
|
|
const reviews = pages(env, `repos/${repo}/pulls/${pr}/reviews`);
|
|
for (const r of [line, top, reviews]) if (typeof r === "string") return error(r);
|
|
const records: BotComment[] = [];
|
|
const add = (item: GhItem, bot: string, source: BotComment["source"], extra: Partial<BotComment> = {}) =>
|
|
records.push({ id: item.id, bot, source, url: item.html_url ?? "", body: item.body ?? "", suppress_candidates: [], ...extra });
|
|
for (const c of line as GhItem[]) {
|
|
const bot = ownerOf(c, bots);
|
|
if (bot && c.in_reply_to_id == null && (c.position != null || c.subject_type === "file")) add(c, bot, "line", { path: c.path, line: c.line ?? null });
|
|
}
|
|
for (const c of top as GhItem[]) {
|
|
const bot = ownerOf(c, bots);
|
|
if (bot) add(c, bot, "top");
|
|
}
|
|
const newest = new Map<string, GhItem>();
|
|
for (const r of reviews as GhItem[]) {
|
|
const bot = ownerOf(r, bots);
|
|
if (bot && (r.body ?? "").trim() && (r.submitted_at ?? "") >= (newest.get(bot)?.submitted_at ?? "")) newest.set(bot, r);
|
|
}
|
|
for (const [bot, r] of newest) add(r, bot, "review");
|
|
const id = slug(env);
|
|
const rows = id ? [HISTORY_FILE, LEGACY_HISTORY_FILE].flatMap(name => {
|
|
const rows = parseHistory(readOr(path.join(resolveStateRoot(env.vars), "projects", id, name)));
|
|
return name === LEGACY_HISTORY_FILE ? rows.map(r => ({ ...r, bot: "greptile" })) : rows;
|
|
}) : [];
|
|
for (const rec of records) {
|
|
if (!rec.path) continue;
|
|
rec.suppress_candidates = [...new Set(rows.filter(r => r.type === "fp" && r.repo === repo && r.bot === rec.bot && patternMatches(r.pattern, rec.path!)).map(r => r.category))];
|
|
}
|
|
const dir = fs.mkdtempSync(path.join(env.tmpDir, "gstack-review-bots-"));
|
|
fs.chmodSync(dir, 0o700);
|
|
fs.writeFileSync(path.join(dir, "comments.jsonl"), records.map(r => JSON.stringify(r) + "\n").join(""), { mode: 0o600 });
|
|
env.out(`REVIEW_BOTS_DIR: ${dir}\n`);
|
|
env.out(`REVIEW_BOTS_COUNTS: ${bots.map(b => `${b}=${records.filter(r => r.bot === b).length}`).join(" ")}\n`);
|
|
env.out(`REVIEW_BOTS_SUPPRESSION_CANDIDATES: ${records.filter(r => r.suppress_candidates.length > 0).length}\n`);
|
|
return 0;
|
|
}
|
|
|
|
function record(env: ReviewBotsEnv, args: string[]): number {
|
|
const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
const [dir, id, type, category] = [flag("--dir"), flag("--id"), flag("--type"), flag("--category")];
|
|
const usage = "usage: gstack-review-bots record --dir <REVIEW_BOTS_DIR> --id <comment id> --type fix|fp|already-fixed --category <category>\n";
|
|
if (!dir || !id || !type || !category) { env.err(usage); return 64; }
|
|
if (!/^[1-9][0-9]*$/.test(id)) { env.err("gstack-review-bots: --id must be a comment id from comments.jsonl\n"); return 64; }
|
|
if (!(HISTORY_TYPES as readonly string[]).includes(type)) { env.err(`gstack-review-bots: --type must be one of ${HISTORY_TYPES.join(", ")}\n`); return 64; }
|
|
if (!(CATEGORIES as readonly string[]).includes(category)) { env.err(`gstack-review-bots: --category must be one of ${CATEGORIES.join(", ")}\n`); return 64; }
|
|
const jsonl = path.join(dir, "comments.jsonl");
|
|
if (!fs.existsSync(jsonl)) { env.err(`gstack-review-bots: ${jsonl} is gone; run fetch again and record by the new ids\n`); return 1; }
|
|
let matches: BotComment[];
|
|
try { matches = readOr(jsonl).split("\n").filter(Boolean).map(l => JSON.parse(l) as BotComment).filter(c => String(c.id) === id); }
|
|
catch { env.err(`gstack-review-bots: ${jsonl} is not valid JSONL; run fetch again\n`); return 1; }
|
|
if (matches.length !== 1) { env.err(`gstack-review-bots: ${matches.length ? "more than one" : "no"} comment ${id} in ${jsonl}\n`); return 1; }
|
|
const [bot, file] = [matches[0]!.bot, matches[0]!.path || "top-level"];
|
|
if (/[|*\r\n]/.test(file)) { env.err("gstack-review-bots: the comment's path contains |, * or a line break, so it cannot be a literal history row\n"); return 1; }
|
|
const repoRun = env.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
const repo = repoRun.status === 0 ? repoRun.stdout.trim() : "";
|
|
if (!repo) { env.err(`gstack-review-bots: cannot resolve the GitHub repository: ${firstLine(repoRun.stderr)}\n`); return 1; }
|
|
const row = `${new Date(env.now()).toISOString().slice(0, 10)} | ${repo} | ${type} | ${file} | ${category} | ${bot.toLowerCase()}\n`;
|
|
for (const target of historyFiles(env, HISTORY_FILE)) {
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.appendFileSync(target, row);
|
|
}
|
|
env.out(`REVIEW_BOT_HISTORY: recorded ${type} for ${bot.toLowerCase()}\n`);
|
|
return 0;
|
|
}
|
|
|
|
export function runReviewBots(argv: string[], env: ReviewBotsEnv): number {
|
|
const [cmd, ...rest] = argv;
|
|
if (cmd === "fetch") return fetch(env, rest);
|
|
if (cmd === "record") return record(env, rest);
|
|
env.err("usage: gstack-review-bots {fetch [--pr <n>] | record --dir D --id N --type fix|fp|already-fixed --category C}\n");
|
|
return cmd === "help" || cmd === "--help" ? 0 : 64;
|
|
}
|