detect-pr-changes.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { describe, expect, test } from 'bun:test'
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import {
  6. classifyChangedPaths,
  7. formatGitHubOutput,
  8. parseChangedPaths,
  9. } from './detect-pr-changes'
  10. const NO_CHANGES = {
  11. 'has-evals': false,
  12. 'has-docs': false,
  13. 'has-workflows': false,
  14. 'has-packages': false,
  15. 'has-canonical': false,
  16. }
  17. const SCRIPT_PATH = join(import.meta.dir, 'detect-pr-changes.ts')
  18. const WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'pr-checks.yml')
  19. const PACKAGES_WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'packages-checks.yml')
  20. const DRIFT_SCRIPT_PATH = join(import.meta.dir, 'check-build-drift.sh')
  21. type CliResult = {
  22. exitCode: number
  23. stderr: string
  24. stdout: string
  25. }
  26. async function runCli(input: string, outputPath?: string): Promise<CliResult> {
  27. const env = { ...process.env }
  28. if (outputPath === undefined) delete env.GITHUB_OUTPUT
  29. else env.GITHUB_OUTPUT = outputPath
  30. const subprocess = Bun.spawn([process.execPath, 'run', SCRIPT_PATH], {
  31. env,
  32. stderr: 'pipe',
  33. stdin: new Blob([input]),
  34. stdout: 'pipe',
  35. })
  36. const [exitCode, stderr, stdout] = await Promise.all([
  37. subprocess.exited,
  38. new Response(subprocess.stderr).text(),
  39. new Response(subprocess.stdout).text(),
  40. ])
  41. return { exitCode, stderr, stdout }
  42. }
  43. async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
  44. const directory = await mkdtemp(join(tmpdir(), 'detect-pr-changes-'))
  45. try {
  46. return await run(directory)
  47. } finally {
  48. await rm(directory, { force: true, recursive: true })
  49. }
  50. }
  51. describe('classifyChangedPaths', () => {
  52. test('detects eval changes', () => {
  53. expect(classifyChangedPaths(['evals/framework/src/index.ts'])).toEqual({
  54. ...NO_CHANGES,
  55. 'has-evals': true,
  56. })
  57. })
  58. test('detects docs changes', () => {
  59. expect(classifyChangedPaths(['docs/maintenance/guide.md'])).toEqual({
  60. ...NO_CHANGES,
  61. 'has-docs': true,
  62. })
  63. })
  64. test('detects workflow changes', () => {
  65. expect(classifyChangedPaths(['.github/workflows/pr-checks.yml'])).toEqual({
  66. ...NO_CHANGES,
  67. 'has-workflows': true,
  68. })
  69. })
  70. test('detects packages changes', () => {
  71. expect(classifyChangedPaths(['packages/cli/src/index.ts'])).toEqual({
  72. ...NO_CHANGES,
  73. 'has-packages': true,
  74. 'has-canonical': true,
  75. })
  76. expect(classifyChangedPaths(['packages/compatibility-layer/package.json'])).toEqual({
  77. ...NO_CHANGES,
  78. 'has-packages': true,
  79. 'has-canonical': true,
  80. })
  81. })
  82. test('detects shared workspace dependency changes', () => {
  83. for (const path of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
  84. expect(classifyChangedPaths([path])).toEqual({
  85. ...NO_CHANGES,
  86. 'has-evals': true,
  87. 'has-packages': true,
  88. 'has-canonical': true,
  89. })
  90. }
  91. })
  92. test('detects package automation changes', () => {
  93. for (const path of [
  94. '.github/dependabot.yml',
  95. '.github/workflows/packages-checks.yml',
  96. 'scripts/validation/detect-pr-changes.ts',
  97. 'scripts/validation/detect-pr-changes.test.ts',
  98. ]) {
  99. expect(classifyChangedPaths([path])).toEqual({
  100. ...NO_CHANGES,
  101. 'has-packages': true,
  102. ...(path.startsWith('.github/workflows/') ? { 'has-workflows': true } : {}),
  103. ...(path === '.github/workflows/packages-checks.yml' ? { 'has-canonical': true } : {}),
  104. })
  105. }
  106. })
  107. test('routes canonical source changes to the drift gate', () => {
  108. for (const path of ['content/agents/core/openagent.md', 'content/profiles/default.yaml']) {
  109. expect(classifyChangedPaths([path])).toEqual({
  110. ...NO_CHANGES,
  111. 'has-canonical': true,
  112. })
  113. }
  114. })
  115. test('routes generated-tree changes to the drift gate', () => {
  116. // A PR that hand-edits generated output touches no packages/** path at all. If these did
  117. // not set has-canonical, the gate would skip exactly the change it exists to catch.
  118. for (const path of [
  119. '.opencode/agent/core/openagent.md',
  120. '.oac/build-manifest.json',
  121. 'registry.json',
  122. ]) {
  123. expect(classifyChangedPaths([path])).toEqual({
  124. ...NO_CHANGES,
  125. 'has-canonical': true,
  126. })
  127. }
  128. })
  129. test('routes drift-gate machinery changes to the drift gate', () => {
  130. for (const path of ['Makefile', 'scripts/validation/check-build-drift.sh']) {
  131. expect(classifyChangedPaths([path])).toEqual({
  132. ...NO_CHANGES,
  133. 'has-canonical': true,
  134. })
  135. }
  136. })
  137. test('rejects near-prefix paths for canonical routing', () => {
  138. expect(
  139. classifyChangedPaths([
  140. 'content-old/agents/x.md',
  141. 'nested/content/agents/x.md',
  142. 'contents/x.md',
  143. '.opencode/command/add-context.md',
  144. 'registry.json.bak',
  145. 'Makefile.old',
  146. ]),
  147. ).toEqual(NO_CHANGES)
  148. })
  149. test('detects mixed changes', () => {
  150. expect(
  151. classifyChangedPaths([
  152. 'evals/framework/package.json',
  153. 'docs/README.md',
  154. '.github/workflows/release.yml',
  155. 'packages/cli/package.json',
  156. 'src/index.ts',
  157. ]),
  158. ).toEqual({
  159. 'has-evals': true,
  160. 'has-docs': true,
  161. 'has-workflows': true,
  162. 'has-packages': true,
  163. 'has-canonical': true,
  164. })
  165. })
  166. test('returns false for every category when paths do not match', () => {
  167. expect(classifyChangedPaths(['README.md', 'scripts/check.ts'])).toEqual(NO_CHANGES)
  168. })
  169. test('returns false for every category when input is empty', () => {
  170. expect(classifyChangedPaths([])).toEqual(NO_CHANGES)
  171. })
  172. test('rejects near-prefix paths', () => {
  173. expect(
  174. classifyChangedPaths([
  175. 'evals-old/test.ts',
  176. 'docs.md',
  177. '.github/workflows-old/check.yml',
  178. 'nested/evals/test.ts',
  179. 'packages-old/cli/index.ts',
  180. 'packages.json',
  181. 'nested/packages/cli/index.ts',
  182. ]),
  183. ).toEqual(NO_CHANGES)
  184. })
  185. })
  186. describe('parseChangedPaths', () => {
  187. test('preserves path identity and removes empty NUL records', () => {
  188. expect(parseChangedPaths(' evals/test.ts \0\0docs/雪\n$HOME; file.md\0')).toEqual([
  189. ' evals/test.ts ',
  190. 'docs/雪\n$HOME; file.md',
  191. ])
  192. })
  193. })
  194. describe('formatGitHubOutput', () => {
  195. test('formats newline-delimited GitHub outputs', () => {
  196. expect(
  197. formatGitHubOutput({
  198. 'has-evals': true,
  199. 'has-docs': false,
  200. 'has-workflows': true,
  201. 'has-packages': false,
  202. 'has-canonical': false,
  203. }),
  204. ).toBe(
  205. 'has-evals=true\nhas-docs=false\nhas-workflows=true\nhas-packages=false\n' +
  206. 'has-canonical=false\n',
  207. )
  208. })
  209. })
  210. describe('CLI', () => {
  211. test('appends outputs to GITHUB_OUTPUT', async () => {
  212. await withTempDir(async (directory) => {
  213. const outputPath = join(directory, 'github-output')
  214. await writeFile(outputPath, 'existing=value\n')
  215. const result = await runCli('evals/test.ts\0', outputPath)
  216. expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '' })
  217. expect(await readFile(outputPath, 'utf8')).toBe(
  218. 'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\n' +
  219. 'has-packages=false\nhas-canonical=false\n',
  220. )
  221. })
  222. })
  223. test('writes false outputs for empty stdin', async () => {
  224. await withTempDir(async (directory) => {
  225. const outputPath = join(directory, 'github-output')
  226. const result = await runCli('', outputPath)
  227. expect(result.exitCode).toBe(0)
  228. expect(await readFile(outputPath, 'utf8')).toBe(
  229. 'has-evals=false\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n' +
  230. 'has-canonical=false\n',
  231. )
  232. })
  233. })
  234. test('fails clearly when GITHUB_OUTPUT is missing', async () => {
  235. const result = await runCli('evals/test.ts\0')
  236. expect(result.exitCode).not.toBe(0)
  237. expect(result.stderr).toContain('GITHUB_OUTPUT is required but was not set')
  238. })
  239. test('writes an error to stderr and exits nonzero for an unwritable target', async () => {
  240. await withTempDir(async (directory) => {
  241. const outputPath = join(directory, 'output-directory')
  242. await mkdir(outputPath)
  243. const result = await runCli('docs/guide.md\0', outputPath)
  244. expect(result.exitCode).not.toBe(0)
  245. expect(result.stderr).toContain('Unable to append change detection outputs to GITHUB_OUTPUT')
  246. })
  247. })
  248. test('handles NUL-delimited Unicode and metacharacter filenames without changing identity', async () => {
  249. await withTempDir(async (directory) => {
  250. const outputPath = join(directory, 'github-output')
  251. const paths = [
  252. 'evals/ leading and trailing .ts ',
  253. 'docs/雪\n$HOME;$(touch never).md',
  254. '.github/workflows/[check]& weird.yml',
  255. 'packages/cli/src/ spaced 雪.ts',
  256. ]
  257. const result = await runCli(`${paths.join('\0')}\0`, outputPath)
  258. expect(result.exitCode).toBe(0)
  259. expect(await readFile(outputPath, 'utf8')).toBe(
  260. 'has-evals=true\nhas-docs=true\nhas-workflows=true\nhas-packages=true\n' +
  261. 'has-canonical=true\n',
  262. )
  263. })
  264. })
  265. test('does not trim leading whitespace into a matching path', async () => {
  266. await withTempDir(async (directory) => {
  267. const outputPath = join(directory, 'github-output')
  268. const result = await runCli(' evals/not-under-evals.ts\0', outputPath)
  269. expect(result.exitCode).toBe(0)
  270. expect(await readFile(outputPath, 'utf8')).toContain('has-evals=false\n')
  271. })
  272. })
  273. })
  274. describe('PR checks workflow contract', () => {
  275. test('uses the NUL-delimited detector and exact output names', async () => {
  276. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  277. expect(workflow).toContain('git diff --name-only -z')
  278. expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
  279. expect(workflow).toContain('has-evals: ${{ steps.filter.outputs.has-evals }}')
  280. expect(workflow).toContain('has-docs: ${{ steps.filter.outputs.has-docs }}')
  281. expect(workflow).toContain('has-workflows: ${{ steps.filter.outputs.has-workflows }}')
  282. expect(workflow).not.toMatch(/steps\.filter\.outputs\.(evals|docs|workflows)(?:\s|})/)
  283. expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
  284. expect(workflow).toContain('bun-version: 1.3.14')
  285. })
  286. test('requires successful change detection before reporting overall success', async () => {
  287. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  288. const overallStatus = workflow.slice(workflow.indexOf('# Overall status'))
  289. expect(overallStatus).toContain('needs.check-changes.result }}" == "success"')
  290. expect(overallStatus).toContain('needs.check-changes.outputs.has-evals }}" != "true"')
  291. expect(overallStatus).toContain('exit 1')
  292. })
  293. })
  294. describe('Packages checks workflow contract', () => {
  295. test('uses the NUL-delimited detector and the has-packages output', async () => {
  296. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  297. expect(workflow).toContain('git diff --name-only -z')
  298. expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
  299. expect(workflow).toContain('has-packages: ${{ steps.filter.outputs.has-packages }}')
  300. expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
  301. expect(workflow).toContain('bun-version: 1.3.14')
  302. })
  303. test('gates every package check job on the has-packages flag', async () => {
  304. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  305. expect(workflow).toContain('cli-checks:')
  306. expect(workflow).toContain('compatibility-layer-checks:')
  307. const gates = workflow.match(/needs\.check-changes\.outputs\.has-packages == 'true'/g) ?? []
  308. expect(gates.length).toBeGreaterThanOrEqual(2)
  309. })
  310. test('triggers on packages/** pull request changes', async () => {
  311. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  312. expect(workflow).toContain('pull_request:')
  313. expect(workflow).toContain("- 'packages/**'")
  314. })
  315. })
  316. describe('Canonical drift gate contract', () => {
  317. test('triggers on the canonical source and the generated trees', async () => {
  318. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  319. for (const path of [
  320. "- 'content/**'",
  321. "- '.opencode/agent/**'",
  322. "- '.oac/**'",
  323. "- 'registry.json'",
  324. "- 'Makefile'",
  325. "- 'scripts/validation/check-build-drift.sh'",
  326. ]) {
  327. expect(workflow).toContain(path)
  328. }
  329. })
  330. test('gates the drift job on has-canonical and runs the same make target as contributors', async () => {
  331. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  332. expect(workflow).toContain('canonical-drift:')
  333. expect(workflow).toContain('has-canonical: ${{ steps.filter.outputs.has-canonical }}')
  334. expect(workflow).toContain("needs.check-changes.outputs.has-canonical == 'true'")
  335. expect(workflow).toContain('run: make check-canonical-drift')
  336. })
  337. test('is blocking — never continue-on-error, and the summary fails on it', async () => {
  338. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  339. // A gate that cannot fail the PR reads as coverage while providing none. Match the YAML
  340. // key rather than the bare phrase, which also appears in the job's own comment.
  341. expect(workflow).not.toMatch(/^\s*continue-on-error:\s*true/m)
  342. expect(workflow).toContain('needs: [check-changes, cli-checks, compatibility-layer-checks, canonical-drift]')
  343. expect(workflow).toContain('"${{ needs.canonical-drift.result }}"')
  344. const summary = workflow.slice(workflow.indexOf(' summary:'))
  345. expect(summary).toContain('exit 1')
  346. })
  347. test('the drift script rebuilds and compares the committed generated trees', async () => {
  348. const script = await readFile(DRIFT_SCRIPT_PATH, 'utf8')
  349. // A write build, not `--check`: check() never compares the manifest, which is committed.
  350. expect(script).toContain('bun packages/cli/dist/index.js build')
  351. expect(script).toContain('.oac/build-manifest.json')
  352. expect(script).toContain('git status --porcelain')
  353. expect(script).toContain('exit 1')
  354. // plugins/claude-code/** is staged and compared, never emitted — not this gate's business.
  355. expect(script).not.toContain('plugins/claude-code/agents)')
  356. })
  357. })