@redpanda-data/docs-extensions-and-macros 5.56.0 → 5.57.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 +38 -3
- package/package.json +1 -1
- package/tools/enterprise-features/detect-gating-changes.js +59 -12
- package/tools/redpanda-release-notes/generate-release-notes.js +87 -48
- package/tools/redpanda-release-notes/parse-release-body.js +33 -4
- package/tools/redpanda-release-notes/property-names.js +65 -0
package/bin/doc-tools.js
CHANGED
|
@@ -2132,6 +2132,11 @@ automation
|
|
|
2132
2132
|
* bullets — it is a CANDIDATE for the curation step that rewrites voice, adds
|
|
2133
2133
|
* `Area::` labels, normalizes units, and phrases CVEs.
|
|
2134
2134
|
*
|
|
2135
|
+
* In phase 1, a backticked name that matches a property in the release
|
|
2136
|
+
* series' redpanda-properties-<tag>.json becomes a `prop:<name>[link=true]` link. The
|
|
2137
|
+
* file is found in modules/reference/attachments/ unless --properties-file
|
|
2138
|
+
* names one. With no file, names stay monospace and a warning is logged.
|
|
2139
|
+
*
|
|
2135
2140
|
* @why
|
|
2136
2141
|
* Release notes are the one reference surface whose source of truth is
|
|
2137
2142
|
* engineer-authored prose rather than code, so the deterministic part
|
|
@@ -2164,6 +2169,7 @@ automation
|
|
|
2164
2169
|
.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)')
|
|
2165
2170
|
.option('--date <date>', 'Authored release date as YYYY-MM-DD (required with --body; confirmed on the PR)')
|
|
2166
2171
|
.option('--page <file>', 'Target release-notes page (relative to repo root, must stay inside the repository)', 'modules/reference/pages/releases/redpanda.adoc')
|
|
2172
|
+
.option('--properties-file <file>', 'Phase 1: property JSON whose names become prop: links (relative to repo root, must stay inside the repository). Default: the newest redpanda-properties-<tag>.json in modules/reference/attachments/ for the tag\'s release series')
|
|
2167
2173
|
.option('--section-only', 'Phase 1 only: print the candidate section and do not read or modify the page')
|
|
2168
2174
|
.option('--dry-run', 'Print output to stdout instead of writing the page')
|
|
2169
2175
|
.action(async (options) => {
|
|
@@ -2172,6 +2178,11 @@ automation
|
|
|
2172
2178
|
buildReleaseSection,
|
|
2173
2179
|
isGaTag,
|
|
2174
2180
|
} = require('../tools/redpanda-release-notes/generate-release-notes.js')
|
|
2181
|
+
const {
|
|
2182
|
+
ATTACHMENTS_DIR,
|
|
2183
|
+
findPropertiesFile,
|
|
2184
|
+
loadPropertyNames,
|
|
2185
|
+
} = require('../tools/redpanda-release-notes/property-names.js')
|
|
2175
2186
|
try {
|
|
2176
2187
|
const repoRoot = findRepoRoot()
|
|
2177
2188
|
|
|
@@ -2192,6 +2203,19 @@ automation
|
|
|
2192
2203
|
return abs
|
|
2193
2204
|
}
|
|
2194
2205
|
|
|
2206
|
+
// Phase 1 links known property names. An explicit --properties-file must
|
|
2207
|
+
// exist; a missing default only means names stay monospace.
|
|
2208
|
+
const readPropertyNames = () => {
|
|
2209
|
+
const file = options.propertiesFile
|
|
2210
|
+
? readInside(options.propertiesFile, '--properties-file')
|
|
2211
|
+
: findPropertiesFile(path.join(repoRoot, ATTACHMENTS_DIR), options.tag)
|
|
2212
|
+
if (!file) {
|
|
2213
|
+
console.warn(`[release-notes] WARN: no property file for ${options.tag} in ${ATTACHMENTS_DIR}; property names stay monospace.`)
|
|
2214
|
+
return undefined
|
|
2215
|
+
}
|
|
2216
|
+
return loadPropertyNames(file)
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2195
2219
|
// Phase-1 candidate preview: emit the candidate for the curation step. No
|
|
2196
2220
|
// page is read or written — this is the LLM step's input, nothing else.
|
|
2197
2221
|
if (options.sectionOnly) {
|
|
@@ -2203,7 +2227,9 @@ automation
|
|
|
2203
2227
|
return
|
|
2204
2228
|
}
|
|
2205
2229
|
const body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
|
|
2206
|
-
const section = buildReleaseSection({
|
|
2230
|
+
const section = buildReleaseSection({
|
|
2231
|
+
body, version: options.tag, date: options.date, propertyNames: readPropertyNames(),
|
|
2232
|
+
})
|
|
2207
2233
|
process.stdout.write(section)
|
|
2208
2234
|
return
|
|
2209
2235
|
}
|
|
@@ -2213,6 +2239,7 @@ automation
|
|
|
2213
2239
|
const genArgs = { tag: options.tag, date: options.date, pageContent }
|
|
2214
2240
|
if (hasBody) {
|
|
2215
2241
|
genArgs.body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
|
|
2242
|
+
genArgs.propertyNames = readPropertyNames()
|
|
2216
2243
|
} else {
|
|
2217
2244
|
genArgs.section = fs.readFileSync(readInside(options.sectionFile, '--section-file'), 'utf8')
|
|
2218
2245
|
}
|
|
@@ -3055,9 +3082,10 @@ programCli
|
|
|
3055
3082
|
.requiredOption('--repo <path>', 'Path to a Redpanda core checkout. Nothing is cloned.')
|
|
3056
3083
|
.requiredOption('--diff <base>', 'Base ref: changes in <base>...HEAD are inspected')
|
|
3057
3084
|
.option('--format <format>', 'Output format: json or human', 'json')
|
|
3085
|
+
.option('--exclude <file>', 'File of already-dispatched findings, one "name change_kind" per line, to leave out of the report')
|
|
3058
3086
|
.action((options) => {
|
|
3059
3087
|
const { spawnSync } = require('child_process')
|
|
3060
|
-
const { detectGatingChanges, buildDocImpact, enterprisePropertyNames, ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE } = require('../tools/enterprise-features/detect-gating-changes')
|
|
3088
|
+
const { detectGatingChanges, buildDocImpact, withoutSent, enterprisePropertyNames, ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE } = require('../tools/enterprise-features/detect-gating-changes')
|
|
3061
3089
|
const empty = { findings: [] }
|
|
3062
3090
|
const result = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', `${options.diff}...HEAD`, '--', ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE], {
|
|
3063
3091
|
cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024
|
|
@@ -3072,7 +3100,14 @@ programCli
|
|
|
3072
3100
|
const header = spawnSync('git', ['show', `HEAD:${CONFIG_HEADER}`], { cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
3073
3101
|
if (header.status !== 0) console.error(`git show HEAD:${CONFIG_HEADER} failed in ${options.repo}; configuration.cc changes are not checked: ${(header.stderr || '').trim()}`)
|
|
3074
3102
|
const enterpriseNames = header.status === 0 ? enterprisePropertyNames(header.stdout) : []
|
|
3075
|
-
|
|
3103
|
+
let findings = detectGatingChanges(result.stdout, { enterpriseNames })
|
|
3104
|
+
if (options.exclude) {
|
|
3105
|
+
try {
|
|
3106
|
+
findings = withoutSent(findings, fs.readFileSync(options.exclude, 'utf8'))
|
|
3107
|
+
} catch (err) {
|
|
3108
|
+
console.error(`Could not read ${options.exclude}; nothing excluded: ${err.message}`)
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3076
3111
|
const report = buildDocImpact(findings) || empty
|
|
3077
3112
|
if (options.format === 'human') {
|
|
3078
3113
|
if (findings.length === 0) { console.log('No enterprise gating changes.'); return }
|
package/package.json
CHANGED
|
@@ -26,10 +26,13 @@
|
|
|
26
26
|
* so a test can hand it a saved patch; the CLI wrapper runs git.
|
|
27
27
|
*
|
|
28
28
|
* Detection is by added and removed lines, paired by name, so a value that
|
|
29
|
-
* merely moves within the enum is not reported.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* merely moves within the enum is not reported. Only the member line inside
|
|
30
|
+
* the enum counts; `case license_required_feature::<name>:` labels are
|
|
31
|
+
* ignored, because they move whenever the formatter is rewritten.
|
|
32
|
+
*
|
|
33
|
+
* Not covered: node properties (src/v/config/node_config.h, where
|
|
34
|
+
* `fips_mode` is gated) are not scanned, and a restricted value that names a
|
|
35
|
+
* constant defined elsewhere is not seen (see restrictedValueChanges).
|
|
33
36
|
*
|
|
34
37
|
* Each finding names ONE affected page, the licensing overview. The docs-site
|
|
35
38
|
* receiver falls back to matching a finding with no name match against open
|
|
@@ -54,15 +57,20 @@ const REGISTRY_URL = 'https://github.com/redpanda-data/docs/blob/main/shared/mod
|
|
|
54
57
|
function splitFiles (diffText) {
|
|
55
58
|
const files = []
|
|
56
59
|
let current = null
|
|
60
|
+
let inHunk = false
|
|
57
61
|
for (const raw of String(diffText || '').split('\n')) {
|
|
58
62
|
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw)
|
|
59
63
|
if (m) {
|
|
60
64
|
current = { path: m[2], lines: [] }
|
|
61
65
|
files.push(current)
|
|
66
|
+
inHunk = false
|
|
62
67
|
continue
|
|
63
68
|
}
|
|
64
69
|
if (!current) continue
|
|
65
|
-
|
|
70
|
+
// File header lines come before the first hunk. Inside a hunk, a removed
|
|
71
|
+
// `--x;` or an added `++x;` reads as `---x;` or `+++x;` and is content.
|
|
72
|
+
if (raw.startsWith('@@')) inHunk = true
|
|
73
|
+
if (!inHunk && /^(\+\+\+|---|index |new file|deleted file|similarity|rename|old mode|new mode)/.test(raw)) continue
|
|
66
74
|
current.lines.push(raw)
|
|
67
75
|
}
|
|
68
76
|
return files
|
|
@@ -92,7 +100,7 @@ function enumChanges (lines) {
|
|
|
92
100
|
const sign = line[0]
|
|
93
101
|
if (sign !== '+' && sign !== '-') continue
|
|
94
102
|
const target = sign === '+' ? added : removed
|
|
95
|
-
const member = inEnum && /^\s*([a-z][a-z0-9_]*)\s*,?\s*(\/\/.*)?$/.exec(body)
|
|
103
|
+
const member = inEnum && /^\s*([a-z][a-z0-9_]*)\s*(?:=\s*[^,/]+?)?\s*,?\s*(\/\/.*)?$/.exec(body)
|
|
96
104
|
if (member) target.add(member[1])
|
|
97
105
|
}
|
|
98
106
|
return {
|
|
@@ -107,6 +115,8 @@ function enumChanges (lines) {
|
|
|
107
115
|
// at a `;`, and also at a brace, an access specifier or a preprocessor line,
|
|
108
116
|
// so a declaration never absorbs the line before it.
|
|
109
117
|
const ENTERPRISE_DECL = /^\s*(?:config::)?enterprise<[\s\S]*>\s*([a-z][a-z0-9_]*)\s*;\s*$/
|
|
118
|
+
// Any templated member declaration, wrapped or not (`property<bool> x;`).
|
|
119
|
+
const ANY_DECL = /^\s*[\w:]+<[\s\S]*>\s*([a-z][a-z0-9_]*)\s*;\s*$/
|
|
110
120
|
|
|
111
121
|
// Comments and attributes are dropped before joining: a `/* ... */` or
|
|
112
122
|
// `[[attr]]` line above a declaration has no `;` or brace, so it would
|
|
@@ -186,24 +196,29 @@ function enterprisePropertyNames (headerText) {
|
|
|
186
196
|
* configuration.h. A declaration changed in place shows as a -/+ pair with
|
|
187
197
|
* the same name; the wrapper is on exactly one side. Each side of each hunk
|
|
188
198
|
* (context plus its own changed lines) is joined into statements, so a
|
|
189
|
-
* declaration that wraps across lines is read whole.
|
|
190
|
-
*
|
|
199
|
+
* declaration that wraps across lines is read whole. A wrapped declaration
|
|
200
|
+
* that is removed and declared nowhere on the new side, wrapped or not, is
|
|
201
|
+
* `deleted`: the property is gone, not ungated.
|
|
202
|
+
* @returns {{added: string[], removed: string[], deleted: string[]}}
|
|
191
203
|
*/
|
|
192
204
|
function enterprisePropertyChanges (lines) {
|
|
193
205
|
const added = new Set()
|
|
194
206
|
const removed = new Set()
|
|
195
|
-
const
|
|
207
|
+
const declaredNew = new Set()
|
|
208
|
+
const collect = (side, target, declared) => {
|
|
196
209
|
for (const st of joinStatements(side)) {
|
|
197
210
|
if (!st.changed) continue
|
|
198
211
|
const m = ENTERPRISE_DECL.exec(st.text)
|
|
199
212
|
if (m) target.add(m[1])
|
|
213
|
+
const any = declared && ANY_DECL.exec(st.text)
|
|
214
|
+
if (any) declared.add(any[1])
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
let oldSide = []
|
|
203
218
|
let newSide = []
|
|
204
219
|
const flushHunk = () => {
|
|
205
220
|
collect(oldSide, removed)
|
|
206
|
-
collect(newSide, added)
|
|
221
|
+
collect(newSide, added, declaredNew)
|
|
207
222
|
oldSide = []
|
|
208
223
|
newSide = []
|
|
209
224
|
}
|
|
@@ -216,9 +231,11 @@ function enterprisePropertyChanges (lines) {
|
|
|
216
231
|
else if (sign === ' ') { oldSide.push({ text }); newSide.push({ text }) }
|
|
217
232
|
}
|
|
218
233
|
flushHunk()
|
|
234
|
+
const gone = [...removed].filter((n) => !added.has(n))
|
|
219
235
|
return {
|
|
220
236
|
added: [...added].filter((n) => !removed.has(n)).sort(),
|
|
221
|
-
removed:
|
|
237
|
+
removed: gone.filter((n) => declaredNew.has(n)).sort(),
|
|
238
|
+
deleted: gone.filter((n) => !declaredNew.has(n)).sort()
|
|
222
239
|
}
|
|
223
240
|
}
|
|
224
241
|
|
|
@@ -253,6 +270,14 @@ function restrictedValueChanges (lines, enterpriseNames) {
|
|
|
253
270
|
const body = line.slice(1)
|
|
254
271
|
const opens = /^\s*,\s*([a-z][a-z0-9_]*)\s*\(/.exec(body)
|
|
255
272
|
if (opens) { init = opens[1]; changed = false; continue }
|
|
273
|
+
// `*this, <value>, "name",` on one line carries the whole restricted
|
|
274
|
+
// argument list.
|
|
275
|
+
const oneLine = /^\s*\*this\s*,.*"([a-z][a-z0-9_]*)"\s*,/.exec(body)
|
|
276
|
+
if (oneLine && enterpriseNames.has(oneLine[1])) {
|
|
277
|
+
if (sign !== ' ') found.add(oneLine[1])
|
|
278
|
+
changed = false
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
256
281
|
if (/^\s*\*this\s*,/.test(body)) { changed = sign !== ' '; continue }
|
|
257
282
|
// The name literal closes the restricted arguments. A string literal
|
|
258
283
|
// that is neither this initializer's name nor an enterprise property
|
|
@@ -307,7 +332,7 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
|
|
|
307
332
|
})
|
|
308
333
|
}
|
|
309
334
|
} else if (file.path === CONFIG_HEADER) {
|
|
310
|
-
const { added, removed } = enterprisePropertyChanges(file.lines)
|
|
335
|
+
const { added, removed, deleted } = enterprisePropertyChanges(file.lines)
|
|
311
336
|
for (const name of added) {
|
|
312
337
|
findings.push({
|
|
313
338
|
surface: 'enterprise-property',
|
|
@@ -326,6 +351,15 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
|
|
|
326
351
|
summary: `\`${name}\` is no longer wrapped in \`config::enterprise<>\` in \`${CONFIG_HEADER}\`, so it no longer needs a license. The registry entry that names it (${REGISTRY_URL}) and its row on ${DISABLE_PAGE} need to change.`
|
|
327
352
|
})
|
|
328
353
|
}
|
|
354
|
+
for (const name of deleted) {
|
|
355
|
+
findings.push({
|
|
356
|
+
surface: 'enterprise-property',
|
|
357
|
+
name,
|
|
358
|
+
change_kind: 'enterprise-property-removed',
|
|
359
|
+
affected_pages: [LICENSING_OVERVIEW],
|
|
360
|
+
summary: `\`${name}\`, a \`config::enterprise<>\` property, was removed from \`${CONFIG_HEADER}\`. From the first release that contains this change the property no longer exists, so its row on ${DISABLE_PAGE} goes and the registry entry that names it (${REGISTRY_URL}) needs a new source pointer or a note, never deletion: a published external name stays in the registry.`
|
|
361
|
+
})
|
|
362
|
+
}
|
|
329
363
|
} else if (file.path === CONFIG_SOURCE) {
|
|
330
364
|
// A property whose wrapper changed in the header is already reported
|
|
331
365
|
// above; its initializer changes with it.
|
|
@@ -344,6 +378,18 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
|
|
|
344
378
|
return findings
|
|
345
379
|
}
|
|
346
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Drop findings already dispatched for this PR. `sent` is the text of the
|
|
383
|
+
* review state's dispatched-gating.txt: one `name change_kind` key per line.
|
|
384
|
+
* Filtering here, before buildDocImpact, keeps the ticket title and body to
|
|
385
|
+
* the findings that remain, and lets an eleventh finding in once the first
|
|
386
|
+
* ten are sent.
|
|
387
|
+
*/
|
|
388
|
+
function withoutSent (findings, sent) {
|
|
389
|
+
const done = new Set(String(sent || '').split('\n').map((l) => l.trim()).filter(Boolean))
|
|
390
|
+
return findings.filter((f) => !done.has(`${f.name} ${f.change_kind}`))
|
|
391
|
+
}
|
|
392
|
+
|
|
347
393
|
/**
|
|
348
394
|
* Shape findings as the doc-impact report the review workflow dispatches
|
|
349
395
|
* (the same JSON the model writes to doc-impact.json), or null when there is
|
|
@@ -385,6 +431,7 @@ module.exports = {
|
|
|
385
431
|
CONFIG_SOURCE,
|
|
386
432
|
detectGatingChanges,
|
|
387
433
|
buildDocImpact,
|
|
434
|
+
withoutSent,
|
|
388
435
|
enumChanges,
|
|
389
436
|
enterprisePropertyChanges,
|
|
390
437
|
enterprisePropertyNames,
|
|
@@ -132,48 +132,90 @@ function insertReleaseSection(pageContent, sectionText) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* unchanged — only the order of entries within a category changes. Running it on
|
|
142
|
-
* an already-clustered section is a no-op, so it is safe to apply on every
|
|
143
|
-
* release and on a backfill.
|
|
135
|
+
* Groups the entries within each `=== category` of a curated section by their
|
|
136
|
+
* `Area::` label. Areas are ordered ALPHABETICALLY (case-insensitive), so a given
|
|
137
|
+
* area sits in the same place on every release instead of moving with the
|
|
138
|
+
* source's merge order; entries keep their source order within an area, so the
|
|
139
|
+
* sort is stable. Each area is written ONCE per category, as the definition-list
|
|
140
|
+
* term, with its entries as a bulleted list under it:
|
|
144
141
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
142
|
+
* Cloud Topics::
|
|
143
|
+
* * First entry.
|
|
144
|
+
* * Second entry.
|
|
145
|
+
*
|
|
146
|
+
* An area with one entry still gets a one-item list. Input entries may be in
|
|
147
|
+
* either form: single-line `Area:: description` (what curation writes) or an
|
|
148
|
+
* already-grouped `Area::` paragraph with single-line `* ` items. A category can
|
|
149
|
+
* mix the two. Running it on its own output is a no-op, so it is safe to apply on
|
|
150
|
+
* every release, on a backfill, and on a section grouped by hand.
|
|
151
|
+
*
|
|
152
|
+
* A category with any other kind of entry (a phase-1 candidate's plain `*`
|
|
153
|
+
* bullets, or a multi-line entry) is not grouped, so this is a safe no-op on
|
|
154
|
+
* anything it does not recognize. Its entries keep their order and text. The
|
|
155
|
+
* one change is that single-line entries on adjacent lines come out with a
|
|
156
|
+
* blank line between them, which AsciiDoc renders the same way. Line endings
|
|
157
|
+
* in the output are LF. A curated category (one with at least
|
|
158
|
+
* one `Area::` entry) that is left as it is logs a warning, because it then
|
|
159
|
+
* ships ungrouped. Area keys are trimmed, so `Kafka API` and `Kafka API ` are one
|
|
160
|
+
* area, written as the trimmed label. Headings and any prose outside a category
|
|
161
|
+
* are passed through untouched.
|
|
153
162
|
*
|
|
154
163
|
* @param {string} sectionText - A rendered release section.
|
|
155
|
-
* @return {string} The section with each category's entries
|
|
164
|
+
* @return {string} The section with each category's entries grouped by area,
|
|
156
165
|
* ending in a single newline.
|
|
157
166
|
*/
|
|
158
167
|
function clusterSectionByArea(sectionText) {
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// that first separator, so a `::` later in the description is
|
|
162
|
-
// leading list marker (`*`, `-`, `.`), at column zero or
|
|
163
|
-
// so a plain bullet that happens to carry a `::`
|
|
164
|
-
// is not mistaken for an area entry
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
// A single-line entry: the area label is the text up to the first `:: `
|
|
169
|
+
// (double-colon + space), the AsciiDoc definition-list term separator. A lazy
|
|
170
|
+
// match stops at that first separator, so a `::` later in the description is
|
|
171
|
+
// left alone. A leading list marker (`*`, `-`, `.`), at column zero or
|
|
172
|
+
// indented, is rejected so a plain bullet that happens to carry a `::` (a
|
|
173
|
+
// phase-1 candidate line) is not mistaken for an area entry.
|
|
174
|
+
const ENTRY_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s+(\S.*)$/;
|
|
175
|
+
// A grouped area: a term line with nothing after the `::`.
|
|
176
|
+
const TERM_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s*$/;
|
|
177
|
+
// An item of a grouped area: one `* ` bullet on one line.
|
|
178
|
+
const ITEM_RE = /^\* (\S.*)$/;
|
|
179
|
+
|
|
180
|
+
// Parses one paragraph into [area, items], or null if it is not a recognized
|
|
181
|
+
// entry. A grouped paragraph must be a term line followed only by items.
|
|
182
|
+
const parseEntry = (para) => {
|
|
183
|
+
if (para.length === 1) {
|
|
184
|
+
const m = para[0].match(ENTRY_RE);
|
|
185
|
+
if (m) return [m[1].trim(), [m[2]]];
|
|
186
|
+
}
|
|
187
|
+
const t = para[0].match(TERM_RE);
|
|
188
|
+
if (t && para.length > 1) {
|
|
189
|
+
const items = [];
|
|
190
|
+
for (const line of para.slice(1)) {
|
|
191
|
+
const i = line.match(ITEM_RE);
|
|
192
|
+
if (!i) return null;
|
|
193
|
+
items.push(i[1]);
|
|
194
|
+
}
|
|
195
|
+
return [t[1].trim(), items];
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
const looksCurated = (para) => ENTRY_RE.test(para[0]) || TERM_RE.test(para[0]);
|
|
167
200
|
|
|
168
201
|
// Tokenize into paragraphs: maximal runs of non-blank lines. In this format
|
|
169
|
-
// every heading and every
|
|
170
|
-
|
|
202
|
+
// every heading, every single-line entry, and every grouped area is one such
|
|
203
|
+
// paragraph. Single-line entries on adjacent lines, with no blank line
|
|
204
|
+
// between them, are valid AsciiDoc too, so each one starts its own paragraph.
|
|
205
|
+
// A grouped area's term line does not match ENTRY_RE, so its bullets stay in
|
|
206
|
+
// one paragraph with it. CRLF and CR line endings become LF first: `.` in
|
|
207
|
+
// the patterns above does not match `\r`, so a CRLF line would match nothing.
|
|
208
|
+
const lines = String(sectionText).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n');
|
|
171
209
|
const paragraphs = [];
|
|
172
210
|
let current = [];
|
|
173
211
|
for (const line of lines) {
|
|
174
212
|
if (/^\s*$/.test(line)) {
|
|
175
213
|
if (current.length) { paragraphs.push(current); current = []; }
|
|
176
214
|
} else {
|
|
215
|
+
if (current.length === 1 && ENTRY_RE.test(current[0]) && ENTRY_RE.test(line)) {
|
|
216
|
+
paragraphs.push(current);
|
|
217
|
+
current = [];
|
|
218
|
+
}
|
|
177
219
|
current.push(line);
|
|
178
220
|
}
|
|
179
221
|
}
|
|
@@ -181,38 +223,35 @@ function clusterSectionByArea(sectionText) {
|
|
|
181
223
|
|
|
182
224
|
const out = [];
|
|
183
225
|
let categoryEntries = null; // entry paragraphs of the open `=== category`
|
|
184
|
-
let categoryBail = false; // the open category has an unrecognized entry
|
|
185
226
|
let categoryTitle = ''; // the open category's heading text, for warnings
|
|
186
227
|
|
|
187
228
|
const flushCategory = () => {
|
|
188
229
|
if (!categoryEntries) return;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
230
|
+
const parsed = categoryEntries.map(parseEntry);
|
|
231
|
+
if (parsed.some((e) => e === null)) {
|
|
232
|
+
// A phase-1 bullet category is expected to be left as it is. A curated one
|
|
233
|
+
// (it has at least one `Area::` entry) left as it is means some entry is
|
|
234
|
+
// neither form, so the whole category ships ungrouped. Say so rather than
|
|
235
|
+
// fail quietly.
|
|
236
|
+
if (categoryEntries.some(looksCurated)) {
|
|
237
|
+
console.warn(`${LOG_TAG} WARN: "${categoryTitle}" not grouped by area: an entry is neither a single-line \`Area:: description\` nor an \`Area::\` term with single-line \`* \` items (for example a \`+\` continuation or a code block).`);
|
|
195
238
|
}
|
|
196
239
|
for (const entry of categoryEntries) out.push(entry);
|
|
197
240
|
} else {
|
|
198
241
|
const order = [];
|
|
199
242
|
const groups = new Map();
|
|
200
|
-
for (const
|
|
201
|
-
// Trimmed, so a stray space before `::` does not split one area in two.
|
|
202
|
-
// Only the grouping key is trimmed; the entry text is emitted verbatim.
|
|
203
|
-
const key = entry[0].match(ENTRY_RE)[1].trim();
|
|
243
|
+
for (const [key, items] of parsed) {
|
|
204
244
|
if (!groups.has(key)) { groups.set(key, []); order.push(key); }
|
|
205
|
-
groups.get(key).push(
|
|
245
|
+
groups.get(key).push(...items);
|
|
206
246
|
}
|
|
207
247
|
// Areas alphabetical (case-insensitive), so an area lands in the same
|
|
208
248
|
// place on every release; entries keep their source order within an area.
|
|
209
249
|
order.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }));
|
|
210
250
|
for (const key of order) {
|
|
211
|
-
|
|
251
|
+
out.push([`${key}::`, ...groups.get(key).map((item) => `* ${item}`)]);
|
|
212
252
|
}
|
|
213
253
|
}
|
|
214
254
|
categoryEntries = null;
|
|
215
|
-
categoryBail = false;
|
|
216
255
|
};
|
|
217
256
|
|
|
218
257
|
for (const para of paragraphs) {
|
|
@@ -222,7 +261,6 @@ function clusterSectionByArea(sectionText) {
|
|
|
222
261
|
flushCategory();
|
|
223
262
|
out.push(para);
|
|
224
263
|
categoryEntries = [];
|
|
225
|
-
categoryBail = false;
|
|
226
264
|
categoryTitle = first.replace(/^===\s+/, '').trim();
|
|
227
265
|
} else if (/^==\s/.test(first)) {
|
|
228
266
|
// The release heading (or any other top-level heading) closes the open
|
|
@@ -232,7 +270,6 @@ function clusterSectionByArea(sectionText) {
|
|
|
232
270
|
categoryEntries = null;
|
|
233
271
|
} else if (categoryEntries) {
|
|
234
272
|
categoryEntries.push(para);
|
|
235
|
-
if (para.length !== 1 || !ENTRY_RE.test(para[0])) categoryBail = true;
|
|
236
273
|
} else {
|
|
237
274
|
// Prose outside any category (for example an intro paragraph): untouched.
|
|
238
275
|
out.push(para);
|
|
@@ -250,10 +287,11 @@ function clusterSectionByArea(sectionText) {
|
|
|
250
287
|
* @param {string} options.body - The raw rpchangelog release body markdown.
|
|
251
288
|
* @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
|
|
252
289
|
* @param {string} options.date - The release date as `YYYY-MM-DD`.
|
|
290
|
+
* @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
|
|
253
291
|
* @return {string} The rendered AsciiDoc section.
|
|
254
292
|
*/
|
|
255
|
-
function buildReleaseSection({ body, version, date }) {
|
|
256
|
-
const { sections } = parseReleaseBody(body);
|
|
293
|
+
function buildReleaseSection({ body, version, date, propertyNames }) {
|
|
294
|
+
const { sections } = parseReleaseBody(body, { propertyNames });
|
|
257
295
|
return renderReleaseSection({ version, date, sections });
|
|
258
296
|
}
|
|
259
297
|
|
|
@@ -306,9 +344,10 @@ function assertSectionMatchesTag(section, version) {
|
|
|
306
344
|
* @param {string} options.tag - The release tag (`v26.2.3`).
|
|
307
345
|
* @param {string} [options.date] - The authored release date as `YYYY-MM-DD` (phase 1 only).
|
|
308
346
|
* @param {string} options.pageContent - The current target page content.
|
|
347
|
+
* @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros (phase 1 only).
|
|
309
348
|
* @return {{status: 'ok'|'skipped', reason?: string, section?: string, content?: string}}
|
|
310
349
|
*/
|
|
311
|
-
function generateReleaseNotes({ body, section, tag, date, pageContent }) {
|
|
350
|
+
function generateReleaseNotes({ body, section, tag, date, pageContent, propertyNames }) {
|
|
312
351
|
if (body == null && section == null) {
|
|
313
352
|
throw new Error('Provide either body (phase 1) or section (phase 2).');
|
|
314
353
|
}
|
|
@@ -337,7 +376,7 @@ function generateReleaseNotes({ body, section, tag, date, pageContent }) {
|
|
|
337
376
|
// have to, and so a hand-written section is normalized the same way.
|
|
338
377
|
finalSection = clusterSectionByArea(section);
|
|
339
378
|
} else {
|
|
340
|
-
finalSection = buildReleaseSection({ body, version, date });
|
|
379
|
+
finalSection = buildReleaseSection({ body, version, date, propertyNames });
|
|
341
380
|
}
|
|
342
381
|
const content = insertReleaseSection(pageContent, finalSection);
|
|
343
382
|
return { status: 'ok', section: finalSection, content };
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// This module does ONLY the deterministic, no-judgment work: split into
|
|
10
10
|
// sections, join wrapped bullet lines, strip source noise, unescape backticks,
|
|
11
|
-
// de-duplicate backport repeats, and
|
|
11
|
+
// link known configuration properties, de-duplicate backport repeats, and
|
|
12
|
+
// normalize category order/case. It does NOT
|
|
12
13
|
// rewrite voice, add `Area::` labels, normalize units, phrase CVEs, or drop
|
|
13
14
|
// bodyless entries — those need editorial judgment and belong to the curation
|
|
14
15
|
// step that runs on the generated page.
|
|
@@ -88,18 +89,43 @@ function stripLeadingIssueRef(text) {
|
|
|
88
89
|
* the editorial curation step.
|
|
89
90
|
*
|
|
90
91
|
* @param {string} raw - The joined raw bullet text.
|
|
92
|
+
* @param {Object} [options]
|
|
93
|
+
* @param {Set<string>} [options.propertyNames] - Known property names to link (see linkProperties).
|
|
91
94
|
* @return {string} The cleaned entry text.
|
|
92
95
|
*/
|
|
93
|
-
function cleanEntry(raw) {
|
|
96
|
+
function cleanEntry(raw, { propertyNames } = {}) {
|
|
94
97
|
let text = raw.replace(/\s+/g, ' ').trim();
|
|
95
98
|
text = stripLeadingIssueRef(text);
|
|
96
99
|
text = stripAttribution(text);
|
|
97
100
|
text = text.replace(/\\`/g, '`');
|
|
101
|
+
text = linkProperties(text, propertyNames);
|
|
98
102
|
text = normalizeUnits(text);
|
|
99
103
|
text = escapeAsciiDocBraces(text);
|
|
100
104
|
return text.trim();
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Rewrites a backticked configuration property name as a `prop:` macro with
|
|
109
|
+
* `link=true`, so the page links it to the property reference. Without
|
|
110
|
+
* `link=true`, the macro shows only a hover tooltip. Engineers cannot write `prop:` in
|
|
111
|
+
* the source, because nothing resolves it outside the docs build, so this is
|
|
112
|
+
* where the link is added.
|
|
113
|
+
*
|
|
114
|
+
* Only an exact match for a known name is rewritten: `` `retention.ms` `` is,
|
|
115
|
+
* `` `retention.ms=1000` `` and `` `health_manager` `` are not. A name with no
|
|
116
|
+
* `_` or `.` (such as `admin` or `rack`) is never rewritten, because in prose
|
|
117
|
+
* those words usually mean something other than the property.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} text - The entry text, with backticks already unescaped.
|
|
120
|
+
* @param {Set<string>} [propertyNames] - Known property names. Without it, the text is returned unchanged.
|
|
121
|
+
* @return {string} The text with known property names as `prop:<name>[link=true]`.
|
|
122
|
+
*/
|
|
123
|
+
function linkProperties(text, propertyNames) {
|
|
124
|
+
if (!propertyNames || propertyNames.size === 0) return text;
|
|
125
|
+
return text.replace(/`([\w.-]+)`/g, (match, name) =>
|
|
126
|
+
/[_.]/.test(name) && propertyNames.has(name) ? `prop:${name}[link=true]` : match);
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/**
|
|
104
130
|
* Rewrites the source's underscore data-size notation to the readable unit the
|
|
105
131
|
* standard requires (`4_MiB` -> `4 MiB`). Only a digit followed by `_` and a
|
|
@@ -131,11 +157,13 @@ function escapeAsciiDocBraces(text) {
|
|
|
131
157
|
* grouped by canonical category in page order.
|
|
132
158
|
*
|
|
133
159
|
* @param {string} body - The raw GitHub Release body markdown.
|
|
160
|
+
* @param {Object} [options]
|
|
161
|
+
* @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
|
|
134
162
|
* @return {{sections: Array<{kind: string, title: string, entries: string[]}>}}
|
|
135
163
|
* Sections in page order (Features, Bug fixes, Improvements); a category with
|
|
136
164
|
* no entries is omitted.
|
|
137
165
|
*/
|
|
138
|
-
function parseReleaseBody(body) {
|
|
166
|
+
function parseReleaseBody(body, { propertyNames } = {}) {
|
|
139
167
|
if (typeof body !== 'string' || body.trim() === '') {
|
|
140
168
|
throw new Error('Release body is empty.');
|
|
141
169
|
}
|
|
@@ -155,7 +183,7 @@ function parseReleaseBody(body) {
|
|
|
155
183
|
|
|
156
184
|
const flush = () => {
|
|
157
185
|
if (current !== null && currentKind && buckets[currentKind]) {
|
|
158
|
-
const entry = cleanEntry(current);
|
|
186
|
+
const entry = cleanEntry(current, { propertyNames });
|
|
159
187
|
if (entry) buckets[currentKind].push(entry);
|
|
160
188
|
}
|
|
161
189
|
current = null;
|
|
@@ -231,6 +259,7 @@ module.exports = {
|
|
|
231
259
|
stripAttribution,
|
|
232
260
|
stripLeadingIssueRef,
|
|
233
261
|
cleanEntry,
|
|
262
|
+
linkProperties,
|
|
234
263
|
normalizeUnits,
|
|
235
264
|
escapeAsciiDocBraces,
|
|
236
265
|
PAGE_ORDER,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Load the configuration property names that the release-notes generator links
|
|
2
|
+
// as `prop:` macros. The names come from the same redpanda-properties-<tag>.json
|
|
3
|
+
// attachment that the prop macro validates against, so a name linked here is a
|
|
4
|
+
// name the docs build can resolve.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// The attachment directory, relative to the docs repository root.
|
|
10
|
+
const ATTACHMENTS_DIR = 'modules/reference/attachments';
|
|
11
|
+
const PROPERTIES_JSON_RX = /^redpanda-properties-v(\d+)\.(\d+)\.(\d+)\.json$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the property names in a parsed property JSON file.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} json - Parsed redpanda-properties-<tag>.json content.
|
|
17
|
+
* @return {Set<string>} The property names.
|
|
18
|
+
* @throws {Error} If the file has no `properties` object.
|
|
19
|
+
*/
|
|
20
|
+
function propertyNamesFromJson(json) {
|
|
21
|
+
if (!json || typeof json.properties !== 'object' || json.properties === null) {
|
|
22
|
+
throw new Error('Property file has no "properties" object.');
|
|
23
|
+
}
|
|
24
|
+
return new Set(Object.keys(json.properties));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Finds the newest GA property file for a tag's release series. A 26.2 tag
|
|
29
|
+
* uses 26.2 data only, the same rule the prop macro applies to a page, so a
|
|
30
|
+
* property from another release series is never linked.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} dir - The directory to search.
|
|
33
|
+
* @param {string} tag - The release tag (`v26.2.5` or `26.2.5`).
|
|
34
|
+
* @return {string|null} The path to the newest matching file, or null if there is none.
|
|
35
|
+
*/
|
|
36
|
+
function findPropertiesFile(dir, tag) {
|
|
37
|
+
const m = String(tag).match(/^v?(\d+)\.(\d+)\./);
|
|
38
|
+
if (!m || !fs.existsSync(dir)) return null;
|
|
39
|
+
const [, major, minor] = m;
|
|
40
|
+
let best = null;
|
|
41
|
+
for (const file of fs.readdirSync(dir)) {
|
|
42
|
+
const f = file.match(PROPERTIES_JSON_RX);
|
|
43
|
+
if (!f || f[1] !== major || f[2] !== minor) continue;
|
|
44
|
+
const patch = Number(f[3]);
|
|
45
|
+
if (!best || patch > best.patch) best = { patch, file };
|
|
46
|
+
}
|
|
47
|
+
return best ? path.join(dir, best.file) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads the property names from a property JSON file.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} file - Path to a redpanda-properties-<tag>.json file.
|
|
54
|
+
* @return {Set<string>} The property names.
|
|
55
|
+
*/
|
|
56
|
+
function loadPropertyNames(file) {
|
|
57
|
+
return propertyNamesFromJson(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
ATTACHMENTS_DIR,
|
|
62
|
+
propertyNamesFromJson,
|
|
63
|
+
findPropertiesFile,
|
|
64
|
+
loadPropertyNames,
|
|
65
|
+
};
|