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

548 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Post-processes CHANGELOG.md on a release-please PR branch, appending an
* inline ", thanks @login" suffix to each logical top-level changelog bullet
* whose same-repo issue or PR references were opened by external, non-bot
* contributors. The managed suffix is written on the last line of the logical
* entry (the top-level bullet when single-line, otherwise the last indented
* non-list continuation).
*
* Logical entries: a top-level "* " / "- " bullet plus immediately following
* indented non-list continuation lines. Indented nested list items are copied
* unchanged and are not association or suffix targets. A blank line, heading,
* nested list item, or another top-level bullet terminates the entry.
*
* Generated suffix grammar (machine-managed only):
* , thanks @login
* , thanks @alice, @bob
* Exact manually authored suffixes of this form are indistinguishable and may
* be stripped or regenerated. Other prose containing "thanks" is preserved.
*
* Credits are regenerated from current API classifications each run. Stale
* suffixes are removed when no mapped external contributors remain. Legacy
* "### Thanks" blocks are removed from the latest version section only.
*
* 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 credits) is preserved.
* (Not a filesystem-level atomic rename.)
*
* 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
/** Exact trailing generated suffix: ", thanks @a" or ", thanks @a, @b" */
const INLINE_THANKS_RE = /, thanks @[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:, @[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)*\s*$/
const TOP_LEVEL_BULLET_RE = /^[*-] /
const NESTED_LIST_RE = /^\s+[*-] /
const HEADING_RE = /^#{1,3} /
const THANKS_HEADING_RE = /^###\s+Thanks\s*$/
const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
// ---------------------------------------------------------------------------
// 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
err.body = await res.text().catch((readErr) => {
console.warn(`Could not read error body for #${number}: ${readErr.message}`)
return undefined
})
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 || status === 502 || status === 503 || status === 504) return true
if (status === 403 && headers) {
return headers.get('x-ratelimit-remaining') === '0' || headers.get('retry-after') !== null
}
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'
}
// ---------------------------------------------------------------------------
// Line splitting (LF / CRLF preserving)
// ---------------------------------------------------------------------------
/**
* Split text into lines preserving each line's trailing newline (or none on EOF).
* @returns {{ content: string, newline: string, raw: string }[]}
* content = line without trailing newline; newline = '\n' | '\r\n' | '\r' | ''
*/
export function splitLines(text) {
const result = []
let i = 0
while (i < text.length) {
let j = i
while (j < text.length && text[j] !== '\n' && text[j] !== '\r') j++
const content = text.slice(i, j)
let newline = ''
if (j < text.length) {
if (text[j] === '\r' && text[j + 1] === '\n') {
newline = '\r\n'
j += 2
} else if (text[j] === '\r') {
newline = '\r'
j += 1
} else {
newline = '\n'
j += 1
}
}
result.push({ content, newline, raw: content + newline })
i = j
}
return result
}
export function joinLines(lines) {
return lines.map((l) => l.raw).join('')
}
/** Rebuild a split line after changing its content (preserves newline). */
function withContent(line, content) {
if (content === line.content) return line
return { content, newline: line.newline, raw: content + line.newline }
}
// ---------------------------------------------------------------------------
// Changelog parsing
// ---------------------------------------------------------------------------
/**
* 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 = splitLines(text)
let headingLine = -1
for (let i = 0; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i].content)) {
headingLine = i
break
}
}
if (headingLine === -1) return null
const start = text.indexOf(lines[headingLine].raw)
let endLine = lines.length
for (let i = headingLine + 1; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i].content)) {
endLine = i
break
}
}
const end =
endLine === lines.length ? text.length : text.indexOf(lines[endLine].raw, 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()
const linkRe = /\[#(\d+)]\(https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\)/gi
let match
while ((match = linkRe.exec(text)) !== null) {
const [, capturedText, linkOwner, linkRepo, , linkPathNumber] = match
const linkNumber = Number(linkPathNumber)
if (Number(capturedText) !== linkNumber) continue
if (linkOwner.toLowerCase() !== owner.toLowerCase()) continue
if (linkRepo.toLowerCase() !== repo.toLowerCase()) continue
numbers.add(linkNumber)
}
return [...numbers].sort((a, b) => a - b)
}
// ---------------------------------------------------------------------------
// Inline thanks suffix
// ---------------------------------------------------------------------------
/** Sort logins case-insensitively with deterministic tie-break on original. */
export function sortLogins(logins) {
return [...logins].sort((a, b) => {
const al = a.toLowerCase()
const bl = b.toLowerCase()
if (al !== bl) return al < bl ? -1 : 1
if (a !== b) return a < b ? -1 : 1
return 0
})
}
/** Deduplicate by case-insensitive key, keeping first-seen casing. */
export function dedupeLogins(logins) {
const seen = new Set()
const out = []
for (const login of logins) {
const key = login.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(login)
}
return out
}
/**
* Remove only the exact trailing generated suffix from a line's content
* (no trailing newline). Returns content unchanged if no match.
*/
export function stripInlineThanks(content) {
return content.replace(INLINE_THANKS_RE, '')
}
/**
* Append ", thanks @a, @b" after stripping any existing generated suffix.
* Empty logins yields stripped content with no suffix.
*/
export function applyInlineThanks(content, logins) {
const base = stripInlineThanks(content)
const unique = sortLogins(dedupeLogins(logins))
if (unique.length === 0) return base
return `${base}, thanks ${unique.map((l) => `@${l}`).join(', ')}`
}
// ---------------------------------------------------------------------------
// Legacy ### Thanks removal (latest section only)
// ---------------------------------------------------------------------------
/**
* Remove any existing "### Thanks" block from within [start, end).
* A Thanks block starts with "### Thanks" and ends at the next "### " heading
* or the section boundary.
*/
export function removeThanksSection(text, start, end) {
const section = text.slice(start, end)
const lines = splitLines(section)
let thanksStart = -1
for (let i = 0; i < lines.length; i++) {
if (THANKS_HEADING_RE.test(lines[i].content)) {
thanksStart = i
break
}
}
if (thanksStart === -1) return text
let thanksEnd = lines.length
for (let i = thanksStart + 1; i < lines.length; i++) {
if (/^###\s/.test(lines[i].content)) {
thanksEnd = i
break
}
}
const before = lines.slice(0, thanksStart)
const after = lines.slice(thanksEnd)
while (after.length > 0 && after[0].content === '') after.shift()
while (before.length > 0 && before[before.length - 1].content === '') {
before.pop()
}
if (before.length > 0 && after.length > 0) {
const nl = before[before.length - 1].newline || after[0].newline || '\n'
before.push({ content: '', newline: nl, raw: nl })
}
const newSection = joinLines([...before, ...after])
return text.slice(0, start) + newSection + text.slice(end)
}
// ---------------------------------------------------------------------------
// Logical bullet entries
// ---------------------------------------------------------------------------
/**
* Credit logical top-level bullets in a version section.
* Strips any managed suffix from every line of the entry, then writes the
* regenerated suffix on the last line of the logical entry only.
* @param {string} sectionText latest version section only
* @param {Map<number, string>} loginByNumber external login per issue/PR number
* @param {string} owner
* @param {string} repo
*/
export function creditChangelogEntries(sectionText, loginByNumber, owner, repo) {
const lines = splitLines(sectionText)
const out = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!TOP_LEVEL_BULLET_RE.test(line.content)) {
out.push(line)
i++
continue
}
// Collect logical entry: top-level bullet + indented non-list continuations
const entryLines = [line]
i++
while (i < lines.length) {
const next = lines[i]
if (
next.content === '' ||
HEADING_RE.test(next.content) ||
TOP_LEVEL_BULLET_RE.test(next.content) ||
NESTED_LIST_RE.test(next.content)
) {
break
}
if (!/^\s+\S/.test(next.content)) break
entryLines.push(next)
i++
}
// Strip managed suffixes from every line so a prior single-line credit
// does not linger after a continuation is added on a later run.
for (let j = 0; j < entryLines.length; j++) {
entryLines[j] = withContent(entryLines[j], stripInlineThanks(entryLines[j].content))
}
const blockText = entryLines.map((l) => l.content).join('\n')
const numbers = extractReferences(blockText, owner, repo)
const logins = []
for (const num of numbers) {
const login = loginByNumber.get(num)
if (login) logins.push(login)
}
const lastIdx = entryLines.length - 1
entryLines[lastIdx] = withContent(
entryLines[lastIdx],
applyInlineThanks(entryLines[lastIdx].content, logins),
)
out.push(...entryLines)
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
export async function main() {
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)
}
text = removeThanksSection(text, section.start, section.end)
const sectionAfterThanks = parseVersionSection(text)
if (!sectionAfterThanks) {
console.error('No version heading found after Thanks removal')
process.exit(1)
}
const sectionText = text.slice(sectionAfterThanks.start, sectionAfterThanks.end)
const numbers = extractReferences(sectionText, owner, repoName)
const loginByNumber = 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)) {
console.error(`Fatal error looking up #${num}: HTTP ${err.status}`)
process.exit(1)
}
console.error(`Failed to look up #${num} after ${MAX_RETRIES} attempts: ${err.message}`)
process.exit(1)
}
const classification = classifyAuthor(response)
if (classification === 'external') {
loginByNumber.set(num, response.user.login)
}
}
const credited = creditChangelogEntries(sectionText, loginByNumber, owner, repoName)
const newText =
text.slice(0, sectionAfterThanks.start) + credited + text.slice(sectionAfterThanks.end)
writeFileSync(changelogPath, newText, 'utf-8')
process.exit(0)
}
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)
})
}