@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.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 +315 -7
- package/cli-utils/docs-links-to-xrefs.js +174 -0
- package/cli-utils/generated-marker.js +43 -0
- package/cli-utils/helm-commented-values.js +37 -3
- package/extension-utils/llms-utils.js +6 -2
- package/extension-utils/metadata-candidates.js +304 -0
- package/extension-utils/metadata-check.js +174 -0
- package/extension-utils/metadata-suggest.js +573 -0
- package/extension-utils/taxonomy-categories.js +106 -0
- package/extension-utils/taxonomy.js +566 -0
- package/extensions/REFERENCE.adoc +90 -4
- package/extensions/algolia-indexer/generate-index.js +13 -1
- package/extensions/algolia-indexer/index.js +4 -2
- package/extensions/convert-sitemap-to-markdown.js +47 -12
- package/extensions/convert-to-markdown.js +176 -0
- package/extensions/generate-fields-only-pages.js +14 -0
- package/extensions/modify-connect-tag-playbook.js +2 -2
- package/extensions/url-to-xref.js +92 -22
- package/extensions/validate-attributes.js +10 -1
- package/package.json +2 -1
- package/tools/check-metadata/index.js +523 -0
- package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
- package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
- package/tools/cloud-regions/render-cloud-regions.js +3 -0
- package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
- package/tools/dispatch-graph/classify-runs.js +128 -0
- package/tools/dispatch-graph/parse-workflows.js +499 -0
- package/tools/enterprise-features/detect-gating-changes.js +393 -0
- package/tools/enterprise-features/verify.js +2 -1
- package/tools/gen-rpk-ascii.py +7 -1
- package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
- package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
- package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
- package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
- package/tools/lint-strings/index.js +133 -3
- package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
- package/tools/lint-strings/surfaces/connect.js +72 -12
- package/tools/lint-strings/surfaces/crd.js +10 -6
- package/tools/metrics/metrics.py +11 -0
- package/tools/property-extractor/generate-handlebars-docs.js +9 -1
- package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
- package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
- package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
- package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
- package/tools/rpk-docs/generate-rpk-docs.js +2 -0
- package/tools/rpk-docs/generate-x-env-partial.js +7 -4
- package/tools/rpk-docs/helpers/index.js +4 -1
- 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
|
+
}
|