mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -63,7 +63,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
# fromJSON must tolerate empty pr: env is evaluated even when if: skips
|
||||||
|
# (publish runs leave pr unset). Empty object yields empty RELEASE_BRANCH.
|
||||||
|
RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr || '{}').headBranchName || '' }}
|
||||||
MAINTAINER_LOGINS: ""
|
MAINTAINER_LOGINS: ""
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$RELEASE_BRANCH" ]; then
|
if [ -z "$RELEASE_BRANCH" ]; then
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-processes CHANGELOG.md on a release-please PR branch, adding a
|
* Post-processes CHANGELOG.md on a release-please PR branch, appending an
|
||||||
* "### Thanks" section that credits external, non-bot contributors whose
|
* inline ", thanks @login" suffix to each logical top-level changelog bullet
|
||||||
* issue or PR is referenced in the latest release block.
|
* 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.
|
* Atomic: builds the complete result in memory before touching the file.
|
||||||
* If any retryable lookup fails, exits nonzero without writing so the
|
* If any retryable lookup fails, exits nonzero without writing so the
|
||||||
* existing changelog (including any prior Thanks section) is preserved.
|
* existing changelog (including any prior credits) is preserved.
|
||||||
|
* (Not a filesystem-level atomic rename.)
|
||||||
*
|
*
|
||||||
* Inputs (env):
|
* Inputs (env):
|
||||||
* GITHUB_TOKEN (required) GitHub API token
|
* GITHUB_TOKEN (required) GitHub API token
|
||||||
@@ -40,6 +59,15 @@ const RETRY_WAITS_MS = [1000, 2000]
|
|||||||
const MAX_WAIT_PER_RETRY_MS = 15_000
|
const MAX_WAIT_PER_RETRY_MS = 15_000
|
||||||
const TOTAL_RETRY_CAP_MS = 40_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
|
// GitHub API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,8 +90,10 @@ async function fetchIssue(repo, number, token) {
|
|||||||
const err = new Error(`GitHub API ${res.status} for #${number}`)
|
const err = new Error(`GitHub API ${res.status} for #${number}`)
|
||||||
err.status = res.status
|
err.status = res.status
|
||||||
err.headers = res.headers
|
err.headers = res.headers
|
||||||
// Attach body text for diagnostics but do not leak into error messages
|
err.body = await res.text().catch((readErr) => {
|
||||||
try { err.body = await res.text() } catch { /* ignore */ }
|
console.warn(`Could not read error body for #${number}: ${readErr.message}`)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -71,13 +101,10 @@ async function fetchIssue(repo, number, token) {
|
|||||||
|
|
||||||
/** True when the HTTP status is a retryable server/rate-limit error. */
|
/** True when the HTTP status is a retryable server/rate-limit error. */
|
||||||
export function shouldRetry(status, headers) {
|
export function shouldRetry(status, headers) {
|
||||||
if (status === 429) return true
|
if (status === 429 || status === 502 || status === 503 || status === 504) return true
|
||||||
if (status === 403 && headers) {
|
if (status === 403 && headers) {
|
||||||
const remaining = headers.get('x-ratelimit-remaining')
|
return headers.get('x-ratelimit-remaining') === '0' || headers.get('retry-after') !== null
|
||||||
const retryAfter = headers.get('retry-after')
|
|
||||||
if (remaining === '0' || retryAfter !== null) return true
|
|
||||||
}
|
}
|
||||||
if (status === 502 || status === 503 || status === 504) return true
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +187,53 @@ export function classifyAuthor(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Changelog parsing
|
// Line splitting (LF / CRLF preserving)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
|
/**
|
||||||
|
* 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.
|
* Locate the first version heading and return its character-index range.
|
||||||
@@ -171,35 +241,28 @@ const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
|
|||||||
* @returns {{ start: number, end: number } | null}
|
* @returns {{ start: number, end: number } | null}
|
||||||
*/
|
*/
|
||||||
export function parseVersionSection(text) {
|
export function parseVersionSection(text) {
|
||||||
const lines = text.split('\n')
|
const lines = splitLines(text)
|
||||||
let headingLine = -1
|
let headingLine = -1
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (VERSION_HEADING_RE.test(lines[i])) {
|
if (VERSION_HEADING_RE.test(lines[i].content)) {
|
||||||
headingLine = i
|
headingLine = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (headingLine === -1) return null
|
if (headingLine === -1) return null
|
||||||
|
|
||||||
// Start: character index of the heading line in the original text
|
const start = text.indexOf(lines[headingLine].raw)
|
||||||
const start = text.indexOf(lines[headingLine])
|
|
||||||
|
|
||||||
// Find end line: next version heading or EOF
|
|
||||||
let endLine = lines.length
|
let endLine = lines.length
|
||||||
for (let i = headingLine + 1; i < lines.length; i++) {
|
for (let i = headingLine + 1; i < lines.length; i++) {
|
||||||
if (VERSION_HEADING_RE.test(lines[i])) {
|
if (VERSION_HEADING_RE.test(lines[i].content)) {
|
||||||
endLine = i
|
endLine = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// End: if EOF, text.length; otherwise start of the next heading
|
const end =
|
||||||
let end
|
endLine === lines.length ? text.length : text.indexOf(lines[endLine].raw, start)
|
||||||
if (endLine === lines.length) {
|
|
||||||
end = text.length
|
|
||||||
} else {
|
|
||||||
end = text.indexOf(lines[endLine], start)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
@@ -212,32 +275,15 @@ export function parseVersionSection(text) {
|
|||||||
export function extractReferences(text, owner, repo) {
|
export function extractReferences(text, owner, repo) {
|
||||||
const numbers = new Set()
|
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
|
const linkRe = /\[#(\d+)]\(https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\)/gi
|
||||||
|
|
||||||
let match
|
let match
|
||||||
while ((match = linkRe.exec(text)) !== null) {
|
while ((match = linkRe.exec(text)) !== null) {
|
||||||
const linkOwner = match[2]
|
const [, capturedText, linkOwner, linkRepo, , linkPathNumber] = match
|
||||||
const linkRepo = match[3]
|
const linkNumber = Number(linkPathNumber)
|
||||||
const pathType = match[4]
|
if (Number(capturedText) !== linkNumber) continue
|
||||||
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 (linkOwner.toLowerCase() !== owner.toLowerCase()) continue
|
||||||
if (linkRepo.toLowerCase() !== repo.toLowerCase()) continue
|
if (linkRepo.toLowerCase() !== repo.toLowerCase()) continue
|
||||||
|
|
||||||
// Must be /issues/ or /pull/
|
|
||||||
if (pathType !== 'issues' && pathType !== 'pull') continue
|
|
||||||
|
|
||||||
numbers.add(linkNumber)
|
numbers.add(linkNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,46 +291,68 @@ export function extractReferences(text, owner, repo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Thanks section
|
// 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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* 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).
|
* Remove any existing "### Thanks" block from within [start, end).
|
||||||
* A Thanks block starts with "### Thanks" and ends at the next "### " heading,
|
* A Thanks block starts with "### Thanks" and ends at the next "### " heading
|
||||||
* blank line before a "## " heading, or the section boundary.
|
* or the section boundary.
|
||||||
*/
|
*/
|
||||||
export function removeThanksSection(text, start, end) {
|
export function removeThanksSection(text, start, end) {
|
||||||
const section = text.slice(start, end)
|
const section = text.slice(start, end)
|
||||||
const lines = section.split('\n')
|
const lines = splitLines(section)
|
||||||
|
|
||||||
let thanksStart = -1
|
let thanksStart = -1
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (THANKS_HEADING_RE.test(lines[i])) {
|
if (THANKS_HEADING_RE.test(lines[i].content)) {
|
||||||
thanksStart = i
|
thanksStart = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -292,58 +360,100 @@ export function removeThanksSection(text, start, end) {
|
|||||||
|
|
||||||
if (thanksStart === -1) return text
|
if (thanksStart === -1) return text
|
||||||
|
|
||||||
// Find the end: next "### " heading or section boundary
|
|
||||||
let thanksEnd = lines.length
|
let thanksEnd = lines.length
|
||||||
for (let i = thanksStart + 1; i < lines.length; i++) {
|
for (let i = thanksStart + 1; i < lines.length; i++) {
|
||||||
if (/^###\s/.test(lines[i])) {
|
if (/^###\s/.test(lines[i].content)) {
|
||||||
thanksEnd = i
|
thanksEnd = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild: drop lines [thanksStart, thanksEnd), preserve trailing blank
|
|
||||||
const before = lines.slice(0, thanksStart)
|
const before = lines.slice(0, thanksStart)
|
||||||
const after = lines.slice(thanksEnd)
|
const after = lines.slice(thanksEnd)
|
||||||
|
|
||||||
// Trim trailing blank lines between thanks and next subsection
|
while (after.length > 0 && after[0].content === '') after.shift()
|
||||||
while (after.length > 0 && after[0] === '') after.shift()
|
while (before.length > 0 && before[before.length - 1].content === '') {
|
||||||
|
before.pop()
|
||||||
|
}
|
||||||
|
|
||||||
const newSection = [...before, ...after].join('\n')
|
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)
|
return text.slice(0, start) + newSection + text.slice(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logical bullet entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject a Thanks section into the version block after the version heading
|
* Credit logical top-level bullets in a version section.
|
||||||
* line and its trailing blank lines, before the first subsection.
|
* 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 injectThanksSection(text, thanksSection, start, end) {
|
export function creditChangelogEntries(sectionText, loginByNumber, owner, repo) {
|
||||||
const section = text.slice(start, end)
|
const lines = splitLines(sectionText)
|
||||||
const lines = section.split('\n')
|
const out = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
// Find where the heading + trailing blanks end
|
while (i < lines.length) {
|
||||||
let insertAfter = 0 // line index of the version heading
|
const line = lines[i]
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (VERSION_HEADING_RE.test(lines[i])) {
|
if (!TOP_LEVEL_BULLET_RE.test(line.content)) {
|
||||||
insertAfter = i
|
out.push(line)
|
||||||
break
|
i++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Skip trailing blank lines after the heading
|
// Collect logical entry: top-level bullet + indented non-list continuations
|
||||||
while (insertAfter + 1 < lines.length && lines[insertAfter + 1] === '') {
|
const entryLines = [line]
|
||||||
insertAfter++
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = lines.slice(0, insertAfter + 1)
|
return joinLines(out)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -351,7 +461,6 @@ export function injectThanksSection(text, thanksSection, start, end) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
// Validate inputs
|
|
||||||
if (!TOKEN) {
|
if (!TOKEN) {
|
||||||
console.error('GITHUB_TOKEN is required')
|
console.error('GITHUB_TOKEN is required')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@@ -382,18 +491,17 @@ export async function main() {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionText = text.slice(section.start, section.end)
|
text = removeThanksSection(text, section.start, section.end)
|
||||||
const numbers = extractReferences(sectionText, owner, repoName)
|
const sectionAfterThanks = parseVersionSection(text)
|
||||||
|
if (!sectionAfterThanks) {
|
||||||
if (numbers.length === 0) {
|
console.error('No version heading found after Thanks removal')
|
||||||
// No references at all -- remove any stale Thanks, exit if no change
|
process.exit(1)
|
||||||
const cleaned = removeThanksSection(text, section.start, section.end)
|
|
||||||
writeFileSync(changelogPath, cleaned, 'utf-8')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up every unique number
|
const sectionText = text.slice(sectionAfterThanks.start, sectionAfterThanks.end)
|
||||||
const contributors = new Map()
|
const numbers = extractReferences(sectionText, owner, repoName)
|
||||||
|
|
||||||
|
const loginByNumber = new Map()
|
||||||
for (const num of numbers) {
|
for (const num of numbers) {
|
||||||
let response
|
let response
|
||||||
try {
|
try {
|
||||||
@@ -405,50 +513,27 @@ export async function main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (err.status !== undefined && !shouldRetry(err.status, err.headers)) {
|
if (err.status !== undefined && !shouldRetry(err.status, err.headers)) {
|
||||||
// Non-retryable error -- fail immediately
|
|
||||||
console.error(`Fatal error looking up #${num}: HTTP ${err.status}`)
|
console.error(`Fatal error looking up #${num}: HTTP ${err.status}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
// Retryable failure after all retries
|
|
||||||
console.error(`Failed to look up #${num} after ${MAX_RETRIES} attempts: ${err.message}`)
|
console.error(`Failed to look up #${num} after ${MAX_RETRIES} attempts: ${err.message}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = classifyAuthor(response)
|
const classification = classifyAuthor(response)
|
||||||
if (classification === 'external') {
|
if (classification === 'external') {
|
||||||
const login = response.user.login
|
loginByNumber.set(num, 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
|
const credited = creditChangelogEntries(sectionText, loginByNumber, owner, repoName)
|
||||||
let newText
|
const newText =
|
||||||
if (contributors.size > 0) {
|
text.slice(0, sectionAfterThanks.start) + credited + text.slice(sectionAfterThanks.end)
|
||||||
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')
|
writeFileSync(changelogPath, newText, 'utf-8')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only run main() when invoked as a script, not when imported for tests.
|
|
||||||
const runningDirectly =
|
const runningDirectly =
|
||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
(process.argv[1].endsWith('credit-changelog-contributors.mjs') ||
|
(process.argv[1].endsWith('credit-changelog-contributors.mjs') ||
|
||||||
|
|||||||
@@ -10,17 +10,28 @@
|
|||||||
import { describe, it } from 'node:test'
|
import { describe, it } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
// Import pure helpers from the script
|
|
||||||
import {
|
import {
|
||||||
parseVersionSection,
|
parseVersionSection,
|
||||||
extractReferences,
|
extractReferences,
|
||||||
classifyAuthor,
|
classifyAuthor,
|
||||||
shouldRetry,
|
shouldRetry,
|
||||||
buildThanksSection,
|
stripInlineThanks,
|
||||||
|
applyInlineThanks,
|
||||||
|
sortLogins,
|
||||||
|
dedupeLogins,
|
||||||
removeThanksSection,
|
removeThanksSection,
|
||||||
injectThanksSection,
|
creditChangelogEntries,
|
||||||
|
splitLines,
|
||||||
|
joinLines,
|
||||||
} from './credit-changelog-contributors.mjs'
|
} from './credit-changelog-contributors.mjs'
|
||||||
|
|
||||||
|
const OWNER = 'Studio-Saelix'
|
||||||
|
const REPO = 'sencho'
|
||||||
|
|
||||||
|
function sameRepo(n, kind = 'issues') {
|
||||||
|
return `[#${n}](https://github.com/${OWNER}/${REPO}/${kind}/${n})`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// parseVersionSection
|
// parseVersionSection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -43,9 +54,7 @@ describe('parseVersionSection', () => {
|
|||||||
|
|
||||||
const result = parseVersionSection(text)
|
const result = parseVersionSection(text)
|
||||||
assert.notEqual(result, null)
|
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]'))
|
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]'))
|
assert.ok(!text.slice(result.start, result.end).includes('## [0.92.0]'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +74,6 @@ describe('parseVersionSection', () => {
|
|||||||
const text = '## [1.0.0]\n\n### Added\n* feat\n'
|
const text = '## [1.0.0]\n\n### Added\n* feat\n'
|
||||||
const result = parseVersionSection(text)
|
const result = parseVersionSection(text)
|
||||||
assert.notEqual(result, null)
|
assert.notEqual(result, null)
|
||||||
// The block should contain the full text (no trailing \n means end === text.length)
|
|
||||||
assert.equal(result.end, text.length)
|
assert.equal(result.end, text.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,7 +81,6 @@ describe('parseVersionSection', () => {
|
|||||||
const text = '# Changelog — all notable changes\n\n## [0.94.0]\n\n### Added\n* item ✓\n'
|
const text = '# Changelog — all notable changes\n\n## [0.94.0]\n\n### Added\n* item ✓\n'
|
||||||
const result = parseVersionSection(text)
|
const result = parseVersionSection(text)
|
||||||
assert.notEqual(result, null)
|
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]'))
|
assert.ok(text.slice(result.start, result.end).startsWith('## [0.94.0]'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,34 +106,29 @@ describe('parseVersionSection', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('extractReferences', () => {
|
describe('extractReferences', () => {
|
||||||
const OWNER = 'Studio-Saelix'
|
|
||||||
const REPO = 'sencho'
|
|
||||||
|
|
||||||
it('extracts PR references from ([#N](.../issues/N))', () => {
|
it('extracts PR references from ([#N](.../issues/N))', () => {
|
||||||
const text =
|
const text = `* feat: add thing (${sameRepo(1442)})`
|
||||||
'* feat: add thing ([#1442](https://github.com/Studio-Saelix/sencho/issues/1442))'
|
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [1442])
|
assert.deepEqual(refs, [1442])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extracts linked-issue references from closes [#N](...)', () => {
|
it('extracts linked-issue references from closes [#N](...)', () => {
|
||||||
const text =
|
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)'
|
`* fix: clear logs (${sameRepo(1448)}) ([abc](...)), closes ${sameRepo(1444)}`
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [1444, 1448])
|
assert.deepEqual(refs, [1444, 1448])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extracts fixes/resolves variants', () => {
|
it('extracts fixes/resolves variants', () => {
|
||||||
const text =
|
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: a (${sameRepo(1)}) ([...]), fixes ${sameRepo(2)}\n` +
|
||||||
'* fix: b ([#3](https://github.com/Studio-Saelix/sencho/issues/3)) ([...]), resolves [#4](https://github.com/Studio-Saelix/sencho/issues/4)'
|
`* fix: b (${sameRepo(3)}) ([...]), resolves ${sameRepo(4)}`
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [1, 2, 3, 4])
|
assert.deepEqual(refs, [1, 2, 3, 4])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts /pull/ URLs', () => {
|
it('accepts /pull/ URLs', () => {
|
||||||
const text =
|
const text = `* feat: pr ref (${sameRepo(5, 'pull')})`
|
||||||
'* feat: pr ref ([#5](https://github.com/Studio-Saelix/sencho/pull/5))'
|
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [5])
|
assert.deepEqual(refs, [5])
|
||||||
})
|
})
|
||||||
@@ -141,7 +143,6 @@ describe('extractReferences', () => {
|
|||||||
it('accepts pre-transfer org (AnsoCode/Sencho)', () => {
|
it('accepts pre-transfer org (AnsoCode/Sencho)', () => {
|
||||||
const text =
|
const text =
|
||||||
'* old: thing ([#583](https://github.com/AnsoCode/Sencho/issues/583))'
|
'* 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')
|
const refs = extractReferences(text, 'AnsoCode', 'Sencho')
|
||||||
assert.deepEqual(refs, [583])
|
assert.deepEqual(refs, [583])
|
||||||
})
|
})
|
||||||
@@ -155,17 +156,17 @@ describe('extractReferences', () => {
|
|||||||
|
|
||||||
it('deduplicates repeated numbers', () => {
|
it('deduplicates repeated numbers', () => {
|
||||||
const text =
|
const text =
|
||||||
'* feat: a ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
|
`* feat: a (${sameRepo(10)})\n` +
|
||||||
'* feat: b ([#10](https://github.com/Studio-Saelix/sencho/issues/10))'
|
`* feat: b (${sameRepo(10)})`
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [10])
|
assert.deepEqual(refs, [10])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns sorted numbers', () => {
|
it('returns sorted numbers', () => {
|
||||||
const text =
|
const text =
|
||||||
'* feat: c ([#30](https://github.com/Studio-Saelix/sencho/issues/30))\n' +
|
`* feat: c (${sameRepo(30)})\n` +
|
||||||
'* feat: a ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
|
`* feat: a (${sameRepo(10)})\n` +
|
||||||
'* feat: b ([#20](https://github.com/Studio-Saelix/sencho/issues/20))'
|
`* feat: b (${sameRepo(20)})`
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, [10, 20, 30])
|
assert.deepEqual(refs, [10, 20, 30])
|
||||||
})
|
})
|
||||||
@@ -181,7 +182,7 @@ describe('extractReferences', () => {
|
|||||||
const text =
|
const text =
|
||||||
'* feat: wrong ([#42](https://github.com/Studio-Saelix/sencho/issues/99))'
|
'* feat: wrong ([#42](https://github.com/Studio-Saelix/sencho/issues/99))'
|
||||||
const refs = extractReferences(text, OWNER, REPO)
|
const refs = extractReferences(text, OWNER, REPO)
|
||||||
assert.deepEqual(refs, []) // #42 != #99
|
assert.deepEqual(refs, [])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,11 +262,7 @@ describe('classifyAuthor', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respects MAINTAINER_LOGINS override', () => {
|
it('respects MAINTAINER_LOGINS override baseline (module-load constant)', () => {
|
||||||
// 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
|
const prev = process.env.MAINTAINER_LOGINS
|
||||||
process.env.MAINTAINER_LOGINS = 'overrideUser'
|
process.env.MAINTAINER_LOGINS = 'overrideUser'
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -279,7 +276,6 @@ describe('classifyAuthor', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('case-insensitive login matching', () => {
|
it('case-insensitive login matching', () => {
|
||||||
// Bot pattern is case-insensitive
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
classifyAuthor({
|
classifyAuthor({
|
||||||
user: { type: 'User', login: 'SomeBot[BOT]' },
|
user: { type: 'User', login: 'SomeBot[BOT]' },
|
||||||
@@ -334,289 +330,356 @@ describe('shouldRetry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildThanksSection
|
// Inline thanks helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('buildThanksSection', () => {
|
describe('inline thanks helpers', () => {
|
||||||
it('returns empty string for empty map', () => {
|
it('strips exact trailing suffix', () => {
|
||||||
assert.equal(buildThanksSection(new Map()), '')
|
assert.equal(
|
||||||
|
stripInlineThanks('closes #1, thanks @auspex'),
|
||||||
|
'closes #1',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds section for a single contributor', () => {
|
it('preserves near-match prose that is not the managed suffix', () => {
|
||||||
const map = new Map()
|
const prose = 'special thanks to everyone who filed bugs'
|
||||||
map.set('Crosis47', {
|
assert.equal(stripInlineThanks(prose), prose)
|
||||||
displayName: 'Crosis47',
|
assert.equal(applyInlineThanks(prose, []), prose)
|
||||||
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', () => {
|
it('dedupes case-insensitively keeping first casing', () => {
|
||||||
const map = new Map()
|
assert.deepEqual(dedupeLogins(['Auspex', 'auspex', 'Bob']), ['Auspex', 'Bob'])
|
||||||
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', () => {
|
it('sorts case-insensitively with deterministic tie-break', () => {
|
||||||
const map = new Map()
|
assert.deepEqual(sortLogins(['bob', 'Alice', 'alice']), ['Alice', 'alice', 'bob'])
|
||||||
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)
|
it('applies sorted unique suffix', () => {
|
||||||
const idxA = result.indexOf('@alice')
|
// First-seen casing wins ('alice'); Alice is dropped as a case-insensitive dup.
|
||||||
const idxZ = result.indexOf('@zoe')
|
assert.equal(
|
||||||
assert.ok(idxA < idxZ)
|
applyInlineThanks('line', ['bob', 'alice', 'Alice']),
|
||||||
|
'line, thanks @alice, @bob',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes stale suffix when logins empty', () => {
|
||||||
|
assert.equal(
|
||||||
|
applyInlineThanks('line, thanks @stale', []),
|
||||||
|
'line',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// removeThanksSection
|
// creditChangelogEntries
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('removeThanksSection', () => {
|
describe('creditChangelogEntries', () => {
|
||||||
it('removes existing Thanks from the block', () => {
|
it('0.95.0-style bullet: PR plus closes #1581 becomes thanks @auspex', () => {
|
||||||
|
const section = [
|
||||||
|
'## [0.95.0](...) (2026-07-12)',
|
||||||
|
'',
|
||||||
|
'### Fixed',
|
||||||
|
`* **drift:** resolve explicit network names that equal compose keys (${sameRepo(1588)}) ([4123793](...)), closes ${sameRepo(1581)}`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const map = new Map([[1581, 'auspex']])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
assert.ok(result.includes(`, closes ${sameRepo(1581)}, thanks @auspex`))
|
||||||
|
assert.ok(!result.includes('thanks @auspex, thanks'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('credits reference on indented non-list continuation line', () => {
|
||||||
const section = [
|
const section = [
|
||||||
'## [0.93.0]',
|
'## [0.93.0]',
|
||||||
'',
|
'',
|
||||||
'### Thanks',
|
|
||||||
'',
|
|
||||||
'* @alice for [#1](u)',
|
|
||||||
'',
|
|
||||||
'### Added',
|
'### Added',
|
||||||
'* feat',
|
`* feat (${sameRepo(1448)})`,
|
||||||
|
` ([abc](...)), closes ${sameRepo(1444)}`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const text = `# Changelog\n\n${section}\n\n## [0.92.0]`
|
|
||||||
const parsed = parseVersionSection(text)
|
const map = new Map([[1444, 'Crosis47']])
|
||||||
const result = removeThanksSection(text, parsed.start, parsed.end)
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
assert.ok(!result.includes('### Thanks'))
|
assert.ok(result.includes(`closes ${sameRepo(1444)}, thanks @Crosis47`))
|
||||||
assert.ok(result.includes('### Added'))
|
assert.ok(!result.split('\n')[3].includes('thanks'))
|
||||||
assert.ok(result.includes('## [0.92.0]'))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is no-op when no Thanks section exists', () => {
|
it('dash top-level bullet is credited', () => {
|
||||||
const text = [
|
const section = [
|
||||||
'## [0.93.0]',
|
'## [1.0.0]',
|
||||||
'',
|
'',
|
||||||
'### Added',
|
`### Fixed`,
|
||||||
'* feat',
|
`- fix: thing (${sameRepo(10)})`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const parsed = parseVersionSection(text)
|
const map = new Map([[10, 'dashUser']])
|
||||||
const result = removeThanksSection(text, parsed.start, parsed.end)
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
assert.equal(result, text)
|
assert.ok(result.includes(`- fix: thing (${sameRepo(10)}), thanks @dashUser`))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not touch previous release Thanks', () => {
|
it('same-repo #123 credited; cross-repo #123 on other bullet is not', () => {
|
||||||
const text = [
|
const section = [
|
||||||
'## [0.94.0]',
|
'## [1.0.0]',
|
||||||
'',
|
|
||||||
'### Added',
|
|
||||||
'* new feat',
|
|
||||||
'',
|
|
||||||
'## [0.93.0]',
|
|
||||||
'',
|
|
||||||
'### Thanks',
|
|
||||||
'',
|
|
||||||
'* @alice for [#1](u)',
|
|
||||||
'',
|
'',
|
||||||
'### Fixed',
|
'### Fixed',
|
||||||
'* old fix',
|
`* same (${sameRepo(123)})`,
|
||||||
|
'* cross ([#123](https://github.com/OtherOrg/other/issues/123))',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const parsed = parseVersionSection(text)
|
const map = new Map([[123, 'sameRepoUser']])
|
||||||
const result = removeThanksSection(text, parsed.start, parsed.end)
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
// Latest (0.94.0) has no Thanks, so no-op
|
const lines = result.split('\n')
|
||||||
assert.equal(result, text)
|
assert.ok(lines[3].includes(', thanks @sameRepoUser'))
|
||||||
// But previous release still has its Thanks
|
assert.equal(lines[4], '* cross ([#123](https://github.com/OtherOrg/other/issues/123))')
|
||||||
assert.ok(result.includes('### Thanks'))
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
it('two externals on one logical bullet: sorted and deduplicated', () => {
|
||||||
// injectThanksSection
|
const section = [
|
||||||
// ---------------------------------------------------------------------------
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
`* fix (${sameRepo(1)}) closes ${sameRepo(2)}`,
|
||||||
|
].join('\n')
|
||||||
|
const map = new Map([
|
||||||
|
[1, 'zoe'],
|
||||||
|
[2, 'alice'],
|
||||||
|
])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
assert.ok(result.includes(', thanks @alice, @zoe'))
|
||||||
|
})
|
||||||
|
|
||||||
describe('injectThanksSection', () => {
|
it('one contributor on multiple bullets credits each applicable bullet', () => {
|
||||||
it('inserts Thanks after version heading', () => {
|
const section = [
|
||||||
const text = [
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
`* a (${sameRepo(1)})`,
|
||||||
|
`* b (${sameRepo(1)})`,
|
||||||
|
`* c (${sameRepo(2)})`,
|
||||||
|
].join('\n')
|
||||||
|
const map = new Map([
|
||||||
|
[1, 'helper'],
|
||||||
|
[2, 'other'],
|
||||||
|
])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
const lines = result.split('\n')
|
||||||
|
assert.ok(lines[2].endsWith(', thanks @helper'))
|
||||||
|
assert.ok(lines[3].endsWith(', thanks @helper'))
|
||||||
|
assert.ok(lines[4].endsWith(', thanks @other'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('second run produces byte-identical output', () => {
|
||||||
|
const section = [
|
||||||
'## [0.93.0]',
|
'## [0.93.0]',
|
||||||
'',
|
'',
|
||||||
'### Added',
|
'### Added',
|
||||||
'* feat',
|
`* feat (${sameRepo(1448)})`,
|
||||||
|
` ([abc](...)), closes ${sameRepo(1444)}`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const parsed = parseVersionSection(text)
|
const map = new Map([[1444, 'Crosis47']])
|
||||||
const thanks = '### Thanks\n\n* @alice for [#1](u)'
|
const first = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
|
const second = creditChangelogEntries(first, map, OWNER, REPO)
|
||||||
assert.ok(result.includes('### Thanks'))
|
assert.equal(second, first)
|
||||||
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)', () => {
|
it('strips stale suffix from first line when a continuation is added later', () => {
|
||||||
const text = '## [0.93.0]\n'
|
const section = [
|
||||||
const parsed = parseVersionSection(text)
|
'## [1.0.0]',
|
||||||
const thanks = '### Thanks\n\n* @bob for [#2](u)'
|
'',
|
||||||
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
|
`* feat (${sameRepo(1)}), thanks @old`,
|
||||||
assert.ok(result.includes('### Thanks'))
|
` continuation closes ${sameRepo(2)}`,
|
||||||
assert.ok(result.includes('@bob'))
|
].join('\n')
|
||||||
|
const map = new Map([[2, 'newUser']])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
const lines = result.split('\n')
|
||||||
|
assert.equal(lines[2], `* feat (${sameRepo(1)})`)
|
||||||
|
assert.ok(lines[3].endsWith(', thanks @newUser'))
|
||||||
|
assert.ok(!lines[2].includes('thanks'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not mutate content outside the block', () => {
|
it('removes stale inline credit when login map has no matching externals', () => {
|
||||||
const before = '# Changelog\n\n'
|
const section = [
|
||||||
const block = '## [0.93.0]\n\n### Added\n* feat\n'
|
'## [1.0.0]',
|
||||||
const after = '\n## [0.92.0]\n\n### Fixed\n* old fix\n'
|
'',
|
||||||
const text = before + block + after
|
`* fix (${sameRepo(1)}), thanks @stale`,
|
||||||
const parsed = parseVersionSection(text)
|
].join('\n')
|
||||||
const thanks = '### Thanks\n\n* @carol for [#3](u)'
|
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
|
||||||
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
|
assert.equal(result.split('\n')[2], `* fix (${sameRepo(1)})`)
|
||||||
assert.ok(result.startsWith(before))
|
})
|
||||||
assert.ok(result.endsWith(after))
|
|
||||||
|
it('bullet with no external refs and no suffix remains byte-identical', () => {
|
||||||
|
const section = [
|
||||||
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
'### Added',
|
||||||
|
'* feat: plain entry',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
|
||||||
|
assert.equal(result, section)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves near-match thanks prose that is not the managed suffix', () => {
|
||||||
|
const section = [
|
||||||
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
'* feat: special thanks to reviewers',
|
||||||
|
].join('\n')
|
||||||
|
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
|
||||||
|
assert.equal(result, section)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nested list child remains unchanged; suffix on parent', () => {
|
||||||
|
const section = [
|
||||||
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
`* parent with ref (${sameRepo(7)})`,
|
||||||
|
' * nested child stays put',
|
||||||
|
'* sibling',
|
||||||
|
].join('\n')
|
||||||
|
const map = new Map([[7, 'parentUser']])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
const lines = result.split('\n')
|
||||||
|
assert.equal(lines[2], `* parent with ref (${sameRepo(7)}), thanks @parentUser`)
|
||||||
|
assert.equal(lines[3], ' * nested child stays put')
|
||||||
|
assert.equal(lines[4], '* sibling')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nested list refs are excluded from parent association', () => {
|
||||||
|
const section = [
|
||||||
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
'* parent with no refs',
|
||||||
|
` * nested closes ${sameRepo(7)}`,
|
||||||
|
].join('\n')
|
||||||
|
const map = new Map([[7, 'nestedUser']])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
assert.equal(result, section)
|
||||||
|
assert.ok(!result.includes('thanks'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blank line terminates logical entry (later prose not absorbed)', () => {
|
||||||
|
const section = [
|
||||||
|
'## [1.0.0]',
|
||||||
|
'',
|
||||||
|
`* parent (${sameRepo(1)})`,
|
||||||
|
'',
|
||||||
|
' later prose that looks indented',
|
||||||
|
].join('\n')
|
||||||
|
const map = new Map([[1, 'user']])
|
||||||
|
const result = creditChangelogEntries(section, map, OWNER, REPO)
|
||||||
|
const lines = result.split('\n')
|
||||||
|
assert.equal(lines[2], `* parent (${sameRepo(1)}), thanks @user`)
|
||||||
|
assert.equal(lines[3], '')
|
||||||
|
assert.equal(lines[4], ' later prose that looks indented')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves LF after an actual inline rewrite', () => {
|
||||||
|
const section = `## [1.0.0]\n\n* feat (${sameRepo(1)})\n`
|
||||||
|
assert.ok(!section.includes('\r'))
|
||||||
|
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
|
||||||
|
assert.ok(!result.includes('\r'))
|
||||||
|
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\n`))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves CRLF after an actual inline rewrite', () => {
|
||||||
|
const section = `## [1.0.0]\r\n\r\n* feat (${sameRepo(1)})\r\n`
|
||||||
|
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
|
||||||
|
assert.ok(result.includes('## [1.0.0]\r\n'))
|
||||||
|
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\r\n`))
|
||||||
|
assert.equal(result.includes('\n') && !result.includes('\r\n'), false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bounded mutation: both releases have Thanks
|
// removeThanksSection + bounded mutation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('bounded mutation with multiple Thanks sections', () => {
|
describe('removeThanksSection and bounded mutation', () => {
|
||||||
it('removes Thanks only from latest, leaves previous intact', () => {
|
it('removes legacy Thanks from latest while preserving historical Thanks', () => {
|
||||||
const text = [
|
const text = [
|
||||||
'## [0.94.0]',
|
'# Changelog',
|
||||||
|
'',
|
||||||
|
'## [0.95.0]',
|
||||||
'',
|
'',
|
||||||
'### Thanks',
|
'### Thanks',
|
||||||
'',
|
'',
|
||||||
'* @new for [#10](u)',
|
'* @auspex for [#1581](u)',
|
||||||
'',
|
'',
|
||||||
'### Added',
|
'### Added',
|
||||||
'* latest feat',
|
'* feat',
|
||||||
'',
|
'',
|
||||||
'## [0.93.0]',
|
'## [0.94.0]',
|
||||||
'',
|
'',
|
||||||
'### Thanks',
|
'### Thanks',
|
||||||
'',
|
'',
|
||||||
'* @old for [#5](u)',
|
'* @old for [#5](u)',
|
||||||
'',
|
'',
|
||||||
'### Fixed',
|
'### Fixed',
|
||||||
'* old fix',
|
'* old',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const parsed = parseVersionSection(text)
|
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)
|
const result = removeThanksSection(text, parsed.start, parsed.end)
|
||||||
|
|
||||||
// Re-parse the result to get the new latest section boundaries
|
|
||||||
const reparsed = parseVersionSection(result)
|
const reparsed = parseVersionSection(result)
|
||||||
const newLatest = result.slice(reparsed.start, reparsed.end)
|
const latest = result.slice(reparsed.start, reparsed.end)
|
||||||
assert.ok(!newLatest.includes('### Thanks'))
|
assert.ok(!latest.includes('### Thanks'))
|
||||||
|
assert.ok(!latest.includes('@auspex'))
|
||||||
// Previous release still has its Thanks
|
|
||||||
assert.ok(result.includes('@old for [#5](u)'))
|
assert.ok(result.includes('@old for [#5](u)'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('second-run idempotency: same output when run twice', () => {
|
it('content before and after latest version remains byte-identical after credit', () => {
|
||||||
const text = [
|
const prefix = '# Changelog\n\nIntro prose.\n\n'
|
||||||
'## [0.93.0]',
|
const latest = [
|
||||||
|
'## [1.0.0]',
|
||||||
'',
|
'',
|
||||||
'### Added',
|
'### Added',
|
||||||
'* feat ([#1448](https://github.com/Studio-Saelix/sencho/issues/1448))',
|
`* feat (${sameRepo(1)})`,
|
||||||
' ([abc](...)), closes [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
const suffix = '## [0.9.0]\n\n### Fixed\n* old\n'
|
||||||
|
const full = prefix + latest + suffix
|
||||||
|
|
||||||
// First "run": no Thanks yet
|
const parsed = parseVersionSection(full)
|
||||||
const parsed = parseVersionSection(text)
|
const section = full.slice(parsed.start, parsed.end)
|
||||||
const thanks = '### Thanks\n\n* @Crosis47 for [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'
|
const credited = creditChangelogEntries(section, new Map([[1, 'x']]), OWNER, REPO)
|
||||||
const first = injectThanksSection(text, thanks, parsed.start, parsed.end)
|
const out = full.slice(0, parsed.start) + credited + full.slice(parsed.end)
|
||||||
|
|
||||||
// Second "run": inject same thanks into already-injected result
|
assert.equal(out.slice(0, parsed.start), prefix)
|
||||||
// (remove first, then inject)
|
assert.equal(out.slice(out.length - suffix.length), suffix)
|
||||||
const parsed2 = parseVersionSection(first)
|
assert.ok(out.includes(', thanks @x'))
|
||||||
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
|
// splitLines / joinLines round-trip
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('missing version heading', () => {
|
describe('splitLines', () => {
|
||||||
it('parseVersionSection returns null, main() would exit 1', () => {
|
it('round-trips LF and CRLF', () => {
|
||||||
const text = '# Changelog\n\nNo version here.\n'
|
const lf = 'a\nb\n'
|
||||||
assert.equal(parseVersionSection(text), null)
|
assert.equal(joinLines(splitLines(lf)), lf)
|
||||||
|
const crlf = 'a\r\nb\r\n'
|
||||||
|
assert.equal(joinLines(splitLines(crlf)), crlf)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Line ending preservation (LF input)
|
// Workflow expression documentation (empty vs populated pr)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('line ending preservation', () => {
|
describe('workflow RELEASE_BRANCH expression (documented behavior)', () => {
|
||||||
it('preserves LF line endings', () => {
|
it('empty pr || {} yields no headBranchName (publish-run safe)', () => {
|
||||||
const text = '## [0.93.0]\n\n### Added\n* feat\n'
|
// Mirrors: fromJSON(steps.release.outputs.pr || '{}').headBranchName || ''
|
||||||
assert.ok(!text.includes('\r'))
|
const pr = ''
|
||||||
const parsed = parseVersionSection(text)
|
const parsed = JSON.parse(pr || '{}')
|
||||||
const result = injectThanksSection(
|
const branch = parsed.headBranchName || ''
|
||||||
text,
|
assert.equal(branch, '')
|
||||||
'### 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', () => {
|
it('populated pr JSON yields headBranchName', () => {
|
||||||
const text = '## [0.93.0]\r\n\r\n### Added\r\n* feat\r\n'
|
const pr = JSON.stringify({
|
||||||
assert.ok(text.includes('\r\n'))
|
headBranchName: 'release-please--branches--main--components--sencho',
|
||||||
const parsed = parseVersionSection(text)
|
})
|
||||||
const thanks = '### Thanks\n\n* @x for [#1](u)'
|
const parsed = JSON.parse(pr || '{}')
|
||||||
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
|
const branch = parsed.headBranchName || ''
|
||||||
// Existing CRLF content survives; the injected section uses LF internally.
|
assert.equal(branch, 'release-please--branches--main--components--sencho')
|
||||||
// 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