feat(gstack-post): one argv-only, redaction-scanned write path for PR and issue text

bin/gstack-post posts a PR/MR or issue comment, a review-comment reply, a
PR title or body, a new PR/MR or a new issue from agent-written files. Every
value reaches gh/glab as its own argv element in --flag=<value> form or on
stdin, never through a shell string, and a target is a number or a URL of
this repository on the same host.

The helper owns the redaction scan: it reads each file once, scans those
exact bytes (visibility from config, then gh/glab; the pusher's own email
suppressed) and sends the same bytes. HIGH is refused with exit 1 and no
confirmation can post it. MEDIUM exits 2 with RULE:/LINE:/PART: lines and a
TOKEN: digest of the bytes, destination, operation and findings; only
--confirm <token> for those exact bytes posts, so any edit needs a new
confirmation. gh/glab failures exit 3, usage errors 64. It also carries the
projectCards REST fallback and the title read-back that /ship did by hand.

The logic lives in lib/gstack-post.ts behind an injectable runner, so its
tests run in the Windows free lane; the stub-gh executable test is POSIX.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-07 19:55:38 +00:00
1 parent 7fab2a7139
commit eac410f7e2
4 files changed
+702

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bun
/**
* gstack-post — post PR/MR and issue text from agent-written files.
*
* gstack-post {pr-comment|issue-comment|reply|pr-title|pr-body} <target> --body-file F|--title-file F
* gstack-post pr-create --base <branch> [--head <branch>] [--draft] --title-file F --body-file F
* gstack-post issue-create --title-file F --body-file F
*
* Scans the exact bytes it sends (lib/redact-engine.ts) and passes every value
* to gh/glab as an argv element or on stdin, never through a shell string.
* Exit: 0 posted · 1 HIGH refused · 2 MEDIUM needs --confirm <token> ·
* 3 gh/glab failed · 64 usage. Logic and full usage: lib/gstack-post.ts.
*/
import * as fs from "fs";
import * as path from "path";
import { spawnSync } from "child_process";
import { runPost } from "../lib/gstack-post";
const code = runPost(process.argv.slice(2), {
run(cmd, args, input) {
const r = spawnSync(cmd, args, {
input: input ?? "",
encoding: "utf8",
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error) : "") };
},
readFile: (p) => fs.readFileSync(p, "utf8"),
out: (t) => process.stdout.write(t),
err: (t) => process.stderr.write(t),
}, path.join(import.meta.dir, "gstack-config"));
process.exitCode = code;
+338
View File
@@ -0,0 +1,338 @@
/**
* gstack-post: the one write path for PR and issue text (B4).
*
* Every value reaches `gh`/`glab` as its own argv element in `--flag=<value>`
* form, or on stdin, never through a shell string. The text comes from files
* the agent wrote with its file tool; the helper reads each file once, scans
* those exact bytes with the shared redaction engine, and sends the same bytes.
*
* Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused;
* 2 MEDIUM findings need confirmation, printed as `RULE:` lines plus a
* `TOKEN:` that binds the bytes, destination, operation and findings;
* 3 `gh`/`glab` failed; 64 usage or invalid input. Nothing is posted on any
* non-zero exit. bin/gstack-post is the CLI; tests drive `runPost` with a fake
* runner so the logic is covered on every platform.
*/
import { createHash } from "crypto";
import { scan, type Finding, type RepoVisibility } from "./redact-engine";
import { canonicalRemote } from "./remote-identity";
export const OPS = ["pr-comment", "issue-comment", "reply", "pr-title", "pr-body", "pr-create", "issue-create"] as const;
export type Op = (typeof OPS)[number];
export type HostKind = "github" | "gitlab";
export interface RunResult { status: number; stdout: string; stderr: string }
export interface PostEnv {
/** Runs `cmd` with an argv array (no shell). `input` goes to stdin. */
run(cmd: string, args: string[], input?: string): RunResult;
readFile(path: string): string;
out(text: string): void;
err(text: string): void;
}
export const EXIT = { posted: 0, refused: 1, confirm: 2, cliFailed: 3, usage: 64 } as const;
export const USAGE = `usage: gstack-post <op> [<target>] [options]
gstack-post pr-comment <pr> --body-file F
gstack-post issue-comment <issue> --body-file F
gstack-post reply <pr> --to <review-comment-id> --body-file F (GitHub only)
gstack-post pr-title <pr> --title-file F
gstack-post pr-body <pr> --body-file F
gstack-post pr-create --base <branch> [--head <branch>] [--draft] --title-file F --body-file F
gstack-post issue-create --title-file F --body-file F
<pr>/<issue> is a number or a URL of this repository on the same host.
Options: --host github|gitlab (default: detected from the origin remote)
--repo-visibility public|private|unknown (default: config, then gh/glab)
--confirm <token> post bytes whose MEDIUM findings the user confirmed
Write the text files with your file-write tool; never put the text in a
shell command. The helper scans the exact bytes it sends.
Exit: 0 posted · 1 HIGH finding refused · 2 MEDIUM findings need
confirmation (RULE:/TOKEN: lines on stdout) · 3 gh/glab failed · 64 usage
`;
interface Request {
op: Op;
target?: string;
to?: string;
base?: string;
head?: string;
draft: boolean;
titleFile?: string;
bodyFile?: string;
host?: HostKind;
visibility?: RepoVisibility;
confirm?: string;
}
class UsageError extends Error {}
const VALUE_FLAGS = new Set(["--to", "--base", "--head", "--title-file", "--body-file", "--host", "--repo-visibility", "--confirm"]);
function parseArgs(argv: string[]): Request {
const [op, ...rest] = argv;
if (!OPS.includes(op as Op)) throw new UsageError(op ? `unknown operation "${op}"` : "missing operation");
const flags = new Map<string, string>();
const positional: string[] = [];
let draft = false;
for (let i = 0; i < rest.length; i++) {
const a = rest[i]!;
if (a === "--draft") { draft = true; continue; }
const eq = a.indexOf("=");
const name = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
if (VALUE_FLAGS.has(name)) {
const value = name === a ? rest[++i] : a.slice(eq + 1);
if (value === undefined || value === "") throw new UsageError(`${name} needs a value`);
if (flags.has(name)) throw new UsageError(`${name} given twice`);
flags.set(name, value);
continue;
}
if (a.startsWith("-")) throw new UsageError(`unknown option "${a}"`);
positional.push(a);
}
const host = flags.get("--host");
if (host !== undefined && host !== "github" && host !== "gitlab") throw new UsageError("--host must be github or gitlab");
const vis = flags.get("--repo-visibility");
if (vis !== undefined && !["public", "private", "unknown"].includes(vis)) throw new UsageError("--repo-visibility must be public, private or unknown");
const req: Request = {
op: op as Op, draft,
target: positional[0], to: flags.get("--to"), base: flags.get("--base"), head: flags.get("--head"),
titleFile: flags.get("--title-file"), bodyFile: flags.get("--body-file"),
host: host as HostKind | undefined, visibility: vis as RepoVisibility | undefined, confirm: flags.get("--confirm"),
};
const creates = req.op === "pr-create" || req.op === "issue-create";
if (positional.length > (creates ? 0 : 1)) throw new UsageError(`unexpected argument "${positional[creates ? 0 : 1]}"`);
if (!creates && !req.target) throw new UsageError(`${req.op} needs a target (a number or a URL)`);
const needsTitle = req.op === "pr-title" || creates;
const needsBody = req.op !== "pr-title";
if (needsTitle !== !!req.titleFile) throw new UsageError(needsTitle ? `${req.op} needs --title-file` : `${req.op} takes no --title-file`);
if (needsBody !== !!req.bodyFile) throw new UsageError(needsBody ? `${req.op} needs --body-file` : `${req.op} takes no --body-file (use --title-file)`);
if ((req.op === "reply") !== !!req.to) throw new UsageError(req.op === "reply" ? "reply needs --to <review-comment-id>" : "--to applies to reply only");
if (req.to !== undefined && !/^[1-9][0-9]*$/.test(req.to)) throw new UsageError("--to must be a numeric comment id");
if ((req.op === "pr-create") !== !!req.base) throw new UsageError(req.op === "pr-create" ? "pr-create needs --base <branch>" : "--base applies to pr-create only");
if (req.op !== "pr-create" && (req.head || req.draft)) throw new UsageError("--head and --draft apply to pr-create only");
return req;
}
interface Remote { host: string; segments: string[]; kind: HostKind }
function hostedId(url: string) {
const id = canonicalRemote(url.trim());
return id.hosted && id.segments.length >= 2 ? { host: id.canonical.split("/")[0]!, segments: id.segments } : undefined;
}
/**
* The origin remote's host and path. A hostname that names neither GitHub nor
* GitLab (an enterprise host, a proxy) is resolved by asking gh, then glab,
* for the repository URL.
*/
function resolveRemote(env: PostEnv, forced?: HostKind): Remote {
const r = env.run("git", ["remote", "get-url", "origin"]);
const origin = r.status === 0 ? hostedId(r.stdout) : undefined;
if (!origin) throw new UsageError("cannot read a hosted origin remote (git remote get-url origin)");
const named: HostKind | undefined = origin.host.includes("github") ? "github" : origin.host.includes("gitlab") ? "gitlab" : undefined;
if (named && (!forced || forced === named)) return { ...origin, kind: named };
for (const kind of forced ? [forced] : (["github", "gitlab"] as const)) {
const q = kind === "github"
? env.run("gh", ["repo", "view", "--json", "url", "--jq", ".url"])
: env.run("glab", ["repo", "view", "--output", "json"]);
let url = q.status === 0 ? q.stdout : "";
if (kind === "gitlab" && url) { try { url = String(JSON.parse(url).web_url ?? ""); } catch { url = ""; } }
const id = url ? hostedId(url) : undefined;
if (id) return { ...id, kind };
}
if (forced) return { ...origin, kind: forced };
throw new UsageError(`cannot tell whether ${origin.host} is GitHub or GitLab; pass --host github|gitlab`);
}
/** A number, or a URL of this repository on the remote's host, as a number. */
function targetNumber(target: string, op: Op, remote: Remote): string {
if (/^[1-9][0-9]*$/.test(target)) return target;
let url: URL;
try { url = new URL(target); } catch { throw new UsageError(`target "${target}" is neither a number nor a URL`); }
if (url.protocol !== "https:" && url.protocol !== "http:") throw new UsageError("target URL must be http(s)");
if (url.hostname.toLowerCase() !== remote.host) throw new UsageError(`target URL host ${url.hostname} is not this repository's host ${remote.host}`);
const parts = url.pathname.split("/").filter(Boolean);
const issue = op === "issue-comment";
const markers = remote.kind === "github" ? [issue ? "issues" : "pull"] : ["-", issue ? "issues" : "merge_requests"];
const at = parts.findIndex((p, i) => markers.every((m, j) => parts[i + j] === m));
const repo = at < 0 ? [] : parts.slice(0, at);
const num = at < 0 ? undefined : parts[at + markers.length];
const same = repo.length === remote.segments.length && repo.every((s, i) => s.toLowerCase() === remote.segments[i]!.toLowerCase());
if (!same || !num || !/^[1-9][0-9]*$/.test(num)) throw new UsageError(`target URL is not ${issue ? "an issue" : "a pull/merge request"} of ${remote.segments.join("/")}`);
return num;
}
function readText(env: PostEnv, file: string, what: "title" | "body"): string {
let text: string;
try { text = env.readFile(file); } catch { throw new UsageError(`cannot read ${what} file ${file}; write it with your file-write tool first`); }
if (what === "title") {
text = text.replace(/\r?\n$/, "");
if (/[\r\n]/.test(text)) throw new UsageError("title file must hold one line");
}
if (!text.trim()) throw new UsageError(`${what} file ${file} is empty; write the text first`);
return text;
}
function visibility(env: PostEnv, req: Request, kind: HostKind, configBin: string): RepoVisibility {
if (req.visibility) return req.visibility;
const norm = (s: string) => {
const v = s.trim().toLowerCase();
return v === "public" || v === "private" || v === "unknown" ? v : undefined;
};
const cfg = env.run(configBin, ["get", "redact_repo_visibility"]);
const fromConfig = cfg.status === 0 ? norm(cfg.stdout) : undefined;
if (fromConfig) return fromConfig;
if (kind === "github") {
const gh = env.run("gh", ["repo", "view", "--json", "visibility", "--jq", ".visibility"]);
return (gh.status === 0 && norm(gh.stdout)) || "unknown";
}
const gl = env.run("glab", ["repo", "view", "--output", "json"]);
if (gl.status !== 0) return "unknown";
try { return norm(String(JSON.parse(gl.stdout).visibility ?? "")) ?? "unknown"; } catch { return "unknown"; }
}
type Part = "title" | "body";
interface Located { part: Part; finding: Finding }
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined) {
const found: Located[] = [];
let oversize = false;
for (const [part, text] of parts) {
const result = scan(text, { repoVisibility: vis, ...(selfEmail ? { selfEmail } : {}) });
oversize ||= result.oversize;
for (const finding of result.findings) found.push({ part, finding });
}
return { found, oversize };
}
export function confirmationToken(fields: Record<string, unknown>, parts: Array<[Part, string]>, medium: Located[]): string {
const h = createHash("sha256");
h.update(JSON.stringify(fields));
for (const [part, text] of parts) h.update(`\0${part}\0${Buffer.byteLength(text)}\0`).update(text);
h.update(JSON.stringify(medium.map(m => [m.part, m.finding.id, m.finding.line, m.finding.col])));
return h.digest("hex").slice(0, 24);
}
/** The argv that sends one operation; `stdin` carries the body where the CLI reads it from "-". */
function command(kind: HostKind, op: Op, n: string | undefined, req: Request, title?: string, body?: string): { cmd: string; args: string[]; stdin?: string } {
if (kind === "github") {
switch (op) {
case "pr-comment": return { cmd: "gh", args: ["pr", "comment", n!, "--body-file=-"], stdin: body };
case "issue-comment": return { cmd: "gh", args: ["issue", "comment", n!, "--body-file=-"], stdin: body };
case "reply": return { cmd: "gh", args: ["api", "--method=POST", `repos/{owner}/{repo}/pulls/${n}/comments/${req.to}/replies`, "--field=body=@-"], stdin: body };
case "pr-title": return { cmd: "gh", args: ["pr", "edit", n!, `--title=${title}`] };
case "pr-body": return { cmd: "gh", args: ["pr", "edit", n!, "--body-file=-"], stdin: body };
case "pr-create": return { cmd: "gh", args: ["pr", "create", `--base=${req.base}`, ...(req.head ? [`--head=${req.head}`] : []), `--title=${title}`, "--body-file=-", ...(req.draft ? ["--draft"] : [])], stdin: body };
case "issue-create": return { cmd: "gh", args: ["issue", "create", `--title=${title}`, "--body-file=-"], stdin: body };
}
}
switch (op) {
case "pr-comment": return { cmd: "glab", args: ["mr", "note", n!, `--message=${body}`] };
case "issue-comment": return { cmd: "glab", args: ["issue", "note", n!, `--message=${body}`] };
case "pr-title": return { cmd: "glab", args: ["mr", "update", n!, `--title=${title}`] };
case "pr-body": return { cmd: "glab", args: ["mr", "update", n!, `--description=${body}`] };
case "pr-create": return { cmd: "glab", args: ["mr", "create", `--target-branch=${req.base}`, ...(req.head ? [`--source-branch=${req.head}`] : []), `--title=${title}`, `--description=${body}`, "--yes", ...(req.draft ? ["--draft"] : [])] };
case "issue-create": return { cmd: "glab", args: ["issue", "create", `--title=${title}`, `--description=${body}`, "--yes"] };
default: throw new UsageError(`${op} is not supported on GitLab`);
}
}
/** `gh pr edit` can fail on the retired projectCards GraphQL field; the REST PATCH sends the same bytes. */
function restEdit(op: Op, n: string, title?: string, body?: string) {
return op === "pr-title"
? { cmd: "gh", args: ["api", "--method=PATCH", `repos/{owner}/{repo}/pulls/${n}`, `--raw-field=title=${title}`] }
: { cmd: "gh", args: ["api", "--method=PATCH", `repos/{owner}/{repo}/pulls/${n}`, "--field=body=@-", "--silent"], stdin: body };
}
function readBackTitle(env: PostEnv, kind: HostKind, n: string): string | undefined {
const r = kind === "github"
? env.run("gh", ["pr", "view", n, "--json", "title", "--jq", ".title"])
: env.run("glab", ["mr", "view", n, "--output", "json"]);
if (r.status !== 0) return undefined;
if (kind === "github") return r.stdout.replace(/\r?\n$/, "");
try { return String(JSON.parse(r.stdout).title); } catch { return undefined; }
}
function send(env: PostEnv, kind: HostKind, req: Request, n: string | undefined, title?: string, body?: string): number {
let c = command(kind, req.op, n, req, title, body);
let r = env.run(c.cmd, c.args, c.stdin);
if (r.status !== 0 && kind === "github" && (req.op === "pr-title" || req.op === "pr-body") && /projectCards/.test(r.stderr)) {
c = restEdit(req.op, n!, title, body);
r = env.run(c.cmd, c.args, c.stdin);
}
if (r.status !== 0) {
env.err(`gstack-post: ${c.cmd} failed (exit ${r.status}); nothing more was sent.\n${r.stderr}`);
return EXIT.cliFailed;
}
if (req.op === "pr-title") {
for (let attempt = 0; readBackTitle(env, kind, n!) !== title; attempt++) {
if (attempt === 1) {
env.err("gstack-post: the PR title still differs from the title file after one retry.\n");
return EXIT.cliFailed;
}
if (env.run(c.cmd, c.args, c.stdin).status !== 0) {
env.err("gstack-post: retrying the title edit failed.\n");
return EXIT.cliFailed;
}
}
}
if (r.stdout.trim()) env.out(r.stdout.endsWith("\n") ? r.stdout : r.stdout + "\n");
env.out(`POSTED: ${req.op}${n ? ` ${n}` : ""}\n`);
return EXIT.posted;
}
export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config"): number {
if (argv.length === 0 || ["help", "--help", "-h"].includes(argv[0]!)) {
(argv.length === 0 ? env.err : env.out)(USAGE);
return argv.length === 0 ? EXIT.usage : EXIT.posted;
}
try {
const req = parseArgs(argv);
const remote = resolveRemote(env, req.host);
if (req.op === "reply" && remote.kind !== "github") throw new UsageError("reply is supported on GitHub only");
const n = req.target === undefined ? undefined : targetNumber(req.target, req.op, remote);
const title = req.titleFile ? readText(env, req.titleFile, "title") : undefined;
const body = req.bodyFile ? readText(env, req.bodyFile, "body") : undefined;
const parts: Array<[Part, string]> = [];
if (title !== undefined) parts.push(["title", title]);
if (body !== undefined) parts.push(["body", body]);
const vis = visibility(env, req, remote.kind, configBin);
const email = env.run("git", ["config", "user.email"]);
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined);
env.out(`REPO_VISIBILITY: ${vis}\n`);
const line = (m: Located) => `RULE: ${m.finding.id} LINE: ${m.finding.line} PART: ${m.part}\n`;
const high = found.filter(m => m.finding.severity === "HIGH");
if (oversize || high.length) {
for (const m of high) env.out(`HIGH ${line(m)}`);
env.err(oversize
? "gstack-post: refused, the text is too large to scan safely. Nothing was posted.\n"
: `gstack-post: refused, HIGH finding (${[...new Set(high.map(m => m.finding.id))].join(", ")}). Remove the value at its source and rotate it if it is a credential; no confirmation can post it.\n`);
return EXIT.refused;
}
for (const m of found.filter(f => f.finding.severity === "LOW" || f.finding.severity === "WARN")) env.err(`note: ${m.finding.severity} ${line(m)}`);
const medium = found.filter(m => m.finding.severity === "MEDIUM");
if (medium.length) {
const token = confirmationToken(
{ op: req.op, host: remote.kind, repo: [remote.host, ...remote.segments].join("/"), target: n ?? null, to: req.to ?? null, base: req.base ?? null, head: req.head ?? null, draft: req.draft },
parts, medium);
if (req.confirm !== token) {
for (const m of medium) env.out(line(m));
env.out(`TOKEN: ${token}\n`);
env.err(req.confirm
? "gstack-post: the text, destination or findings changed since that token; nothing was posted. Ask about these findings again.\n"
: "gstack-post: MEDIUM findings; nothing was posted. Ask the user about each finding; to post these exact bytes, rerun with --confirm <token>. Any edit needs a new scan.\n");
return EXIT.confirm;
}
}
return send(env, remote.kind, req, n, title, body);
} catch (e) {
if (!(e instanceof UsageError)) throw e;
env.err(`gstack-post: ${e.message} (gstack-post --help shows usage)\n`);
return EXIT.usage;
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* gstack-post executable against stub gh/glab (B4). The stubs record each
* argv element and stdin byte for byte, proving the real spawn layer sends
* values as arguments: shell metacharacters in the text never run, and a
* title that looks like a flag arrives as one `--title=` element.
* POSIX only (shebang stubs); test/gstack-post.test.ts covers the logic on
* Windows.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { spawnSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ROOT = path.resolve(import.meta.dir, "..");
const POST = path.join(ROOT, "bin", "gstack-post");
let tmp = "";
let repo = "";
let stubs = "";
let state = "";
let gitEnv: Record<string, string> = {};
function stub(name: string) {
const file = path.join(stubs, name);
fs.writeFileSync(file, `#!/usr/bin/env bash
log="$STUB_LOG/${name}.$(date +%s%N)"
for a in "$@"; do printf '%s\\0' "$a"; done > "$log.args"
cat > "$log.stdin"
case "$1 $2" in
"repo view") echo "\${STUB_VISIBILITY:-PRIVATE}" ;;
"pr view") cat "$STUB_LOG/title" 2>/dev/null ;;
"pr edit") for a in "$@"; do case "$a" in --title=*) printf '%s\\n' "\${a#--title=}" > "$STUB_LOG/title" ;; esac; done ;;
"pr create") echo "https://github.com/acme/widget/pull/77" ;;
esac
exit 0
`, { mode: 0o755 });
}
function calls(log: string, name: string) {
return fs.readdirSync(log).filter(f => f.startsWith(name + ".") && f.endsWith(".args")).sort().map(f => ({
args: fs.readFileSync(path.join(log, f), "utf8").split("\0").slice(0, -1),
stdin: fs.readFileSync(path.join(log, f.replace(/\.args$/, ".stdin")), "utf8"),
})).filter(c => c.args[1] !== "view");
}
function post(args: string[], extraEnv: Record<string, string> = {}) {
const log = fs.mkdtempSync(path.join(tmp, "log-"));
const r = spawnSync(process.execPath, [POST, ...args], {
cwd: repo, encoding: "utf8", timeout: 30_000,
env: { ...process.env, ...gitEnv, PATH: `${stubs}${path.delimiter}${process.env.PATH}`, STUB_LOG: log, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, ...extraEnv },
});
return { ...r, log };
}
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-post-cli-"));
repo = path.join(tmp, "repo");
stubs = path.join(tmp, "bin");
state = path.join(tmp, "state");
fs.mkdirSync(repo);
fs.mkdirSync(stubs);
fs.mkdirSync(state);
// The fixture ignores the machine's git config: a global url.insteadOf
// rewrite would change what `git remote get-url origin` reports.
fs.writeFileSync(path.join(tmp, "gitconfig"), "");
gitEnv = { GIT_CONFIG_GLOBAL: path.join(tmp, "gitconfig"), GIT_CONFIG_NOSYSTEM: "1" };
const git = (args: string[]) => spawnSync("git", args, { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } });
git(["init", "-q"]);
git(["remote", "add", "origin", "git@github.com:acme/widget.git"]);
git(["config", "user.email", "me@acme-corp.io"]);
stub("gh");
stub("glab");
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
function write(name: string, text: string) {
const file = path.join(repo, name);
fs.writeFileSync(file, text);
return file;
}
describe("gstack-post executable", () => {
test("body bytes arrive on stdin unchanged and nothing in them runs", () => {
const body = "Fixed in `touch pwned-backtick`. $(touch pwned-subst) \"q\" 'single' \\ end\n";
const r = post(["pr-comment", "12", "--body-file", write("body.md", body)]);
expect(r.status).toBe(0);
expect(calls(r.log, "gh")).toEqual([{ args: ["pr", "comment", "12", "--body-file=-"], stdin: body }]);
expect(fs.existsSync(path.join(repo, "pwned-backtick"))).toBe(false);
expect(fs.existsSync(path.join(repo, "pwned-subst"))).toBe(false);
});
test("a title of --repo evil/x is one --title= argument and reads back", () => {
const r = post(["pr-title", "7", "--title-file", write("title.txt", "--repo evil/x\n")]);
expect(r.status).toBe(0);
expect(calls(r.log, "gh")).toEqual([{ args: ["pr", "edit", "7", "--title=--repo evil/x"], stdin: "" }]);
});
test("pr-create prints the new URL", () => {
const r = post(["pr-create", "--base", "main", "--draft", "--title-file", write("t2.txt", "v1.0.0 feat: x"), "--body-file", write("b2.md", "body")]);
expect(r.status).toBe(0);
expect(r.stdout).toContain("https://github.com/acme/widget/pull/77");
expect(calls(r.log, "gh")[0]!.args).toEqual(["pr", "create", "--base=main", "--title=v1.0.0 feat: x", "--body-file=-", "--draft"]);
});
test("HIGH is refused and MEDIUM waits for its token, on a public repo", () => {
const high = post(["pr-comment", "1", "--body-file", write("h.md", "key AKIA" + "1234567890ABCDEF\n")], { STUB_VISIBILITY: "PUBLIC" });
expect(high.status).toBe(1);
expect(calls(high.log, "gh")).toEqual([]);
const file = write("m.md", "cc jane.roe@acme-corp.io\n");
const medium = post(["pr-comment", "1", "--body-file", file], { STUB_VISIBILITY: "PUBLIC" });
expect(medium.status).toBe(2);
expect(medium.stdout).toContain("REPO_VISIBILITY: public");
expect(medium.stdout).toContain("RULE: pii.email LINE: 1 PART: body");
expect(calls(medium.log, "gh")).toEqual([]);
const t = /^TOKEN: (\S+)$/m.exec(medium.stdout)![1]!;
fs.writeFileSync(file, "cc john.doe@acme-corp.io\n");
const changed = post(["pr-comment", "1", "--body-file", file, "--confirm", t], { STUB_VISIBILITY: "PUBLIC" });
expect(changed.status).toBe(2);
expect(calls(changed.log, "gh")).toEqual([]);
const t2 = /^TOKEN: (\S+)$/m.exec(changed.stdout)![1]!;
const sent = post(["pr-comment", "1", "--body-file", file, "--confirm", t2], { STUB_VISIBILITY: "PUBLIC" });
expect(sent.status).toBe(0);
expect(calls(sent.log, "gh")).toEqual([{ args: ["pr", "comment", "1", "--body-file=-"], stdin: "cc john.doe@acme-corp.io\n" }]);
});
test("GitLab sends the body as one --description= argument", () => {
const body = "multi\nline $(id)\n";
const r = post(["pr-body", "4", "--host", "gitlab", "--body-file", write("g.md", body)], { STUB_VISIBILITY: "" });
expect(r.status).toBe(0);
expect(calls(r.log, "glab")).toEqual([{ args: ["mr", "update", "4", `--description=${body}`], stdin: "" }]);
});
});
+198
View File
@@ -0,0 +1,198 @@
/**
* 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;
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 === "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 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"]);
});
});