@edgehero/pi-dispatch 1.3.0 → 1.5.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/src/triggers.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
3
3
  * the single reviewed source of standing triggers for BOTH services: the worker owns `on.type:"cron"`
4
- * (local jobs), the receiver owns the webhook types (`label|comment|pull_request` -> a forge job). Each
4
+ * (local jobs), the receiver owns the webhook types (`label|comment|pull_request|issue` -> a forge job). Each
5
5
  * service validates the WHOLE file, then selects the `on.type` it owns, so a malformed file fails both
6
6
  * identically and the two cannot drift.
7
7
  *
@@ -24,10 +24,25 @@
24
24
  */
25
25
 
26
26
  import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
27
+ // SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
28
+ // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
29
+ // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
30
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
27
31
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
28
32
  import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
29
33
 
30
- const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
34
+ const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
35
+
36
+ /**
37
+ * The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
38
+ * `ON_TYPES` excludes it, so an authored `on.type: "disarmed"` refuses like any unknown type. The
39
+ * sentinel keeps the entry's raw array position (deleting it would shift `triggerIndex` attribution
40
+ * for every later entry) while making it unmatchable BY CONSTRUCTION -- it carries no selectors, no
41
+ * actions, no run.kind and no flow, so no receiver group, no schedule and no flow allowlist can ever
42
+ * pick it up. A marker flag on the original type was rejected: one consumer forgetting to check it
43
+ * is a spent one-shot firing again, and this shape makes that bug unwritable.
44
+ */
45
+ export const DISARMED_TYPE = "disarmed";
31
46
 
32
47
  // `RUN_KINDS` and `FORGE_KINDS` come from the forge table (forges.mjs) rather than being written out
33
48
  // here. They differ by exactly `local`, and that difference IS the on x run matrix below: a webhook
@@ -41,8 +56,17 @@ export { FORGE_KINDS };
41
56
  * what their forge's documentation says and can grep for it there.
42
57
  *
43
58
  * GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
44
- * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `merge` and
45
- * `close` are omitted on purpose: a job started by a merge or a close has nothing left to act on.
59
+ * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`.
60
+ *
61
+ * The close words (issue #231) carry a distinction the old "nothing left to act on" wording folded
62
+ * flat. A job ABOUT the closed thing still has nothing left to act on, and `run.replicas`' neighbour
63
+ * refusal still stands; what a close CAN do is release separately-armed work -- an operator wrote
64
+ * "when this closes, run that" into this file, and the close is the starting gun, not the subject.
65
+ * So `closed`/`close` are close-ONLY action lists (mixing them with other actions is refused below:
66
+ * the two families gate on different actors), while `merge` stays omitted everywhere -- GitHub and
67
+ * Forgejo emit `closed` for a merged PR so close rules cover merges there, and GitLab's `merge` is
68
+ * its own action no rule takes (an explicit close is what fires a GitLab close rule; the spec names
69
+ * the gap).
46
70
  *
47
71
  * `review_submitted` (issue #66) is github's fifth and the one compound word here. It names the
48
72
  * `pull_request_review` event's `submitted` action, so both halves are greppable in GitHub's own docs, the
@@ -53,23 +77,61 @@ export { FORGE_KINDS };
53
77
  * `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
54
78
  */
55
79
  const PR_ACTIONS = {
56
- github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
80
+ github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
57
81
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
58
82
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
59
- // what `on.reviewState` below exists to narrow.
60
- gitlab: new Set(["open", "update", "reopen", "approved"]),
83
+ // what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
84
+ gitlab: new Set(["open", "update", "reopen", "approved", "close"]),
61
85
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
62
86
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
63
87
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
64
88
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
65
- forgejo: new Set(["label_updated", "opened", "synchronized", "reopened"]),
89
+ forgejo: new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
66
90
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
67
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
68
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
69
- // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
91
+ // is omitted for the reason the close words' own comment above records, and there is no close word here at
92
+ // all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
93
+ // any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
94
+ // INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
95
+ // label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
96
+ // `prLabelAction` is null and a predicated PR rule is refused below.
70
97
  azure: new Set(["created", "updated"]),
71
98
  };
72
99
 
100
+ /**
101
+ * The one action per forge that closes a pull request, in that forge's own words (issue #231). Spelled
102
+ * once because three places turn on it: the close-only refusal in `normalizePullRequest` (a close rule
103
+ * gates on the CLOSER's write access, every other PR rule gates on the author's association or a
104
+ * collaborator's label, and one rule cannot gate on two different actors), the `capable` switch that
105
+ * admits `on.number`/`on.once` there, and the queue's semantic-key discriminant. Azure is absent for
106
+ * the subset reason the table above records, so `PR_ACTIONS.azure` simply never grows the word and the
107
+ * existing vocabulary refusal names azure on its own.
108
+ *
109
+ * EXPORTED for the two consumers that must never re-derive it: the receiver's grouping (a close-only
110
+ * rule routes through the close gate, every other PR rule through the author gate, and the split must
111
+ * be THIS table's) and the queue's semantic-key discriminant (a matched close action word is what
112
+ * marks a close job).
113
+ */
114
+ export const PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
115
+
116
+ /**
117
+ * The `issue` action vocabulary, per forge, in each forge's own words (issue #231). One word each so
118
+ * far: the type exists for "when issue #40 closes, run deploy", and every other issue event already
119
+ * has a home (`label` for label predicates, `comment` for phrases). GitLab's word is `close` (its
120
+ * `object_attributes.action`), GitHub's and Forgejo's is `closed`.
121
+ *
122
+ * Azure is REFUSED rather than absent-and-unhandled, with its own message: a work item's close is a
123
+ * `System.State` transition whose terminal names vary by process template (Agile "Closed", Scrum
124
+ * "Done", plus "Resolved"), and the projected subset carries only `System.Tags` -- so matching a
125
+ * close needs both an INT-AZURE-PAYLOAD-SUBSET widening and a state vocabulary this version does not
126
+ * guess at. Not yet covered, not impossible -- validateResumeFlag's distinction, kept for the same
127
+ * reason.
128
+ */
129
+ const ISSUE_ACTIONS = {
130
+ github: new Set(["closed"]),
131
+ gitlab: new Set(["close"]),
132
+ forgejo: new Set(["closed"]),
133
+ };
134
+
73
135
  /**
74
136
  * The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
75
137
  * vocabulary of the optional `on.reviewState` narrowing (issue #66).
@@ -175,8 +237,12 @@ function normalizeTrigger(entry, index, path, state) {
175
237
  if (run === null || typeof run !== "object") {
176
238
  throw configError(`${at}: "run" must be an object: ${path}`);
177
239
  }
240
+ // Joined from the set for the joined-vocabulary rule stated over the run.kind check below -- a type
241
+ // added to the vocabulary can never be
242
+ // refused by a message that does not mention it. This is also what keeps `DISARMED_TYPE` refused
243
+ // when authored: it is deliberately not in ON_TYPES, so it reads here as any other unknown type.
178
244
  if (!ON_TYPES.has(on.type)) {
179
- throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
245
+ throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
180
246
  }
181
247
  // The legal-values half of every message below is JOINED from the table rather than typed out, so a
182
248
  // forge added to the table can never be refused by a message that does not mention it -- which reads
@@ -195,9 +261,18 @@ function normalizeTrigger(entry, index, path, state) {
195
261
  if (!isForgeKind(run.kind)) {
196
262
  throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
197
263
  }
198
- if (on.type === "label") return normalizeLabel(on, run, index, path);
199
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
200
- return normalizePullRequest(on, run, index, path);
264
+ let normalized;
265
+ if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
266
+ else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
267
+ else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
268
+ else normalized = normalizePullRequest(on, run, index, path);
269
+
270
+ // A disarmed one-shot has validated IN FULL above (a disarmed entry with a malformed run still
271
+ // refuses the file -- writeTriggers' fail-closed contract needs the whole file valid, and the
272
+ // worker's disarm only ever ADDS one key to an entry that already passed). Only then does it
273
+ // normalize to the sentinel, so its raw index survives and nothing downstream can match it.
274
+ if (on.disarmed !== undefined) return { on: { type: DISARMED_TYPE }, run: {} };
275
+ return normalized;
201
276
  }
202
277
 
203
278
  function normalizeCron(on, run, index, path, state) {
@@ -264,6 +339,10 @@ function normalizeCron(on, run, index, path, state) {
264
339
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
265
340
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
266
341
  validateReplicas(run, `cron trigger "${id}"`, path);
342
+ // Same posture for the close-trigger fields (issue #231): none has a legal value on cron.
343
+ validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
344
+ validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
345
+ validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
267
346
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
268
347
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
269
348
 
@@ -600,6 +679,122 @@ function validatePredicate(on, index, path, requirePositive) {
600
679
  return { any: on.any, all: on.all, none: on.none };
601
680
  }
602
681
 
682
+ /**
683
+ * `on.number` -- narrow a close-capable trigger to ONE item, by the number the forge itself assigns
684
+ * (issue #231). Legal with or without `on.once`: a standing "every close of #40" rule is coherent
685
+ * narrowing, exactly as `on.reviewState` narrows without changing what the rule is. Called from ALL
686
+ * normalizers, `validateReplicas`' posture -- where it cannot apply it refuses, never ignores.
687
+ * `capable` is passed only by the close-capable paths (the `issue` normalizer, and a `pull_request`
688
+ * rule whose only action is the forge's close word).
689
+ */
690
+ function validateNumber(on, at, path, { capable = false, onType } = {}) {
691
+ const number = on.number;
692
+ if (number === undefined) return undefined;
693
+ if (!capable) {
694
+ if (onType === "cron") {
695
+ throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
696
+ }
697
+ throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
698
+ }
699
+ if (!Number.isInteger(number) || number < 1) {
700
+ throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
701
+ }
702
+ return number;
703
+ }
704
+
705
+ /**
706
+ * `on.once` -- a one-shot: the trigger fires, produces a run record, and the worker disarms it by
707
+ * adding `on.disarmed` to this entry (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE). Strictly boolean
708
+ * and fail-loud, the house rule; `false` is legal and carried, validateResumeFlag's argument -- an
709
+ * operator who wrote down today's default must not be refused for stating present behaviour.
710
+ *
711
+ * `once: true` REQUIRES `on.number`, and the requirement is a race analysis, not taste: a numberless
712
+ * one-shot matched by two different items' closes inside one dedup window enqueues both before either
713
+ * disarm lands, and which item "spent" the trigger is a coin flip. With a number, concurrent
714
+ * duplicates are duplicates of the SAME item, which is what the delivery GUID and the semantic window
715
+ * actually bound -- and the number is the identity the disarm re-checks before it writes, so a file
716
+ * edited between enqueue and disarm refuses loudly instead of disarming a stranger.
717
+ *
718
+ * `once: true` is refused beside `run.replicas` (`false`, the written-down default, is not): "exactly
719
+ * one run" and "N sandboxes race" contradict on their face,
720
+ * and with N run records the disarm no longer says which one spent the trigger.
721
+ */
722
+ function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
723
+ const once = on.once;
724
+ if (once === undefined) return undefined;
725
+ if (!capable) {
726
+ if (onType === "cron") {
727
+ throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
728
+ }
729
+ // The close-word hint is JOINED from the table (the same rule the vocabulary messages follow), so a
730
+ // forge gaining a close word later can never be pointed away from it by a stale sentence here.
731
+ const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
732
+ throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
733
+ }
734
+ if (typeof once !== "boolean") {
735
+ throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
736
+ }
737
+ if (once === true && on.number === undefined) {
738
+ throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
739
+ }
740
+ if (once === true && run.replicas !== undefined) {
741
+ throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
742
+ }
743
+ return once;
744
+ }
745
+
746
+ /**
747
+ * `on.disarmed` -- the mark the worker writes when a one-shot fires: `{ at, jobId? }`, provenance an
748
+ * operator can read a year later when the run record is long reaped (issue #231). Hand-writable too
749
+ * (jobId optional), which is how an operator disarms deliberately; deleting the key is how they
750
+ * re-arm. The entry it sits on normalizes to the DISARMED_TYPE sentinel -- see normalizeTrigger --
751
+ * but only AFTER this shape check and the full entry validation pass, so a corrupted disarm mark is
752
+ * a load refusal, never a silently-still-armed rule.
753
+ */
754
+ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
755
+ const disarmed = on.disarmed;
756
+ if (disarmed === undefined) return undefined;
757
+ if (!capable) {
758
+ throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
759
+ }
760
+ if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
761
+ throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
762
+ }
763
+ for (const key of Object.keys(disarmed)) {
764
+ if (key !== "at" && key !== "jobId") {
765
+ throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
766
+ }
767
+ }
768
+ if (!isNonEmptyString(disarmed.at)) {
769
+ throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
770
+ }
771
+ if (disarmed.jobId !== undefined && !isNonEmptyString(disarmed.jobId)) {
772
+ throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
773
+ }
774
+ if (on.once !== true) {
775
+ throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
776
+ }
777
+ return disarmed;
778
+ }
779
+
780
+ /**
781
+ * `run.flow` charset for the WEBHOOK kinds (issue #231). materialize.mjs refuses a name outside
782
+ * SKILL_NAME_RE at job start, AFTER the budget slot is reserved -- so until now a charset-invalid
783
+ * forge flow loaded clean and could only ever fail in-container (graph-model already renders it as
784
+ * the `charset-invalid` defect). Refusing at load turns that paid failure into a free one, and it is
785
+ * also what keeps `:` out of the flow slot of the queue's semantic dedup key, where the command jobs'
786
+ * `cmd:` prefix lives and the close jobs' discriminant sits beside it once close routing lands.
787
+ * Deliberately NOT called on cron:
788
+ * a local flow resolves inside the operator's own folder by pi itself, and the semantic key does not
789
+ * apply to repeat jobs -- narrowing there would refuse deployments this hazard cannot reach.
790
+ */
791
+ function validateFlowName(run, at, path) {
792
+ if (run.flow === undefined) return;
793
+ if (!SKILL_NAME_RE.test(run.flow)) {
794
+ throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
795
+ }
796
+ }
797
+
603
798
 
604
799
  /**
605
800
  * `run.repository` -- WHICH repository a job clones, for a forge whose trigger subject does not name one.
@@ -792,11 +987,15 @@ function validateSecretsProfile(run, at, path) {
792
987
  function normalizeLabel(on, run, index, path) {
793
988
  const at = `trigger at index ${index}`;
794
989
  const predicate = validatePredicate(on, index, path, true);
990
+ validateNumber(on, at, path, { onType: "label" });
991
+ validateOnce(on, run, at, path, { onType: "label" });
992
+ validateDisarmed(on, at, path, { onType: "label" });
795
993
  // First among the run checks, before the flow-required check -- validateCommand says why.
796
994
  const command = validateCommand(run, at, path, { onType: "label" });
797
995
  if (command === undefined && !isNonEmptyString(run.flow)) {
798
996
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
799
997
  }
998
+ validateFlowName(run, at, path);
800
999
  const packages = validatePackagesFlag(run, at, path);
801
1000
  const image = validateImageRef(run, at, path);
802
1001
  const skillsDir = validateSkillsDir(run, at, path);
@@ -817,6 +1016,9 @@ function normalizeComment(on, run, index, path, state) {
817
1016
  if (!isNonEmptyString(on.phrase)) {
818
1017
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
819
1018
  }
1019
+ validateNumber(on, at, path, { onType: "comment" });
1020
+ validateOnce(on, run, at, path, { onType: "comment" });
1021
+ validateDisarmed(on, at, path, { onType: "comment" });
820
1022
  // First among the run checks, before the flow-required check -- validateCommand says why. A command
821
1023
  // trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
822
1024
  // token inert is the receiver filter's job, not a shape this validator can see.
@@ -824,6 +1026,7 @@ function normalizeComment(on, run, index, path, state) {
824
1026
  if (command === undefined && !isNonEmptyString(run.flow)) {
825
1027
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
826
1028
  }
1029
+ validateFlowName(run, at, path);
827
1030
  // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
828
1031
  // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
829
1032
  // and a deployment serving GitHub and GitLab is entitled to the same `@pi` phrase on each.
@@ -846,6 +1049,78 @@ function normalizeComment(on, run, index, path, state) {
846
1049
  };
847
1050
  }
848
1051
 
1052
+ /**
1053
+ * `on.type: "issue"` (issue #231): fire when an ISSUE's lifecycle action happens -- one word so far,
1054
+ * the close. The type exists because every other issue event already has a home (`label` for label
1055
+ * predicates, `comment` for phrases) and neither of those shapes fits a close: there is no label diff
1056
+ * to match and no phrase to read, only an action, an item number, and the actor who performed it.
1057
+ * A PULL REQUEST's close is deliberately NOT this type -- it rides `on.type: "pull_request"` as an
1058
+ * action, the #66 rule (one forge's PR lifecycle must not be a type while another's is an action),
1059
+ * and because GitLab and Azure number issues and merge/pull requests from SEPARATE sequences, a
1060
+ * both-kinds type narrowed by `on.number` would be ambiguous exactly where a one-shot spending
1061
+ * itself on the wrong #5 hurts most. Under the split, the type IS the discriminator.
1062
+ */
1063
+ function normalizeIssue(on, run, index, path) {
1064
+ const at = `trigger at index ${index}`;
1065
+
1066
+ // Kind first: on azure the whole TYPE is unsupported, and hearing that beats hearing that one
1067
+ // action word is. See ISSUE_ACTIONS for why, and validateResumeFlag for the "not yet covered"
1068
+ // vocabulary this reuses.
1069
+ if (run.kind === "azure") {
1070
+ throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
1071
+ }
1072
+
1073
+ const actions = on.action;
1074
+ if (!Array.isArray(actions) || actions.length === 0) {
1075
+ throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
1076
+ }
1077
+ // Validated against THIS entry's forge, in that forge's own words -- normalizePullRequest's rule.
1078
+ const allowed = ISSUE_ACTIONS[run.kind];
1079
+ const expected = [...allowed].join("|");
1080
+ for (const a of actions) {
1081
+ if (!allowed.has(a)) {
1082
+ throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
1083
+ }
1084
+ }
1085
+
1086
+ // The close route matches action and number alone, so a predicate here would be accepted-and-
1087
+ // ignored -- the exact thing validateRepository's posture forbids. Refused, not dropped.
1088
+ if (on.any !== undefined || on.all !== undefined || on.none !== undefined) {
1089
+ throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
1090
+ }
1091
+
1092
+ const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
1093
+ const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
1094
+ validateDisarmed(on, at, path, { capable: true, onType: "issue" });
1095
+
1096
+ // First among the run checks, before the flow-required check -- validateCommand says why.
1097
+ const command = validateCommand(run, at, path, { onType: "issue" });
1098
+ if (command === undefined && !isNonEmptyString(run.flow)) {
1099
+ throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
1100
+ }
1101
+ validateFlowName(run, at, path);
1102
+ const packages = validatePackagesFlag(run, at, path);
1103
+ const image = validateImageRef(run, at, path);
1104
+ const skillsDir = validateSkillsDir(run, at, path);
1105
+ const instructions = validateInstructions(run, at, path);
1106
+ const resume = validateResumeFlag(run, at, path);
1107
+ validateRepository(run, "issue", at, path);
1108
+ const replicas = validateReplicas(run, at, path);
1109
+ const secrets = validateSecrets(run, at, path);
1110
+ const secretsProfile = validateSecretsProfile(run, at, path);
1111
+ return {
1112
+ on: {
1113
+ type: "issue",
1114
+ action: [...actions],
1115
+ // Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
1116
+ // normalized shape must not grow keys.
1117
+ ...(number !== undefined && { number }),
1118
+ ...(once !== undefined && { once }),
1119
+ },
1120
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
1121
+ };
1122
+ }
1123
+
849
1124
  function normalizePullRequest(on, run, index, path) {
850
1125
  const at = `trigger at index ${index}`;
851
1126
 
@@ -863,6 +1138,16 @@ function normalizePullRequest(on, run, index, path) {
863
1138
  }
864
1139
  }
865
1140
 
1141
+ // The close-only split (issue #231). A close rule gates on the CLOSER's write access; every other
1142
+ // PR action gates on the author's association or a collaborator-applied label. One rule cannot
1143
+ // gate on two different actors, so a list mixing the close word with anything else is refused --
1144
+ // which is also what lets the receiver group close rules separately without re-deriving this.
1145
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
1146
+ const isClose = closeWord !== undefined && actions.includes(closeWord);
1147
+ if (isClose && actions.some((a) => a !== closeWord)) {
1148
+ throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
1149
+ }
1150
+
866
1151
  const reviewState = validateReviewState(on, actions, run, at, path);
867
1152
 
868
1153
  // A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
@@ -882,12 +1167,21 @@ function normalizePullRequest(on, run, index, path) {
882
1167
  if (run.kind === "azure" && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
883
1168
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
884
1169
  }
1170
+ // A close-only rule reads no label diff either -- its route matches action and number alone -- so
1171
+ // a predicate on it is normalizeIssue's refusal arriving on the PR side.
1172
+ if (isClose && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
1173
+ throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
1174
+ }
885
1175
  const predicate = validatePredicate(on, index, path, requirePositive);
1176
+ const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
1177
+ const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
1178
+ validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
886
1179
  // First among the run checks, before the flow-required check -- validateCommand says why.
887
1180
  const command = validateCommand(run, at, path, { onType: "pull_request" });
888
1181
  if (command === undefined && !isNonEmptyString(run.flow)) {
889
1182
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
890
1183
  }
1184
+ validateFlowName(run, at, path);
891
1185
  const packages = validatePackagesFlag(run, at, path);
892
1186
  const image = validateImageRef(run, at, path);
893
1187
  const skillsDir = validateSkillsDir(run, at, path);
@@ -907,6 +1201,9 @@ function normalizePullRequest(on, run, index, path) {
907
1201
  any: predicate.any,
908
1202
  all: predicate.all,
909
1203
  none: predicate.none,
1204
+ // Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
1205
+ ...(number !== undefined && { number }),
1206
+ ...(once !== undefined && { once }),
910
1207
  },
911
1208
  run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
912
1209
  };