mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
chore: auto-credit external contributors in changelog (#1585)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user