@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "1.2.0",
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.3.0",
48
+ "@edgehero/pi-dispatch": "^1.6.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
@@ -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
- return { indeterminate: `azure lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
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
- const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0;
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
- // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
291
- // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
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, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
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). */
@@ -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,
@@ -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
+ }
@@ -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
- resolved = routeLabel(subset, triggers, "issue");
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
- resolved = routeMergeRequest(subset, triggers);
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,