@edgehero/pi-dispatch-receiver 1.1.0 → 1.3.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/azure-members.mjs +4 -1
- package/src/close.mjs +34 -0
- package/src/config.mjs +34 -9
- package/src/filter-azure.mjs +11 -0
- package/src/filter-forgejo.mjs +112 -0
- package/src/filter-gitlab.mjs +117 -2
- package/src/filter.mjs +173 -1
- package/src/forgejo-members.mjs +4 -1
- package/src/forgejo-subset.mjs +14 -2
- package/src/github-members.mjs +140 -0
- package/src/gitlab-members.mjs +4 -1
- package/src/poller.mjs +143 -8
- package/src/receiver.mjs +47 -6
- package/src/start.mjs +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": "^1.
|
|
48
|
+
"@edgehero/pi-dispatch": "^1.4.0",
|
|
49
49
|
"bullmq": "5.80.4",
|
|
50
50
|
"ioredis": "5.11.1"
|
|
51
51
|
}
|
package/src/azure-members.mjs
CHANGED
|
@@ -88,7 +88,10 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
88
88
|
try {
|
|
89
89
|
return { body: await res.json(), continuation: continuation || null };
|
|
90
90
|
} catch (err) {
|
|
91
|
-
|
|
91
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
92
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
93
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
94
|
+
return { indeterminate: "azure lookup returned unparseable JSON" };
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
package/src/close.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE derivation of "which close rule matches this delivery" (issue #231), shared by every
|
|
3
|
+
* forge's close route and by the GitHub arm's wants-authority predicate. Two copies of a gate
|
|
4
|
+
* decision is the shape filter.mjs's prAuthorOk comment warns against, and here the two callers are
|
|
5
|
+
* a NETWORK CALL apart: the receiver asks "does any rule want this close" before spending a
|
|
6
|
+
* permission lookup, and the filter asks it again to route -- if those two answers could drift, a
|
|
7
|
+
* delivery could cost a lookup it then drops, or worse, route a close the lookup never gated.
|
|
8
|
+
*
|
|
9
|
+
* Total and throw-free like predicate.mjs: rules are loader-validated shapes, the inputs are
|
|
10
|
+
* adversarial payload fields, and the receiver's gates must never throw on data.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find the first rule (file order, firstMatchingRule's convention) whose action set has this close
|
|
15
|
+
* word and whose `number` narrowing (when present) names this item. Returns:
|
|
16
|
+
* { rule } -- matched; the caller gates and enqueues
|
|
17
|
+
* { reason: "close-number-not-matched" } -- a close rule is armed but names a different item
|
|
18
|
+
* { reason: "no-matching-close-trigger" } -- nothing armed wants this close
|
|
19
|
+
* The two refusal tokens are distinct because they call for different operator responses: "your
|
|
20
|
+
* one-shot exists and this was a different item closing" and "nothing is armed" read nothing alike
|
|
21
|
+
* from a panel.
|
|
22
|
+
*/
|
|
23
|
+
export function findCloseRule(rules, closeWord, number) {
|
|
24
|
+
let numberSkipped = false;
|
|
25
|
+
for (const rule of rules ?? []) {
|
|
26
|
+
if (!rule.actions?.has(closeWord)) continue;
|
|
27
|
+
if (rule.number !== undefined && rule.number !== number) {
|
|
28
|
+
numberSkipped = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return { rule };
|
|
32
|
+
}
|
|
33
|
+
return { reason: numberSkipped ? "close-number-not-matched" : "no-matching-close-trigger" };
|
|
34
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { existsSync, readFileSync } from "node:fs";
|
|
34
34
|
import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
|
|
35
|
-
import { FORGE_KINDS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
|
|
35
|
+
import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
|
|
36
36
|
|
|
37
37
|
// Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
|
|
38
38
|
// default must be the file init just told the operator it created, not a demo buried in the repo.
|
|
@@ -102,7 +102,13 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
|
|
|
102
102
|
* credential lying around is not a statement that this receiver terminates GitHub webhooks.
|
|
103
103
|
*/
|
|
104
104
|
function decideServesGithub(env, githubGroup) {
|
|
105
|
-
|
|
105
|
+
// The close groups count too (issue #231): a deployment whose ONLY github rule is a close trigger
|
|
106
|
+
// must still mount `/`, require WEBHOOK_SECRET and build auth, or the one-shot the operator armed
|
|
107
|
+
// can never fire -- and the receiver would report nothing, the silent no-op this file's header
|
|
108
|
+
// calls the worst outcome. Conversely a deployment whose last github one-shot is SPENT stops
|
|
109
|
+
// counting here at its next boot (the sentinel groups nowhere), which is deliberate: nothing is
|
|
110
|
+
// left to serve, and INT-TRIGGERS-FILE-CONTRACT states it.
|
|
111
|
+
const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0 || githubGroup.issue.length > 0 || githubGroup.prClose.length > 0;
|
|
106
112
|
// `loadGitHubAuth` has already refused a garbled value by the time we get here, so anything non-empty
|
|
107
113
|
// here is one of pat|gh|app -- a real choice, not a typo we would be reading as consent.
|
|
108
114
|
const explicitAuthSource = env.GITHUB_AUTH_SOURCE !== undefined && env.GITHUB_AUTH_SOURCE !== "";
|
|
@@ -228,7 +234,11 @@ function loadAzureConfig(env) {
|
|
|
228
234
|
* `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
|
|
229
235
|
* - `label`: ordered `{ index, predicate, flow, command, packages, image }` rules (first match wins in the filter).
|
|
230
236
|
* - `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.
|
|
237
|
+
* - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules (never a close-only rule).
|
|
238
|
+
* - `issue`: ordered `{ index, actions:Set, number?, once?, ... }` close rules (issue #231).
|
|
239
|
+
* - `prClose`: ordered close-only pull_request rules, same shape as `issue` -- split from
|
|
240
|
+
* `pullRequest` so the closer-gated route and the author-gated route can never read
|
|
241
|
+
* each other's rules (the loader's no-mixing refusal makes the split total).
|
|
232
242
|
* and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
|
|
233
243
|
* unlisted flow. A rule carries EITHER `flow` or `command` (issue #189; the shared parser enforces the
|
|
234
244
|
* exclusivity): a command rule's match dispatches a registered pi extension command in the container, so
|
|
@@ -283,13 +293,28 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
283
293
|
}
|
|
284
294
|
const group = groups[run.kind];
|
|
285
295
|
if (on.type === "label") {
|
|
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 });
|
|
296
|
+
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, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository });
|
|
287
297
|
} else if (on.type === "comment") {
|
|
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
|
|
298
|
+
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, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
289
299
|
} else if (on.type === "pull_request") {
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
300
|
+
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
301
|
+
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
302
|
+
// groups and neither route ever re-derives the distinction -- the loader already refused any
|
|
303
|
+
// list mixing the close word with another action, which is what makes this split total. The
|
|
304
|
+
// split reads the SHARED close-word table, never a re-typed word.
|
|
305
|
+
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
306
|
+
if (closeWord !== undefined && on.action.includes(closeWord)) {
|
|
307
|
+
group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
308
|
+
} else {
|
|
309
|
+
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
310
|
+
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
311
|
+
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, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
312
|
+
}
|
|
313
|
+
} else if (on.type === "issue") {
|
|
314
|
+
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
315
|
+
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
316
|
+
// normalized them -- the filter's close route reads them, nothing else does.
|
|
317
|
+
group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
293
318
|
}
|
|
294
319
|
}
|
|
295
320
|
|
|
@@ -297,7 +322,7 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
function emptyGroup() {
|
|
300
|
-
return { label: [], comment: null, pullRequest: [] };
|
|
325
|
+
return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
/** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
|
package/src/filter-azure.mjs
CHANGED
|
@@ -114,6 +114,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
114
114
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
115
115
|
// basename(folder) restraint already exists to prevent.
|
|
116
116
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
117
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
118
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
119
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
120
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
121
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
117
122
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
118
123
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
119
124
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
@@ -181,6 +186,8 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
181
186
|
packages: rule.packages,
|
|
182
187
|
image: rule.image,
|
|
183
188
|
skillsDir: rule.skillsDir,
|
|
189
|
+
secrets: rule.secrets,
|
|
190
|
+
secretsProfile: rule.secretsProfile,
|
|
184
191
|
instructions: rule.instructions,
|
|
185
192
|
resume: rule.resume,
|
|
186
193
|
replicas: rule.replicas,
|
|
@@ -222,6 +229,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
222
229
|
packages: triggers.comment.packages,
|
|
223
230
|
image: triggers.comment.image,
|
|
224
231
|
skillsDir: triggers.comment.skillsDir,
|
|
232
|
+
secrets: triggers.comment.secrets,
|
|
233
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
225
234
|
instructions: triggers.comment.instructions,
|
|
226
235
|
resume: triggers.comment.resume,
|
|
227
236
|
replicas: triggers.comment.replicas,
|
|
@@ -252,6 +261,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
252
261
|
packages: rule.packages,
|
|
253
262
|
image: rule.image,
|
|
254
263
|
skillsDir: rule.skillsDir,
|
|
264
|
+
secrets: rule.secrets,
|
|
265
|
+
secretsProfile: rule.secretsProfile,
|
|
255
266
|
instructions: rule.instructions,
|
|
256
267
|
resume: rule.resume,
|
|
257
268
|
replicas: rule.replicas,
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
37
|
+
import { findCloseRule } from "./close.mjs";
|
|
37
38
|
import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
|
|
38
39
|
|
|
39
40
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
@@ -84,6 +85,17 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
84
85
|
if (action === null) return unactionable();
|
|
85
86
|
if (eventName === "issues" && LABEL_ACTIONS.has(action)) {
|
|
86
87
|
resolved = routeIssueLabel(subset, group);
|
|
88
|
+
} else if (eventName === "issues" && action === "closed") {
|
|
89
|
+
// The close trigger (issue #231). `group.issue` holds ONLY close rules (the loader admits no
|
|
90
|
+
// other issue kind), and this arm can never contend with the label one: `closed` is not in
|
|
91
|
+
// LABEL_ACTIONS and must not become so -- a close is a statement about an item, not a label move.
|
|
92
|
+
resolved = routeIssueClose(subset, group, authorized);
|
|
93
|
+
} else if (eventName === "pull_request" && raw === "closed") {
|
|
94
|
+
// The RAW word, for routePullRequest's reason below: the close vocabulary is the forge's own
|
|
95
|
+
// (PR_CLOSE_ACTIONS, which the loader validated the rule against). Checked BEFORE the PR_ACTIONS
|
|
96
|
+
// arm, though today they cannot overlap; `group.prClose` was split from `group.pullRequest` at
|
|
97
|
+
// load, so a close rule is unreachable from routePullRequest and vice versa.
|
|
98
|
+
resolved = routePullRequestClose(subset, group, authorized);
|
|
87
99
|
} else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
|
|
88
100
|
// The RAW word, not the mapped one. A trigger file names actions in the forge's own vocabulary
|
|
89
101
|
// (`label_updated`, `synchronized`) because that is what an operator reads in Forgejo's docs and
|
|
@@ -116,6 +128,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
116
128
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
117
129
|
// basename(folder) restraint already exists to prevent.
|
|
118
130
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
131
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
132
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
133
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
134
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
135
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
119
136
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
120
137
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
121
138
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
@@ -149,6 +166,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
149
166
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
150
167
|
image: rule.image,
|
|
151
168
|
skillsDir: rule.skillsDir,
|
|
169
|
+
secrets: rule.secrets,
|
|
170
|
+
secretsProfile: rule.secretsProfile,
|
|
152
171
|
instructions: rule.instructions,
|
|
153
172
|
resume: rule.resume,
|
|
154
173
|
replicas: rule.replicas,
|
|
@@ -195,6 +214,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
195
214
|
packages: triggers.comment.packages,
|
|
196
215
|
image: triggers.comment.image,
|
|
197
216
|
skillsDir: triggers.comment.skillsDir,
|
|
217
|
+
secrets: triggers.comment.secrets,
|
|
218
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
198
219
|
instructions: triggers.comment.instructions,
|
|
199
220
|
resume: triggers.comment.resume,
|
|
200
221
|
replicas: triggers.comment.replicas,
|
|
@@ -223,6 +244,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
223
244
|
packages: rule.packages,
|
|
224
245
|
image: rule.image,
|
|
225
246
|
skillsDir: rule.skillsDir,
|
|
247
|
+
secrets: rule.secrets,
|
|
248
|
+
secretsProfile: rule.secretsProfile,
|
|
226
249
|
instructions: rule.instructions,
|
|
227
250
|
resume: rule.resume,
|
|
228
251
|
replicas: rule.replicas,
|
|
@@ -239,3 +262,92 @@ function routePullRequest(subset, triggers, action) {
|
|
|
239
262
|
}
|
|
240
263
|
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
241
264
|
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
268
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
269
|
+
* a network call apart is the drift that module's header warns against.
|
|
270
|
+
*
|
|
271
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
272
|
+
* `filterForgejo` already refused every non-true verdict before routing, and on a webhook close
|
|
273
|
+
* delivery the SENDER is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
274
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever moves,
|
|
275
|
+
* or a resolver starts returning a shape it never has, a close must fail closed under its own token
|
|
276
|
+
* rather than route on a stale assumption about the caller.
|
|
277
|
+
*/
|
|
278
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
279
|
+
const number = subset.issue?.number;
|
|
280
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
281
|
+
// degenerate shape would otherwise enqueue a numberless target.
|
|
282
|
+
if (!Number.isInteger(number)) {
|
|
283
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
284
|
+
}
|
|
285
|
+
// "closed" is PR_CLOSE_ACTIONS.forgejo -- the forge's own word, the one the loader validated the
|
|
286
|
+
// rule's action list against, exactly as routePullRequest matches raw vocabulary.
|
|
287
|
+
const found = findCloseRule(triggers.issue, "closed", number);
|
|
288
|
+
if (found.rule === undefined) {
|
|
289
|
+
return { enqueue: false, reason: found.reason };
|
|
290
|
+
}
|
|
291
|
+
if (authorized !== true) {
|
|
292
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
293
|
+
}
|
|
294
|
+
const rule = found.rule;
|
|
295
|
+
return {
|
|
296
|
+
enqueue: true,
|
|
297
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
298
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
299
|
+
image: rule.image,
|
|
300
|
+
skillsDir: rule.skillsDir,
|
|
301
|
+
secrets: rule.secrets,
|
|
302
|
+
secretsProfile: rule.secretsProfile,
|
|
303
|
+
instructions: rule.instructions,
|
|
304
|
+
resume: rule.resume,
|
|
305
|
+
replicas: rule.replicas,
|
|
306
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
307
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
308
|
+
// record stays byte-identical to the other routes' shape.
|
|
309
|
+
matched: { index: rule.index, type: "issue", action: "closed", number, ...(rule.once === true && { once: true }) },
|
|
310
|
+
target: { type: "issue", number, title: subset.issue?.title, body: subset.issue?.body },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** PR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
315
|
+
function routePullRequestClose(subset, triggers, authorized) {
|
|
316
|
+
const pr = subset.pull_request;
|
|
317
|
+
// Object AND integer number, the github arm's rule: a shape without a usable number must refuse
|
|
318
|
+
// before any rule is consulted.
|
|
319
|
+
if (!pr || !Number.isInteger(pr.number)) {
|
|
320
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
321
|
+
}
|
|
322
|
+
const found = findCloseRule(triggers.prClose, "closed", pr.number);
|
|
323
|
+
if (found.rule === undefined) {
|
|
324
|
+
return { enqueue: false, reason: found.reason };
|
|
325
|
+
}
|
|
326
|
+
if (authorized !== true) {
|
|
327
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
328
|
+
}
|
|
329
|
+
const rule = found.rule;
|
|
330
|
+
return {
|
|
331
|
+
enqueue: true,
|
|
332
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
333
|
+
packages: rule.packages,
|
|
334
|
+
image: rule.image,
|
|
335
|
+
skillsDir: rule.skillsDir,
|
|
336
|
+
secrets: rule.secrets,
|
|
337
|
+
secretsProfile: rule.secretsProfile,
|
|
338
|
+
instructions: rule.instructions,
|
|
339
|
+
resume: rule.resume,
|
|
340
|
+
replicas: rule.replicas,
|
|
341
|
+
// No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
|
|
342
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
343
|
+
matched: { index: rule.index, type: "pull_request", action: "closed", ...(rule.once === true && { once: true }) },
|
|
344
|
+
target: {
|
|
345
|
+
type: "pull_request",
|
|
346
|
+
number: pr.number,
|
|
347
|
+
title: pr.title,
|
|
348
|
+
body: pr.body,
|
|
349
|
+
head: pr.head,
|
|
350
|
+
base: pr.base,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
|
|
44
|
+
import { findCloseRule } from "./close.mjs";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
/** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
|
|
@@ -75,9 +76,32 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
75
76
|
const kind = subset.objectKind;
|
|
76
77
|
let resolved;
|
|
77
78
|
if (kind === "issue") {
|
|
78
|
-
|
|
79
|
+
if (subset.action === "close") {
|
|
80
|
+
// The close trigger (issue #231), tried FIRST -- and the label walk only as a FALLBACK -- because
|
|
81
|
+
// a real GitLab delivery can close and label in one call (a quick action comment, or the sidebar
|
|
82
|
+
// edited during close). One delivery, one job: when an armed close rule wants this close it fires
|
|
83
|
+
// and the label rule does NOT, so the two rule kinds cannot double-bill a single click.
|
|
84
|
+
resolved = routeIssueClose(subset, triggers, authorized);
|
|
85
|
+
if (!resolved.enqueue && resolved.reason !== "closer-not-allowed" && subset.labelChanges) {
|
|
86
|
+
// No close rule took it, and this close also MOVED labels (`changes.labels`, the exact field
|
|
87
|
+
// `addedLabels` reads for every non-open action). Label rules fired on such closes before close
|
|
88
|
+
// routing existed and must keep doing so byte-identically -- including on ANY close refusal, so
|
|
89
|
+
// a one-shot armed for a DIFFERENT item cannot eat a label trigger it was never about, and
|
|
90
|
+
// including the label walk's own refusal reasons when nothing matches. The `closer-not-allowed`
|
|
91
|
+
// exclusion is belt-and-braces with the route's own unreachable gate: a close a drifted
|
|
92
|
+
// resolver refused must stay refused, never demoted into a label job.
|
|
93
|
+
resolved = routeLabel(subset, triggers, "issue");
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
resolved = routeLabel(subset, triggers, "issue");
|
|
97
|
+
}
|
|
79
98
|
} else if (kind === "merge_request") {
|
|
80
|
-
|
|
99
|
+
// An MR close routes over `group.prClose` ALONE (issue #231), with no label fallback: unlike the
|
|
100
|
+
// issue arm above, MR_ACTIONS never contained "close", so no MR rule ever fired on a close and
|
|
101
|
+
// there is nothing for a fallback to keep working. MR_ACTIONS stays untouched for the same reason
|
|
102
|
+
// in the other direction -- close rules can never reach routeMergeRequest, because the loader split
|
|
103
|
+
// them into `prClose` at group time, so the two rule kinds are mutually unreachable by construction.
|
|
104
|
+
resolved = subset.action === "close" ? routeMergeRequestClose(subset, triggers, authorized) : routeMergeRequest(subset, triggers);
|
|
81
105
|
} else if (kind === "note") {
|
|
82
106
|
resolved = routeNote(subset, triggers, knownFlows);
|
|
83
107
|
} else {
|
|
@@ -107,6 +131,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
107
131
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
108
132
|
// basename(folder) restraint already exists to prevent.
|
|
109
133
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
134
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
135
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
136
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
137
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
138
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
110
139
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
111
140
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
112
141
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
@@ -147,6 +176,8 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
147
176
|
packages: rule.packages,
|
|
148
177
|
image: rule.image,
|
|
149
178
|
skillsDir: rule.skillsDir,
|
|
179
|
+
secrets: rule.secrets,
|
|
180
|
+
secretsProfile: rule.secretsProfile,
|
|
150
181
|
instructions: rule.instructions,
|
|
151
182
|
resume: rule.resume,
|
|
152
183
|
replicas: rule.replicas,
|
|
@@ -208,6 +239,8 @@ function mrResult(subset, rule, matched) {
|
|
|
208
239
|
packages: rule.packages,
|
|
209
240
|
image: rule.image,
|
|
210
241
|
skillsDir: rule.skillsDir,
|
|
242
|
+
secrets: rule.secrets,
|
|
243
|
+
secretsProfile: rule.secretsProfile,
|
|
211
244
|
instructions: rule.instructions,
|
|
212
245
|
resume: rule.resume,
|
|
213
246
|
replicas: rule.replicas,
|
|
@@ -216,6 +249,86 @@ function mrResult(subset, rule, matched) {
|
|
|
216
249
|
};
|
|
217
250
|
}
|
|
218
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
254
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
255
|
+
* a network call apart is the drift that module's header warns against.
|
|
256
|
+
*
|
|
257
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
258
|
+
* `filterGitLab` already refused every non-true verdict before routing, and on a webhook close
|
|
259
|
+
* delivery the ACTOR is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
260
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever
|
|
261
|
+
* moves, or a resolver starts returning a shape it never has, a close must fail closed under its own
|
|
262
|
+
* token rather than route on a stale assumption about the caller.
|
|
263
|
+
*/
|
|
264
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
265
|
+
const number = subset.target?.iid;
|
|
266
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
267
|
+
// degenerate shape would otherwise enqueue a numberless target. GitLab sends integer iids.
|
|
268
|
+
if (!Number.isInteger(number)) {
|
|
269
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
270
|
+
}
|
|
271
|
+
// "close" is PR_CLOSE_ACTIONS.gitlab -- the forge's own word, the one the loader validated the
|
|
272
|
+
// rule's action list against. GitLab really does say "close", not "closed".
|
|
273
|
+
const found = findCloseRule(triggers?.issue, "close", number);
|
|
274
|
+
if (found.rule === undefined) {
|
|
275
|
+
return { enqueue: false, reason: found.reason };
|
|
276
|
+
}
|
|
277
|
+
if (authorized !== true) {
|
|
278
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
279
|
+
}
|
|
280
|
+
const rule = found.rule;
|
|
281
|
+
return {
|
|
282
|
+
enqueue: true,
|
|
283
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
284
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
285
|
+
image: rule.image,
|
|
286
|
+
skillsDir: rule.skillsDir,
|
|
287
|
+
secrets: rule.secrets,
|
|
288
|
+
secretsProfile: rule.secretsProfile,
|
|
289
|
+
instructions: rule.instructions,
|
|
290
|
+
resume: rule.resume,
|
|
291
|
+
replicas: rule.replicas,
|
|
292
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
293
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
294
|
+
// record stays byte-identical to the other routes' shape.
|
|
295
|
+
matched: { index: rule.index, type: "issue", action: "close", number, ...(rule.once === true && { once: true }) },
|
|
296
|
+
target: buildTarget(subset, "issue"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** MR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
301
|
+
function routeMergeRequestClose(subset, triggers, authorized) {
|
|
302
|
+
// The issue arm's integer guard, same reason.
|
|
303
|
+
if (!Number.isInteger(subset.target?.iid)) {
|
|
304
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
305
|
+
}
|
|
306
|
+
const found = findCloseRule(triggers?.prClose, "close", subset.target?.iid);
|
|
307
|
+
if (found.rule === undefined) {
|
|
308
|
+
return { enqueue: false, reason: found.reason };
|
|
309
|
+
}
|
|
310
|
+
if (authorized !== true) {
|
|
311
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
312
|
+
}
|
|
313
|
+
const rule = found.rule;
|
|
314
|
+
return {
|
|
315
|
+
enqueue: true,
|
|
316
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
317
|
+
packages: rule.packages,
|
|
318
|
+
image: rule.image,
|
|
319
|
+
skillsDir: rule.skillsDir,
|
|
320
|
+
secrets: rule.secrets,
|
|
321
|
+
secretsProfile: rule.secretsProfile,
|
|
322
|
+
instructions: rule.instructions,
|
|
323
|
+
resume: rule.resume,
|
|
324
|
+
replicas: rule.replicas,
|
|
325
|
+
// No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
|
|
326
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
327
|
+
matched: { index: rule.index, type: "pull_request", action: "close", ...(rule.once === true && { once: true }) },
|
|
328
|
+
target: buildTarget(subset, "pull_request"),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
219
332
|
/**
|
|
220
333
|
* Note (comment) path. `noteable_type` states whether the comment is on an issue or a merge request --
|
|
221
334
|
* GitHub infers the same thing from the presence of `issue.pull_request`. Routing it wrong would mint an
|
|
@@ -255,6 +368,8 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
255
368
|
packages: triggers.comment.packages,
|
|
256
369
|
image: triggers.comment.image,
|
|
257
370
|
skillsDir: triggers.comment.skillsDir,
|
|
371
|
+
secrets: triggers.comment.secrets,
|
|
372
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
258
373
|
instructions: triggers.comment.instructions,
|
|
259
374
|
resume: triggers.comment.resume,
|
|
260
375
|
replicas: triggers.comment.replicas,
|