diff --git a/.github/scripts/pr-test-loc-summary.mjs b/.github/scripts/pr-test-loc-summary.mjs new file mode 100644 index 00000000000..f7fa2a67bb0 --- /dev/null +++ b/.github/scripts/pr-test-loc-summary.mjs @@ -0,0 +1,228 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs' + +export const PR_FILES_PAGE_LIMIT = 3000 + +export function nextLink(linkHeader) { + if (linkHeader == null || linkHeader.length === 0) { + return undefined + } + + for (const part of linkHeader.split(',')) { + const match = part.match(/<([^>]+)>\s*;\s*rel="next"/) + if (match != null) { + return match[1] + } + } + + return undefined +} + +function githubHeaders(token) { + return { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'orca-pr-test-loc', + 'X-GitHub-Api-Version': '2022-11-28' + } +} + +export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) { + const files = [] + let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100` + + while (url != null) { + const response = await fetchImpl(url, { headers: githubHeaders(token) }) + if (!response.ok) { + throw new Error( + `Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}` + ) + } + const page = await response.json() + if (!Array.isArray(page)) { + throw new Error(`Unexpected PR files payload for #${pullNumber}`) + } + files.push(...page) + if (files.length >= PR_FILES_PAGE_LIMIT) { + console.log( + `PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.` + ) + return files.slice(0, PR_FILES_PAGE_LIMIT) + } + url = nextLink(response.headers.get('link')) + } + + return files +} + +function writeGithubOutput(totals) { + const block = `${renderLocBlock(totals)}\n` + const outputPath = process.env.GITHUB_OUTPUT + if (outputPath != null) { + appendFileSync(outputPath, `summary< | --update-pr [--files-json ] [--merge-body ]` + ) +} + +async function main(argv) { + let filesJsonPath + let mergeBodyPath + let fromPrNumber + let updatePrNumber + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--files-json') { + filesJsonPath = argv[i + 1] + i += 1 + continue + } + if (arg === '--merge-body') { + mergeBodyPath = argv[i + 1] + i += 1 + continue + } + if (arg === '--from-pr') { + fromPrNumber = argv[i + 1] + i += 1 + continue + } + if (arg === '--update-pr') { + updatePrNumber = argv[i + 1] + i += 1 + continue + } + printUsage() + return 2 + } + + const pullNumber = updatePrNumber ?? fromPrNumber + if ( + (argv.includes('--files-json') && filesJsonPath == null) || + (argv.includes('--merge-body') && mergeBodyPath == null) || + (argv.includes('--from-pr') && fromPrNumber == null) || + (argv.includes('--update-pr') && updatePrNumber == null) || + (filesJsonPath == null && pullNumber == null) + ) { + printUsage() + return 2 + } + + let files + if (filesJsonPath != null) { + files = JSON.parse(readFileSync(filesJsonPath, 'utf8')) + } else { + const repository = resolveRepository() + const token = resolveToken() + if (repository == null || token == null) { + console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.') + return 2 + } + files = await listPullFiles({ + ...repository, + pullNumber: Number(pullNumber), + token + }) + } + + const totals = sumChangedFiles(files) + writeGithubOutput(totals) + + if (mergeBodyPath != null) { + process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals)) + return 0 + } + + console.log(renderLocBlock(totals)) + + if (updatePrNumber == null) { + return 0 + } + + const repository = resolveRepository() + const token = resolveToken() + if (repository == null || token == null) { + console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.') + return 2 + } + + return updatePullRequest({ + ...repository, + pullNumber: Number(updatePrNumber), + token, + totals + }) +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)) + .then((code) => { + process.exitCode = code + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/.github/scripts/pr-test-loc-table.mjs b/.github/scripts/pr-test-loc-table.mjs new file mode 100644 index 00000000000..2d0c73a4f1c --- /dev/null +++ b/.github/scripts/pr-test-loc-table.mjs @@ -0,0 +1,86 @@ +export const LOC_BLOCK_START = '' +export const LOC_BLOCK_END = '' +export const LOC_HANDS_OFF_COMMENT = + '' + +const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i +const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i + +export function isTestPath(path) { + const normalized = path.replaceAll('\\', '/') + return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized) +} + +export function emptyLocTotals() { + return { + test: { files: 0, added: 0, deleted: 0 }, + nonTest: { files: 0, added: 0, deleted: 0 } + } +} + +export function sumChangedFiles(files) { + const totals = emptyLocTotals() + for (const file of files) { + const path = file.filename + if (path == null) { + continue + } + const bucket = isTestPath(path) ? totals.test : totals.nonTest + bucket.files += 1 + bucket.added += Number(file.additions ?? 0) + bucket.deleted += Number(file.deletions ?? 0) + } + return totals +} + +function signed(count) { + if (count === 0) { + return '0' + } + return count > 0 ? `+${count}` : `−${Math.abs(count)}` +} + +function locTableRow(label, bucket) { + return `| ${label} | ${bucket.files ?? 0} | ${signed(bucket.added)} | ${signed(-(bucket.deleted ?? 0))} | ${signed((bucket.added ?? 0) - (bucket.deleted ?? 0))} |` +} + +export function formatLocTable({ test, nonTest }) { + return [ + '| | Files | Added | Deleted | Net |', + '| :--- | ---: | ---: | ---: | ---: |', + locTableRow('Test', test), + locTableRow('Prod', nonTest) + ].join('\n') +} + +export function renderLocBlock(totals) { + return [ + LOC_BLOCK_START, + LOC_HANDS_OFF_COMMENT, + '', + formatLocTable(totals), + '', + LOC_BLOCK_END + ].join('\n') +} + +export function mergeLocBlock(body, totals) { + const block = renderLocBlock(totals) + const current = body ?? '' + const start = current.indexOf(LOC_BLOCK_START) + const end = current.indexOf(LOC_BLOCK_END) + + if (start !== -1 && end !== -1 && end > start) { + const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '') + if (rest.trim().length === 0) { + return `${current.slice(0, start)}${block}\n` + } + return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}` + } + + if (current.trim().length === 0) { + return `${block}\n` + } + + return `${block}\n\n${current.replace(/^\r?\n+/, '')}` +} diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml new file mode 100644 index 00000000000..438951ee3fa --- /dev/null +++ b/.github/workflows/pr-test-loc.yml @@ -0,0 +1,36 @@ +name: PR test LoC + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +concurrency: + group: pr-test-loc-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + loc: + name: test vs non-test LoC + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + # Why no checkout: the Files API already has per-file additions/deletions. + - name: Count test vs non-test LoC + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do + gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \ + --jq .content | base64 --decode > "$RUNNER_TEMP/${script}" + done + node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}" diff --git a/config/scripts/pr-test-loc-summary.test.mjs b/config/scripts/pr-test-loc-summary.test.mjs new file mode 100644 index 00000000000..bc92e40eef7 --- /dev/null +++ b/config/scripts/pr-test-loc-summary.test.mjs @@ -0,0 +1,175 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs' +import { + LOC_HANDS_OFF_COMMENT, + isTestPath, + mergeLocBlock, + renderLocBlock, + sumChangedFiles +} from '../../.github/scripts/pr-test-loc-table.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') +const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs') +const tempDirs = [] + +function runLoc(args, { env } = {}) { + return spawnSync(process.execPath, [locScript, ...args], { + cwd: projectDir, + encoding: 'utf8', + env: { ...process.env, ...env } + }) +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('PR test LoC summary', () => { + it('classifies colocated tests and e2e/tests directories', () => { + expect(isTestPath('src/main/foo.test.ts')).toBe(true) + expect(isTestPath('src/main/foo.spec.tsx')).toBe(true) + expect(isTestPath('src/e2e/login.ts')).toBe(true) + expect(isTestPath('tests/tools/probe.mjs')).toBe(true) + expect(isTestPath('src/main/foo-test-setup.ts')).toBe(false) + expect(isTestPath('src/main/foo.ts')).toBe(false) + }) + + it('sums GitHub pull-file additions and deletions', () => { + const totals = sumChangedFiles([ + { filename: 'src/app.ts', additions: 4, deletions: 1 }, + { filename: 'src/app.test.ts', additions: 12, deletions: 3 }, + { filename: 'icon.png', additions: 0, deletions: 0 } + ]) + + expect(totals).toEqual({ + test: { files: 1, added: 12, deleted: 3 }, + nonTest: { files: 2, added: 4, deleted: 1 } + }) + }) + + it('reads the next page from a GitHub Link header', () => { + expect( + nextLink( + '; rel="next", ; rel="last"' + ) + ).toBe('https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2') + expect( + nextLink('; rel="prev"') + ).toBe(undefined) + }) + + it('paginates pull files until Link rel=next is gone', async () => { + const pages = { + 'https://api.github.com/repos/stablyai/orca/pulls/9/files?per_page=100': { + body: [{ filename: 'src/app.ts', additions: 2, deletions: 0 }], + link: '; rel="next"' + }, + 'https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2': { + body: [{ filename: 'src/app.test.ts', additions: 5, deletions: 1 }], + link: null + } + } + + const files = await listPullFiles({ + owner: 'stablyai', + repo: 'orca', + pullNumber: 9, + token: 'test-token', + fetchImpl: async (url) => { + const page = pages[url] + if (page == null) { + throw new Error(`unexpected url ${url}`) + } + return { + ok: true, + json: async () => page.body, + headers: { get: (name) => (name === 'link' ? page.link : null) } + } + } + }) + + expect(sumChangedFiles(files)).toEqual({ + test: { files: 1, added: 5, deleted: 1 }, + nonTest: { files: 1, added: 2, deleted: 0 } + }) + }) + + it('replaces an existing header and prepends when missing', () => { + const totals = { + test: { files: 1, added: 2, deleted: 1 }, + nonTest: { files: 1, added: 4, deleted: 0 } + } + const block = renderLocBlock(totals) + + expect(block).toContain(LOC_HANDS_OFF_COMMENT) + expect(block).toContain('| Test | 1 | +2 | −1 | +1 |') + expect(block).toContain('| Prod | 1 | +4 | 0 | +4 |') + expect(block).not.toContain('| Total |') + expect(mergeLocBlock('## ELI5\n\nHello\n', totals)).toBe(`${block}\n\n## ELI5\n\nHello\n`) + expect(mergeLocBlock(`${block}\n\n## ELI5\n`, totals)).toBe(`${block}\n\n## ELI5\n`) + expect( + mergeLocBlock( + '\n**LoC** · test **+1 / −0**\n\n\n## ELI5\n', + totals + ) + ).toBe(`${block}\n\n## ELI5\n`) + }) + + it('counts and merges from a files JSON fixture', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pr-test-loc-')) + tempDirs.push(root) + const filesPath = join(root, 'files.json') + const bodyPath = join(root, 'body.md') + writeFileSync( + filesPath, + JSON.stringify([ + { filename: 'src/app.ts', additions: 2, deletions: 0 }, + { filename: 'src/app.test.ts', additions: 6, deletions: 1 } + ]) + ) + writeFileSync(bodyPath, '## ELI5\n\nHello\n') + + const result = runLoc(['--files-json', filesPath, '--merge-body', bodyPath]) + + expect(result.status).toBe(0) + expect(result.stdout.startsWith('')).toBe(true) + expect(result.stdout).toContain(LOC_HANDS_OFF_COMMENT) + expect(result.stdout).toContain('| Test | 1 | +6 | −1 | +5 |') + expect(result.stdout).toContain('| Prod | 1 | +2 | 0 | +2 |') + expect(result.stdout).not.toContain('| Total |') + expect(result.stdout).toContain('## ELI5\n\nHello\n') + expect(result.stdout.match(//g)).toHaveLength(1) + }) + + it('exits 2 with usage when no PR or files JSON is supplied', () => { + const result = runLoc([]) + + expect(result.status).toBe(2) + expect(result.stderr).toContain('--from-pr') + }) + + it('is a no-checkout GitHub-hosted PR workflow', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8') + ) + const locJob = workflow.jobs.loc + const serialized = JSON.stringify(workflow) + + expect(locJob['runs-on']).toBe('ubuntu-latest') + expect(locJob.steps).toHaveLength(1) + expect(locJob.steps[0].run).toContain('gh api') + expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs') + expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs') + expect(locJob.steps[0].run).toContain('--update-pr') + expect(workflow.permissions['pull-requests']).toBe('write') + expect(serialized).not.toContain('actions/checkout') + expect(serialized).not.toContain('self-hosted') + }) +})