@edgehero/pi-dispatch-receiver 0.2.0 → 1.0.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/package.json +2 -2
- package/src/config.mjs +16 -8
- package/src/filter-azure.mjs +27 -11
- package/src/filter-forgejo.mjs +29 -13
- package/src/filter-gitlab.mjs +27 -11
- package/src/filter.mjs +31 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@octokit/webhooks": "14.2.0",
|
|
48
|
-
"@edgehero/pi-dispatch": "^0.
|
|
48
|
+
"@edgehero/pi-dispatch": "^1.0.0",
|
|
49
49
|
"bullmq": "5.80.4",
|
|
50
50
|
"ioredis": "5.11.1"
|
|
51
51
|
}
|
package/src/config.mjs
CHANGED
|
@@ -226,11 +226,13 @@ function loadAzureConfig(env) {
|
|
|
226
226
|
* The shared `parseTriggers` validates the WHOLE file (including the on x run matrix and cron entries the
|
|
227
227
|
* worker owns); this loader keeps only the webhook types and groups them PER FORGE, so `cfg.triggers` is
|
|
228
228
|
* `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
|
|
229
|
-
* - `label`: ordered `{ index, predicate, flow, packages, image }` rules (first match wins in the filter).
|
|
230
|
-
* - `comment`: the single `{ index, phrase, defaultFlow, packages, image }` (or null when no comment trigger is configured).
|
|
231
|
-
* - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, packages, image }` rules.
|
|
229
|
+
* - `label`: ordered `{ index, predicate, flow, command, packages, image }` rules (first match wins in the filter).
|
|
230
|
+
* - `comment`: the single `{ index, phrase, defaultFlow, command, packages, image }` (or null when no comment trigger is configured).
|
|
231
|
+
* - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules.
|
|
232
232
|
* and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
|
|
233
|
-
* unlisted flow.
|
|
233
|
+
* unlisted flow. A rule carries EITHER `flow` or `command` (issue #189; the shared parser enforces the
|
|
234
|
+
* exclusivity): a command rule's match dispatches a registered pi extension command in the container, so
|
|
235
|
+
* it resolves no flow and contributes nothing to the flow vocabulary.
|
|
234
236
|
*
|
|
235
237
|
* Grouping by forge FIRST is what keeps each forge's gate reading only its own rules: a GitLab delivery
|
|
236
238
|
* can never match a rule an operator wrote for GitHub, even when both name the same label. `knownFlows`
|
|
@@ -272,16 +274,22 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
272
274
|
|
|
273
275
|
for (const [index, { on, run }] of parsed.entries()) {
|
|
274
276
|
if (on.type === "cron") continue; // the worker owns cron; the receiver never fires it -- but it keeps its index
|
|
275
|
-
|
|
277
|
+
// Guarded because a command trigger (issue #189) has NO run.flow, and `Set.add(undefined)` would put
|
|
278
|
+
// undefined into the comment `<phrase> <flow>` override allowlist. That set exists to bound which
|
|
279
|
+
// names a collaborator's comment may summon; polluting it with a non-name is how a later loose
|
|
280
|
+
// membership test (or a `[...set]` enumeration in a message) starts treating "no flow" as a flow.
|
|
281
|
+
if (typeof run.flow === "string") {
|
|
282
|
+
knownFlows.add(run.flow);
|
|
283
|
+
}
|
|
276
284
|
const group = groups[run.kind];
|
|
277
285
|
if (on.type === "label") {
|
|
278
|
-
group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
|
|
286
|
+
group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
|
|
279
287
|
} else if (on.type === "comment") {
|
|
280
|
-
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
288
|
+
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
281
289
|
} else if (on.type === "pull_request") {
|
|
282
290
|
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
283
291
|
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
284
|
-
group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
|
|
292
|
+
group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
|
|
285
293
|
}
|
|
286
294
|
}
|
|
287
295
|
|
package/src/filter-azure.mjs
CHANGED
|
@@ -102,7 +102,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
102
102
|
// gitlab makes when it carries `projectId` next to `repo`.
|
|
103
103
|
azure: resolved.azure,
|
|
104
104
|
target: resolved.target,
|
|
105
|
-
|
|
105
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
106
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
107
|
+
// resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
|
|
108
|
+
// literal byte-identical to what it always was -- the same move filter.mjs makes.
|
|
109
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
106
110
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
107
111
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
108
112
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
@@ -167,7 +171,8 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
167
171
|
return {
|
|
168
172
|
enqueue: true,
|
|
169
173
|
action,
|
|
170
|
-
flow:
|
|
174
|
+
// A command rule (issue #189) skips flow resolution entirely: the tag match IS the dispatch.
|
|
175
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
171
176
|
repository: rule.repository,
|
|
172
177
|
packages: rule.packages,
|
|
173
178
|
image: rule.image,
|
|
@@ -186,18 +191,28 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
186
191
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
187
192
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
188
193
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
195
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
196
|
+
// fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
197
|
+
// active override would hand any authorized member two levers the trigger's author never granted:
|
|
198
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
199
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
200
|
+
const command = triggers.comment.command;
|
|
201
|
+
let flow;
|
|
202
|
+
if (command === undefined) {
|
|
203
|
+
flow = triggers.comment?.defaultFlow;
|
|
204
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
205
|
+
if (match && knownFlows?.has(match[1])) {
|
|
206
|
+
flow = match[1];
|
|
207
|
+
}
|
|
208
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
209
|
+
return { enqueue: false, reason: "no-flow" };
|
|
210
|
+
}
|
|
196
211
|
}
|
|
197
212
|
return {
|
|
198
213
|
enqueue: true,
|
|
199
214
|
action: "commented",
|
|
200
|
-
flow,
|
|
215
|
+
...(command !== undefined ? { command } : { flow }),
|
|
201
216
|
repository: triggers.comment.repository,
|
|
202
217
|
packages: triggers.comment.packages,
|
|
203
218
|
image: triggers.comment.image,
|
|
@@ -225,7 +240,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
225
240
|
return {
|
|
226
241
|
enqueue: true,
|
|
227
242
|
action,
|
|
228
|
-
flow: rule.
|
|
243
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
244
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
229
245
|
repository: rule.repository,
|
|
230
246
|
packages: rule.packages,
|
|
231
247
|
image: rule.image,
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -104,7 +104,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
104
104
|
const job = {
|
|
105
105
|
repo: subset.repository?.full_name,
|
|
106
106
|
target: resolved.target,
|
|
107
|
-
|
|
107
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
108
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
109
|
+
// resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
|
|
110
|
+
// literal byte-identical to what it always was -- the same move filter.mjs makes.
|
|
111
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
108
112
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
109
113
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
110
114
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
@@ -136,7 +140,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
136
140
|
}
|
|
137
141
|
return {
|
|
138
142
|
enqueue: true,
|
|
139
|
-
flow:
|
|
143
|
+
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
144
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
140
145
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
141
146
|
image: rule.image,
|
|
142
147
|
skillsDir: rule.skillsDir,
|
|
@@ -154,15 +159,25 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
154
159
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
155
160
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
156
161
|
}
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
163
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
164
|
+
// fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
165
|
+
// active override would hand any authorized collaborator two levers the trigger's author never granted:
|
|
166
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
167
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
168
|
+
const command = triggers.comment.command;
|
|
169
|
+
let flow;
|
|
170
|
+
if (command === undefined) {
|
|
171
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
|
|
172
|
+
// flow name, so a comment cannot summon an unlisted flow.
|
|
173
|
+
flow = triggers.comment?.defaultFlow;
|
|
174
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
175
|
+
if (match && knownFlows?.has(match[1])) {
|
|
176
|
+
flow = match[1];
|
|
177
|
+
}
|
|
178
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
179
|
+
return { enqueue: false, reason: "no-flow" };
|
|
180
|
+
}
|
|
166
181
|
}
|
|
167
182
|
// `is_pull` is TOP-LEVEL on Forgejo, where GitHub carries `issue.pull_request`. Reading the wrong one
|
|
168
183
|
// routes every pull-request comment as an issue, and the envelope then tells the agent to open
|
|
@@ -171,7 +186,7 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
171
186
|
const isPR = subset.isPull === true;
|
|
172
187
|
return {
|
|
173
188
|
enqueue: true,
|
|
174
|
-
flow,
|
|
189
|
+
...(command !== undefined ? { command } : { flow }),
|
|
175
190
|
packages: triggers.comment.packages,
|
|
176
191
|
image: triggers.comment.image,
|
|
177
192
|
skillsDir: triggers.comment.skillsDir,
|
|
@@ -197,7 +212,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
197
212
|
if (rule.predicate && !matchesRule(L, rule.predicate)) continue;
|
|
198
213
|
return {
|
|
199
214
|
enqueue: true,
|
|
200
|
-
flow: rule.
|
|
215
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
216
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
201
217
|
packages: rule.packages,
|
|
202
218
|
image: rule.image,
|
|
203
219
|
skillsDir: rule.skillsDir,
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -95,7 +95,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
95
95
|
repo: subset.project?.path,
|
|
96
96
|
projectId: subset.project?.id,
|
|
97
97
|
target: resolved.target,
|
|
98
|
-
|
|
98
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
99
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
100
|
+
// resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
|
|
101
|
+
// literal byte-identical to what it always was -- the same move filter.mjs makes.
|
|
102
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
99
103
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
100
104
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
101
105
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
@@ -134,7 +138,8 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
134
138
|
if (!rule) return { enqueue: false, reason: "no-allowlisted-label" };
|
|
135
139
|
return {
|
|
136
140
|
enqueue: true,
|
|
137
|
-
flow:
|
|
141
|
+
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
142
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
138
143
|
packages: rule.packages,
|
|
139
144
|
image: rule.image,
|
|
140
145
|
skillsDir: rule.skillsDir,
|
|
@@ -193,7 +198,8 @@ function routeMergeRequest(subset, triggers) {
|
|
|
193
198
|
function mrResult(subset, rule, matched) {
|
|
194
199
|
return {
|
|
195
200
|
enqueue: true,
|
|
196
|
-
flow: rule.
|
|
201
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
202
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
197
203
|
packages: rule.packages,
|
|
198
204
|
image: rule.image,
|
|
199
205
|
skillsDir: rule.skillsDir,
|
|
@@ -218,18 +224,28 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
218
224
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
219
225
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
220
226
|
}
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
228
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
229
|
+
// fires, and trailing note text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
230
|
+
// active override would hand any authorized member two levers the trigger's author never granted:
|
|
231
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
232
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
233
|
+
const command = triggers.comment.command;
|
|
234
|
+
let flow;
|
|
235
|
+
if (command === undefined) {
|
|
236
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
|
|
237
|
+
// known flow name, so a comment cannot summon an unlisted flow.
|
|
238
|
+
flow = triggers.comment?.defaultFlow;
|
|
239
|
+
const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
|
|
240
|
+
if (match && knownFlows?.has(match[1])) flow = match[1];
|
|
241
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
242
|
+
return { enqueue: false, reason: "no-flow" };
|
|
243
|
+
}
|
|
228
244
|
}
|
|
229
245
|
const targetType = subset.noteableType === "MergeRequest" ? "pull_request" : "issue";
|
|
230
246
|
return {
|
|
231
247
|
enqueue: true,
|
|
232
|
-
flow,
|
|
248
|
+
...(command !== undefined ? { command } : { flow }),
|
|
233
249
|
packages: triggers.comment.packages,
|
|
234
250
|
image: triggers.comment.image,
|
|
235
251
|
skillsDir: triggers.comment.skillsDir,
|
package/src/filter.mjs
CHANGED
|
@@ -108,7 +108,12 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
108
108
|
const job = {
|
|
109
109
|
repo: subset.repository?.full_name,
|
|
110
110
|
target: resolved.target,
|
|
111
|
-
|
|
111
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
112
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
113
|
+
// resolving a flow, and its job must carry NO flow key at all -- a present-and-undefined flow would
|
|
114
|
+
// change the enqueued bytes for every consumer that serializes the job. The spread keeps a flow
|
|
115
|
+
// rule's literal byte-identical to what it always was.
|
|
116
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
112
117
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
113
118
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
114
119
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
@@ -156,7 +161,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
156
161
|
}
|
|
157
162
|
return {
|
|
158
163
|
enqueue: true,
|
|
159
|
-
flow:
|
|
164
|
+
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
165
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
160
166
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
161
167
|
image: rule.image,
|
|
162
168
|
skillsDir: rule.skillsDir,
|
|
@@ -183,15 +189,26 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
183
189
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
184
190
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
185
191
|
}
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
// On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
|
|
193
|
+
// <flow>` trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase
|
|
194
|
+
// alone fires the command, and everything after it is comment DATA (the handler can read the full
|
|
195
|
+
// delivery via /job/event.json), never a flow lookup. Routing trailing words through the override
|
|
196
|
+
// would hand any collaborator two levers this trigger's author never granted: retarget the command
|
|
197
|
+
// trigger onto a known flow by appending its name, or suppress it outright (the `no-flow` arm) with a
|
|
198
|
+
// word that resolves nowhere. Which flows exist is the operator's file's business, not the commenter's.
|
|
199
|
+
const command = triggers.comment.command;
|
|
200
|
+
let flow;
|
|
201
|
+
if (command === undefined) {
|
|
202
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
|
|
203
|
+
// known flow name, so a comment cannot summon an unlisted flow.
|
|
204
|
+
flow = triggers.comment.defaultFlow;
|
|
205
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
206
|
+
if (match && knownFlows?.has(match[1])) {
|
|
207
|
+
flow = match[1];
|
|
208
|
+
}
|
|
209
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
210
|
+
return { enqueue: false, reason: "no-flow" };
|
|
211
|
+
}
|
|
195
212
|
}
|
|
196
213
|
// An issue_comment on a PR carries issue.pull_request; its issue.number IS the PR number and the
|
|
197
214
|
// issue title/body are the PR's. Route it as a pull_request target so the flow gets PR context and
|
|
@@ -200,7 +217,7 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
200
217
|
const isPR = subset.issue?.pull_request === true;
|
|
201
218
|
return {
|
|
202
219
|
enqueue: true,
|
|
203
|
-
flow,
|
|
220
|
+
...(command !== undefined ? { command } : { flow }),
|
|
204
221
|
// The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
|
|
205
222
|
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
206
223
|
packages: triggers.comment.packages,
|
|
@@ -296,7 +313,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
296
313
|
}
|
|
297
314
|
return {
|
|
298
315
|
enqueue: true,
|
|
299
|
-
flow: rule.
|
|
316
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
317
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
300
318
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
301
319
|
image: rule.image,
|
|
302
320
|
skillsDir: rule.skillsDir,
|