Files
gstack/test/greptile-early.test.ts
Garry Tanandcapy-ai[bot] 482ffd1440 v1.92.14.0 feat: a .gstack-project file makes a nested repo or umbrella folder its own project (#2649, #1923) (#3777)
* feat: a .gstack-project marker makes a nested repo or umbrella folder its own project (#2649, #1923)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: marker tests ignore an ambient GSTACK_STATE_ROOT

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (per-repo records ignore every marker, worktree binding in remote-slug and under a git HOME)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): a linked worktree finds its main checkout's marker on its first run

The worktree binding was written only when the main checkout resolved, so a
worktree outside an umbrella resolved first kept its old bucket and later
switched. Walk up from the main checkout on a miss instead (under its own
$HOME rule) and drop the binding file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs/test: pregate user policy names REPO_SLUG; pin greptile consent to REPO_SLUG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): no-clobber checkpoint recovery, unambiguous marker cache keys, ignore unusable folder names; remote-slug resolves once

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): remote-slug reads a quoted marker path; repo mode stays per repository

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): the review log and /ship spend-cap pointer stay with the repository under a marker; recovered checkpoints keep their repository; a linked worktree's own marker stands for its main checkout

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(captain): approvals live under the repository's own slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:57:18 +00:00

235 lines
12 KiB
TypeScript

/**
* /ship's early Greptile review (B2: CEO-8 detection and draft trigger, DX-10
* status lines, ENG-20 public-repo consent, the gate decision that it is on by
* default with a per-user off switch). Drives lib/greptile-early.ts in process
* against fixture repos with a fake gh, so it runs in the Windows lane too.
*/
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { EARLY_BODY, runGreptileEarly, type EarlyEnv } from "../lib/greptile-early";
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "greptile-early-"));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const SHA = "a".repeat(7) + "b".repeat(33);
const DAY = 86_400_000;
const NOW = Date.parse("2026-10-07T12:00:00Z");
interface Call { cmd: string; args: string[]; file?: string }
function fixture(opts: {
files?: Record<string, string>;
visibility?: string;
comments?: Array<{ login: string; created: string }>;
openPrs?: Array<{ number: number; url: string }>;
checks?: Array<{ name: string; status: string; app: string }>;
reviews?: Array<{ login: string; commit: string }>;
summaries?: string[];
config?: string;
} = {}) {
const root = fs.mkdtempSync(path.join(tmp, "repo-"));
const state = fs.mkdtempSync(path.join(tmp, "state-"));
for (const [name, text] of Object.entries(opts.files ?? {})) {
fs.mkdirSync(path.dirname(path.join(root, name)), { recursive: true });
fs.writeFileSync(path.join(root, name), text);
}
if (opts.config) fs.writeFileSync(path.join(state, "config.yaml"), opts.config);
const calls: Call[] = [];
const out: string[] = [];
let clock = NOW;
const ok = (stdout: string) => ({ status: 0, stdout, stderr: "" });
const env: EarlyEnv = {
vars: { GSTACK_HOME: state },
binDir: "/opt/gstack/bin",
bun: "bun",
now: () => clock,
sleep: ms => { clock += ms; },
out: t => { out.push(t); },
err: t => { out.push(t); },
run(cmd, args) {
const call: Call = { cmd, args };
if (cmd === "bun") {
const bodyFile = args[args.indexOf("--body-file") + 1]!;
call.file = fs.readFileSync(bodyFile, "utf8");
}
calls.push(call);
const joined = args.join(" ");
if (cmd === "git" && args[0] === "rev-parse") return ok(root + "\n");
if (cmd === "git" && args[0] === "branch") return ok("feature/x\n");
if (cmd === "git" && args[0] === "remote") return ok("git@github.com:acme/widget.git\n");
if (cmd.endsWith("gstack-slug")) return ok("acme-widget\n");
if (cmd === "gh" && args[0] === "auth") return ok("");
if (cmd === "gh" && joined.startsWith("repo view --json visibility")) return ok((opts.visibility ?? "PRIVATE") + "\n");
if (cmd === "gh" && args[0] === "repo") return ok("{}");
if (cmd === "gh" && joined.includes("/comments?sort=created")) return ok(JSON.stringify(joined.includes("/issues/") ? opts.comments ?? [] : []));
if (cmd === "gh" && joined.startsWith("pr list")) return ok(JSON.stringify(opts.openPrs ?? []));
if (cmd === "gh" && joined.startsWith("pr view")) return ok(SHA + "\n");
if (cmd === "gh" && joined.includes("/check-runs")) return ok(JSON.stringify(opts.checks ?? []));
if (cmd === "gh" && joined.includes("/reviews")) return ok(JSON.stringify(opts.reviews ?? []));
if (cmd === "gh" && joined.includes("/issues/12/comments")) return ok(JSON.stringify(opts.summaries ?? []));
if (cmd === "bun" && args[1] === "pr-create") return ok("REPO_VISIBILITY: private\nhttps://github.com/acme/widget/pull/12\nPOSTED: pr-create\n");
if (cmd === "bun") return ok("POSTED\n");
return { status: 1, stdout: "", stderr: "unexpected" };
},
};
const run = (...argv: string[]) => {
out.length = 0;
const code = runGreptileEarly(argv, env);
return { code, out: out.join("") };
};
const posts = () => calls.filter(c => c.cmd === "bun").map(c => ({ op: c.args[1], args: c.args.slice(2), file: c.file }));
return { root, state, run, posts, calls, advance: (ms: number) => { clock += ms; } };
}
const value = (out: string, key: string) => new RegExp(`^${key}: (.*)$`, "m").exec(out)?.[1];
describe("detection (CEO-8, DX-10)", () => {
test("a .greptile/ folder wins over greptile.json, and only its triggerOnDrafts counts", () => {
const f = fixture({ files: { ".greptile/config.json": '{"strictness": 2}', "greptile.json": '{"triggerOnDrafts": true}' } });
const r = f.run("detect");
expect(value(r.out, "GREPTILE_SIGNAL")).toBe("config-folder");
expect(value(r.out, "GREPTILE_TRIGGER_ON_DRAFTS")).toBe("false");
expect(value(r.out, "GREPTILE_EARLY")).toBe("on");
const g = fixture({ files: { ".greptile/config.json": '{"triggerOnDrafts": true}' } });
expect(value(g.run("detect").out, "GREPTILE_TRIGGER_ON_DRAFTS")).toBe("true");
});
test("greptile.json alone is a signal; a non-boolean triggerOnDrafts is not true", () => {
const f = fixture({ files: { "greptile.json": '{"triggerOnDrafts": "true"}' } });
const r = f.run("detect");
expect(value(r.out, "GREPTILE_SIGNAL")).toBe("greptile.json");
expect(value(r.out, "GREPTILE_TRIGGER_ON_DRAFTS")).toBe("false");
});
test("a Greptile comment counts only within 90 days", () => {
const recent = fixture({ comments: [{ login: "greptile-apps[bot]", created: new Date(NOW - 10 * DAY).toISOString() }] });
expect(value(recent.run("detect").out, "GREPTILE_SIGNAL")).toBe("comment");
const old = fixture({ comments: [{ login: "greptile-apps[bot]", created: new Date(NOW - 120 * DAY).toISOString() }, { login: "octocat", created: new Date(NOW - DAY).toISOString() }] });
const r = old.run("detect");
expect(value(r.out, "GREPTILE_SIGNAL")).toBe("none");
expect(value(r.out, "GREPTILE_EARLY")).toBe("off");
});
test("without Greptile nothing changes", () => {
const r = fixture().run("detect");
expect(value(r.out, "GREPTILE_EARLY")).toBe("off");
expect(r.out).not.toContain("will push");
});
test("the plan line names the signal, the draft push, the wait cap and the off switch", () => {
const r = fixture({ files: { "greptile.json": "{}" } }).run("detect");
expect(r.out).toContain("Greptile: found greptile.json. /ship will push this branch and open a draft PR now");
expect(r.out).toContain("wait up to 10 minutes");
expect(r.out).toContain("Turn this off: /opt/gstack/bin/gstack-config set ship_greptile_early false");
});
test("ship_greptile_early false turns it off and says how to turn it back on", () => {
const r = fixture({ files: { "greptile.json": "{}" }, config: "ship_greptile_early: false\n" }).run("detect");
expect(value(r.out, "GREPTILE_EARLY")).toBe("off");
expect(r.out).toContain("/opt/gstack/bin/gstack-config set ship_greptile_early true");
});
});
describe("public-repo consent (ENG-20)", () => {
test("a public repo asks once; the answer is remembered per repo", () => {
const f = fixture({ files: { "greptile.json": "{}" }, visibility: "PUBLIC" });
let r = f.run("detect");
expect(value(r.out, "GREPTILE_CONSENT")).toBe("ask");
expect(value(r.out, "GREPTILE_EARLY")).toBe("ask");
expect(f.run("consent", "yes").out).toContain("GREPTILE_CONSENT: granted");
expect(JSON.parse(fs.readFileSync(path.join(f.state, "projects/acme-widget/ship-greptile-early.json"), "utf8")).consent).toBe("yes");
expect(f.calls.filter(c => c.cmd.endsWith("gstack-slug")).map(c => c.args)).toContainEqual(["--get", "REPO_SLUG"]);
expect(f.calls.filter(c => c.cmd.endsWith("gstack-slug") && c.args.join(" ") !== "--get REPO_SLUG")).toEqual([]);
r = f.run("detect");
expect(value(r.out, "GREPTILE_CONSENT")).toBe("granted");
expect(value(r.out, "GREPTILE_EARLY")).toBe("on");
f.run("consent", "no");
r = f.run("detect");
expect(value(r.out, "GREPTILE_EARLY")).toBe("off");
expect(value(r.out, "GREPTILE_EARLY_REASON")).toContain("declined");
});
test("unknown visibility asks like public; a private repo needs no consent", () => {
expect(value(fixture({ files: { "greptile.json": "{}" }, visibility: "" }).run("detect").out, "GREPTILE_CONSENT")).toBe("ask");
expect(value(fixture({ files: { "greptile.json": "{}" } }).run("detect").out, "GREPTILE_CONSENT")).toBe("not-needed");
});
});
describe("opening the early PR (CEO-8)", () => {
test("a draft gets one @greptileai comment through gstack-post when Greptile skips drafts", () => {
const f = fixture({ files: { "greptile.json": "{}" } });
const r = f.run("open", "--base", "main", "--title-file", "/t");
expect(r.code).toBe(0);
expect(value(r.out, "EARLY_PR")).toBe("12");
expect(value(r.out, "EARLY_PR_OPENED_AT")).toBe(String(NOW / 1000));
expect(value(r.out, "GREPTILE_TRIGGER")).toBe("posted");
const [create, comment] = f.posts();
expect(create).toMatchObject({ op: "pr-create", file: EARLY_BODY });
expect(create!.args).toEqual(["--base", "main", "--title-file", "/t", "--body-file", expect.any(String), "--draft"]);
expect(comment).toMatchObject({ op: "pr-comment", file: "@greptileai\n" });
expect(comment!.args[0]).toBe("12");
expect(fs.readdirSync(path.join(f.root, ".gstack/tmp"))).toEqual([]);
});
for (const [name, files] of [["greptile.json", { "greptile.json": '{"triggerOnDrafts": true}' }], [".greptile/ folder", { ".greptile/config.json": '{"triggerOnDrafts": true}' }]] as const) {
test(`triggerOnDrafts: true in ${name} posts no trigger comment`, () => {
const f = fixture({ files });
const r = f.run("open", "--base", "main", "--title-file", "/t");
expect(value(r.out, "GREPTILE_TRIGGER")).toBe("not needed (triggerOnDrafts: true)");
expect(f.posts().map(p => p.op)).toEqual(["pr-create"]);
});
}
test("a ready PR is not a draft and needs no trigger", () => {
const f = fixture({ files: { "greptile.json": "{}" } });
f.run("open", "--base", "main", "--title-file", "/t", "--ready");
expect(f.posts()).toHaveLength(1);
expect(f.posts()[0]!.args).not.toContain("--draft");
});
test("an already open PR is left alone", () => {
const f = fixture({ files: { "greptile.json": "{}" }, openPrs: [{ number: 5, url: "https://github.com/acme/widget/pull/5" }] });
const r = f.run("open", "--base", "main", "--title-file", "/t");
expect(r.code).toBe(0);
expect(r.out).toContain("EARLY_PR: none (PR 5 is already open");
expect(f.posts()).toEqual([]);
});
});
describe("waiting for the review (CEO-8 completion signal)", () => {
const since = String(NOW / 1000);
test("a completed Greptile check run on the head commit completes the wait", () => {
const r = fixture({ checks: [{ name: "Greptile Review", status: "completed", app: "greptile-apps" }] }).run("wait", "12", "--since", since);
expect(r.out).toContain("GREPTILE_REVIEW: complete (check run on aaaaaaa)");
});
test("a Greptile review of the head commit, or a summary naming it, completes the wait", () => {
expect(fixture({ reviews: [{ login: "greptile-apps[bot]", commit: SHA }] }).run("wait", "12", "--since", since).out).toContain("complete (review");
expect(fixture({ summaries: ["Greptile Summary ... Last reviewed commit: aaaaaaab"] }).run("wait", "12", "--since", since).out).toContain("complete (summary comment");
});
test("an in-progress check, an older head's review or another bot's comment does not count", () => {
const f = fixture({
checks: [{ name: "Greptile Review", status: "in_progress", app: "greptile-apps" }, { name: "ci", status: "completed", app: "github-actions" }],
reviews: [{ login: "greptile-apps[bot]", commit: "c".repeat(40) }],
summaries: ["Greptile Summary for an older commit"],
});
const r = f.run("wait", "12", "--since", since);
expect(r.out).toContain("GREPTILE_REVIEW: pending");
expect(r.out).toContain("Greptile: waiting for its review of aaaaaaa (");
expect(r.out).toContain("Turn early review off: /opt/gstack/bin/gstack-config set ship_greptile_early false");
});
test("each call returns within its budget, and the wait times out 10 minutes after the PR opened", () => {
const f = fixture();
const polls = () => f.calls.filter(c => c.args.join(" ").includes("/check-runs")).length;
f.run("wait", "12", "--since", since);
expect(polls()).toBe(4); // at 0, 30, 60 and 90 seconds
f.advance(9 * 60_000);
const r = f.run("wait", "12", "--since", since);
expect(r.out).toContain("GREPTILE_REVIEW: timeout");
expect(r.out).not.toContain("GREPTILE_REVIEW: complete");
});
});