From 195aa4eeda83fdb7fd37c1989a65cc5f79ead3ee Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 6 Oct 2026 20:43:08 +0000 Subject: [PATCH] test(ship): the PR check precedes the Greptile dispatch, and each skip names its reason Runs Step 10's generated check against stub gh for gh missing, not logged in, a non-GitHub remote, no PR, a failed lookup and an existing PR. Fails on the previous text (which printed PR: unknown and dispatched). Co-authored-by: dgrant --- test/ship-greptile-pr-check.test.ts | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/ship-greptile-pr-check.test.ts diff --git a/test/ship-greptile-pr-check.test.ts b/test/ship-greptile-pr-check.test.ts new file mode 100644 index 000000000..c4adb0f34 --- /dev/null +++ b/test/ship-greptile-pr-check.test.ts @@ -0,0 +1,54 @@ +/** + * /ship Step 10 (CEO-19, ENG-1): the PR check runs before the Greptile triage + * dispatch, and every outcome except an existing PR skips the dispatch with + * its reason. The check block is executed from the generated section against + * stub `gh` binaries. + */ +import { afterAll, 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 SECTION = fs.readFileSync(path.join(ROOT, 'ship/sections/greptile.md'), 'utf8'); +const CHECK = SECTION.match(/```bash\n([\s\S]*?)\n```/)![1]; +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ship-greptile-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +/** Run the check with a PATH holding only `head` and, unless null, a stub gh. */ +function check(gh: string | null): string { + const bin = fs.mkdtempSync(path.join(tmp, 'bin-')); + fs.symlinkSync(Bun.which('head')!, path.join(bin, 'head')); + if (gh !== null) fs.writeFileSync(path.join(bin, 'gh'), `#!${Bun.which('bash')}\n${gh}\n`, { mode: 0o755 }); + const r = spawnSync(Bun.which('bash')!, ['-c', CHECK], { cwd: tmp, encoding: 'utf8', timeout: 10_000, env: { PATH: bin } }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); +} + +describe('/ship Step 10 Greptile PR check', () => { + test('the PR check precedes the dispatch, and only an existing PR dispatches', () => { + const checkIdx = SECTION.indexOf('gh pr view --json number'); + const dispatchIdx = SECTION.indexOf('Dispatch a subagent through Agent'); + expect(checkIdx).toBeGreaterThan(-1); + expect(dispatchIdx).toBeGreaterThan(checkIdx); + const between = SECTION.slice(checkIdx, dispatchIdx); + expect(between).toContain('Only `PR: exists` dispatches.'); + expect(between).toContain('"Greptile: not run (); runs on the PR once it exists"'); + expect(SECTION).not.toContain('PR: unknown'); + }); + + const ok = 'exit 0'; + const cases: Array<[string, string | null, string]> = [ + ['gh missing', null, 'PR: skip (gh not installed)'], + ['gh not logged in', `[ "$1" = auth ] && { echo "You are not logged into any GitHub hosts." >&2; exit 1; }; ${ok}`, 'PR: skip (gh not logged in)'], + ['non-GitHub remote', `[ "$1" = repo ] && { echo "none of the git remotes configured for this repository point to a known GitHub host" >&2; exit 1; }; ${ok}`, + 'PR: skip (not a GitHub remote: none of the git remotes configured for this repository point to a known GitHub host)'], + ['no PR yet', `[ "$1" = pr ] && { echo 'no pull requests found for branch "feat"' >&2; exit 1; }; ${ok}`, 'PR: skip (no PR yet)'], + ['other lookup error', `[ "$1" = pr ] && { printf 'HTTP 502: bad gateway\\nretry later\\n' >&2; exit 1; }; ${ok}`, 'PR: skip (PR lookup failed: HTTP 502: bad gateway)'], + ['PR exists', `[ "$1" = pr ] && { echo 12; exit 0; }; ${ok}`, 'PR: exists'], + ]; + for (const [name, gh, expected] of cases) { + test(name, () => expect(check(gh)).toBe(expected)); + } +});