Files
gstack/test/gstack-post.test.ts
T
Garry Tanandcapy-ai[bot] 7ee7e8bdd4 v1.91.238.0 fix(ship): gate-diff base in single-branch clones, gstack-post release versions and receipt ids, evidence tests_ran, restamp duplicate headings and stamp prompt (GSTA-102) (#3439)
* fix(gate-diff): resolve the base to origin/<base> when no local branch exists; the no_base fix line fetches into the tracking ref

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

* fix(post): the repo's release versions and a receipt's tree hash / run ids are not pii.ip_public or pii.phone.e164

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

* fix(evidence): parse the shard runner's and the remote pregate lane's test counts into tests_ran

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

* fix(restamp): CHANGELOG_DUPLICATE_HEADING refuses only duplicates the PR introduces

checkChangelog takes the base branch's CHANGELOG: a heading the base already
carries N times may appear N times on the PR (inherited released history), and
only a further copy is a duplicate. gstack-restamp passes origin/<base>'s
CHANGELOG at the plan's base sha, so a base like gbrain's master, which heads
one version twice, can be stamped; a duplicate the PR adds is still refused
with nothing written.

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

* fix(restamp): the stamp-at-merge next: line no longer asks the lane to edit the CHANGELOG

Under a train the stamp commit may hold only restamp's own output: the train's
verify accepts the CHANGELOG only as the base's plus the PR's own entry
re-headed, and a lane that followed the old "write the CHANGELOG entry text"
prompt froze the queue. The claim-at-open wording is unchanged.

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-10-26 09:33:07 +00:00

266 lines
15 KiB
TypeScript

/**
* gstack-post (B4: DX-6 synopsis and exit codes, ENG-9 digest-token
* confirmation and argument safety, CEO-19 the helper owns the scan).
*
* Drives lib/gstack-post.ts in process with a fake runner, so the posting
* logic is covered on Windows too (DX-15). test/gstack-post-cli.test.ts runs
* the real executable against stub gh/glab on POSIX.
*/
import { describe, expect, test } from "bun:test";
import { EXIT, runPost, type PostEnv } from "../lib/gstack-post";
interface Call { cmd: string; args: string[]; input?: string }
function fakeEnv(opts: {
remote?: string;
files?: Record<string, string>;
visibility?: string;
email?: string;
/** The repository root `git rev-parse --show-toplevel` reports; VERSION and CHANGELOG.md are read from `files` under it. */
root?: string;
respond?: (c: Call) => { status: number; stdout?: string; stderr?: string } | undefined;
} = {}) {
const calls: Call[] = [];
const out: string[] = [];
const err: string[] = [];
let title = "";
const env: PostEnv = {
run(cmd, args, input) {
const call = { cmd, args, input };
calls.push(call);
const custom = opts.respond?.(call);
if (custom) return { stdout: "", stderr: "", ...custom };
if (cmd === "git" && args[0] === "remote") return { status: 0, stdout: (opts.remote ?? "git@github.com:acme/widget.git") + "\n", stderr: "" };
if (cmd === "git" && args[0] === "config") return { status: opts.email ? 0 : 1, stdout: (opts.email ?? "") + "\n", stderr: "" };
if (cmd === "git" && args[0] === "rev-parse") return { status: opts.root ? 0 : 128, stdout: (opts.root ?? "") + "\n", stderr: opts.root ? "" : "fatal: not a git repository" };
if (cmd === "cfg") return { status: 0, stdout: (opts.visibility ?? "private") + "\n", stderr: "" };
if (cmd === "gh" && args[0] === "pr" && args[1] === "edit") {
const t = args.find(a => a.startsWith("--title="));
if (t) title = t.slice("--title=".length);
}
if (cmd === "gh" && args[0] === "pr" && args[1] === "view") return { status: 0, stdout: title + "\n", stderr: "" };
return { status: 0, stdout: "", stderr: "" };
},
readFile(p) {
const f = opts.files ?? {};
if (!(p in f)) throw new Error("ENOENT");
return f[p]!;
},
out: t => { out.push(t); },
err: t => { err.push(t); },
};
const posts = () => calls.filter(c => (c.cmd === "gh" || c.cmd === "glab") && !(c.args[0] === "repo" || c.args[1] === "view"));
return { env, calls, posts, stdout: () => out.join(""), stderr: () => err.join("") };
}
const run = (argv: string[], f: ReturnType<typeof fakeEnv>) => runPost(argv, f.env, "cfg");
const token = (stdout: string) => /^TOKEN: ([0-9a-f]+)$/m.exec(stdout)?.[1];
const AWS = "AKIA" + "1234567890ABCDEF";
describe("gstack-post posts through argv, never a shell string", () => {
test("a clean comment goes to gh as an argument array with the body on stdin", () => {
const body = "Fixed in `abc123`. $(touch pwned) \"quoted\" and 'single'\n";
const f = fakeEnv({ files: { "/b": body } });
expect(run(["pr-comment", "12", "--body-file", "/b"], f)).toBe(EXIT.posted);
expect(f.posts()).toEqual([{ cmd: "gh", args: ["pr", "comment", "12", "--body-file=-"], input: body }]);
expect(f.stdout()).toContain("POSTED: pr-comment 12");
});
test("a title of --repo evil/x is posted as text, in --title=<value> form", () => {
const f = fakeEnv({ files: { "/t": "--repo evil/x\n" } });
expect(run(["pr-title", "7", "--title-file", "/t"], f)).toBe(EXIT.posted);
expect(f.posts()[0]).toEqual({ cmd: "gh", args: ["pr", "edit", "7", "--title=--repo evil/x"], input: undefined });
const g = fakeEnv({ files: { "/t": "--repo evil/x", "/b": "body" } });
expect(run(["pr-create", "--base", "main", "--draft", "--title-file", "/t", "--body-file", "/b"], g)).toBe(EXIT.posted);
expect(g.posts()[0]!.args).toEqual(["pr", "create", "--base=main", "--title=--repo evil/x", "--body-file=-", "--draft"]);
});
test("every operation's GitHub and GitLab argv", () => {
const files = { "/t": "v1.2.3 fix: title", "/b": "line one\nline two" };
const gh = (argv: string[]) => { const f = fakeEnv({ files }); expect(run(argv, f)).toBe(0); return f.posts()[0]!; };
expect(gh(["issue-comment", "3", "--body-file", "/b"]).args).toEqual(["issue", "comment", "3", "--body-file=-"]);
expect(gh(["reply", "4", "--to", "991", "--body-file", "/b"]).args).toEqual(["api", "--method=POST", "repos/{owner}/{repo}/pulls/4/comments/991/replies", "--field=body=@-"]);
expect(gh(["pr-body", "5", "--body-file", "/b"])).toEqual({ cmd: "gh", args: ["pr", "edit", "5", "--body-file=-"], input: files["/b"] });
expect(gh(["issue-create", "--title-file", "/t", "--body-file", "/b"]).args).toEqual(["issue", "create", "--title=v1.2.3 fix: title", "--body-file=-"]);
const gl = (argv: string[]) => { const f = fakeEnv({ files, remote: "https://gitlab.com/grp/sub/proj.git" }); expect(run(argv, f)).toBe(0); return f.posts()[0]!; };
expect(gl(["pr-comment", "8", "--body-file", "/b"]).args).toEqual(["mr", "note", "8", "--message=line one\nline two"]);
expect(gl(["pr-body", "8", "--body-file", "/b"]).args).toEqual(["mr", "update", "8", "--description=line one\nline two"]);
expect(gl(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"]).args)
.toEqual(["mr", "create", "--target-branch=main", "--title=v1.2.3 fix: title", "--description=line one\nline two", "--yes"]);
expect(gl(["issue-create", "--title-file", "/t", "--body-file", "/b"]).args).toEqual(["issue", "create", "--title=v1.2.3 fix: title", "--description=line one\nline two", "--yes"]);
});
test("a target is a number or a URL of this repository on the same host", () => {
const files = { "/b": "hello" };
const ok = fakeEnv({ files });
expect(run(["pr-comment", "https://github.com/Acme/widget/pull/42", "--body-file", "/b"], ok)).toBe(0);
expect(ok.posts()[0]!.args[2]).toBe("42");
for (const bad of ["https://evil.example/acme/widget/pull/42", "https://github.com/evil/x/pull/42", "https://github.com/acme/widget/issues/42", "--repo=evil/x", "12abc"]) {
const f = fakeEnv({ files });
expect(run(["pr-comment", bad, "--body-file", "/b"], f)).toBe(EXIT.usage);
expect(f.posts()).toEqual([]);
}
});
test("usage errors exit 64 and post nothing", () => {
for (const argv of [[], ["pr-merge", "1"], ["pr-title", "1", "--body-file", "/b"], ["reply", "1", "--body-file", "/b"], ["pr-comment", "1", "--body-file", "/missing"], ["pr-title", "1", "--title-file", "/two"]]) {
const f = fakeEnv({ files: { "/b": "x", "/two": "a\nb" } });
expect(run(argv, f)).toBe(EXIT.usage);
expect(f.posts()).toEqual([]);
}
});
});
describe("gstack-post owns the redaction scan", () => {
test("a HIGH secret is refused, even with --confirm, and nothing is posted", () => {
const f = fakeEnv({ files: { "/b": `see key ${AWS}\n` } });
expect(run(["pr-comment", "1", "--body-file", "/b"], f)).toBe(EXIT.refused);
expect(f.stdout()).toContain("HIGH RULE: aws.access_key LINE: 1 PART: body");
expect(f.stdout()).not.toContain(AWS);
expect(f.stderr()).toContain("aws.access_key");
const g = fakeEnv({ files: { "/b": `see key ${AWS}\n` } });
expect(run(["pr-comment", "1", "--body-file", "/b", "--confirm", "0".repeat(24)], g)).toBe(EXIT.refused);
expect([...f.posts(), ...g.posts()]).toEqual([]);
});
test("MEDIUM posts nothing until the caller confirms with the token for those bytes", () => {
const files = { "/b": "Ping jane.roe@acme-corp.io about it.\n" };
const first = fakeEnv({ files, visibility: "public" });
expect(run(["issue-comment", "9", "--body-file", "/b"], first)).toBe(EXIT.confirm);
expect(first.stdout()).toContain("REPO_VISIBILITY: public");
expect(first.stdout()).toContain("RULE: pii.email LINE: 1 PART: body");
expect(first.stdout()).not.toContain("jane.roe");
expect(first.posts()).toEqual([]);
const t = token(first.stdout())!;
expect(t).toMatch(/^[0-9a-f]{24}$/);
const second = fakeEnv({ files, visibility: "public" });
expect(run(["issue-comment", "9", "--body-file", "/b", "--confirm", t], second)).toBe(EXIT.posted);
expect(second.posts()).toEqual([{ cmd: "gh", args: ["issue", "comment", "9", "--body-file=-"], input: files["/b"] }]);
});
test("changed bytes with the same rule and line need a new token", () => {
const a = fakeEnv({ files: { "/b": "Ping jane.roe@acme-corp.io about it.\n" } });
expect(run(["pr-comment", "9", "--body-file", "/b"], a)).toBe(EXIT.confirm);
const old = token(a.stdout())!;
const b = fakeEnv({ files: { "/b": "Ping john.doe@acme-corp.io about it.\n" } });
expect(run(["pr-comment", "9", "--body-file", "/b", "--confirm", old], b)).toBe(EXIT.confirm);
expect(b.stdout()).toContain("RULE: pii.email LINE: 1 PART: body");
expect(token(b.stdout())).not.toBe(old);
expect(b.stderr()).toContain("changed since that token");
expect(b.posts()).toEqual([]);
});
test("the token is bound to the destination and operation", () => {
const files = { "/b": "Ping jane.roe@acme-corp.io about it.\n" };
const a = fakeEnv({ files });
run(["pr-comment", "9", "--body-file", "/b"], a);
const t = token(a.stdout())!;
for (const argv of [["pr-comment", "10", "--body-file", "/b"], ["issue-comment", "9", "--body-file", "/b"], ["pr-body", "9", "--body-file", "/b"]]) {
const f = fakeEnv({ files });
expect(run([...argv, "--confirm", t], f)).toBe(EXIT.confirm);
expect(f.posts()).toEqual([]);
}
});
test("the repo's own release versions in prose are not public IPs; other dotted quads still need confirmation", () => {
const repo = {
"/repo/VERSION": "1.91.226.0\n",
"/repo/CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n## [1.91.191.0] - 2026-10-24\n- x\n\n## v1.91.190.0\n\n## [1.128.1.0 mirror]\n",
};
const clean = [
"The receipt is written to a file (1.91.191.0).",
"Since v1.91.226.0 the queue refuses a READY without it.",
"1.91.191.0",
"landed in release 1.91.190.0 and 1.91.226.0",
];
for (const body of clean) {
const f = fakeEnv({ files: { ...repo, "/b": body + "\n" }, root: "/repo", visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
expect(f.posts()).toHaveLength(1);
}
const controls = [
"resolver 8.8.8.8 answered",
"egress is 203.0.113.7 in prod",
"the mirror at 1.128.1.0 is ours",
"host: 1.91.192.0",
];
for (const body of controls) {
const f = fakeEnv({ files: { ...repo, "/b": body + "\n" }, root: "/repo", visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
expect(f.stdout()).toContain("RULE: pii.ip_public LINE: 1 PART: body");
expect(f.posts()).toEqual([]);
}
const elsewhere = fakeEnv({ files: { "/b": "The receipt is written to a file (1.91.191.0).\n" }, visibility: "public" });
expect(run(["pr-comment", "12", "--body-file", "/b"], elsewhere)).toBe(EXIT.confirm);
expect(elsewhere.stdout()).toContain("RULE: pii.ip_public LINE: 1 PART: body");
const noVersion = fakeEnv({ files: { "/repo/VERSION": "2.4.0\n", "/b": "see (1.91.191.0)\n" }, root: "/repo", visibility: "public" });
expect(run(["pr-comment", "12", "--body-file", "/b"], noVersion)).toBe(EXIT.confirm);
});
test("a receipt's all-digit tree hash and GitHub run ids in prose or link text are not phone numbers; real numbers still are", () => {
const clean = [
"SHIP_RECEIPT: pr=3424 head=abc tree=704992822996",
"receipt tree 704992822996 verified",
"head=193687123456 gated_tree=704992822996 predecessor=123456789012 (gate-reuse: eligible)",
"cleared by run 19368712345",
"see [run 19368712345](https://github.com/garrytan/gstack/actions/runs/19368712345) for the gate",
"run #19368712345 is green",
];
for (const body of clean) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
expect(f.posts()).toHaveLength(1);
}
const controls = [
"call +14155551234 for the on-call",
"the id is 19368712345",
"run 4155551234 answered",
"tree=70499282299 is short",
"subtree=704992822996 is not a receipt key",
];
for (const body of controls) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
expect(f.stdout()).toContain("RULE: pii.phone.e164 LINE: 1 PART: body");
expect(f.posts()).toEqual([]);
}
});
test("the title is scanned too, and the pusher's own email is not a finding", () => {
const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } });
expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm);
expect(f.stdout()).toContain("RULE: pii.email LINE: 1 PART: title");
const own = fakeEnv({ files: { "/b": "Ping jane.roe@acme-corp.io" }, email: "jane.roe@acme-corp.io" });
expect(run(["pr-comment", "1", "--body-file", "/b"], own)).toBe(EXIT.posted);
});
});
describe("gstack-post reports CLI failures and repairs known edit failures", () => {
test("a failed gh call exits 3", () => {
const f = fakeEnv({ files: { "/b": "hi" }, respond: c => (c.cmd === "gh" && c.args[0] === "pr" ? { status: 1, stderr: "HTTP 404" } : undefined) });
expect(run(["pr-comment", "1", "--body-file", "/b"], f)).toBe(EXIT.cliFailed);
expect(f.stderr()).toContain("HTTP 404");
});
test("the projectCards GraphQL failure falls back to the REST PATCH with the same bytes", () => {
const f = fakeEnv({ files: { "/b": "new body" }, respond: c => (c.args[1] === "edit" ? { status: 1, stderr: "GraphQL: repository.pullRequest.projectCards is deprecated" } : undefined) });
expect(run(["pr-body", "6", "--body-file", "/b"], f)).toBe(EXIT.posted);
expect(f.posts().at(-1)).toEqual({ cmd: "gh", args: ["api", "--method=PATCH", "repos/{owner}/{repo}/pulls/6", "--field=body=@-", "--silent"], input: "new body" });
});
test("a title that does not read back is retried once, then reported", () => {
let views = 0;
const f = fakeEnv({ files: { "/t": "v2 feat: x" }, respond: c => (c.args[1] === "view" ? { status: 0, stdout: views++ === 0 ? "old\n" : "v2 feat: x\n" } : undefined) });
expect(run(["pr-title", "2", "--title-file", "/t"], f)).toBe(EXIT.posted);
expect(f.posts().filter(c => c.args[1] === "edit")).toHaveLength(2);
const stuck = fakeEnv({ files: { "/t": "v2 feat: x" }, respond: c => (c.args[1] === "view" ? { status: 0, stdout: "old\n" } : undefined) });
expect(run(["pr-title", "2", "--title-file", "/t"], stuck)).toBe(EXIT.cliFailed);
});
test("an unrecognized remote host asks gh for the repository URL", () => {
const f = fakeEnv({ files: { "/b": "hi" }, remote: "https://proxy.internal/acme/widget.git", respond: c => (c.args[0] === "repo" && c.cmd === "gh" ? { status: 0, stdout: "https://github.com/acme/widget\n" } : undefined) });
expect(run(["pr-comment", "https://github.com/acme/widget/pull/3", "--body-file", "/b"], f)).toBe(EXIT.posted);
expect(f.posts()[0]!.args.slice(0, 3)).toEqual(["pr", "comment", "3"]);
});
});