chore: auto-credit external contributors in changelog (#1585)

This commit is contained in:
Anso
2026-07-06 19:55:09 -04:00
committed by GitHub
parent 383d2248a2
commit 9385720ef0
4 changed files with 1151 additions and 1 deletions
+28
View File
@@ -218,3 +218,31 @@ jobs:
test-results/
ci-logs/
retention-days: 7
# ---------------------------------------------------------------------------
# Changelog Credit Script (PRs only, skipped for release-please PRs)
# ---------------------------------------------------------------------------
changelog-script:
name: Changelog credit script
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request'
&& github.head_ref != 'release-please--branches--main--components--sencho'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: '.node-version'
- name: Validate workflows
uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # v1.7.12
with:
args: .github/workflows/release-please.yml .github/workflows/ci.yml
- name: Syntax check
run: node --check scripts/credit-changelog-contributors.mjs
- name: Unit tests
run: node --test scripts/credit-changelog-contributors.test.mjs
+39 -1
View File
@@ -1,5 +1,9 @@
name: Release Please
concurrency:
group: release-please-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches:
@@ -37,9 +41,43 @@ jobs:
repositories: Sencho
permission-contents: write
permission-pull-requests: write
permission-issues: read
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: '.node-version'
- id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Credit external contributors in changelog
if: ${{ steps.release.outputs.prs_created == 'true' }}
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
MAINTAINER_LOGINS: ""
run: |
if [ -z "$RELEASE_BRANCH" ]; then
echo "RELEASE_BRANCH is empty; aborting."
exit 1
fi
cp scripts/credit-changelog-contributors.mjs "$RUNNER_TEMP/"
git fetch origin "$RELEASE_BRANCH"
git checkout "$RELEASE_BRANCH"
node "$RUNNER_TEMP/credit-changelog-contributors.mjs"
if [ -n "$(git status --porcelain CHANGELOG.md)" ]; then
git config user.name "sencho-quartermaster[bot]"
git config user.email "275163604+sencho-quartermaster[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "chore: credit external contributors in changelog"
git push origin "HEAD:$RELEASE_BRANCH"
fi
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env node
/**
* Post-processes CHANGELOG.md on a release-please PR branch, adding a
* "### Thanks" section that credits external, non-bot contributors whose
* issue or PR is referenced in the latest release block.
*
* Atomic: builds the complete result in memory before touching the file.
* If any retryable lookup fails, exits nonzero without writing so the
* existing changelog (including any prior Thanks section) is preserved.
*
* Inputs (env):
* GITHUB_TOKEN (required) GitHub API token
* GITHUB_REPOSITORY (required) e.g. "Studio-Saelix/sencho"
* MAINTAINER_LOGINS (optional) comma-separated logins to exclude (override)
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TOKEN = process.env.GITHUB_TOKEN
const REPO = process.env.GITHUB_REPOSITORY
const MAINTAINER_LOGINS = (process.env.MAINTAINER_LOGINS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
const BOT_LOGIN_PATTERN = /\[bot\]$/i
const INTERNAL_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
const GITHUB_API = 'https://api.github.com'
const REQUEST_TIMEOUT_MS = 10_000
const MAX_RETRIES = 3 // 1 initial + 2 retries
const RETRY_WAITS_MS = [1000, 2000]
const MAX_WAIT_PER_RETRY_MS = 15_000
const TOTAL_RETRY_CAP_MS = 40_000
// ---------------------------------------------------------------------------
// GitHub API
// ---------------------------------------------------------------------------
/**
* Call GET /repos/{owner}/{repo}/issues/{number}.
* Returns the JSON body or throws on non-2xx.
*/
async function fetchIssue(repo, number, token) {
const url = `${GITHUB_API}/repos/${repo}/issues/${number}`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!res.ok) {
const err = new Error(`GitHub API ${res.status} for #${number}`)
err.status = res.status
err.headers = res.headers
// Attach body text for diagnostics but do not leak into error messages
try { err.body = await res.text() } catch { /* ignore */ }
throw err
}
return res.json()
}
/** True when the HTTP status is a retryable server/rate-limit error. */
export function shouldRetry(status, headers) {
if (status === 429) return true
if (status === 403 && headers) {
const remaining = headers.get('x-ratelimit-remaining')
const retryAfter = headers.get('retry-after')
if (remaining === '0' || retryAfter !== null) return true
}
if (status === 502 || status === 503 || status === 504) return true
return false
}
/** Extract wait seconds from Retry-After header, capped. */
function retryAfterSeconds(headers) {
if (!headers) return null
const v = headers.get('retry-after')
if (!v) return null
const n = Number(v)
if (Number.isFinite(n) && n > 0) return Math.min(n, MAX_WAIT_PER_RETRY_MS / 1000)
return null
}
/**
* Fetch with bounded retry. Returns the JSON body.
* Only retries on network errors + shouldRetry() statuses.
* Non-retryable errors (401, 403 w/o rate-limit, 422, etc.) throw immediately.
*/
async function fetchWithRetry(repo, number, token) {
let lastError
const start = Date.now()
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await fetchIssue(repo, number, token)
} catch (err) {
lastError = err
// Network errors (fetch throws without status) are always retryable
const isNetworkError = err.status === undefined
const retryable = isNetworkError || shouldRetry(err.status, err.headers)
if (!retryable) throw err
if (attempt === MAX_RETRIES - 1) throw err
// Determine wait
let waitMs = RETRY_WAITS_MS[attempt] ?? RETRY_WAITS_MS[RETRY_WAITS_MS.length - 1]
const ra = retryAfterSeconds(err.headers)
if (ra !== null) waitMs = Math.min(ra * 1000, MAX_WAIT_PER_RETRY_MS)
const elapsed = Date.now() - start
if (elapsed + waitMs > TOTAL_RETRY_CAP_MS) {
// Sleep whatever remains before the total cap, then let the loop
// make its final attempt. If it fails the loop exits and throws.
const remaining = TOTAL_RETRY_CAP_MS - elapsed
if (remaining > 0) await sleep(remaining)
continue
}
await sleep(waitMs)
}
}
throw lastError
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
// ---------------------------------------------------------------------------
// Contributor classification
// ---------------------------------------------------------------------------
/**
* Classify a GitHub API issue/PR response.
* @returns {'bot' | 'internal' | 'deleted' | 'external'}
*/
export function classifyAuthor(response) {
const user = response.user
if (user === null) return 'deleted'
if (user.type === 'Bot') return 'bot'
if (BOT_LOGIN_PATTERN.test(user.login)) return 'bot'
const assoc = response.author_association
if (assoc && INTERNAL_ASSOCIATIONS.has(assoc)) return 'internal'
if (MAINTAINER_LOGINS.includes(user.login.toLowerCase())) return 'internal'
return 'external'
}
// ---------------------------------------------------------------------------
// Changelog parsing
// ---------------------------------------------------------------------------
const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
/**
* Locate the first version heading and return its character-index range.
* The block runs from the heading line to the next version heading or EOF.
* @returns {{ start: number, end: number } | null}
*/
export function parseVersionSection(text) {
const lines = text.split('\n')
let headingLine = -1
for (let i = 0; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
headingLine = i
break
}
}
if (headingLine === -1) return null
// Start: character index of the heading line in the original text
const start = text.indexOf(lines[headingLine])
// Find end line: next version heading or EOF
let endLine = lines.length
for (let i = headingLine + 1; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
endLine = i
break
}
}
// End: if EOF, text.length; otherwise start of the next heading
let end
if (endLine === lines.length) {
end = text.length
} else {
end = text.indexOf(lines[endLine], start)
}
return { start, end }
}
/**
* Extract unique same-repository issue/PR numbers from markdown text.
* Only accepts URLs whose owner/repo matches the given repo (case-insensitive)
* and whose path is /issues/N or /pull/N.
*/
export function extractReferences(text, owner, repo) {
const numbers = new Set()
// Match markdown links: [text](url)
// Patterns we capture:
// ([#N](https://github.com/OWNER/REPO/issues/N))
// ([#N](https://github.com/OWNER/REPO/pull/N))
// closes [#N](https://github.com/OWNER/REPO/issues/N)
// fixes [#N](...), resolves [#N](...)
const linkRe = /\[#(\d+)]\(https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\)/gi
let match
while ((match = linkRe.exec(text)) !== null) {
const linkOwner = match[2]
const linkRepo = match[3]
const pathType = match[4]
const linkNumber = Number(match[5])
const capturedNumber = Number(match[1])
// The captured #N must match the URL number
if (capturedNumber !== linkNumber) continue
// Must be same repo (case-insensitive)
if (linkOwner.toLowerCase() !== owner.toLowerCase()) continue
if (linkRepo.toLowerCase() !== repo.toLowerCase()) continue
// Must be /issues/ or /pull/
if (pathType !== 'issues' && pathType !== 'pull') continue
numbers.add(linkNumber)
}
return [...numbers].sort((a, b) => a - b)
}
// ---------------------------------------------------------------------------
// Thanks section
// ---------------------------------------------------------------------------
/**
* Build the "### Thanks" section from a contributor map.
* @param {Map<string, { displayName: string, items: { kind: string, number: number, url: string }[] }>} contributors
*/
export function buildThanksSection(contributors) {
if (contributors.size === 0) return ''
const sorted = [...contributors.entries()].sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
const lines = ['### Thanks', '']
for (const [login, entry] of sorted) {
const refs = entry.items
.sort((a, b) => a.number - b.number)
.map((item) => `[#${item.number}](${item.url})`)
.join(', ')
lines.push(`* @${entry.displayName} for ${refs}`)
}
return lines.join('\n')
}
const THANKS_HEADING_RE = /^###\s+Thanks\s*$/
/**
* Remove any existing "### Thanks" block from within [start, end).
* A Thanks block starts with "### Thanks" and ends at the next "### " heading,
* blank line before a "## " heading, or the section boundary.
*/
export function removeThanksSection(text, start, end) {
const section = text.slice(start, end)
const lines = section.split('\n')
let thanksStart = -1
for (let i = 0; i < lines.length; i++) {
if (THANKS_HEADING_RE.test(lines[i])) {
thanksStart = i
break
}
}
if (thanksStart === -1) return text
// Find the end: next "### " heading or section boundary
let thanksEnd = lines.length
for (let i = thanksStart + 1; i < lines.length; i++) {
if (/^###\s/.test(lines[i])) {
thanksEnd = i
break
}
}
// Rebuild: drop lines [thanksStart, thanksEnd), preserve trailing blank
const before = lines.slice(0, thanksStart)
const after = lines.slice(thanksEnd)
// Trim trailing blank lines between thanks and next subsection
while (after.length > 0 && after[0] === '') after.shift()
const newSection = [...before, ...after].join('\n')
return text.slice(0, start) + newSection + text.slice(end)
}
/**
* Inject a Thanks section into the version block after the version heading
* line and its trailing blank lines, before the first subsection.
*/
export function injectThanksSection(text, thanksSection, start, end) {
const section = text.slice(start, end)
const lines = section.split('\n')
// Find where the heading + trailing blanks end
let insertAfter = 0 // line index of the version heading
for (let i = 0; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
insertAfter = i
break
}
}
// Skip trailing blank lines after the heading
while (insertAfter + 1 < lines.length && lines[insertAfter + 1] === '') {
insertAfter++
}
const before = lines.slice(0, insertAfter + 1)
const after = lines.slice(insertAfter + 1)
// Ensure blank separation
const parts = [...before]
if (parts[parts.length - 1] !== '') parts.push('')
parts.push(thanksSection)
if (after.length > 0 && after[0] !== '') parts.push('')
const newSection = [...parts, ...after].join('\n')
return text.slice(0, start) + newSection + text.slice(end)
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
export async function main() {
// Validate inputs
if (!TOKEN) {
console.error('GITHUB_TOKEN is required')
process.exit(1)
}
if (!REPO) {
console.error('GITHUB_REPOSITORY is required')
process.exit(1)
}
const [owner, repoName] = REPO.split('/')
if (!owner || !repoName) {
console.error(`Invalid GITHUB_REPOSITORY: ${REPO}`)
process.exit(1)
}
const changelogPath = resolve(process.cwd(), 'CHANGELOG.md')
let text
try {
text = readFileSync(changelogPath, 'utf-8')
} catch (err) {
console.error(`Cannot read CHANGELOG.md: ${err.message}`)
process.exit(1)
}
const section = parseVersionSection(text)
if (!section) {
console.error('No version heading found in CHANGELOG.md')
process.exit(1)
}
const sectionText = text.slice(section.start, section.end)
const numbers = extractReferences(sectionText, owner, repoName)
if (numbers.length === 0) {
// No references at all -- remove any stale Thanks, exit if no change
const cleaned = removeThanksSection(text, section.start, section.end)
writeFileSync(changelogPath, cleaned, 'utf-8')
process.exit(0)
}
// Look up every unique number
const contributors = new Map()
for (const num of numbers) {
let response
try {
response = await fetchWithRetry(REPO, num, TOKEN)
} catch (err) {
const status = err.status ?? 'network'
if (status === 404) {
console.warn(`Skipping #${num}: not found (404)`)
continue
}
if (err.status !== undefined && !shouldRetry(err.status, err.headers)) {
// Non-retryable error -- fail immediately
console.error(`Fatal error looking up #${num}: HTTP ${err.status}`)
process.exit(1)
}
// Retryable failure after all retries
console.error(`Failed to look up #${num} after ${MAX_RETRIES} attempts: ${err.message}`)
process.exit(1)
}
const classification = classifyAuthor(response)
if (classification === 'external') {
const login = response.user.login
if (!contributors.has(login)) {
contributors.set(login, {
displayName: login,
items: [],
})
}
const entry = contributors.get(login)
const url = response.html_url
const kind = response.pull_request ? 'pr' : 'issue'
// Avoid duplicate entries for the same number
if (!entry.items.some((item) => item.number === num)) {
entry.items.push({ kind, number: num, url })
}
}
}
// Build or remove Thanks section
let newText
if (contributors.size > 0) {
const thanks = buildThanksSection(contributors)
// Remove any existing Thanks first, then inject
const cleaned = removeThanksSection(text, section.start, section.end)
newText = injectThanksSection(cleaned, thanks, section.start, section.end)
} else {
newText = removeThanksSection(text, section.start, section.end)
}
writeFileSync(changelogPath, newText, 'utf-8')
process.exit(0)
}
// Only run main() when invoked as a script, not when imported for tests.
const runningDirectly =
process.argv[1] &&
(process.argv[1].endsWith('credit-changelog-contributors.mjs') ||
process.argv[1].endsWith('credit-changelog-contributors'))
if (runningDirectly) {
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
}
@@ -0,0 +1,622 @@
/**
* 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 pure helpers from the script
import {
parseVersionSection,
extractReferences,
classifyAuthor,
shouldRetry,
buildThanksSection,
removeThanksSection,
injectThanksSection,
} from './credit-changelog-contributors.mjs'
// ---------------------------------------------------------------------------
// 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)
// The 0.93.0 block starts at the "## [0.93.0]" line
assert.ok(text.slice(result.start, result.end).includes('## [0.93.0]'))
// It must NOT include 0.92.0 content
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)
// The block should contain the full text (no trailing \n means end === text.length)
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)
// String.indexOf works at code-unit level; the block should contain the heading
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', () => {
const OWNER = 'Studio-Saelix'
const REPO = 'sencho'
it('extracts PR references from ([#N](.../issues/N))', () => {
const text =
'* feat: add thing ([#1442](https://github.com/Studio-Saelix/sencho/issues/1442))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1442])
})
it('extracts linked-issue references from closes [#N](...)', () => {
const text =
'* fix: clear logs ([#1448](https://github.com/Studio-Saelix/sencho/issues/1448)) ([abc](...)), closes [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1444, 1448])
})
it('extracts fixes/resolves variants', () => {
const text =
'* fix: a ([#1](https://github.com/Studio-Saelix/sencho/issues/1)) ([...]), fixes [#2](https://github.com/Studio-Saelix/sencho/issues/2)\n' +
'* fix: b ([#3](https://github.com/Studio-Saelix/sencho/issues/3)) ([...]), resolves [#4](https://github.com/Studio-Saelix/sencho/issues/4)'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1, 2, 3, 4])
})
it('accepts /pull/ URLs', () => {
const text =
'* feat: pr ref ([#5](https://github.com/Studio-Saelix/sencho/pull/5))'
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))'
// The owner/repo must be passed as the pre-transfer ones to match
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 ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
'* feat: b ([#10](https://github.com/Studio-Saelix/sencho/issues/10))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [10])
})
it('returns sorted numbers', () => {
const text =
'* feat: c ([#30](https://github.com/Studio-Saelix/sencho/issues/30))\n' +
'* feat: a ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
'* feat: b ([#20](https://github.com/Studio-Saelix/sencho/issues/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, []) // #42 != #99
})
})
// ---------------------------------------------------------------------------
// 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', () => {
// MAINTAINER_LOGINS is read at module import time, so changing the env
// var here does not affect classifyAuthor's internal constant. This test
// verifies the baseline: overrideUser with NONE association is external
// when no maintainer logins are configured.
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', () => {
// Bot pattern is case-insensitive
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)
})
})
// ---------------------------------------------------------------------------
// buildThanksSection
// ---------------------------------------------------------------------------
describe('buildThanksSection', () => {
it('returns empty string for empty map', () => {
assert.equal(buildThanksSection(new Map()), '')
})
it('builds section for a single contributor', () => {
const map = new Map()
map.set('Crosis47', {
displayName: 'Crosis47',
items: [{ kind: 'issue', number: 1444, url: 'https://github.com/Studio-Saelix/sencho/issues/1444' }],
})
const result = buildThanksSection(map)
assert.ok(result.startsWith('### Thanks'))
assert.ok(result.includes('@Crosis47'))
assert.ok(result.includes('[#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'))
})
it('groups multiple contributions for same login', () => {
const map = new Map()
map.set('alice', {
displayName: 'alice',
items: [
{ kind: 'issue', number: 10, url: 'https://github.com/Studio-Saelix/sencho/issues/10' },
{ kind: 'pr', number: 5, url: 'https://github.com/Studio-Saelix/sencho/pull/5' },
],
})
const result = buildThanksSection(map)
assert.ok(result.includes('#5') && result.includes('#10'))
// Numbers sorted
const idx5 = result.indexOf('#5')
const idx10 = result.indexOf('#10')
assert.ok(idx5 < idx10)
})
it('sorts contributors alphabetically by login', () => {
const map = new Map()
map.set('zoe', { displayName: 'zoe', items: [{ kind: 'issue', number: 1, url: 'u' }] })
map.set('alice', { displayName: 'alice', items: [{ kind: 'issue', number: 2, url: 'u' }] })
const result = buildThanksSection(map)
const idxA = result.indexOf('@alice')
const idxZ = result.indexOf('@zoe')
assert.ok(idxA < idxZ)
})
})
// ---------------------------------------------------------------------------
// removeThanksSection
// ---------------------------------------------------------------------------
describe('removeThanksSection', () => {
it('removes existing Thanks from the block', () => {
const section = [
'## [0.93.0]',
'',
'### Thanks',
'',
'* @alice for [#1](u)',
'',
'### Added',
'* feat',
].join('\n')
const text = `# Changelog\n\n${section}\n\n## [0.92.0]`
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
assert.ok(!result.includes('### Thanks'))
assert.ok(result.includes('### Added'))
assert.ok(result.includes('## [0.92.0]'))
})
it('is no-op when no Thanks section exists', () => {
const text = [
'## [0.93.0]',
'',
'### Added',
'* feat',
].join('\n')
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
assert.equal(result, text)
})
it('does not touch previous release Thanks', () => {
const text = [
'## [0.94.0]',
'',
'### Added',
'* new feat',
'',
'## [0.93.0]',
'',
'### Thanks',
'',
'* @alice for [#1](u)',
'',
'### Fixed',
'* old fix',
].join('\n')
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
// Latest (0.94.0) has no Thanks, so no-op
assert.equal(result, text)
// But previous release still has its Thanks
assert.ok(result.includes('### Thanks'))
})
})
// ---------------------------------------------------------------------------
// injectThanksSection
// ---------------------------------------------------------------------------
describe('injectThanksSection', () => {
it('inserts Thanks after version heading', () => {
const text = [
'## [0.93.0]',
'',
'### Added',
'* feat',
].join('\n')
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @alice for [#1](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@alice'))
const thanksIdx = result.indexOf('### Thanks')
const addedIdx = result.indexOf('### Added')
assert.ok(thanksIdx < addedIdx)
})
it('injects into empty block (no subsections)', () => {
const text = '## [0.93.0]\n'
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @bob for [#2](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@bob'))
})
it('does not mutate content outside the block', () => {
const before = '# Changelog\n\n'
const block = '## [0.93.0]\n\n### Added\n* feat\n'
const after = '\n## [0.92.0]\n\n### Fixed\n* old fix\n'
const text = before + block + after
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @carol for [#3](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.startsWith(before))
assert.ok(result.endsWith(after))
})
})
// ---------------------------------------------------------------------------
// Bounded mutation: both releases have Thanks
// ---------------------------------------------------------------------------
describe('bounded mutation with multiple Thanks sections', () => {
it('removes Thanks only from latest, leaves previous intact', () => {
const text = [
'## [0.94.0]',
'',
'### Thanks',
'',
'* @new for [#10](u)',
'',
'### Added',
'* latest feat',
'',
'## [0.93.0]',
'',
'### Thanks',
'',
'* @old for [#5](u)',
'',
'### Fixed',
'* old fix',
].join('\n')
const parsed = parseVersionSection(text)
assert.notEqual(parsed, null)
// Verify latest section contains Thanks
const latest = text.slice(parsed.start, parsed.end)
assert.ok(latest.includes('### Thanks'))
assert.ok(latest.includes('@new'))
// Remove from latest
const result = removeThanksSection(text, parsed.start, parsed.end)
// Re-parse the result to get the new latest section boundaries
const reparsed = parseVersionSection(result)
const newLatest = result.slice(reparsed.start, reparsed.end)
assert.ok(!newLatest.includes('### Thanks'))
// Previous release still has its Thanks
assert.ok(result.includes('@old for [#5](u)'))
})
it('second-run idempotency: same output when run twice', () => {
const text = [
'## [0.93.0]',
'',
'### Added',
'* feat ([#1448](https://github.com/Studio-Saelix/sencho/issues/1448))',
' ([abc](...)), closes [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)',
].join('\n')
// First "run": no Thanks yet
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @Crosis47 for [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'
const first = injectThanksSection(text, thanks, parsed.start, parsed.end)
// Second "run": inject same thanks into already-injected result
// (remove first, then inject)
const parsed2 = parseVersionSection(first)
const cleaned = removeThanksSection(first, parsed2.start, parsed2.end)
const second = injectThanksSection(cleaned, thanks, parsed2.start, parsed2.end)
assert.equal(second, first)
})
it('empty-result removal is idempotent', () => {
const text = [
'## [0.93.0]',
'',
'### Thanks',
'',
'* @stale for [#1](u)',
'',
'### Added',
'* feat',
].join('\n')
const parsed = parseVersionSection(text)
const first = removeThanksSection(text, parsed.start, parsed.end)
const second = removeThanksSection(first, parsed.start, parsed.end)
assert.equal(second, first)
})
})
// ---------------------------------------------------------------------------
// End-to-end: no version heading
// ---------------------------------------------------------------------------
describe('missing version heading', () => {
it('parseVersionSection returns null, main() would exit 1', () => {
const text = '# Changelog\n\nNo version here.\n'
assert.equal(parseVersionSection(text), null)
})
})
// ---------------------------------------------------------------------------
// Line ending preservation (LF input)
// ---------------------------------------------------------------------------
describe('line ending preservation', () => {
it('preserves LF line endings', () => {
const text = '## [0.93.0]\n\n### Added\n* feat\n'
assert.ok(!text.includes('\r'))
const parsed = parseVersionSection(text)
const result = injectThanksSection(
text,
'### Thanks\n\n* @x for [#1](u)',
parsed.start,
parsed.end,
)
assert.ok(!result.includes('\r'))
// Verify the injected section separator uses LF
assert.ok(result.includes('\n\n### Added'))
})
it('preserves CRLF line endings', () => {
const text = '## [0.93.0]\r\n\r\n### Added\r\n* feat\r\n'
assert.ok(text.includes('\r\n'))
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @x for [#1](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
// Existing CRLF content survives; the injected section uses LF internally.
// The version heading and ### Added heading keep their CRLF endings.
assert.ok(result.includes('## [0.93.0]\r\n'))
assert.ok(result.includes('\r\n### Added'))
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@x'))
})
})