@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.
- package/bin/doc-tools.js +323 -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 +440 -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,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
|
+
}
|