@edgehero/pi-dispatch-receiver 1.2.0 → 1.4.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 +4 -0
- package/src/filter-forgejo.mjs +107 -0
- package/src/filter-gitlab.mjs +112 -2
- package/src/filter.mjs +167 -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.4.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.6.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, secrets: run.secrets, secretsProfile: run.secretsProfile, 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, waitFor: run.waitFor, 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, secrets: run.secrets, secretsProfile: run.secretsProfile, 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, waitFor: run.waitFor, 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, waitFor: run.waitFor, 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, waitFor: run.waitFor, 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, waitFor: run.waitFor, 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
|
@@ -119,6 +119,7 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
119
119
|
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
120
120
|
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
121
121
|
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
122
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
122
123
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
123
124
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
124
125
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
@@ -188,6 +189,7 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
188
189
|
skillsDir: rule.skillsDir,
|
|
189
190
|
secrets: rule.secrets,
|
|
190
191
|
secretsProfile: rule.secretsProfile,
|
|
192
|
+
waitFor: rule.waitFor,
|
|
191
193
|
instructions: rule.instructions,
|
|
192
194
|
resume: rule.resume,
|
|
193
195
|
replicas: rule.replicas,
|
|
@@ -231,6 +233,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
231
233
|
skillsDir: triggers.comment.skillsDir,
|
|
232
234
|
secrets: triggers.comment.secrets,
|
|
233
235
|
secretsProfile: triggers.comment.secretsProfile,
|
|
236
|
+
waitFor: triggers.comment.waitFor,
|
|
234
237
|
instructions: triggers.comment.instructions,
|
|
235
238
|
resume: triggers.comment.resume,
|
|
236
239
|
replicas: triggers.comment.replicas,
|
|
@@ -263,6 +266,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
263
266
|
skillsDir: rule.skillsDir,
|
|
264
267
|
secrets: rule.secrets,
|
|
265
268
|
secretsProfile: rule.secretsProfile,
|
|
269
|
+
waitFor: rule.waitFor,
|
|
266
270
|
instructions: rule.instructions,
|
|
267
271
|
resume: rule.resume,
|
|
268
272
|
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
|
|
@@ -121,6 +133,7 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
121
133
|
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
122
134
|
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
123
135
|
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
136
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
124
137
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
125
138
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
126
139
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
@@ -156,6 +169,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
156
169
|
skillsDir: rule.skillsDir,
|
|
157
170
|
secrets: rule.secrets,
|
|
158
171
|
secretsProfile: rule.secretsProfile,
|
|
172
|
+
waitFor: rule.waitFor,
|
|
159
173
|
instructions: rule.instructions,
|
|
160
174
|
resume: rule.resume,
|
|
161
175
|
replicas: rule.replicas,
|
|
@@ -204,6 +218,7 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
204
218
|
skillsDir: triggers.comment.skillsDir,
|
|
205
219
|
secrets: triggers.comment.secrets,
|
|
206
220
|
secretsProfile: triggers.comment.secretsProfile,
|
|
221
|
+
waitFor: triggers.comment.waitFor,
|
|
207
222
|
instructions: triggers.comment.instructions,
|
|
208
223
|
resume: triggers.comment.resume,
|
|
209
224
|
replicas: triggers.comment.replicas,
|
|
@@ -234,6 +249,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
234
249
|
skillsDir: rule.skillsDir,
|
|
235
250
|
secrets: rule.secrets,
|
|
236
251
|
secretsProfile: rule.secretsProfile,
|
|
252
|
+
waitFor: rule.waitFor,
|
|
237
253
|
instructions: rule.instructions,
|
|
238
254
|
resume: rule.resume,
|
|
239
255
|
replicas: rule.replicas,
|
|
@@ -250,3 +266,94 @@ function routePullRequest(subset, triggers, action) {
|
|
|
250
266
|
}
|
|
251
267
|
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
252
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
272
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
273
|
+
* a network call apart is the drift that module's header warns against.
|
|
274
|
+
*
|
|
275
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
276
|
+
* `filterForgejo` already refused every non-true verdict before routing, and on a webhook close
|
|
277
|
+
* delivery the SENDER is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
278
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever moves,
|
|
279
|
+
* or a resolver starts returning a shape it never has, a close must fail closed under its own token
|
|
280
|
+
* rather than route on a stale assumption about the caller.
|
|
281
|
+
*/
|
|
282
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
283
|
+
const number = subset.issue?.number;
|
|
284
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
285
|
+
// degenerate shape would otherwise enqueue a numberless target.
|
|
286
|
+
if (!Number.isInteger(number)) {
|
|
287
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
288
|
+
}
|
|
289
|
+
// "closed" is PR_CLOSE_ACTIONS.forgejo -- the forge's own word, the one the loader validated the
|
|
290
|
+
// rule's action list against, exactly as routePullRequest matches raw vocabulary.
|
|
291
|
+
const found = findCloseRule(triggers.issue, "closed", number);
|
|
292
|
+
if (found.rule === undefined) {
|
|
293
|
+
return { enqueue: false, reason: found.reason };
|
|
294
|
+
}
|
|
295
|
+
if (authorized !== true) {
|
|
296
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
297
|
+
}
|
|
298
|
+
const rule = found.rule;
|
|
299
|
+
return {
|
|
300
|
+
enqueue: true,
|
|
301
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
302
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
303
|
+
image: rule.image,
|
|
304
|
+
skillsDir: rule.skillsDir,
|
|
305
|
+
secrets: rule.secrets,
|
|
306
|
+
secretsProfile: rule.secretsProfile,
|
|
307
|
+
waitFor: rule.waitFor,
|
|
308
|
+
instructions: rule.instructions,
|
|
309
|
+
resume: rule.resume,
|
|
310
|
+
replicas: rule.replicas,
|
|
311
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
312
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
313
|
+
// record stays byte-identical to the other routes' shape.
|
|
314
|
+
matched: { index: rule.index, type: "issue", action: "closed", number, ...(rule.once === true && { once: true }) },
|
|
315
|
+
target: { type: "issue", number, title: subset.issue?.title, body: subset.issue?.body },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** PR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
320
|
+
function routePullRequestClose(subset, triggers, authorized) {
|
|
321
|
+
const pr = subset.pull_request;
|
|
322
|
+
// Object AND integer number, the github arm's rule: a shape without a usable number must refuse
|
|
323
|
+
// before any rule is consulted.
|
|
324
|
+
if (!pr || !Number.isInteger(pr.number)) {
|
|
325
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
326
|
+
}
|
|
327
|
+
const found = findCloseRule(triggers.prClose, "closed", pr.number);
|
|
328
|
+
if (found.rule === undefined) {
|
|
329
|
+
return { enqueue: false, reason: found.reason };
|
|
330
|
+
}
|
|
331
|
+
if (authorized !== true) {
|
|
332
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
333
|
+
}
|
|
334
|
+
const rule = found.rule;
|
|
335
|
+
return {
|
|
336
|
+
enqueue: true,
|
|
337
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
338
|
+
packages: rule.packages,
|
|
339
|
+
image: rule.image,
|
|
340
|
+
skillsDir: rule.skillsDir,
|
|
341
|
+
secrets: rule.secrets,
|
|
342
|
+
secretsProfile: rule.secretsProfile,
|
|
343
|
+
waitFor: rule.waitFor,
|
|
344
|
+
instructions: rule.instructions,
|
|
345
|
+
resume: rule.resume,
|
|
346
|
+
replicas: rule.replicas,
|
|
347
|
+
// No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
|
|
348
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
349
|
+
matched: { index: rule.index, type: "pull_request", action: "closed", ...(rule.once === true && { once: true }) },
|
|
350
|
+
target: {
|
|
351
|
+
type: "pull_request",
|
|
352
|
+
number: pr.number,
|
|
353
|
+
title: pr.title,
|
|
354
|
+
body: pr.body,
|
|
355
|
+
head: pr.head,
|
|
356
|
+
base: pr.base,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
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 {
|
|
@@ -112,6 +136,7 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
112
136
|
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
113
137
|
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
114
138
|
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
139
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
115
140
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
116
141
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
117
142
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
@@ -154,6 +179,7 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
154
179
|
skillsDir: rule.skillsDir,
|
|
155
180
|
secrets: rule.secrets,
|
|
156
181
|
secretsProfile: rule.secretsProfile,
|
|
182
|
+
waitFor: rule.waitFor,
|
|
157
183
|
instructions: rule.instructions,
|
|
158
184
|
resume: rule.resume,
|
|
159
185
|
replicas: rule.replicas,
|
|
@@ -217,6 +243,7 @@ function mrResult(subset, rule, matched) {
|
|
|
217
243
|
skillsDir: rule.skillsDir,
|
|
218
244
|
secrets: rule.secrets,
|
|
219
245
|
secretsProfile: rule.secretsProfile,
|
|
246
|
+
waitFor: rule.waitFor,
|
|
220
247
|
instructions: rule.instructions,
|
|
221
248
|
resume: rule.resume,
|
|
222
249
|
replicas: rule.replicas,
|
|
@@ -225,6 +252,88 @@ function mrResult(subset, rule, matched) {
|
|
|
225
252
|
};
|
|
226
253
|
}
|
|
227
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
257
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
258
|
+
* a network call apart is the drift that module's header warns against.
|
|
259
|
+
*
|
|
260
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
261
|
+
* `filterGitLab` already refused every non-true verdict before routing, and on a webhook close
|
|
262
|
+
* delivery the ACTOR is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
263
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever
|
|
264
|
+
* moves, or a resolver starts returning a shape it never has, a close must fail closed under its own
|
|
265
|
+
* token rather than route on a stale assumption about the caller.
|
|
266
|
+
*/
|
|
267
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
268
|
+
const number = subset.target?.iid;
|
|
269
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
270
|
+
// degenerate shape would otherwise enqueue a numberless target. GitLab sends integer iids.
|
|
271
|
+
if (!Number.isInteger(number)) {
|
|
272
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
273
|
+
}
|
|
274
|
+
// "close" is PR_CLOSE_ACTIONS.gitlab -- the forge's own word, the one the loader validated the
|
|
275
|
+
// rule's action list against. GitLab really does say "close", not "closed".
|
|
276
|
+
const found = findCloseRule(triggers?.issue, "close", number);
|
|
277
|
+
if (found.rule === undefined) {
|
|
278
|
+
return { enqueue: false, reason: found.reason };
|
|
279
|
+
}
|
|
280
|
+
if (authorized !== true) {
|
|
281
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
282
|
+
}
|
|
283
|
+
const rule = found.rule;
|
|
284
|
+
return {
|
|
285
|
+
enqueue: true,
|
|
286
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
287
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
288
|
+
image: rule.image,
|
|
289
|
+
skillsDir: rule.skillsDir,
|
|
290
|
+
secrets: rule.secrets,
|
|
291
|
+
secretsProfile: rule.secretsProfile,
|
|
292
|
+
waitFor: rule.waitFor,
|
|
293
|
+
instructions: rule.instructions,
|
|
294
|
+
resume: rule.resume,
|
|
295
|
+
replicas: rule.replicas,
|
|
296
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
297
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
298
|
+
// record stays byte-identical to the other routes' shape.
|
|
299
|
+
matched: { index: rule.index, type: "issue", action: "close", number, ...(rule.once === true && { once: true }) },
|
|
300
|
+
target: buildTarget(subset, "issue"),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** MR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
305
|
+
function routeMergeRequestClose(subset, triggers, authorized) {
|
|
306
|
+
// The issue arm's integer guard, same reason.
|
|
307
|
+
if (!Number.isInteger(subset.target?.iid)) {
|
|
308
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
309
|
+
}
|
|
310
|
+
const found = findCloseRule(triggers?.prClose, "close", subset.target?.iid);
|
|
311
|
+
if (found.rule === undefined) {
|
|
312
|
+
return { enqueue: false, reason: found.reason };
|
|
313
|
+
}
|
|
314
|
+
if (authorized !== true) {
|
|
315
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
316
|
+
}
|
|
317
|
+
const rule = found.rule;
|
|
318
|
+
return {
|
|
319
|
+
enqueue: true,
|
|
320
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
321
|
+
packages: rule.packages,
|
|
322
|
+
image: rule.image,
|
|
323
|
+
skillsDir: rule.skillsDir,
|
|
324
|
+
secrets: rule.secrets,
|
|
325
|
+
secretsProfile: rule.secretsProfile,
|
|
326
|
+
waitFor: rule.waitFor,
|
|
327
|
+
instructions: rule.instructions,
|
|
328
|
+
resume: rule.resume,
|
|
329
|
+
replicas: rule.replicas,
|
|
330
|
+
// No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
|
|
331
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
332
|
+
matched: { index: rule.index, type: "pull_request", action: "close", ...(rule.once === true && { once: true }) },
|
|
333
|
+
target: buildTarget(subset, "pull_request"),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
228
337
|
/**
|
|
229
338
|
* Note (comment) path. `noteable_type` states whether the comment is on an issue or a merge request --
|
|
230
339
|
* GitHub infers the same thing from the presence of `issue.pull_request`. Routing it wrong would mint an
|
|
@@ -266,6 +375,7 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
266
375
|
skillsDir: triggers.comment.skillsDir,
|
|
267
376
|
secrets: triggers.comment.secrets,
|
|
268
377
|
secretsProfile: triggers.comment.secretsProfile,
|
|
378
|
+
waitFor: triggers.comment.waitFor,
|
|
269
379
|
instructions: triggers.comment.instructions,
|
|
270
380
|
resume: triggers.comment.resume,
|
|
271
381
|
replicas: triggers.comment.replicas,
|