diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 249fa5b0..0d10862c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 934ae716..a213a553 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -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 diff --git a/scripts/credit-changelog-contributors.mjs b/scripts/credit-changelog-contributors.mjs new file mode 100644 index 00000000..c2f34e00 --- /dev/null +++ b/scripts/credit-changelog-contributors.mjs @@ -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} 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) + }) +} diff --git a/scripts/credit-changelog-contributors.test.mjs b/scripts/credit-changelog-contributors.test.mjs new file mode 100644 index 00000000..38f59cb7 --- /dev/null +++ b/scripts/credit-changelog-contributors.test.mjs @@ -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')) + }) +})