@redpanda-data/docs-extensions-and-macros 5.53.0 → 5.55.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 -5
- package/bin/doc-tools.js +166 -2
- package/bin/mcp-tools/generated-docs-review.js +26 -9
- package/bin/mcp-tools/overrides-audit.js +1 -1
- package/bin/mcp-tools/rpcn-docs.js +4 -14
- package/cli-utils/format-helm-spec.js +4 -3
- package/cli-utils/helm-commented-values.js +88 -18
- package/cli-utils/self-managed-docs-branch.js +36 -30
- package/extension-utils/html-utils.js +27 -0
- package/extensions/REFERENCE.adoc +64 -12
- package/extensions/add-git-dates.js +13 -1
- package/extensions/aggregate-terms.js +34 -20
- package/extensions/generate-rp-connect-info.js +79 -24
- package/extensions/modify-connect-tag-playbook.js +498 -75
- package/extensions/resolve-xrefs-in-attachments.js +1 -12
- package/extensions/url-to-xref.js +3 -0
- package/extensions/util/connect-catalog.js +10 -5
- package/extensions/util/connect-docs-asset.js +188 -0
- package/extensions/util/connect-github.js +16 -0
- package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
- package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
- package/extensions/version-fetcher/list-connect-releases.js +10 -0
- package/extensions/version-fetcher/release-lines.js +64 -0
- package/extensions/version-fetcher/set-latest-version.js +73 -3
- package/macros/README.adoc +1 -0
- package/macros/REFERENCE.adoc +99 -0
- package/macros/badge.js +5 -8
- package/macros/enterprise.js +2 -5
- package/macros/glossary.js +4 -14
- package/macros/iceberg-explorer.js +242 -0
- package/macros/prop.js +3 -13
- package/mcp/USER_GUIDE.adoc +25 -135
- package/package.json +4 -2
- package/tools/connect-docs/check-build-log.js +311 -0
- package/tools/connect-docs/check-rendered-html.js +469 -0
- package/tools/connect-docs/ci-playbook.yml +77 -0
- package/tools/connect-docs/diff-generated.js +382 -0
- package/tools/connect-docs/pr-comment.js +104 -0
- package/tools/doc-impact-recording/index.js +190 -0
- package/tools/overrides-audit/README.adoc +1 -2
- package/tools/overrides-audit/adapters/properties.js +1 -1
- package/tools/overrides-audit/index.js +3 -4
- package/tools/redpanda-connect/AUTOMATION.md +3 -36
- package/tools/redpanda-connect/README.adoc +5 -33
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
- package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
- package/tools/redpanda-connect/report-delta.js +6 -3
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
- package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
- package/tools/rpk-docs/templates/command.hbs +1 -1
- package/tools/overrides-audit/adapters/connect.js +0 -144
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* doc-tools connect-docs-diff: what a connect PR changes on docs.redpanda.com.
|
|
8
|
+
*
|
|
9
|
+
* Compares two trees generated by connect's docs generator (the PR's merge
|
|
10
|
+
* base and its head) and maps every added, removed, or changed partial or
|
|
11
|
+
* example to the published page or pages that include it. Reviewing this
|
|
12
|
+
* output instead of the Go source catches every published change: connect
|
|
13
|
+
* strings, benthos strings that arrive through a go.mod bump, and generator
|
|
14
|
+
* changes alike.
|
|
15
|
+
*
|
|
16
|
+
* A tree root is any of: a directory that contains modules/components/
|
|
17
|
+
* (connect's docs/ directory), the modules/ directory itself (the root of the
|
|
18
|
+
* redpanda-connect-docs.tar.gz asset and of the docs artifact connect
|
|
19
|
+
* uploads), or the components/ directory.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DEFAULTS = Object.freeze({
|
|
23
|
+
siteUrl: 'https://docs.redpanda.com',
|
|
24
|
+
// The connect component publishes at /connect/. /redpanda-connect/ is an
|
|
25
|
+
// older prefix that now redirects there.
|
|
26
|
+
componentPath: 'connect',
|
|
27
|
+
maxBytes: 60000,
|
|
28
|
+
maxFileDiffBytes: 8000,
|
|
29
|
+
contextLines: 3
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// The generator writes some families with a hyphenated type directory
|
|
33
|
+
// (fields/rate-limits, examples/common/rate-limits) and others with an
|
|
34
|
+
// underscore (descriptions/rate_limits). Pages use the underscore form.
|
|
35
|
+
const TYPE_DIRS = new Set(['inputs', 'outputs', 'processors', 'caches', 'buffers', 'metrics', 'tracers', 'scanners', 'rate_limits'])
|
|
36
|
+
|
|
37
|
+
// fields/config/<name> and the config examples are the reference sections of
|
|
38
|
+
// a few hand-written pages. An explicit map, checked against the include
|
|
39
|
+
// directives in rp-connect-docs; anything not listed is reported as having no
|
|
40
|
+
// single page rather than guessed.
|
|
41
|
+
const CONFIG_PAGES = Object.freeze({
|
|
42
|
+
http: 'components/http/about/',
|
|
43
|
+
logger: 'components/logger/about/',
|
|
44
|
+
redpanda: 'components/redpanda/about/',
|
|
45
|
+
templates: 'configuration/templating/'
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const NO_SINGLE_PAGE = 'no single page'
|
|
49
|
+
// A page row lists this many files, then a count.
|
|
50
|
+
const MAX_FILES_PER_ROW = 10
|
|
51
|
+
|
|
52
|
+
function resolveComponentsDir (root) {
|
|
53
|
+
const candidates = [
|
|
54
|
+
path.join(root, 'modules', 'components'),
|
|
55
|
+
path.join(root, 'docs', 'modules', 'components'),
|
|
56
|
+
path.join(root, 'components'),
|
|
57
|
+
root
|
|
58
|
+
]
|
|
59
|
+
for (const dir of candidates) {
|
|
60
|
+
if (fs.existsSync(path.join(dir, 'partials')) || fs.existsSync(path.join(dir, 'examples'))) return dir
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`${root} is not a generated docs tree: no modules/components, components, or partials/examples directory found`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Files under partials/ and examples/, relative to the components dir. */
|
|
66
|
+
function listTree (componentsDir) {
|
|
67
|
+
const out = new Map()
|
|
68
|
+
for (const family of ['partials', 'examples']) {
|
|
69
|
+
const base = path.join(componentsDir, family)
|
|
70
|
+
if (!fs.existsSync(base)) continue
|
|
71
|
+
const stack = [base]
|
|
72
|
+
while (stack.length) {
|
|
73
|
+
const current = stack.pop()
|
|
74
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
75
|
+
const full = path.join(current, entry.name)
|
|
76
|
+
if (entry.isDirectory()) stack.push(full)
|
|
77
|
+
else if (entry.isFile()) out.set(path.relative(componentsDir, full).split(path.sep).join('/'), full)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeType (type) {
|
|
85
|
+
return type.replace(/-/g, '_')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The published pages for one generated file, relative to the component
|
|
90
|
+
* root, or [] when the file feeds no single page.
|
|
91
|
+
* `rel` is relative to the components dir: partials/... or examples/...
|
|
92
|
+
*/
|
|
93
|
+
function pagesFor (rel) {
|
|
94
|
+
const parts = rel.replace(/\.(adoc|ya?ml|json)$/, '').split('/')
|
|
95
|
+
const [family, kind, ...rest] = parts
|
|
96
|
+
if (family === 'partials') {
|
|
97
|
+
if (kind === 'bloblang-functions' && rest.length === 1) return [{ page: 'guides/bloblang/functions/', anchor: rest[0] }]
|
|
98
|
+
if (kind === 'bloblang-methods' && rest.length === 1) return [{ page: 'guides/bloblang/methods/', anchor: rest[0] }]
|
|
99
|
+
if (kind === 'bloblang' && rest.length === 1 && (rest[0] === 'functions' || rest[0] === 'methods')) {
|
|
100
|
+
return [{ page: `guides/bloblang/${rest[0]}/` }]
|
|
101
|
+
}
|
|
102
|
+
if (['fields', 'descriptions', 'metadata', 'examples', 'availability'].includes(kind) && rest.length === 2) {
|
|
103
|
+
const [type, name] = rest
|
|
104
|
+
if (type === 'config') return CONFIG_PAGES[name] ? [{ page: CONFIG_PAGES[name] }] : []
|
|
105
|
+
const t = normalizeType(type)
|
|
106
|
+
if (TYPE_DIRS.has(t)) return [{ page: `components/${t}/${name}/` }]
|
|
107
|
+
}
|
|
108
|
+
return []
|
|
109
|
+
}
|
|
110
|
+
if (family === 'examples' && (kind === 'common' || kind === 'advanced') && rest.length === 2) {
|
|
111
|
+
const [type, name] = rest
|
|
112
|
+
if (type === 'config') return CONFIG_PAGES[name] ? [{ page: CONFIG_PAGES[name] }] : []
|
|
113
|
+
const t = normalizeType(type)
|
|
114
|
+
if (TYPE_DIRS.has(t)) return [{ page: `components/${t}/${name}/` }]
|
|
115
|
+
}
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Unified diff (Myers O(ND)), small and dependency-free.
|
|
121
|
+
|
|
122
|
+
function diffLines (a, b) {
|
|
123
|
+
const n = a.length
|
|
124
|
+
const m = b.length
|
|
125
|
+
const max = n + m
|
|
126
|
+
const offset = max
|
|
127
|
+
const v = new Array(2 * max + 2).fill(0)
|
|
128
|
+
const trace = []
|
|
129
|
+
outer:
|
|
130
|
+
for (let d = 0; d <= max; d++) {
|
|
131
|
+
trace.push(v.slice())
|
|
132
|
+
for (let k = -d; k <= d; k += 2) {
|
|
133
|
+
let x
|
|
134
|
+
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1]
|
|
135
|
+
else x = v[offset + k - 1] + 1
|
|
136
|
+
let y = x - k
|
|
137
|
+
while (x < n && y < m && a[x] === b[y]) { x++; y++ }
|
|
138
|
+
v[offset + k] = x
|
|
139
|
+
if (x >= n && y >= m) break outer
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Backtrack into an edit script of { op: ' ' | '-' | '+', line }.
|
|
143
|
+
const ops = []
|
|
144
|
+
let x = n
|
|
145
|
+
let y = m
|
|
146
|
+
for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) {
|
|
147
|
+
const vd = trace[d]
|
|
148
|
+
const k = x - y
|
|
149
|
+
let prevK
|
|
150
|
+
if (k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1])) prevK = k + 1
|
|
151
|
+
else prevK = k - 1
|
|
152
|
+
const prevX = vd[offset + prevK]
|
|
153
|
+
const prevY = prevX - prevK
|
|
154
|
+
while (x > prevX && y > prevY) { ops.push({ op: ' ', line: a[x - 1] }); x--; y-- }
|
|
155
|
+
if (d > 0) {
|
|
156
|
+
if (x === prevX) { ops.push({ op: '+', line: b[y - 1] }); y-- } else { ops.push({ op: '-', line: a[x - 1] }); x-- }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return ops.reverse()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function splitLines (text) {
|
|
163
|
+
if (text === '') return []
|
|
164
|
+
const lines = text.split('\n')
|
|
165
|
+
if (lines[lines.length - 1] === '') lines.pop()
|
|
166
|
+
return lines
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function unifiedDiff (oldText, newText, { oldName = 'a', newName = 'b', context = DEFAULTS.contextLines } = {}) {
|
|
170
|
+
const a = splitLines(oldText)
|
|
171
|
+
const b = splitLines(newText)
|
|
172
|
+
const ops = diffLines(a, b)
|
|
173
|
+
if (!ops.some((o) => o.op !== ' ')) return ''
|
|
174
|
+
// Group changes into hunks with `context` lines around them.
|
|
175
|
+
const hunks = []
|
|
176
|
+
let i = 0
|
|
177
|
+
let aLine = 0
|
|
178
|
+
let bLine = 0
|
|
179
|
+
const positions = ops.map((o) => {
|
|
180
|
+
const pos = { a: aLine, b: bLine }
|
|
181
|
+
if (o.op !== '+') aLine++
|
|
182
|
+
if (o.op !== '-') bLine++
|
|
183
|
+
return pos
|
|
184
|
+
})
|
|
185
|
+
while (i < ops.length) {
|
|
186
|
+
if (ops[i].op === ' ') { i++; continue }
|
|
187
|
+
let start = Math.max(0, i - context)
|
|
188
|
+
let end = i
|
|
189
|
+
// Extend while the next change is within 2*context of the current one.
|
|
190
|
+
while (end < ops.length) {
|
|
191
|
+
if (ops[end].op !== ' ') { end++; continue }
|
|
192
|
+
let j = end
|
|
193
|
+
while (j < ops.length && ops[j].op === ' ') j++
|
|
194
|
+
if (j < ops.length && j - end <= 2 * context) { end = j; continue }
|
|
195
|
+
end = Math.min(ops.length, end + context)
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
hunks.push({ start, end })
|
|
199
|
+
i = end
|
|
200
|
+
}
|
|
201
|
+
const out = [`--- ${oldName}`, `+++ ${newName}`]
|
|
202
|
+
for (const h of hunks) {
|
|
203
|
+
const slice = ops.slice(h.start, h.end)
|
|
204
|
+
const aStart = positions[h.start].a
|
|
205
|
+
const bStart = positions[h.start].b
|
|
206
|
+
const aCount = slice.filter((o) => o.op !== '+').length
|
|
207
|
+
const bCount = slice.filter((o) => o.op !== '-').length
|
|
208
|
+
out.push(`@@ -${aCount ? aStart + 1 : aStart},${aCount} +${bCount ? bStart + 1 : bStart},${bCount} @@`)
|
|
209
|
+
for (const o of slice) out.push(`${o.op}${o.line}`)
|
|
210
|
+
}
|
|
211
|
+
return out.join('\n') + '\n'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function diffGenerated ({ baseDir, headDir, siteUrl = DEFAULTS.siteUrl, componentPath = DEFAULTS.componentPath } = {}) {
|
|
217
|
+
const base = listTree(resolveComponentsDir(baseDir))
|
|
218
|
+
const head = listTree(resolveComponentsDir(headDir))
|
|
219
|
+
const all = [...new Set([...base.keys(), ...head.keys()])].sort()
|
|
220
|
+
const root = `${siteUrl.replace(/\/$/, '')}/${componentPath}/`
|
|
221
|
+
const files = []
|
|
222
|
+
for (const rel of all) {
|
|
223
|
+
const oldFile = base.get(rel)
|
|
224
|
+
const newFile = head.get(rel)
|
|
225
|
+
const oldText = oldFile ? fs.readFileSync(oldFile, 'utf8') : ''
|
|
226
|
+
const newText = newFile ? fs.readFileSync(newFile, 'utf8') : ''
|
|
227
|
+
let status
|
|
228
|
+
if (!oldFile) status = 'added'
|
|
229
|
+
else if (!newFile) status = 'removed'
|
|
230
|
+
else if (oldText !== newText) status = 'changed'
|
|
231
|
+
else continue
|
|
232
|
+
const pages = pagesFor(rel).map((p) => ({
|
|
233
|
+
url: `${root}${p.page}${p.anchor ? `#${p.anchor}` : ''}`,
|
|
234
|
+
page: `${root}${p.page}`,
|
|
235
|
+
// Where Antora writes the page, relative to the output directory.
|
|
236
|
+
sitePath: `${componentPath}/${p.page}index.html`
|
|
237
|
+
}))
|
|
238
|
+
files.push({ path: `modules/components/${rel}`, status, pages, oldText, newText })
|
|
239
|
+
}
|
|
240
|
+
// One row per page; files that feed no single page are listed on their own.
|
|
241
|
+
const byPage = new Map()
|
|
242
|
+
const unmapped = []
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
if (!f.pages.length) { unmapped.push(f.path); continue }
|
|
245
|
+
for (const p of f.pages) {
|
|
246
|
+
const entry = byPage.get(p.page) || { url: p.page, sitePath: p.sitePath, anchors: [], files: [] }
|
|
247
|
+
if (p.url !== p.page && !entry.anchors.includes(p.url)) entry.anchors.push(p.url)
|
|
248
|
+
entry.files.push({ path: f.path, status: f.status })
|
|
249
|
+
byPage.set(p.page, entry)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const pages = [...byPage.values()].sort((a, b) => a.url.localeCompare(b.url))
|
|
253
|
+
return {
|
|
254
|
+
summary: {
|
|
255
|
+
files: files.length,
|
|
256
|
+
added: files.filter((f) => f.status === 'added').length,
|
|
257
|
+
removed: files.filter((f) => f.status === 'removed').length,
|
|
258
|
+
changed: files.filter((f) => f.status === 'changed').length,
|
|
259
|
+
pages: pages.length,
|
|
260
|
+
unmapped: unmapped.length
|
|
261
|
+
},
|
|
262
|
+
pages,
|
|
263
|
+
unmapped,
|
|
264
|
+
files
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function fence (text) {
|
|
269
|
+
// A fence longer than any backtick run in the text, so a diff of AsciiDoc
|
|
270
|
+
// with its own fences cannot close the block early.
|
|
271
|
+
const longest = Math.max(2, ...(text.match(/`+/g) || []).map((s) => s.length))
|
|
272
|
+
return '`'.repeat(longest + 1)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function formatMarkdown (result, { maxBytes = DEFAULTS.maxBytes, maxFileDiffBytes = DEFAULTS.maxFileDiffBytes } = {}) {
|
|
276
|
+
const s = result.summary
|
|
277
|
+
const lines = []
|
|
278
|
+
lines.push('## Connect reference docs: published changes', '')
|
|
279
|
+
if (!s.files) {
|
|
280
|
+
lines.push('This PR does not change the generated reference docs.', '')
|
|
281
|
+
return lines.join('\n')
|
|
282
|
+
}
|
|
283
|
+
lines.push(`${s.files} generated ${s.files === 1 ? 'file' : 'files'} differ from the merge base (${s.changed} changed, ${s.added} added, ${s.removed} removed), on ${s.pages} published ${s.pages === 1 ? 'page' : 'pages'}.`, '')
|
|
284
|
+
if (result.pages.length) {
|
|
285
|
+
lines.push('| Page | Changed files |', '|---|---|')
|
|
286
|
+
for (const p of result.pages) {
|
|
287
|
+
const label = p.url.replace(/^https?:\/\/[^/]+/, '')
|
|
288
|
+
const shownFiles = p.files.slice(0, MAX_FILES_PER_ROW).map((f) => {
|
|
289
|
+
const name = f.path.replace(/^modules\/components\//, '')
|
|
290
|
+
// Per-entry partials (one Bloblang function or method) link to their
|
|
291
|
+
// section of the page.
|
|
292
|
+
const anchor = p.anchors.find((a) => a.endsWith(`#${path.basename(name).replace(/\.[^.]+$/, '')}`))
|
|
293
|
+
return `${anchor && f.status !== 'removed' ? `[${name}](${anchor})` : name} (${f.status})`
|
|
294
|
+
})
|
|
295
|
+
if (p.files.length > MAX_FILES_PER_ROW) shownFiles.push(`and ${p.files.length - MAX_FILES_PER_ROW} more`)
|
|
296
|
+
lines.push(`| [${label}](${p.url}) | ${shownFiles.join('<br>')} |`)
|
|
297
|
+
}
|
|
298
|
+
lines.push('')
|
|
299
|
+
}
|
|
300
|
+
if (result.unmapped.length) {
|
|
301
|
+
lines.push(`Files with ${NO_SINGLE_PAGE} (they feed several pages, or none directly):`, '')
|
|
302
|
+
for (const f of result.unmapped) lines.push(`- ${f}`)
|
|
303
|
+
lines.push('')
|
|
304
|
+
}
|
|
305
|
+
lines.push('### Diffs', '')
|
|
306
|
+
let used = Buffer.byteLength(lines.join('\n'))
|
|
307
|
+
let shown = 0
|
|
308
|
+
for (const f of result.files) {
|
|
309
|
+
let diff = unifiedDiff(f.oldText, f.newText, {
|
|
310
|
+
oldName: f.status === 'added' ? '/dev/null' : `a/${f.path}`,
|
|
311
|
+
newName: f.status === 'removed' ? '/dev/null' : `b/${f.path}`
|
|
312
|
+
})
|
|
313
|
+
let note = ''
|
|
314
|
+
if (Buffer.byteLength(diff) > maxFileDiffBytes) {
|
|
315
|
+
diff = diff.slice(0, maxFileDiffBytes)
|
|
316
|
+
diff = diff.slice(0, diff.lastIndexOf('\n') + 1)
|
|
317
|
+
note = `\n_Diff truncated at ${maxFileDiffBytes} bytes._\n`
|
|
318
|
+
}
|
|
319
|
+
const f2 = fence(diff)
|
|
320
|
+
const block = [
|
|
321
|
+
'<details>',
|
|
322
|
+
`<summary>${f.path} (${f.status})</summary>`,
|
|
323
|
+
'',
|
|
324
|
+
`${f2}diff`,
|
|
325
|
+
diff.replace(/\n$/, ''),
|
|
326
|
+
f2,
|
|
327
|
+
note,
|
|
328
|
+
'</details>',
|
|
329
|
+
''
|
|
330
|
+
].join('\n')
|
|
331
|
+
if (used + Buffer.byteLength(block) > maxBytes) break
|
|
332
|
+
lines.push(block)
|
|
333
|
+
used += Buffer.byteLength(block) + 1
|
|
334
|
+
shown++
|
|
335
|
+
}
|
|
336
|
+
if (shown < result.files.length) {
|
|
337
|
+
lines.push(`_${result.files.length - shown} more ${result.files.length - shown === 1 ? 'diff is' : 'diffs are'} not shown: the summary is capped at ${maxBytes} bytes._`, '')
|
|
338
|
+
}
|
|
339
|
+
return lines.join('\n')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function toJson (result) {
|
|
343
|
+
return {
|
|
344
|
+
summary: result.summary,
|
|
345
|
+
pages: result.pages,
|
|
346
|
+
unmapped: result.unmapped,
|
|
347
|
+
files: result.files.map((f) => ({ path: f.path, status: f.status, pages: f.pages.map((p) => p.url) }))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function runCli (baseDir, headDir, options = {}) {
|
|
352
|
+
let result
|
|
353
|
+
try {
|
|
354
|
+
for (const [label, dir] of [['base', baseDir], ['head', headDir]]) {
|
|
355
|
+
if (!dir || !fs.existsSync(dir)) throw new Error(`${label} directory not found: ${dir}`)
|
|
356
|
+
}
|
|
357
|
+
result = diffGenerated({ baseDir, headDir, siteUrl: options.siteUrl })
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error(`Error: ${err.message}`)
|
|
360
|
+
process.exit(2)
|
|
361
|
+
}
|
|
362
|
+
const maxBytes = options.maxBytes != null ? Number(options.maxBytes) : DEFAULTS.maxBytes
|
|
363
|
+
const out = options.format === 'json' ? JSON.stringify(toJson(result), null, 2) : formatMarkdown(result, { maxBytes })
|
|
364
|
+
console.log(out)
|
|
365
|
+
if (options.output) {
|
|
366
|
+
fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
|
367
|
+
fs.writeFileSync(path.resolve(options.output), out + '\n')
|
|
368
|
+
}
|
|
369
|
+
process.exit(0)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = {
|
|
373
|
+
DEFAULTS,
|
|
374
|
+
CONFIG_PAGES,
|
|
375
|
+
resolveComponentsDir,
|
|
376
|
+
pagesFor,
|
|
377
|
+
unifiedDiff,
|
|
378
|
+
diffGenerated,
|
|
379
|
+
formatMarkdown,
|
|
380
|
+
toJson,
|
|
381
|
+
runCli
|
|
382
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The connect-docs-check PR comment: one sticky comment per connect PR that
|
|
7
|
+
* tells the author and the docs team what the PR changes on the published
|
|
8
|
+
* Connect docs, whether the docs build passed, and where to review the
|
|
9
|
+
* rendered pages.
|
|
10
|
+
*
|
|
11
|
+
* Usage: node pr-comment.js --build <pass|fail> [--diff diff.json] [--html-findings <n>]
|
|
12
|
+
* [--preview <deploy url>] [--run-url <url>] [--sha <sha>]
|
|
13
|
+
* [--output comment.md]
|
|
14
|
+
*
|
|
15
|
+
* Prints JSON { body, notify }. `notify` is true when the PR changes a
|
|
16
|
+
* published page, breaks the build, or has rendered-HTML findings on a page
|
|
17
|
+
* it changes: the cases where the docs team should
|
|
18
|
+
* look. The workflow posts the comment only when notify is true or a
|
|
19
|
+
* comment already exists, so PRs that touch no docs get no comment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MARKER = '<!-- connect-docs-check -->'
|
|
23
|
+
const MAX_ROWS = 30
|
|
24
|
+
const PROD = 'https://docs.redpanda.com'
|
|
25
|
+
|
|
26
|
+
function pagePath (sitePath) {
|
|
27
|
+
return '/' + String(sitePath).replace(/index\.html$/, '')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildComment ({ build, diff, preview, runUrl, sha, htmlFindings = 0 }) {
|
|
31
|
+
const pages = (diff && diff.pages) || []
|
|
32
|
+
const summary = (diff && diff.summary) || null
|
|
33
|
+
const failed = build !== 'pass'
|
|
34
|
+
const lines = [MARKER, '### Connect docs check', '']
|
|
35
|
+
|
|
36
|
+
lines.push(failed
|
|
37
|
+
? '**The docs build failed.** This PR breaks the Connect docs build: see the job summary for the errors.'
|
|
38
|
+
: 'The docs build passed with this PR\'s generated docs.')
|
|
39
|
+
lines.push('')
|
|
40
|
+
if (htmlFindings > 0) {
|
|
41
|
+
lines.push(`**${htmlFindings} rendered-HTML ${htmlFindings === 1 ? 'finding' : 'findings'} on pages this PR changes**, such as broken anchors or unconverted AsciiDoc. They are listed in the job summary.`, '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (preview) {
|
|
45
|
+
lines.push(`Preview: ${preview.replace(/\/$/, '')}/connect/home/`, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!diff) {
|
|
49
|
+
lines.push('The merge base was not generated, so the changed pages are not listed.', '')
|
|
50
|
+
} else if (!pages.length && !(summary && summary.files)) {
|
|
51
|
+
lines.push('This PR doesn\'t change any published Connect docs page.', '')
|
|
52
|
+
} else {
|
|
53
|
+
const s = summary || {}
|
|
54
|
+
lines.push(`This PR changes ${pages.length} published ${pages.length === 1 ? 'page' : 'pages'} (${s.files || 0} generated ${s.files === 1 ? 'file' : 'files'}: ${s.changed || 0} changed, ${s.added || 0} added, ${s.removed || 0} removed). Review the rendered pages, not only the Go strings.`, '')
|
|
55
|
+
if (pages.length) {
|
|
56
|
+
lines.push(preview ? '| Page | Preview |' : '| Page |', preview ? '|---|---|' : '|---|')
|
|
57
|
+
for (const p of pages.slice(0, MAX_ROWS)) {
|
|
58
|
+
const pth = pagePath(p.sitePath)
|
|
59
|
+
const live = `[${pth}](${PROD}${pth})`
|
|
60
|
+
lines.push(preview ? `| ${live} | [preview](${preview.replace(/\/$/, '')}${pth}) |` : `| ${live} |`)
|
|
61
|
+
}
|
|
62
|
+
if (pages.length > MAX_ROWS) lines.push('', `And ${pages.length - MAX_ROWS} more pages.`)
|
|
63
|
+
lines.push('')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tail = []
|
|
68
|
+
if (runUrl) tail.push(`Per-file diffs and the rendered-HTML findings are in the [job summary](${runUrl}).`)
|
|
69
|
+
if (sha) tail.push(`Checked at ${String(sha).slice(0, 9)}.`)
|
|
70
|
+
if (tail.length) lines.push(tail.join(' '))
|
|
71
|
+
|
|
72
|
+
return { body: lines.join('\n').trimEnd() + '\n', notify: failed || htmlFindings > 0 || pages.length > 0 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseArgs (argv) {
|
|
76
|
+
const opts = {}
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const m = /^--(build|diff|html-findings|preview|run-url|sha|output)$/.exec(argv[i])
|
|
79
|
+
if (!m) throw new Error(`unknown argument: ${argv[i]}`)
|
|
80
|
+
opts[m[1]] = argv[++i]
|
|
81
|
+
}
|
|
82
|
+
if (opts.build !== 'pass' && opts.build !== 'fail') throw new Error('--build must be pass or fail')
|
|
83
|
+
return opts
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runCli (argv) {
|
|
87
|
+
const opts = parseArgs(argv)
|
|
88
|
+
const diff = opts.diff && fs.existsSync(opts.diff) ? JSON.parse(fs.readFileSync(opts.diff, 'utf8')) : null
|
|
89
|
+
const result = buildComment({ build: opts.build, diff, htmlFindings: Number(opts['html-findings'] || 0), preview: opts.preview || '', runUrl: opts['run-url'], sha: opts.sha })
|
|
90
|
+
if (opts.output) fs.writeFileSync(opts.output, result.body)
|
|
91
|
+
process.stdout.write(JSON.stringify({ notify: result.notify }) + '\n')
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (require.main === module) {
|
|
96
|
+
try {
|
|
97
|
+
runCli(process.argv.slice(2))
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(`Error: ${err.message}`)
|
|
100
|
+
process.exit(2)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { MARKER, MAX_ROWS, buildComment, runCli }
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turn a Claude Code transcript into a doc-impact MCP recording.
|
|
5
|
+
*
|
|
6
|
+
* The doc-strings review asks the docs MCP server (docs.redpanda.com/mcp)
|
|
7
|
+
* whether published pages already cover a changed string. The doc-impact
|
|
8
|
+
* eval replays those answers per item, so an eval run sees the docs as they
|
|
9
|
+
* were when the review ran, not as they are today. A recording made later
|
|
10
|
+
* would already contain the writer's fix for the PR, and the pass would
|
|
11
|
+
* then correctly say "already documented" for a PR that needed docs.
|
|
12
|
+
*
|
|
13
|
+
* This reads the transcript the review step leaves behind and keeps only
|
|
14
|
+
* what a replay needs: the tool name, its arguments, and the result content.
|
|
15
|
+
* Nothing else from the transcript is copied, so the session's tokens,
|
|
16
|
+
* headers, environment and identity never reach the recording.
|
|
17
|
+
*
|
|
18
|
+
* The arguments are the model's own text, though, and the review reads
|
|
19
|
+
* untrusted PR content. A call whose arguments or result contain something
|
|
20
|
+
* shaped like a credential is dropped rather than saved, because the
|
|
21
|
+
* recording is uploaded as an artifact and log masking doesn't cover
|
|
22
|
+
* artifacts.
|
|
23
|
+
*
|
|
24
|
+
* Accepted input:
|
|
25
|
+
* - the execution file anthropics/claude-code-action writes: one JSON
|
|
26
|
+
* array of SDK messages;
|
|
27
|
+
* - `claude -p --output-format stream-json` output: one message per line.
|
|
28
|
+
* Both carry the same message shapes, so one parser serves both.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs')
|
|
32
|
+
const path = require('path')
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SERVER = 'redpanda-docs'
|
|
35
|
+
|
|
36
|
+
// Credential shapes a recording must never carry: GitHub tokens (classic and
|
|
37
|
+
// fine-grained), JWTs (which include GitHub App and OIDC tokens), and AWS
|
|
38
|
+
// access key IDs.
|
|
39
|
+
const SECRET_PATTERNS = [
|
|
40
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/,
|
|
41
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
42
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/,
|
|
43
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/** Whether a value, serialized, contains anything shaped like a credential. */
|
|
47
|
+
function looksSecret (value) {
|
|
48
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value)
|
|
49
|
+
return SECRET_PATTERNS.some((re) => re.test(text || ''))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Messages from a transcript, in order. Lines that are not JSON are skipped. */
|
|
53
|
+
function parseTranscript (text) {
|
|
54
|
+
const raw = String(text || '')
|
|
55
|
+
const trimmed = raw.trim()
|
|
56
|
+
if (trimmed.startsWith('[')) {
|
|
57
|
+
try {
|
|
58
|
+
const all = JSON.parse(trimmed)
|
|
59
|
+
if (Array.isArray(all)) return all.filter((m) => m && typeof m === 'object')
|
|
60
|
+
} catch {
|
|
61
|
+
// Not one JSON document; read it line by line below.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const out = []
|
|
65
|
+
for (const line of raw.split('\n')) {
|
|
66
|
+
if (!line.trim()) continue
|
|
67
|
+
try {
|
|
68
|
+
const m = JSON.parse(line)
|
|
69
|
+
if (m && typeof m === 'object' && !Array.isArray(m)) out.push(m)
|
|
70
|
+
} catch {
|
|
71
|
+
// Progress lines and other non-JSON output are not messages.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Tool-result content as an array of MCP content blocks. */
|
|
78
|
+
function contentBlocks (content) {
|
|
79
|
+
if (content == null) return []
|
|
80
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
|
81
|
+
if (Array.isArray(content)) return content.map((b) => (typeof b === 'string' ? { type: 'text', text: b } : b))
|
|
82
|
+
return [{ type: 'text', text: JSON.stringify(content) }]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function blocksOf (message) {
|
|
86
|
+
const content = message && message.message && message.message.content
|
|
87
|
+
return Array.isArray(content) ? content : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every call to one MCP server's tools, paired with its result, in call
|
|
92
|
+
* order. A call with no result (the run stopped first) is left out: a
|
|
93
|
+
* replay of it would answer with nothing the server ever said.
|
|
94
|
+
*/
|
|
95
|
+
function extractCalls (messages, { server = DEFAULT_SERVER } = {}) {
|
|
96
|
+
const prefix = `mcp__${server}__`
|
|
97
|
+
const calls = []
|
|
98
|
+
const byId = new Map()
|
|
99
|
+
for (const m of messages || []) {
|
|
100
|
+
if (m.type === 'assistant') {
|
|
101
|
+
for (const b of blocksOf(m)) {
|
|
102
|
+
if (b && b.type === 'tool_use' && typeof b.name === 'string' && b.name.startsWith(prefix)) {
|
|
103
|
+
const call = { tool: b.name.slice(prefix.length), arguments: b.input || {}, content: null, is_error: false }
|
|
104
|
+
calls.push(call)
|
|
105
|
+
byId.set(b.id, call)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else if (m.type === 'user') {
|
|
109
|
+
for (const b of blocksOf(m)) {
|
|
110
|
+
if (b && b.type === 'tool_result' && byId.has(b.tool_use_id)) {
|
|
111
|
+
const call = byId.get(b.tool_use_id)
|
|
112
|
+
call.content = contentBlocks(b.content)
|
|
113
|
+
call.is_error = Boolean(b.is_error)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return calls.filter((c) => c.content !== null)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Split calls into the ones safe to save and the count dropped as secret-bearing. */
|
|
122
|
+
function dropSecretCalls (calls) {
|
|
123
|
+
const kept = calls.filter((c) => !looksSecret(c.arguments) && !looksSecret(c.content))
|
|
124
|
+
return { kept, dropped: calls.length - kept.length }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The model the session ran on, from its init message. */
|
|
128
|
+
function sessionModel (messages) {
|
|
129
|
+
const init = (messages || []).find((m) => m.type === 'system' && m.subtype === 'init')
|
|
130
|
+
return (init && typeof init.model === 'string' && init.model) || null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A recording in the shape the doc-impact eval replays:
|
|
135
|
+
* { item, recorded_at, server_url, model, source, calls: [{ tool, arguments, content, is_error }] }
|
|
136
|
+
*/
|
|
137
|
+
function buildRecording (messages, { item = null, recordedAt, serverUrl = null, model = null, server = DEFAULT_SERVER } = {}) {
|
|
138
|
+
const { kept, dropped } = dropSecretCalls(extractCalls(messages, { server }))
|
|
139
|
+
const recording = {
|
|
140
|
+
item,
|
|
141
|
+
recorded_at: recordedAt || new Date().toISOString(),
|
|
142
|
+
server_url: serverUrl,
|
|
143
|
+
model: model || sessionModel(messages),
|
|
144
|
+
source: 'production',
|
|
145
|
+
calls: kept
|
|
146
|
+
}
|
|
147
|
+
// Not enumerable, so it never reaches the written file.
|
|
148
|
+
Object.defineProperty(recording, 'droppedSecretCalls', { value: dropped })
|
|
149
|
+
return recording
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* CLI entry: read --execution-file, write --output. Writes nothing when the
|
|
154
|
+
* session made no docs calls it can save, and removes any file an earlier
|
|
155
|
+
* run left at --output, so the caller can tell "no calls" from "a
|
|
156
|
+
* recording" by the file's presence. Exits 0 either way; a missing or
|
|
157
|
+
* unreadable transcript is exit 1.
|
|
158
|
+
*/
|
|
159
|
+
function runCli (options, { log = console.log, error = console.error } = {}) {
|
|
160
|
+
const file = options.executionFile
|
|
161
|
+
let text
|
|
162
|
+
try {
|
|
163
|
+
text = fs.readFileSync(file, 'utf8')
|
|
164
|
+
} catch (e) {
|
|
165
|
+
error(`Cannot read the transcript at ${file}: ${e.message}`)
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
const messages = parseTranscript(text)
|
|
169
|
+
const recording = buildRecording(messages, {
|
|
170
|
+
item: options.item || null,
|
|
171
|
+
recordedAt: options.recordedAt,
|
|
172
|
+
serverUrl: options.serverUrl || null,
|
|
173
|
+
model: options.model || null,
|
|
174
|
+
server: options.server || DEFAULT_SERVER
|
|
175
|
+
})
|
|
176
|
+
if (recording.droppedSecretCalls) {
|
|
177
|
+
error(`Dropped ${recording.droppedSecretCalls} MCP call(s) whose arguments or result look like a credential.`)
|
|
178
|
+
}
|
|
179
|
+
if (!recording.calls.length) {
|
|
180
|
+
fs.rmSync(options.output, { force: true })
|
|
181
|
+
log(`No ${options.server || DEFAULT_SERVER} MCP calls to save in ${messages.length} transcript message(s); no recording written.`)
|
|
182
|
+
return 0
|
|
183
|
+
}
|
|
184
|
+
fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
|
185
|
+
fs.writeFileSync(options.output, JSON.stringify(recording, null, 2) + '\n')
|
|
186
|
+
log(`Wrote ${recording.calls.length} ${options.server || DEFAULT_SERVER} MCP call(s) to ${options.output}.`)
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { parseTranscript, extractCalls, sessionModel, buildRecording, dropSecretCalls, looksSecret, runCli, DEFAULT_SERVER }
|