@edgehero/pi-dispatch-receiver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Receiver configuration, from the environment. Validated and fail-loud, mirroring the worker:
3
+ * a misconfigured receiver refuses to start with a clear message rather than booting into a state
4
+ * where webhooks silently go unverified or untriggered.
5
+ *
6
+ * The security-sensitive GitHub auth block is single-sourced from `@edgehero/pi-dispatch/config` --
7
+ * `loadGitHubAuth` is parsed once, in one place, so the receiver and worker cannot drift on it. The
8
+ * trigger schema is likewise single-sourced from `@edgehero/pi-dispatch/triggers` -- both services validate
9
+ * the WHOLE unified triggers file and each selects the `on.type` it owns (issue #20).
10
+ *
11
+ * - `webhookSecret` is REQUIRED: without it the receiver cannot verify `X-Hub-Signature-256` over the
12
+ * raw body, and an unverified webhook is a forgeable paid-agent trigger (CONST-HMAC-OVER-RAW-BODY).
13
+ * - `triggers` is the receiver's webhook allowlist, grouped by type: label rules (the label IS the
14
+ * collaborator approval), the single comment trigger (phrase + default flow), and pull_request rules.
15
+ * Only collaborators can apply labels, so the label/PR-label allowlist is the human approval gate
16
+ * (CONST-TRIGGER-AUTHOR-GATE). It is read from `./triggers.json` in the working directory by
17
+ * default -- the file `pi-dispatch init` scaffolds -- with PI_TRIGGERS_FILE as the override.
18
+ * - `bind` defaults to `0.0.0.0` (public): the receiver is the trigger surface that lives outside pi
19
+ * (DES-TRIGGER-OUTSIDE-PI). It carries no admin/dashboard config -- the admin surface is a pi extension
20
+ * in the operator's session and binds no port (DES-ADMIN-VIA-PI-EXTENSION), so there is none here.
21
+ *
22
+ * Errors are tagged `piDispatchConfig` (via the shared `configError`) so the entry can print them
23
+ * cleanly and exit non-zero.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
28
+ import { FORGE_KINDS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
29
+
30
+ // Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
31
+ // default must be the file init just told the operator it created, not a demo buried in the repo.
32
+ const DEFAULT_TRIGGERS_PATH = "./triggers.json";
33
+
34
+ /**
35
+ * Parse the receiver's config from `env` (default process.env). Filesystem access is injected
36
+ * (`readFile`, `fileExists`) so the loader is hermetically testable and never touches disk in tests.
37
+ */
38
+ export function loadReceiverConfig(env = process.env, { readFile = readFileSync, fileExists = existsSync } = {}) {
39
+ const webhookSecret = env.WEBHOOK_SECRET;
40
+ if (webhookSecret === undefined || webhookSecret.trim() === "") {
41
+ throw configError("WEBHOOK_SECRET is required; refusing to start a receiver that cannot verify signatures");
42
+ }
43
+
44
+ return {
45
+ webhookSecret,
46
+ valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379", // mirrors worker config: producer and consumer share one queue
47
+ port: positiveInt(env, "RECEIVER_PORT", 3000),
48
+ bind: env.RECEIVER_BIND ?? "0.0.0.0",
49
+ triggers: loadTriggers(env, readFile, fileExists),
50
+ github: loadGitHubAuth(env, fileExists),
51
+ gitlab: loadGitLabConfig(env),
52
+ forgejo: loadForgejoConfig(env),
53
+ azure: loadAzureConfig(env),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The GitLab endpoint's configuration, or `null` when the deployment serves no GitLab -- in which case no
59
+ * `/gitlab` route exists at all, rather than one that answers 401. An endpoint that responds is an endpoint
60
+ * an operator can believe is armed.
61
+ *
62
+ * `GITLAB_WEBHOOK_MODE` is REQUIRED once any GitLab variable is set, and is not defaulted. The two modes
63
+ * are not equally strong -- `signature` is an HMAC over the body, `token` is a shared-secret compare that
64
+ * proves nothing about the body's integrity -- so which one a deployment runs must be a thing somebody
65
+ * chose and can be asked about, never a thing it fell into. Defaulting to the weaker one would silently
66
+ * downgrade every operator who did not know the field existed; defaulting to the stronger one would break
67
+ * every instance below GitLab 19.0.
68
+ */
69
+ function loadGitLabConfig(env) {
70
+ const mode = env.GITLAB_WEBHOOK_MODE;
71
+ const secret = env.GITLAB_WEBHOOK_SECRET;
72
+ const token = env.GITLAB_TOKEN;
73
+ const apiUrl = env.GITLAB_URL ?? "https://gitlab.com";
74
+ if (!mode && !secret && !token) return null;
75
+
76
+ if (mode !== "signature" && mode !== "token") {
77
+ throw configError(`GITLAB_WEBHOOK_MODE must be "signature" (HMAC, GitLab 19.0+) or "token" (X-Gitlab-Token, any version); got ${JSON.stringify(mode)}`);
78
+ }
79
+ if (typeof secret !== "string" || secret.trim() === "") {
80
+ throw configError("GITLAB_WEBHOOK_SECRET is required; refusing to start a gitlab endpoint that cannot verify deliveries");
81
+ }
82
+ // The gate needs this token BEFORE any job runs: the actor's access level is what authorises a GitLab
83
+ // trigger at all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and
84
+ // every delivery 503s. Refusing at boot is the difference between one clear message and a redelivery loop.
85
+ if (typeof token !== "string" || token.trim() === "") {
86
+ throw configError("GITLAB_TOKEN is required for gitlab triggers -- the receiver resolves the actor's project access level before it may enqueue");
87
+ }
88
+ return { mode, secret, token, apiUrl };
89
+ }
90
+
91
+ /**
92
+ * The Forgejo/Gitea block, or `null` when nothing names it -- same presence rule as GitLab's: an endpoint
93
+ * that answers for a forge nobody configured is an endpoint an operator can believe is armed.
94
+ *
95
+ * There is no MODE here, and that absence is the good news. Forgejo signs the raw body with HMAC-SHA256
96
+ * and sends GitHub's three headers verbatim, so there is exactly one verification mechanism and it is the
97
+ * strong one -- no choice to make, and none to get wrong.
98
+ *
99
+ * `FORGEJO_BOT_ID` is optional and exists for one reason: a repository-scoped token cannot call
100
+ * `GET /user` (see worker/src/forgejo-identity.mjs). Without it, following the token-scoping advice would
101
+ * make the receiver unable to identify itself, and identity is not optional here.
102
+ */
103
+ function loadForgejoConfig(env) {
104
+ const secret = env.FORGEJO_WEBHOOK_SECRET;
105
+ const token = env.FORGEJO_TOKEN;
106
+ const apiUrl = env.FORGEJO_URL;
107
+ const botId = env.FORGEJO_BOT_ID ?? null;
108
+ if (!secret && !token && !apiUrl) return null;
109
+
110
+ // No default instance URL, deliberately: Forgejo is self-hosted by nature and there is no forgejo.com to
111
+ // fall back to. Guessing one would send a token to a host the operator never named.
112
+ if (typeof apiUrl !== "string" || apiUrl.trim() === "") {
113
+ throw configError("FORGEJO_URL is required for forgejo triggers -- there is no default instance to fall back to");
114
+ }
115
+ if (typeof secret !== "string" || secret.trim() === "") {
116
+ throw configError("FORGEJO_WEBHOOK_SECRET is required; refusing to start a forgejo endpoint that cannot verify deliveries");
117
+ }
118
+ // Needed BEFORE any job runs: the actor's repository permission is what authorises a forgejo trigger at
119
+ // all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and every delivery
120
+ // 503s. Refusing at boot is the difference between one clear message and a redelivery loop.
121
+ if (typeof token !== "string" || token.trim() === "") {
122
+ throw configError("FORGEJO_TOKEN is required for forgejo triggers -- the receiver resolves the actor's repository permission before it may enqueue");
123
+ }
124
+ return { secret, token, apiUrl: apiUrl.trim(), botId };
125
+ }
126
+
127
+ /**
128
+ * The Azure DevOps block, or `null` when nothing names it.
129
+ *
130
+ * `AZURE_WEBHOOK_MODE` is REQUIRED once any AZURE_* variable is set and is deliberately not defaulted --
131
+ * the same rule GitLab's mode gets, and for a sharper reason. Azure offers no HMAC at all, so BOTH modes
132
+ * are shared-secret compares that cover no bytes; which header carries the secret is a deployment fact
133
+ * somebody has to have decided, and defaulting it would mean an operator could arm an endpoint without
134
+ * ever noticing that its gate proves only that the sender knew a string.
135
+ */
136
+ function loadAzureConfig(env) {
137
+ const mode = env.AZURE_WEBHOOK_MODE;
138
+ const secret = env.AZURE_WEBHOOK_SECRET;
139
+ const token = env.AZURE_TOKEN;
140
+ const orgUrl = env.AZURE_ORG_URL;
141
+ const headerName = env.AZURE_WEBHOOK_HEADER ?? null;
142
+ if (!mode && !secret && !token && !orgUrl) return null;
143
+
144
+ if (mode !== "basic" && mode !== "header") {
145
+ throw configError(`AZURE_WEBHOOK_MODE must be "basic" (HTTP Basic on the subscription) or "header" (a custom header); got ${JSON.stringify(mode)}`);
146
+ }
147
+ if (mode === "header" && (typeof headerName !== "string" || headerName.trim() === "")) {
148
+ throw configError("AZURE_WEBHOOK_HEADER is required when AZURE_WEBHOOK_MODE=header -- there is no default header name to guess");
149
+ }
150
+ if (typeof secret !== "string" || secret.trim() === "") {
151
+ throw configError("AZURE_WEBHOOK_SECRET is required; refusing to start an azure endpoint that cannot authenticate deliveries");
152
+ }
153
+ // No default organization URL: guessing one would send an operator's token to an organization they
154
+ // never named.
155
+ if (typeof orgUrl !== "string" || orgUrl.trim() === "") {
156
+ throw configError("AZURE_ORG_URL is required for azure triggers (e.g. https://dev.azure.com/your-org)");
157
+ }
158
+ // Needed BEFORE any job runs: project membership is what authorises an azure trigger at all
159
+ // (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and every delivery 503s.
160
+ if (typeof token !== "string" || token.trim() === "") {
161
+ throw configError("AZURE_TOKEN is required for azure triggers -- the receiver resolves the actor's project membership before it may enqueue");
162
+ }
163
+ return { mode, secret, headerName: headerName?.trim() ?? null, token, orgUrl: orgUrl.trim().replace(/\/+$/, "") };
164
+ }
165
+
166
+ /**
167
+ * Load, validate, and group the receiver's webhook triggers from the unified triggers file. The file is
168
+ * the reviewed, committed source of truth for which events trigger which flow; a missing, unparseable, or
169
+ * malformed file fails loud rather than degrading to an empty (silently trigger-nothing) allowlist.
170
+ *
171
+ * The shared `parseTriggers` validates the WHOLE file (including the on x run matrix and cron entries the
172
+ * worker owns); this loader keeps only the webhook types and groups them PER FORGE, so `cfg.triggers` is
173
+ * `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
174
+ * - `label`: ordered `{ index, predicate, flow, packages, image }` rules (first match wins in the filter).
175
+ * - `comment`: the single `{ index, phrase, defaultFlow, packages, image }` (or null when no comment trigger is configured).
176
+ * - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, packages, image }` rules.
177
+ * and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
178
+ * unlisted flow.
179
+ *
180
+ * Grouping by forge FIRST is what keeps each forge's gate reading only its own rules: a GitLab delivery
181
+ * can never match a rule an operator wrote for GitHub, even when both name the same label. `knownFlows`
182
+ * stays shared deliberately -- a flow is a skill in a repo, not a property of the forge that asked for it,
183
+ * and the set exists to bound which names a comment may summon, which is the same bound either way.
184
+ *
185
+ * `packages` (load the operator-staged pi packages) and `image` (which container image the job runs in) are
186
+ * the entry's per-trigger execution fields (INT-TRIGGERS-FILE-CONTRACT, REQ-GLOBAL-PI-OVERLAY). Both ride on
187
+ * the RULE, not on the group, because the filter resolves them from the rule that actually matched -- two
188
+ * rules in one file may name different images, and picking the group's would run the wrong toolchain for
189
+ * whichever rule lost. Absent stays undefined so the filter can omit it entirely and leave an unflagged job
190
+ * byte-identical to today's. `replicas` (REQ-REPLICA-RUNS) rides the rule for the same reason and one
191
+ * sharper: it multiplies spend, so reading it off any rule other than the one that matched would bill an
192
+ * operator for a decision they made about a different trigger.
193
+ *
194
+ * Each grouped rule carries `index`: its 0-based position in the RAW `triggers` array, cron entries
195
+ * counted. The raw file index is the rule's identity -- the filter reports it on the job as
196
+ * `trigger.matched.index`, so a run is explainable back to the exact triggers.json entry that fired it.
197
+ */
198
+ function loadTriggers(env, readFile, fileExists) {
199
+ const path = env.PI_TRIGGERS_FILE ?? DEFAULT_TRIGGERS_PATH;
200
+
201
+ if (!fileExists(path)) {
202
+ throw configError(`triggers file not found: ${path} -- run \`pi-dispatch init\` in this folder to scaffold one, or set PI_TRIGGERS_FILE to yours`);
203
+ }
204
+
205
+ const parsed = parseTriggers(readFile(path, "utf8"), path); // fail-loud
206
+
207
+ // Every forge gets a group whether or not the file names it, so the filter can read
208
+ // `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
209
+ //
210
+ // Built FROM the forge table rather than written out, because the failure of forgetting one is unusually
211
+ // quiet: `groups[run.kind]` would be undefined, `group.label.push` would throw, and `reloadTriggers`
212
+ // below catches everything and KEEPS the previously-loaded triggers. The operator would see one
213
+ // "invalid" message and their old rules would go on firing indefinitely. A test asserts this object's
214
+ // keys are exactly FORGE_KINDS, so the two can never drift.
215
+ const groups = Object.fromEntries(FORGE_KINDS.map((kind) => [kind, emptyGroup()]));
216
+ const knownFlows = new Set();
217
+
218
+ for (const [index, { on, run }] of parsed.entries()) {
219
+ if (on.type === "cron") continue; // the worker owns cron; the receiver never fires it -- but it keeps its index
220
+ knownFlows.add(run.flow);
221
+ const group = groups[run.kind];
222
+ if (on.type === "label") {
223
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas, repository: run.repository });
224
+ } else if (on.type === "comment") {
225
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
226
+ } else if (on.type === "pull_request") {
227
+ group.pullRequest.push({ index, actions: new Set(on.action), predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas });
228
+ }
229
+ }
230
+
231
+ return { ...groups, knownFlows };
232
+ }
233
+
234
+ function emptyGroup() {
235
+ return { label: [], comment: null, pullRequest: [] };
236
+ }
237
+
238
+ /** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
239
+ export function triggersFilePath(env = process.env) {
240
+ return env.PI_TRIGGERS_FILE ?? DEFAULT_TRIGGERS_PATH;
241
+ }
242
+
243
+ /**
244
+ * Live-reload the receiver's triggers: re-read + re-group the file and swap `cfg.triggers` IN PLACE, so the
245
+ * already-wired handler (which closes over `cfg`) picks up the new triggers on its next request -- no
246
+ * restart, mirroring how the worker re-reads the settings overlay per job. If the new file is
247
+ * missing/unparseable/invalid, the running triggers are KEPT (never crash a live receiver on a bad edit)
248
+ * and the reason is returned. Returns `{ ok: true }` or `{ invalid }`.
249
+ */
250
+ export function reloadTriggers(env, cfg, { readFile = readFileSync, fileExists = existsSync } = {}) {
251
+ try {
252
+ cfg.triggers = loadTriggers(env, readFile, fileExists);
253
+ return { ok: true };
254
+ } catch (e) {
255
+ return { invalid: e?.message ?? String(e) };
256
+ }
257
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The Azure DevOps trigger gate. Pure, total, and offline-testable, like its three siblings.
3
+ *
4
+ * The evaluation ORDER is the same fail-closed one, but step 0 does more work here than anywhere else,
5
+ * because Azure is the only forge that identifies an actor two different ways:
6
+ * 0. The actor must resolve to a GUID or a parseable address. Neither is a reject.
7
+ * 1. The bot-loop guard, UNCONDITIONALLY, before any authority or tag check.
8
+ * 2. The authority gate, for every trigger type.
9
+ * 3. Route on the event id.
10
+ *
11
+ * WHY STEP 0 IS THE SHARPEST HAZARD ON THIS FORGE. `filter.mjs` rejects a non-NUMBER `sender.id` first, and
12
+ * its header explains why: `undefined === selfId` is false, so a malformed payload would fall through and
13
+ * ENQUEUE. That guard is correct on GitHub precisely because it demands a number -- and Azure has no
14
+ * numbers. A pull-request actor is a GUID string; a work-item actor is `"Display Name <email>"`. Relaxing
15
+ * the type check is exactly where the fail-open comes back, so `selfId` here is a two-field
16
+ * `{ id, email }` and the comparison is explicit on both, with neither side allowed to be empty. The
17
+ * consequence of getting it wrong is the one `filter-gitlab.mjs:11` exists to prevent: the harness's own
18
+ * work-item comments re-trigger jobs, whose comments re-trigger jobs.
19
+ *
20
+ * TAGS ARE MATCHED ON THE DIFF, NEVER THE SET. Azure has no `labeled` event; a tag change arrives as
21
+ * `workitem.updated` carrying a `{ oldValue, newValue }` pair. Matching the current set would fire the
22
+ * trigger again on every later edit of any field -- a paid run per typo fix, forever. `filter-gitlab.mjs`
23
+ * documents this trap for GitLab's `changes.labels`; Azure is its second customer, and `no-label-change`
24
+ * is the same drop reason on purpose.
25
+ */
26
+
27
+ import { createHash } from "node:crypto";
28
+ import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
29
+ import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
30
+
31
+ /** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
32
+ const PR_ACTION_FOR = {
33
+ "git.pullrequest.created": "created",
34
+ "git.pullrequest.updated": "updated",
35
+ };
36
+
37
+ /**
38
+ * Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
39
+ *
40
+ * `selfId` is `{ id, email }` -- see the header for why it is not one value.
41
+ */
42
+ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, deliveryId) {
43
+ const actor = subset?.actor ?? {};
44
+ const hasId = typeof actor.id === "string" && actor.id !== "";
45
+ const hasEmail = typeof actor.email === "string" && actor.email !== "";
46
+
47
+ // (0) Fail-closed on identity. MUST precede the self compare.
48
+ if (!hasId && !hasEmail) {
49
+ return { enqueue: false, reason: "missing-sender-id" };
50
+ }
51
+
52
+ // (1) Bot-loop guard, unconditional. Both forms are compared, and an EMPTY side never matches: a
53
+ // deployment whose selfId resolved only one of the two must not silently match every actor on the
54
+ // other.
55
+ const selfGuid = typeof selfId?.id === "string" && selfId.id !== "" ? selfId.id : null;
56
+ const selfEmail = typeof selfId?.email === "string" && selfId.email !== "" ? selfId.email.toLowerCase() : null;
57
+ if ((hasId && selfGuid !== null && actor.id === selfGuid) || (hasEmail && selfEmail !== null && actor.email === selfEmail)) {
58
+ return { enqueue: false, reason: "self" };
59
+ }
60
+
61
+ // (2) The authority gate, for EVERY trigger type. Strict `!== true`, so anything that is not an
62
+ // explicit yes refuses.
63
+ if (authorized !== true) {
64
+ return { enqueue: false, reason: "author-not-allowed" };
65
+ }
66
+
67
+ // (3) Route on the Service Hook event id.
68
+ const event = subset.eventType;
69
+ const group = triggers ?? {};
70
+ let resolved;
71
+
72
+ if (WORK_ITEM_EVENTS.has(event)) {
73
+ resolved = event === "workitem.commented" ? routeComment(subset, group, knownFlows, "issue") : routeWorkItemTag(subset, group, event);
74
+ } else if (event === PR_COMMENT_EVENT) {
75
+ resolved = routeComment(subset, group, knownFlows, "pull_request");
76
+ } else if (PR_EVENTS.has(event)) {
77
+ resolved = routePullRequest(subset, group, PR_ACTION_FOR[event]);
78
+ } else {
79
+ return { enqueue: false, reason: "unhandled-event" };
80
+ }
81
+
82
+ if (!resolved.enqueue) return resolved; // carries the drop reason
83
+
84
+ // WHICH REPOSITORY the job clones. A pull-request event names its own and that is authoritative. A WORK
85
+ // ITEM names none -- Azure work items belong to a project, and a project may hold many repositories --
86
+ // so the matched rule's `run.repository` supplies it. The loader requires that field on exactly the
87
+ // azure trigger types a work item can fire, so reaching here without one is not possible; the guard is
88
+ // here anyway because "not possible" and "cannot happen" are different claims and only one is testable.
89
+ const scope = resolveScope(subset, resolved);
90
+ if (scope === null) {
91
+ return { enqueue: false, reason: "no-repository" };
92
+ }
93
+ resolved.repo = scope.repo;
94
+ resolved.azure = scope.azure;
95
+
96
+ const job = {
97
+ // `<project>/<repository>`, WITHOUT the organization -- exactly as a gitlab job's `repo` omits the
98
+ // instance. A deployment has one AZURE_ORG_URL, so the org is constant across every azure job here
99
+ // and repeating it in a pause-window scope and a run-record label would be noise, not information.
100
+ repo: resolved.repo,
101
+ // The structured triple the API paths need, alongside the human-readable label -- the same split
102
+ // gitlab makes when it carries `projectId` next to `repo`.
103
+ azure: resolved.azure,
104
+ target: resolved.target,
105
+ flow: resolved.flow,
106
+ ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
107
+ ...(resolved.image !== undefined ? { image: resolved.image } : {}),
108
+ ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
109
+ trigger: {
110
+ event,
111
+ action: resolved.action,
112
+ deliveryId,
113
+ // A GUID when the delivery carried one, else a HASH of the address -- never the address itself.
114
+ // A work-item payload offers nothing but `"Display Name <email>"`, and this object is copied
115
+ // verbatim into /job/event.json and summarised into the durable run record, both of which are
116
+ // PII-free BY CONSTRUCTION. Hashing keeps "the same actor twice" answerable without putting
117
+ // somebody's email in a file the agent reads and an operator greps -- the same move
118
+ // `session-key.mjs` makes for branch names.
119
+ sender: { id: hasId ? actor.id : stableActorId(actor.email) },
120
+ matched: resolved.matched,
121
+ ...(resolved.comment ? { comment: resolved.comment } : {}),
122
+ },
123
+ };
124
+ return { enqueue: true, job };
125
+ }
126
+
127
+ /**
128
+ * Work-item tag path. The DIFF is the trigger.
129
+ *
130
+ * `workitem.created` has no diff and is matched on the set it arrived with, which is correct and not an
131
+ * exception: a work item created already carrying the tag has changed from having none.
132
+ */
133
+ function routeWorkItemTag(subset, triggers, event) {
134
+ if (event === "workitem.updated") {
135
+ const changes = subset.tagChanges;
136
+ if (changes === undefined) {
137
+ // An update that changed no tags. Dropping it here is what stops every later edit of any field on
138
+ // a tagged work item from starting another paid run.
139
+ return { enqueue: false, reason: "no-label-change" };
140
+ }
141
+ // Verbatim, matching `labelSet`'s own semantics -- it does not case-fold, and a diff that did would
142
+ // disagree with the predicate that runs against it two lines later.
143
+ const previous = labelSet(changes.previous);
144
+ const added = changes.current.filter((t) => !previous.has(t.name));
145
+ if (added.length === 0) {
146
+ // Tags changed, but only by REMOVAL. Removing a tag must never start a paid run -- the same rule
147
+ // Forgejo's `label_cleared` gets.
148
+ return { enqueue: false, reason: "no-label-change" };
149
+ }
150
+ return matchLabelRules(subset, triggers, added, "updated");
151
+ }
152
+ return matchLabelRules(subset, triggers, subset.tags ?? [], "created");
153
+ }
154
+
155
+ function matchLabelRules(subset, triggers, labels, action) {
156
+ const L = labelSet(labels);
157
+ const rule = firstMatchingRule(triggers.label, L);
158
+ if (rule === undefined) {
159
+ return { enqueue: false, reason: "no-allowlisted-label" };
160
+ }
161
+ return {
162
+ enqueue: true,
163
+ action,
164
+ flow: rule.flow,
165
+ repository: rule.repository,
166
+ packages: rule.packages,
167
+ image: rule.image,
168
+ resume: rule.resume,
169
+ matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
170
+ target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
171
+ };
172
+ }
173
+
174
+ /** Comment path, on either a work item or a pull request. The authority gate above has already run. */
175
+ function routeComment(subset, triggers, knownFlows, targetType) {
176
+ const phrase = triggers.comment?.phrase;
177
+ const body = subset.comment;
178
+ if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
179
+ return { enqueue: false, reason: "no-trigger-phrase" };
180
+ }
181
+ let flow = triggers.comment?.defaultFlow;
182
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
183
+ if (match && knownFlows?.has(match[1])) {
184
+ flow = match[1];
185
+ }
186
+ if (flow === null || flow === undefined || flow === "") {
187
+ return { enqueue: false, reason: "no-flow" };
188
+ }
189
+ return {
190
+ enqueue: true,
191
+ action: "commented",
192
+ flow,
193
+ repository: triggers.comment.repository,
194
+ packages: triggers.comment.packages,
195
+ image: triggers.comment.image,
196
+ resume: triggers.comment.resume,
197
+ matched: { index: triggers.comment.index, type: "comment", phrase },
198
+ // No author_association: Azure has none, and the authority that admitted this comment was resolved
199
+ // from the graph, not read off the body.
200
+ comment: { body },
201
+ target: { type: targetType, number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
202
+ };
203
+ }
204
+
205
+ /** Pull-request path. */
206
+ function routePullRequest(subset, triggers, action) {
207
+ if (typeof action !== "string") {
208
+ return { enqueue: false, reason: "unhandled-event" };
209
+ }
210
+ // Azure attaches no labels to a pull request, so a predicate cannot narrow one and a rule that carries
211
+ // a positive selector would never match. The loader refuses such a rule at load; here the action alone
212
+ // selects, and the actor's membership is the gate.
213
+ for (const rule of triggers.pullRequest ?? []) {
214
+ if (!rule.actions.has(action)) continue;
215
+ return {
216
+ enqueue: true,
217
+ action,
218
+ flow: rule.flow,
219
+ repository: rule.repository,
220
+ packages: rule.packages,
221
+ image: rule.image,
222
+ resume: rule.resume,
223
+ matched: { index: rule.index, type: "pull_request", action },
224
+ target: {
225
+ type: "pull_request",
226
+ number: subset.target?.number,
227
+ title: subset.target?.title,
228
+ body: subset.target?.body,
229
+ head: subset.target?.head,
230
+ base: subset.target?.base,
231
+ },
232
+ };
233
+ }
234
+ return { enqueue: false, reason: "no-matching-pr-trigger" };
235
+ }
236
+
237
+ /**
238
+ * The `{ repo, azure }` scope for this job, or `null` when the delivery and the rule between them do not
239
+ * name a repository.
240
+ *
241
+ * The project comes from the payload in both cases -- a delivery always names its project. Only the
242
+ * REPOSITORY differs: present on a pull-request event, absent on a work item and supplied by the rule.
243
+ */
244
+ function resolveScope(subset, resolved) {
245
+ const project = subset.project?.name;
246
+ const projectId = subset.project?.id;
247
+ const repository = subset.repository?.name ?? resolved.repository;
248
+ if (typeof project !== "string" || project === "" || typeof repository !== "string" || repository === "") {
249
+ return null;
250
+ }
251
+ return {
252
+ repo: `${project}/${repository}`,
253
+ azure: {
254
+ project,
255
+ ...(typeof projectId === "string" && projectId !== "" ? { projectId } : {}),
256
+ repository,
257
+ // The repository ID is what every Azure git API path actually takes. It is present on a
258
+ // pull-request delivery and absent on a work item, where the host resolves it by name instead.
259
+ ...(typeof subset.repository?.id === "string" && subset.repository.id !== "" ? { repositoryId: subset.repository.id } : {}),
260
+ },
261
+ };
262
+ }
263
+
264
+ /**
265
+ * A stable, opaque id for an actor known only by email address.
266
+ *
267
+ * Truncated to 16 hex characters: long enough that a collision is not a practical concern for the number
268
+ * of humans in one Azure DevOps organization, short enough to read in a log line. Not reversible, and not
269
+ * meant to be -- its only job is to let two records be compared for "same person".
270
+ */
271
+ function stableActorId(email) {
272
+ return `azid-${createHash("sha256").update(String(email)).digest("hex").slice(0, 16)}`;
273
+ }