mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
7fab2a7139
commit
eac410f7e2
4 files changed
+702
No files matched your search
Executable
+34
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: "" }]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user