@edgehero/pi-dispatch-receiver 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/azure-members.mjs +86 -11
- package/src/cli.mjs +2 -1
- package/src/config.mjs +73 -8
- package/src/filter-azure.mjs +12 -0
- package/src/filter-forgejo.mjs +12 -0
- package/src/filter-gitlab.mjs +12 -0
- package/src/filter.mjs +118 -11
- package/src/poller.mjs +141 -5
- package/src/receiver.mjs +48 -5
- package/src/start.mjs +31 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": "^0.
|
|
48
|
+
"@edgehero/pi-dispatch": "^0.2.0",
|
|
49
49
|
"bullmq": "5.80.4",
|
|
50
50
|
"ioredis": "5.11.1"
|
|
51
51
|
}
|
package/src/azure-members.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* one, which is a real cost this arm carries and the other three do not:
|
|
8
8
|
*
|
|
9
9
|
* 1. the actor -> a subject DESCRIPTOR. A pull-request payload gives a GUID, a work-item payload gives
|
|
10
|
-
* only an email address, so there are two lookups depending on which the event carried.
|
|
10
|
+
* only an email address, so there are two lookups depending on which the event carried. The email one
|
|
11
|
+
* is a LIST walk and may itself span several requests -- see the pagination note on resolveDescriptor.
|
|
11
12
|
* 2. the descriptor -> membership of the project's own group.
|
|
12
13
|
*
|
|
13
14
|
* Both can go indeterminate, so the indeterminate surface here is wider than GitLab's, not merely equal to
|
|
@@ -29,6 +30,19 @@
|
|
|
29
30
|
|
|
30
31
|
import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How many pages of the organisation's user list one delivery may walk while looking for an email address.
|
|
35
|
+
*
|
|
36
|
+
* The cap is counted in PAGES and not in users, because the page size is Azure's to choose and is not part
|
|
37
|
+
* of the request -- what this bounds is request AMPLIFICATION: one webhook must never turn into an
|
|
38
|
+
* unbounded crawl of a directory, and an org that grew a page while the loop was running must still
|
|
39
|
+
* terminate. Twenty is chosen to be comfortably past any real organisation at Azure's own page size
|
|
40
|
+
* (hundreds of subjects per page) and still a fixed number of round trips.
|
|
41
|
+
*
|
|
42
|
+
* Running out of pages is NOT a refusal -- see resolveDescriptor.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_USER_PAGES = 20;
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* Build the resolver.
|
|
34
48
|
*
|
|
@@ -44,6 +58,21 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
44
58
|
// Azure authenticates a PAT as HTTP Basic with an empty username.
|
|
45
59
|
const auth = `Basic ${Buffer.from(`:${token}`, "utf8").toString("base64")}`;
|
|
46
60
|
|
|
61
|
+
/**
|
|
62
|
+
* One Graph GET. `{ body, continuation }` on a 2xx, `{ indeterminate }` on anything else -- the whole
|
|
63
|
+
* error taxonomy of this module lives here, once, so a new call site cannot invent a different one.
|
|
64
|
+
*
|
|
65
|
+
* `continuation` carries Azure's paging cursor for the LIST endpoints and is null for everything else.
|
|
66
|
+
* The Graph does not use a Link header and does not put the cursor in the body: a page that is not the
|
|
67
|
+
* last one returns an `X-MS-ContinuationToken` RESPONSE HEADER, which the caller sends back as a
|
|
68
|
+
* `continuationToken` QUERY PARAMETER on the next request. It is read here rather than in a sibling
|
|
69
|
+
* helper so the two single-object descriptor lookups and the membership lookup keep sharing one code
|
|
70
|
+
* path; they simply ignore a field Azure never sets for them.
|
|
71
|
+
*
|
|
72
|
+
* `headers.get` is called defensively. A real `Response` always has it, but `fetchFn` is injected, and a
|
|
73
|
+
* fake that returns a plain object must not turn a lookup into a TypeError -- which would escape as a
|
|
74
|
+
* throw rather than as this module's `{ indeterminate }`.
|
|
75
|
+
*/
|
|
47
76
|
async function get(url) {
|
|
48
77
|
let res;
|
|
49
78
|
try {
|
|
@@ -55,8 +84,9 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
55
84
|
// Status only. An Azure error body can echo the request, and the request carried the token.
|
|
56
85
|
return { indeterminate: `azure lookup returned ${res.status}` };
|
|
57
86
|
}
|
|
87
|
+
const continuation = typeof res.headers?.get === "function" ? res.headers.get("x-ms-continuationtoken") : null;
|
|
58
88
|
try {
|
|
59
|
-
return { body: await res.json() };
|
|
89
|
+
return { body: await res.json(), continuation: continuation || null };
|
|
60
90
|
} catch (err) {
|
|
61
91
|
return { indeterminate: `azure lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
|
|
62
92
|
}
|
|
@@ -85,6 +115,12 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
85
115
|
// Membership is TRANSITIVE via `direction=up`: a member of a team inside the project is a member of
|
|
86
116
|
// the project, and asking only for direct membership would refuse most real organisations -- the same
|
|
87
117
|
// mistake `members/all` avoids on GitLab.
|
|
118
|
+
//
|
|
119
|
+
// This reads ONE page, unlike the users listing above, and that is a scope statement rather than a
|
|
120
|
+
// claim: what is being listed here is one subject's own containers, not a directory, so the two are
|
|
121
|
+
// not the same size of question. `get` surfaces `continuation` for this call too, so if this ever
|
|
122
|
+
// needs following the mechanism is already here -- but no cap or verdict is invented for a paging
|
|
123
|
+
// behaviour nothing in this codebase has observed on this endpoint.
|
|
88
124
|
const memberships = await get(`${vssps}/_apis/graph/memberships/${encodeURIComponent(descriptor.value)}?direction=up&api-version=7.1-preview.1`);
|
|
89
125
|
if (memberships.indeterminate) return memberships;
|
|
90
126
|
const list = memberships.body?.value;
|
|
@@ -94,7 +130,38 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
94
130
|
return { authorized: list.some((m) => m?.containerDescriptor === container) };
|
|
95
131
|
};
|
|
96
132
|
|
|
97
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* The actor's subject descriptor: by GUID for a pull request, by email for a work item.
|
|
135
|
+
*
|
|
136
|
+
* THE EMAIL PATH IS PAGINATED, and that is the whole difficulty of this function. Azure's Graph has no
|
|
137
|
+
* lookup-by-mail-address endpoint, so the organisation's user list is fetched and filtered locally --
|
|
138
|
+
* and that list is paged. Reading only the first page meant that in any organisation whose directory
|
|
139
|
+
* exceeds one page, an actor beyond it resolved to nobody, which is DETERMINATE: the gate refused, the
|
|
140
|
+
* receiver answered 204, and work-item (tag and comment) triggers simply never fired for those people,
|
|
141
|
+
* behind a status indistinguishable from a stranger being correctly turned away. Pull requests were
|
|
142
|
+
* never affected -- a PR names its actor by GUID and takes the direct descriptor lookup above.
|
|
143
|
+
*
|
|
144
|
+
* So the pages are followed (see `get` for the header/query-parameter mechanism), and the three ways out
|
|
145
|
+
* are deliberately three different answers:
|
|
146
|
+
*
|
|
147
|
+
* - FOUND, on any page -> the descriptor. The loop stops at the hit, so an actor on page 1 still costs
|
|
148
|
+
* exactly one request; nobody pays for pagination that was not needed.
|
|
149
|
+
* - the listing ENDED (a page with no continuation token) and the actor was not in it -> determinate
|
|
150
|
+
* `null`, which the caller refuses. This is the honest refusal: the entire directory was read.
|
|
151
|
+
* - the PAGE CAP was reached while Azure was still offering more -> `{ indeterminate }`, never `null`.
|
|
152
|
+
* The search was abandoned, not completed, and the module's own docblock is explicit that
|
|
153
|
+
* indeterminate and unauthorized must not be conflated: this way the receiver answers 503 and Azure
|
|
154
|
+
* redelivers, instead of burying an exhausted search inside a 204 that reads as a policy decision.
|
|
155
|
+
*
|
|
156
|
+
* A FILTERED LOOKUP WOULD BE BETTER AND IS NOT USED. Nothing in the Graph surface this module already
|
|
157
|
+
* speaks offers a by-mail filter on `graph/users` (`subjectTypes` selects kinds of subject, not
|
|
158
|
+
* identities), and the endpoints that come close -- a subject query, the older Identities API -- return
|
|
159
|
+
* shapes this file has never handled and whose descriptor flavour may not be the one `graph/memberships`
|
|
160
|
+
* accepts. Guessing one would repeat exactly the mistake the security-namespace paragraph above refuses:
|
|
161
|
+
* a confident answer about a different object. The list walk is coarser and verifiable, so it stays.
|
|
162
|
+
*
|
|
163
|
+
* The address is still never logged or returned, including in the indeterminate reason.
|
|
164
|
+
*/
|
|
98
165
|
async function resolveDescriptor(actor) {
|
|
99
166
|
if (typeof actor?.id === "string" && actor.id !== "") {
|
|
100
167
|
const res = await get(`${vssps}/_apis/graph/descriptors/${encodeURIComponent(actor.id)}?api-version=7.1-preview.1`);
|
|
@@ -103,15 +170,23 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
103
170
|
return { value: typeof value === "string" && value !== "" ? value : null };
|
|
104
171
|
}
|
|
105
172
|
if (typeof actor?.email === "string" && actor.email !== "") {
|
|
106
|
-
// There is no lookup-by-email endpoint, so the users list is filtered. `subjectTypes=aad,msa`
|
|
107
|
-
// excludes groups and service principals, which cannot be the human this gate is about.
|
|
108
|
-
const res = await get(`${vssps}/_apis/graph/users?subjectTypes=aad,msa&api-version=7.1-preview.1`);
|
|
109
|
-
if (res.indeterminate) return res;
|
|
110
|
-
const users = res.body?.value;
|
|
111
|
-
if (!Array.isArray(users)) return { indeterminate: "azure users lookup returned no array" };
|
|
112
173
|
const wanted = actor.email.toLowerCase();
|
|
113
|
-
|
|
114
|
-
|
|
174
|
+
let token = null;
|
|
175
|
+
for (let page = 0; page < MAX_USER_PAGES; page++) {
|
|
176
|
+
// `subjectTypes=aad,msa` excludes groups and service principals, which cannot be the human this
|
|
177
|
+
// gate is about. The continuation token is appended only when there is one, so the FIRST request
|
|
178
|
+
// is byte-identical to the single-page one this replaced.
|
|
179
|
+
const res = await get(`${vssps}/_apis/graph/users?subjectTypes=aad,msa&api-version=7.1-preview.1${token ? `&continuationToken=${encodeURIComponent(token)}` : ""}`);
|
|
180
|
+
if (res.indeterminate) return res;
|
|
181
|
+
const users = res.body?.value;
|
|
182
|
+
if (!Array.isArray(users)) return { indeterminate: "azure users lookup returned no array" };
|
|
183
|
+
const hit = users.find((u) => String(u?.principalName ?? "").toLowerCase() === wanted || String(u?.mailAddress ?? "").toLowerCase() === wanted);
|
|
184
|
+
if (hit) return { value: typeof hit.descriptor === "string" ? hit.descriptor : null };
|
|
185
|
+
if (!res.continuation) return { value: null }; // the list ended: a determinate "not in this org"
|
|
186
|
+
token = res.continuation;
|
|
187
|
+
}
|
|
188
|
+
// Still more pages on offer. Nothing was decided, so nothing is refused.
|
|
189
|
+
return { indeterminate: `azure users lookup did not reach the actor within ${MAX_USER_PAGES} pages` };
|
|
115
190
|
}
|
|
116
191
|
// Neither a GUID nor a parseable address: the delivery named nobody this gate can ask about.
|
|
117
192
|
return { value: null };
|
package/src/cli.mjs
CHANGED
|
@@ -22,7 +22,8 @@ const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub
|
|
|
22
22
|
reads api.github.com with the operator's own credential instead
|
|
23
23
|
|
|
24
24
|
Config comes from the environment (see .env.example): WEBHOOK_SECRET is required for serve
|
|
25
|
-
(poll needs none — there is no inbound delivery to
|
|
25
|
+
only when your triggers name github (poll needs none either — there is no inbound delivery to
|
|
26
|
+
verify, and a forge-only deployment has no github endpoint), PI_TRIGGERS_FILE overrides the
|
|
26
27
|
./triggers.json default, VALKEY_URL names the queue, RECEIVER_PORT/RECEIVER_BIND choose where
|
|
27
28
|
serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches and how often.`;
|
|
28
29
|
|
package/src/config.mjs
CHANGED
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* trigger schema is likewise single-sourced from `@edgehero/pi-dispatch/triggers` -- both services validate
|
|
9
9
|
* the WHOLE unified triggers file and each selects the `on.type` it owns (issue #20).
|
|
10
10
|
*
|
|
11
|
-
* - `webhookSecret` is REQUIRED
|
|
12
|
-
* raw body, and an unverified webhook is a
|
|
11
|
+
* - `webhookSecret` is REQUIRED WHENEVER THE DEPLOYMENT SERVES GITHUB (`servesGithub`, below): without it
|
|
12
|
+
* the receiver cannot verify `X-Hub-Signature-256` over the raw body, and an unverified webhook is a
|
|
13
|
+
* forgeable paid-agent trigger (CONST-HMAC-OVER-RAW-BODY). A GitLab-only / Forgejo-only / Azure-only
|
|
14
|
+
* deployment has no GitHub endpoint and therefore nothing for that secret to verify, so demanding one
|
|
15
|
+
* there blocked a deployment the harness fully supports (issue #99).
|
|
16
|
+
* - `servesGithub` is that decision, made HERE and named, because three separate things hang off it and
|
|
17
|
+
* they must never disagree: the WEBHOOK_SECRET requirement, whether `/` is mounted at all
|
|
18
|
+
* (receiver.mjs), and whether boot resolves the harness's GitHub identity to arm the bot-loop guard
|
|
19
|
+
* (start.mjs). Deriving it inline in two places is how a route ends up live with a disarmed guard.
|
|
13
20
|
* - `triggers` is the receiver's webhook allowlist, grouped by type: label rules (the label IS the
|
|
14
21
|
* collaborator approval), the single comment trigger (phrase + default flow), and pull_request rules.
|
|
15
22
|
* Only collaborators can apply labels, so the label/PR-label allowlist is the human approval gate
|
|
@@ -36,24 +43,72 @@ const DEFAULT_TRIGGERS_PATH = "./triggers.json";
|
|
|
36
43
|
* (`readFile`, `fileExists`) so the loader is hermetically testable and never touches disk in tests.
|
|
37
44
|
*/
|
|
38
45
|
export function loadReceiverConfig(env = process.env, { readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
46
|
+
// Triggers and the GitHub auth block are parsed FIRST, before the secret check, because the secret is
|
|
47
|
+
// now conditional on what they say. Their own fail-loud errors therefore surface first -- a garbled
|
|
48
|
+
// GITHUB_AUTH_SOURCE is reported as a garbled GITHUB_AUTH_SOURCE, never laundered into a confusing
|
|
49
|
+
// complaint about WEBHOOK_SECRET.
|
|
50
|
+
const triggers = loadTriggers(env, readFile, fileExists);
|
|
51
|
+
const github = loadGitHubAuth(env, fileExists);
|
|
52
|
+
const servesGithub = decideServesGithub(env, triggers.github);
|
|
53
|
+
|
|
39
54
|
const webhookSecret = env.WEBHOOK_SECRET;
|
|
40
|
-
if (webhookSecret === undefined || webhookSecret.trim() === "") {
|
|
55
|
+
if (servesGithub && (webhookSecret === undefined || webhookSecret.trim() === "")) {
|
|
56
|
+
// Same message and same behaviour as before for every deployment that serves GitHub: the secret is
|
|
57
|
+
// the whole trust boundary of the `/` endpoint, so a receiver that would mount it without one must
|
|
58
|
+
// refuse to start rather than answer 401 forever (or, worse, accept forged deliveries).
|
|
41
59
|
throw configError("WEBHOOK_SECRET is required; refusing to start a receiver that cannot verify signatures");
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
return {
|
|
63
|
+
// Passed through as the env gave it (possibly undefined) when the deployment serves no GitHub. Nothing
|
|
64
|
+
// reads it in that case -- receiver.mjs builds no github handler at all -- and `servesGithub` beside it
|
|
65
|
+
// is what says so, so an unread absent secret can never be mistaken for an armed endpoint.
|
|
45
66
|
webhookSecret,
|
|
67
|
+
servesGithub,
|
|
46
68
|
valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379", // mirrors worker config: producer and consumer share one queue
|
|
47
69
|
port: positiveInt(env, "RECEIVER_PORT", 3000),
|
|
48
70
|
bind: env.RECEIVER_BIND ?? "0.0.0.0",
|
|
49
|
-
triggers
|
|
50
|
-
github
|
|
71
|
+
triggers,
|
|
72
|
+
github,
|
|
51
73
|
gitlab: loadGitLabConfig(env),
|
|
52
74
|
forgejo: loadForgejoConfig(env),
|
|
53
75
|
azure: loadAzureConfig(env),
|
|
54
76
|
};
|
|
55
77
|
}
|
|
56
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Does this deployment actually SERVE GitHub? Three things hang off the answer -- the WEBHOOK_SECRET
|
|
81
|
+
* requirement above, whether `/` is mounted (receiver.mjs), and whether boot resolves the harness's own
|
|
82
|
+
* GitHub id to arm the bot-loop guard (start.mjs) -- so it is decided once, here, from the operator's own
|
|
83
|
+
* files rather than assumed.
|
|
84
|
+
*
|
|
85
|
+
* Either of two signals is a yes:
|
|
86
|
+
*
|
|
87
|
+
* - THE TRIGGERS FILE NAMES A GITHUB WEBHOOK RULE. `triggers.github` is the group `loadTriggers` built
|
|
88
|
+
* from every `run.kind: "github"` webhook entry, and `emptyGroup()` is what a forge the file never
|
|
89
|
+
* mentions gets. So an empty group -- zero label rules, no comment trigger, zero pull_request rules --
|
|
90
|
+
* means a signed GitHub delivery could not fire anything even if it arrived: the endpoint would verify
|
|
91
|
+
* the HMAC, match no rule, and answer 204 forever. That is not a GitHub deployment.
|
|
92
|
+
*
|
|
93
|
+
* - GITHUB_AUTH_SOURCE IS SET EXPLICITLY. An operator who names an auth source has said "GitHub" out
|
|
94
|
+
* loud, and they may well be arming the receiver before the first rule exists (or driving it from the
|
|
95
|
+
* poller, which is GitHub-only). Explicit intent wins over the inferred signal, and it is also what
|
|
96
|
+
* keeps this change byte-identical to today's behaviour for every deployment that sets the variable.
|
|
97
|
+
* Read from `env` and NOT from the parsed block: `loadGitHubAuth` defaults `source` to "gh", so
|
|
98
|
+
* `github.source` cannot tell an explicit choice from a default and would answer yes for everyone.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately NOT a signal: the presence of GITHUB_PAT / GITHUB_APP_* alone. Those are credentials a
|
|
101
|
+
* shared env file may carry for the worker (which mints per-job tokens and is a separate process); a
|
|
102
|
+
* credential lying around is not a statement that this receiver terminates GitHub webhooks.
|
|
103
|
+
*/
|
|
104
|
+
function decideServesGithub(env, githubGroup) {
|
|
105
|
+
const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0;
|
|
106
|
+
// `loadGitHubAuth` has already refused a garbled value by the time we get here, so anything non-empty
|
|
107
|
+
// here is one of pat|gh|app -- a real choice, not a typo we would be reading as consent.
|
|
108
|
+
const explicitAuthSource = env.GITHUB_AUTH_SOURCE !== undefined && env.GITHUB_AUTH_SOURCE !== "";
|
|
109
|
+
return hasGithubTriggers || explicitAuthSource;
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
/**
|
|
58
113
|
* The GitLab endpoint's configuration, or `null` when the deployment serves no GitLab -- in which case no
|
|
59
114
|
* `/gitlab` route exists at all, rather than one that answers 401. An endpoint that responds is an endpoint
|
|
@@ -220,11 +275,13 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
220
275
|
knownFlows.add(run.flow);
|
|
221
276
|
const group = groups[run.kind];
|
|
222
277
|
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 });
|
|
278
|
+
group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
|
|
224
279
|
} 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
|
|
280
|
+
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
226
281
|
} else if (on.type === "pull_request") {
|
|
227
|
-
|
|
282
|
+
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
283
|
+
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
284
|
+
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, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
|
|
228
285
|
}
|
|
229
286
|
}
|
|
230
287
|
|
|
@@ -246,6 +303,14 @@ export function triggersFilePath(env = process.env) {
|
|
|
246
303
|
* restart, mirroring how the worker re-reads the settings overlay per job. If the new file is
|
|
247
304
|
* missing/unparseable/invalid, the running triggers are KEPT (never crash a live receiver on a bad edit)
|
|
248
305
|
* and the reason is returned. Returns `{ ok: true }` or `{ invalid }`.
|
|
306
|
+
*
|
|
307
|
+
* `servesGithub` is deliberately NOT recomputed. It is a BOOT decision, because the two other things it
|
|
308
|
+
* governs are boot facts: whether `/` was mounted and whether the harness's GitHub identity was resolved.
|
|
309
|
+
* Recomputing it here would let a live file edit mount a github endpoint whose bot-loop guard was never
|
|
310
|
+
* armed -- the one state this design exists to make unreachable. Both directions of a live edit are
|
|
311
|
+
* therefore safe and neither is silent: adding a github rule to a github-free receiver leaves `/` 404ing
|
|
312
|
+
* until a restart (the operator's next step anyway, since the deployment also needs a webhook secret), and
|
|
313
|
+
* removing the last github rule leaves `/` verifying deliveries that now match nothing and answer 204.
|
|
249
314
|
*/
|
|
250
315
|
export function reloadTriggers(env, cfg, { readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
251
316
|
try {
|
package/src/filter-azure.mjs
CHANGED
|
@@ -105,6 +105,12 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
105
105
|
flow: resolved.flow,
|
|
106
106
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
107
107
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
108
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
109
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
110
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
111
|
+
// basename(folder) restraint already exists to prevent.
|
|
112
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
113
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
108
114
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
109
115
|
trigger: {
|
|
110
116
|
event,
|
|
@@ -165,6 +171,8 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
165
171
|
repository: rule.repository,
|
|
166
172
|
packages: rule.packages,
|
|
167
173
|
image: rule.image,
|
|
174
|
+
skillsDir: rule.skillsDir,
|
|
175
|
+
instructions: rule.instructions,
|
|
168
176
|
resume: rule.resume,
|
|
169
177
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
170
178
|
target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
|
|
@@ -193,6 +201,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
193
201
|
repository: triggers.comment.repository,
|
|
194
202
|
packages: triggers.comment.packages,
|
|
195
203
|
image: triggers.comment.image,
|
|
204
|
+
skillsDir: triggers.comment.skillsDir,
|
|
205
|
+
instructions: triggers.comment.instructions,
|
|
196
206
|
resume: triggers.comment.resume,
|
|
197
207
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
198
208
|
// No author_association: Azure has none, and the authority that admitted this comment was resolved
|
|
@@ -219,6 +229,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
219
229
|
repository: rule.repository,
|
|
220
230
|
packages: rule.packages,
|
|
221
231
|
image: rule.image,
|
|
232
|
+
skillsDir: rule.skillsDir,
|
|
233
|
+
instructions: rule.instructions,
|
|
222
234
|
resume: rule.resume,
|
|
223
235
|
matched: { index: rule.index, type: "pull_request", action },
|
|
224
236
|
target: {
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -107,6 +107,12 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
107
107
|
flow: resolved.flow,
|
|
108
108
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
109
109
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
110
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
111
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
112
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
113
|
+
// basename(folder) restraint already exists to prevent.
|
|
114
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
115
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
110
116
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
111
117
|
trigger: {
|
|
112
118
|
event: eventName,
|
|
@@ -133,6 +139,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
133
139
|
flow: rule.flow,
|
|
134
140
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
135
141
|
image: rule.image,
|
|
142
|
+
skillsDir: rule.skillsDir,
|
|
143
|
+
instructions: rule.instructions,
|
|
136
144
|
resume: rule.resume,
|
|
137
145
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
138
146
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
@@ -166,6 +174,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
166
174
|
flow,
|
|
167
175
|
packages: triggers.comment.packages,
|
|
168
176
|
image: triggers.comment.image,
|
|
177
|
+
skillsDir: triggers.comment.skillsDir,
|
|
178
|
+
instructions: triggers.comment.instructions,
|
|
169
179
|
resume: triggers.comment.resume,
|
|
170
180
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
171
181
|
// The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
|
|
@@ -190,6 +200,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
190
200
|
flow: rule.flow,
|
|
191
201
|
packages: rule.packages,
|
|
192
202
|
image: rule.image,
|
|
203
|
+
skillsDir: rule.skillsDir,
|
|
204
|
+
instructions: rule.instructions,
|
|
193
205
|
resume: rule.resume,
|
|
194
206
|
matched: { index: rule.index, type: "pull_request", action },
|
|
195
207
|
target: {
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -98,6 +98,12 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
98
98
|
flow: resolved.flow,
|
|
99
99
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
100
100
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
101
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
102
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
103
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
104
|
+
// basename(folder) restraint already exists to prevent.
|
|
105
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
106
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
101
107
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
102
108
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
103
109
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
@@ -131,6 +137,8 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
131
137
|
flow: rule.flow,
|
|
132
138
|
packages: rule.packages,
|
|
133
139
|
image: rule.image,
|
|
140
|
+
skillsDir: rule.skillsDir,
|
|
141
|
+
instructions: rule.instructions,
|
|
134
142
|
resume: rule.resume,
|
|
135
143
|
matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
|
|
136
144
|
target: buildTarget(subset, targetType),
|
|
@@ -188,6 +196,8 @@ function mrResult(subset, rule, matched) {
|
|
|
188
196
|
flow: rule.flow,
|
|
189
197
|
packages: rule.packages,
|
|
190
198
|
image: rule.image,
|
|
199
|
+
skillsDir: rule.skillsDir,
|
|
200
|
+
instructions: rule.instructions,
|
|
191
201
|
resume: rule.resume,
|
|
192
202
|
matched,
|
|
193
203
|
target: buildTarget(subset, "pull_request"),
|
|
@@ -222,6 +232,8 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
222
232
|
flow,
|
|
223
233
|
packages: triggers.comment.packages,
|
|
224
234
|
image: triggers.comment.image,
|
|
235
|
+
skillsDir: triggers.comment.skillsDir,
|
|
236
|
+
instructions: triggers.comment.instructions,
|
|
225
237
|
resume: triggers.comment.resume,
|
|
226
238
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
227
239
|
target: buildTarget(subset, targetType),
|
package/src/filter.mjs
CHANGED
|
@@ -20,11 +20,18 @@
|
|
|
20
20
|
* check would reintroduce exactly that loop.
|
|
21
21
|
* 2. Only then route on event + action: issue label, author-gated comment, or pull_request.
|
|
22
22
|
*
|
|
23
|
-
* PR
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* PR GATE (security-critical), three arms, and WHICH FIELD each reads is the load-bearing part:
|
|
24
|
+
* - `labeled` is self-gating: only collaborators can apply labels, so the label predicate IS the
|
|
25
|
+
* approval, exactly as on the issue label path. No author check at all, deliberately.
|
|
26
|
+
* - auto actions (`opened|synchronize|reopened`) fire only when the PR `author_association` is a
|
|
27
|
+
* collaborator, so a stranger's fork PR never auto-fires.
|
|
28
|
+
* - a submitted review (`review_submitted`, issue #66) fires on the REVIEWER's
|
|
29
|
+
* `review.author_association`, NEVER the PR author's. This is the first GitHub event where the actor
|
|
30
|
+
* and the PR author are DIFFERENT PEOPLE, which is why the auto-action shortcut of gating on the PR
|
|
31
|
+
* author does not carry: a collaborator reviewing a stranger's fork PR must run, and a stranger
|
|
32
|
+
* reviewing their own PR must not. Reading `pr.author_association` here inverts both halves at once.
|
|
33
|
+
* All three are hard-coded here, never config-optional -- an ungated auto-trigger is an unbounded paid run
|
|
34
|
+
* started by whoever opens a fork PR (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
|
|
28
35
|
*
|
|
29
36
|
* `selfId` is the numeric id of whichever identity posts as the harness (the App's bot user, or the PAT
|
|
30
37
|
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
@@ -35,6 +42,12 @@ const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
|
35
42
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
36
43
|
const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
37
44
|
const PR_AUTO_ACTIONS = new Set(["opened", "synchronize", "reopened"]);
|
|
45
|
+
// The triggers.json word for a submitted review, and the raw action GitHub sends on the
|
|
46
|
+
// `pull_request_review` event. They differ on purpose -- see the routing block below.
|
|
47
|
+
const REVIEW_ACTION = "review_submitted";
|
|
48
|
+
const REVIEW_EVENT_ACTION = "submitted";
|
|
49
|
+
// PR_AUTO_ACTIONS is deliberately NOT extended with REVIEW_ACTION: it exists only to select the
|
|
50
|
+
// `pr-author-not-allowed` drop reason, and the review path has reasons of its own.
|
|
38
51
|
|
|
39
52
|
export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
40
53
|
// (0) Fail-closed on identity. MUST precede the self compare -- see header, ordering constraint.
|
|
@@ -61,6 +74,17 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
61
74
|
resolved = routeComment(subset, triggers, cfg?.triggers?.knownFlows);
|
|
62
75
|
} else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
|
|
63
76
|
resolved = routePullRequest(subset, triggers, action);
|
|
77
|
+
} else if (eventName === "pull_request_review" && action === REVIEW_EVENT_ACTION) {
|
|
78
|
+
// A SECOND event name on the SAME route, not a fifth on.type (issue #66): a review is an event about
|
|
79
|
+
// a pull request, and GitLab's analogue `approved` already rides on.type "pull_request", so a new
|
|
80
|
+
// type would make one forge's review a type and the other's an action. The route is handed the
|
|
81
|
+
// CANONICAL word because that is what triggers.json spells and what the rule loop matches --
|
|
82
|
+
// `submitted` alone would be meaningless in a pull_request action list.
|
|
83
|
+
//
|
|
84
|
+
// `edited` and `dismissed` fall through to unhandled-event deliberately: an edit would re-fire on
|
|
85
|
+
// text the harness has already been paid to read, and a dismissal removes a verdict rather than
|
|
86
|
+
// stating one.
|
|
87
|
+
resolved = routePullRequest(subset, triggers, REVIEW_ACTION);
|
|
64
88
|
} else {
|
|
65
89
|
return { enqueue: false, reason: "unhandled-event" };
|
|
66
90
|
}
|
|
@@ -87,6 +111,12 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
87
111
|
flow: resolved.flow,
|
|
88
112
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
89
113
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
114
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
115
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
116
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
117
|
+
// basename(folder) restraint already exists to prevent.
|
|
118
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
119
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
90
120
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
91
121
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
92
122
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
@@ -102,6 +132,16 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
102
132
|
sender: { id: subset.sender.id },
|
|
103
133
|
matched: resolved.matched,
|
|
104
134
|
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
135
|
+
// The invoking review, present only on the review route (issue #66), sibling of `comment` and
|
|
136
|
+
// carried for the same reason: without it a "Request changes: rename the helper" review starts a
|
|
137
|
+
// job that cannot know what was asked. All four fields are named by INT-WEBHOOK-PAYLOAD-SUBSET and
|
|
138
|
+
// the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
139
|
+
//
|
|
140
|
+
// NOTE the pair above: `event` is `pull_request_review` and `action` is the raw `submitted`,
|
|
141
|
+
// byte-for-byte what GitHub sent. `matched.action` is the canonical `review_submitted`, because
|
|
142
|
+
// `matched` names the triggers.json entry that fired rather than the payload. This is the first
|
|
143
|
+
// GitHub case where the two differ, and INT-CONTAINER-JOB-INPUTS says so.
|
|
144
|
+
...(resolved.review ? { review: resolved.review } : {}),
|
|
105
145
|
},
|
|
106
146
|
};
|
|
107
147
|
return { enqueue: true, job };
|
|
@@ -119,6 +159,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
119
159
|
flow: rule.flow,
|
|
120
160
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
121
161
|
image: rule.image,
|
|
162
|
+
skillsDir: rule.skillsDir,
|
|
163
|
+
instructions: rule.instructions,
|
|
122
164
|
resume: rule.resume,
|
|
123
165
|
replicas: rule.replicas,
|
|
124
166
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
@@ -163,6 +205,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
163
205
|
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
164
206
|
packages: triggers.comment.packages,
|
|
165
207
|
image: triggers.comment.image,
|
|
208
|
+
skillsDir: triggers.comment.skillsDir,
|
|
209
|
+
instructions: triggers.comment.instructions,
|
|
166
210
|
resume: triggers.comment.resume,
|
|
167
211
|
replicas: triggers.comment.replicas,
|
|
168
212
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
@@ -174,10 +218,43 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
174
218
|
}
|
|
175
219
|
|
|
176
220
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
221
|
+
* Whether the actor behind a PR event clears the write-access gate -- and WHICH actor that is.
|
|
222
|
+
*
|
|
223
|
+
* One expression rather than a branch inside the rule loop, deliberately (issue #66). A review is the
|
|
224
|
+
* REVIEWER's say-so, never the PR author's: a collaborator reviewing a stranger's fork PR must run, and a
|
|
225
|
+
* stranger reviewing their own PR must not. Reading `pr.author_association` for a review inverts both
|
|
226
|
+
* halves at once, and an `authorOk` that means two different things depending on which `if` you are
|
|
227
|
+
* standing in is exactly how that inversion gets silently reintroduced later. `labeled` reaches neither
|
|
228
|
+
* branch: it is gated by its label predicate instead.
|
|
229
|
+
*/
|
|
230
|
+
function prAuthorOk(action, pr, review) {
|
|
231
|
+
if (action === REVIEW_ACTION) return AUTHOR_ALLOWLIST.has(review?.author_association);
|
|
232
|
+
return AUTHOR_ALLOWLIST.has(pr?.author_association);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A `commented` review carrying nothing to act on.
|
|
237
|
+
*
|
|
238
|
+
* A review made only of INLINE comments arrives here with an empty body, because those comments ride
|
|
239
|
+
* `pull_request_review_comment` -- an event this project does not ingest. The payload carries no
|
|
240
|
+
* line-comment count and this module is pure, so it cannot tell "empty because it has line comments" from
|
|
241
|
+
* "empty because it is empty", and buying a container for an empty string is the worse of the two errors.
|
|
242
|
+
* The residual (a Comment-type review of line comments only never fires) is stated in SECURITY.md rather
|
|
243
|
+
* than left to a drop reason.
|
|
244
|
+
*
|
|
245
|
+
* `approved` and `changes_requested` still fire with no body: there the verdict IS the signal.
|
|
246
|
+
* Case-insensitive on `state` as belt-and-braces; `parseSubset` has already folded it.
|
|
247
|
+
*/
|
|
248
|
+
function isEmptyCommentedReview(review) {
|
|
249
|
+
return String(review?.state ?? "").toLowerCase() === "commented" && String(review?.body ?? "").trim() === "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Pull-request path, three gates by action. `labeled` is gated by the label predicate
|
|
254
|
+
* (collaborator-applied label = approval); auto actions (`opened|synchronize|reopened`) by the PR
|
|
255
|
+
* author_association; `review_submitted` by the REVIEWER's (see `prAuthorOk`). All hard-coded, never
|
|
256
|
+
* config-optional. A trigger's optional predicate only narrows; an empty predicate is vacuously true.
|
|
257
|
+
* First matching rule (in file order) wins.
|
|
181
258
|
*/
|
|
182
259
|
function routePullRequest(subset, triggers, action) {
|
|
183
260
|
const pr = subset.pull_request;
|
|
@@ -185,29 +262,59 @@ function routePullRequest(subset, triggers, action) {
|
|
|
185
262
|
return { enqueue: false, reason: "missing-pull-request" };
|
|
186
263
|
}
|
|
187
264
|
const L = labelSet(pr.labels);
|
|
188
|
-
const
|
|
265
|
+
const isReview = action === REVIEW_ACTION;
|
|
266
|
+
const review = subset.review;
|
|
267
|
+
const authorOk = prAuthorOk(action, pr, review);
|
|
189
268
|
|
|
269
|
+
// The review path refuses BEFORE the rule loop, in the order routeComment uses (author, then is there
|
|
270
|
+
// anything to act on, then which rule). Two consequences worth stating rather than discovering:
|
|
271
|
+
// security beats content when both are true, so a stranger's empty review reports the author refusal;
|
|
272
|
+
// and an operator with no review rule armed sees these reasons rather than `no-matching-pr-trigger`,
|
|
273
|
+
// which `pr-author-not-allowed` below already does and which is the right trade -- "nobody may start a
|
|
274
|
+
// job this way" and "there is nothing here to act on" are both facts about the DELIVERY, true whatever
|
|
275
|
+
// the trigger file says.
|
|
276
|
+
if (isReview) {
|
|
277
|
+
if (!authorOk) return { enqueue: false, reason: "review-author-not-allowed" };
|
|
278
|
+
if (isEmptyCommentedReview(review)) return { enqueue: false, reason: "no-review-body" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let stateSkipped = false;
|
|
190
282
|
for (const rule of triggers.pullRequest ?? []) {
|
|
191
283
|
if (!rule.actions.has(action)) continue;
|
|
192
284
|
if (action === "labeled") {
|
|
193
285
|
if (!matchesRule(L, rule.predicate)) continue;
|
|
194
286
|
} else {
|
|
195
|
-
// Auto action -- author_association is the hard gate; the predicate (if any) narrows
|
|
287
|
+
// Auto action or review -- author_association is the hard gate; the predicate (if any) narrows.
|
|
196
288
|
if (!authorOk) continue;
|
|
197
289
|
if (!matchesRule(L, rule.predicate)) continue;
|
|
198
290
|
}
|
|
291
|
+
// The optional `on.reviewState` narrowing, checked LAST among a rule's tests so the flag below means
|
|
292
|
+
// the verdict was the ONLY thing that failed. Unnarrowed rules carry null and fire on every verdict.
|
|
293
|
+
if (isReview && rule.reviewStates !== null && rule.reviewStates !== undefined && !rule.reviewStates.has(review?.state)) {
|
|
294
|
+
stateSkipped = true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
199
297
|
return {
|
|
200
298
|
enqueue: true,
|
|
201
299
|
flow: rule.flow,
|
|
202
300
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
203
301
|
image: rule.image,
|
|
302
|
+
skillsDir: rule.skillsDir,
|
|
303
|
+
instructions: rule.instructions,
|
|
204
304
|
resume: rule.resume,
|
|
205
305
|
replicas: rule.replicas,
|
|
206
306
|
matched: { index: rule.index, type: "pull_request", action },
|
|
307
|
+
...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
|
|
207
308
|
target: buildPrTarget(pr),
|
|
208
309
|
};
|
|
209
310
|
}
|
|
210
311
|
|
|
312
|
+
// "Your rule exists, this verdict was not in your list" is a different operator response from "no rule
|
|
313
|
+
// matched at all", so it gets its own reason -- the same call filter-forgejo.mjs makes for a recognised
|
|
314
|
+
// but unactionable action.
|
|
315
|
+
if (stateSkipped) {
|
|
316
|
+
return { enqueue: false, reason: "review-state-not-matched" };
|
|
317
|
+
}
|
|
211
318
|
// Surface the security-relevant author drop distinctly from a plain no-match so it is observable.
|
|
212
319
|
if (PR_AUTO_ACTIONS.has(action) && !authorOk) {
|
|
213
320
|
return { enqueue: false, reason: "pr-author-not-allowed" };
|
package/src/poller.mjs
CHANGED
|
@@ -35,20 +35,33 @@
|
|
|
35
35
|
* presence, not transitions, so close->reopen inside one cycle is invisible, and a
|
|
36
36
|
* reopen that also pushed fires a single `reopened` where webhooks would fire two
|
|
37
37
|
* events -- the fresh sha still rides the job's target).
|
|
38
|
+
* - reviews: GET /repos/{o}/{r}/pulls/{n}/reviews for each OPEN pr, cursor = last processed
|
|
39
|
+
* review id (numeric and monotonic, so the events discipline applies). Issue #66's
|
|
40
|
+
* fourth source, and it exists because the alternative was a `review_submitted`
|
|
41
|
+
* trigger that loads clean under polling and can never fire -- the silently dead
|
|
42
|
+
* trigger this project refuses everywhere else. Only open PRs are swept: a review on
|
|
43
|
+
* a closed PR has nothing left to act on, the same call `merge`/`close` get in the
|
|
44
|
+
* action vocabulary. REST spells `state` in upper case where the webhook spells it
|
|
45
|
+
* lower; `parseSubset` folds it, so both transports produce the same job.
|
|
38
46
|
*
|
|
39
47
|
* DEDUP IDS (REQ-DEDUP-BY-DELIVERY-GUID): polling has no delivery GUID, so each source mints a
|
|
40
48
|
* deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (label events),
|
|
41
49
|
* `poll-c<commentId>` (comments), `poll-pr<number>-<headSha7>` (PR actions; sha-keyed so a retried
|
|
42
50
|
* cycle cannot double-enqueue while a real new push mints a new id -- with the honest corollary that
|
|
43
|
-
* a same-sha reopen inside the retention window coalesces with its own `opened` job)
|
|
51
|
+
* a same-sha reopen inside the retention window coalesces with its own `opened` job), and
|
|
52
|
+
* `poll-rv<reviewId>` (reviews; a review id is immutable, so the id alone is the identity). The shared
|
|
44
53
|
* `gh-` prefix is added by enqueueGitHubJob's jobId path, same as for webhook deliveries.
|
|
45
54
|
*
|
|
46
55
|
* CURSORS AND ETAGS live in redis, namespaced per repo:
|
|
47
56
|
* poll:<owner/repo>:cursor:events last processed /issues/events id (numeric, monotonic)
|
|
48
57
|
* poll:<owner/repo>:cursor:comments newest processed comment updated_at (second-precision ISO)
|
|
49
58
|
* poll:<owner/repo>:cursor:prs ISO of when the PR snapshot was armed (presence = armed)
|
|
59
|
+
* poll:<owner/repo>:cursor:reviews last processed review id (numeric, monotonic; presence = armed)
|
|
50
60
|
* poll:<owner/repo>:prs hash: PR number -> head sha while open, "closed" once gone
|
|
51
61
|
* poll:<owner/repo>:etag:<endpoint> conditional-GET validator (endpoint: events|comments|pulls)
|
|
62
|
+
* poll:<owner/repo>:etag:reviews hash: PR number -> that PR's reviews-endpoint validator. A HASH
|
|
63
|
+
* rather than a key per PR, so the family below stays enumerable
|
|
64
|
+
* and `touchRepo` can still refresh it as a unit.
|
|
52
65
|
* All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful repo poll. 35 days
|
|
53
66
|
* deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
|
|
54
67
|
* the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
|
|
@@ -100,6 +113,10 @@ const DISCOVERY_EVERY = 10;
|
|
|
100
113
|
// next cycle picks up the rest); the open-PR list just caps how many open PRs the diff can see.
|
|
101
114
|
const MAX_EVENT_PAGES = 5;
|
|
102
115
|
const MAX_PR_PAGES = 10;
|
|
116
|
+
// How many open PRs one cycle sweeps for reviews. This is a REQUEST bound, not a page bound: the reviews
|
|
117
|
+
// endpoint is per-PR, so an unbounded sweep would spend one request per open PR per cycle. 50 covers any
|
|
118
|
+
// repo a single poller realistically services, and the overflow is logged rather than silently dropped.
|
|
119
|
+
const MAX_REVIEW_PRS = 50;
|
|
103
120
|
// The hash value marking a PR that left the open list. Cannot collide with a head sha (hex only).
|
|
104
121
|
const CLOSED_MARKER = "closed";
|
|
105
122
|
|
|
@@ -320,6 +337,8 @@ function keyNames(repo) {
|
|
|
320
337
|
etagEvents: `${p}:etag:events`,
|
|
321
338
|
etagComments: `${p}:etag:comments`,
|
|
322
339
|
etagPulls: `${p}:etag:pulls`,
|
|
340
|
+
reviews: `${p}:cursor:reviews`,
|
|
341
|
+
etagReviews: `${p}:etag:reviews`,
|
|
323
342
|
};
|
|
324
343
|
}
|
|
325
344
|
|
|
@@ -330,7 +349,7 @@ async function setWithTtl(ctx, key, value) {
|
|
|
330
349
|
/** Refresh the whole key family together -- coherence over per-key precision (see module header). */
|
|
331
350
|
async function touchRepo(ctx, repo) {
|
|
332
351
|
const k = keyNames(repo);
|
|
333
|
-
for (const key of [k.events, k.comments, k.prsArmed, k.prs, k.etagEvents, k.etagComments, k.etagPulls]) {
|
|
352
|
+
for (const key of [k.events, k.comments, k.prsArmed, k.prs, k.etagEvents, k.etagComments, k.etagPulls, k.reviews, k.etagReviews]) {
|
|
334
353
|
await ctx.redis.expire(key, CURSOR_TTL_SECONDS);
|
|
335
354
|
}
|
|
336
355
|
}
|
|
@@ -349,6 +368,11 @@ function isoSeconds(ms) {
|
|
|
349
368
|
* never sends one -- arming needs the body, and a stray stored validator answering 304 on an unarmed
|
|
350
369
|
* endpoint would leave it unarmed forever.
|
|
351
370
|
*
|
|
371
|
+
* `etagKey` may also be `{ key, field }`, which stores the validator in a HASH field instead. That form
|
|
372
|
+
* exists for the per-PR reviews endpoint (issue #66): one validator per open PR would otherwise mean an
|
|
373
|
+
* unbounded key family that `touchRepo` cannot enumerate, and the whole TTL argument in the header rests
|
|
374
|
+
* on the family being refreshable as a unit. The conditional-GET discipline stays in this one place.
|
|
375
|
+
*
|
|
352
376
|
* An exhausted quota (403/429 with x-ratelimit-remaining: 0) throws RateLimited for the cycle loop
|
|
353
377
|
* to sleep out; any other non-2xx throws a plain error for per-repo isolation to log.
|
|
354
378
|
*/
|
|
@@ -362,7 +386,7 @@ function makeApi(ctx, token, stats) {
|
|
|
362
386
|
authorization: `Bearer ${token}`,
|
|
363
387
|
};
|
|
364
388
|
if (etagKey && revalidate) {
|
|
365
|
-
const etag = await ctx.redis.get(etagKey);
|
|
389
|
+
const etag = etagKey.field === undefined ? await ctx.redis.get(etagKey) : await ctx.redis.hget(etagKey.key, etagKey.field);
|
|
366
390
|
if (etag) headers["if-none-match"] = etag;
|
|
367
391
|
}
|
|
368
392
|
const res = await ctx.fetchFn(`${API_URL}${path}`, { headers });
|
|
@@ -383,7 +407,13 @@ function makeApi(ctx, token, stats) {
|
|
|
383
407
|
}
|
|
384
408
|
if (etagKey) {
|
|
385
409
|
const tag = res.headers?.get?.("etag");
|
|
386
|
-
if (tag
|
|
410
|
+
if (tag && etagKey.field === undefined) {
|
|
411
|
+
await ctx.redis.set(etagKey, tag, "EX", CURSOR_TTL_SECONDS);
|
|
412
|
+
} else if (tag) {
|
|
413
|
+
// The hash carries no TTL of its own here; `touchRepo` expires it with the rest of the family.
|
|
414
|
+
await ctx.redis.hset(etagKey.key, etagKey.field, tag);
|
|
415
|
+
await ctx.redis.expire(etagKey.key, CURSOR_TTL_SECONDS);
|
|
416
|
+
}
|
|
387
417
|
}
|
|
388
418
|
stats.fetched += 1;
|
|
389
419
|
return { json: await res.json() };
|
|
@@ -580,7 +610,15 @@ async function pollPulls(ctx, api, repo, stats) {
|
|
|
580
610
|
const armed = (await ctx.redis.get(k.prsArmed)) !== null;
|
|
581
611
|
|
|
582
612
|
const first = await api.get(`/repos/${repo}/pulls?state=open&sort=updated&direction=asc&per_page=100`, k.etagPulls, armed);
|
|
583
|
-
if (first.notModified)
|
|
613
|
+
if (first.notModified) {
|
|
614
|
+
// Reviews still sweep on this path, and that is deliberate rather than defensive. Whether submitting
|
|
615
|
+
// a review perturbs the open-PR LIST is GitHub's business, not a property this project should bet
|
|
616
|
+
// correctness on: if it does not, an unswept 304 cycle would mean review triggers that fire only when
|
|
617
|
+
// something else happens to touch the PR. `null` tells pollReviews to take its sweep set from the
|
|
618
|
+
// snapshot hash instead of a list it does not have.
|
|
619
|
+
await pollReviews(ctx, api, repo, null, stats);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
584
622
|
let open = Array.isArray(first.json) ? first.json : [];
|
|
585
623
|
let batch = open;
|
|
586
624
|
let page = 2;
|
|
@@ -601,6 +639,10 @@ async function pollPulls(ctx, api, repo, stats) {
|
|
|
601
639
|
await ctx.redis.expire(k.prs, CURSOR_TTL_SECONDS);
|
|
602
640
|
await setWithTtl(ctx, k.prsArmed, isoSeconds(ctx.now()));
|
|
603
641
|
ctx.out({ event: "poll_armed", repo, endpoint: "pulls", open: open.length });
|
|
642
|
+
// Reviews arm on this cycle too. Returning without them would leave the reviews cursor unset for a
|
|
643
|
+
// whole extra cycle, and the first cycle that DID arm it would silently swallow every review
|
|
644
|
+
// submitted in between -- arming twice against one snapshot, losing the window between the two.
|
|
645
|
+
await pollReviews(ctx, api, repo, open, stats);
|
|
604
646
|
return;
|
|
605
647
|
}
|
|
606
648
|
|
|
@@ -630,6 +672,100 @@ async function pollPulls(ctx, api, repo, stats) {
|
|
|
630
672
|
await ctx.redis.hset(k.prs, field, CLOSED_MARKER);
|
|
631
673
|
}
|
|
632
674
|
}
|
|
675
|
+
|
|
676
|
+
// Reviews ride the SAME open-PR list this function already paid for -- one /pulls response feeds both
|
|
677
|
+
// sources, which is the whole reason this call lives here rather than in its own pollRepo step.
|
|
678
|
+
await pollReviews(ctx, api, repo, open, stats);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The review feed (issue #66): GET /repos/{o}/{r}/pulls/{n}/reviews per OPEN pr, cursor = last processed
|
|
683
|
+
* review id.
|
|
684
|
+
*
|
|
685
|
+
* Review ids are numeric and monotonic, but this sweep reads MANY endpoints (one per open PR) whose ids
|
|
686
|
+
* interleave, so the cursor is persisted ONCE at the end -- the comments-feed discipline, not the label
|
|
687
|
+
* feed's per-item one. Advancing per review would be actively wrong here: PR #1's review 200 followed by
|
|
688
|
+
* PR #2's review 150 would leave the cursor at 150 and re-enqueue 200 next cycle, or (writing the running
|
|
689
|
+
* max) would strand 150 forever if the sweep died between the two. Persisting once means a mid-sweep
|
|
690
|
+
* failure retries the WHOLE sweep, and the reviews already enqueued dedup on their `gh-poll-rv<id>`
|
|
691
|
+
* jobIds -- the same idempotence the replica fanout in `gate` leans on.
|
|
692
|
+
*
|
|
693
|
+
* COST is why the per-PR ETag exists. Without it a 50-open-PR repo spends 50 requests a cycle forever;
|
|
694
|
+
* with it the idle steady state is 50 * 304, which GitHub does not charge against the rate limit. The
|
|
695
|
+
* validators live in one hash (see keyNames) so the key family stays enumerable for touchRepo.
|
|
696
|
+
*
|
|
697
|
+
* A review on a CLOSED pr is never seen, deliberately: the open list is the sweep set, and a job started
|
|
698
|
+
* by a review of a merged PR has nothing left to act on -- the same call `merge` and `close` get in the
|
|
699
|
+
* action vocabulary.
|
|
700
|
+
*/
|
|
701
|
+
async function pollReviews(ctx, api, repo, open, stats) {
|
|
702
|
+
const k = keyNames(repo);
|
|
703
|
+
const cursorRaw = await ctx.redis.get(k.reviews);
|
|
704
|
+
const armed = cursorRaw !== null;
|
|
705
|
+
const cursor = armed ? Number(cursorRaw) : 0;
|
|
706
|
+
|
|
707
|
+
// The sweep set. `open` is the list pollPulls just fetched; `null` means it got a 304 and the snapshot
|
|
708
|
+
// hash is the only record of which PRs are open. Numbers are enough to drive the sweep -- the PR OBJECT
|
|
709
|
+
// is only needed for a PR that turns out to have a new review, and is fetched lazily below, which is
|
|
710
|
+
// the same once-per-new-event fetch handleLabeledEvent and handleComment already do.
|
|
711
|
+
let numbers;
|
|
712
|
+
if (open !== null) {
|
|
713
|
+
numbers = open.filter((pr) => pr?.number != null).map((pr) => pr.number);
|
|
714
|
+
} else {
|
|
715
|
+
const known = await ctx.redis.hgetall(k.prs);
|
|
716
|
+
numbers = Object.entries(known)
|
|
717
|
+
.filter(([, sha]) => sha !== CLOSED_MARKER)
|
|
718
|
+
.map(([field]) => Number(field))
|
|
719
|
+
.filter((n) => Number.isFinite(n))
|
|
720
|
+
.sort((a, b) => a - b);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Bounded, and said out loud when it bites: a silent cap reads as coverage.
|
|
724
|
+
if (numbers.length > MAX_REVIEW_PRS) {
|
|
725
|
+
ctx.out({ event: "poll_reviews_gap", repo, open: numbers.length, swept: MAX_REVIEW_PRS });
|
|
726
|
+
}
|
|
727
|
+
const bounded = numbers.slice(0, MAX_REVIEW_PRS);
|
|
728
|
+
const byNumber = new Map((open ?? []).filter((pr) => pr?.number != null).map((pr) => [pr.number, pr]));
|
|
729
|
+
|
|
730
|
+
let maxId = cursor;
|
|
731
|
+
for (const number of bounded) {
|
|
732
|
+
const field = String(number);
|
|
733
|
+
// While ARMING, `revalidate: false` for the same reason every other source uses it: arming needs
|
|
734
|
+
// the body, and a stored validator answering 304 on an unarmed endpoint would strand it unarmed.
|
|
735
|
+
const res = await api.get(`/repos/${repo}/pulls/${number}/reviews?per_page=100`, { key: k.etagReviews, field }, armed);
|
|
736
|
+
if (res.notModified) continue;
|
|
737
|
+
const reviews = Array.isArray(res.json) ? res.json : [];
|
|
738
|
+
|
|
739
|
+
let pr = byNumber.get(number) ?? null;
|
|
740
|
+
for (const review of reviews) {
|
|
741
|
+
if (typeof review?.id !== "number" || review.id <= cursor) continue;
|
|
742
|
+
if (review.id > maxId) maxId = review.id;
|
|
743
|
+
if (!armed) continue; // ARM WITHOUT REPLAY: learn the high-water mark, enqueue nothing.
|
|
744
|
+
|
|
745
|
+
// Lazily, and at most once per PR per cycle: only a PR with a genuinely new review costs this.
|
|
746
|
+
if (pr === null) {
|
|
747
|
+
pr = (await api.get(`/repos/${repo}/pulls/${number}`)).json ?? null;
|
|
748
|
+
byNumber.set(number, pr);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The webhook's own payload shape, so the SAME parseSubset + filter decide. `sender` is the
|
|
752
|
+
// REVIEWER, which is what keeps the bot-loop guard correct: a review the harness itself posted
|
|
753
|
+
// carries our own id and drops as `self` before any gate runs. `state` arrives upper-case from
|
|
754
|
+
// REST and parseSubset folds it; that fold is what makes this job identical to the webhook twin's.
|
|
755
|
+
const payload = { action: "submitted", sender: { id: review.user?.id }, pull_request: pr, review, repository: { full_name: repo } };
|
|
756
|
+
await gate(ctx, "pull_request_review", payload, `poll-rv${review.id}`, stats);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Once, after the whole sweep -- see the header of this function for why per-item would be wrong.
|
|
761
|
+
// Arming ALWAYS writes, even at 0: a repo whose open PRs carry no reviews yet must still become armed,
|
|
762
|
+
// or it stays on the `revalidate: false` path and re-fetches every PR in full, every cycle, forever.
|
|
763
|
+
if (!armed) {
|
|
764
|
+
await setWithTtl(ctx, k.reviews, String(maxId));
|
|
765
|
+
ctx.out({ event: "poll_armed", repo, endpoint: "reviews", cursor: maxId });
|
|
766
|
+
} else if (maxId !== cursor) {
|
|
767
|
+
await setWithTtl(ctx, k.reviews, String(maxId));
|
|
768
|
+
}
|
|
633
769
|
}
|
|
634
770
|
|
|
635
771
|
/**
|
package/src/receiver.mjs
CHANGED
|
@@ -75,6 +75,28 @@ export function parseSubset(payload) {
|
|
|
75
75
|
base: { ref: pr.base?.ref },
|
|
76
76
|
}
|
|
77
77
|
: undefined,
|
|
78
|
+
// pull_request_review event fields (issue #66). Shape-gated like `pull_request` above rather than
|
|
79
|
+
// event-gated, for the same reason: this function never sees the event name, and a payload either
|
|
80
|
+
// carries a review or it does not.
|
|
81
|
+
//
|
|
82
|
+
// `id` is here because a review's INLINE comments ride `pull_request_review_comment`, an event this
|
|
83
|
+
// project deliberately does not ingest -- so the id is the only handle a flow has on them. No
|
|
84
|
+
// `review.user`: the bot-loop guard reads `sender.id` and nothing else, a second identity field is a
|
|
85
|
+
// second thing to keep in sync, and a login is PII with no reader (see issue.pull_request above).
|
|
86
|
+
review: payload.review
|
|
87
|
+
? {
|
|
88
|
+
id: payload.review.id,
|
|
89
|
+
body: payload.review.body,
|
|
90
|
+
// Folded to lower case HERE, the one place both sources converge. The webhook sends
|
|
91
|
+
// `approved`; GET /pulls/{n}/reviews sends `APPROVED`. The poller feeds this same function
|
|
92
|
+
// with REST-derived payloads, so without the fold the identical logical review would produce
|
|
93
|
+
// two different jobs depending on transport, and a polled `COMMENTED` would slip past the
|
|
94
|
+
// empty-body check and buy a container for an empty string. (GitHub docs, not source at a
|
|
95
|
+
// pin: filter.test.mjs drives both casings so the claim is pinned by behaviour.)
|
|
96
|
+
state: typeof payload.review.state === "string" ? payload.review.state.toLowerCase() : payload.review.state,
|
|
97
|
+
author_association: payload.review.author_association,
|
|
98
|
+
}
|
|
99
|
+
: undefined,
|
|
78
100
|
repository: { full_name: payload.repository?.full_name },
|
|
79
101
|
};
|
|
80
102
|
}
|
|
@@ -88,19 +110,34 @@ export function parseSubset(payload) {
|
|
|
88
110
|
* even tell two forges apart reliably, and a request that could select which gate it faced would always
|
|
89
111
|
* select the weakest one available.
|
|
90
112
|
*
|
|
91
|
-
* `/` stays mapped to GitHub
|
|
92
|
-
* and silently 404-ing them would look exactly like the harness being
|
|
113
|
+
* `/` stays mapped to GitHub -- for a deployment that SERVES GitHub. Existing deployments configured their
|
|
114
|
+
* webhook URL before any path existed, and silently 404-ing them would look exactly like the harness being
|
|
115
|
+
* down, so the path itself never moves.
|
|
93
116
|
*
|
|
94
117
|
* A forge with no configuration gets no route at all -- not a route that answers 401. An endpoint that
|
|
95
|
-
* responds to an unconfigured forge is an endpoint an operator can believe is armed.
|
|
118
|
+
* responds to an unconfigured forge is an endpoint an operator can believe is armed. GitHub is no longer
|
|
119
|
+
* the exception to that rule (issue #99): a GitLab-only / Forgejo-only / Azure-only deployment has
|
|
120
|
+
* `cfg.servesGithub === false`, builds no github handler, and 404s `/`.
|
|
121
|
+
*
|
|
122
|
+
* The github arm is gated on `cfg.servesGithub` being TRUTHY, not on it not being `false`, and that
|
|
123
|
+
* direction is load-bearing. `start.mjs` skips resolving the harness's GitHub identity when the property is
|
|
124
|
+
* off, so a config object that somehow reached here without the property would otherwise mount `/` with
|
|
125
|
+
* `selfId === undefined` -- a live endpoint whose bot-loop guard compares every `sender.id` against
|
|
126
|
+
* undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
|
|
127
|
+
* failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
|
|
96
128
|
*/
|
|
97
129
|
export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null }) {
|
|
98
|
-
|
|
130
|
+
// Built only when the deployment serves GitHub. Construction is not free of the secret either: the
|
|
131
|
+
// `new Webhooks({ secret })` inside makeVerifiedHandler throws "options.secret required" on an absent
|
|
132
|
+
// one, so not building the arm is what lets a github-free deployment legitimately have no secret --
|
|
133
|
+
// no placeholder, nothing papered over, and no handler holding a secret nobody chose.
|
|
134
|
+
const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log }) : null;
|
|
99
135
|
|
|
100
136
|
// A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
|
|
101
137
|
// it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
|
|
102
138
|
// with a null handler is a CONFIGURED-OFF forge and answers 404; a path absent from the table entirely
|
|
103
|
-
// falls through to GitHub, which is what keeps `/` working
|
|
139
|
+
// falls through to GitHub, which is what keeps `/` working -- and a configured-off GitHub answers the
|
|
140
|
+
// same 404 from the fallthrough itself, below.
|
|
104
141
|
const routes = {
|
|
105
142
|
"/gitlab": gitlab ? makeGitLabHandler({ queue, cfg, log, ...gitlab }) : null,
|
|
106
143
|
"/forgejo": forgejo ? makeForgejoHandler({ queue, cfg, log, ...forgejo }) : null,
|
|
@@ -116,6 +153,12 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
|
|
|
116
153
|
if (!handler) return respond(res, 404, { error: `${path.slice(1)} webhooks are not configured` });
|
|
117
154
|
return await handler(req, res);
|
|
118
155
|
}
|
|
156
|
+
// The GitHub fallthrough (`/` and anything unrouted). Absent when the deployment serves no GitHub, and
|
|
157
|
+
// 404 rather than 401 or 405 for the same reason the table's null arms are: a status that discusses
|
|
158
|
+
// credentials or methods says "armed, and you got something wrong", and an operator who reads that
|
|
159
|
+
// about an endpoint that cannot fire anything will go hunting for a mis-keyed secret for an hour. The
|
|
160
|
+
// message is spelled out rather than derived from `path`, which is "/" here and would slice to nothing.
|
|
161
|
+
if (!github) return respond(res, 404, { error: "github webhooks are not configured" });
|
|
119
162
|
return await github(req, res);
|
|
120
163
|
};
|
|
121
164
|
}
|
package/src/start.mjs
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* agent. It only produces jobs; it never runs pi.
|
|
7
7
|
*
|
|
8
8
|
* CONST-TRIGGER-AUTHOR-GATE: `selfId` is the bot-loop guard's sole input -- the filter drops any event
|
|
9
|
-
* whose `sender.id` is our own. Resolving it is therefore a HARD-FAIL boot invariant
|
|
10
|
-
* not resolve, the rejection propagates and the server is NEVER
|
|
11
|
-
* without `selfId` would run the guard disarmed, and its own completion
|
|
12
|
-
* -- an unbounded paid recursion. The worker's auth is best-effort because
|
|
13
|
-
* per-job; the receiver has no such per-job fallback, so identity resolution is a
|
|
9
|
+
* whose `sender.id` is our own. Resolving it is therefore a HARD-FAIL boot invariant WHEREVER A FORGE
|
|
10
|
+
* ENDPOINT IS LIVE: if identity does not resolve, the rejection propagates and the server is NEVER
|
|
11
|
+
* created. A receiver that listened without `selfId` would run the guard disarmed, and its own completion
|
|
12
|
+
* comments would re-trigger jobs -- an unbounded paid recursion. The worker's auth is best-effort because
|
|
13
|
+
* it can fail a github job per-job; the receiver has no such per-job fallback, so identity resolution is a
|
|
14
|
+
* boot gate. Every arm here is gated on its own forge being configured, github included (`cfg.servesGithub`,
|
|
15
|
+
* issue #99) -- an arm whose endpoint does not exist has no guard to arm, and the invariant that matters is
|
|
16
|
+
* that the two are decided by the SAME property, never separately.
|
|
14
17
|
*
|
|
15
18
|
* The receiver resolves identity ONLY. It holds no per-repo tokens: minting a scoped token is the
|
|
16
19
|
* worker's job, per container, per job (CONST-TOKEN-SCOPED-PER-JOB).
|
|
@@ -58,11 +61,29 @@ export async function startReceiver(
|
|
|
58
61
|
|
|
59
62
|
const cfg = loadReceiverConfig(env);
|
|
60
63
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// The GitHub arm, when the deployment actually serves GitHub -- now conditional, exactly like the three
|
|
65
|
+
// sibling arms below (issue #99). It was unconditional, and since GITHUB_AUTH_SOURCE defaults to `gh` and
|
|
66
|
+
// the gh path shells out to `gh auth token`, a GitLab-only deployment could not boot without installing
|
|
67
|
+
// and logging into the GitHub CLI it has no use for.
|
|
68
|
+
//
|
|
69
|
+
// SKIPPING THIS IS ONLY SAFE BECAUSE THE ROUTE IS ALSO ABSENT. `cfg.servesGithub` gates both: this
|
|
70
|
+
// identity resolution AND whether `makeReceiver` mounts `/` at all. `selfId` is the bot-loop guard's sole
|
|
71
|
+
// input, so the two MUST stay coupled -- if a future change mounts `/` unconditionally again, this
|
|
72
|
+
// resolution has to come back with it, or the github endpoint would run its guard disarmed and the
|
|
73
|
+
// harness's own completion comments would re-trigger jobs forever. Read that as: never make one of these
|
|
74
|
+
// two conditions unconditional without the other.
|
|
75
|
+
let selfId;
|
|
76
|
+
if (cfg.servesGithub) {
|
|
77
|
+
// HARD-FAIL identity resolution -- NO try/catch. A throw here (absent/bad github auth, unresolvable
|
|
78
|
+
// id) propagates and the server below is never created: without selfId the bot-loop guard cannot
|
|
79
|
+
// run, so refusing to boot is the only safe outcome.
|
|
80
|
+
({ selfId } = await makeAuth(cfg.github));
|
|
81
|
+
log({ event: "self_identity", id: selfId, source: cfg.github.source });
|
|
82
|
+
} else {
|
|
83
|
+
// Said out loud, because the alternative is an operator staring at a label trigger that does nothing.
|
|
84
|
+
// The two ways out are the two signals `decideServesGithub` reads, so the line names both.
|
|
85
|
+
log({ event: "github_arm_skipped", reason: "no github triggers and GITHUB_AUTH_SOURCE unset" });
|
|
86
|
+
}
|
|
66
87
|
|
|
67
88
|
// Ride-out connection (no failFast): the receiver is long-running and should survive a Valkey
|
|
68
89
|
// restart, not give up on a transient disconnect.
|