Files
gstack/lib/gstack-post.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

392 lines
21 KiB
TypeScript

/**
* 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";
import { parseChangelog } from "./changelog-check";
import { versionWidth } from "./version-source";
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 }
/**
* The repository's own four-part release versions: the root VERSION file's
* value and every version in a root CHANGELOG.md release heading, read with
* the same parser gstack-restamp uses (`## [1.2.3.4]`, `## v1.2.3.4`, an
* optional `- date`; a heading that only mentions a quad is not one). A
* four-part version is byte-for-byte a dotted quad, and `1.91.191.0` is a
* public address, so "(1.91.191.0)" in a PR body raised pii.ip_public
* (MEDIUM), which an unattended /ship cannot confirm. The engine's own version
* exemption reads only the declaration right before the span, so prose that
* merely names a release gets no help from it. These exact strings are
* allowlisted for the scan wherever they appear (the allowlist is exact-string
* and context-free, so `host: <released quad>` is exempt too, a trade the
* repo's own release set is small enough to carry); any other dotted quad
* (8.8.8.8, 203.0.113.7, a version this repo never released) still reports.
* The files are read from the checked-out tree, so a branch that adds a
* four-part heading widens the set for posts made from that checkout.
*/
function repoReleaseVersions(env: PostEnv): string[] {
const top = env.run("git", ["rev-parse", "--show-toplevel"]);
const root = top.status === 0 ? top.stdout.trim() : "";
if (!root) return [];
const read = (name: string) => { try { return env.readFile(`${root}/${name}`); } catch { return ""; } };
const versions = new Set<string>();
const current = read("VERSION").trim();
if (versionWidth(current) === 4) versions.add(current);
for (const s of parseChangelog(read("CHANGELOG.md")).sections) if (s.version && versionWidth(s.version) === 4) versions.add(s.version);
return [...versions];
}
/**
* All-digit identifiers a ship receipt or a CI link names, which pii.phone.e164
* reads as phone numbers: a 12-hex receipt sha that happens to be all digits
* (`tree=704992822996`, `tree 704992822996`, and the receipt's `head=`,
* `gated_tree=` and `predecessor=` keys) and an 11-digit GitHub Actions
* run id as prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`;
* the engine already exempts the id inside the URL itself). Only the exact
* tokens found in those positions are allowlisted, so a bare 11-digit number,
* a 10-digit national number and an E.164 `+14155551234` still report.
*/
function receiptDigitTokens(texts: string[]): string[] {
const tokens = new Set<string>();
for (const text of texts) {
for (const m of text.matchAll(/(?:^|[^\w])(?:tree|gated_tree|head|predecessor)[= ](\d{12})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\brun #?(\d{11})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\/actions\/runs\/(\d{11})\b/g)) tokens.add(m[1]!);
}
return [...tokens];
}
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined, releaseVersions: string[]) {
const found: Located[] = [];
let oversize = false;
const allowlist = [...releaseVersions, ...receiptDigitTokens(parts.map(([, text]) => text))];
for (const [part, text] of parts) {
const result = scan(text, { repoVisibility: vis, allowlist, ...(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 hasQuad = parts.some(([, text]) => /\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text));
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined, hasQuad ? repoReleaseVersions(env) : []);
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;
}
}