@edgehero/pi-dispatch-receiver 0.2.1 → 1.1.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 +34 -11
- package/src/filter-forgejo.mjs +36 -13
- package/src/filter-gitlab.mjs +34 -11
- package/src/filter.mjs +31 -13
- package/src/poller.mjs +4 -1
- package/src/receiver.mjs +45 -20
- package/src/start.mjs +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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
|
|
@@ -112,6 +116,10 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
112
116
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
113
117
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
114
118
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
119
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
120
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
121
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
122
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
115
123
|
trigger: {
|
|
116
124
|
event,
|
|
117
125
|
action: resolved.action,
|
|
@@ -167,13 +175,15 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
167
175
|
return {
|
|
168
176
|
enqueue: true,
|
|
169
177
|
action,
|
|
170
|
-
flow:
|
|
178
|
+
// A command rule (issue #189) skips flow resolution entirely: the tag match IS the dispatch.
|
|
179
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
171
180
|
repository: rule.repository,
|
|
172
181
|
packages: rule.packages,
|
|
173
182
|
image: rule.image,
|
|
174
183
|
skillsDir: rule.skillsDir,
|
|
175
184
|
instructions: rule.instructions,
|
|
176
185
|
resume: rule.resume,
|
|
186
|
+
replicas: rule.replicas,
|
|
177
187
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
178
188
|
target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
|
|
179
189
|
};
|
|
@@ -186,24 +196,35 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
186
196
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
187
197
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
188
198
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
200
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
201
|
+
// fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
202
|
+
// active override would hand any authorized member two levers the trigger's author never granted:
|
|
203
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
204
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
205
|
+
const command = triggers.comment.command;
|
|
206
|
+
let flow;
|
|
207
|
+
if (command === undefined) {
|
|
208
|
+
flow = triggers.comment?.defaultFlow;
|
|
209
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
210
|
+
if (match && knownFlows?.has(match[1])) {
|
|
211
|
+
flow = match[1];
|
|
212
|
+
}
|
|
213
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
214
|
+
return { enqueue: false, reason: "no-flow" };
|
|
215
|
+
}
|
|
196
216
|
}
|
|
197
217
|
return {
|
|
198
218
|
enqueue: true,
|
|
199
219
|
action: "commented",
|
|
200
|
-
flow,
|
|
220
|
+
...(command !== undefined ? { command } : { flow }),
|
|
201
221
|
repository: triggers.comment.repository,
|
|
202
222
|
packages: triggers.comment.packages,
|
|
203
223
|
image: triggers.comment.image,
|
|
204
224
|
skillsDir: triggers.comment.skillsDir,
|
|
205
225
|
instructions: triggers.comment.instructions,
|
|
206
226
|
resume: triggers.comment.resume,
|
|
227
|
+
replicas: triggers.comment.replicas,
|
|
207
228
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
208
229
|
// No author_association: Azure has none, and the authority that admitted this comment was resolved
|
|
209
230
|
// from the graph, not read off the body.
|
|
@@ -225,13 +246,15 @@ function routePullRequest(subset, triggers, action) {
|
|
|
225
246
|
return {
|
|
226
247
|
enqueue: true,
|
|
227
248
|
action,
|
|
228
|
-
flow: rule.
|
|
249
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
250
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
229
251
|
repository: rule.repository,
|
|
230
252
|
packages: rule.packages,
|
|
231
253
|
image: rule.image,
|
|
232
254
|
skillsDir: rule.skillsDir,
|
|
233
255
|
instructions: rule.instructions,
|
|
234
256
|
resume: rule.resume,
|
|
257
|
+
replicas: rule.replicas,
|
|
235
258
|
matched: { index: rule.index, type: "pull_request", action },
|
|
236
259
|
target: {
|
|
237
260
|
type: "pull_request",
|
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
|
|
@@ -114,6 +118,10 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
114
118
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
115
119
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
116
120
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
121
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
122
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
123
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
124
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
117
125
|
trigger: {
|
|
118
126
|
event: eventName,
|
|
119
127
|
// Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
|
|
@@ -136,12 +144,14 @@ function routeIssueLabel(subset, triggers) {
|
|
|
136
144
|
}
|
|
137
145
|
return {
|
|
138
146
|
enqueue: true,
|
|
139
|
-
flow:
|
|
147
|
+
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
148
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
140
149
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
141
150
|
image: rule.image,
|
|
142
151
|
skillsDir: rule.skillsDir,
|
|
143
152
|
instructions: rule.instructions,
|
|
144
153
|
resume: rule.resume,
|
|
154
|
+
replicas: rule.replicas,
|
|
145
155
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
146
156
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
147
157
|
};
|
|
@@ -154,15 +164,25 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
154
164
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
155
165
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
156
166
|
}
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
168
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
169
|
+
// fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
170
|
+
// active override would hand any authorized collaborator two levers the trigger's author never granted:
|
|
171
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
172
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
173
|
+
const command = triggers.comment.command;
|
|
174
|
+
let flow;
|
|
175
|
+
if (command === undefined) {
|
|
176
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
|
|
177
|
+
// flow name, so a comment cannot summon an unlisted flow.
|
|
178
|
+
flow = triggers.comment?.defaultFlow;
|
|
179
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
180
|
+
if (match && knownFlows?.has(match[1])) {
|
|
181
|
+
flow = match[1];
|
|
182
|
+
}
|
|
183
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
184
|
+
return { enqueue: false, reason: "no-flow" };
|
|
185
|
+
}
|
|
166
186
|
}
|
|
167
187
|
// `is_pull` is TOP-LEVEL on Forgejo, where GitHub carries `issue.pull_request`. Reading the wrong one
|
|
168
188
|
// routes every pull-request comment as an issue, and the envelope then tells the agent to open
|
|
@@ -171,12 +191,13 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
171
191
|
const isPR = subset.isPull === true;
|
|
172
192
|
return {
|
|
173
193
|
enqueue: true,
|
|
174
|
-
flow,
|
|
194
|
+
...(command !== undefined ? { command } : { flow }),
|
|
175
195
|
packages: triggers.comment.packages,
|
|
176
196
|
image: triggers.comment.image,
|
|
177
197
|
skillsDir: triggers.comment.skillsDir,
|
|
178
198
|
instructions: triggers.comment.instructions,
|
|
179
199
|
resume: triggers.comment.resume,
|
|
200
|
+
replicas: triggers.comment.replicas,
|
|
180
201
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
181
202
|
// The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
|
|
182
203
|
// authority that admitted this comment was resolved from the API, not read off the body.
|
|
@@ -197,12 +218,14 @@ function routePullRequest(subset, triggers, action) {
|
|
|
197
218
|
if (rule.predicate && !matchesRule(L, rule.predicate)) continue;
|
|
198
219
|
return {
|
|
199
220
|
enqueue: true,
|
|
200
|
-
flow: rule.
|
|
221
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
222
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
201
223
|
packages: rule.packages,
|
|
202
224
|
image: rule.image,
|
|
203
225
|
skillsDir: rule.skillsDir,
|
|
204
226
|
instructions: rule.instructions,
|
|
205
227
|
resume: rule.resume,
|
|
228
|
+
replicas: rule.replicas,
|
|
206
229
|
matched: { index: rule.index, type: "pull_request", action },
|
|
207
230
|
target: {
|
|
208
231
|
type: "pull_request",
|
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
|
|
@@ -107,6 +111,10 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
107
111
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
108
112
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
109
113
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
114
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
115
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
116
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
117
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
110
118
|
trigger: {
|
|
111
119
|
event: kind,
|
|
112
120
|
action: subset.action,
|
|
@@ -134,12 +142,14 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
134
142
|
if (!rule) return { enqueue: false, reason: "no-allowlisted-label" };
|
|
135
143
|
return {
|
|
136
144
|
enqueue: true,
|
|
137
|
-
flow:
|
|
145
|
+
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
146
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
138
147
|
packages: rule.packages,
|
|
139
148
|
image: rule.image,
|
|
140
149
|
skillsDir: rule.skillsDir,
|
|
141
150
|
instructions: rule.instructions,
|
|
142
151
|
resume: rule.resume,
|
|
152
|
+
replicas: rule.replicas,
|
|
143
153
|
matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
|
|
144
154
|
target: buildTarget(subset, targetType),
|
|
145
155
|
};
|
|
@@ -193,12 +203,14 @@ function routeMergeRequest(subset, triggers) {
|
|
|
193
203
|
function mrResult(subset, rule, matched) {
|
|
194
204
|
return {
|
|
195
205
|
enqueue: true,
|
|
196
|
-
flow: rule.
|
|
206
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
207
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
197
208
|
packages: rule.packages,
|
|
198
209
|
image: rule.image,
|
|
199
210
|
skillsDir: rule.skillsDir,
|
|
200
211
|
instructions: rule.instructions,
|
|
201
212
|
resume: rule.resume,
|
|
213
|
+
replicas: rule.replicas,
|
|
202
214
|
matched,
|
|
203
215
|
target: buildTarget(subset, "pull_request"),
|
|
204
216
|
};
|
|
@@ -218,23 +230,34 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
218
230
|
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
219
231
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
220
232
|
}
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
234
|
+
// trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
|
|
235
|
+
// fires, and trailing note text stays DATA (the handler reads the delivery via /job/event.json). An
|
|
236
|
+
// active override would hand any authorized member two levers the trigger's author never granted:
|
|
237
|
+
// retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
|
|
238
|
+
// word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
|
|
239
|
+
const command = triggers.comment.command;
|
|
240
|
+
let flow;
|
|
241
|
+
if (command === undefined) {
|
|
242
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
|
|
243
|
+
// known flow name, so a comment cannot summon an unlisted flow.
|
|
244
|
+
flow = triggers.comment?.defaultFlow;
|
|
245
|
+
const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
|
|
246
|
+
if (match && knownFlows?.has(match[1])) flow = match[1];
|
|
247
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
248
|
+
return { enqueue: false, reason: "no-flow" };
|
|
249
|
+
}
|
|
228
250
|
}
|
|
229
251
|
const targetType = subset.noteableType === "MergeRequest" ? "pull_request" : "issue";
|
|
230
252
|
return {
|
|
231
253
|
enqueue: true,
|
|
232
|
-
flow,
|
|
254
|
+
...(command !== undefined ? { command } : { flow }),
|
|
233
255
|
packages: triggers.comment.packages,
|
|
234
256
|
image: triggers.comment.image,
|
|
235
257
|
skillsDir: triggers.comment.skillsDir,
|
|
236
258
|
instructions: triggers.comment.instructions,
|
|
237
259
|
resume: triggers.comment.resume,
|
|
260
|
+
replicas: triggers.comment.replicas,
|
|
238
261
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
239
262
|
target: buildTarget(subset, targetType),
|
|
240
263
|
// The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
|
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,
|
package/src/poller.mjs
CHANGED
|
@@ -804,7 +804,10 @@ function makeAppInstallationTokenFn(github, { fetchFn, readFile, now }) {
|
|
|
804
804
|
let cached = null; // { token, expiresAtMs }
|
|
805
805
|
return async () => {
|
|
806
806
|
if (cached !== null && cached.expiresAtMs - now() > 5 * 60_000) return cached.token;
|
|
807
|
-
|
|
807
|
+
// Inline key (GITHUB_APP_PRIVATE_KEY) when the operator supplied one, the file otherwise. The shared
|
|
808
|
+
// loadGitHubAuth normalised and shape-checked it at load and refuses both-set, so there is nothing to
|
|
809
|
+
// decide here (issue #208).
|
|
810
|
+
const pem = github.privateKey ?? (await readFile(github.privateKeyPath, "utf8"));
|
|
808
811
|
const jwt = appJwt(github.appId, pem, now());
|
|
809
812
|
const res = await fetchFn(`${API_URL}/app/installations/${github.installationId}/access_tokens`, {
|
|
810
813
|
method: "POST",
|
package/src/receiver.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The webhook receiver: a thin producer that turns a verified
|
|
2
|
+
* The webhook receiver: a thin producer that turns a verified forge webhook into (at most) one queued
|
|
3
3
|
* job -- or, when the matched trigger opted into `run.replicas`, into exactly that many independent ones
|
|
4
|
-
* (REQ-REPLICA-RUNS
|
|
4
|
+
* (REQ-REPLICA-RUNS, on every forge since #187; the poller stays github-only, so replicas on the other
|
|
5
|
+
* three arrive by webhook alone). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
|
|
5
6
|
* HMAC trust boundary), `filter` (the trigger/author gate), and the SHARED `enqueueGitHubJob` -- and adds
|
|
6
7
|
* only the glue: parse the verified body, project the payload subset, route on the filter's verdict, and
|
|
7
8
|
* map the outcome to a status code.
|
|
@@ -169,6 +170,36 @@ function pathOf(url) {
|
|
|
169
170
|
return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, shared by
|
|
175
|
+
* all four forge arms since #187 widened `run.replicas` past github.
|
|
176
|
+
*
|
|
177
|
+
* ONE body rather than four, for the reason `enqueueForgeJob` gives for collapsing its own wrappers
|
|
178
|
+
* (queue.mjs): four copies is four places for one of them to be quietly weakened while every test stays
|
|
179
|
+
* green. What would be weakened here is the `replicas > 1` conditional, whose whole job is byte-identity
|
|
180
|
+
* for an unflagged delivery -- spread `replica: i` unconditionally in one arm and that forge's `data`, its
|
|
181
|
+
* jobId and its dedup id all change for every ordinary job, with no test outside that forge to notice.
|
|
182
|
+
*
|
|
183
|
+
* Absent `replicas` is `1` and the call below is byte-identical to the single enqueue it replaced: no
|
|
184
|
+
* `replica` key on the job, so the jobId, the dedup id and `data` are exactly what they were.
|
|
185
|
+
*
|
|
186
|
+
* PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
|
|
187
|
+
* replica k throws, the caller's catch answers 503, the forge redelivers, replicas 1..k-1 dedup on their
|
|
188
|
+
* own now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
|
|
189
|
+
*
|
|
190
|
+
* `enqueue` is a callback because the four arms spell their enqueue differently (a named github/gitlab
|
|
191
|
+
* wrapper, or `enqueueForgeJob` with an explicit kind); the fanout itself is forge-blind.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<number>} how many jobs were enqueued, for the caller's `enqueued` log line.
|
|
194
|
+
*/
|
|
195
|
+
async function fanout(job, enqueue) {
|
|
196
|
+
const replicas = job.replicas ?? 1;
|
|
197
|
+
for (let i = 1; i <= replicas; i++) {
|
|
198
|
+
await enqueue(replicas > 1 ? { ...job, replica: i } : job);
|
|
199
|
+
}
|
|
200
|
+
return replicas;
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
/**
|
|
173
204
|
* The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
|
|
174
205
|
* request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
|
|
@@ -189,19 +220,10 @@ function makeGitHubHandler({ queue, selfId, cfg, log }) {
|
|
|
189
220
|
return respond(res, 204);
|
|
190
221
|
}
|
|
191
222
|
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
// the call below is byte-identical to the single enqueue it replaced -- no `replica` key on the job,
|
|
195
|
-
// so the jobId, the dedup id and `data` are all exactly what they were.
|
|
196
|
-
//
|
|
197
|
-
// PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
|
|
198
|
-
// replica k throws, the catch below answers 503, GitHub redelivers, replicas 1..k-1 dedup on their own
|
|
199
|
-
// now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
|
|
200
|
-
const replicas = result.job.replicas ?? 1;
|
|
223
|
+
// Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
|
|
224
|
+
let replicas;
|
|
201
225
|
try {
|
|
202
|
-
|
|
203
|
-
await enqueueGitHubJob(queue, replicas > 1 ? { ...result.job, replica: i } : result.job);
|
|
204
|
-
}
|
|
226
|
+
replicas = await fanout(result.job, (j) => enqueueGitHubJob(queue, j));
|
|
205
227
|
} catch (err) {
|
|
206
228
|
// Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
|
|
207
229
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
@@ -249,8 +271,9 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
|
|
|
249
271
|
return respond(res, 204);
|
|
250
272
|
}
|
|
251
273
|
|
|
274
|
+
let replicas;
|
|
252
275
|
try {
|
|
253
|
-
await enqueueGitLabJob(queue,
|
|
276
|
+
replicas = await fanout(result.job, (j) => enqueueGitLabJob(queue, j));
|
|
254
277
|
} catch (err) {
|
|
255
278
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
256
279
|
return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
|
|
@@ -259,7 +282,7 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
|
|
|
259
282
|
// `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
|
|
260
283
|
// the semantic dedup key makes, because the two are separate number sequences.
|
|
261
284
|
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
262
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
|
|
285
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
263
286
|
return respond(res, 202, { status: "queued" });
|
|
264
287
|
});
|
|
265
288
|
}
|
|
@@ -300,14 +323,15 @@ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority
|
|
|
300
323
|
return respond(res, 204);
|
|
301
324
|
}
|
|
302
325
|
|
|
326
|
+
let replicas;
|
|
303
327
|
try {
|
|
304
|
-
await enqueueForgeJob(queue, "forgejo",
|
|
328
|
+
replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "forgejo", j));
|
|
305
329
|
} catch (err) {
|
|
306
330
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
307
331
|
return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
|
|
308
332
|
}
|
|
309
333
|
|
|
310
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow });
|
|
334
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
311
335
|
return respond(res, 202, { status: "queued" });
|
|
312
336
|
});
|
|
313
337
|
}
|
|
@@ -357,8 +381,9 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
|
|
|
357
381
|
return respond(res, 204);
|
|
358
382
|
}
|
|
359
383
|
|
|
384
|
+
let replicas;
|
|
360
385
|
try {
|
|
361
|
-
await enqueueForgeJob(queue, "azure",
|
|
386
|
+
replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "azure", j));
|
|
362
387
|
} catch (err) {
|
|
363
388
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
364
389
|
return respond(res, 503, { error: "enqueue-failed" });
|
|
@@ -367,7 +392,7 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
|
|
|
367
392
|
// `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
|
|
368
393
|
// discrimination the semantic dedup key makes.
|
|
369
394
|
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
370
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
|
|
395
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
371
396
|
return respond(res, 202, { status: "queued" });
|
|
372
397
|
});
|
|
373
398
|
}
|
package/src/start.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { watch } from "node:fs";
|
|
|
28
28
|
import { dirname, basename } from "node:path";
|
|
29
29
|
import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
|
|
30
30
|
import { makeReceiver } from "./receiver.mjs";
|
|
31
|
+
import { entryExitCode } from "./cli.mjs";
|
|
31
32
|
import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
|
|
32
33
|
import { resolveGitLabSelfId } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
33
34
|
import { resolveForgejoSelfId } from "@edgehero/pi-dispatch/forgejo-identity";
|
|
@@ -192,6 +193,12 @@ function watchTriggers(env, cfg, log) {
|
|
|
192
193
|
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("start.mjs")) {
|
|
193
194
|
startReceiver(process.env).catch((err) => {
|
|
194
195
|
process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
|
|
195
|
-
|
|
196
|
+
// entryExitCode, NOT a bare 1. This file is what `receiver.service` execs -- cli.mjs is not on that
|
|
197
|
+
// path -- so the mapping cli.mjs documents ("a supervisor restarting on exit 2 would loop on a config
|
|
198
|
+
// that can never parse") only reaches a real deployment from here. A tagged config refusal exits 2
|
|
199
|
+
// and `RestartPreventExitStatus=2` stops the unit; anything else is infra and stays retryable at 1.
|
|
200
|
+
// IMPORTED rather than restated: two copies of an exit-code rule is one place for it to drift, and
|
|
201
|
+
// the copy that drifts is the one nobody is looking at.
|
|
202
|
+
process.exitCode = entryExitCode(err);
|
|
196
203
|
});
|
|
197
204
|
}
|