mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the PR body's machine-read lines into a closing Agent notes section The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Teach /ship to write plain titles, PR openings, commit messages and changelog entries The README now lists what gstack is for, so a PR opening can name the goal it serves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep tests, size budgets and generated files in step with plain titles and Agent notes Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the test-profile list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep text people add below the PR notes, and fix three smaller review findings The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes. Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Mask credentials in refused-title lines and keep older PRs' plan lines readable A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Print a fixed placeholder when a credential in a refused title cannot be masked Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a plain-title policy edit from scheduling every paid eval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the paid-eval list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recheck the PR title on every edit so a base change cannot turn a refused title green Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#1 reader=adversarial-native 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>
134 lines
7.4 KiB
TypeScript
Executable File
134 lines
7.4 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-plain-title — check that a PR title, branch name, PR opening,
|
|
* changelog entry and commit subjects read in everyday words (R76,
|
|
* docs/ship-policy.md#plain-titles). Rules: lib/plain-title.ts; policy:
|
|
* .gstack/plain-titles.json; codes PLAIN_* in docs/troubleshooting.md.
|
|
* Prints one `PLAIN_TITLE: <CODE> <kind> "<match>" — <hint>` line per finding
|
|
* (never the whole title or body) and a last summary line.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { PLAIN_TITLE_POLICY_PATH, checkBranch, checkChangelogEntry, checkCommitSubject, checkOpening, checkTitle, loadPlainTitlePolicy, type PlainFinding, type PlainTitlePolicy } from '../lib/plain-title';
|
|
import { resultLine } from '../lib/result-codes';
|
|
|
|
const HELP = `gstack-plain-title — check that titles, branch names, PR openings and changelog entries read in plain words (docs/ship-policy.md#plain-titles)
|
|
|
|
check [--title <t> | --title-file <f>] [--branch <b>] [--body-file <f>] [--changelog <f>]
|
|
[--commits-file <f>] [--created-at <iso>] [--mode refuse|warn] [--json]
|
|
check each input against ${PLAIN_TITLE_POLICY_PATH}; --commits-file holds one commit
|
|
subject per line and only ever warns; a PR created before the policy's since date is exempt
|
|
validate check ${PLAIN_TITLE_POLICY_PATH} against its schema
|
|
init [--force] write an example ${PLAIN_TITLE_POLICY_PATH}
|
|
|
|
Last line: PLAIN_TITLE: ok | refused=<n> | warned=<n> | exempt (created before <since>) | none (no policy)
|
|
Exit codes: 0 ok, none, exempt or warnings only · 1 refused · 2 usage · 3 invalid policy`;
|
|
|
|
const EXAMPLE = {
|
|
$schema: 'https://github.com/garrytan/gstack/blob/main/docs/ship-policy.md#plain-titles',
|
|
codes: ['\\bPROJ-\\d+\\b'],
|
|
terms: ['an-internal-word'],
|
|
allow: [{ text: 'PROJ-1 SDK', reason: 'a public product name that contains a code' }],
|
|
since: new Date().toISOString().slice(0, 10),
|
|
exempt_branch_prefixes: ['release/'],
|
|
};
|
|
|
|
const VALUE_FLAGS = ['--title', '--title-file', '--branch', '--body-file', '--changelog', '--commits-file', '--created-at', '--mode'];
|
|
|
|
function usage(message: string): number {
|
|
process.stderr.write(`gstack-plain-title: ${message}\n${HELP}\n`);
|
|
return 2;
|
|
}
|
|
|
|
function repoRoot(): string {
|
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
|
|
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd();
|
|
}
|
|
|
|
function loadPolicy(root: string, context: string): PlainTitlePolicy | null | 'invalid' {
|
|
try { return loadPlainTitlePolicy(root); } catch (e: any) {
|
|
process.stderr.write(resultLine(context, 'PLAIN_POLICY_INVALID', e.message) + '\n');
|
|
return 'invalid';
|
|
}
|
|
}
|
|
|
|
const clean = (s: string) => s.replace(/[\u0000-\u001f\u007f"]/g, ' ').slice(0, 80);
|
|
|
|
function check(args: string[]): number {
|
|
const opts: Record<string, string> = {};
|
|
let json = false;
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i]!;
|
|
if (a === '--json') { json = true; continue; }
|
|
if (!VALUE_FLAGS.includes(a)) return usage(`unknown flag ${a}`);
|
|
const v = args[++i];
|
|
if (v === undefined) return usage(`${a} needs a value`);
|
|
opts[a] = v;
|
|
}
|
|
if (opts['--title'] !== undefined && opts['--title-file'] !== undefined) return usage('pass --title or --title-file, not both');
|
|
if (!['--title', '--title-file', '--branch', '--body-file', '--changelog', '--commits-file'].some(f => opts[f] !== undefined)) return usage('nothing to check; pass a title, branch, body, changelog or commits file');
|
|
const mode = opts['--mode'] ?? 'refuse';
|
|
if (mode !== 'refuse' && mode !== 'warn') return usage(`--mode must be refuse or warn, not ${clean(mode)}`);
|
|
const createdAt = opts['--created-at'];
|
|
if (createdAt !== undefined && Number.isNaN(Date.parse(createdAt))) return usage(`--created-at is not a date: ${clean(createdAt)}`);
|
|
const read: Record<string, string> = {};
|
|
for (const f of ['--title-file', '--body-file', '--changelog', '--commits-file']) {
|
|
if (opts[f] === undefined) continue;
|
|
try { read[f] = fs.readFileSync(path.resolve(opts[f]!), 'utf8'); } catch (e: any) { return usage(`cannot read ${f} ${opts[f]}: ${e.code ?? e.message}`); }
|
|
}
|
|
|
|
const policy = loadPolicy(repoRoot(), 'gstack-plain-title check');
|
|
if (policy === 'invalid') return 3;
|
|
const done = (status: string, findings: Array<PlainFinding & { severity: string }> = []) => {
|
|
const refused = findings.filter(f => f.severity === 'refused').length;
|
|
process.stdout.write(json ? JSON.stringify({ status, refused, warned: findings.length - refused, findings }) + '\n' : `PLAIN_TITLE: ${status}\n`);
|
|
return refused ? 1 : 0;
|
|
};
|
|
if (!policy) return done('none (no policy)');
|
|
if (createdAt !== undefined && Date.parse(createdAt) < Date.parse(policy.since)) return done(`exempt (created before ${policy.since})`);
|
|
|
|
const title = opts['--title'] ?? read['--title-file']?.replace(/\r?\n+$/, '');
|
|
const severity = mode === 'warn' ? 'warned' : 'refused';
|
|
const findings = [
|
|
...(title !== undefined ? checkTitle(title, policy) : []),
|
|
...(opts['--branch'] !== undefined ? checkBranch(opts['--branch'], policy) : []),
|
|
...(read['--body-file'] !== undefined ? checkOpening(read['--body-file'], policy) : []),
|
|
...(read['--changelog'] !== undefined ? checkChangelogEntry(read['--changelog'], policy) : []),
|
|
].map(f => ({ ...f, severity }));
|
|
for (const line of (read['--commits-file'] ?? '').split(/\r?\n/)) {
|
|
if (line.trim()) findings.push(...checkCommitSubject(line, policy).map(f => ({ ...f, severity: 'warned' })));
|
|
}
|
|
if (!json) for (const f of findings) process.stdout.write(`PLAIN_TITLE: ${f.code} ${f.kind} "${clean(f.match)}" — ${f.hint}${f.severity === 'warned' ? ' (warning only)' : ''}\n`);
|
|
const refused = findings.filter(f => f.severity === 'refused').length;
|
|
const warned = findings.length - refused;
|
|
return done(refused ? `refused=${refused}${warned ? ` warned=${warned}` : ''}` : warned ? `warned=${warned}` : 'ok', findings);
|
|
}
|
|
|
|
function main(argv: string[]): number {
|
|
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return 0; }
|
|
const [cmd, ...rest] = argv;
|
|
if (cmd === 'check') return check(rest);
|
|
if (cmd === 'validate') {
|
|
if (rest.length) return usage(`validate takes no arguments`);
|
|
const policy = loadPolicy(repoRoot(), 'gstack-plain-title validate');
|
|
if (policy === 'invalid') return 3;
|
|
process.stdout.write(policy
|
|
? `PLAIN_TITLE: valid ${PLAIN_TITLE_POLICY_PATH} (codes=${policy.codes.length} terms=${policy.terms.length} allow=${policy.allow.length} since=${policy.since})\n`
|
|
: 'PLAIN_TITLE: none (no policy)\n');
|
|
return 0;
|
|
}
|
|
if (cmd === 'init') {
|
|
if (rest.some(a => a !== '--force')) return usage('init takes only --force');
|
|
const file = path.join(repoRoot(), PLAIN_TITLE_POLICY_PATH);
|
|
if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(`gstack-plain-title: ${PLAIN_TITLE_POLICY_PATH} exists (pass --force to overwrite)\n`); return 1; }
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, JSON.stringify(EXAMPLE, null, 2) + '\n');
|
|
process.stdout.write(`PLAIN_TITLE: wrote ${PLAIN_TITLE_POLICY_PATH}; list your tracker codes and project-only words, then run gstack-plain-title validate\n`);
|
|
return 0;
|
|
}
|
|
return usage(cmd ? `unknown command ${clean(cmd)}` : 'no command');
|
|
}
|
|
|
|
process.exitCode = main(process.argv.slice(2));
|