@redpanda-data/docs-extensions-and-macros 5.54.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.
Files changed (87) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +354 -9
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/docs-links-to-xrefs.js +174 -0
  7. package/cli-utils/format-helm-spec.js +4 -3
  8. package/cli-utils/generated-marker.js +43 -0
  9. package/cli-utils/helm-commented-values.js +125 -21
  10. package/cli-utils/self-managed-docs-branch.js +36 -30
  11. package/extension-utils/html-utils.js +27 -0
  12. package/extension-utils/llms-utils.js +6 -2
  13. package/extension-utils/metadata-candidates.js +304 -0
  14. package/extension-utils/metadata-check.js +174 -0
  15. package/extension-utils/metadata-suggest.js +573 -0
  16. package/extension-utils/taxonomy-categories.js +106 -0
  17. package/extension-utils/taxonomy.js +566 -0
  18. package/extensions/REFERENCE.adoc +110 -8
  19. package/extensions/add-git-dates.js +13 -1
  20. package/extensions/aggregate-terms.js +34 -20
  21. package/extensions/algolia-indexer/generate-index.js +13 -1
  22. package/extensions/algolia-indexer/index.js +4 -2
  23. package/extensions/convert-sitemap-to-markdown.js +47 -12
  24. package/extensions/convert-to-markdown.js +176 -0
  25. package/extensions/generate-fields-only-pages.js +14 -0
  26. package/extensions/modify-connect-tag-playbook.js +181 -3
  27. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  28. package/extensions/url-to-xref.js +92 -22
  29. package/extensions/util/connect-docs-asset.js +6 -5
  30. package/extensions/validate-attributes.js +10 -1
  31. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  32. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  33. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  34. package/extensions/version-fetcher/release-lines.js +64 -0
  35. package/extensions/version-fetcher/set-latest-version.js +73 -3
  36. package/macros/README.adoc +1 -0
  37. package/macros/REFERENCE.adoc +99 -0
  38. package/macros/badge.js +5 -8
  39. package/macros/enterprise.js +2 -5
  40. package/macros/glossary.js +4 -14
  41. package/macros/iceberg-explorer.js +242 -0
  42. package/macros/prop.js +3 -13
  43. package/mcp/USER_GUIDE.adoc +25 -135
  44. package/package.json +5 -2
  45. package/tools/check-metadata/index.js +523 -0
  46. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  47. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  48. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  49. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  50. package/tools/dispatch-graph/classify-runs.js +128 -0
  51. package/tools/dispatch-graph/parse-workflows.js +499 -0
  52. package/tools/doc-impact-recording/index.js +190 -0
  53. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  54. package/tools/enterprise-features/verify.js +2 -1
  55. package/tools/gen-rpk-ascii.py +7 -1
  56. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  57. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  58. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  59. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  60. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  61. package/tools/lint-strings/index.js +133 -3
  62. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  63. package/tools/lint-strings/surfaces/connect.js +72 -12
  64. package/tools/lint-strings/surfaces/crd.js +10 -6
  65. package/tools/metrics/metrics.py +11 -0
  66. package/tools/overrides-audit/README.adoc +1 -2
  67. package/tools/overrides-audit/adapters/properties.js +1 -1
  68. package/tools/overrides-audit/index.js +3 -4
  69. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  70. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  71. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  72. package/tools/redpanda-connect/README.adoc +5 -33
  73. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
  74. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  75. package/tools/redpanda-connect/report-delta.js +6 -3
  76. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
  77. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  78. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  79. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  80. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  81. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  82. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  83. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  84. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  85. package/tools/rpk-docs/helpers/index.js +4 -1
  86. package/tools/rpk-docs/templates/command.hbs +2 -2
  87. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -0,0 +1,128 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Turns a workflow's recent run list (GET /repos/{r}/actions/workflows/{file}/runs)
5
+ * into the few numbers the dispatch-graph report needs, and says whether they
6
+ * amount to a finding.
7
+ *
8
+ * The run list is the only place a broken receiver shows up. A dispatch is
9
+ * accepted with 204 whether or not anyone listens, and a receiver that fails
10
+ * every time looks, from the sender's side, exactly like one that works every
11
+ * time. So "48 of 48 failed at Commit changes" is invisible until something
12
+ * reads the receiver's runs, which is what this does.
13
+ */
14
+
15
+ const FAILURE_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure', 'action_required'])
16
+ // Not counted either way: the run did not get as far as doing the work.
17
+ const NEUTRAL_CONCLUSIONS = new Set(['skipped', 'cancelled', 'neutral', 'stale'])
18
+
19
+ /**
20
+ * @typedef {object} RunSummary
21
+ * @property {number} total - runs the API returned (capped by per_page)
22
+ * @property {Object<string, number>} byConclusion
23
+ * @property {string|null} lastRunAt - ISO date of the newest run
24
+ * @property {string|null} lastConclusion
25
+ * @property {string|null} lastRunUrl
26
+ * @property {number} dispatchRuns - runs whose event was repository_dispatch
27
+ * @property {string|null} lastDispatchRunAt
28
+ * @property {{size: number, failures: number, successes: number, considered: number, failRate: number|null}} window
29
+ */
30
+
31
+ /**
32
+ * @param {Array<object>} runs - workflow_runs from the API, newest first
33
+ * @param {{window?: number}} [opts] - how many of the newest runs the fail rate is computed over (default 20)
34
+ * @returns {RunSummary}
35
+ */
36
+ function summarizeRuns (runs, opts = {}) {
37
+ const windowSize = opts.window || 20
38
+ const list = Array.isArray(runs) ? runs.slice() : []
39
+ // The API returns newest first; sort defensively in case a stub does not.
40
+ list.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
41
+
42
+ const byConclusion = {}
43
+ let dispatchRuns = 0
44
+ let lastDispatchRunAt = null
45
+ for (const r of list) {
46
+ const c = r.conclusion || (r.status === 'completed' ? 'unknown' : (r.status || 'in_progress'))
47
+ byConclusion[c] = (byConclusion[c] || 0) + 1
48
+ if (r.event === 'repository_dispatch') {
49
+ dispatchRuns += 1
50
+ if (!lastDispatchRunAt) lastDispatchRunAt = r.created_at || null
51
+ }
52
+ }
53
+
54
+ const recent = list.slice(0, windowSize)
55
+ let failures = 0
56
+ let successes = 0
57
+ let considered = 0
58
+ for (const r of recent) {
59
+ if (!r.conclusion || NEUTRAL_CONCLUSIONS.has(r.conclusion)) continue
60
+ considered += 1
61
+ if (FAILURE_CONCLUSIONS.has(r.conclusion)) failures += 1
62
+ else if (r.conclusion === 'success') successes += 1
63
+ }
64
+
65
+ const newest = list[0] || null
66
+ return {
67
+ total: list.length,
68
+ byConclusion,
69
+ lastRunAt: newest ? newest.created_at || null : null,
70
+ lastConclusion: newest ? newest.conclusion || newest.status || null : null,
71
+ lastRunUrl: newest ? newest.html_url || null : null,
72
+ dispatchRuns,
73
+ lastDispatchRunAt,
74
+ window: {
75
+ size: recent.length,
76
+ failures,
77
+ successes,
78
+ considered,
79
+ failRate: considered ? failures / considered : null,
80
+ },
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Is a receiver's run record a finding?
86
+ *
87
+ * @param {RunSummary} summary
88
+ * @param {{threshold?: number}} [opts] - fail rate above which the receiver is flagged (default 0.5, strictly greater)
89
+ * @returns {{status: 'healthy'|'failing'|'never-ran'|'no-dispatch-runs'|'quiet', failRate: number|null}}
90
+ * failing: more than `threshold` of the considered runs in the window failed
91
+ * never-ran: the workflow has no runs at all
92
+ * no-dispatch-runs: it has run, but never from a repository_dispatch (manual or cron only)
93
+ * quiet: runs exist but none in the window reached a conclusion to judge
94
+ */
95
+ function classifyReceiver (summary, opts = {}) {
96
+ const threshold = opts.threshold === undefined ? 0.5 : opts.threshold
97
+ if (!summary || summary.total === 0) return { status: 'never-ran', failRate: null }
98
+ const { failRate } = summary.window
99
+ if (failRate !== null && failRate > threshold) return { status: 'failing', failRate }
100
+ if (summary.dispatchRuns === 0) return { status: 'no-dispatch-runs', failRate }
101
+ if (failRate === null) return { status: 'quiet', failRate }
102
+ return { status: 'healthy', failRate }
103
+ }
104
+
105
+ /**
106
+ * Is a sender's run record a finding? A sender that has never run has never
107
+ * dispatched, whatever its workflow file says.
108
+ *
109
+ * @param {RunSummary} summary
110
+ * @returns {{status: 'fired'|'never-fired'}}
111
+ */
112
+ function classifySender (summary) {
113
+ if (!summary || summary.total === 0) return { status: 'never-fired' }
114
+ return { status: 'fired' }
115
+ }
116
+
117
+ function formatRate (rate) {
118
+ return rate === null || rate === undefined ? '-' : `${Math.round(rate * 100)}%`
119
+ }
120
+
121
+ module.exports = {
122
+ summarizeRuns,
123
+ classifyReceiver,
124
+ classifySender,
125
+ formatRate,
126
+ FAILURE_CONCLUSIONS,
127
+ NEUTRAL_CONCLUSIONS,
128
+ }
@@ -0,0 +1,499 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Reads the two halves of a repository_dispatch edge out of GitHub Actions
5
+ * workflow files: the listeners (`on.repository_dispatch.types`) and the
6
+ * senders (anything that POSTs to /repos/{owner}/{repo}/dispatches).
7
+ *
8
+ * Senders come in several shapes across the Redpanda org, and the check has
9
+ * to see every one of them or a live edge gets reported as a listener with no
10
+ * sender. The shapes supported, each with a fixture in
11
+ * __tests__/tools/dispatch-graph/fixtures/, are:
12
+ *
13
+ * - `uses: peter-evans/repository-dispatch@vN` with `with.repository` and
14
+ * `with.event-type` (streaming-enterprise, helm-charts, the operator).
15
+ * `repository` may be `${{ matrix.repo }}` over a literal matrix
16
+ * (docs-extensions-and-macros publish-to-npm), or `${{ env.X }}` with X
17
+ * set at workflow, job or step level.
18
+ * - `gh api repos/o/r/dispatches -f event_type=x` in a run block, one line
19
+ * or backslash-continued (connect, cloudv2, docs). Payload fields given as
20
+ * `-f 'client_payload[plugin]=ai'` are read too.
21
+ * - `jq -n ... '{event_type: "x", ...}' | gh api "repos/$VAR/dispatches"
22
+ * --input -` (doc-strings-review). The repo comes from the env chain, the
23
+ * event type from the only event_type literal in the same run block.
24
+ * - `curl ... https://api.github.com/repos/o/r/dispatches` with a JSON body
25
+ * carrying `"event_type": "x"`.
26
+ * - `github.rest.repos.createDispatchEvent({owner, repo, event_type})` in
27
+ * an actions/github-script step.
28
+ *
29
+ * Shell comment lines are stripped from run blocks first, and YAML comments
30
+ * never reach the parsed tree, so a dispatch that is only mentioned in a
31
+ * comment is not a sender. An event type or target that cannot be resolved
32
+ * from the same file (`${{ inputs.x }}`, `${{ secrets.x }}`, a value computed
33
+ * in an earlier step) is returned with `unresolved` set and the raw text kept,
34
+ * so the report can say "could not tell" rather than drop it.
35
+ */
36
+
37
+ const yaml = require('js-yaml')
38
+
39
+ const PETER_EVANS_RE = /^peter-evans\/repository-dispatch(?:@|$)/
40
+ const EXPR_RE = /\$\{\{\s*([^}]*?)\s*\}\}/g
41
+ const EVENT_TYPE_CHARS = '[A-Za-z0-9_.:-]+'
42
+
43
+ /**
44
+ * `on` is a YAML 1.1 boolean, so some parsers hand it back as the key `true`.
45
+ * js-yaml 4 keeps it as a string, but accept both so a schema change cannot
46
+ * silently make every workflow look trigger-less.
47
+ */
48
+ function triggersOf (doc) {
49
+ if (!doc || typeof doc !== 'object') return null
50
+ if (doc.on !== undefined) return doc.on
51
+ if (doc[true] !== undefined) return doc[true]
52
+ return null
53
+ }
54
+
55
+ function toArray (v) {
56
+ if (v === undefined || v === null) return []
57
+ return Array.isArray(v) ? v : [v]
58
+ }
59
+
60
+ function loadWorkflow (text) {
61
+ const doc = yaml.load(text)
62
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
63
+ throw new Error('not a workflow document (top level is not a mapping)')
64
+ }
65
+ return doc
66
+ }
67
+
68
+ /**
69
+ * @typedef {object} Listener
70
+ * @property {string} repo - owner/name
71
+ * @property {string} file - workflow file name
72
+ * @property {string} workflowName
73
+ * @property {string|null} eventType - null when the workflow listens for ANY repository_dispatch
74
+ * @property {boolean} cronBackstop - true when `on.schedule` is also set
75
+ * @property {Object<string, string[]>} choiceInputs - workflow_dispatch inputs of type choice, with their options
76
+ */
77
+
78
+ /**
79
+ * Listeners declared by one workflow file. Empty when the file has no
80
+ * repository_dispatch trigger.
81
+ *
82
+ * @param {string} text
83
+ * @param {{repo: string, file: string}} where
84
+ * @returns {Listener[]}
85
+ */
86
+ function parseListeners (text, where) {
87
+ const doc = loadWorkflow(text)
88
+ const on = triggersOf(doc)
89
+ if (on === null) return []
90
+
91
+ let types = null
92
+ let cronBackstop = false
93
+ let choiceInputs = {}
94
+
95
+ if (typeof on === 'string') {
96
+ if (on === 'repository_dispatch') types = []
97
+ } else if (Array.isArray(on)) {
98
+ if (on.includes('repository_dispatch')) types = []
99
+ cronBackstop = on.includes('schedule')
100
+ } else if (typeof on === 'object') {
101
+ if (Object.prototype.hasOwnProperty.call(on, 'repository_dispatch')) {
102
+ const rd = on.repository_dispatch
103
+ types = rd && typeof rd === 'object' ? toArray(rd.types).map(String) : []
104
+ }
105
+ cronBackstop = Object.prototype.hasOwnProperty.call(on, 'schedule')
106
+ const inputs = on.workflow_dispatch && on.workflow_dispatch.inputs
107
+ if (inputs && typeof inputs === 'object') {
108
+ for (const [name, spec] of Object.entries(inputs)) {
109
+ if (spec && spec.type === 'choice' && Array.isArray(spec.options)) {
110
+ choiceInputs[name] = spec.options.map(String)
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ if (types === null) return []
117
+
118
+ const base = {
119
+ repo: where.repo,
120
+ file: where.file,
121
+ workflowName: typeof doc.name === 'string' ? doc.name : where.file,
122
+ cronBackstop,
123
+ choiceInputs,
124
+ }
125
+ if (types.length === 0) return [{ ...base, eventType: null }]
126
+ return types.map((t) => ({ ...base, eventType: t }))
127
+ }
128
+
129
+ /**
130
+ * @typedef {object} Sender
131
+ * @property {string} repo - owner/name of the repo whose workflow sends
132
+ * @property {string} file
133
+ * @property {string} workflowName
134
+ * @property {string[]} triggers - top-level event names the sender fires on (release, push, ...)
135
+ * @property {string|null} targetRepo - owner/name, or null when unresolved
136
+ * @property {string|null} eventType - or null when unresolved
137
+ * @property {boolean} unresolved
138
+ * @property {string} form - which syntax it was found in
139
+ * @property {string} [raw] - the unresolved text, for the report
140
+ * @property {Object<string, string>} payload - literal client_payload values found beside the dispatch
141
+ * @property {string} [stepName]
142
+ * @property {boolean} conditional - the dispatch step, or its job, has an `if:`, so a run of the workflow does not mean it dispatched
143
+ * @property {string|null} condition - the `if:` text(s), job first, for the report
144
+ * @property {{jobKey: string, jobName: string, stepName: string, stepNumber: number}} stepRef - how to find the dispatch step in the jobs API
145
+ */
146
+
147
+ /** Trigger names a workflow runs on, for the never-fired hint. */
148
+ function triggerNames (on) {
149
+ if (typeof on === 'string') return [on]
150
+ if (Array.isArray(on)) return on.map(String)
151
+ if (on && typeof on === 'object') return Object.keys(on)
152
+ return []
153
+ }
154
+
155
+ /** Literal defaults of workflow_call and workflow_dispatch inputs. */
156
+ function inputDefaults (on) {
157
+ const out = {}
158
+ if (!on || typeof on !== 'object' || Array.isArray(on)) return out
159
+ for (const trigger of ['workflow_call', 'workflow_dispatch']) {
160
+ const inputs = on[trigger] && on[trigger].inputs
161
+ if (!inputs || typeof inputs !== 'object') continue
162
+ for (const [name, spec] of Object.entries(inputs)) {
163
+ if (spec && (typeof spec.default === 'string' || typeof spec.default === 'number')) {
164
+ out[name] = String(spec.default)
165
+ }
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ /** Flatten a strategy.matrix into { key: [literal values] }. */
172
+ function matrixValues (strategy) {
173
+ const out = {}
174
+ const matrix = strategy && strategy.matrix
175
+ if (!matrix || typeof matrix !== 'object') return out
176
+ for (const [k, v] of Object.entries(matrix)) {
177
+ if (k === 'include' || k === 'exclude') continue
178
+ if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string' || typeof x === 'number').map(String)
179
+ }
180
+ for (const inc of toArray(matrix.include)) {
181
+ if (!inc || typeof inc !== 'object') continue
182
+ for (const [k, v] of Object.entries(inc)) {
183
+ if (typeof v !== 'string' && typeof v !== 'number') continue
184
+ out[k] = out[k] || []
185
+ if (!out[k].includes(String(v))) out[k].push(String(v))
186
+ }
187
+ }
188
+ return out
189
+ }
190
+
191
+ function envChain (...levels) {
192
+ const out = {}
193
+ for (const lvl of levels) {
194
+ if (lvl && typeof lvl === 'object') {
195
+ for (const [k, v] of Object.entries(lvl)) {
196
+ if (typeof v === 'string' || typeof v === 'number') out[k] = String(v)
197
+ }
198
+ }
199
+ }
200
+ return out
201
+ }
202
+
203
+ /**
204
+ * Resolve a value that may carry `${{ }}` expressions or shell `$VAR`s.
205
+ * Returns an array of candidate literals: one entry normally, several when a
206
+ * matrix expands, empty when something could not be resolved.
207
+ *
208
+ * Only the forms a file can answer by itself are resolved: env.X from the env
209
+ * chain, matrix.X from a literal matrix, github.repository as the sender's
210
+ * own repo. Anything else (inputs, secrets, vars, step outputs) is unresolved.
211
+ */
212
+ function resolveValue (value, ctx) {
213
+ if (value === undefined || value === null) return []
214
+ let text = String(value).trim()
215
+ if (text === '') return []
216
+
217
+ // Shell-style references inside run blocks: $X, ${X}. An env value can
218
+ // itself be an expression (DISPATCH_REPO: ${{ inputs.dispatch_repo }}), so
219
+ // the expression pass below runs on the substituted text.
220
+ text = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, a, b) => {
221
+ const name = a || b
222
+ if (name === 'GITHUB_REPOSITORY') return ctx.selfRepo
223
+ return ctx.env[name] !== undefined ? ctx.env[name] : m
224
+ })
225
+
226
+ const variants = [text]
227
+ let unresolved = false
228
+ // Expand expressions; matrix refs fan out.
229
+ const expanded = []
230
+ for (const variant of variants) {
231
+ let results = [variant]
232
+ const exprs = [...variant.matchAll(EXPR_RE)]
233
+ for (const m of exprs) {
234
+ const expr = m[1]
235
+ let replacements = null
236
+ let em
237
+ if ((em = expr.match(/^env\.([A-Za-z_][A-Za-z0-9_]*)$/))) {
238
+ if (ctx.env[em[1]] !== undefined) replacements = [ctx.env[em[1]]]
239
+ } else if ((em = expr.match(/^matrix\.([A-Za-z_][A-Za-z0-9_-]*)$/))) {
240
+ if (ctx.matrix[em[1]] && ctx.matrix[em[1]].length) replacements = ctx.matrix[em[1]]
241
+ } else if (expr === 'github.repository') {
242
+ replacements = [ctx.selfRepo]
243
+ } else if ((em = expr.match(/^inputs\.([A-Za-z_][A-Za-z0-9_-]*)$/))) {
244
+ // A workflow_call/workflow_dispatch input with a literal default is
245
+ // what runs unless a caller overrides it, so the default is the edge.
246
+ if (ctx.inputDefaults[em[1]] !== undefined) replacements = [ctx.inputDefaults[em[1]]]
247
+ }
248
+ if (!replacements) { unresolved = true; break }
249
+ const next = []
250
+ for (const r of results) for (const rep of replacements) next.push(r.replace(m[0], rep))
251
+ results = next
252
+ }
253
+ if (!unresolved) expanded.push(...results)
254
+ }
255
+ if (unresolved) return []
256
+ // Anything still carrying a $ was a variable this file does not define.
257
+ return expanded.filter((s) => !/\$/.test(s)).map((s) => s.replace(/^["']|["']$/g, ''))
258
+ }
259
+
260
+ /** owner/name, or null when the text is not a plain repo slug. */
261
+ function normalizeRepo (slug) {
262
+ if (!slug) return null
263
+ const m = String(slug).trim().replace(/^https?:\/\/(api\.)?github\.com\/(repos\/)?/, '').match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/)
264
+ // GitHub slugs are case-insensitive, so `Redpanda-Data/Docs` and
265
+ // `redpanda-data/docs` are the same edge. Keys are always lowercase.
266
+ return m ? `${m[1]}/${m[2]}`.toLowerCase() : null
267
+ }
268
+
269
+ /** Literal `"key": "value"` pairs from a JSON-ish payload string. */
270
+ function literalPayloadPairs (text) {
271
+ const out = {}
272
+ if (!text) return out
273
+ for (const m of String(text).matchAll(/["']?([A-Za-z_][A-Za-z0-9_]*)["']?\s*:\s*(["'])([^"'$]*)\2/g)) {
274
+ if (m[1] === 'event_type' || m[1] === 'client_payload') continue
275
+ out[m[1]] = m[3]
276
+ }
277
+ return out
278
+ }
279
+
280
+ /** Join backslash-continued lines and drop shell comment lines. */
281
+ function shellLogicalLines (run) {
282
+ const lines = []
283
+ let buf = null
284
+ for (const raw of String(run).split('\n')) {
285
+ const line = raw.replace(/\r$/, '')
286
+ if (buf === null && /^\s*#/.test(line)) continue
287
+ if (buf === null) buf = line
288
+ else buf += ' ' + line.trim()
289
+ if (/\\\s*$/.test(buf)) { buf = buf.replace(/\s*\\\s*$/, ''); continue }
290
+ lines.push(buf)
291
+ buf = null
292
+ }
293
+ if (buf !== null) lines.push(buf)
294
+ return lines
295
+ }
296
+
297
+ // The repo part may be one shell variable (`repos/$DISPATCH_REPO/dispatches`)
298
+ // or two literal segments, so it is captured whole and resolved afterwards.
299
+ const DISPATCH_PATH_RE = /(?:https?:\/\/api\.github\.com\/)?\/?repos\/([^\s"']+?)\/dispatches\b/
300
+ const EVENT_TYPE_FLAG_RE = new RegExp(`(?:-f|-F|--field|--raw-field)[= ]+["']?event_type=["']?(${EVENT_TYPE_CHARS})`)
301
+ const EVENT_TYPE_JSON_RE = new RegExp(`["']?event_type["']?\\s*[:=]\\s*["'](${EVENT_TYPE_CHARS})["']`, 'g')
302
+ const PAYLOAD_FLAG_RE = /(?:-f|-F|--field|--raw-field)[= ]+["']?client_payload\[([A-Za-z_][A-Za-z0-9_]*)\]=["']?([^"'\s$]+)/g
303
+
304
+ function sendersFromRun (run, ctx, step) {
305
+ const senders = []
306
+ const cleaned = shellLogicalLines(run)
307
+ const block = cleaned.join('\n')
308
+ // Event types stated anywhere in the (comment-stripped) block, for the
309
+ // piped --input form where the type is not on the gh api line.
310
+ const blockTypes = [...new Set([...block.matchAll(EVENT_TYPE_JSON_RE)].map((m) => m[1]))]
311
+ const blockPayload = literalPayloadPairs(block)
312
+
313
+ for (const line of cleaned) {
314
+ const pm = line.match(DISPATCH_PATH_RE)
315
+ if (!pm) continue
316
+ const repos = resolveValue(pm[1], ctx).map(normalizeRepo).filter(Boolean)
317
+
318
+ let eventTypes = []
319
+ const fm = line.match(EVENT_TYPE_FLAG_RE)
320
+ if (fm) eventTypes = [fm[1]]
321
+ else {
322
+ const inline = [...line.matchAll(EVENT_TYPE_JSON_RE)].map((m) => m[1])
323
+ if (inline.length) eventTypes = [...new Set(inline)]
324
+ else if (blockTypes.length === 1) eventTypes = blockTypes
325
+ }
326
+
327
+ const payload = { ...blockPayload }
328
+ for (const m of line.matchAll(PAYLOAD_FLAG_RE)) payload[m[1]] = m[2]
329
+
330
+ const form = /curl/.test(line) ? 'curl' : 'gh-api'
331
+ if (repos.length === 0 || eventTypes.length === 0) {
332
+ senders.push(ctx.make({
333
+ targetRepo: repos[0] || null,
334
+ eventType: eventTypes[0] || null,
335
+ unresolved: true,
336
+ raw: line.trim(),
337
+ form,
338
+ payload,
339
+ stepName: step && step.name,
340
+ }))
341
+ continue
342
+ }
343
+ for (const r of repos) {
344
+ for (const t of eventTypes) {
345
+ senders.push(ctx.make({ targetRepo: r, eventType: t, unresolved: false, form, payload, stepName: step && step.name }))
346
+ }
347
+ }
348
+ }
349
+ return senders
350
+ }
351
+
352
+ const GITHUB_SCRIPT_DISPATCH_RE = /createDispatchEvent\s*\(\s*\{([\s\S]*?)\}\s*\)/g
353
+
354
+ function sendersFromGithubScript (script, ctx, step) {
355
+ const senders = []
356
+ const stripped = String(script).split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n')
357
+ for (const m of stripped.matchAll(GITHUB_SCRIPT_DISPATCH_RE)) {
358
+ const body = m[1]
359
+ const lit = (key) => {
360
+ const r = body.match(new RegExp(`\\b${key}\\s*:\\s*["'\`]([^"'\`$]+)["'\`]`))
361
+ return r ? r[1] : null
362
+ }
363
+ const owner = lit('owner')
364
+ const repo = lit('repo')
365
+ const eventType = lit('event_type')
366
+ const selfOwner = /\bowner\s*:\s*context\.repo\.owner\b/.test(body)
367
+ const selfRepo = /\brepo\s*:\s*context\.repo\.repo\b/.test(body)
368
+ let target = null
369
+ if (owner && repo) target = `${owner}/${repo}`
370
+ else if (selfOwner && repo) target = `${ctx.selfRepo.split('/')[0]}/${repo}`
371
+ else if (selfOwner && selfRepo) target = ctx.selfRepo
372
+ if (target) target = target.toLowerCase()
373
+ senders.push(ctx.make({
374
+ targetRepo: target,
375
+ eventType,
376
+ unresolved: !(target && eventType),
377
+ raw: target && eventType ? undefined : body.trim().replace(/\s+/g, ' ').slice(0, 160),
378
+ form: 'github-script',
379
+ payload: Object.fromEntries(Object.entries(literalPayloadPairs(body)).filter(([k]) => !['owner', 'repo'].includes(k))),
380
+ stepName: step && step.name,
381
+ }))
382
+ }
383
+ return senders
384
+ }
385
+
386
+ /** An `if:` value as text, or null when there is none. `if: true` is no condition. */
387
+ function ifText (v) {
388
+ if (v === undefined || v === null || v === true) return null
389
+ const t = String(v).trim()
390
+ if (t === '' || /^\$\{\{\s*true\s*\}\}$/.test(t)) return null
391
+ return t
392
+ }
393
+
394
+ /** The step name the jobs API reports: `name:`, else "Run <uses>" or "Run <first line of run>". */
395
+ function defaultStepName (step) {
396
+ if (typeof step.name === 'string' && step.name.trim()) return step.name.trim()
397
+ if (typeof step.uses === 'string') return `Run ${step.uses.trim()}`
398
+ if (typeof step.run === 'string') return `Run ${step.run.trim().split('\n')[0]}`
399
+ return ''
400
+ }
401
+
402
+ /**
403
+ * Senders declared by one workflow file.
404
+ *
405
+ * @param {string} text
406
+ * @param {{repo: string, file: string}} where
407
+ * @returns {Sender[]}
408
+ */
409
+ function parseSenders (text, where) {
410
+ const doc = loadWorkflow(text)
411
+ const on = triggersOf(doc)
412
+ const triggers = triggerNames(on)
413
+ const workflowName = typeof doc.name === 'string' ? doc.name : where.file
414
+ const senders = []
415
+ const jobs = doc.jobs && typeof doc.jobs === 'object' ? doc.jobs : {}
416
+ const defaults = inputDefaults(on)
417
+
418
+ for (const [jobKey, job] of Object.entries(jobs)) {
419
+ if (!job || typeof job !== 'object') continue
420
+ const matrix = matrixValues(job.strategy)
421
+ const steps = toArray(job.steps)
422
+ for (let i = 0; i < steps.length; i++) {
423
+ const step = steps[i]
424
+ if (!step || typeof step !== 'object') continue
425
+ const env = envChain(doc.env, job.env, step.env)
426
+ const conditions = [ifText(job.if), ifText(step.if)].filter(Boolean)
427
+ const stepRef = {
428
+ jobKey,
429
+ jobName: typeof job.name === 'string' ? job.name : jobKey,
430
+ stepName: defaultStepName(step),
431
+ // The jobs API numbers "Set up job" as 1, so the workflow's first step is 2.
432
+ stepNumber: i + 2,
433
+ }
434
+ const ctx = {
435
+ env,
436
+ matrix,
437
+ inputDefaults: defaults,
438
+ selfRepo: String(where.repo).toLowerCase(),
439
+ make: (fields) => ({
440
+ repo: where.repo,
441
+ file: where.file,
442
+ workflowName,
443
+ triggers,
444
+ payload: {},
445
+ conditional: conditions.length > 0,
446
+ condition: conditions.length ? conditions.join(' && ') : null,
447
+ stepRef,
448
+ ...fields,
449
+ }),
450
+ }
451
+
452
+ if (typeof step.uses === 'string' && PETER_EVANS_RE.test(step.uses.trim())) {
453
+ const w = step.with && typeof step.with === 'object' ? step.with : {}
454
+ // peter-evans defaults `repository` to the workflow's own repo.
455
+ const repoCandidates = w.repository === undefined ? [ctx.selfRepo] : resolveValue(w.repository, ctx).map(normalizeRepo).filter(Boolean)
456
+ const typeCandidates = resolveValue(w['event-type'], ctx)
457
+ const payload = literalPayloadPairs(w['client-payload'])
458
+ if (repoCandidates.length === 0 || typeCandidates.length === 0) {
459
+ senders.push(ctx.make({
460
+ targetRepo: repoCandidates[0] || null,
461
+ eventType: typeCandidates[0] || null,
462
+ unresolved: true,
463
+ raw: `repository: ${w.repository === undefined ? '(default)' : w.repository}, event-type: ${w['event-type']}`,
464
+ form: 'peter-evans',
465
+ payload,
466
+ stepName: step.name,
467
+ }))
468
+ continue
469
+ }
470
+ for (const r of repoCandidates) {
471
+ for (const t of typeCandidates) {
472
+ senders.push(ctx.make({ targetRepo: r, eventType: t, unresolved: false, form: 'peter-evans', payload, stepName: step.name }))
473
+ }
474
+ }
475
+ continue
476
+ }
477
+
478
+ if (typeof step.run === 'string' && /\/dispatches\b/.test(step.run)) {
479
+ senders.push(...sendersFromRun(step.run, ctx, step))
480
+ }
481
+
482
+ if (typeof step.uses === 'string' && /^actions\/github-script(?:@|$)/.test(step.uses.trim()) &&
483
+ step.with && typeof step.with.script === 'string' && /createDispatchEvent/.test(step.with.script)) {
484
+ senders.push(...sendersFromGithubScript(step.with.script, ctx, step))
485
+ }
486
+ }
487
+ }
488
+ return senders
489
+ }
490
+
491
+ module.exports = {
492
+ parseListeners,
493
+ parseSenders,
494
+ resolveValue,
495
+ normalizeRepo,
496
+ shellLogicalLines,
497
+ literalPayloadPairs,
498
+ triggersOf,
499
+ }