mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
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>
133 lines
6.2 KiB
TypeScript
133 lines
6.2 KiB
TypeScript
/**
|
|
* 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: "" }]);
|
|
});
|
|
});
|