mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
463 lines
14 KiB
JavaScript
463 lines
14 KiB
JavaScript
#!/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)
|
|
})
|
|
}
|