Welcome AI-assisted contributions while reserving co-author trailers for human credit. Reuse commitlint and Git trailer parsing to reject known assistant identities in new commits without altering existing history or legitimate credits.
174 lines
6.2 KiB
TypeScript
174 lines
6.2 KiB
TypeScript
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 <claude@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 <person@anthropic.com>',
|
|
'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>',
|
|
'Codex <codex@openai.com>',
|
|
'Cursor <noreply@cursor.com>',
|
|
'Gemini CLI <gemini-cli@google.com>',
|
|
'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 <noreply@anthropic.com>\n```'
|
|
)
|
|
).toBe(false)
|
|
expect(
|
|
hasAICoauthor(
|
|
'fix: update\n\nCo-authored-by: Human <person@example.org>\ncO-aUtHoReD-bY: Claude <NOREPLY@ANTHROPIC.COM>'
|
|
)
|
|
).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 <noreply@anthropic.com>`)
|
|
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([
|
|
['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 <noreply@anthropic.com>'
|
|
)
|
|
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 <noreply@anthropic.com>'
|
|
)
|
|
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 })
|
|
}
|
|
})
|