@redpanda-data/docs-extensions-and-macros 5.51.2 → 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-mcp.js +4 -0
- package/bin/doc-tools.js +119 -0
- package/bin/mcp-tools/rpcn-docs.js +1 -0
- package/extensions/REFERENCE.adoc +19 -4
- package/extensions/generate-rp-connect-categories.js +76 -25
- package/extensions/generate-rp-connect-info.js +150 -40
- package/extensions/modify-connect-tag-playbook.js +183 -17
- package/extensions/util/connect-catalog.js +153 -0
- package/macros/rp-connect-components.js +23 -8
- package/package.json +1 -1
- package/tools/lint-strings/diff.js +4 -1
- package/tools/lint-strings/engine.js +5 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
- package/tools/lint-strings/fixtures/connect/repo/go.mod.txt +5 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/cli/lint.go +19 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/common.go +20 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/docs.go +8 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/input_thing.go +32 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/output_thing.go +12 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/bloblang.go +21 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/scanner.go +12 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/plugins/info.csv +3 -0
- package/tools/lint-strings/fixtures/connect/repo/internal/retries/retries.go +13 -0
- package/tools/lint-strings/go-index.js +1143 -0
- package/tools/lint-strings/index.js +28 -2
- package/tools/lint-strings/rules/common.js +10 -2
- package/tools/lint-strings/surfaces/connect.js +1484 -137
- package/tools/lint-strings/surfaces/rpk.js +1 -0
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +23 -13
- package/tools/redpanda-connect/report-delta.js +1 -0
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -13
- package/tools/redpanda-connect/templates/connector.hbs +20 -2
- package/tools/redpanda-release-notes/generate-release-notes.js +242 -0
- package/tools/redpanda-release-notes/parse-release-body.js +241 -0
- package/tools/redpanda-release-notes/render-release-notes.js +70 -0
- package/tools/rpk-docs/helpers/index.js +32 -35
|
@@ -334,6 +334,11 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
334
334
|
templateDescription,
|
|
335
335
|
templateBloblang,
|
|
336
336
|
writeFullDrafts,
|
|
337
|
+
// False when the connect repo provides the generated partials, config
|
|
338
|
+
// snippets, and Bloblang reference (see the modify-connect-tag-playbook
|
|
339
|
+
// extension). Drafts are still written, and still include the description
|
|
340
|
+
// partial the connect repo publishes for them.
|
|
341
|
+
writePartials = true,
|
|
337
342
|
// Explicit opt-in for the destructive description-partial orphan sweep.
|
|
338
343
|
// Off by default: see ORPHAN_SWEEP_MAX_FRACTION.
|
|
339
344
|
pruneOrphanedDescriptions = false,
|
|
@@ -458,7 +463,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
458
463
|
: path.join(outputRoot, 'components');
|
|
459
464
|
const configExamplesRoot = path.resolve(process.cwd(), 'modules/components/examples');
|
|
460
465
|
|
|
461
|
-
if (!writeFullDrafts) {
|
|
466
|
+
if (!writeFullDrafts && writePartials) {
|
|
462
467
|
fs.mkdirSync(fieldsOutRoot, { recursive: true });
|
|
463
468
|
fs.mkdirSync(examplesOutRoot, { recursive: true });
|
|
464
469
|
fs.mkdirSync(metadataOutRoot, { recursive: true });
|
|
@@ -512,7 +517,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
512
517
|
examplesOut = renderExamplesPartial(item);
|
|
513
518
|
}
|
|
514
519
|
|
|
515
|
-
if (fieldsOut.trim()) {
|
|
520
|
+
if (writePartials && fieldsOut.trim()) {
|
|
516
521
|
const fPath = path.join(fieldsOutRoot, type, `${name}.adoc`);
|
|
517
522
|
fs.mkdirSync(path.dirname(fPath), { recursive: true });
|
|
518
523
|
if (fs.existsSync(fPath)) {
|
|
@@ -532,7 +537,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
532
537
|
// gate, one list. The old denylist named only the two bloblang keys, so
|
|
533
538
|
// any other non-page data key (config today, anything added upstream
|
|
534
539
|
// tomorrow) leaked partials no page can include.
|
|
535
|
-
if (examplesOut.trim() && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
|
|
540
|
+
if (writePartials && examplesOut.trim() && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
|
|
536
541
|
const ePath = path.join(examplesOutRoot, type, `${name}.adoc`);
|
|
537
542
|
fs.mkdirSync(path.dirname(ePath), { recursive: true });
|
|
538
543
|
fs.writeFileSync(ePath, examplesOut);
|
|
@@ -542,7 +547,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
542
547
|
}
|
|
543
548
|
}
|
|
544
549
|
|
|
545
|
-
if (CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
|
|
550
|
+
if (writePartials && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
|
|
546
551
|
// Render the metadata partial from the connector's description. Only
|
|
547
552
|
// written when the description contains a `== Metadata` section.
|
|
548
553
|
// Rendered inside the guard, not before it: the loop covers every
|
|
@@ -611,8 +616,8 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
611
616
|
visitedDescriptionPartials.add(`${partialTypeDir}/${name}`);
|
|
612
617
|
const dPath = path.join(descriptionOutRoot, partialTypeDir, `${name}.adoc`);
|
|
613
618
|
if (descriptionOut.trim()) {
|
|
614
|
-
fs.mkdirSync(path.dirname(dPath), { recursive: true });
|
|
615
|
-
if (fs.existsSync(dPath)) {
|
|
619
|
+
if (writePartials) fs.mkdirSync(path.dirname(dPath), { recursive: true });
|
|
620
|
+
if (writePartials && fs.existsSync(dPath)) {
|
|
616
621
|
// Same content-loss check the metadata partial ten lines up has
|
|
617
622
|
// had since #236, for the same reason: regeneration is
|
|
618
623
|
// authoritative, but a section disappearing from a published
|
|
@@ -637,7 +642,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
637
642
|
);
|
|
638
643
|
}
|
|
639
644
|
}
|
|
640
|
-
fs.writeFileSync(dPath, descriptionOut);
|
|
645
|
+
if (writePartials) fs.writeFileSync(dPath, descriptionOut);
|
|
641
646
|
// The partial now exists for this connector, so a drafted page can
|
|
642
647
|
// include its body instead of freezing the summary and description
|
|
643
648
|
// into the page. Gated on the file actually being written:
|
|
@@ -652,7 +657,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
652
657
|
if (typeof item.summary === 'string' && item.summary.trim()) {
|
|
653
658
|
item.hasDescriptionMeta = true;
|
|
654
659
|
}
|
|
655
|
-
if (!writeFullDrafts) {
|
|
660
|
+
if (!writeFullDrafts && writePartials) {
|
|
656
661
|
partialsWritten++;
|
|
657
662
|
partialFiles.push(path.relative(process.cwd(), dPath));
|
|
658
663
|
}
|
|
@@ -683,7 +688,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
683
688
|
descriptionReports.push({ connector: `${partialTypeDir}/${name}`, message: seqMsg });
|
|
684
689
|
console.warn(seqMsg);
|
|
685
690
|
}
|
|
686
|
-
} else if (fs.existsSync(dPath)) {
|
|
691
|
+
} else if (writePartials && fs.existsSync(dPath)) {
|
|
687
692
|
// The upstream description disappeared, but a previously generated
|
|
688
693
|
// partial is still on disk and an already published page may include
|
|
689
694
|
// it. Deleting the file would break that include, so blank it to a
|
|
@@ -732,6 +737,11 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
732
737
|
|
|
733
738
|
// typeDir is already computed at the beginning of the loop
|
|
734
739
|
|
|
740
|
+
// When the connect repo publishes the partials, the draft includes
|
|
741
|
+
// its availability, footnotes, and catalog data instead of freezing
|
|
742
|
+
// categories and the cgo requirement into the page.
|
|
743
|
+
item.connectPartials = !writePartials;
|
|
744
|
+
|
|
735
745
|
let content;
|
|
736
746
|
try {
|
|
737
747
|
content = compiledTemplate(item);
|
|
@@ -767,7 +777,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
767
777
|
}
|
|
768
778
|
|
|
769
779
|
// Bloblang function/method partials (only if includeBloblang is true)
|
|
770
|
-
if (options.includeBloblang) {
|
|
780
|
+
if (writePartials && options.includeBloblang) {
|
|
771
781
|
for (const { key, folder } of bloblangTypes) {
|
|
772
782
|
const items = dataObj[key];
|
|
773
783
|
if (!Array.isArray(items)) continue;
|
|
@@ -877,7 +887,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
877
887
|
// Common/Advanced config snippet YAMLs in modules/components/examples
|
|
878
888
|
const commonConfig = helpers.commonConfig;
|
|
879
889
|
const advancedConfig = helpers.advancedConfig;
|
|
880
|
-
for (const [type, items] of Object.entries(dataObj)) {
|
|
890
|
+
for (const [type, items] of writePartials ? Object.entries(dataObj) : []) {
|
|
881
891
|
if (!Array.isArray(items)) continue;
|
|
882
892
|
for (const item of items) {
|
|
883
893
|
if (!item.name || !item.config || !Array.isArray(item.config.children)) continue;
|
|
@@ -908,7 +918,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
908
918
|
// The sweep is destructive against published content, so collect the
|
|
909
919
|
// candidates first and check the blast radius before writing anything.
|
|
910
920
|
let orphanSweepSkipped = null;
|
|
911
|
-
if (!writeFullDrafts && fs.existsSync(descriptionOutRoot)) {
|
|
921
|
+
if (!writeFullDrafts && writePartials && fs.existsSync(descriptionOutRoot)) {
|
|
912
922
|
const orphans = [];
|
|
913
923
|
let partialsOnDisk = 0;
|
|
914
924
|
for (const typeDirName of fs.readdirSync(descriptionOutRoot)) {
|
|
@@ -966,7 +976,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
966
976
|
// Self-heal published page headers: pages are one-time drafts, so pages
|
|
967
977
|
// created before the template emitted :description: never got one.
|
|
968
978
|
let descriptionBackfill = { backfilled: [], skippedNoSummary: [] };
|
|
969
|
-
if (!writeFullDrafts) {
|
|
979
|
+
if (!writeFullDrafts && writePartials) {
|
|
970
980
|
descriptionBackfill = backfillPageDescriptions(dataObj);
|
|
971
981
|
}
|
|
972
982
|
|
|
@@ -24,6 +24,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
|
|
|
24
24
|
type,
|
|
25
25
|
status: raw.status || '',
|
|
26
26
|
version: raw.version || raw.introducedInVersion || '',
|
|
27
|
+
summary: raw.summary || '',
|
|
27
28
|
description: raw.description || '',
|
|
28
29
|
requiresCgo: metadata.requiresCgo || false,
|
|
29
30
|
cloudOnly: metadata.cloudOnly || false,
|
|
@@ -399,7 +399,7 @@ function updateWhatsNew ({ dataDir, oldVersion, newVersion, binaryAnalysis }) {
|
|
|
399
399
|
for (const comp of regularComponents) {
|
|
400
400
|
const typeLabel = comp.type.charAt(0).toUpperCase() + comp.type.slice(1)
|
|
401
401
|
const statusLabel = comp.status || '-'
|
|
402
|
-
let desc = comp.summary
|
|
402
|
+
let desc = comp.summary ? capToTwoSentences(comp.summary) : (comp.description ? capToTwoSentences(comp.description) : '// TODO: Add description')
|
|
403
403
|
|
|
404
404
|
if (comp.requiresCgo) {
|
|
405
405
|
const cgoNote = '\nNOTE: Requires a cgo-enabled binary. See the xref:install:index.adoc[installation guides] for details.'
|
|
@@ -1243,8 +1243,10 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1243
1243
|
// Main Processing: Handle the latest version (final iteration)
|
|
1244
1244
|
// ========================================================================
|
|
1245
1245
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1246
|
+
// options.partials is false with --no-partials: the connect repo publishes
|
|
1247
|
+
// the generated partials, so this run only updates release-level content.
|
|
1248
|
+
const writePartials = options.partials !== false
|
|
1249
|
+
let partialsWritten = 0, partialFiles = []
|
|
1248
1250
|
const descriptionReports = []
|
|
1249
1251
|
const lostSectionWarnings = []
|
|
1250
1252
|
const styleRegressionWarnings = []
|
|
@@ -1256,7 +1258,10 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1256
1258
|
const { collectGeneratorReports } = require('./pr-summary-formatter.js')
|
|
1257
1259
|
const collect = (result) => collectGeneratorReports(result, { descriptionReports, lostSectionWarnings, styleRegressionWarnings })
|
|
1258
1260
|
|
|
1259
|
-
|
|
1261
|
+
if (!writePartials) {
|
|
1262
|
+
console.log('Skipping connector partials (--no-partials): the connect repo provides them.')
|
|
1263
|
+
} else try {
|
|
1264
|
+
console.log('Generating connector partials...')
|
|
1260
1265
|
const result = await generateRpcnConnectorDocs({
|
|
1261
1266
|
data: dataFile,
|
|
1262
1267
|
overrides: options.overrides,
|
|
@@ -1511,20 +1516,20 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1511
1516
|
}
|
|
1512
1517
|
}
|
|
1513
1518
|
|
|
1514
|
-
// Publish
|
|
1519
|
+
// Publish the connector data to attachments. The Bloblang playground and
|
|
1520
|
+
// other site tools read this file, so it is published on every run, with
|
|
1521
|
+
// overrides merged in only when an overrides file still exists.
|
|
1515
1522
|
// IMPORTANT: This must run AFTER binary analysis and augmentation to include CGO-only connectors
|
|
1516
|
-
|
|
1523
|
+
{
|
|
1517
1524
|
try {
|
|
1518
|
-
const { mergeOverrides, resolveReferences } = require('./generate-rpcn-connector-docs.js')
|
|
1519
|
-
|
|
1520
1525
|
// Use the augmented newIndex which now includes CGO-only and cloud-only connectors
|
|
1521
1526
|
const mergedData = JSON.parse(JSON.stringify(newIndex))
|
|
1522
1527
|
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
+
if (options.overrides && fs.existsSync(options.overrides)) {
|
|
1529
|
+
const { mergeOverrides, resolveReferences } = require('./generate-rpcn-connector-docs.js')
|
|
1530
|
+
const ovObj = JSON.parse(fs.readFileSync(options.overrides, 'utf8'))
|
|
1531
|
+
mergeOverrides(mergedData, resolveReferences(ovObj, ovObj))
|
|
1532
|
+
}
|
|
1528
1533
|
|
|
1529
1534
|
const attachmentsRoot = path.resolve(process.cwd(), 'modules/components/attachments')
|
|
1530
1535
|
fs.mkdirSync(attachmentsRoot, { recursive: true })
|
|
@@ -2066,6 +2071,7 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
2066
2071
|
templateDescription: options.templateDescription,
|
|
2067
2072
|
templateIntro: options.templateIntro,
|
|
2068
2073
|
writeFullDrafts: true,
|
|
2074
|
+
writePartials,
|
|
2069
2075
|
cgoOnly: binaryAnalysis?.cgoOnly || [],
|
|
2070
2076
|
cloudOnly: binaryAnalysis?.comparison?.cloudOnly || [],
|
|
2071
2077
|
csvMetadata
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
= {{{name}}}
|
|
2
2
|
// tag::single-source[]
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
{{!-- Drafts are written once and never regenerated, so they hold no
|
|
4
|
+
reference facts that change with the source. The type comes from the
|
|
5
|
+
page directory, the support level and Cloud availability from the
|
|
6
|
+
catalog data, and, when the connect repo publishes the partials
|
|
7
|
+
(connectPartials), the categories from its catalog.json and the status
|
|
8
|
+
and cgo flags from the description partial's meta region. --}}
|
|
9
|
+
{{#unless connectPartials}}
|
|
5
10
|
:categories: [{{join categories ", "}}]
|
|
11
|
+
{{/unless}}
|
|
6
12
|
{{#if hasDescriptionMeta}}
|
|
7
13
|
// tag::meta[]
|
|
8
14
|
{{{descriptionInclude this "meta"}}}
|
|
@@ -27,6 +33,10 @@
|
|
|
27
33
|
(cloud-docs .github/description-allowlist.txt). --}}
|
|
28
34
|
|
|
29
35
|
component_type_dropdown::[]
|
|
36
|
+
{{#if connectPartials}}
|
|
37
|
+
|
|
38
|
+
include::connect:components:partial$availability/{{typeDir}}/{{name}}.adoc[opts=optional]
|
|
39
|
+
{{/if}}
|
|
30
40
|
|
|
31
41
|
{{#if hasDescriptionPartial}}
|
|
32
42
|
{{{descriptionInclude this "body"}}}
|
|
@@ -58,6 +68,7 @@ include::components:example$advanced/{{typeDir}}/{{name}}.yaml[]
|
|
|
58
68
|
======
|
|
59
69
|
{{/if}}
|
|
60
70
|
|
|
71
|
+
{{#unless connectPartials}}
|
|
61
72
|
{{#if requiresCgo}}
|
|
62
73
|
== Requirements
|
|
63
74
|
|
|
@@ -71,6 +82,7 @@ You can either xref:install:prebuilt-binary.adoc[download a prebuilt cgo-enabled
|
|
|
71
82
|
{{/if}}
|
|
72
83
|
|
|
73
84
|
{{/if}}
|
|
85
|
+
{{/unless}}
|
|
74
86
|
{{#if (or this.config.children children)}}
|
|
75
87
|
include::connect:components:partial$fields/{{typeDir}}/{{name}}.adoc[]
|
|
76
88
|
{{/if}}
|
|
@@ -78,6 +90,12 @@ include::connect:components:partial$fields/{{typeDir}}/{{name}}.adoc[]
|
|
|
78
90
|
{{#if this.examples}}
|
|
79
91
|
include::connect:components:partial$examples/{{typeDir}}/{{name}}.adoc[]
|
|
80
92
|
{{/if}}
|
|
93
|
+
{{#if connectPartials}}
|
|
94
|
+
{{#if hasDescriptionPartial}}
|
|
95
|
+
|
|
96
|
+
{{{descriptionInclude this "footnotes"}}}
|
|
97
|
+
{{/if}}
|
|
98
|
+
{{/if}}
|
|
81
99
|
|
|
82
100
|
|
|
83
101
|
// end::single-source[]
|
|
@@ -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
|
+
};
|