@redpanda-data/docs-extensions-and-macros 5.53.0 → 5.54.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 +127 -0
- package/extensions/REFERENCE.adoc +45 -9
- package/extensions/generate-rp-connect-info.js +79 -24
- package/extensions/modify-connect-tag-playbook.js +319 -74
- package/extensions/url-to-xref.js +3 -0
- package/extensions/util/connect-catalog.js +10 -5
- package/extensions/util/connect-docs-asset.js +187 -0
- package/extensions/util/connect-github.js +16 -0
- package/extensions/version-fetcher/list-connect-releases.js +10 -0
- package/package.json +1 -1
- 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
|
@@ -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 }
|