@redpanda-data/docs-extensions-and-macros 5.52.0 → 5.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/doc-tools.js
CHANGED
|
@@ -2002,6 +2002,124 @@ automation
|
|
|
2002
2002
|
}
|
|
2003
2003
|
})
|
|
2004
2004
|
|
|
2005
|
+
/**
|
|
2006
|
+
* generate redpanda-release-notes
|
|
2007
|
+
*
|
|
2008
|
+
* @description
|
|
2009
|
+
* Converts a Self-Managed rpchangelog release body into a candidate AsciiDoc
|
|
2010
|
+
* section and inserts it into the Redpanda Release Notes page, newest-first.
|
|
2011
|
+
* Applies the mechanical guards: GA tags only (a prerelease is a no-op), the
|
|
2012
|
+
* `:earliest-tracked-version:` floor, and idempotency (an already-present
|
|
2013
|
+
* version is a no-op). The section carries de-noised source prose as plain
|
|
2014
|
+
* bullets — it is a CANDIDATE for the curation step that rewrites voice, adds
|
|
2015
|
+
* `Area::` labels, normalizes units, and phrases CVEs.
|
|
2016
|
+
*
|
|
2017
|
+
* @why
|
|
2018
|
+
* Release notes are the one reference surface whose source of truth is
|
|
2019
|
+
* engineer-authored prose rather than code, so the deterministic part
|
|
2020
|
+
* (parsing, de-noising, de-duplicating backports, ordering, insertion) is
|
|
2021
|
+
* split from the editorial part. This command is the deterministic half; it is
|
|
2022
|
+
* unit-tested and makes no editorial change.
|
|
2023
|
+
*
|
|
2024
|
+
* @example
|
|
2025
|
+
* # Phase 1: build the candidate the curation skill will edit (no page touched).
|
|
2026
|
+
* # Paths must stay inside the repository; write the fetched body somewhere local.
|
|
2027
|
+
* npx doc-tools generate redpanda-release-notes \
|
|
2028
|
+
* --tag v26.2.3 --date 2026-09-15 --body docs-data/v26.2.3-body.md --section-only > candidate.adoc
|
|
2029
|
+
*
|
|
2030
|
+
* # (the sm-release-notes skill curates candidate.adoc into curated.adoc)
|
|
2031
|
+
*
|
|
2032
|
+
* # Phase 2: insert the curated section into the page under the guards
|
|
2033
|
+
* npx doc-tools generate redpanda-release-notes \
|
|
2034
|
+
* --tag v26.2.3 --section-file curated.adoc
|
|
2035
|
+
*
|
|
2036
|
+
* @requirements
|
|
2037
|
+
* - Phase 1 needs --body (+ --date); phase 2 needs --section-file. Fetching the
|
|
2038
|
+
* body from the private streaming-enterprise release, and running the curation
|
|
2039
|
+
* skill between the two phases, are wired by the docs-repo workflow, not here.
|
|
2040
|
+
*/
|
|
2041
|
+
automation
|
|
2042
|
+
.command('redpanda-release-notes')
|
|
2043
|
+
.description('Generate a Self-Managed release-notes section from an rpchangelog release body')
|
|
2044
|
+
.requiredOption('--tag <tag>', 'GA release tag, such as v26.2.3')
|
|
2045
|
+
.option('--body <file>', 'Phase 1: rpchangelog release body markdown to build a candidate from (relative to repo root, must stay inside the repository)')
|
|
2046
|
+
.option('--section-file <file>', 'Phase 2: a curated section (from the sm-release-notes skill) to insert (relative to repo root, must stay inside the repository)')
|
|
2047
|
+
.option('--date <date>', 'Authored release date as YYYY-MM-DD (required with --body; confirmed on the PR)')
|
|
2048
|
+
.option('--page <file>', 'Target release-notes page (relative to repo root, must stay inside the repository)', 'modules/reference/pages/releases/redpanda.adoc')
|
|
2049
|
+
.option('--section-only', 'Phase 1 only: print the candidate section and do not read or modify the page')
|
|
2050
|
+
.option('--dry-run', 'Print output to stdout instead of writing the page')
|
|
2051
|
+
.action(async (options) => {
|
|
2052
|
+
const {
|
|
2053
|
+
generateReleaseNotes,
|
|
2054
|
+
buildReleaseSection,
|
|
2055
|
+
isGaTag,
|
|
2056
|
+
} = require('../tools/redpanda-release-notes/generate-release-notes.js')
|
|
2057
|
+
try {
|
|
2058
|
+
const repoRoot = findRepoRoot()
|
|
2059
|
+
|
|
2060
|
+
// Exactly one input: --body (phase 1, build a candidate) XOR --section-file
|
|
2061
|
+
// (phase 2, insert the curated section). Curation sits between them.
|
|
2062
|
+
const hasBody = Boolean(options.body)
|
|
2063
|
+
const hasSection = Boolean(options.sectionFile)
|
|
2064
|
+
if (hasBody === hasSection) {
|
|
2065
|
+
throw new Error('Provide exactly one of --body (phase 1) or --section-file (phase 2).')
|
|
2066
|
+
}
|
|
2067
|
+
if (hasBody && !options.date) {
|
|
2068
|
+
throw new Error('--date is required with --body.')
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
const readInside = (rel, label) => {
|
|
2072
|
+
const abs = resolveInsideRepo(repoRoot, rel, label)
|
|
2073
|
+
if (!fs.existsSync(abs)) throw new Error(`File not found for ${label}: ${abs}`)
|
|
2074
|
+
return abs
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// Phase-1 candidate preview: emit the candidate for the curation step. No
|
|
2078
|
+
// page is read or written — this is the LLM step's input, nothing else.
|
|
2079
|
+
if (options.sectionOnly) {
|
|
2080
|
+
if (!hasBody) throw new Error('--section-only applies to --body (candidate generation).')
|
|
2081
|
+
// Same GA-only contract as the insert path: a non-GA tag is a clean
|
|
2082
|
+
// no-op, not an "invalid version" throw from buildReleaseSection.
|
|
2083
|
+
if (!isGaTag(options.tag)) {
|
|
2084
|
+
console.log(`[release-notes] Skipped: not a GA tag: ${options.tag}`)
|
|
2085
|
+
return
|
|
2086
|
+
}
|
|
2087
|
+
const body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
|
|
2088
|
+
const section = buildReleaseSection({ body, version: options.tag, date: options.date })
|
|
2089
|
+
process.stdout.write(section)
|
|
2090
|
+
return
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const pagePath = readInside(options.page, '--page')
|
|
2094
|
+
const pageContent = fs.readFileSync(pagePath, 'utf8')
|
|
2095
|
+
const genArgs = { tag: options.tag, date: options.date, pageContent }
|
|
2096
|
+
if (hasBody) {
|
|
2097
|
+
genArgs.body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
|
|
2098
|
+
} else {
|
|
2099
|
+
genArgs.section = fs.readFileSync(readInside(options.sectionFile, '--section-file'), 'utf8')
|
|
2100
|
+
}
|
|
2101
|
+
const result = generateReleaseNotes(genArgs)
|
|
2102
|
+
|
|
2103
|
+
if (result.status === 'skipped') {
|
|
2104
|
+
// A guard miss is a clean no-op, not an error: the workflow runs this on
|
|
2105
|
+
// every release and most runs have nothing to add.
|
|
2106
|
+
console.log(`[release-notes] Skipped: ${result.reason}`)
|
|
2107
|
+
return
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
if (options.dryRun) {
|
|
2111
|
+
process.stdout.write(result.content)
|
|
2112
|
+
console.log(`\nDone: (dry-run) updated page for ${options.tag} printed to stdout.`)
|
|
2113
|
+
} else {
|
|
2114
|
+
fs.writeFileSync(pagePath, result.content, 'utf8')
|
|
2115
|
+
console.log(`Done: Inserted the ${options.tag} section into ${pagePath}`)
|
|
2116
|
+
}
|
|
2117
|
+
} catch (err) {
|
|
2118
|
+
console.error(`Error: Failed to generate release notes: ${err.message}`)
|
|
2119
|
+
process.exit(1)
|
|
2120
|
+
}
|
|
2121
|
+
})
|
|
2122
|
+
|
|
2005
2123
|
/**
|
|
2006
2124
|
* generate crd-spec
|
|
2007
2125
|
*
|
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Orchestrate release-notes generation: parse the body, render a candidate
|
|
2
|
+
// section, and insert it into the target page under the mechanical guards
|
|
3
|
+
// (GA-only, floor, idempotency). Pure and IO-free: callers pass the page
|
|
4
|
+
// content in and get the updated content back; the doc-tools action does the
|
|
5
|
+
// file read/write, and the workflow fetches the release body.
|
|
6
|
+
|
|
7
|
+
const { parseReleaseBody } = require('./parse-release-body');
|
|
8
|
+
const { renderReleaseSection, normalizeVersion } = require('./render-release-notes');
|
|
9
|
+
|
|
10
|
+
const LOG_TAG = '[release-notes]';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compares two `X.Y.Z` versions numerically.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} a - A version (optionally v-prefixed).
|
|
16
|
+
* @param {string} b - A version (optionally v-prefixed).
|
|
17
|
+
* @return {number} -1 if a < b, 0 if equal, 1 if a > b.
|
|
18
|
+
*/
|
|
19
|
+
function compareVersions(a, b) {
|
|
20
|
+
const pa = normalizeVersion(a).split('.').map(Number);
|
|
21
|
+
const pb = normalizeVersion(b).split('.').map(Number);
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const x = pa[i] || 0;
|
|
24
|
+
const y = pb[i] || 0;
|
|
25
|
+
if (x < y) return -1;
|
|
26
|
+
if (x > y) return 1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports whether a tag is a GA release. Release notes are added for GA tags
|
|
33
|
+
* only; a prerelease (for example `v26.2.3-rc1`) gets no section.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} tag - The release tag.
|
|
36
|
+
* @return {boolean} True if the tag is a bare `vX.Y.Z` with no prerelease suffix.
|
|
37
|
+
*/
|
|
38
|
+
function isGaTag(tag) {
|
|
39
|
+
return /^v?\d+\.\d+\.\d+$/.test(String(tag || '').trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads the `:earliest-tracked-version:` floor attribute from the page.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} pageContent - The target page content.
|
|
46
|
+
* @return {string|null} The bare floor version, or null if the attribute is absent.
|
|
47
|
+
*/
|
|
48
|
+
function readFloor(pageContent) {
|
|
49
|
+
const m = String(pageContent).match(/^:earliest-tracked-version:\s*(.+?)\s*$/m);
|
|
50
|
+
return m ? normalizeVersion(m[1]) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reports whether the page already has a section for this version, so a re-run
|
|
55
|
+
* for an already-published tag is a no-op rather than a duplicate.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} pageContent - The target page content.
|
|
58
|
+
* @param {string} version - The release version (optionally v-prefixed).
|
|
59
|
+
* @return {boolean} True if a `== vX.Y.Z (` heading is already present.
|
|
60
|
+
*/
|
|
61
|
+
function hasVersionSection(pageContent, version) {
|
|
62
|
+
const v = normalizeVersion(version).replace(/[.]/g, '\\.');
|
|
63
|
+
return new RegExp(`^==\\s+v${v}\\s*\\(`, 'm').test(String(pageContent));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Inserts a rendered section into the page in descending-version order: before
|
|
68
|
+
* the first existing `== vX.Y.Z` heading OLDER than the section, so a later
|
|
69
|
+
* backfill of an older release lands below the newer ones already present
|
|
70
|
+
* rather than jumping to the top. When the section is older than every existing
|
|
71
|
+
* release, it is inserted AFTER the last version section — before the next
|
|
72
|
+
* top-level heading following it (the older-versions footer or any other), else
|
|
73
|
+
* at the end — so a footerless page stays ordered. When there are no version
|
|
74
|
+
* sections yet, it goes before the older-versions footer, else the first
|
|
75
|
+
* top-level heading, else the end. The section's own version is read with
|
|
76
|
+
* multiline matching, so a leading blank line or comment does not hide it.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} pageContent - The target page content.
|
|
79
|
+
* @param {string} sectionText - The rendered section (ending in a newline).
|
|
80
|
+
* @return {string} The page content with the section inserted.
|
|
81
|
+
*/
|
|
82
|
+
function insertReleaseSection(pageContent, sectionText) {
|
|
83
|
+
const lines = String(pageContent).split('\n');
|
|
84
|
+
const sectionLines = sectionText.replace(/\n+$/, '').split('\n');
|
|
85
|
+
const versionHeading = /^==\s+v(\d+\.\d+\.\d+)\s*\(/;
|
|
86
|
+
// Multiline: the curated section may open with a blank line or an AsciiDoc
|
|
87
|
+
// comment, so the heading is not guaranteed to be at character zero.
|
|
88
|
+
const newVer = (sectionText.match(/^==\s+v(\d+\.\d+\.\d+)\s*\(/m) || [])[1];
|
|
89
|
+
|
|
90
|
+
const insertAt = (i) => {
|
|
91
|
+
// The anchor heading is already preceded by a blank line, so the section
|
|
92
|
+
// plus one blank separator keeps exactly one blank line on each side.
|
|
93
|
+
lines.splice(i, 0, ...sectionLines, '');
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
};
|
|
96
|
+
const appendAtEnd = () => {
|
|
97
|
+
const tail = lines[lines.length - 1] === '' ? [] : [''];
|
|
98
|
+
return [...lines, ...tail, ...sectionLines, ''].join('\n');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 1) Descending order: before the first existing section older than this one.
|
|
102
|
+
// A new newest release lands at the top this way too.
|
|
103
|
+
if (newVer) {
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const m = lines[i].match(versionHeading);
|
|
106
|
+
if (m && compareVersions(newVer, m[1]) > 0) return insertAt(i);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2) Older than every existing section (or version unknown): sit AFTER the
|
|
111
|
+
// last version section — before the next top-level heading that follows it
|
|
112
|
+
// (the older-versions footer or any other), else at the end. This keeps a
|
|
113
|
+
// footerless page ordered correctly instead of jumping to the top.
|
|
114
|
+
let lastVersionIdx = -1;
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
if (versionHeading.test(lines[i])) lastVersionIdx = i;
|
|
117
|
+
}
|
|
118
|
+
if (lastVersionIdx !== -1) {
|
|
119
|
+
for (let i = lastVersionIdx + 1; i < lines.length; i++) {
|
|
120
|
+
if (/^==\s/.test(lines[i])) return insertAt(i);
|
|
121
|
+
}
|
|
122
|
+
return appendAtEnd();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3) No version sections at all: before the older-versions footer, else
|
|
126
|
+
// before the first top-level heading, else at the end.
|
|
127
|
+
const footerIdx = lines.findIndex((l) => /^==\s+Release notes for older versions/i.test(l));
|
|
128
|
+
if (footerIdx !== -1) return insertAt(footerIdx);
|
|
129
|
+
const firstHeadingIdx = lines.findIndex((l) => /^==\s/.test(l));
|
|
130
|
+
if (firstHeadingIdx !== -1) return insertAt(firstHeadingIdx);
|
|
131
|
+
return appendAtEnd();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Builds a candidate release section from a raw release body.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} options
|
|
138
|
+
* @param {string} options.body - The raw rpchangelog release body markdown.
|
|
139
|
+
* @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
|
|
140
|
+
* @param {string} options.date - The release date as `YYYY-MM-DD`.
|
|
141
|
+
* @return {string} The rendered AsciiDoc section.
|
|
142
|
+
*/
|
|
143
|
+
function buildReleaseSection({ body, version, date }) {
|
|
144
|
+
const { sections } = parseReleaseBody(body);
|
|
145
|
+
return renderReleaseSection({ version, date, sections });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Asserts that an externally-produced (curated) section carries exactly one
|
|
150
|
+
* `== vX.Y.Z (date)` heading whose version matches the requested tag. Phase 2
|
|
151
|
+
* inserts the section verbatim, and the idempotency guard keys off the tag, so
|
|
152
|
+
* a heading that disagrees with the tag would defeat idempotency and let
|
|
153
|
+
* reruns insert duplicates. This is malformed input, so it throws (fail loud)
|
|
154
|
+
* rather than returning a skip.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} section - The curated section text.
|
|
157
|
+
* @param {string} version - The bare `X.Y.Z` version the tag resolved to.
|
|
158
|
+
* @throws {Error} If the section has zero or several release headings, or one
|
|
159
|
+
* whose version does not match.
|
|
160
|
+
*/
|
|
161
|
+
function assertSectionMatchesTag(section, version) {
|
|
162
|
+
// Count heading-ish lines loosely (any `== v<digit>…`) so a single MALFORMED
|
|
163
|
+
// heading fails as malformed rather than slipping through as "found 0".
|
|
164
|
+
const headingish = String(section).match(/^==\s+v\d.*$/gm) || [];
|
|
165
|
+
if (headingish.length !== 1) {
|
|
166
|
+
throw new Error(`Curated section must contain exactly one "== vX.Y.Z (YYYY-MM-DD)" heading; found ${headingish.length}.`);
|
|
167
|
+
}
|
|
168
|
+
// Validate the complete heading: version, a YYYY-MM-DD date, a closing paren,
|
|
169
|
+
// and nothing trailing.
|
|
170
|
+
const m = headingish[0].match(/^==\s+v(\d+\.\d+\.\d+)\s+\(\d{4}-\d{2}-\d{2}\)\s*$/);
|
|
171
|
+
if (!m) {
|
|
172
|
+
throw new Error(`Malformed release heading ${JSON.stringify(headingish[0])}; expected "== vX.Y.Z (YYYY-MM-DD)".`);
|
|
173
|
+
}
|
|
174
|
+
if (m[1] !== version) {
|
|
175
|
+
throw new Error(`Curated section heading (v${m[1]}) does not match the requested tag (v${version}).`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generates the updated page content for a release, applying the GA, floor, and
|
|
181
|
+
* idempotency guards. Never throws for a guard miss: returns a `skipped` status
|
|
182
|
+
* so the workflow can no-op cleanly.
|
|
183
|
+
*
|
|
184
|
+
* Two phases, because the LLM curation step sits between them:
|
|
185
|
+
* - Phase 1 (pass `body`): build the de-noised candidate section from the raw
|
|
186
|
+
* release body. This candidate is the curation step's input.
|
|
187
|
+
* - Phase 2 (pass `section`): insert the curated section the skill produced.
|
|
188
|
+
* Exactly one of `body` or `section` is supplied. The guards apply in both
|
|
189
|
+
* phases, since they depend on the tag and the page, not on the entries.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} options
|
|
192
|
+
* @param {string} [options.body] - Raw rpchangelog release body (phase 1).
|
|
193
|
+
* @param {string} [options.section] - A pre-built/curated section to insert (phase 2).
|
|
194
|
+
* @param {string} options.tag - The release tag (`v26.2.3`).
|
|
195
|
+
* @param {string} [options.date] - The authored release date as `YYYY-MM-DD` (phase 1 only).
|
|
196
|
+
* @param {string} options.pageContent - The current target page content.
|
|
197
|
+
* @return {{status: 'ok'|'skipped', reason?: string, section?: string, content?: string}}
|
|
198
|
+
*/
|
|
199
|
+
function generateReleaseNotes({ body, section, tag, date, pageContent }) {
|
|
200
|
+
if (body == null && section == null) {
|
|
201
|
+
throw new Error('Provide either body (phase 1) or section (phase 2).');
|
|
202
|
+
}
|
|
203
|
+
if (body != null && section != null) {
|
|
204
|
+
throw new Error('Provide only one of body or section, not both.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!isGaTag(tag)) {
|
|
208
|
+
return { status: 'skipped', reason: `not a GA tag: ${tag}` };
|
|
209
|
+
}
|
|
210
|
+
const version = normalizeVersion(tag);
|
|
211
|
+
|
|
212
|
+
const floor = readFloor(pageContent);
|
|
213
|
+
if (floor && compareVersions(version, floor) <= 0) {
|
|
214
|
+
return { status: 'skipped', reason: `version ${version} is not newer than the floor ${floor}` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hasVersionSection(pageContent, version)) {
|
|
218
|
+
return { status: 'skipped', reason: `page already has a section for v${version}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let finalSection;
|
|
222
|
+
if (section != null) {
|
|
223
|
+
assertSectionMatchesTag(section, version);
|
|
224
|
+
finalSection = section;
|
|
225
|
+
} else {
|
|
226
|
+
finalSection = buildReleaseSection({ body, version, date });
|
|
227
|
+
}
|
|
228
|
+
const content = insertReleaseSection(pageContent, finalSection);
|
|
229
|
+
return { status: 'ok', section: finalSection, content };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
generateReleaseNotes,
|
|
234
|
+
buildReleaseSection,
|
|
235
|
+
assertSectionMatchesTag,
|
|
236
|
+
insertReleaseSection,
|
|
237
|
+
compareVersions,
|
|
238
|
+
isGaTag,
|
|
239
|
+
readFloor,
|
|
240
|
+
hasVersionSection,
|
|
241
|
+
LOG_TAG,
|
|
242
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Parse a Self-Managed rpchangelog release body into a structured, de-noised model.
|
|
2
|
+
//
|
|
3
|
+
// Input is the raw GitHub Release body a release captain pastes into the
|
|
4
|
+
// streaming-enterprise release (the output of `rpchangelog.py rel`): markdown
|
|
5
|
+
// with `## Features` / `## Bug Fixes` / `## Improvements` sections, each a list
|
|
6
|
+
// of `*` bullets carrying `by @author in [#PR](url)` suffixes and, sometimes,
|
|
7
|
+
// a leading `[#issue](url)` reference.
|
|
8
|
+
//
|
|
9
|
+
// This module does ONLY the deterministic, no-judgment work: split into
|
|
10
|
+
// sections, join wrapped bullet lines, strip source noise, unescape backticks,
|
|
11
|
+
// de-duplicate backport repeats, and normalize category order/case. It does NOT
|
|
12
|
+
// rewrite voice, add `Area::` labels, normalize units, phrase CVEs, or drop
|
|
13
|
+
// bodyless entries — those need editorial judgment and belong to the curation
|
|
14
|
+
// step that runs on the generated page.
|
|
15
|
+
|
|
16
|
+
const LOG_TAG = '[release-notes]';
|
|
17
|
+
|
|
18
|
+
// Canonical page order and headings: Features, then Bug fixes, then Improvements
|
|
19
|
+
// (improvements last — they are usually internal detail, least relevant to a
|
|
20
|
+
// reader scanning what changed). This matches rpchangelog's raw source order
|
|
21
|
+
// (Features -> Bug Fixes -> Improvements), so the reorder only recases the
|
|
22
|
+
// "Bug Fixes" heading. It deliberately differs from ADP, which puts fixes last.
|
|
23
|
+
const KIND_FEATURE = 'feature';
|
|
24
|
+
const KIND_IMPROVEMENT = 'improvement';
|
|
25
|
+
const KIND_FIX = 'fix';
|
|
26
|
+
|
|
27
|
+
const PAGE_ORDER = [KIND_FEATURE, KIND_FIX, KIND_IMPROVEMENT];
|
|
28
|
+
const PAGE_TITLE = {
|
|
29
|
+
[KIND_FEATURE]: 'Features',
|
|
30
|
+
[KIND_IMPROVEMENT]: 'Improvements',
|
|
31
|
+
[KIND_FIX]: 'Bug fixes',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Maps a source `##` heading to a canonical kind by keyword, mirroring ADP's
|
|
36
|
+
* `sectionKind`. Returns null for a heading that is not one of the three
|
|
37
|
+
* release-note categories (for example rpchangelog's `--show-extra` `None` /
|
|
38
|
+
* `Unclear` sections), so the caller can ignore it.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} heading - The heading text after the `##` marker.
|
|
41
|
+
* @return {string|null} One of the KIND_* constants, or null if unrecognized.
|
|
42
|
+
*/
|
|
43
|
+
function sectionKind(heading) {
|
|
44
|
+
const h = heading.toLowerCase();
|
|
45
|
+
if (h.includes('feature')) return KIND_FEATURE;
|
|
46
|
+
if (h.includes('improvement')) return KIND_IMPROVEMENT;
|
|
47
|
+
if (h.includes('fix')) return KIND_FIX;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strips the trailing attribution blocks rpchangelog appends to every bullet:
|
|
53
|
+
* ` by @author` optionally followed by ` in [#PR](url)`, repeated once per PR
|
|
54
|
+
* when a change was aggregated from several PRs (the backport case). Works from
|
|
55
|
+
* the end of the string so it only ever removes trailing attribution, never a
|
|
56
|
+
* legitimate mid-sentence "by".
|
|
57
|
+
*
|
|
58
|
+
* @param {string} text - A single bullet's text.
|
|
59
|
+
* @return {string} The text with trailing attribution removed.
|
|
60
|
+
*/
|
|
61
|
+
function stripAttribution(text) {
|
|
62
|
+
const trailing = /\s+by\s+@[A-Za-z0-9][A-Za-z0-9._-]*(?:\s+in\s+\[#\d+\]\([^)]*\))?\s*$/i;
|
|
63
|
+
let out = text;
|
|
64
|
+
while (trailing.test(out)) {
|
|
65
|
+
out = out.replace(trailing, '');
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Strips a leading `[#issue](url)` reference that rpchangelog prepends when a
|
|
72
|
+
* PR body declared `Fixes: #N`. Only the leading reference is removed; links
|
|
73
|
+
* inside the sentence are left alone.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} text - A single bullet's text.
|
|
76
|
+
* @return {string} The text with a leading issue reference removed.
|
|
77
|
+
*/
|
|
78
|
+
function stripLeadingIssueRef(text) {
|
|
79
|
+
return text.replace(/^\s*\[#\d+\]\([^)]*\)\s+/, '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cleans one bullet: remove attribution and leading issue ref, unescape
|
|
84
|
+
* backslash-escaped backticks (`\`` -> `` ` ``) that survive from the PR body,
|
|
85
|
+
* collapse whitespace introduced by joining wrapped lines, and apply the two
|
|
86
|
+
* context-free mechanical normalizations — AsciiDoc brace escaping and the
|
|
87
|
+
* standard-mandated unit notation. House-style spelling and voice are left to
|
|
88
|
+
* the editorial curation step.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} raw - The joined raw bullet text.
|
|
91
|
+
* @return {string} The cleaned entry text.
|
|
92
|
+
*/
|
|
93
|
+
function cleanEntry(raw) {
|
|
94
|
+
let text = raw.replace(/\s+/g, ' ').trim();
|
|
95
|
+
text = stripLeadingIssueRef(text);
|
|
96
|
+
text = stripAttribution(text);
|
|
97
|
+
text = text.replace(/\\`/g, '`');
|
|
98
|
+
text = normalizeUnits(text);
|
|
99
|
+
text = escapeAsciiDocBraces(text);
|
|
100
|
+
return text.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Rewrites the source's underscore data-size notation to the readable unit the
|
|
105
|
+
* standard requires (`4_MiB` -> `4 MiB`). Only a digit followed by `_` and a
|
|
106
|
+
* size unit is touched, so identifiers such as `s3_fifo` or `last_offset_delta`
|
|
107
|
+
* are left alone.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} text - The entry text.
|
|
110
|
+
* @return {string} The text with data-size units normalized.
|
|
111
|
+
*/
|
|
112
|
+
function normalizeUnits(text) {
|
|
113
|
+
return text.replace(/(\d)_((?:[KMGT]i?)?B)\b/g, '$1 $2');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Escapes AsciiDoc attribute-reference braces so a literal `{id}` in the source
|
|
118
|
+
* renders as text rather than an (empty) attribute substitution. Braces already
|
|
119
|
+
* escaped are left as-is. AsciiDoc substitutes attributes even inside inline
|
|
120
|
+
* monospace, so this applies everywhere in the entry, including within backticks.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} text - The entry text.
|
|
123
|
+
* @return {string} The text with unescaped braces escaped.
|
|
124
|
+
*/
|
|
125
|
+
function escapeAsciiDocBraces(text) {
|
|
126
|
+
return text.replace(/(?<!\\)([{}])/g, '\\$1');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parses a raw rpchangelog release body into de-noised, de-duplicated entries
|
|
131
|
+
* grouped by canonical category in page order.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} body - The raw GitHub Release body markdown.
|
|
134
|
+
* @return {{sections: Array<{kind: string, title: string, entries: string[]}>}}
|
|
135
|
+
* Sections in page order (Features, Bug fixes, Improvements); a category with
|
|
136
|
+
* no entries is omitted.
|
|
137
|
+
*/
|
|
138
|
+
function parseReleaseBody(body) {
|
|
139
|
+
if (typeof body !== 'string' || body.trim() === '') {
|
|
140
|
+
throw new Error('Release body is empty.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Drop HTML comments up front (rpchangelog strips them from PR bodies, but a
|
|
144
|
+
// pasted body may still carry some).
|
|
145
|
+
const cleaned = body.replace(/<!--[\s\S]*?-->/g, '');
|
|
146
|
+
|
|
147
|
+
const buckets = {
|
|
148
|
+
[KIND_FEATURE]: [],
|
|
149
|
+
[KIND_IMPROVEMENT]: [],
|
|
150
|
+
[KIND_FIX]: [],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let currentKind = null; // kind of the section we are inside, or null to ignore
|
|
154
|
+
let current = null; // the bullet text being accumulated, or null between bullets
|
|
155
|
+
|
|
156
|
+
const flush = () => {
|
|
157
|
+
if (current !== null && currentKind && buckets[currentKind]) {
|
|
158
|
+
const entry = cleanEntry(current);
|
|
159
|
+
if (entry) buckets[currentKind].push(entry);
|
|
160
|
+
}
|
|
161
|
+
current = null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const lines = cleaned.split(/\r?\n/);
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
167
|
+
if (heading) {
|
|
168
|
+
flush();
|
|
169
|
+
currentKind = sectionKind(heading[1]);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The changelog footer ends the release notes; nothing after it is an entry.
|
|
174
|
+
if (/^\s*\*\*(?:Partial|Full)\s+Changelog\*\*/i.test(line)) {
|
|
175
|
+
flush();
|
|
176
|
+
currentKind = null;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (currentKind === null) continue;
|
|
181
|
+
|
|
182
|
+
const bullet = line.match(/^\s*[*-]\s+(.*)$/);
|
|
183
|
+
if (bullet) {
|
|
184
|
+
flush();
|
|
185
|
+
current = bullet[1];
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (line.trim() === '') {
|
|
190
|
+
// Blank line: entries in this format are contiguous, so a blank marks the
|
|
191
|
+
// end of the current bullet rather than a continuation.
|
|
192
|
+
flush();
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A non-blank, non-bullet line continues the current wrapped bullet.
|
|
197
|
+
if (current !== null) {
|
|
198
|
+
current += ' ' + line.trim();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
flush();
|
|
202
|
+
|
|
203
|
+
const sections = [];
|
|
204
|
+
for (const kind of PAGE_ORDER) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const entry of buckets[kind]) {
|
|
208
|
+
// De-duplicate backport repeats: the same change aggregated from several
|
|
209
|
+
// PRs yields identical text once attribution is stripped.
|
|
210
|
+
const key = entry.toLowerCase();
|
|
211
|
+
if (seen.has(key)) continue;
|
|
212
|
+
seen.add(key);
|
|
213
|
+
entries.push(entry);
|
|
214
|
+
}
|
|
215
|
+
if (entries.length > 0) {
|
|
216
|
+
sections.push({ kind, title: PAGE_TITLE[kind], entries });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (sections.length === 0) {
|
|
221
|
+
console.warn(`${LOG_TAG} WARN: no Features/Improvements/Bug fixes entries found in the release body.`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { sections };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
parseReleaseBody,
|
|
229
|
+
// Exported for unit tests and reuse.
|
|
230
|
+
sectionKind,
|
|
231
|
+
stripAttribution,
|
|
232
|
+
stripLeadingIssueRef,
|
|
233
|
+
cleanEntry,
|
|
234
|
+
normalizeUnits,
|
|
235
|
+
escapeAsciiDocBraces,
|
|
236
|
+
PAGE_ORDER,
|
|
237
|
+
PAGE_TITLE,
|
|
238
|
+
KIND_FEATURE,
|
|
239
|
+
KIND_IMPROVEMENT,
|
|
240
|
+
KIND_FIX,
|
|
241
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Render a parsed release model into a candidate AsciiDoc release-notes section.
|
|
2
|
+
//
|
|
3
|
+
// Output is a `== vX.Y.Z (date)` section with `=== Features` / `=== Bug fixes`
|
|
4
|
+
// / `=== Improvements` subsections of plain `*` bullets. Entries are emitted as
|
|
5
|
+
// bullets carrying the de-noised source prose verbatim: this is a CANDIDATE for
|
|
6
|
+
// the curation step, which rewrites voice, converts bullets to `Area::`
|
|
7
|
+
// definition-list items, normalizes units, and phrases CVEs. This renderer adds
|
|
8
|
+
// no `Area::` labels and makes no editorial change.
|
|
9
|
+
|
|
10
|
+
const LOG_TAG = '[release-notes]';
|
|
11
|
+
|
|
12
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
13
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes a tag or version to a bare `X.Y.Z` (strips a leading `v`).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} version - A version or tag, such as `v26.2.3` or `26.2.3`.
|
|
19
|
+
* @return {string} The bare `X.Y.Z` version.
|
|
20
|
+
*/
|
|
21
|
+
function normalizeVersion(version) {
|
|
22
|
+
return String(version || '').trim().replace(/^v/i, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders one release section as AsciiDoc.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
|
|
30
|
+
* @param {string} options.date - The release date as `YYYY-MM-DD` (authored; the
|
|
31
|
+
* captain confirms it on the PR).
|
|
32
|
+
* @param {Array<{title: string, entries: string[]}>} options.sections - Sections
|
|
33
|
+
* in page order, as produced by parseReleaseBody.
|
|
34
|
+
* @return {string} The AsciiDoc section, ending with a single trailing newline.
|
|
35
|
+
* @throws {Error} If the version or date is malformed, or there are no sections.
|
|
36
|
+
*/
|
|
37
|
+
function renderReleaseSection({ version, date, sections }) {
|
|
38
|
+
const v = normalizeVersion(version);
|
|
39
|
+
if (!VERSION_RE.test(v)) {
|
|
40
|
+
throw new Error(`Invalid version: ${JSON.stringify(version)}. Expected X.Y.Z (optionally v-prefixed).`);
|
|
41
|
+
}
|
|
42
|
+
if (!DATE_RE.test(String(date || '').trim())) {
|
|
43
|
+
throw new Error(`Invalid date: ${JSON.stringify(date)}. Expected YYYY-MM-DD.`);
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
46
|
+
throw new Error('No release-note sections to render.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lines = [`== v${v} (${String(date).trim()})`, ''];
|
|
50
|
+
for (const section of sections) {
|
|
51
|
+
if (!section.entries || section.entries.length === 0) continue;
|
|
52
|
+
lines.push(`=== ${section.title}`, '');
|
|
53
|
+
for (const entry of section.entries) {
|
|
54
|
+
lines.push(`* ${entry}`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Trim the trailing blank line to exactly one terminating newline.
|
|
60
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
61
|
+
return lines.join('\n') + '\n';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
renderReleaseSection,
|
|
66
|
+
normalizeVersion,
|
|
67
|
+
VERSION_RE,
|
|
68
|
+
DATE_RE,
|
|
69
|
+
LOG_TAG,
|
|
70
|
+
};
|