@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.1

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.
Files changed (53) hide show
  1. package/bin/doc-tools.js +323 -7
  2. package/cli-utils/docs-links-to-xrefs.js +174 -0
  3. package/cli-utils/generated-marker.js +43 -0
  4. package/cli-utils/helm-commented-values.js +37 -3
  5. package/extension-utils/llms-utils.js +6 -2
  6. package/extension-utils/metadata-candidates.js +304 -0
  7. package/extension-utils/metadata-check.js +174 -0
  8. package/extension-utils/metadata-suggest.js +573 -0
  9. package/extension-utils/taxonomy-categories.js +106 -0
  10. package/extension-utils/taxonomy.js +566 -0
  11. package/extensions/REFERENCE.adoc +90 -4
  12. package/extensions/algolia-indexer/generate-index.js +13 -1
  13. package/extensions/algolia-indexer/index.js +4 -2
  14. package/extensions/convert-sitemap-to-markdown.js +47 -12
  15. package/extensions/convert-to-markdown.js +176 -0
  16. package/extensions/generate-fields-only-pages.js +14 -0
  17. package/extensions/modify-connect-tag-playbook.js +2 -2
  18. package/extensions/url-to-xref.js +92 -22
  19. package/extensions/validate-attributes.js +10 -1
  20. package/package.json +2 -1
  21. package/tools/check-metadata/index.js +523 -0
  22. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  23. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  24. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  25. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  26. package/tools/dispatch-graph/classify-runs.js +128 -0
  27. package/tools/dispatch-graph/parse-workflows.js +499 -0
  28. package/tools/enterprise-features/detect-gating-changes.js +440 -0
  29. package/tools/enterprise-features/verify.js +2 -1
  30. package/tools/gen-rpk-ascii.py +7 -1
  31. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  32. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  33. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  34. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  35. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  36. package/tools/lint-strings/index.js +133 -3
  37. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  38. package/tools/lint-strings/surfaces/connect.js +72 -12
  39. package/tools/lint-strings/surfaces/crd.js +10 -6
  40. package/tools/metrics/metrics.py +11 -0
  41. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  42. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  43. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
  44. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
  45. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  46. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  47. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  48. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  49. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  50. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  51. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  52. package/tools/rpk-docs/helpers/index.js +4 -1
  53. package/tools/rpk-docs/templates/command.hbs +1 -1
@@ -0,0 +1,790 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Health check for the repository_dispatch graph between Redpanda's
5
+ * engineering repos and its docs repos.
6
+ *
7
+ * A repository_dispatch is accepted with HTTP 204 whether or not a workflow
8
+ * listens for it, and a receiver that fails on every run is indistinguishable,
9
+ * from the sender's side, from one that succeeds on every run. Both halves of
10
+ * an edge therefore have to be read from the outside: the workflow files on
11
+ * each repo's default branch say who sends what to whom, and the Actions runs
12
+ * API says whether each half has ever run and how it has been going. This
13
+ * module does both and turns the result into findings a scheduled job can
14
+ * file as one issue.
15
+ *
16
+ * Findings (exit 1):
17
+ * sender-without-listener a dispatch nothing in the target repo listens for
18
+ * listener-without-sender a docs-repo listener nothing dispatches to (no cron backstop)
19
+ * sender-never-fired the sending workflow has no runs at all
20
+ * receiver-failing more than --fail-threshold of the receiver's newest runs failed
21
+ * receiver-never-received the sender has fired, the receiver has never run from a dispatch
22
+ * payload-variant-without-sender a receiver's choice input lists a value no sender ever sends
23
+ * duplicate-sender the same edge is dispatched from more than one repo (one is usually stale)
24
+ * sender-step-never-succeeded the sender's dispatch step has an `if:` and did not succeed in any recent run checked
25
+ * receiver-disabled the receiving workflow is disabled (state is not active), so dispatches are dropped
26
+ *
27
+ * Informational (do not affect the exit code):
28
+ * listener-without-sender (cron) nothing dispatches to it, but a schedule runs it anyway
29
+ * unscanned-target a dispatch to a repo outside the configured list
30
+ * unresolved-sender a dispatch whose target or type cannot be read from the file
31
+ * reusable-sender a workflow_call-only sender, whose runs live in its callers
32
+ * receiver-no-dispatch-runs a receiver that has only ever run by hand or on a schedule
33
+ * parse-error a workflow file that is not valid YAML
34
+ * archived-repo a configured repo that is archived; its workflows cannot run, so it sends nothing
35
+ * renamed-repo a configured repo that GitHub redirects to a new name; it is checked under the new name
36
+ * conditional-sender a gated dispatch step that could not be located in the jobs API, so the workflow run counts
37
+ *
38
+ * Could-not-run (exit 2) is anything that stops the check seeing the whole
39
+ * graph: a repo that cannot be read, a runs query that fails. The findings
40
+ * from the readable part are still reported, but the run never exits 0.
41
+ */
42
+
43
+ const { parseListeners, parseSenders } = require('./parse-workflows')
44
+ const { summarizeRuns, classifyReceiver, classifySender, formatRate } = require('./classify-runs')
45
+
46
+ /**
47
+ * The repos whose workflows make up the graph. Engineering repos send,
48
+ * docs repos receive; the roles only decide which side "no counterpart" is
49
+ * reported on. Every sender and listener found in any of them is included.
50
+ */
51
+ const DEFAULT_REPOS = [
52
+ { repo: 'redpanda-data/streaming-enterprise', role: 'engineering' },
53
+ { repo: 'redpanda-data/redpanda', role: 'engineering' },
54
+ { repo: 'redpanda-data/cloudv2', role: 'engineering' },
55
+ { repo: 'redpanda-data/redpanda-operator', role: 'engineering' },
56
+ { repo: 'redpanda-data/connect', role: 'engineering' },
57
+ { repo: 'redpanda-data/console', role: 'engineering' },
58
+ { repo: 'redpanda-data/helm-charts', role: 'engineering' },
59
+ { repo: 'redpanda-data/redpanda-check', role: 'engineering' },
60
+ { repo: 'redpanda-data/docs', role: 'docs' },
61
+ { repo: 'redpanda-data/cloud-docs', role: 'docs' },
62
+ { repo: 'redpanda-data/rp-connect-docs', role: 'docs' },
63
+ { repo: 'redpanda-data/api-docs', role: 'docs' },
64
+ { repo: 'redpanda-data/adp-docs', role: 'docs' },
65
+ { repo: 'redpanda-data/docs-site', role: 'docs' },
66
+ { repo: 'redpanda-data/docs-ui', role: 'docs' },
67
+ { repo: 'redpanda-data/docs-extensions-and-macros', role: 'docs' },
68
+ ]
69
+
70
+ const SENTINEL = 'DISPATCH_GRAPH_FINDINGS_CONFIRMED'
71
+
72
+ /**
73
+ * Thin adapter over Octokit so the check can be exercised against a stub.
74
+ *
75
+ * @param {import('@octokit/rest').Octokit} octokit
76
+ */
77
+ function createGitHubApi (octokit) {
78
+ const split = (slug) => {
79
+ const [owner, repo] = slug.split('/')
80
+ return { owner, repo }
81
+ }
82
+ return {
83
+ async getDefaultBranch (slug) {
84
+ const { data } = await octokit.repos.get(split(slug))
85
+ return data.default_branch
86
+ },
87
+ // repos.get follows a rename redirect, so full_name is the current name.
88
+ async getRepo (slug) {
89
+ const { data } = await octokit.repos.get(split(slug))
90
+ return { fullName: data.full_name, defaultBranch: data.default_branch, archived: Boolean(data.archived) }
91
+ },
92
+ async listWorkflows (slug) {
93
+ const { data } = await octokit.actions.listRepoWorkflows({ ...split(slug), per_page: 100 })
94
+ return (data.workflows || []).map((w) => ({ path: w.path, state: w.state }))
95
+ },
96
+ async listJobsForRun (slug, runId) {
97
+ const { data } = await octokit.actions.listJobsForWorkflowRun({ ...split(slug), run_id: runId, per_page: 100 })
98
+ return data.jobs || []
99
+ },
100
+ async listWorkflowFiles (slug, ref) {
101
+ const { data } = await octokit.repos.getContent({ ...split(slug), path: '.github/workflows', ref })
102
+ if (!Array.isArray(data)) return []
103
+ return data
104
+ .filter((e) => e.type === 'file' && /\.ya?ml$/i.test(e.name))
105
+ .map((e) => e.name)
106
+ },
107
+ async getFileText (slug, path, ref) {
108
+ const { data } = await octokit.repos.getContent({ ...split(slug), path, ref })
109
+ if (Array.isArray(data) || typeof data.content !== 'string') throw new Error(`${slug}:${path} is not a file`)
110
+ return Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
111
+ },
112
+ async listWorkflowRuns (slug, file, perPage, opts = {}) {
113
+ const params = { ...split(slug), workflow_id: file, per_page: perPage }
114
+ if (opts.event) params.event = opts.event
115
+ const { data } = await octokit.actions.listWorkflowRuns(params)
116
+ return data.workflow_runs || []
117
+ },
118
+ }
119
+ }
120
+
121
+ function isReusableOnly (sender) {
122
+ const t = (sender.triggers || []).filter((x) => x !== 'workflow_call')
123
+ return (sender.triggers || []).includes('workflow_call') && t.length === 0
124
+ }
125
+
126
+ function edgeKey (repo, type) {
127
+ return `${repo}#${type}`
128
+ }
129
+
130
+ function shortRepo (slug) {
131
+ return String(slug).replace(/^redpanda-data\//, '')
132
+ }
133
+
134
+ function describeSender (s) {
135
+ return `${shortRepo(s.repo)}/${s.file}`
136
+ }
137
+
138
+ async function mapLimit (items, limit, fn) {
139
+ const out = new Array(items.length)
140
+ let next = 0
141
+ async function worker () {
142
+ while (next < items.length) {
143
+ const i = next++
144
+ out[i] = await fn(items[i], i)
145
+ }
146
+ }
147
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * Read every workflow in every configured repo and return the parsed halves.
153
+ */
154
+ async function scanRepos ({ repos, api, concurrency }) {
155
+ const listeners = []
156
+ const senders = []
157
+ const unreadable = []
158
+ const parseErrors = []
159
+ const scanned = []
160
+ const notes = []
161
+ const aliases = {} // configured slug (lowercase) -> current full_name (lowercase)
162
+ const seen = new Set()
163
+
164
+ await mapLimit(repos, concurrency, async ({ repo: configured, role }) => {
165
+ let ref
166
+ let files
167
+ let repo = String(configured).toLowerCase()
168
+ try {
169
+ const meta = api.getRepo
170
+ ? await api.getRepo(configured)
171
+ : { fullName: configured, defaultBranch: await api.getDefaultBranch(configured), archived: false }
172
+ repo = String(meta.fullName || configured).toLowerCase()
173
+ ref = meta.defaultBranch
174
+ if (repo !== String(configured).toLowerCase()) {
175
+ aliases[String(configured).toLowerCase()] = repo
176
+ notes.push({ kind: 'renamed-repo', message: `${configured} has been renamed to ${repo}; it is checked under the new name. Update the configured list.` })
177
+ }
178
+ if (seen.has(repo)) return // configured twice, for example under an old and a new name
179
+ seen.add(repo)
180
+ if (meta.archived) {
181
+ // Archived repos cannot run workflows, so nothing in them sends or
182
+ // receives. Kept in the scanned set so a dispatch aimed at one is
183
+ // still reported as having no listener.
184
+ notes.push({ kind: 'archived-repo', message: `${repo} is archived; its workflows cannot run, so it is skipped as a sender and has no listeners.` })
185
+ scanned.push({ repo, role, ref, workflows: 0, archived: true })
186
+ return
187
+ }
188
+ files = await api.listWorkflowFiles(repo, ref)
189
+ } catch (err) {
190
+ unreadable.push({ repo, role, error: describeError(err) })
191
+ return
192
+ }
193
+ const workflows = []
194
+ for (const file of files) {
195
+ let text
196
+ try {
197
+ text = await api.getFileText(repo, `.github/workflows/${file}`, ref)
198
+ } catch (err) {
199
+ unreadable.push({ repo, role, file, error: describeError(err) })
200
+ continue
201
+ }
202
+ const where = { repo, file }
203
+ try {
204
+ const l = parseListeners(text, where).map((x) => ({ ...x, role }))
205
+ const s = parseSenders(text, where).map((x) => ({ ...x, role }))
206
+ listeners.push(...l)
207
+ senders.push(...s)
208
+ workflows.push({ file, listeners: l.length, senders: s.length })
209
+ } catch (err) {
210
+ parseErrors.push({ repo, file, error: err.message.split('\n')[0] })
211
+ }
212
+ }
213
+ scanned.push({ repo, role, ref, workflows: workflows.length })
214
+ })
215
+
216
+ scanned.sort((a, b) => a.repo.localeCompare(b.repo))
217
+ return { listeners, senders, unreadable, parseErrors, scanned, notes, aliases }
218
+ }
219
+
220
+ function describeError (err) {
221
+ const status = err && err.status ? ` (HTTP ${err.status})` : ''
222
+ return `${(err && err.message) || String(err)}${status}`
223
+ }
224
+
225
+ /**
226
+ * Join senders and listeners into edges and derive the structural findings
227
+ * (everything that can be known from the files alone).
228
+ */
229
+ function buildGraph ({ listeners, senders, scannedRepos, docsRepos, aliases = {} }) {
230
+ const scannedSet = new Set(scannedRepos)
231
+ const docsSet = new Set(docsRepos)
232
+ const listenersByKey = new Map()
233
+ const anyTypeListeners = new Map() // repo -> listeners with eventType null
234
+ for (const l of listeners) {
235
+ if (l.eventType === null) {
236
+ if (!anyTypeListeners.has(l.repo)) anyTypeListeners.set(l.repo, [])
237
+ anyTypeListeners.get(l.repo).push(l)
238
+ continue
239
+ }
240
+ const k = edgeKey(l.repo, l.eventType)
241
+ if (!listenersByKey.has(k)) listenersByKey.set(k, [])
242
+ listenersByKey.get(k).push(l)
243
+ }
244
+
245
+ const findings = []
246
+ const info = []
247
+ const edges = new Map() // key -> { targetRepo, eventType, senders: [], listeners: [] }
248
+
249
+ const addEdge = (repo, type) => {
250
+ const k = edgeKey(repo, type)
251
+ if (!edges.has(k)) edges.set(k, { key: k, targetRepo: repo, eventType: type, senders: [], listeners: [] })
252
+ return edges.get(k)
253
+ }
254
+
255
+ for (const s of senders) {
256
+ // A dispatch aimed at a repo's old name is redirected by GitHub, so it is
257
+ // an edge to the repo under its current name.
258
+ if (s.targetRepo && aliases[s.targetRepo]) s.targetRepo = aliases[s.targetRepo]
259
+ if (s.unresolved || !s.targetRepo || !s.eventType) {
260
+ info.push({
261
+ kind: 'unresolved-sender',
262
+ message: `${describeSender(s)}: dispatch target or event type cannot be resolved from the file (${s.raw || 'no literal value'})`,
263
+ sender: s,
264
+ })
265
+ continue
266
+ }
267
+ if (!scannedSet.has(s.targetRepo)) {
268
+ info.push({
269
+ kind: 'unscanned-target',
270
+ message: `${describeSender(s)} -> ${shortRepo(s.targetRepo)}#${s.eventType}: target repo is not in the scanned list, so its listener was not checked`,
271
+ sender: s,
272
+ })
273
+ continue
274
+ }
275
+ const edge = addEdge(s.targetRepo, s.eventType)
276
+ edge.senders.push(s)
277
+ const matched = (listenersByKey.get(edge.key) || []).concat(anyTypeListeners.get(s.targetRepo) || [])
278
+ for (const l of matched) if (!edge.listeners.includes(l)) edge.listeners.push(l)
279
+ }
280
+
281
+ for (const l of listeners) {
282
+ if (l.eventType === null) continue
283
+ const edge = addEdge(l.repo, l.eventType)
284
+ if (!edge.listeners.includes(l)) edge.listeners.push(l)
285
+ }
286
+
287
+ for (const edge of edges.values()) {
288
+ const target = shortRepo(edge.targetRepo)
289
+ if (edge.senders.length && edge.listeners.length === 0) {
290
+ for (const s of edge.senders) {
291
+ findings.push({
292
+ severity: 'error',
293
+ kind: 'sender-without-listener',
294
+ edge: edge.key,
295
+ message: `${describeSender(s)} -> ${target}#${edge.eventType}: no workflow in ${edge.targetRepo} listens for this event. GitHub accepts the dispatch (204) and drops it.`,
296
+ })
297
+ }
298
+ }
299
+ if (edge.listeners.length && edge.senders.length === 0) {
300
+ for (const l of edge.listeners) {
301
+ if (!docsSet.has(l.repo)) continue // engineering-side listeners (slash commands, bots) are out of scope
302
+ if (l.cronBackstop) {
303
+ info.push({
304
+ kind: 'listener-without-sender',
305
+ edge: edge.key,
306
+ message: `${target}/${l.file} listens for ${edge.eventType} but nothing in the scanned repos dispatches it; a schedule runs it anyway`,
307
+ })
308
+ } else {
309
+ findings.push({
310
+ severity: 'error',
311
+ kind: 'listener-without-sender',
312
+ edge: edge.key,
313
+ message: `${target}/${l.file} listens for ${edge.eventType} but nothing in the scanned repos dispatches it, and it has no cron backstop, so it only runs by hand.`,
314
+ })
315
+ }
316
+ }
317
+ }
318
+ // Choice inputs on a dispatch-driven workflow enumerate the variants it
319
+ // expects. A variant no sender carries in its payload is a dead branch.
320
+ for (const l of edge.listeners) {
321
+ for (const [input, options] of Object.entries(l.choiceInputs || {})) {
322
+ const sent = edge.senders.filter((s) => s.payload && s.payload[input] !== undefined)
323
+ if (sent.length === 0) continue
324
+ const sentValues = new Set(sent.map((s) => s.payload[input]))
325
+ const missing = options.filter((o) => !sentValues.has(o))
326
+ for (const value of missing) {
327
+ findings.push({
328
+ severity: 'error',
329
+ kind: 'payload-variant-without-sender',
330
+ edge: edge.key,
331
+ message: `${target}/${l.file} expects ${input}: ${value} on ${edge.eventType}, but no scanned sender dispatches that value (senders send ${[...sentValues].join(', ')}).`,
332
+ })
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ // A listener in an engineering repo that nothing in the scanned set
339
+ // dispatches to (slash-command bots, deploy hooks) is not part of the docs
340
+ // graph; it leaves the edge list rather than padding the table.
341
+ let outOfScopeListeners = 0
342
+ const kept = [...edges.values()].filter((e) => {
343
+ if (e.senders.length) return true
344
+ if (e.listeners.some((l) => docsSet.has(l.repo))) return true
345
+ outOfScopeListeners += e.listeners.length
346
+ return false
347
+ })
348
+
349
+ return { edges: kept.sort((a, b) => a.key.localeCompare(b.key)), findings, info, outOfScopeListeners }
350
+ }
351
+
352
+ /**
353
+ * Fetch the run record of every workflow that takes part in an edge and
354
+ * derive the run-based findings.
355
+ */
356
+ async function recordRuns ({ edges, api, perPage, window, threshold, concurrency, stepRuns = 20 }) {
357
+ const workflows = new Map() // repo/file -> { repo, file, listener }
358
+ for (const e of edges) {
359
+ for (const s of e.senders) {
360
+ const k = `${s.repo}/${s.file}`
361
+ if (!workflows.has(k)) workflows.set(k, { repo: s.repo, file: s.file, listener: false })
362
+ }
363
+ for (const l of e.listeners) workflows.set(`${l.repo}/${l.file}`, { repo: l.repo, file: l.file, listener: true })
364
+ }
365
+ const runs = new Map()
366
+ const rawRuns = new Map()
367
+ const runsUnreadable = []
368
+ let extraCalls = 0
369
+ await mapLimit([...workflows.values()], concurrency, async ({ repo, file, listener }) => {
370
+ try {
371
+ const list = await api.listWorkflowRuns(repo, file, perPage)
372
+ const summary = summarizeRuns(list, { window })
373
+ // A listener that also runs on push, pull_request or a schedule can
374
+ // fill a whole page with those runs and push its dispatch runs off the
375
+ // end. Only when the page is full and holds no dispatch run is the
376
+ // filtered query needed; a shorter page is the complete history.
377
+ if (listener && summary.dispatchRuns === 0 && list.length >= perPage) {
378
+ extraCalls += 1
379
+ const dispatched = await api.listWorkflowRuns(repo, file, perPage, { event: 'repository_dispatch' })
380
+ const only = (dispatched || []).filter((r) => !r.event || r.event === 'repository_dispatch')
381
+ const d = summarizeRuns(only, { window })
382
+ summary.dispatchRuns = d.total
383
+ summary.lastDispatchRunAt = d.lastRunAt
384
+ }
385
+ runs.set(`${repo}/${file}`, summary)
386
+ rawRuns.set(`${repo}/${file}`, list)
387
+ } catch (err) {
388
+ runsUnreadable.push({ repo, file, error: describeError(err) })
389
+ }
390
+ })
391
+
392
+ // A disabled workflow receives nothing: GitHub still answers the dispatch
393
+ // with 204. One workflow list per repo that holds a listener.
394
+ const states = new Map() // repo -> Map(file -> state)
395
+ if (api.listWorkflows) {
396
+ const listenerRepos = [...new Set([...workflows.values()].filter((w) => w.listener).map((w) => w.repo))]
397
+ await mapLimit(listenerRepos, concurrency, async (repo) => {
398
+ try {
399
+ extraCalls += 1
400
+ const list = await api.listWorkflows(repo)
401
+ states.set(repo, new Map(list.map((w) => [String(w.path).replace(/^\.github\/workflows\//, ''), w.state])))
402
+ } catch (err) {
403
+ runsUnreadable.push({ repo, file: '(workflow list)', error: describeError(err) })
404
+ }
405
+ })
406
+ }
407
+
408
+ // A sender whose dispatch step (or its job) is gated by `if:` has not
409
+ // dispatched just because the workflow ran. Read the jobs of its newest
410
+ // completed runs, newest first, and stop at the first run where the step
411
+ // succeeded. Bounded by stepRuns per workflow; jobs are cached per run.
412
+ // The bound is generous because a gate like "only on a non-prerelease tag"
413
+ // skips the step on most runs: on 2026-10-22 connect's release.yml needed
414
+ // 7 runs and a window of 5 reported a working sender as dead.
415
+ const jobsCache = new Map()
416
+ const getJobs = async (repo, runId) => {
417
+ const k = `${repo}#${runId}`
418
+ if (!jobsCache.has(k)) {
419
+ extraCalls += 1
420
+ jobsCache.set(k, api.listJobsForRun(repo, runId))
421
+ }
422
+ return jobsCache.get(k)
423
+ }
424
+ const stepChecks = new Map() // sender object -> { checked, succeeded, located }
425
+ const conditional = []
426
+ for (const e of edges) for (const s of e.senders) if (s.conditional && !conditional.includes(s)) conditional.push(s)
427
+ if (api.listJobsForRun) {
428
+ await mapLimit(conditional, concurrency, async (s) => {
429
+ const list = rawRuns.get(`${s.repo}/${s.file}`)
430
+ if (!list || list.length === 0) return
431
+ const completed = list
432
+ .filter((r) => r.status === 'completed' && r.id !== undefined)
433
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
434
+ .slice(0, stepRuns)
435
+ const check = { checked: 0, succeeded: 0, located: 0 }
436
+ try {
437
+ for (const r of completed) {
438
+ const jobs = await getJobs(s.repo, r.id)
439
+ check.checked += 1
440
+ const conclusion = dispatchStepConclusion(jobs, s.stepRef)
441
+ if (conclusion === undefined) continue
442
+ check.located += 1
443
+ if (conclusion === 'success') { check.succeeded += 1; break }
444
+ }
445
+ stepChecks.set(s, check)
446
+ } catch (err) {
447
+ runsUnreadable.push({ repo: s.repo, file: `${s.file} (jobs)`, error: describeError(err) })
448
+ }
449
+ })
450
+ }
451
+
452
+ const findings = []
453
+ const info = []
454
+ const reportedSenders = new Set()
455
+ for (const e of edges) {
456
+ const target = shortRepo(e.targetRepo)
457
+ let anySenderFired = false
458
+ for (const s of e.senders) {
459
+ const summary = runs.get(`${s.repo}/${s.file}`)
460
+ if (!summary) continue
461
+ const id = `${s.repo}/${s.file}`
462
+ if (s.conditional && summary.total > 0) {
463
+ const check = stepChecks.get(s)
464
+ if (check && check.succeeded > 0) {
465
+ anySenderFired = true
466
+ } else if (check && check.located > 0) {
467
+ if (!reportedSenders.has(`${id}#step`)) {
468
+ reportedSenders.add(`${id}#step`)
469
+ findings.push({
470
+ severity: 'error',
471
+ kind: 'sender-step-never-succeeded',
472
+ edge: e.key,
473
+ message: `${describeSender(s)} ran, but its dispatch step "${s.stepRef.stepName}" (if: ${s.condition}) did not succeed in any of the newest ${check.checked} completed run(s), so it has not dispatched ${e.eventType} recently.`,
474
+ })
475
+ }
476
+ } else {
477
+ // The step could not be found in the jobs API (a job name built
478
+ // from an expression, say), or the jobs could not be read at all.
479
+ // The workflow run is the best evidence left.
480
+ anySenderFired = true
481
+ info.push({
482
+ kind: 'conditional-sender',
483
+ edge: e.key,
484
+ message: `${describeSender(s)} dispatches only when ${s.condition}; its dispatch step was not found in the jobs of its recent runs (the step may be newer than those runs), so a workflow run is counted as a dispatch.`,
485
+ })
486
+ }
487
+ continue
488
+ }
489
+ // A reusable workflow (workflow_call only) runs inside its callers'
490
+ // jobs, so its own run list is empty by construction. Its edge is still
491
+ // reported, but "never fired" cannot be read off this repo.
492
+ if (isReusableOnly(s) && summary.total === 0) {
493
+ anySenderFired = true
494
+ if (!reportedSenders.has(id)) {
495
+ reportedSenders.add(id)
496
+ info.push({
497
+ kind: 'reusable-sender',
498
+ edge: e.key,
499
+ message: `${describeSender(s)} is a reusable workflow (workflow_call), so its runs are recorded in the calling repos and not checked here.`,
500
+ })
501
+ }
502
+ continue
503
+ }
504
+ const c = classifySender(summary)
505
+ if (c.status === 'fired') anySenderFired = true
506
+ if (c.status === 'never-fired' && !reportedSenders.has(id)) {
507
+ reportedSenders.add(id)
508
+ const hint = s.triggers.includes('release')
509
+ ? ' It runs on release events; a release created by a workflow using GITHUB_TOKEN does not fire them.'
510
+ : ''
511
+ findings.push({
512
+ severity: 'error',
513
+ kind: 'sender-never-fired',
514
+ edge: e.key,
515
+ message: `${describeSender(s)} has never run, so it has never dispatched ${e.eventType}.${hint}`,
516
+ })
517
+ }
518
+ }
519
+ // The same (target, type) dispatched from more than one repo with
520
+ // payloads that cannot be told apart is almost always a stale copy left
521
+ // behind by a repo move. Senders whose literal payloads differ (one
522
+ // event, several plugin values) are distinct variants, not duplicates.
523
+ const byPayload = new Map()
524
+ for (const s of e.senders) {
525
+ const sig = JSON.stringify(Object.entries(s.payload || {}).sort())
526
+ if (!byPayload.has(sig)) byPayload.set(sig, [])
527
+ byPayload.get(sig).push(s)
528
+ }
529
+ for (const group of byPayload.values()) {
530
+ const repos = [...new Set(group.map((s) => s.repo))]
531
+ if (repos.length < 2) continue
532
+ const described = group.map((s) => {
533
+ const r = runs.get(`${s.repo}/${s.file}`)
534
+ const last = r ? (r.total === 0 ? 'never ran' : `last run ${day(r.lastRunAt)}`) : 'runs unknown'
535
+ return `${describeSender(s)} (${last})`
536
+ })
537
+ findings.push({
538
+ severity: 'warning',
539
+ kind: 'duplicate-sender',
540
+ edge: e.key,
541
+ message: `${target}#${e.eventType} is dispatched with the same payload from ${repos.length} repos: ${described.join('; ')}. One is usually a stale copy left behind by a repo move.`,
542
+ })
543
+ }
544
+ for (const l of e.listeners) {
545
+ const state = states.get(l.repo) && states.get(l.repo).get(l.file)
546
+ if (state && state !== 'active') {
547
+ findings.push({
548
+ severity: 'error',
549
+ kind: 'receiver-disabled',
550
+ edge: e.key,
551
+ message: `${target}/${l.file} is ${state}, so it does not run on ${e.eventType}. GitHub accepts the dispatch (204) and drops it.`,
552
+ })
553
+ continue
554
+ }
555
+ const summary = runs.get(`${l.repo}/${l.file}`)
556
+ if (!summary) continue
557
+ const c = classifyReceiver(summary, { threshold })
558
+ if (c.status === 'failing') {
559
+ const w = summary.window
560
+ findings.push({
561
+ severity: 'error',
562
+ kind: 'receiver-failing',
563
+ edge: e.key,
564
+ message: `${target}/${l.file} failed ${w.failures} of its newest ${w.considered} runs (${formatRate(c.failRate)}); last run ${summary.lastConclusion} at ${summary.lastRunAt}. ${summary.lastRunUrl || ''}`.trim(),
565
+ })
566
+ } else if (e.senders.length && anySenderFired && summary.dispatchRuns === 0) {
567
+ findings.push({
568
+ severity: 'error',
569
+ kind: 'receiver-never-received',
570
+ edge: e.key,
571
+ message: `${target}/${l.file} has ${summary.total} run(s) but none from a repository_dispatch, although a sender of ${e.eventType} has fired.`,
572
+ })
573
+ } else if (e.senders.length && summary.dispatchRuns === 0) {
574
+ info.push({
575
+ kind: 'receiver-no-dispatch-runs',
576
+ edge: e.key,
577
+ message: `${target}/${l.file} has only ${summary.total} manual or scheduled run(s), never one from a repository_dispatch.`,
578
+ })
579
+ }
580
+ }
581
+ }
582
+ return { runs, runsUnreadable, findings, info, stepChecks, extraCalls }
583
+ }
584
+
585
+ /**
586
+ * The conclusion of the dispatch step in one run's jobs, or undefined when the
587
+ * step cannot be found. A matrix job is reported as "name (a, b)", so any job
588
+ * whose name starts with the job's name counts; success in any of them wins.
589
+ */
590
+ function dispatchStepConclusion (jobs, ref) {
591
+ if (!ref) return undefined
592
+ const matching = (jobs || []).filter((j) => j.name === ref.jobName || String(j.name).startsWith(`${ref.jobName} (`) ||
593
+ (ref.jobName !== ref.jobKey && (j.name === ref.jobKey || String(j.name).startsWith(`${ref.jobKey} (`))))
594
+ let found
595
+ for (const j of matching) {
596
+ // A job skipped by its own `if:` has no steps; its dispatch step did not run.
597
+ if (j.conclusion === 'skipped' && (!j.steps || j.steps.length === 0)) { found = found || 'skipped'; continue }
598
+ const steps = j.steps || []
599
+ const step = steps.find((st) => st.name === ref.stepName) || steps.find((st) => st.number === ref.stepNumber && !ref.stepName)
600
+ if (!step) continue
601
+ if (step.conclusion === 'success') return 'success'
602
+ found = step.conclusion || 'unknown'
603
+ }
604
+ return found
605
+ }
606
+
607
+ /**
608
+ * Run the whole check.
609
+ *
610
+ * @param {object} opts
611
+ * @param {Array<{repo: string, role: 'engineering'|'docs'}>} [opts.repos]
612
+ * @param {ReturnType<typeof createGitHubApi>} opts.api
613
+ * @param {number} [opts.threshold=0.5]
614
+ * @param {number} [opts.window=20]
615
+ * @param {number} [opts.perPage=50]
616
+ * @param {number} [opts.concurrency=4]
617
+ * @param {number} [opts.stepRuns=20] - most runs whose jobs are read per gated sender
618
+ */
619
+ async function runCheck (opts) {
620
+ const repos = opts.repos || DEFAULT_REPOS
621
+ const api = opts.api
622
+ if (!api) throw new Error('runCheck requires an api')
623
+ const threshold = opts.threshold === undefined ? 0.5 : opts.threshold
624
+ const window = opts.window || 20
625
+ const perPage = opts.perPage || 50
626
+ const concurrency = opts.concurrency || 4
627
+
628
+ const scan = await scanRepos({ repos, api, concurrency })
629
+ const scannedRepos = scan.scanned.map((s) => s.repo)
630
+ const docsRepos = scan.scanned.filter((r) => r.role === 'docs').map((r) => r.repo)
631
+ const graph = buildGraph({ listeners: scan.listeners, senders: scan.senders, scannedRepos, docsRepos, aliases: scan.aliases })
632
+ const runRecord = await recordRuns({ edges: graph.edges, api, perPage, window, threshold, concurrency, stepRuns: opts.stepRuns })
633
+
634
+ const findings = [...graph.findings, ...runRecord.findings]
635
+ const info = [
636
+ ...scan.notes,
637
+ ...graph.info,
638
+ ...runRecord.info,
639
+ ...scan.parseErrors.map((p) => ({ kind: 'parse-error', message: `${shortRepo(p.repo)}/${p.file}: ${p.error}` })),
640
+ ]
641
+
642
+ const edges = graph.edges.map((e) => ({
643
+ key: e.key,
644
+ targetRepo: e.targetRepo,
645
+ eventType: e.eventType,
646
+ senders: e.senders.map((s) => ({
647
+ repo: s.repo,
648
+ file: s.file,
649
+ form: s.form,
650
+ triggers: s.triggers,
651
+ payload: s.payload,
652
+ conditional: Boolean(s.conditional),
653
+ condition: s.condition || null,
654
+ dispatchStep: runRecord.stepChecks.get(s) || null,
655
+ runs: runRecord.runs.get(`${s.repo}/${s.file}`) || null,
656
+ })),
657
+ listeners: e.listeners.map((l) => ({
658
+ repo: l.repo,
659
+ file: l.file,
660
+ cronBackstop: l.cronBackstop,
661
+ runs: runRecord.runs.get(`${l.repo}/${l.file}`) || null,
662
+ })),
663
+ }))
664
+
665
+ const unreadable = [...scan.unreadable, ...runRecord.runsUnreadable]
666
+ const hasFindings = findings.length > 0
667
+ const exitCode = unreadable.length ? 2 : hasFindings ? 1 : 0
668
+
669
+ return {
670
+ checkedAt: new Date().toISOString(),
671
+ options: { threshold, window, perPage },
672
+ extraCalls: runRecord.extraCalls,
673
+ scanned: scan.scanned,
674
+ unreadable,
675
+ counts: {
676
+ repos: scan.scanned.length,
677
+ senders: scan.senders.length,
678
+ listeners: scan.listeners.length,
679
+ edges: edges.length,
680
+ outOfScopeListeners: graph.outOfScopeListeners,
681
+ findings: findings.length,
682
+ info: info.length,
683
+ },
684
+ edges,
685
+ findings,
686
+ info,
687
+ exitCode,
688
+ }
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Human-readable report
693
+ // ---------------------------------------------------------------------------
694
+
695
+ function pad (s, n) {
696
+ s = String(s === undefined || s === null ? '' : s)
697
+ return s.length >= n ? s : s + ' '.repeat(n - s.length)
698
+ }
699
+
700
+ function day (iso) {
701
+ return iso ? String(iso).slice(0, 10) : '-'
702
+ }
703
+
704
+ function runsCell (summary) {
705
+ if (!summary) return 'unknown'
706
+ if (summary.total === 0) return 'never ran'
707
+ const parts = Object.entries(summary.byConclusion)
708
+ .sort((a, b) => b[1] - a[1])
709
+ .map(([k, v]) => `${v} ${k}`)
710
+ return parts.join(', ')
711
+ }
712
+
713
+ function formatReport (result) {
714
+ const lines = []
715
+ const c = result.counts
716
+ const roles = result.scanned.reduce((acc, s) => { acc[s.role] = (acc[s.role] || 0) + 1; return acc }, {})
717
+ lines.push(`Dispatch graph: ${c.repos} repos scanned (${roles.engineering || 0} engineering, ${roles.docs || 0} docs), ` +
718
+ `${c.senders} senders, ${c.listeners} listeners, ${c.edges} edges` +
719
+ (c.outOfScopeListeners ? ` (${c.outOfScopeListeners} engineering-side listeners with no docs sender left out).` : '.'))
720
+ lines.push(`Receiver fail rate is over the newest ${result.options.window} runs; flagged above ${Math.round(result.options.threshold * 100)}%.`)
721
+ lines.push('')
722
+
723
+ if (result.unreadable.length) {
724
+ lines.push(`✗ Could not read ${result.unreadable.length} thing(s); the graph below is incomplete:`)
725
+ for (const u of result.unreadable) {
726
+ lines.push(` - ${shortRepo(u.repo)}${u.file ? '/' + u.file : ''}: ${u.error}`)
727
+ }
728
+ lines.push(' A private repo needs a token that can read it (REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN or GH_TOKEN).')
729
+ lines.push('')
730
+ }
731
+
732
+ if (result.findings.length) {
733
+ lines.push(`✗ ${result.findings.length} finding(s):`)
734
+ for (const f of result.findings) {
735
+ lines.push(` [${f.kind}] ${f.message}`)
736
+ }
737
+ } else {
738
+ lines.push('✓ No findings: every dispatch has a listener, every docs listener has a sender or a cron, and no receiver is failing.')
739
+ }
740
+ lines.push('')
741
+
742
+ if (result.info.length) {
743
+ lines.push(`ℹ ${result.info.length} informational:`)
744
+ for (const i of result.info) lines.push(` [${i.kind}] ${i.message}`)
745
+ lines.push('')
746
+ }
747
+
748
+ // Edge table
749
+ const rows = []
750
+ for (const e of result.edges) {
751
+ const senders = e.senders.length ? e.senders : [null]
752
+ const listeners = e.listeners.length ? e.listeners : [null]
753
+ for (const s of senders) {
754
+ for (const l of listeners) {
755
+ rows.push({
756
+ sender: s ? `${shortRepo(s.repo)}/${s.file}${s.conditional ? ' [if]' : ''}` : '(none)',
757
+ senderRuns: s ? (s.runs ? (s.runs.total === 0 ? 'never ran' : `${s.runs.total}, last ${day(s.runs.lastRunAt)}`) : 'unknown') : '',
758
+ event: e.eventType,
759
+ receiver: l ? `${shortRepo(l.repo)}/${l.file}` : `(none in ${shortRepo(e.targetRepo)})`,
760
+ receiverRuns: l ? runsCell(l.runs) : '',
761
+ fail: l && l.runs ? formatRate(l.runs.window.failRate) : '',
762
+ last: l && l.runs ? day(l.runs.lastRunAt) : '',
763
+ cron: l && l.cronBackstop ? 'cron' : '',
764
+ })
765
+ }
766
+ }
767
+ }
768
+ const cols = [
769
+ ['sender', 'SENDER'], ['senderRuns', 'SENDER RUNS'], ['event', 'EVENT'], ['receiver', 'RECEIVER'],
770
+ ['receiverRuns', 'RECEIVER RUNS'], ['fail', 'FAIL'], ['last', 'LAST RUN'], ['cron', ''],
771
+ ]
772
+ const widths = cols.map(([k, h]) => Math.max(h.length, ...rows.map((r) => String(r[k]).length)))
773
+ lines.push('Edges:')
774
+ lines.push(' ' + cols.map(([, h], i) => pad(h, widths[i])).join(' ').trimEnd())
775
+ for (const r of rows) {
776
+ lines.push(' ' + cols.map(([k], i) => pad(r[k], widths[i])).join(' ').trimEnd())
777
+ }
778
+ return lines.join('\n')
779
+ }
780
+
781
+ module.exports = {
782
+ DEFAULT_REPOS,
783
+ SENTINEL,
784
+ createGitHubApi,
785
+ scanRepos,
786
+ buildGraph,
787
+ recordRuns,
788
+ runCheck,
789
+ formatReport,
790
+ }