import { expect, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { hasAICoauthor } from '#ci/commit-attribution' const root = resolve(import.meta.dir, '../../..') async function lint(message: string, args: string[] = [], prTitle = false) { const child = Bun.spawn([process.execPath, 'run', 'check:commits', '--verbose', ...args], { cwd: root, env: { ...process.env, COMMITLINT_PR_TITLE: prTitle ? '1' : '0' }, stdin: new Blob([message]), stdout: 'pipe', stderr: 'pipe' }) const [code, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() ]) return { code, output: stdout + stderr } } test.each([ 'fix: preserve selection', 'refactor(MCP): preserve DOM/CSS and Kiwi names', 'feat!: change the tool contract', `fix: explain the change\n\n${'Detailed rationale. '.repeat(12)}`, 'Release v0.14.0', 'Merge pull request #697 from open-pencil/mcp-v2-webmcp' ])('accepts supported commit: %s', async (message) => { const result = await lint(message) expect(result.output).toContain('found 0 problems') expect(result.code).toBe(0) }) test.each([ 'fix: document Claude Code and OpenAI integration', 'fix: preserve credits\n\nCo-authored-by: Claude Martin ', 'fix: preserve credits\n\nCo-authored-by: person@example.org', 'fix: preserve credits\n\nCo-authored-by: Claude <123+claude@users.noreply.github.com>', 'fix: preserve credits\n\nCo-authored-by: Codex <456+codex@users.noreply.github.com>', 'fix: preserve credits\n\nCo-authored-by: Human Contributor ', 'build: update packages\n\nCo-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>' ])('preserves ordinary mentions, human credits, and maintenance bots: %s', async (message) => { expect((await lint(message)).code).toBe(0) }) test.each([ 'Claude Sonnet 5 ', 'noreply@anthropic.com', 'NOREPLY@OPENAI.COM', 'Codex ', 'Cursor ', 'Gemini CLI ', 'Copilot <175728472+Copilot@users.noreply.github.com>', 'Claude <123+claude[bot]@users.noreply.github.com>' ])('recognizes known assistant identities: %s', (identity) => { expect(hasAICoauthor(`fix: update\n\nCo-authored-by: ${identity}`)).toBe(true) }) test('parses real trailers without treating fenced examples as authorship', () => { expect( hasAICoauthor( 'docs: explain policy\n\n```text\nCo-authored-by: Claude \n```' ) ).toBe(false) expect( hasAICoauthor( 'fix: update\n\nCo-authored-by: Human \ncO-aUtHoReD-bY: Claude ' ) ).toBe(true) }) test.each([ 'fix: update', 'Merge pull request #1 from contributor/fix', 'Revert "fix: update"', 'Release v0.14.0' ])('attribution cannot bypass lint through an ignored subject: %s', async (subject) => { const result = await lint(`${subject}\n\nCo-authored-by: Claude `) expect(result.code).not.toBe(0) expect(result.output).toContain('no-ai-coauthors') expect(result.output).toContain('AI-assisted contributions are welcome') expect(result.output).toContain('Preserve human co-author credits') }) test.each(['fix: update', 'Release v0.14.0'])( 'rejects bare assistant email trailers: %s', async (subject) => { const result = await lint(`${subject}\n\nCo-authored-by: noreply@anthropic.com`) expect(result.code).not.toBe(0) expect(result.output).toContain('no-ai-coauthors') } ) test.each([ ['Fixed stuff', 'type-empty'], ['feature: add tools', 'type-enum'], ['fix:', 'subject-empty'], ['fix: trailing period.', 'subject-full-stop'], ['Release whatever', 'type-empty'], ['Bad subject\n\nRelease v0.14.0', 'type-empty'] ])('rejects invalid commit: %s', async (message, rule) => { const result = await lint(message) expect(result.code).not.toBe(0) expect(result.output).toContain(rule) expect(result.output).toContain('CONTRIBUTING.md#commit-messages') }) test.each([ 'fix(MCP): preserve connection settings', 'feat!: change the tool contract', 'Release v0.14.0' ])('accepts supported PR title: %s', async (title) => { expect((await lint(title, [], true)).code).toBe(0) }) test.each([ 'Merge pull request #700 from open-pencil/build/commitlint', "Merge remote-tracking branch 'origin/master' into build/commitlint", 'Revert "fix: preserve selection"', 'Fixed stuff' ])('rejects non-conventional PR title: %s', async (title) => { const result = await lint(title, [], true) expect(result.code).not.toBe(0) expect(result.output).toContain('type-empty') }) test('checks the full PR range while excluding existing base history', async () => { const directory = await mkdtemp(join(tmpdir(), 'open-pencil-commitlint-')) async function git(...args: string[]) { const child = Bun.spawn(['git', ...args], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }) const [code, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() ]) if (code !== 0) throw new Error(stderr) return stdout.trim() } try { await git('init', '--quiet') await git('config', 'user.name', 'Commitlint test') await git('config', 'user.email', 'commitlint@example.invalid') await git('config', 'commit.gpgsign', 'false') await git('config', 'core.hooksPath', join(directory, 'no-hooks')) await git( 'commit', '--allow-empty', '-m', 'Legacy base message\n\nCo-authored-by: Claude ' ) const base = await git('rev-parse', 'HEAD') await git('commit', '--allow-empty', '-m', 'docs: explain setup') const args = [ '--cwd', directory, '--config', join(root, 'commitlint.config.ts'), '--from', base, '--to', 'HEAD' ] expect((await lint('', args)).code).toBe(0) await git( 'commit', '--allow-empty', '-m', 'fix: update\n\nCo-authored-by: Claude ' ) await git('commit', '--allow-empty', '-m', 'Bad intermediate commit') await git('commit', '--allow-empty', '-m', 'fix: valid final commit') const result = await lint('', args) expect(result.code).not.toBe(0) expect(result.output).toContain('Bad intermediate commit') expect(result.output).toContain('no-ai-coauthors') expect(result.output).not.toContain('Legacy base message') } finally { await rm(directory, { recursive: true, force: true }) } })