@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/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Forgejo/Gitea trigger gate. Pure, total, and offline-testable: this module imports nothing
|
|
3
|
+
* side-effecting, does no I/O and never throws, so the security-critical decision can be exercised
|
|
4
|
+
* without a server, a socket, or a queue.
|
|
5
|
+
*
|
|
6
|
+
* The evaluation ORDER is fail-closed and load-bearing, and is the same order both other gates use:
|
|
7
|
+
* 0. A non-numeric `sender.id` is rejected FIRST. It must precede the `=== selfId` compare, because
|
|
8
|
+
* `undefined === selfId` is `false` and would fall through to enqueue -- failing OPEN on a malformed
|
|
9
|
+
* payload. Missing identity is a reject, never a pass.
|
|
10
|
+
* 1. The bot-loop guard runs UNCONDITIONALLY, before any authority or label check. Under a hand-minted
|
|
11
|
+
* Forgejo token the harness IS a repository collaborator and would clear the gate, so a status
|
|
12
|
+
* comment it posts -- or its own push to a PR head, which fires `synchronized` -- is an event that
|
|
13
|
+
* passes: an unbounded paid recursion. Gating this drop behind the authority check reintroduces it.
|
|
14
|
+
* 2. The authority gate, for EVERY trigger type.
|
|
15
|
+
* 3. Only then route on event + action.
|
|
16
|
+
*
|
|
17
|
+
* WHY THE AUTHORITY GATE COVERS LABELS TOO, unlike GitHub's. On GitHub the label path needs no author
|
|
18
|
+
* check because applying a label already required write access, so the label IS the approval. That is very
|
|
19
|
+
* probably true on Forgejo as well -- issue #61 argues it, and Forgejo's permission model agrees on
|
|
20
|
+
* inspection. It is not, however, something this project has verified against a running instance across
|
|
21
|
+
* versions, and the cost of being wrong is a stranger starting paid jobs. So the gate does not rest on it:
|
|
22
|
+
* `authorized` is required for every trigger type, and if the label really is self-gating then this check
|
|
23
|
+
* is redundant rather than wrong. That is the cheaper direction to be wrong in.
|
|
24
|
+
*
|
|
25
|
+
* `authorized` is resolved by the receiver BEFORE this function (forgejo-members.mjs) and passed in,
|
|
26
|
+
* occupying the slot `author_association` holds on the GitHub side: the lookup is a network call, and
|
|
27
|
+
* putting a fetch inside the gate would destroy the purity that makes it testable.
|
|
28
|
+
*
|
|
29
|
+
* ACTIONS ARRIVE IN FORGEJO'S OWN WORDS and are mapped explicitly (forgejo-subset.mjs). A recognised
|
|
30
|
+
* action that is not actionable drops under its OWN reason rather than as `unhandled-event`, so an
|
|
31
|
+
* operator whose trigger never fires can tell "Forgejo said something we deliberately ignore" from "we did
|
|
32
|
+
* not recognise this at all". `label_cleared` is the case that matters: removing a label must never start
|
|
33
|
+
* a paid run, and it must be visible that it was seen and refused.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
37
|
+
import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
|
|
38
|
+
|
|
39
|
+
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
40
|
+
const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
|
|
44
|
+
*
|
|
45
|
+
* `subset` is a `parseForgejoSubset` projection; `triggers` is the forgejo rule group; `knownFlows` is the
|
|
46
|
+
* whole file's flow vocabulary; `authorized` is the receiver's resolved verdict; `deliveryId` is the
|
|
47
|
+
* `X-GitHub-Delivery` GUID, which Forgejo emits and keeps stable across its own retries.
|
|
48
|
+
*/
|
|
49
|
+
export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, authorized, deliveryId) {
|
|
50
|
+
// (0) Fail-closed on identity. MUST precede the self compare -- see header.
|
|
51
|
+
if (typeof subset?.sender?.id !== "number") {
|
|
52
|
+
return { enqueue: false, reason: "missing-sender-id" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// (1) Bot-loop guard: unconditional, independent of the authority outcome below.
|
|
56
|
+
if (subset.sender.id === selfId) {
|
|
57
|
+
return { enqueue: false, reason: "self" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// (2) The authority gate, for EVERY trigger type. Strict `!== true` so anything that is not an explicit
|
|
61
|
+
// yes -- undefined, a stray truthy value, a resolver that returned the wrong shape -- refuses.
|
|
62
|
+
if (authorized !== true) {
|
|
63
|
+
return { enqueue: false, reason: "author-not-allowed" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// (3) Map Forgejo's action word, then route. The mapping is what stops `label_updated` and
|
|
67
|
+
// `synchronized` -- one letter from GitHub's words -- from falling out as unrecognised.
|
|
68
|
+
const raw = subset.action;
|
|
69
|
+
const group = triggers ?? {};
|
|
70
|
+
// Recognised and deliberately ignored, versus never heard of. Two reasons, because they call for two
|
|
71
|
+
// different operator responses: one is "that is not a trigger", the other is "check your action
|
|
72
|
+
// vocabulary against your forge's".
|
|
73
|
+
const unactionable = () => ({ enqueue: false, reason: isRecognizedAction(raw) ? "action-not-actionable" : "unhandled-event" });
|
|
74
|
+
let resolved;
|
|
75
|
+
|
|
76
|
+
if (eventName === "issue_comment") {
|
|
77
|
+
// Deliberately OUTSIDE the action map. A comment's `created` is the same word on Forgejo as on
|
|
78
|
+
// GitHub, and a comment carries no label semantics to translate -- mapping it would only create a
|
|
79
|
+
// place for it to fall through.
|
|
80
|
+
if (raw !== "created") return unactionable();
|
|
81
|
+
resolved = routeComment(subset, group, knownFlows);
|
|
82
|
+
} else {
|
|
83
|
+
const action = mapAction(eventName, raw);
|
|
84
|
+
if (action === null) return unactionable();
|
|
85
|
+
if (eventName === "issues" && LABEL_ACTIONS.has(action)) {
|
|
86
|
+
resolved = routeIssueLabel(subset, group);
|
|
87
|
+
} else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
|
|
88
|
+
// The RAW word, not the mapped one. A trigger file names actions in the forge's own vocabulary
|
|
89
|
+
// (`label_updated`, `synchronized`) because that is what an operator reads in Forgejo's docs and
|
|
90
|
+
// what the loader validates against -- so the rule match has to be against the same words. The
|
|
91
|
+
// mapped action decided WHICH route; it must not also decide which rule.
|
|
92
|
+
resolved = routePullRequest(subset, group, raw);
|
|
93
|
+
} else {
|
|
94
|
+
return { enqueue: false, reason: "unhandled-event" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!resolved.enqueue) return resolved; // carries the drop reason
|
|
99
|
+
|
|
100
|
+
// The job literal mirrors the other two forges' exactly -- `repo`, `target`, `flow`, the three
|
|
101
|
+
// conditional execution knobs, and a descriptive `trigger`. `sender.login` is in the subset but is NOT
|
|
102
|
+
// carried here: it exists for the permission lookup and nowhere else, and this object is copied verbatim
|
|
103
|
+
// into /job/event.json, which must stay free of personal data.
|
|
104
|
+
const job = {
|
|
105
|
+
repo: subset.repository?.full_name,
|
|
106
|
+
target: resolved.target,
|
|
107
|
+
flow: resolved.flow,
|
|
108
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
109
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
110
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
111
|
+
trigger: {
|
|
112
|
+
event: eventName,
|
|
113
|
+
// Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
|
|
114
|
+
action: raw,
|
|
115
|
+
deliveryId,
|
|
116
|
+
sender: { id: subset.sender.id },
|
|
117
|
+
matched: resolved.matched,
|
|
118
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return { enqueue: true, job };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Issue label path: the label predicate selects, and the resolved permission has already gated. */
|
|
125
|
+
function routeIssueLabel(subset, triggers) {
|
|
126
|
+
const L = labelSet(subset.issue?.labels);
|
|
127
|
+
const rule = firstMatchingRule(triggers.label, L);
|
|
128
|
+
if (rule === undefined) {
|
|
129
|
+
return { enqueue: false, reason: "no-allowlisted-label" };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
enqueue: true,
|
|
133
|
+
flow: rule.flow,
|
|
134
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
135
|
+
image: rule.image,
|
|
136
|
+
resume: rule.resume,
|
|
137
|
+
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
138
|
+
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Comment path. The authority gate above has already run, so this only has to match the phrase. */
|
|
143
|
+
function routeComment(subset, triggers, knownFlows) {
|
|
144
|
+
const phrase = triggers.comment?.phrase;
|
|
145
|
+
const body = subset.comment?.body;
|
|
146
|
+
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
147
|
+
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
148
|
+
}
|
|
149
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
|
|
150
|
+
// flow name, so a comment cannot summon an unlisted flow.
|
|
151
|
+
let flow = triggers.comment?.defaultFlow;
|
|
152
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
153
|
+
if (match && knownFlows?.has(match[1])) {
|
|
154
|
+
flow = match[1];
|
|
155
|
+
}
|
|
156
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
157
|
+
return { enqueue: false, reason: "no-flow" };
|
|
158
|
+
}
|
|
159
|
+
// `is_pull` is TOP-LEVEL on Forgejo, where GitHub carries `issue.pull_request`. Reading the wrong one
|
|
160
|
+
// routes every pull-request comment as an issue, and the envelope then tells the agent to open
|
|
161
|
+
// `pi/issue-<n>` for something that is already a pull request: wrong work, no error, and a run that
|
|
162
|
+
// looks successful. The issue number IS the PR number here, exactly as on GitHub.
|
|
163
|
+
const isPR = subset.isPull === true;
|
|
164
|
+
return {
|
|
165
|
+
enqueue: true,
|
|
166
|
+
flow,
|
|
167
|
+
packages: triggers.comment.packages,
|
|
168
|
+
image: triggers.comment.image,
|
|
169
|
+
resume: triggers.comment.resume,
|
|
170
|
+
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
171
|
+
// The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
|
|
172
|
+
// authority that admitted this comment was resolved from the API, not read off the body.
|
|
173
|
+
comment: { body: subset.comment.body },
|
|
174
|
+
target: { type: isPR ? "pull_request" : "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Pull-request path. `action` is Forgejo's OWN word, matching the vocabulary the loader validated. */
|
|
179
|
+
function routePullRequest(subset, triggers, action) {
|
|
180
|
+
const pr = subset.pull_request;
|
|
181
|
+
if (!pr) {
|
|
182
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
183
|
+
}
|
|
184
|
+
const L = labelSet(pr.labels);
|
|
185
|
+
for (const rule of triggers.pullRequest ?? []) {
|
|
186
|
+
if (!rule.actions.has(action)) continue;
|
|
187
|
+
if (rule.predicate && !matchesRule(L, rule.predicate)) continue;
|
|
188
|
+
return {
|
|
189
|
+
enqueue: true,
|
|
190
|
+
flow: rule.flow,
|
|
191
|
+
packages: rule.packages,
|
|
192
|
+
image: rule.image,
|
|
193
|
+
resume: rule.resume,
|
|
194
|
+
matched: { index: rule.index, type: "pull_request", action },
|
|
195
|
+
target: {
|
|
196
|
+
type: "pull_request",
|
|
197
|
+
number: pr.number,
|
|
198
|
+
title: pr.title,
|
|
199
|
+
body: pr.body,
|
|
200
|
+
head: pr.head,
|
|
201
|
+
base: pr.base,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
206
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitLab trigger gate: decide whether a verified GitLab webhook becomes a paid agent job, and with
|
|
3
|
+
* what shape. The counterpart of filter.mjs, and pure for the same reason -- imports nothing side-effecting,
|
|
4
|
+
* touches no I/O, and NEVER throws -- so the security-critical decision is unit testable offline, without a
|
|
5
|
+
* server, a socket, or a GitLab.
|
|
6
|
+
*
|
|
7
|
+
* The evaluation ORDER is fail-closed and load-bearing, mirroring filter.mjs:
|
|
8
|
+
* 0. A non-numeric `user.id` is rejected FIRST, before the self compare -- `undefined === selfId` is
|
|
9
|
+
* false and would fall through to enqueue, failing OPEN on a malformed payload.
|
|
10
|
+
* 1. The bot-loop guard runs UNCONDITIONALLY. Under a project access token the harness is a project
|
|
11
|
+
* member and would clear the access gate, so its own status comment is an event that passes -- an
|
|
12
|
+
* unbounded paid recursion. Gating this behind the access check would reintroduce exactly that loop.
|
|
13
|
+
* 2. THEN the access gate, then the route.
|
|
14
|
+
*
|
|
15
|
+
* WHY THE ACCESS GATE COVERS LABEL TRIGGERS TOO, unlike GitHub.
|
|
16
|
+
*
|
|
17
|
+
* `CONST-TRIGGER-AUTHOR-GATE` rests on "only collaborators can apply labels -- therefore the label IS the
|
|
18
|
+
* human approval step". On GitLab that premise does not hold, in three independent ways: the minimum role
|
|
19
|
+
* for label management has differed across versions, Ultimate's custom roles let an operator grant it at
|
|
20
|
+
* any level, and a Guest can set labels on an issue AT CREATION -- so a stranger can open an issue already
|
|
21
|
+
* carrying the trigger label. A label therefore proves nothing here on its own, and every gitlab trigger
|
|
22
|
+
* is gated on the actor's resolved authority regardless of type. That verdict is produced by the receiver
|
|
23
|
+
* BEFORE this function (gitlab-members.mjs) and passed in, occupying the slot `author_association` holds
|
|
24
|
+
* on the GitHub side: the lookup is a network call, and putting a fetch inside the gate would destroy the
|
|
25
|
+
* purity that makes the gate testable.
|
|
26
|
+
*
|
|
27
|
+
* What crosses that boundary is a BOOLEAN, not GitLab's role integer. Every forge answers "may this actor
|
|
28
|
+
* start a job" in its own vocabulary -- an integer role here, a string enum on Forgejo, a payload field on
|
|
29
|
+
* GitHub, a group membership on Azure DevOps -- and only the answer is common. The `>= 30` threshold lives
|
|
30
|
+
* with the lookup that produces the number; this file owns what to DO about the verdict, which is the part
|
|
31
|
+
* that has to be the same everywhere.
|
|
32
|
+
*
|
|
33
|
+
* WHY A LABEL RULE MATCHES THE DIFF AND NOT THE LABEL SET.
|
|
34
|
+
*
|
|
35
|
+
* GitLab has no `labeled` action. Adding a label arrives as `action: "update"` carrying
|
|
36
|
+
* `changes.labels: { previous, current }`. Matching the CURRENT set -- the shape the GitHub path uses --
|
|
37
|
+
* would re-fire on every subsequent edit of that issue, because the label is still there: retitle it,
|
|
38
|
+
* reassign it, change its milestone, and each one starts another paid run. So the label set a rule is
|
|
39
|
+
* tested against is `current \ previous`, the labels THIS event added, and an `update` carrying no
|
|
40
|
+
* `changes.labels` matches nothing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
/** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
|
|
47
|
+
const MR_ACTIONS = new Set(["open", "update", "reopen", "approved"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
|
|
51
|
+
*
|
|
52
|
+
* `subset` is a `parseGitLabSubset` projection; `triggers` is the gitlab rule group; `authorized` is the
|
|
53
|
+
* receiver's resolved verdict for this actor; `deliveryId` is the stable `webhook-id`, carried into the job
|
|
54
|
+
* for dedup.
|
|
55
|
+
*/
|
|
56
|
+
export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, deliveryId) {
|
|
57
|
+
// (0) Fail-closed on identity. MUST precede the self compare -- see header.
|
|
58
|
+
if (typeof subset?.user?.id !== "number") {
|
|
59
|
+
return { enqueue: false, reason: "missing-sender-id" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// (1) Bot-loop guard: unconditional, independent of the access outcome below.
|
|
63
|
+
if (subset.user.id === selfId) {
|
|
64
|
+
return { enqueue: false, reason: "self" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// (2) The access gate, for EVERY trigger type. See the header for why a label does not substitute.
|
|
68
|
+
// Strict `!== true`, so anything that is not an explicit yes -- undefined, a stray truthy value, a
|
|
69
|
+
// resolver that returned the wrong shape -- refuses rather than passes.
|
|
70
|
+
if (authorized !== true) {
|
|
71
|
+
return { enqueue: false, reason: "author-not-allowed" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// (3) Route on the object kind.
|
|
75
|
+
const kind = subset.objectKind;
|
|
76
|
+
let resolved;
|
|
77
|
+
if (kind === "issue") {
|
|
78
|
+
resolved = routeLabel(subset, triggers, "issue");
|
|
79
|
+
} else if (kind === "merge_request") {
|
|
80
|
+
resolved = routeMergeRequest(subset, triggers);
|
|
81
|
+
} else if (kind === "note") {
|
|
82
|
+
resolved = routeNote(subset, triggers, knownFlows);
|
|
83
|
+
} else {
|
|
84
|
+
return { enqueue: false, reason: "unhandled-event" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!resolved.enqueue) return resolved; // carries the drop reason
|
|
88
|
+
|
|
89
|
+
// (4) Build the job from the INT-GITLAB-PAYLOAD-SUBSET fields only. `user.username` is deliberately
|
|
90
|
+
// NOT carried: it exists so the receiver could resolve an access level, and it is personal data with no
|
|
91
|
+
// downstream reader (no-pii-in-logs). `projectId` rides the job because every GitLab API path the
|
|
92
|
+
// worker needs takes the numeric id -- a `group/subgroup/project` path has no fixed segment count, and
|
|
93
|
+
// splitting one is how a nested project silently becomes its own parent group.
|
|
94
|
+
const job = {
|
|
95
|
+
repo: subset.project?.path,
|
|
96
|
+
projectId: subset.project?.id,
|
|
97
|
+
target: resolved.target,
|
|
98
|
+
flow: resolved.flow,
|
|
99
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
100
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
101
|
+
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
102
|
+
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
103
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
104
|
+
trigger: {
|
|
105
|
+
event: kind,
|
|
106
|
+
action: subset.action,
|
|
107
|
+
deliveryId,
|
|
108
|
+
sender: { id: subset.user.id },
|
|
109
|
+
matched: resolved.matched,
|
|
110
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return { enqueue: true, job };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Issue path. Fires on the labels THIS event added, never on the labels the issue happens to carry.
|
|
118
|
+
*
|
|
119
|
+
* `open` is included because an issue can be created with labels already on it, and that is a real
|
|
120
|
+
* trigger -- but only because the access gate above has already established the creator is a member. On
|
|
121
|
+
* GitHub the same case is safe for a different reason (only collaborators can apply labels at all).
|
|
122
|
+
*/
|
|
123
|
+
function routeLabel(subset, triggers, targetType) {
|
|
124
|
+
const added = addedLabels(subset);
|
|
125
|
+
if (added === null) return { enqueue: false, reason: "no-label-change" };
|
|
126
|
+
|
|
127
|
+
const rule = firstMatchingRule(triggers?.label, added);
|
|
128
|
+
if (!rule) return { enqueue: false, reason: "no-allowlisted-label" };
|
|
129
|
+
return {
|
|
130
|
+
enqueue: true,
|
|
131
|
+
flow: rule.flow,
|
|
132
|
+
packages: rule.packages,
|
|
133
|
+
image: rule.image,
|
|
134
|
+
resume: rule.resume,
|
|
135
|
+
matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
|
|
136
|
+
target: buildTarget(subset, targetType),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The label set an event ADDED, or `null` when it added none.
|
|
142
|
+
*
|
|
143
|
+
* `open` is the one case where there is no diff and the whole set is the addition: the issue did not
|
|
144
|
+
* exist before, so every label on it arrived with it. Every other action needs `changes.labels`, and its
|
|
145
|
+
* absence means no label moved -- which is precisely the case that must not re-fire.
|
|
146
|
+
*/
|
|
147
|
+
function addedLabels(subset) {
|
|
148
|
+
if (subset.action === "open") {
|
|
149
|
+
const L = labelSet(subset.target?.labels);
|
|
150
|
+
return L.size > 0 ? L : null;
|
|
151
|
+
}
|
|
152
|
+
const changes = subset.labelChanges;
|
|
153
|
+
if (!changes) return null;
|
|
154
|
+
const previous = labelSet(changes.previous);
|
|
155
|
+
const added = new Set();
|
|
156
|
+
for (const name of labelSet(changes.current)) {
|
|
157
|
+
if (!previous.has(name)) added.add(name);
|
|
158
|
+
}
|
|
159
|
+
return added.size > 0 ? added : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Merge-request path. A rule carrying a positive selector is label-gated and matches the added labels
|
|
164
|
+
* exactly as an issue rule does; a rule without one fires on its named actions alone -- which is safe here
|
|
165
|
+
* only because the access gate already ran, and is why the loader does not demand a positive selector on
|
|
166
|
+
* a gitlab MR rule the way it does on a github `labeled` one.
|
|
167
|
+
*/
|
|
168
|
+
function routeMergeRequest(subset, triggers) {
|
|
169
|
+
const action = subset.action;
|
|
170
|
+
if (!MR_ACTIONS.has(action)) return { enqueue: false, reason: "unhandled-event" };
|
|
171
|
+
|
|
172
|
+
const added = addedLabels(subset);
|
|
173
|
+
for (const rule of triggers?.pullRequest ?? []) {
|
|
174
|
+
if (!rule.actions.has(action)) continue;
|
|
175
|
+
const positive = (rule.predicate?.any?.length ?? 0) + (rule.predicate?.all?.length ?? 0) > 0;
|
|
176
|
+
if (positive) {
|
|
177
|
+
if (added === null || !firstMatchingRule([rule], added)) continue;
|
|
178
|
+
return mrResult(subset, rule, { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) });
|
|
179
|
+
}
|
|
180
|
+
return mrResult(subset, rule, { index: rule.index, type: "pull_request", action });
|
|
181
|
+
}
|
|
182
|
+
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mrResult(subset, rule, matched) {
|
|
186
|
+
return {
|
|
187
|
+
enqueue: true,
|
|
188
|
+
flow: rule.flow,
|
|
189
|
+
packages: rule.packages,
|
|
190
|
+
image: rule.image,
|
|
191
|
+
resume: rule.resume,
|
|
192
|
+
matched,
|
|
193
|
+
target: buildTarget(subset, "pull_request"),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Note (comment) path. `noteable_type` states whether the comment is on an issue or a merge request --
|
|
199
|
+
* GitHub infers the same thing from the presence of `issue.pull_request`. Routing it wrong would mint an
|
|
200
|
+
* issue branch for something that is already a merge request: wrong work, no error, and it looks like a
|
|
201
|
+
* successful run.
|
|
202
|
+
*/
|
|
203
|
+
function routeNote(subset, triggers, knownFlows) {
|
|
204
|
+
if (subset.action !== "create") return { enqueue: false, reason: "unhandled-event" };
|
|
205
|
+
|
|
206
|
+
const phrase = triggers?.comment?.phrase;
|
|
207
|
+
const body = subset.note;
|
|
208
|
+
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
209
|
+
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
210
|
+
}
|
|
211
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
|
|
212
|
+
// known flow name, so a comment cannot summon an unlisted flow.
|
|
213
|
+
let flow = triggers.comment?.defaultFlow;
|
|
214
|
+
const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
|
|
215
|
+
if (match && knownFlows?.has(match[1])) flow = match[1];
|
|
216
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
217
|
+
return { enqueue: false, reason: "no-flow" };
|
|
218
|
+
}
|
|
219
|
+
const targetType = subset.noteableType === "MergeRequest" ? "pull_request" : "issue";
|
|
220
|
+
return {
|
|
221
|
+
enqueue: true,
|
|
222
|
+
flow,
|
|
223
|
+
packages: triggers.comment.packages,
|
|
224
|
+
image: triggers.comment.image,
|
|
225
|
+
resume: triggers.comment.resume,
|
|
226
|
+
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
227
|
+
target: buildTarget(subset, targetType),
|
|
228
|
+
// The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
|
|
229
|
+
// counterpart exists on GitLab: the approval is the resolved access level, which is a harness
|
|
230
|
+
// decision and belongs in `matched`, not in a field that looks like payload.
|
|
231
|
+
comment: { body },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Escape a literal for embedding in a RegExp -- the trigger phrase is config, not a pattern. */
|
|
236
|
+
function escapeLiteral(literal) {
|
|
237
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The job's target. `iid` is the per-project number a human sees and an API path takes; `id` would be a
|
|
242
|
+
* global key naming somebody else's issue. `type` uses the SHARED vocabulary (`issue` / `pull_request`)
|
|
243
|
+
* rather than GitLab's nouns, because it discriminates a job shape the worker already understands.
|
|
244
|
+
*/
|
|
245
|
+
function buildTarget(subset, type) {
|
|
246
|
+
return {
|
|
247
|
+
type,
|
|
248
|
+
number: subset.target?.iid,
|
|
249
|
+
title: subset.target?.title,
|
|
250
|
+
body: subset.target?.description,
|
|
251
|
+
};
|
|
252
|
+
}
|