Files
sencho/scripts/credit-changelog-contributors.test.mjs
T
Anso 21633b87e3 ci: credit changelog contributors inline and harden empty pr fromJSON (#1625)
Release publish runs leave steps.release.outputs.pr empty, so fromJSON crashed
even when the credit step was gated off. Credit external issue openers with an
inline thanks suffix on logical changelog bullets instead of a Thanks section.
Keep fatal API lookup logs status-only.
2026-07-13 15:49:04 -04:00

686 lines
21 KiB
JavaScript

/**
* Tests for scripts/credit-changelog-contributors.mjs
*
* Run: node --test scripts/credit-changelog-contributors.test.mjs
*
* All fixtures are inline strings. The test imports pure functions from the
* main script; main() is never invoked because the CLI guard fires.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
parseVersionSection,
extractReferences,
classifyAuthor,
shouldRetry,
stripInlineThanks,
applyInlineThanks,
sortLogins,
dedupeLogins,
removeThanksSection,
creditChangelogEntries,
splitLines,
joinLines,
} from './credit-changelog-contributors.mjs'
const OWNER = 'Studio-Saelix'
const REPO = 'sencho'
function sameRepo(n, kind = 'issues') {
return `[#${n}](https://github.com/${OWNER}/${REPO}/${kind}/${n})`
}
// ---------------------------------------------------------------------------
// parseVersionSection
// ---------------------------------------------------------------------------
describe('parseVersionSection', () => {
it('finds the first version heading', () => {
const text = [
'# Changelog',
'',
'## [0.93.0](...) (2026-06-25)',
'',
'### Added',
'* some change',
'',
'## [0.92.0](...) (2026-06-19)',
'',
'### Fixed',
'* old fix',
].join('\n')
const result = parseVersionSection(text)
assert.notEqual(result, null)
assert.ok(text.slice(result.start, result.end).includes('## [0.93.0]'))
assert.ok(!text.slice(result.start, result.end).includes('## [0.92.0]'))
})
it('returns null when no version heading exists', () => {
const text = '# Changelog\n\nSome text without a version.\n'
assert.equal(parseVersionSection(text), null)
})
it('handles heading at offset 0', () => {
const text = '## [1.0.0]\n\n### Added\n* feat\n'
const result = parseVersionSection(text)
assert.notEqual(result, null)
assert.equal(result.start, 0)
})
it('handles heading at EOF (no next version)', () => {
const text = '## [1.0.0]\n\n### Added\n* feat\n'
const result = parseVersionSection(text)
assert.notEqual(result, null)
assert.equal(result.end, text.length)
})
it('bounds correctly with Unicode content before heading', () => {
const text = '# Changelog — all notable changes\n\n## [0.94.0]\n\n### Added\n* item ✓\n'
const result = parseVersionSection(text)
assert.notEqual(result, null)
assert.ok(text.slice(result.start, result.end).startsWith('## [0.94.0]'))
})
it('preserves Unicode inside the section', () => {
const text = [
'## [0.94.0]',
'',
'### Added',
'* fix: é and 中文 chars',
'',
'## [0.93.0]',
].join('\n')
const result = parseVersionSection(text)
const block = text.slice(result.start, result.end)
assert.ok(block.includes('é'))
assert.ok(block.includes('中文'))
assert.ok(!block.includes('## [0.93.0]'))
})
})
// ---------------------------------------------------------------------------
// extractReferences
// ---------------------------------------------------------------------------
describe('extractReferences', () => {
it('extracts PR references from ([#N](.../issues/N))', () => {
const text = `* feat: add thing (${sameRepo(1442)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1442])
})
it('extracts linked-issue references from closes [#N](...)', () => {
const text =
`* fix: clear logs (${sameRepo(1448)}) ([abc](...)), closes ${sameRepo(1444)}`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1444, 1448])
})
it('extracts fixes/resolves variants', () => {
const text =
`* fix: a (${sameRepo(1)}) ([...]), fixes ${sameRepo(2)}\n` +
`* fix: b (${sameRepo(3)}) ([...]), resolves ${sameRepo(4)}`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1, 2, 3, 4])
})
it('accepts /pull/ URLs', () => {
const text = `* feat: pr ref (${sameRepo(5, 'pull')})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [5])
})
it('rejects cross-repo URLs', () => {
const text =
'* feat: external ([#99](https://github.com/OtherOrg/other/issues/99))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [])
})
it('accepts pre-transfer org (AnsoCode/Sencho)', () => {
const text =
'* old: thing ([#583](https://github.com/AnsoCode/Sencho/issues/583))'
const refs = extractReferences(text, 'AnsoCode', 'Sencho')
assert.deepEqual(refs, [583])
})
it('rejects commit SHA links', () => {
const text =
'* feat: thing ([abc1234](https://github.com/Studio-Saelix/sencho/commit/abc1234))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [])
})
it('deduplicates repeated numbers', () => {
const text =
`* feat: a (${sameRepo(10)})\n` +
`* feat: b (${sameRepo(10)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [10])
})
it('returns sorted numbers', () => {
const text =
`* feat: c (${sameRepo(30)})\n` +
`* feat: a (${sameRepo(10)})\n` +
`* feat: b (${sameRepo(20)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [10, 20, 30])
})
it('rejects non-github.com hosts', () => {
const text =
'* feat: other ([#1](https://gitlab.com/Studio-Saelix/sencho/issues/1))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [])
})
it('rejects URLs with mismatched number in text vs URL', () => {
const text =
'* feat: wrong ([#42](https://github.com/Studio-Saelix/sencho/issues/99))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [])
})
})
// ---------------------------------------------------------------------------
// classifyAuthor
// ---------------------------------------------------------------------------
describe('classifyAuthor', () => {
it('classifies Bot type as bot', () => {
assert.equal(
classifyAuthor({ user: { type: 'Bot', login: 'dependabot[bot]' } }),
'bot',
)
})
it('classifies [bot] login suffix as bot', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'sencho-quartermaster[bot]' },
author_association: 'CONTRIBUTOR',
}),
'bot',
)
})
it('classifies OWNER as internal', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'owner123' },
author_association: 'OWNER',
}),
'internal',
)
})
it('classifies MEMBER as internal', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'AnsoCode' },
author_association: 'MEMBER',
}),
'internal',
)
})
it('classifies COLLABORATOR as internal', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'collab1' },
author_association: 'COLLABORATOR',
}),
'internal',
)
})
it('classifies deleted user (null)', () => {
assert.equal(classifyAuthor({ user: null }), 'deleted')
})
it('classifies NONE as external', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'Crosis47' },
author_association: 'NONE',
}),
'external',
)
})
it('classifies CONTRIBUTOR non-bot as external', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'helper' },
author_association: 'CONTRIBUTOR',
}),
'external',
)
})
it('respects MAINTAINER_LOGINS override baseline (module-load constant)', () => {
const prev = process.env.MAINTAINER_LOGINS
process.env.MAINTAINER_LOGINS = 'overrideUser'
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'overrideUser' },
author_association: 'NONE',
}),
'external',
)
process.env.MAINTAINER_LOGINS = prev
})
it('case-insensitive login matching', () => {
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'SomeBot[BOT]' },
author_association: 'NONE',
}),
'bot',
)
})
})
// ---------------------------------------------------------------------------
// shouldRetry
// ---------------------------------------------------------------------------
describe('shouldRetry', () => {
function headers(init) {
return new Headers(init)
}
it('retries 429', () => {
assert.equal(shouldRetry(429, headers()), true)
})
it('retries rate-limited 403', () => {
assert.equal(
shouldRetry(403, headers({ 'x-ratelimit-remaining': '0' })),
true,
)
assert.equal(
shouldRetry(403, headers({ 'retry-after': '60' })),
true,
)
})
it('does not retry normal 403', () => {
assert.equal(shouldRetry(403, headers()), false)
})
it('retries 502/503/504', () => {
assert.equal(shouldRetry(502, headers()), true)
assert.equal(shouldRetry(503, headers()), true)
assert.equal(shouldRetry(504, headers()), true)
})
it('does not retry 401', () => {
assert.equal(shouldRetry(401, headers()), false)
})
it('does not retry 422', () => {
assert.equal(shouldRetry(422, headers()), false)
})
})
// ---------------------------------------------------------------------------
// Inline thanks helpers
// ---------------------------------------------------------------------------
describe('inline thanks helpers', () => {
it('strips exact trailing suffix', () => {
assert.equal(
stripInlineThanks('closes #1, thanks @auspex'),
'closes #1',
)
})
it('preserves near-match prose that is not the managed suffix', () => {
const prose = 'special thanks to everyone who filed bugs'
assert.equal(stripInlineThanks(prose), prose)
assert.equal(applyInlineThanks(prose, []), prose)
})
it('dedupes case-insensitively keeping first casing', () => {
assert.deepEqual(dedupeLogins(['Auspex', 'auspex', 'Bob']), ['Auspex', 'Bob'])
})
it('sorts case-insensitively with deterministic tie-break', () => {
assert.deepEqual(sortLogins(['bob', 'Alice', 'alice']), ['Alice', 'alice', 'bob'])
})
it('applies sorted unique suffix', () => {
// First-seen casing wins ('alice'); Alice is dropped as a case-insensitive dup.
assert.equal(
applyInlineThanks('line', ['bob', 'alice', 'Alice']),
'line, thanks @alice, @bob',
)
})
it('removes stale suffix when logins empty', () => {
assert.equal(
applyInlineThanks('line, thanks @stale', []),
'line',
)
})
})
// ---------------------------------------------------------------------------
// creditChangelogEntries
// ---------------------------------------------------------------------------
describe('creditChangelogEntries', () => {
it('0.95.0-style bullet: PR plus closes #1581 becomes thanks @auspex', () => {
const section = [
'## [0.95.0](...) (2026-07-12)',
'',
'### Fixed',
`* **drift:** resolve explicit network names that equal compose keys (${sameRepo(1588)}) ([4123793](...)), closes ${sameRepo(1581)}`,
].join('\n')
const map = new Map([[1581, 'auspex']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`, closes ${sameRepo(1581)}, thanks @auspex`))
assert.ok(!result.includes('thanks @auspex, thanks'))
})
it('credits reference on indented non-list continuation line', () => {
const section = [
'## [0.93.0]',
'',
'### Added',
`* feat (${sameRepo(1448)})`,
` ([abc](...)), closes ${sameRepo(1444)}`,
].join('\n')
const map = new Map([[1444, 'Crosis47']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`closes ${sameRepo(1444)}, thanks @Crosis47`))
assert.ok(!result.split('\n')[3].includes('thanks'))
})
it('dash top-level bullet is credited', () => {
const section = [
'## [1.0.0]',
'',
`### Fixed`,
`- fix: thing (${sameRepo(10)})`,
].join('\n')
const map = new Map([[10, 'dashUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`- fix: thing (${sameRepo(10)}), thanks @dashUser`))
})
it('same-repo #123 credited; cross-repo #123 on other bullet is not', () => {
const section = [
'## [1.0.0]',
'',
'### Fixed',
`* same (${sameRepo(123)})`,
'* cross ([#123](https://github.com/OtherOrg/other/issues/123))',
].join('\n')
const map = new Map([[123, 'sameRepoUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.ok(lines[3].includes(', thanks @sameRepoUser'))
assert.equal(lines[4], '* cross ([#123](https://github.com/OtherOrg/other/issues/123))')
})
it('two externals on one logical bullet: sorted and deduplicated', () => {
const section = [
'## [1.0.0]',
'',
`* fix (${sameRepo(1)}) closes ${sameRepo(2)}`,
].join('\n')
const map = new Map([
[1, 'zoe'],
[2, 'alice'],
])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(', thanks @alice, @zoe'))
})
it('one contributor on multiple bullets credits each applicable bullet', () => {
const section = [
'## [1.0.0]',
'',
`* a (${sameRepo(1)})`,
`* b (${sameRepo(1)})`,
`* c (${sameRepo(2)})`,
].join('\n')
const map = new Map([
[1, 'helper'],
[2, 'other'],
])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.ok(lines[2].endsWith(', thanks @helper'))
assert.ok(lines[3].endsWith(', thanks @helper'))
assert.ok(lines[4].endsWith(', thanks @other'))
})
it('second run produces byte-identical output', () => {
const section = [
'## [0.93.0]',
'',
'### Added',
`* feat (${sameRepo(1448)})`,
` ([abc](...)), closes ${sameRepo(1444)}`,
].join('\n')
const map = new Map([[1444, 'Crosis47']])
const first = creditChangelogEntries(section, map, OWNER, REPO)
const second = creditChangelogEntries(first, map, OWNER, REPO)
assert.equal(second, first)
})
it('strips stale suffix from first line when a continuation is added later', () => {
const section = [
'## [1.0.0]',
'',
`* feat (${sameRepo(1)}), thanks @old`,
` continuation closes ${sameRepo(2)}`,
].join('\n')
const map = new Map([[2, 'newUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* feat (${sameRepo(1)})`)
assert.ok(lines[3].endsWith(', thanks @newUser'))
assert.ok(!lines[2].includes('thanks'))
})
it('removes stale inline credit when login map has no matching externals', () => {
const section = [
'## [1.0.0]',
'',
`* fix (${sameRepo(1)}), thanks @stale`,
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result.split('\n')[2], `* fix (${sameRepo(1)})`)
})
it('bullet with no external refs and no suffix remains byte-identical', () => {
const section = [
'## [1.0.0]',
'',
'### Added',
'* feat: plain entry',
'',
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result, section)
})
it('preserves near-match thanks prose that is not the managed suffix', () => {
const section = [
'## [1.0.0]',
'',
'* feat: special thanks to reviewers',
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result, section)
})
it('nested list child remains unchanged; suffix on parent', () => {
const section = [
'## [1.0.0]',
'',
`* parent with ref (${sameRepo(7)})`,
' * nested child stays put',
'* sibling',
].join('\n')
const map = new Map([[7, 'parentUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* parent with ref (${sameRepo(7)}), thanks @parentUser`)
assert.equal(lines[3], ' * nested child stays put')
assert.equal(lines[4], '* sibling')
})
it('nested list refs are excluded from parent association', () => {
const section = [
'## [1.0.0]',
'',
'* parent with no refs',
` * nested closes ${sameRepo(7)}`,
].join('\n')
const map = new Map([[7, 'nestedUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.equal(result, section)
assert.ok(!result.includes('thanks'))
})
it('blank line terminates logical entry (later prose not absorbed)', () => {
const section = [
'## [1.0.0]',
'',
`* parent (${sameRepo(1)})`,
'',
' later prose that looks indented',
].join('\n')
const map = new Map([[1, 'user']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* parent (${sameRepo(1)}), thanks @user`)
assert.equal(lines[3], '')
assert.equal(lines[4], ' later prose that looks indented')
})
it('preserves LF after an actual inline rewrite', () => {
const section = `## [1.0.0]\n\n* feat (${sameRepo(1)})\n`
assert.ok(!section.includes('\r'))
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
assert.ok(!result.includes('\r'))
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\n`))
})
it('preserves CRLF after an actual inline rewrite', () => {
const section = `## [1.0.0]\r\n\r\n* feat (${sameRepo(1)})\r\n`
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
assert.ok(result.includes('## [1.0.0]\r\n'))
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\r\n`))
assert.equal(result.includes('\n') && !result.includes('\r\n'), false)
})
})
// ---------------------------------------------------------------------------
// removeThanksSection + bounded mutation
// ---------------------------------------------------------------------------
describe('removeThanksSection and bounded mutation', () => {
it('removes legacy Thanks from latest while preserving historical Thanks', () => {
const text = [
'# Changelog',
'',
'## [0.95.0]',
'',
'### Thanks',
'',
'* @auspex for [#1581](u)',
'',
'### Added',
'* feat',
'',
'## [0.94.0]',
'',
'### Thanks',
'',
'* @old for [#5](u)',
'',
'### Fixed',
'* old',
].join('\n')
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
const reparsed = parseVersionSection(result)
const latest = result.slice(reparsed.start, reparsed.end)
assert.ok(!latest.includes('### Thanks'))
assert.ok(!latest.includes('@auspex'))
assert.ok(result.includes('@old for [#5](u)'))
})
it('content before and after latest version remains byte-identical after credit', () => {
const prefix = '# Changelog\n\nIntro prose.\n\n'
const latest = [
'## [1.0.0]',
'',
'### Added',
`* feat (${sameRepo(1)})`,
'',
].join('\n')
const suffix = '## [0.9.0]\n\n### Fixed\n* old\n'
const full = prefix + latest + suffix
const parsed = parseVersionSection(full)
const section = full.slice(parsed.start, parsed.end)
const credited = creditChangelogEntries(section, new Map([[1, 'x']]), OWNER, REPO)
const out = full.slice(0, parsed.start) + credited + full.slice(parsed.end)
assert.equal(out.slice(0, parsed.start), prefix)
assert.equal(out.slice(out.length - suffix.length), suffix)
assert.ok(out.includes(', thanks @x'))
})
})
// ---------------------------------------------------------------------------
// splitLines / joinLines round-trip
// ---------------------------------------------------------------------------
describe('splitLines', () => {
it('round-trips LF and CRLF', () => {
const lf = 'a\nb\n'
assert.equal(joinLines(splitLines(lf)), lf)
const crlf = 'a\r\nb\r\n'
assert.equal(joinLines(splitLines(crlf)), crlf)
})
})
// ---------------------------------------------------------------------------
// Workflow expression documentation (empty vs populated pr)
// ---------------------------------------------------------------------------
describe('workflow RELEASE_BRANCH expression (documented behavior)', () => {
it('empty pr || {} yields no headBranchName (publish-run safe)', () => {
// Mirrors: fromJSON(steps.release.outputs.pr || '{}').headBranchName || ''
const pr = ''
const parsed = JSON.parse(pr || '{}')
const branch = parsed.headBranchName || ''
assert.equal(branch, '')
})
it('populated pr JSON yields headBranchName', () => {
const pr = JSON.stringify({
headBranchName: 'release-please--branches--main--components--sencho',
})
const parsed = JSON.parse(pr || '{}')
const branch = parsed.headBranchName || ''
assert.equal(branch, 'release-please--branches--main--components--sencho')
})
})