detect-pr-changes.ts 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. #!/usr/bin/env bun
  2. import { appendFile } from 'node:fs/promises'
  3. export type ChangeFlags = {
  4. 'has-evals': boolean
  5. 'has-docs': boolean
  6. 'has-workflows': boolean
  7. 'has-packages': boolean
  8. /**
  9. * Anything that can move the generated trees, and therefore must re-run the drift gate:
  10. * the canonical source, the build that reads it, and the committed output itself.
  11. *
  12. * Separate from `has-packages` on purpose. A content-only edit must run the drift gate but
  13. * has no reason to run the cli/compatibility-layer test suites, and a PR that hand-edits
  14. * `.opencode/agent/**` alone touches no `packages/**` path at all — folding this into
  15. * `has-packages` would either skip the gate on exactly the change it exists to catch, or
  16. * run every package suite on every prose tweak.
  17. */
  18. 'has-canonical': boolean
  19. }
  20. export function parseChangedPaths(input: string): string[] {
  21. return input.split('\0').filter((path) => path.length > 0)
  22. }
  23. export function classifyChangedPaths(paths: readonly string[]): ChangeFlags {
  24. const hasWorkspaceChange = paths.some((path) =>
  25. ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'].includes(path),
  26. )
  27. return {
  28. 'has-evals': hasWorkspaceChange || paths.some((path) => path.startsWith('evals/')),
  29. 'has-docs': paths.some((path) => path.startsWith('docs/')),
  30. 'has-workflows': paths.some((path) => path.startsWith('.github/workflows/')),
  31. 'has-packages': hasWorkspaceChange || paths.some((path) =>
  32. path.startsWith('packages/') ||
  33. path === '.github/dependabot.yml' ||
  34. path === '.github/workflows/packages-checks.yml' ||
  35. path === 'scripts/validation/detect-pr-changes.ts' ||
  36. path === 'scripts/validation/detect-pr-changes.test.ts',
  37. ),
  38. 'has-canonical': hasWorkspaceChange || paths.some((path) =>
  39. // The canonical source.
  40. path.startsWith('content/') ||
  41. // The build that turns it into output.
  42. path.startsWith('packages/') ||
  43. // The committed output — a hand-edit here is precisely what the gate exists to catch.
  44. path.startsWith('.opencode/agent/') ||
  45. path.startsWith('.oac/') ||
  46. path === 'registry.json' ||
  47. // The gate's own machinery.
  48. path === 'Makefile' ||
  49. path === 'scripts/validation/check-build-drift.sh' ||
  50. path === '.github/workflows/packages-checks.yml',
  51. ),
  52. }
  53. }
  54. export function formatGitHubOutput(flags: ChangeFlags): string {
  55. return [
  56. `has-evals=${flags['has-evals']}`,
  57. `has-docs=${flags['has-docs']}`,
  58. `has-workflows=${flags['has-workflows']}`,
  59. `has-packages=${flags['has-packages']}`,
  60. `has-canonical=${flags['has-canonical']}`,
  61. ].join('\n') + '\n'
  62. }
  63. async function main(): Promise<void> {
  64. const outputPath = process.env.GITHUB_OUTPUT
  65. if (!outputPath) throw new Error('GITHUB_OUTPUT is required but was not set')
  66. const paths = parseChangedPaths(await Bun.stdin.text())
  67. const output = formatGitHubOutput(classifyChangedPaths(paths))
  68. await appendFile(outputPath, output, 'utf8').catch((error: unknown) => {
  69. const message = error instanceof Error ? error.message : String(error)
  70. throw new Error(`Unable to append change detection outputs to GITHUB_OUTPUT (${outputPath}): ${message}`)
  71. })
  72. }
  73. if (import.meta.main) {
  74. await main().catch((error: unknown) => {
  75. const message = error instanceof Error ? error.message : String(error)
  76. console.error(`detect-pr-changes: ${message}`)
  77. process.exitCode = 1
  78. })
  79. }