mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(ship): PR bodies lead with the problem and carry a Blast radius line, and gstack-prose-lint checks the CHANGELOG entry and PR body drafts Absorb lane B (Garry, 2026-10-26: "Do it, send" on plans/absorb-tstack.md item 4 and plans/absorb-pstack.md items 2(a) and 11). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the prose-lint fixtures and NOTICE.md as free-only PR inputs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin prose-lint gate lines and usage exits, use a gstack-native dropped-article example, keep NOTICE.md unmapped Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the CHANGELOG prose-lint gate and the Blast radius body section in the contributor docs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): name the evidence ladder by skill instead of by a runtime-root path that does not exist yet test/runtime-root-assets.test.ts checks every $GSTACK_ROOT/<path> literal in a host render against the staged root; investigate/sections/blast-radius.md lands with its own lane, so the Blast radius item and the Capy body step now say "the /investigate skill's Blast radius section, when present". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the Blast radius section in the two Capy body-order lists The docs re-audit found capy/skill/gstack/SKILL.md (Merge asks) and docs/capy-install.md still listing the Capy PR body as What changed, Why, Approvals; both now match capy-pr.md.tmpl. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the compose block's prose-lint trailer goes to stderr, and the publication-gates fixture stages gstack-prose-lint beside gstack-post test/ship-publication-gates.test.ts runs the real compose block under a HOME whose installed bin dir held only gstack-post, so the block's new gstack-prose-lint call failed with "No such file" on every CI free lane; the fixture now stages both bins the blocks call, and the lint's ok line goes to stderr so the composed body is the only stdout. 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>
196 lines
11 KiB
TypeScript
196 lines
11 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { expectMentions } from './helpers/prompt-structure';
|
|
|
|
const template = readFileSync(new URL('../ship/sections/pr-body.md.tmpl', import.meta.url), 'utf8');
|
|
|
|
/** The agent writes the body drafts with its file-write tool; the block reads them by printed name. */
|
|
function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest = '## Test plan\n- [x] bun test: pass'): (block: string) => string {
|
|
mkdirSync(join(root, '.gstack/tmp'), { recursive: true });
|
|
writeFileSync(join(root, '.gstack/tmp/pr-body-top.abc123'), top);
|
|
writeFileSync(join(root, '.gstack/tmp/pr-body-rest.abc123'), rest);
|
|
return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123');
|
|
}
|
|
const POST = resolve(import.meta.dir, '../bin/gstack-post');
|
|
const PROSE_LINT = resolve(import.meta.dir, '../bin/gstack-prose-lint');
|
|
|
|
/** The two real bins the blocks call, at the installed path under a HOME of `root`. */
|
|
function installBins(root: string): void {
|
|
const bin = join(root, '.claude/skills/gstack/bin');
|
|
mkdirSync(bin, { recursive: true });
|
|
for (const [name, target] of [['gstack-post', POST], ['gstack-prose-lint', PROSE_LINT]] as const) {
|
|
writeFileSync(join(bin, name), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(target)} "$@"\n`, { mode: 0o755 });
|
|
}
|
|
}
|
|
const fenceAfter = (marker: string) => {
|
|
const at = template.indexOf(marker);
|
|
expect(at).toBeGreaterThan(-1);
|
|
const open = template.indexOf('```bash\n', at) + 8;
|
|
return template.slice(open, template.indexOf('\n```', open));
|
|
};
|
|
const composeBlock = template.match(/```bash\n(: "\$\{DOCS_SECTION_FILE:[\s\S]*?)\n```/)?.[1]!;
|
|
const editBlock = fenceAfter('**Existing open PR/MR**');
|
|
const createBlock = fenceAfter('**No open PR/MR:**');
|
|
|
|
/**
|
|
* A repo whose origin is GitHub, a stub gh that records argv and stdin, and
|
|
* the real gstack-post and gstack-prose-lint at the installed path the blocks call.
|
|
*/
|
|
function publicationRepo(root: string) {
|
|
const log = join(root, 'log');
|
|
const stubs = join(root, 'stubs');
|
|
const bin = join(root, '.claude/skills/gstack/bin');
|
|
for (const dir of [log, stubs, bin]) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(root, 'gitconfig'), '');
|
|
const gitEnv = { GIT_CONFIG_GLOBAL: join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
|
|
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) {
|
|
Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 });
|
|
}
|
|
installBins(root);
|
|
writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash
|
|
case "$1 $2" in
|
|
"repo view") echo PRIVATE; exit 0 ;;
|
|
"pr view") cat "${log}/title" 2>/dev/null; exit 0 ;;
|
|
esac
|
|
n=$(ls "${log}" | grep -c args)
|
|
for a in "$@"; do printf '%s\\0' "$a"; done > "${log}/$n.args"
|
|
cat > "${log}/$n.stdin"
|
|
for a in "$@"; do case "$a" in --title=*) printf '%s\\n' "\${a#--title=}" > "${log}/title" ;; esac; done
|
|
[ "$1 $2" = "pr create" ] && echo "https://github.com/o/r/pull/9"
|
|
exit 0
|
|
`, { mode: 0o755 });
|
|
const run = (script: string) => Bun.spawnSync(['bash', '-c', script], {
|
|
cwd: root, env: { ...process.env, ...gitEnv, HOME: root, PATH: `${stubs}:${process.env.PATH}`, DOCS_SECTION_FILE: join(root, 'section.md'), TMPDIR: root },
|
|
stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
|
|
});
|
|
const calls = () => readdirSync(log).filter(f => f.endsWith('.args')).sort().map(f => ({
|
|
args: readFileSync(join(log, f), 'utf8').split('\0').slice(0, -1),
|
|
stdin: readFileSync(join(log, f.replace('.args', '.stdin')), 'utf8'),
|
|
}));
|
|
return { run, calls };
|
|
}
|
|
|
|
/** Compose the body with the real compose block and write the Step 18 title file. */
|
|
function composed(root: string, opts: { title?: string; top?: string } = {}) {
|
|
const repo = publicationRepo(root);
|
|
writeFileSync(join(root, 'section.md'), '**Status:** current — no edits.');
|
|
const compose = repo.run(writeDrafts(root, opts.top)(composeBlock));
|
|
expect(compose.exitCode, compose.stderr.toString()).toBe(0);
|
|
const bodyFile = /^PR_BODY_FILE: (\S+)$/m.exec(compose.stdout.toString())![1]!;
|
|
writeFileSync(join(root, '.gstack/tmp/pr-title.abc123'), (opts.title ?? 'v1.2.3.4 fix: publication checks') + '\n');
|
|
const fill = (block: string) => `PR_BODY_FILE=${JSON.stringify(bodyFile)}\n` +
|
|
block.replace('<title-file-name>', 'pr-title.abc123').replaceAll('<pr-number>', '9').replace('<base>', 'main');
|
|
return { ...repo, bodyFile, publish: (block: string) => repo.run(fill(block)) };
|
|
}
|
|
|
|
// The posting cases drive a POSIX stub gh that gstack-post spawns; Windows CreateProcess cannot
|
|
// exec its shebang. gstack-post's argument-array and token logic run in-process on Windows in
|
|
// test/gstack-post.test.ts.
|
|
const postsThroughStubGh = (failure: string) => process.platform === 'win32' && failure === 'none';
|
|
for (const failure of ['none', 'body', 'title'] as const) {
|
|
test.skipIf(postsThroughStubGh(failure))(`publication posts only scanned bytes through gstack-post: ${failure}`, () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'ship-post-'));
|
|
try {
|
|
const secret = 'AKIA' + '1234567890ABCDEF';
|
|
const c = composed(root, {
|
|
top: failure === 'body' ? `## Summary\nkey ${secret}\n\n## Documentation` : undefined,
|
|
title: failure === 'title' ? `v1.2.3.4 fix: ${secret}` : undefined,
|
|
});
|
|
const result = c.publish(createBlock);
|
|
if (failure === 'none') {
|
|
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
|
expect(result.stdout.toString()).toContain('https://github.com/o/r/pull/9');
|
|
expect(c.calls()).toEqual([{ args: ['pr', 'create', '--base=main', '--title=v1.2.3.4 fix: publication checks', '--body-file=-'], stdin: readFileSync(c.bodyFile, 'utf8') }]);
|
|
} else {
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout.toString()).toContain(`HIGH RULE: aws.access_key LINE: ${failure === 'body' ? 2 : 1} PART: ${failure}`);
|
|
expect(c.calls()).toEqual([]);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
test.skipIf(process.platform === 'win32')('MEDIUM findings publish nothing until the block reruns with the printed token', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'ship-post-medium-'));
|
|
try {
|
|
const c = composed(root, { top: '## Summary\nThanks jane.roe@acme-corp.io\n\n## Documentation' });
|
|
const first = c.publish(editBlock);
|
|
expect(first.exitCode).toBe(2);
|
|
expect(first.stdout.toString()).toContain('RULE: pii.email LINE: 2 PART: body');
|
|
expect(c.calls()).toEqual([]);
|
|
const token = /^TOKEN: (\S+)$/m.exec(first.stdout.toString())![1]!;
|
|
const flagged = 'pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"';
|
|
expect(editBlock).toContain(flagged);
|
|
const then = c.publish(editBlock.replace(flagged, `${flagged} --confirm ${token}`));
|
|
expect(then.exitCode, then.stderr.toString()).toBe(0);
|
|
const calls = c.calls();
|
|
expect(calls.map(call => call.args)).toEqual([
|
|
['pr', 'edit', '9', '--body-file=-'],
|
|
['pr', 'edit', '9', '--title=v1.2.3.4 fix: publication checks'],
|
|
]);
|
|
expect(calls[0]!.stdin).toBe(readFileSync(c.bodyFile, 'utf8'));
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('publication composes the saved documentation section unchanged and refuses to compose without it', () => {
|
|
const block = composeBlock;
|
|
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:'));
|
|
const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
|
|
try {
|
|
installBins(root);
|
|
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
|
|
writeFileSync(join(root, 'section.md'), section);
|
|
const run = (file: string) => Bun.spawnSync(['bash', '-c', `${writeDrafts(root)(compose)}cat -- "$PR_BODY_FILE"`], {
|
|
cwd: root, env: { ...process.env, HOME: root, DOCS_SECTION_FILE: file }, stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
|
|
});
|
|
const composed = run(join(root, 'section.md'));
|
|
expect(composed.exitCode).toBe(0);
|
|
expect(composed.stdout.toString()).toBe(`## Summary\n\n## Documentation\n${section}\n## Test plan\n- [x] bun test: pass\n`);
|
|
expect(existsSync(join(root, '.gstack/tmp/pr-body-top.abc123'))).toBe(false);
|
|
expect(existsSync(join(root, '.gstack/tmp/pr-body-rest.abc123'))).toBe(false);
|
|
const missing = run(join(root, 'absent.md'));
|
|
expect(missing.exitCode).toBe(1);
|
|
expect(missing.stdout.toString()).toBe('');
|
|
rmSync(join(root, '.gstack/tmp'), { recursive: true, force: true });
|
|
const unwritten = Bun.spawnSync(['bash', '-c', `${compose.replace('<body-top-file-name>', 'never-written').replace('<body-rest-file-name>', 'never-written')}cat -- "$PR_BODY_FILE"`], {
|
|
cwd: root, env: { ...process.env, HOME: root, DOCS_SECTION_FILE: join(root, 'section.md') }, stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
|
|
});
|
|
expect(unwritten.exitCode).toBe(1);
|
|
expect(unwritten.stdout.toString()).toBe('');
|
|
expect(unwritten.stderr.toString()).toContain('the body was never written');
|
|
const unset = Bun.spawnSync(['bash', '-c', block], {
|
|
cwd: root, env: { ...process.env, HOME: root, DOCS_SECTION_FILE: '' },
|
|
stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
|
|
});
|
|
expect(unset.exitCode).not.toBe(0);
|
|
expect(unset.stderr.toString()).toContain('Restore the saved Step 14.5 section file path');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('publication reports unavailable triage separately from an empty successful fetch', () => {
|
|
const section = template.slice(template.indexOf('## Greptile Review'), template.indexOf('## Scope Drift'));
|
|
expect(section).toContain('Greptile triage: UNAVAILABLE (dispatch failed)');
|
|
expect(section).toContain('actual reason');
|
|
expect(section).toContain('complete');
|
|
expect(section).toContain('no_pr');
|
|
});
|
|
|
|
test('publication refreshes the open review and title without treating lookup failure as absence', () => {
|
|
const lookup = template.slice(0, template.indexOf('### Resolve Linked Spec'));
|
|
expect(lookup.replace(/\s+/g, ' ')).toContain('Errors or ambiguous matches STOP publication');
|
|
expectMentions(lookup.replace(/\s+/g, ' '), [['fresh body', 'before', 'publishing']], 'lookup.replace(/\s+/g, )');
|
|
expect(lookup).not.toContain('Ship control flow');
|
|
expect(lookup).not.toContain('|| echo "NO_PR"');
|
|
expect(lookup).not.toContain('|| echo "NO_MR"');
|
|
expectMentions(template, [['blocks', 'error', 'exit']], 'template');
|
|
expect(template).toContain('public-strict policy');
|
|
});
|