@redpanda-data/docs-extensions-and-macros 5.35.0 → 5.37.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 +268 -6
- package/cli-utils/enterprise-sources.js +3 -3
- package/cli-utils/github-token.js +53 -13
- package/cli-utils/kapa-credentials.js +109 -0
- package/cli-utils/octokit-client.js +2 -2
- package/cli-utils/self-managed-docs-branch.js +2 -2
- package/docs-data/kapa-source-groups.json +119 -0
- package/docs-data/kapa-source-groups.schema.json +73 -0
- package/extensions/kapa-source-groups.js +180 -0
- package/extensions/version-fetcher/set-latest-version.js +6 -3
- package/package.json +7 -3
- package/tools/generate-cli-docs.js +19 -4
- package/tools/get-redpanda-version.js +2 -2
- package/tools/kapa-source-groups/fetch-with-deadline.js +33 -0
- package/tools/kapa-source-groups/generate-kapa-source-groups.js +319 -0
- package/tools/kapa-source-groups/kapa-source-versions.js +83 -0
- package/tools/kapa-source-groups/published-segments.js +98 -0
- package/tools/property-extractor/rp-util-fetch.js +2 -2
- package/tools/rpk-docs/rpk-docs-handler.js +2 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Standalone module to read Kapa source groups and emit the mapping that tells
|
|
2
|
+
// each docs surface which group to scope a query to.
|
|
3
|
+
// Usage: generateKapaSourceGroups({ apiKey, projectId, defaultSegment })
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS
|
|
6
|
+
// ---------------
|
|
7
|
+
// Kapa indexes several separately crawled copies of the Self-Managed docs (one
|
|
8
|
+
// per version). Without scoping, a reader on a 25.1 page gets answers drawn from
|
|
9
|
+
// any of them: one measured call returned the same architecture page six times at
|
|
10
|
+
// six different versions. Source groups fix that at retrieval time, but the group
|
|
11
|
+
// UUIDs live in the Kapa dashboard while the version list lives in Antora, so
|
|
12
|
+
// something has to join the two. This generator is that join.
|
|
13
|
+
//
|
|
14
|
+
// The Kapa side is read-only by design: Kapa publishes no endpoint to create a
|
|
15
|
+
// group or assign a source to one ("we do not provide an API endpoint for
|
|
16
|
+
// uploading or managing sources"). So this reads the state a human created in the
|
|
17
|
+
// dashboard and turns it into a committed artifact the build and the browser can
|
|
18
|
+
// both consume. It never mutates Kapa.
|
|
19
|
+
//
|
|
20
|
+
// RESPONSE SHAPES (observed live 2026-09-04, project 97f44223)
|
|
21
|
+
// -----------------------------------------------------------
|
|
22
|
+
// GET /ingestion/v1/projects/:id/source-groups/
|
|
23
|
+
// { count, next, previous, results: [
|
|
24
|
+
// { id, name, type: 'product'|'version', description, sub_groups: [...], created_at, updated_at }
|
|
25
|
+
// ]}
|
|
26
|
+
//
|
|
27
|
+
// GET /ingestion/v1/projects/:id/sources/
|
|
28
|
+
// { count, next, previous, results: [
|
|
29
|
+
// { id, name, type: 'scrape'|'openapi'|'custom_qa'|'youtube'|..., source_groups: [...], ... }
|
|
30
|
+
// ]}
|
|
31
|
+
//
|
|
32
|
+
// Kapa's published OpenAPI schema declares BOTH `sub_groups` and `source_groups`
|
|
33
|
+
// as `"type":"string"`. That is a spec bug: live responses return arrays. The
|
|
34
|
+
// parsers below accept an array, a single object, a comma-separated string, or
|
|
35
|
+
// null, so a schema fix on Kapa's side cannot break us either way.
|
|
36
|
+
//
|
|
37
|
+
// "GLOBAL" IS NOT A GROUP
|
|
38
|
+
// ----------------------
|
|
39
|
+
// The dashboard shows a "Global sources" row, and Kapa's own docs call it "a
|
|
40
|
+
// special Global group that exists by default" with an ID. Both are misleading.
|
|
41
|
+
// The API returns no such group, and an unassigned source reports
|
|
42
|
+
// `source_groups: []`. Global is simply "assigned to no group". Assigning a
|
|
43
|
+
// source to a version group is what takes it out of the global set; unassigned
|
|
44
|
+
// sources stay global and are returned alongside whichever group a query scopes
|
|
45
|
+
// to. That is what keeps Agentic Data Plane content reachable from every page.
|
|
46
|
+
|
|
47
|
+
const { KAPA_API_BASE, kapaAuthHeaders } = require('../../cli-utils/kapa-credentials')
|
|
48
|
+
const { fetchWithDeadline } = require('./fetch-with-deadline')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Raised when Kapa's group tree itself is unusable: no groups, no version
|
|
52
|
+
* sub-groups, a duplicated group name, a group with no sources, or the default
|
|
53
|
+
* segment's group missing.
|
|
54
|
+
*
|
|
55
|
+
* `generate` treats all of these as fatal and names the reason, because every
|
|
56
|
+
* one of them describes a change someone made in the dashboard that the mapping
|
|
57
|
+
* cannot represent. The scheduled `validate` command does not call `generate`;
|
|
58
|
+
* it reads the sources endpoint directly and reports its own four gaps.
|
|
59
|
+
*/
|
|
60
|
+
class KapaGroupTreeError extends Error {
|
|
61
|
+
constructor (message) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'KapaGroupTreeError'
|
|
64
|
+
this.code = 'KAPA_GROUP_TREE'
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const REQUEST_TIMEOUT_MS = 15000
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalise the fields Kapa mis-declares as strings. Accepts an array, a single
|
|
72
|
+
* object, a comma-separated string of ids, or null/undefined.
|
|
73
|
+
*
|
|
74
|
+
* @param {*} value
|
|
75
|
+
* @returns {Array<object|string>}
|
|
76
|
+
*/
|
|
77
|
+
function toList (value) {
|
|
78
|
+
if (Array.isArray(value)) return value
|
|
79
|
+
if (value === null || value === undefined || value === '') return []
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'object') return [value]
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Pull an id out of either an object or a bare id string. */
|
|
88
|
+
function idOf (entry) {
|
|
89
|
+
if (typeof entry === 'string') return entry
|
|
90
|
+
return entry && typeof entry === 'object' ? entry.id || null : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetch every page of a Kapa list endpoint.
|
|
95
|
+
*
|
|
96
|
+
* Both ingestion endpoints return count/next/previous but declare NO pagination
|
|
97
|
+
* query parameters, so the only supported way through is to follow `next`. At
|
|
98
|
+
* 22 sources this project fits in one page today; follow the cursor anyway so it
|
|
99
|
+
* does not quietly truncate later.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} url - Absolute first-page URL
|
|
102
|
+
* @param {string} apiKey
|
|
103
|
+
* @param {Function} [fetchImpl] - Injectable for tests
|
|
104
|
+
* @returns {Promise<object[]>} Concatenated results
|
|
105
|
+
*/
|
|
106
|
+
async function fetchAllPages (url, apiKey, fetchImpl = globalThis.fetch) {
|
|
107
|
+
const out = []
|
|
108
|
+
let next = url
|
|
109
|
+
let guard = 0
|
|
110
|
+
|
|
111
|
+
while (next) {
|
|
112
|
+
if (++guard > 50) throw new Error(`Kapa pagination did not terminate after ${guard - 1} pages: ${url}`)
|
|
113
|
+
|
|
114
|
+
// The deadline covers the body read too; see fetch-with-deadline.js for
|
|
115
|
+
// why clearing the timer after headers alone is not a timeout.
|
|
116
|
+
const { res, body } = await fetchWithDeadline(
|
|
117
|
+
fetchImpl, next, { headers: kapaAuthHeaders(apiKey) }, REQUEST_TIMEOUT_MS,
|
|
118
|
+
(r) => (r.ok ? r.json() : null)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
// 401/403 is the overwhelmingly likely failure and deserves its own hint:
|
|
123
|
+
// a wrong-project key returns 403, not an empty list.
|
|
124
|
+
const hint = res.status === 401 || res.status === 403
|
|
125
|
+
? ' Check that KAPA_API_KEY is valid and belongs to the project in KAPA_PROJECT_ID.'
|
|
126
|
+
: ''
|
|
127
|
+
throw new Error(`Kapa API request failed: ${res.status} ${res.statusText} for ${next}.${hint}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!Array.isArray(body?.results)) {
|
|
131
|
+
throw new Error(`Kapa API returned an unexpected shape for ${next}: no results array.`)
|
|
132
|
+
}
|
|
133
|
+
out.push(...body.results)
|
|
134
|
+
next = body.next || null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Flatten the group tree into parent + children.
|
|
142
|
+
*
|
|
143
|
+
* Kapa's hierarchy is two levels and is expressed top-down: a group carries
|
|
144
|
+
* `sub_groups`, and there is no parent_id/parent/level field anywhere in the
|
|
145
|
+
* schema. So children are only discoverable by descending from parents.
|
|
146
|
+
*
|
|
147
|
+
* @param {object[]} groups - `results` from the source-groups endpoint
|
|
148
|
+
* @returns {{parents: object[], childrenByParentId: Map<string, object[]>}}
|
|
149
|
+
*/
|
|
150
|
+
function flattenGroups (groups) {
|
|
151
|
+
const parents = []
|
|
152
|
+
const childrenByParentId = new Map()
|
|
153
|
+
|
|
154
|
+
for (const group of groups) {
|
|
155
|
+
const subs = toList(group.sub_groups).filter((s) => typeof s === 'object')
|
|
156
|
+
parents.push(group)
|
|
157
|
+
childrenByParentId.set(group.id, subs)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { parents, childrenByParentId }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the segment -> group mapping.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} options
|
|
167
|
+
* @param {string} options.apiKey
|
|
168
|
+
* @param {string} options.projectId
|
|
169
|
+
* @param {string} [options.defaultSegment='current'] - Segment used for pages with no version
|
|
170
|
+
* @param {string} [options.parentGroupName] - Restrict to one parent by name (default: the only parent that has children)
|
|
171
|
+
* @param {Function} [options.fetchImpl]
|
|
172
|
+
* @returns {Promise<string>} Pretty-printed JSON, newline-terminated
|
|
173
|
+
*/
|
|
174
|
+
async function generateKapaSourceGroups ({
|
|
175
|
+
apiKey,
|
|
176
|
+
projectId,
|
|
177
|
+
defaultSegment = 'current',
|
|
178
|
+
parentGroupName,
|
|
179
|
+
fetchImpl,
|
|
180
|
+
} = {}) {
|
|
181
|
+
if (!apiKey) throw new Error('generateKapaSourceGroups requires an apiKey')
|
|
182
|
+
if (!projectId) throw new Error('generateKapaSourceGroups requires a projectId')
|
|
183
|
+
|
|
184
|
+
const base = `${KAPA_API_BASE}/ingestion/v1/projects/${encodeURIComponent(projectId)}`
|
|
185
|
+
const [groups, sources] = await Promise.all([
|
|
186
|
+
fetchAllPages(`${base}/source-groups/`, apiKey, fetchImpl),
|
|
187
|
+
fetchAllPages(`${base}/sources/`, apiKey, fetchImpl),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
if (groups.length === 0) {
|
|
191
|
+
// An empty list looks identical to "the dashboard work has not been done
|
|
192
|
+
// yet". Refuse rather than writing an empty mapping that would make every
|
|
193
|
+
// consumer silently fall back to unscoped retrieval.
|
|
194
|
+
throw new KapaGroupTreeError(
|
|
195
|
+
'Kapa returned no source groups. Create the parent group and its version sub groups in the ' +
|
|
196
|
+
'Kapa dashboard (Sources > Manage groups) before running this generator.'
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { parents, childrenByParentId } = flattenGroups(groups)
|
|
201
|
+
|
|
202
|
+
const candidates = parentGroupName
|
|
203
|
+
? parents.filter((p) => p.name === parentGroupName)
|
|
204
|
+
: parents.filter((p) => (childrenByParentId.get(p.id) || []).length > 0)
|
|
205
|
+
|
|
206
|
+
if (candidates.length === 0) {
|
|
207
|
+
const names = parents.map((p) => `"${p.name}"`).join(', ')
|
|
208
|
+
throw new KapaGroupTreeError(
|
|
209
|
+
parentGroupName
|
|
210
|
+
? `No source group named "${parentGroupName}". Groups present: ${names}.`
|
|
211
|
+
: 'No source group has any sub groups, so there are no version groups to map. ' +
|
|
212
|
+
`Add version sub groups under a parent in the Kapa dashboard. Groups present: ${names}.`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (candidates.length > 1) {
|
|
216
|
+
const names = candidates.map((p) => `"${p.name}"`).join(', ')
|
|
217
|
+
throw new KapaGroupTreeError(
|
|
218
|
+
`Ambiguous parent group: ${names} all have sub groups. Pass --parent-group to choose one.`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const parent = candidates[0]
|
|
223
|
+
const children = childrenByParentId.get(parent.id) || []
|
|
224
|
+
|
|
225
|
+
// Which sources are assigned to each group. A version group with no sources
|
|
226
|
+
// is worse than no group at all: scoping to it returns only the global set, so
|
|
227
|
+
// the reader silently gets no version-specific content. Surface it.
|
|
228
|
+
const sourcesByGroupId = new Map()
|
|
229
|
+
for (const source of sources) {
|
|
230
|
+
for (const entry of toList(source.source_groups)) {
|
|
231
|
+
const gid = idOf(entry)
|
|
232
|
+
if (!gid) continue
|
|
233
|
+
if (!sourcesByGroupId.has(gid)) sourcesByGroupId.set(gid, [])
|
|
234
|
+
sourcesByGroupId.get(gid).push({ id: source.id, name: source.name })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const segments = {}
|
|
239
|
+
const empty = []
|
|
240
|
+
for (const child of children) {
|
|
241
|
+
const assigned = (sourcesByGroupId.get(child.id) || [])
|
|
242
|
+
.slice()
|
|
243
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
244
|
+
if (assigned.length === 0) empty.push(child.name)
|
|
245
|
+
if (Object.prototype.hasOwnProperty.call(segments, child.name)) {
|
|
246
|
+
// Silently overwriting makes half that version's sources unreachable at
|
|
247
|
+
// retrieval, and which group_id survives depends on the order Kapa
|
|
248
|
+
// returns sub_groups in, which it does not guarantee. So the committed
|
|
249
|
+
// file flaps between runs and the drift issue reads only "group id
|
|
250
|
+
// changed", with no hint that two groups share a name. Duplicating a name
|
|
251
|
+
// is an easy dashboard slip: re-creating a group instead of editing it.
|
|
252
|
+
throw new KapaGroupTreeError(
|
|
253
|
+
`Two sub groups under "${parent.name}" are both named "${child.name}" ` +
|
|
254
|
+
`(${segments[child.name].group_id} and ${child.id}). Rename or delete one in the Kapa ` +
|
|
255
|
+
'dashboard: a segment can map to only one group, and which one won here would depend on ' +
|
|
256
|
+
'the order Kapa happens to return them in.'
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
segments[child.name] = {
|
|
260
|
+
group_id: child.id,
|
|
261
|
+
group_name: child.name,
|
|
262
|
+
// Sorted so a reordering upstream cannot produce a docs diff.
|
|
263
|
+
source_ids: assigned.map((s) => s.id).sort(),
|
|
264
|
+
source_names: assigned.map((s) => s.name),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!segments[defaultSegment]) {
|
|
269
|
+
throw new KapaGroupTreeError(
|
|
270
|
+
`Default segment "${defaultSegment}" is not one of the version groups ` +
|
|
271
|
+
`(${Object.keys(segments).sort().join(', ') || 'none'}). Pages with no version would have ` +
|
|
272
|
+
'nothing to scope to, so this must be fixed before the mapping is usable.'
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (empty.length) {
|
|
277
|
+
throw new KapaGroupTreeError(
|
|
278
|
+
`These version groups have no sources assigned: ${empty.sort().join(', ')}. ` +
|
|
279
|
+
'Scoping a query to an empty group returns only global sources, so the reader would get no ' +
|
|
280
|
+
'version-specific content at all. Assign each Documentation (X) source to its group in the ' +
|
|
281
|
+
'Kapa dashboard (Sources > Configure), then re-run.'
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Sources deliberately left unassigned. These are the global set: always
|
|
286
|
+
// returned alongside whichever group a query scopes to. Recorded because the
|
|
287
|
+
// whole design depends on it (Agentic Data Plane must stay reachable from
|
|
288
|
+
// every page), so a future reader can see it was a choice, not an oversight.
|
|
289
|
+
const globalSources = sources
|
|
290
|
+
.filter((s) => toList(s.source_groups).length === 0)
|
|
291
|
+
.map((s) => s.name)
|
|
292
|
+
.sort((a, b) => a.localeCompare(b))
|
|
293
|
+
|
|
294
|
+
// Keys sorted throughout: this file is diff-checked in CI, so a stable order
|
|
295
|
+
// is what makes "no changes" mean "nothing changed upstream". Note there is
|
|
296
|
+
// deliberately no generated-at timestamp -- that is exactly what stops
|
|
297
|
+
// cloud-regions from being diff-checkable.
|
|
298
|
+
const mapping = {
|
|
299
|
+
project_id: projectId,
|
|
300
|
+
parent_group: { id: parent.id, name: parent.name, type: parent.type || null },
|
|
301
|
+
default_segment: defaultSegment,
|
|
302
|
+
segments: Object.fromEntries(
|
|
303
|
+
Object.keys(segments).sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).map((k) => [k, segments[k]])
|
|
304
|
+
),
|
|
305
|
+
global_sources: globalSources,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `${JSON.stringify(mapping, null, 2)}\n`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
KapaGroupTreeError,
|
|
313
|
+
generateKapaSourceGroups,
|
|
314
|
+
// Exported for unit tests.
|
|
315
|
+
toList,
|
|
316
|
+
idOf,
|
|
317
|
+
flattenGroups,
|
|
318
|
+
fetchAllPages,
|
|
319
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which streaming docs versions Kapa has a source for.
|
|
3
|
+
*
|
|
4
|
+
* Kapa names one crawl per published version, `Documentation (24.2)`,
|
|
5
|
+
* `Documentation (current)`, and so on. The sources endpoint exposes no URL,
|
|
6
|
+
* only the name, so the version is read from the name. That convention is the
|
|
7
|
+
* contract this check depends on: a crawl named anything else is invisible to it
|
|
8
|
+
* and reported as missing, which is the right direction to be wrong in.
|
|
9
|
+
*
|
|
10
|
+
* A source that exists but sits in no group is reported separately. Unassigned
|
|
11
|
+
* sources are GLOBAL in Kapa, returned for every query on every version, so a
|
|
12
|
+
* `Documentation (26.2)` left unassigned would leak 26.2 content into 24.2
|
|
13
|
+
* readers' answers with no error anywhere.
|
|
14
|
+
*
|
|
15
|
+
* The group ids each version's sources sit in are returned too, so the caller
|
|
16
|
+
* can compare them with the committed mapping. Every deployed surface scopes a
|
|
17
|
+
* query by the mapping's group_id, not by Kapa's current state, so a group that
|
|
18
|
+
* was deleted and recreated, or a source moved into another version's group,
|
|
19
|
+
* leaves the mapping pointing at a group that no longer holds that version.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { KAPA_API_BASE } = require('../../cli-utils/kapa-credentials')
|
|
23
|
+
const { fetchAllPages, toList, idOf } = require('./generate-kapa-source-groups.js')
|
|
24
|
+
|
|
25
|
+
const SOURCE_NAME_RE = /^Documentation \(([^)]+)\)$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} name - A Kapa source name
|
|
29
|
+
* @returns {string|null} The version segment, or null when the name is not a docs crawl
|
|
30
|
+
*/
|
|
31
|
+
function versionFromSourceName (name) {
|
|
32
|
+
const m = typeof name === 'string' ? name.match(SOURCE_NAME_RE) : null
|
|
33
|
+
if (!m) return null
|
|
34
|
+
const v = m[1].trim()
|
|
35
|
+
// Cloud and RPCN crawls use the same naming but are not streaming versions.
|
|
36
|
+
return /^(\d+\.\d+|current)$/.test(v) ? v : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify a list of Kapa source objects.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array<{name: string, source_groups?: unknown}>} sources
|
|
43
|
+
* @returns {{covered: Set<string>, unassigned: Set<string>, groups: Map<string, Set<string>>}}
|
|
44
|
+
* covered: versions with a source that is in at least one group
|
|
45
|
+
* unassigned: versions with a source that is in no group (global)
|
|
46
|
+
* groups: for each covered version, the ids of every group its sources sit in
|
|
47
|
+
*/
|
|
48
|
+
function classifySources (sources) {
|
|
49
|
+
const covered = new Set()
|
|
50
|
+
const unassigned = new Set()
|
|
51
|
+
const groups = new Map()
|
|
52
|
+
for (const s of sources || []) {
|
|
53
|
+
const v = versionFromSourceName(s && s.name)
|
|
54
|
+
if (!v) continue
|
|
55
|
+
const ids = toList(s.source_groups).map(idOf).filter(Boolean)
|
|
56
|
+
if (ids.length) {
|
|
57
|
+
covered.add(v)
|
|
58
|
+
if (!groups.has(v)) groups.set(v, new Set())
|
|
59
|
+
for (const id of ids) groups.get(v).add(String(id))
|
|
60
|
+
} else {
|
|
61
|
+
unassigned.add(v)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// A version with two sources, one grouped and one not, is covered: the
|
|
65
|
+
// grouped one scopes correctly. The stray global is still worth a report,
|
|
66
|
+
// but it is a generator concern (it lists global_sources), not a gap here.
|
|
67
|
+
for (const v of covered) unassigned.delete(v)
|
|
68
|
+
return { covered, unassigned, groups }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{apiKey: string, projectId: string, fetchImpl?: typeof fetch}} opts
|
|
73
|
+
* @returns {Promise<{covered: Set<string>, unassigned: Set<string>, groups: Map<string, Set<string>>}>}
|
|
74
|
+
*/
|
|
75
|
+
async function fetchKapaSourceVersions ({ apiKey, projectId, fetchImpl }) {
|
|
76
|
+
if (!apiKey) throw new Error('fetchKapaSourceVersions requires an apiKey')
|
|
77
|
+
if (!projectId) throw new Error('fetchKapaSourceVersions requires a projectId')
|
|
78
|
+
const url = `${KAPA_API_BASE}/ingestion/v1/projects/${encodeURIComponent(projectId)}/sources/`
|
|
79
|
+
const sources = await fetchAllPages(url, apiKey, fetchImpl)
|
|
80
|
+
return classifySources(sources)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { versionFromSourceName, classifySources, fetchKapaSourceVersions, SOURCE_NAME_RE }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Reads the docs site's own sitemap to find out which version segments are
|
|
2
|
+
// actually published.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// `validate kapa-source-groups` compares Kapa's sources and the committed
|
|
7
|
+
// mapping, but both of those are blind to the failure that actually matters: a
|
|
8
|
+
// new docs version being published with no Kapa source group behind it.
|
|
9
|
+
//
|
|
10
|
+
// Cutting a v/X.Y branch in redpanda-data/docs publishes /streaming/X.Y/ because
|
|
11
|
+
// the playbook globs `branches: v/*`, and no file changes in this repo or
|
|
12
|
+
// docs-site. Kapa has no write API, so no source or group appears either. The
|
|
13
|
+
// mapping and Kapa still agree with each other, and every reader on the new
|
|
14
|
+
// version silently falls back to the default segment.
|
|
15
|
+
//
|
|
16
|
+
// So the check needs a third input: what the site publishes. The sitemap is the
|
|
17
|
+
// right source because it reflects what is actually live, rather than what a
|
|
18
|
+
// playbook or a branch list intends.
|
|
19
|
+
|
|
20
|
+
const { fetchWithDeadline } = require('./fetch-with-deadline')
|
|
21
|
+
|
|
22
|
+
const SITEMAP_TIMEOUT_MS = 15000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the distinct version segments from a streaming sitemap.
|
|
26
|
+
*
|
|
27
|
+
* Parsed with a regex rather than an XML parser on purpose: the only thing wanted
|
|
28
|
+
* is the path segment after /streaming/, this runs in CI where a dependency is
|
|
29
|
+
* cost, and a malformed sitemap should yield "no segments found" (which the
|
|
30
|
+
* caller treats as an error) rather than a parser exception.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} xml - Sitemap XML
|
|
33
|
+
* @returns {string[]} Sorted, de-duplicated version segments
|
|
34
|
+
*/
|
|
35
|
+
function parsePublishedSegments (xml) {
|
|
36
|
+
const found = new Set()
|
|
37
|
+
for (const m of String(xml || '').matchAll(/\/streaming\/([^/"'<\s]+)\//g)) {
|
|
38
|
+
found.add(m[1])
|
|
39
|
+
}
|
|
40
|
+
return [...found].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch the published version segments from a docs site.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {string} options.siteUrl - Site origin, e.g. https://docs.redpanda.com
|
|
48
|
+
* @param {Function} [options.fetchImpl] - Injectable for tests
|
|
49
|
+
* @returns {Promise<string[]>}
|
|
50
|
+
* @throws {Error} On a non-OK response, a network failure, or an empty result
|
|
51
|
+
*/
|
|
52
|
+
async function fetchPublishedSegments ({ siteUrl, fetchImpl = globalThis.fetch } = {}) {
|
|
53
|
+
if (!siteUrl) throw new Error('fetchPublishedSegments requires a siteUrl')
|
|
54
|
+
const url = `${String(siteUrl).replace(/\/+$/, '')}/sitemap-streaming.xml`
|
|
55
|
+
|
|
56
|
+
let res, body
|
|
57
|
+
try {
|
|
58
|
+
// The deadline covers the body read too; see fetch-with-deadline.js for
|
|
59
|
+
// why clearing the timer after headers alone is not a timeout.
|
|
60
|
+
;({ res, body } = await fetchWithDeadline(
|
|
61
|
+
fetchImpl, url, {}, SITEMAP_TIMEOUT_MS,
|
|
62
|
+
(r) => (r.ok ? r.text() : null)
|
|
63
|
+
))
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// A network failure means "could not find out", which the caller must keep
|
|
66
|
+
// distinct from "a version is missing" so a scheduled run does not file an
|
|
67
|
+
// issue every time the site is briefly unreachable.
|
|
68
|
+
throw new Error(`Could not fetch ${url}: ${err.message}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!res.ok) throw new Error(`Could not fetch ${url}: ${res.status} ${res.statusText}`)
|
|
72
|
+
|
|
73
|
+
const segments = parsePublishedSegments(body)
|
|
74
|
+
if (segments.length === 0) {
|
|
75
|
+
// An empty sitemap and a moved sitemap look identical, and treating either as
|
|
76
|
+
// "nothing is published" would report every mapped segment as stale.
|
|
77
|
+
throw new Error(`No /streaming/<version>/ URLs found in ${url}. The sitemap may have moved or changed shape.`)
|
|
78
|
+
}
|
|
79
|
+
return segments
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* URL segments that publish without a durable version behind them.
|
|
84
|
+
*
|
|
85
|
+
* docs-site sets `latest_prerelease_version_segment: 'beta'`, so a branch with
|
|
86
|
+
* `prerelease: true` publishes at /streaming/beta/ for the whole pre-GA cycle
|
|
87
|
+
* (26.2's ran 2026-06-11 to 2026-07-28). No Kapa group is expected for it, and
|
|
88
|
+
* a check that reported it as missing would file the same false issue every
|
|
89
|
+
* Monday of every beta.
|
|
90
|
+
*/
|
|
91
|
+
const PRERELEASE_SEGMENTS = new Set(['beta'])
|
|
92
|
+
|
|
93
|
+
/** @param {string} segment */
|
|
94
|
+
function isPrereleaseSegment (segment) {
|
|
95
|
+
return PRERELEASE_SEGMENTS.has(segment)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { parsePublishedSegments, fetchPublishedSegments, isPrereleaseSegment, PRERELEASE_SEGMENTS, SITEMAP_TIMEOUT_MS }
|
|
@@ -4,7 +4,7 @@ const { spawnSync } = require('child_process')
|
|
|
4
4
|
const fs = require('fs')
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const path = require('path')
|
|
7
|
-
const { getGitHubToken } = require('../../cli-utils/github-token')
|
|
7
|
+
const { getGitHubToken, getGitHubApiToken } = require('../../cli-utils/github-token')
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* rp_util's config schema JSON, for any streaming-enterprise ref (tag,
|
|
@@ -287,7 +287,7 @@ const RELEASE_ASSET_TO_SCHEMA_KEY = {
|
|
|
287
287
|
* no release exists for this tag (caller should fall back to building)
|
|
288
288
|
*/
|
|
289
289
|
async function fetchPublishedSchema(tag) {
|
|
290
|
-
const token =
|
|
290
|
+
const token = getGitHubApiToken()
|
|
291
291
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
292
292
|
|
|
293
293
|
const releaseResp = await fetch(
|
|
@@ -1324,8 +1324,8 @@ function downloadRpkRelease(tag, destDir) {
|
|
|
1324
1324
|
// redpanda-data/redpanda. This function already has a fallback (the
|
|
1325
1325
|
// caller builds from source instead), so a missing token warns and
|
|
1326
1326
|
// returns null here rather than throwing the way the clone-only paths do.
|
|
1327
|
-
const {
|
|
1328
|
-
const token =
|
|
1327
|
+
const { getGitHubApiToken } = require('../../cli-utils/github-token')
|
|
1328
|
+
const token = getGitHubApiToken()
|
|
1329
1329
|
if (!token) {
|
|
1330
1330
|
console.warn('No GitHub token available for the private streaming-enterprise release download; falling back to a source build.')
|
|
1331
1331
|
return null
|