@edgehero/pi-dispatch-receiver 1.2.0 → 1.3.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 +4 -1
- package/src/close.mjs +34 -0
- package/src/config.mjs +32 -7
- package/src/filter-forgejo.mjs +101 -0
- package/src/filter-gitlab.mjs +106 -2
- package/src/filter.mjs +162 -1
- package/src/forgejo-members.mjs +4 -1
- package/src/forgejo-subset.mjs +14 -2
- package/src/github-members.mjs +140 -0
- package/src/gitlab-members.mjs +4 -1
- package/src/poller.mjs +143 -8
- package/src/receiver.mjs +47 -6
- package/src/start.mjs +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@octokit/webhooks": "14.2.0",
|
|
48
|
-
"@edgehero/pi-dispatch": "^1.
|
|
48
|
+
"@edgehero/pi-dispatch": "^1.4.0",
|
|
49
49
|
"bullmq": "5.80.4",
|
|
50
50
|
"ioredis": "5.11.1"
|
|
51
51
|
}
|
package/src/azure-members.mjs
CHANGED
|
@@ -88,7 +88,10 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
88
88
|
try {
|
|
89
89
|
return { body: await res.json(), continuation: continuation || null };
|
|
90
90
|
} catch (err) {
|
|
91
|
-
|
|
91
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
92
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
93
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
94
|
+
return { indeterminate: "azure lookup returned unparseable JSON" };
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
package/src/close.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE derivation of "which close rule matches this delivery" (issue #231), shared by every
|
|
3
|
+
* forge's close route and by the GitHub arm's wants-authority predicate. Two copies of a gate
|
|
4
|
+
* decision is the shape filter.mjs's prAuthorOk comment warns against, and here the two callers are
|
|
5
|
+
* a NETWORK CALL apart: the receiver asks "does any rule want this close" before spending a
|
|
6
|
+
* permission lookup, and the filter asks it again to route -- if those two answers could drift, a
|
|
7
|
+
* delivery could cost a lookup it then drops, or worse, route a close the lookup never gated.
|
|
8
|
+
*
|
|
9
|
+
* Total and throw-free like predicate.mjs: rules are loader-validated shapes, the inputs are
|
|
10
|
+
* adversarial payload fields, and the receiver's gates must never throw on data.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find the first rule (file order, firstMatchingRule's convention) whose action set has this close
|
|
15
|
+
* word and whose `number` narrowing (when present) names this item. Returns:
|
|
16
|
+
* { rule } -- matched; the caller gates and enqueues
|
|
17
|
+
* { reason: "close-number-not-matched" } -- a close rule is armed but names a different item
|
|
18
|
+
* { reason: "no-matching-close-trigger" } -- nothing armed wants this close
|
|
19
|
+
* The two refusal tokens are distinct because they call for different operator responses: "your
|
|
20
|
+
* one-shot exists and this was a different item closing" and "nothing is armed" read nothing alike
|
|
21
|
+
* from a panel.
|
|
22
|
+
*/
|
|
23
|
+
export function findCloseRule(rules, closeWord, number) {
|
|
24
|
+
let numberSkipped = false;
|
|
25
|
+
for (const rule of rules ?? []) {
|
|
26
|
+
if (!rule.actions?.has(closeWord)) continue;
|
|
27
|
+
if (rule.number !== undefined && rule.number !== number) {
|
|
28
|
+
numberSkipped = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return { rule };
|
|
32
|
+
}
|
|
33
|
+
return { reason: numberSkipped ? "close-number-not-matched" : "no-matching-close-trigger" };
|
|
34
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { existsSync, readFileSync } from "node:fs";
|
|
34
34
|
import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
|
|
35
|
-
import { FORGE_KINDS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
|
|
35
|
+
import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
|
|
36
36
|
|
|
37
37
|
// Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
|
|
38
38
|
// default must be the file init just told the operator it created, not a demo buried in the repo.
|
|
@@ -102,7 +102,13 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
|
|
|
102
102
|
* credential lying around is not a statement that this receiver terminates GitHub webhooks.
|
|
103
103
|
*/
|
|
104
104
|
function decideServesGithub(env, githubGroup) {
|
|
105
|
-
|
|
105
|
+
// The close groups count too (issue #231): a deployment whose ONLY github rule is a close trigger
|
|
106
|
+
// must still mount `/`, require WEBHOOK_SECRET and build auth, or the one-shot the operator armed
|
|
107
|
+
// can never fire -- and the receiver would report nothing, the silent no-op this file's header
|
|
108
|
+
// calls the worst outcome. Conversely a deployment whose last github one-shot is SPENT stops
|
|
109
|
+
// counting here at its next boot (the sentinel groups nowhere), which is deliberate: nothing is
|
|
110
|
+
// left to serve, and INT-TRIGGERS-FILE-CONTRACT states it.
|
|
111
|
+
const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0 || githubGroup.issue.length > 0 || githubGroup.prClose.length > 0;
|
|
106
112
|
// `loadGitHubAuth` has already refused a garbled value by the time we get here, so anything non-empty
|
|
107
113
|
// here is one of pat|gh|app -- a real choice, not a typo we would be reading as consent.
|
|
108
114
|
const explicitAuthSource = env.GITHUB_AUTH_SOURCE !== undefined && env.GITHUB_AUTH_SOURCE !== "";
|
|
@@ -228,7 +234,11 @@ function loadAzureConfig(env) {
|
|
|
228
234
|
* `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
|
|
229
235
|
* - `label`: ordered `{ index, predicate, flow, command, packages, image }` rules (first match wins in the filter).
|
|
230
236
|
* - `comment`: the single `{ index, phrase, defaultFlow, command, packages, image }` (or null when no comment trigger is configured).
|
|
231
|
-
* - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules.
|
|
237
|
+
* - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules (never a close-only rule).
|
|
238
|
+
* - `issue`: ordered `{ index, actions:Set, number?, once?, ... }` close rules (issue #231).
|
|
239
|
+
* - `prClose`: ordered close-only pull_request rules, same shape as `issue` -- split from
|
|
240
|
+
* `pullRequest` so the closer-gated route and the author-gated route can never read
|
|
241
|
+
* each other's rules (the loader's no-mixing refusal makes the split total).
|
|
232
242
|
* and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
|
|
233
243
|
* unlisted flow. A rule carries EITHER `flow` or `command` (issue #189; the shared parser enforces the
|
|
234
244
|
* exclusivity): a command rule's match dispatches a registered pi extension command in the container, so
|
|
@@ -287,9 +297,24 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
287
297
|
} else if (on.type === "comment") {
|
|
288
298
|
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
289
299
|
} else if (on.type === "pull_request") {
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
300
|
+
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
301
|
+
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
302
|
+
// groups and neither route ever re-derives the distinction -- the loader already refused any
|
|
303
|
+
// list mixing the close word with another action, which is what makes this split total. The
|
|
304
|
+
// split reads the SHARED close-word table, never a re-typed word.
|
|
305
|
+
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
306
|
+
if (closeWord !== undefined && on.action.includes(closeWord)) {
|
|
307
|
+
group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
308
|
+
} else {
|
|
309
|
+
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
310
|
+
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
311
|
+
group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
312
|
+
}
|
|
313
|
+
} else if (on.type === "issue") {
|
|
314
|
+
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
315
|
+
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
316
|
+
// normalized them -- the filter's close route reads them, nothing else does.
|
|
317
|
+
group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
|
|
293
318
|
}
|
|
294
319
|
}
|
|
295
320
|
|
|
@@ -297,7 +322,7 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
function emptyGroup() {
|
|
300
|
-
return { label: [], comment: null, pullRequest: [] };
|
|
325
|
+
return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
/** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
37
|
+
import { findCloseRule } from "./close.mjs";
|
|
37
38
|
import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
|
|
38
39
|
|
|
39
40
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
@@ -84,6 +85,17 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
84
85
|
if (action === null) return unactionable();
|
|
85
86
|
if (eventName === "issues" && LABEL_ACTIONS.has(action)) {
|
|
86
87
|
resolved = routeIssueLabel(subset, group);
|
|
88
|
+
} else if (eventName === "issues" && action === "closed") {
|
|
89
|
+
// The close trigger (issue #231). `group.issue` holds ONLY close rules (the loader admits no
|
|
90
|
+
// other issue kind), and this arm can never contend with the label one: `closed` is not in
|
|
91
|
+
// LABEL_ACTIONS and must not become so -- a close is a statement about an item, not a label move.
|
|
92
|
+
resolved = routeIssueClose(subset, group, authorized);
|
|
93
|
+
} else if (eventName === "pull_request" && raw === "closed") {
|
|
94
|
+
// The RAW word, for routePullRequest's reason below: the close vocabulary is the forge's own
|
|
95
|
+
// (PR_CLOSE_ACTIONS, which the loader validated the rule against). Checked BEFORE the PR_ACTIONS
|
|
96
|
+
// arm, though today they cannot overlap; `group.prClose` was split from `group.pullRequest` at
|
|
97
|
+
// load, so a close rule is unreachable from routePullRequest and vice versa.
|
|
98
|
+
resolved = routePullRequestClose(subset, group, authorized);
|
|
87
99
|
} else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
|
|
88
100
|
// The RAW word, not the mapped one. A trigger file names actions in the forge's own vocabulary
|
|
89
101
|
// (`label_updated`, `synchronized`) because that is what an operator reads in Forgejo's docs and
|
|
@@ -250,3 +262,92 @@ function routePullRequest(subset, triggers, action) {
|
|
|
250
262
|
}
|
|
251
263
|
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
252
264
|
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
268
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
269
|
+
* a network call apart is the drift that module's header warns against.
|
|
270
|
+
*
|
|
271
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
272
|
+
* `filterForgejo` already refused every non-true verdict before routing, and on a webhook close
|
|
273
|
+
* delivery the SENDER is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
274
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever moves,
|
|
275
|
+
* or a resolver starts returning a shape it never has, a close must fail closed under its own token
|
|
276
|
+
* rather than route on a stale assumption about the caller.
|
|
277
|
+
*/
|
|
278
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
279
|
+
const number = subset.issue?.number;
|
|
280
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
281
|
+
// degenerate shape would otherwise enqueue a numberless target.
|
|
282
|
+
if (!Number.isInteger(number)) {
|
|
283
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
284
|
+
}
|
|
285
|
+
// "closed" is PR_CLOSE_ACTIONS.forgejo -- the forge's own word, the one the loader validated the
|
|
286
|
+
// rule's action list against, exactly as routePullRequest matches raw vocabulary.
|
|
287
|
+
const found = findCloseRule(triggers.issue, "closed", number);
|
|
288
|
+
if (found.rule === undefined) {
|
|
289
|
+
return { enqueue: false, reason: found.reason };
|
|
290
|
+
}
|
|
291
|
+
if (authorized !== true) {
|
|
292
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
293
|
+
}
|
|
294
|
+
const rule = found.rule;
|
|
295
|
+
return {
|
|
296
|
+
enqueue: true,
|
|
297
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
298
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
299
|
+
image: rule.image,
|
|
300
|
+
skillsDir: rule.skillsDir,
|
|
301
|
+
secrets: rule.secrets,
|
|
302
|
+
secretsProfile: rule.secretsProfile,
|
|
303
|
+
instructions: rule.instructions,
|
|
304
|
+
resume: rule.resume,
|
|
305
|
+
replicas: rule.replicas,
|
|
306
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
307
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
308
|
+
// record stays byte-identical to the other routes' shape.
|
|
309
|
+
matched: { index: rule.index, type: "issue", action: "closed", number, ...(rule.once === true && { once: true }) },
|
|
310
|
+
target: { type: "issue", number, title: subset.issue?.title, body: subset.issue?.body },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** PR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
315
|
+
function routePullRequestClose(subset, triggers, authorized) {
|
|
316
|
+
const pr = subset.pull_request;
|
|
317
|
+
// Object AND integer number, the github arm's rule: a shape without a usable number must refuse
|
|
318
|
+
// before any rule is consulted.
|
|
319
|
+
if (!pr || !Number.isInteger(pr.number)) {
|
|
320
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
321
|
+
}
|
|
322
|
+
const found = findCloseRule(triggers.prClose, "closed", pr.number);
|
|
323
|
+
if (found.rule === undefined) {
|
|
324
|
+
return { enqueue: false, reason: found.reason };
|
|
325
|
+
}
|
|
326
|
+
if (authorized !== true) {
|
|
327
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
328
|
+
}
|
|
329
|
+
const rule = found.rule;
|
|
330
|
+
return {
|
|
331
|
+
enqueue: true,
|
|
332
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
333
|
+
packages: rule.packages,
|
|
334
|
+
image: rule.image,
|
|
335
|
+
skillsDir: rule.skillsDir,
|
|
336
|
+
secrets: rule.secrets,
|
|
337
|
+
secretsProfile: rule.secretsProfile,
|
|
338
|
+
instructions: rule.instructions,
|
|
339
|
+
resume: rule.resume,
|
|
340
|
+
replicas: rule.replicas,
|
|
341
|
+
// No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
|
|
342
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
343
|
+
matched: { index: rule.index, type: "pull_request", action: "closed", ...(rule.once === true && { once: true }) },
|
|
344
|
+
target: {
|
|
345
|
+
type: "pull_request",
|
|
346
|
+
number: pr.number,
|
|
347
|
+
title: pr.title,
|
|
348
|
+
body: pr.body,
|
|
349
|
+
head: pr.head,
|
|
350
|
+
base: pr.base,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
|
|
44
|
+
import { findCloseRule } from "./close.mjs";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
/** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
|
|
@@ -75,9 +76,32 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
75
76
|
const kind = subset.objectKind;
|
|
76
77
|
let resolved;
|
|
77
78
|
if (kind === "issue") {
|
|
78
|
-
|
|
79
|
+
if (subset.action === "close") {
|
|
80
|
+
// The close trigger (issue #231), tried FIRST -- and the label walk only as a FALLBACK -- because
|
|
81
|
+
// a real GitLab delivery can close and label in one call (a quick action comment, or the sidebar
|
|
82
|
+
// edited during close). One delivery, one job: when an armed close rule wants this close it fires
|
|
83
|
+
// and the label rule does NOT, so the two rule kinds cannot double-bill a single click.
|
|
84
|
+
resolved = routeIssueClose(subset, triggers, authorized);
|
|
85
|
+
if (!resolved.enqueue && resolved.reason !== "closer-not-allowed" && subset.labelChanges) {
|
|
86
|
+
// No close rule took it, and this close also MOVED labels (`changes.labels`, the exact field
|
|
87
|
+
// `addedLabels` reads for every non-open action). Label rules fired on such closes before close
|
|
88
|
+
// routing existed and must keep doing so byte-identically -- including on ANY close refusal, so
|
|
89
|
+
// a one-shot armed for a DIFFERENT item cannot eat a label trigger it was never about, and
|
|
90
|
+
// including the label walk's own refusal reasons when nothing matches. The `closer-not-allowed`
|
|
91
|
+
// exclusion is belt-and-braces with the route's own unreachable gate: a close a drifted
|
|
92
|
+
// resolver refused must stay refused, never demoted into a label job.
|
|
93
|
+
resolved = routeLabel(subset, triggers, "issue");
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
resolved = routeLabel(subset, triggers, "issue");
|
|
97
|
+
}
|
|
79
98
|
} else if (kind === "merge_request") {
|
|
80
|
-
|
|
99
|
+
// An MR close routes over `group.prClose` ALONE (issue #231), with no label fallback: unlike the
|
|
100
|
+
// issue arm above, MR_ACTIONS never contained "close", so no MR rule ever fired on a close and
|
|
101
|
+
// there is nothing for a fallback to keep working. MR_ACTIONS stays untouched for the same reason
|
|
102
|
+
// in the other direction -- close rules can never reach routeMergeRequest, because the loader split
|
|
103
|
+
// them into `prClose` at group time, so the two rule kinds are mutually unreachable by construction.
|
|
104
|
+
resolved = subset.action === "close" ? routeMergeRequestClose(subset, triggers, authorized) : routeMergeRequest(subset, triggers);
|
|
81
105
|
} else if (kind === "note") {
|
|
82
106
|
resolved = routeNote(subset, triggers, knownFlows);
|
|
83
107
|
} else {
|
|
@@ -225,6 +249,86 @@ function mrResult(subset, rule, matched) {
|
|
|
225
249
|
};
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
|
|
254
|
+
* this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
|
|
255
|
+
* a network call apart is the drift that module's header warns against.
|
|
256
|
+
*
|
|
257
|
+
* The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
|
|
258
|
+
* `filterGitLab` already refused every non-true verdict before routing, and on a webhook close
|
|
259
|
+
* delivery the ACTOR is the closer, so the verdict that gate consumed is the closer's. It stays
|
|
260
|
+
* anyway, because this route's own contract is the closer's authority: if the global gate ever
|
|
261
|
+
* moves, or a resolver starts returning a shape it never has, a close must fail closed under its own
|
|
262
|
+
* token rather than route on a stale assumption about the caller.
|
|
263
|
+
*/
|
|
264
|
+
function routeIssueClose(subset, triggers, authorized) {
|
|
265
|
+
const number = subset.target?.iid;
|
|
266
|
+
// Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
|
|
267
|
+
// degenerate shape would otherwise enqueue a numberless target. GitLab sends integer iids.
|
|
268
|
+
if (!Number.isInteger(number)) {
|
|
269
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
270
|
+
}
|
|
271
|
+
// "close" is PR_CLOSE_ACTIONS.gitlab -- the forge's own word, the one the loader validated the
|
|
272
|
+
// rule's action list against. GitLab really does say "close", not "closed".
|
|
273
|
+
const found = findCloseRule(triggers?.issue, "close", number);
|
|
274
|
+
if (found.rule === undefined) {
|
|
275
|
+
return { enqueue: false, reason: found.reason };
|
|
276
|
+
}
|
|
277
|
+
if (authorized !== true) {
|
|
278
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
279
|
+
}
|
|
280
|
+
const rule = found.rule;
|
|
281
|
+
return {
|
|
282
|
+
enqueue: true,
|
|
283
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
284
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
285
|
+
image: rule.image,
|
|
286
|
+
skillsDir: rule.skillsDir,
|
|
287
|
+
secrets: rule.secrets,
|
|
288
|
+
secretsProfile: rule.secretsProfile,
|
|
289
|
+
instructions: rule.instructions,
|
|
290
|
+
resume: rule.resume,
|
|
291
|
+
replicas: rule.replicas,
|
|
292
|
+
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
293
|
+
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
294
|
+
// record stays byte-identical to the other routes' shape.
|
|
295
|
+
matched: { index: rule.index, type: "issue", action: "close", number, ...(rule.once === true && { once: true }) },
|
|
296
|
+
target: buildTarget(subset, "issue"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** MR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
|
|
301
|
+
function routeMergeRequestClose(subset, triggers, authorized) {
|
|
302
|
+
// The issue arm's integer guard, same reason.
|
|
303
|
+
if (!Number.isInteger(subset.target?.iid)) {
|
|
304
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
305
|
+
}
|
|
306
|
+
const found = findCloseRule(triggers?.prClose, "close", subset.target?.iid);
|
|
307
|
+
if (found.rule === undefined) {
|
|
308
|
+
return { enqueue: false, reason: found.reason };
|
|
309
|
+
}
|
|
310
|
+
if (authorized !== true) {
|
|
311
|
+
return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
|
|
312
|
+
}
|
|
313
|
+
const rule = found.rule;
|
|
314
|
+
return {
|
|
315
|
+
enqueue: true,
|
|
316
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
317
|
+
packages: rule.packages,
|
|
318
|
+
image: rule.image,
|
|
319
|
+
skillsDir: rule.skillsDir,
|
|
320
|
+
secrets: rule.secrets,
|
|
321
|
+
secretsProfile: rule.secretsProfile,
|
|
322
|
+
instructions: rule.instructions,
|
|
323
|
+
resume: rule.resume,
|
|
324
|
+
replicas: rule.replicas,
|
|
325
|
+
// No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
|
|
326
|
+
// number is the target's business; the issue route carries it because the github design does.
|
|
327
|
+
matched: { index: rule.index, type: "pull_request", action: "close", ...(rule.once === true && { once: true }) },
|
|
328
|
+
target: buildTarget(subset, "pull_request"),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
228
332
|
/**
|
|
229
333
|
* Note (comment) path. `noteable_type` states whether the comment is on an issue or a merge request --
|
|
230
334
|
* GitHub infers the same thing from the presence of `issue.pull_request`. Routing it wrong would mint an
|
package/src/filter.mjs
CHANGED
|
@@ -33,9 +33,19 @@
|
|
|
33
33
|
* All three are hard-coded here, never config-optional -- an ungated auto-trigger is an unbounded paid run
|
|
34
34
|
* started by whoever opens a fork PR (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
|
|
35
35
|
*
|
|
36
|
+
* CLOSE ROUTES (issue #231): `issues.closed` and `pull_request.closed` route over the close-trigger
|
|
37
|
+
* groups (`triggers.issue` / `triggers.prClose`) via the ONE shared derivation in ./close.mjs. Their
|
|
38
|
+
* gate is `closerAuthorized`, the OPTIONAL sixth parameter: the receiver's pre-resolved answer to
|
|
39
|
+
* "does the account that closed this item hold write access". It is a parameter because this module
|
|
40
|
+
* is pure and that answer needs a network lookup the receiver performs (only when
|
|
41
|
+
* `wantsCloserAuthority` below says a rule wants this close). Strict `=== true` -- anything else,
|
|
42
|
+
* absence included, fails closed -- so every existing five-argument call site behaves byte-identically:
|
|
43
|
+
* their routes never read the value.
|
|
44
|
+
*
|
|
36
45
|
* `selfId` is the numeric id of whichever identity posts as the harness (the App's bot user, or the PAT
|
|
37
46
|
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
38
47
|
*/
|
|
48
|
+
import { findCloseRule } from "./close.mjs";
|
|
39
49
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
40
50
|
|
|
41
51
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
@@ -48,8 +58,17 @@ const REVIEW_ACTION = "review_submitted";
|
|
|
48
58
|
const REVIEW_EVENT_ACTION = "submitted";
|
|
49
59
|
// PR_AUTO_ACTIONS is deliberately NOT extended with REVIEW_ACTION: it exists only to select the
|
|
50
60
|
// `pr-author-not-allowed` drop reason, and the review path has reasons of its own.
|
|
61
|
+
//
|
|
62
|
+
// GitHub's close word (issue #231), byte-equal to PR_CLOSE_ACTIONS.github in the shared forge table
|
|
63
|
+
// (worker/src/triggers.mjs). Spelled here rather than imported, deliberately: this file is the GITHUB
|
|
64
|
+
// gate and already spells GitHub's action vocabulary in its own Sets above -- pulling the per-forge
|
|
65
|
+
// table into it would suggest this gate routes other forges' words, which it never does (each forge
|
|
66
|
+
// has a filter of its own, and the config grouping already consumed the table to build the groups this
|
|
67
|
+
// file reads). The word cannot drift: it is what the wire sends, and the route arm below matches the
|
|
68
|
+
// raw payload action against it.
|
|
69
|
+
const CLOSE_ACTION = "closed";
|
|
51
70
|
|
|
52
|
-
export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
71
|
+
export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthorized) {
|
|
53
72
|
// (0) Fail-closed on identity. MUST precede the self compare -- see header, ordering constraint.
|
|
54
73
|
if (typeof subset?.sender?.id !== "number") {
|
|
55
74
|
return { enqueue: false, reason: "missing-sender-id" };
|
|
@@ -85,6 +104,16 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
85
104
|
// text the harness has already been paid to read, and a dismissal removes a verdict rather than
|
|
86
105
|
// stating one.
|
|
87
106
|
resolved = routePullRequest(subset, triggers, REVIEW_ACTION);
|
|
107
|
+
} else if (eventName === "issues" && action === CLOSE_ACTION) {
|
|
108
|
+
// The issue close-trigger route (issue #231). The closer-authority gate lives INSIDE the route,
|
|
109
|
+
// after the rule match -- see routeClose for why it cannot sit up here.
|
|
110
|
+
resolved = routeIssueClose(subset, triggers, closerAuthorized);
|
|
111
|
+
} else if (eventName === "pull_request" && action === CLOSE_ACTION) {
|
|
112
|
+
// `closed` is deliberately NOT in PR_ACTIONS: a close rule gates on the CLOSER's resolved write
|
|
113
|
+
// access while every other PR rule gates on the author's association or a collaborator's label,
|
|
114
|
+
// and one route cannot gate on two different actors -- the same line config.mjs's prClose split
|
|
115
|
+
// draws, which is what makes `triggers.prClose` the only list this arm ever reads.
|
|
116
|
+
resolved = routePrClose(subset, triggers, closerAuthorized);
|
|
88
117
|
} else {
|
|
89
118
|
return { enqueue: false, reason: "unhandled-event" };
|
|
90
119
|
}
|
|
@@ -358,3 +387,135 @@ function buildPrTarget(pr) {
|
|
|
358
387
|
if (pr.base) target.base = { ref: pr.base.ref };
|
|
359
388
|
return target;
|
|
360
389
|
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Issue close path (issue #231). `matched.number` is the CLOSED ITEM's number, not the rule's
|
|
393
|
+
* narrowing -- an unnarrowed rule fires for any issue, and the decision record must still name which
|
|
394
|
+
* one spent it, or a once disarm is unexplainable back to an item.
|
|
395
|
+
*/
|
|
396
|
+
function routeIssueClose(subset, triggers, closerAuthorized) {
|
|
397
|
+
const number = subset.issue?.number;
|
|
398
|
+
// Integer or refuse, the PR arm's missing-object guard sharpened for BOTH arms: parseSubset always
|
|
399
|
+
// fabricates `subset.issue` as an object, so object presence proves nothing here, and an UNNARROWED
|
|
400
|
+
// rule matches on the action alone -- without this line a signed body replayed under a swapped
|
|
401
|
+
// event header enqueues a paid job whose target has no number at all (and a crafted string number
|
|
402
|
+
// would ride verbatim into event.json). GitHub only ever sends integers; anything else is a shape
|
|
403
|
+
// this route must not spend on.
|
|
404
|
+
if (!Number.isInteger(number)) {
|
|
405
|
+
return { enqueue: false, reason: "missing-issue-number" };
|
|
406
|
+
}
|
|
407
|
+
return routeClose(
|
|
408
|
+
triggers.issue,
|
|
409
|
+
number,
|
|
410
|
+
closerAuthorized,
|
|
411
|
+
(rule) => ({ index: rule.index, type: "issue", action: CLOSE_ACTION, number, ...(rule.once === true && { once: true }) }),
|
|
412
|
+
() => ({ type: "issue", number, title: subset.issue?.title, body: subset.issue?.body }),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* PR close path (issue #231), the same guard shape routePullRequest opens with: no pull_request
|
|
418
|
+
* object is a malformed delivery, refused before any rule is consulted. `matched` carries no number
|
|
419
|
+
* here -- the target does, exactly as on every other pull_request match.
|
|
420
|
+
*/
|
|
421
|
+
function routePrClose(subset, triggers, closerAuthorized) {
|
|
422
|
+
const pr = subset.pull_request;
|
|
423
|
+
// Object AND integer number, the issue arm's rule: a pull_request object without an integer
|
|
424
|
+
// number is the same malformed delivery wearing a shape, and the dedup key would otherwise read
|
|
425
|
+
// `#undefined`. One reason for both cases -- to an operator they are one fact.
|
|
426
|
+
if (pr === null || typeof pr !== "object" || !Number.isInteger(pr.number)) {
|
|
427
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
428
|
+
}
|
|
429
|
+
return routeClose(
|
|
430
|
+
triggers.prClose,
|
|
431
|
+
pr.number,
|
|
432
|
+
closerAuthorized,
|
|
433
|
+
(rule) => ({ index: rule.index, type: "pull_request", action: CLOSE_ACTION, ...(rule.once === true && { once: true }) }),
|
|
434
|
+
() => buildPrTarget(pr),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The shared close route: one body for both arms, driving the ONE rule derivation in ./close.mjs --
|
|
440
|
+
* the same findCloseRule call `wantsCloserAuthority` makes, so "which close does a rule want" can
|
|
441
|
+
* never drift between the receiver's pre-lookup question and this routing answer.
|
|
442
|
+
*
|
|
443
|
+
* `once` rides `matched` only when literally true, mirroring how every optional job field is absent
|
|
444
|
+
* rather than present-and-false: matched is the downstream disarm signal, and its consumers test
|
|
445
|
+
* presence, not truthiness.
|
|
446
|
+
*/
|
|
447
|
+
function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
448
|
+
const found = findCloseRule(rules, CLOSE_ACTION, number);
|
|
449
|
+
if (found.rule === undefined) {
|
|
450
|
+
return { enqueue: false, reason: found.reason };
|
|
451
|
+
}
|
|
452
|
+
// The authority gate sits INSIDE the route and AFTER the match, deliberately. The receiver performs
|
|
453
|
+
// the closer's permission lookup only for a delivery some close rule actually wants (that is what
|
|
454
|
+
// `wantsCloserAuthority` exists for), so a gate BEFORE the rule loop would emit `closer-not-allowed`
|
|
455
|
+
// for closes no rule matches -- a security token no lookup ever backed, telling an operator an
|
|
456
|
+
// authority decision was made about a delivery nobody ever resolved. A close nothing wants is
|
|
457
|
+
// `no-matching-close-trigger`, whoever closed it.
|
|
458
|
+
//
|
|
459
|
+
// Strict `!== true`, never truthiness: the value is the receiver's RESOLVED answer, and anything
|
|
460
|
+
// else reaching here -- undefined from an unwired caller, an indeterminate lookup, a "true" string
|
|
461
|
+
// or a count from a parse bug -- is a wiring fault that must fail CLOSED, the direction every gate
|
|
462
|
+
// in this file already takes (CONST-TRIGGER-AUTHOR-GATE).
|
|
463
|
+
if (closerAuthorized !== true) {
|
|
464
|
+
return { enqueue: false, reason: "closer-not-allowed" };
|
|
465
|
+
}
|
|
466
|
+
const rule = found.rule;
|
|
467
|
+
return {
|
|
468
|
+
enqueue: true,
|
|
469
|
+
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
470
|
+
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
471
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
472
|
+
image: rule.image,
|
|
473
|
+
skillsDir: rule.skillsDir,
|
|
474
|
+
secrets: rule.secrets,
|
|
475
|
+
secretsProfile: rule.secretsProfile,
|
|
476
|
+
instructions: rule.instructions,
|
|
477
|
+
resume: rule.resume,
|
|
478
|
+
replicas: rule.replicas,
|
|
479
|
+
matched: matchedFor(rule),
|
|
480
|
+
target: targetFor(),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Does this forge group arm any close trigger at all? Pure, for the receiver/poller arm: whether the
|
|
486
|
+
* closer-authority machinery is worth wiring up for a delivery stream is a per-group fact, and
|
|
487
|
+
* deriving it anywhere else would be a second spelling of "which groups are the close groups".
|
|
488
|
+
*/
|
|
489
|
+
export function hasCloseTriggers(group) {
|
|
490
|
+
return group?.issue?.length > 0 || group?.prClose?.length > 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Should the receiver spend a permission lookup on this delivery before calling `filter`? Pure, and
|
|
495
|
+
* the SINGLE derivation shared with the route above: it calls the same findCloseRule over the same
|
|
496
|
+
* groups, so the pre-lookup question and the routing answer cannot drift -- a delivery never costs a
|
|
497
|
+
* lookup the route then ignores, and never routes a close the lookup never gated (./close.mjs's
|
|
498
|
+
* header names exactly this hazard).
|
|
499
|
+
*/
|
|
500
|
+
export function wantsCloserAuthority(eventName, subset, group, selfId) {
|
|
501
|
+
// Filter's own step-0/1 guards, replicated FIRST and in the same order (fail-closed identity, then
|
|
502
|
+
// the unconditional bot-loop guard): the harness closing its own issue -- the natural last act of
|
|
503
|
+
// the very flow a close trigger arms -- must not cost a lookup, and an INDETERMINATE lookup on a
|
|
504
|
+
// self-delivery would 503 (so: redeliver, retry, loop) traffic the filter would only ever drop as
|
|
505
|
+
// `self`.
|
|
506
|
+
if (typeof subset?.sender?.id !== "number") return false;
|
|
507
|
+
if (subset.sender.id === selfId) return false;
|
|
508
|
+
if (eventName === "issues" && subset.action === CLOSE_ACTION) {
|
|
509
|
+
// The routes' own shape guards, replicated for the same one-derivation reason as the step-0/1
|
|
510
|
+
// guards above: an UNNARROWED rule matches an undefined number, so without these lines a
|
|
511
|
+
// degenerate payload costs a token mint and a lookup the route then drops -- the exact "lookup
|
|
512
|
+
// the route ignores" this function exists to make impossible.
|
|
513
|
+
if (!Number.isInteger(subset.issue?.number)) return false;
|
|
514
|
+
return findCloseRule(group?.issue, CLOSE_ACTION, subset.issue.number).rule !== undefined;
|
|
515
|
+
}
|
|
516
|
+
if (eventName === "pull_request" && subset.action === CLOSE_ACTION) {
|
|
517
|
+
if (subset.pull_request === null || typeof subset.pull_request !== "object" || !Number.isInteger(subset.pull_request.number)) return false;
|
|
518
|
+
return findCloseRule(group?.prClose, CLOSE_ACTION, subset.pull_request.number).rule !== undefined;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -77,7 +77,10 @@ export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch })
|
|
|
77
77
|
try {
|
|
78
78
|
body = await res.json();
|
|
79
79
|
} catch (err) {
|
|
80
|
-
|
|
80
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
81
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
82
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
83
|
+
return { indeterminate: "collaborator permission lookup returned unparseable JSON" };
|
|
81
84
|
}
|
|
82
85
|
const permission = body?.permission;
|
|
83
86
|
if (typeof permission !== "string" || permission === "") {
|
package/src/forgejo-subset.mjs
CHANGED
|
@@ -44,6 +44,11 @@ const ISSUE_ACTIONS = {
|
|
|
44
44
|
opened: "opened",
|
|
45
45
|
reopened: "reopened",
|
|
46
46
|
label_updated: "labeled",
|
|
47
|
+
// The close trigger (issue #231) made `closed` actionable, so it moved OUT of IGNORED_ACTIONS
|
|
48
|
+
// below. It sits in BOTH maps deliberately: the maps are selected by EVENT NAME, so `closed` on
|
|
49
|
+
// `issues` and `closed` on `pull_request` are two different routes sharing a spelling, not one
|
|
50
|
+
// word listed twice -- the never-in-two rule below is about a map versus the ignored set.
|
|
51
|
+
closed: "closed",
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
const PR_ACTIONS = {
|
|
@@ -51,12 +56,19 @@ const PR_ACTIONS = {
|
|
|
51
56
|
reopened: "reopened",
|
|
52
57
|
label_updated: "labeled",
|
|
53
58
|
synchronized: "synchronize",
|
|
59
|
+
closed: "closed", // issue #231 -- see the note on ISSUE_ACTIONS
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Recognised, and deliberately not actionable. Named so the drop reason can say which it was.
|
|
64
|
+
*
|
|
65
|
+
* DISJOINT from both action maps, and that is an invariant rather than an accident: `mapAction`
|
|
66
|
+
* would happily route a word that also sat here, and this set's claim of "ignored" would then be a
|
|
67
|
+
* lie the drop reason repeats to an operator. `closed` lived here until issue #231 made closes
|
|
68
|
+
* routable; it MOVED into the maps rather than gaining a twin.
|
|
69
|
+
*/
|
|
57
70
|
const IGNORED_ACTIONS = new Set([
|
|
58
71
|
"label_cleared", // removing a label must never start a paid run
|
|
59
|
-
"closed",
|
|
60
72
|
"edited",
|
|
61
73
|
"assigned",
|
|
62
74
|
"unassigned",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a GitHub CLOSER's repository permission -- the enforcement half of `CONST-TRIGGER-AUTHOR-GATE`'s
|
|
3
|
+
* close arm on the GitHub side.
|
|
4
|
+
*
|
|
5
|
+
* Every other GitHub route reads an association straight off the payload and pays no lookup. A close
|
|
6
|
+
* cannot: an issue's own author can close it with no write access whatsoever, and on a pull request
|
|
7
|
+
* `author_association` names the AUTHOR, a different person from the closer -- the review inversion a
|
|
8
|
+
* second time. The payload carries NO association for the closer at all, so the closer is resolved by
|
|
9
|
+
* login through `GET /repos/{owner}/{repo}/collaborators/{username}/permission`, whose legacy
|
|
10
|
+
* `permission` field answers `"admin"|"write"|"read"|"none"`. `admin` and `write` are exactly the levels
|
|
11
|
+
* that can push a branch, which is the property the constitution actually requires.
|
|
12
|
+
*
|
|
13
|
+
* The shape of this module is the Forgejo resolver's, for the same three reasons, restated rather than
|
|
14
|
+
* cross-referenced:
|
|
15
|
+
* - it runs in the RECEIVER, between verification and the gate, so `filter` stays pure, total and
|
|
16
|
+
* offline-testable -- a fetch inside the gate would make the security-critical decision untestable
|
|
17
|
+
* without a server;
|
|
18
|
+
* - it runs AFTER verification -- and, unlike every sibling resolver, only for a close delivery an
|
|
19
|
+
* armed close rule actually matches (`wantsCloserAuthority`), so every other GitHub path stays
|
|
20
|
+
* payload-only and lookup-free, and an unauthenticated flood cannot make this project call GitHub;
|
|
21
|
+
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
22
|
+
* answers. A 404 is determinate and refuses; anything unrecognised is INDETERMINATE and the receiver
|
|
23
|
+
* answers 503, so GitHub redelivers and the stable `X-GitHub-Delivery` GUID dedups the retry.
|
|
24
|
+
* Collapsing indeterminate to "deny" would drop real work during an outage behind a 204 that looks
|
|
25
|
+
* exactly like a stranger being correctly refused.
|
|
26
|
+
*
|
|
27
|
+
* One difference from every sibling: there is no operator token in config to close over. The GitHub arm
|
|
28
|
+
* holds the boot-time auth object instead, so the credential is MINTED per call. On the App source the
|
|
29
|
+
* caller asks the mint to scope it to the one repository AND narrow it to metadata:read, so what this
|
|
30
|
+
* function holds cannot write even if leaked; on pat/gh no narrowing exists (the operator's standing
|
|
31
|
+
* token is what it is, and it already lives in this process's env), so what the wiring buys there is
|
|
32
|
+
* one read with a credential the receiver held anyway. Never a job credential on any source, and no
|
|
33
|
+
* container ever receives it (`CONST-TOKEN-SCOPED-PER-JOB`, whose per-job wording start.mjs's header
|
|
34
|
+
* records). Closes are rare, so per-delivery minting is the recorded cost; the poller's token cache is
|
|
35
|
+
* the fallback if that changes.
|
|
36
|
+
*
|
|
37
|
+
* The username is required (the endpoint takes no numeric id) and is never logged or returned. One
|
|
38
|
+
* honest bound on that claim, shared with every sibling resolver: the network-throw arm returns
|
|
39
|
+
* `fetchFailureReason(err)`, and undici's error messages carry the HOST, never the request path --
|
|
40
|
+
* so the guarantee on that one arm rests on undici's phrasing rather than a fixed token, exactly as
|
|
41
|
+
* it does in the forgejo/gitlab/azure resolvers. Every arm this module authors itself is a fixed
|
|
42
|
+
* string.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
46
|
+
|
|
47
|
+
/** GitHub's API root. Not configurable: this arm serves github.com, the forges with a host knob have their own resolvers. */
|
|
48
|
+
const API_ROOT = "https://api.github.com";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
52
|
+
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
53
|
+
* draws. Two members is the COMPLETE honest mapping, not a shortcut: GitHub folds `maintain` into `write`
|
|
54
|
+
* and `triage` into `read` in this legacy field, so every role that can push already reads as one of
|
|
55
|
+
* these. The response's richer `role_name` is deliberately not read -- it can name Ultimate custom roles
|
|
56
|
+
* this code has no table to rank, and ranking them wrongly fails OPEN.
|
|
57
|
+
*/
|
|
58
|
+
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the resolver. `mintToken` is the boot auth object's own minter (it takes a job-shaped `{ repo }`
|
|
62
|
+
* and scopes the token to it); `fetchFn` is injected so the whole module is testable offline.
|
|
63
|
+
*
|
|
64
|
+
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
65
|
+
* -- the same shape every forge's resolver returns.
|
|
66
|
+
*/
|
|
67
|
+
export function makeResolveGitHubAuthority({ mintToken, fetchFn = fetch }) {
|
|
68
|
+
return async function resolveAuthority(repoFullName, login) {
|
|
69
|
+
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
70
|
+
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
71
|
+
// the answer would be attributed to the wrong repository or the wrong person. Whitespace joins the
|
|
72
|
+
// refused set here because a GitHub login can never carry it, so its presence is a malformed payload,
|
|
73
|
+
// not a user. Determinate refusals, before the mint: nothing is asked about, so nothing is spent.
|
|
74
|
+
const repo = typeof repoFullName === "string" ? repoFullName.split("/") : [];
|
|
75
|
+
// The repo halves get the login's charset discipline PLUS the dot-segment refusal: a half of
|
|
76
|
+
// exactly "." or ".." would URL-normalize the request onto a different endpoint than the one
|
|
77
|
+
// this function believes it is asking (a repo NAME may contain dots -- "next.js" is real -- so
|
|
78
|
+
// only the two pure dot segments are refused, never dots inside a name).
|
|
79
|
+
const badHalf = (h) => !h || h === "." || h === ".." || /[/?#\s]/.test(h);
|
|
80
|
+
if (repo.length !== 2 || badHalf(repo[0]) || badHalf(repo[1]) || typeof login !== "string" || login === "" || /[/?#\s]/.test(login)) {
|
|
81
|
+
// Not a lookup failure -- the payload never named a repository and an actor we could ask about.
|
|
82
|
+
return { authorized: false };
|
|
83
|
+
}
|
|
84
|
+
// Minted per call, repo-scoped (the auth object's minter reads `job.repo`). A mint failure is
|
|
85
|
+
// INDETERMINATE -- the closer's standing was never established -- and the reason is a fixed token,
|
|
86
|
+
// never the thrown message: configError texts name auth sources and key paths, which have no
|
|
87
|
+
// business in a per-delivery log line.
|
|
88
|
+
let token;
|
|
89
|
+
try {
|
|
90
|
+
token = await mintToken({ repo: repoFullName });
|
|
91
|
+
} catch {
|
|
92
|
+
return { indeterminate: "token-mint-failed" };
|
|
93
|
+
}
|
|
94
|
+
const url = `${API_ROOT}/repos/${encodeURIComponent(repo[0])}/${encodeURIComponent(repo[1])}/collaborators/${encodeURIComponent(login)}/permission`;
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
// `redirect: "error"` so a 30x on this path cannot silently send the token somewhere else -- the
|
|
98
|
+
// same rule the Forgejo and GitLab resolvers apply.
|
|
99
|
+
res = await fetchFn(url, {
|
|
100
|
+
headers: {
|
|
101
|
+
accept: "application/vnd.github+json",
|
|
102
|
+
"x-github-api-version": "2022-11-28",
|
|
103
|
+
authorization: `Bearer ${token}`,
|
|
104
|
+
},
|
|
105
|
+
redirect: "error",
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { indeterminate: fetchFailureReason(err) };
|
|
109
|
+
}
|
|
110
|
+
if (res.status === 404) {
|
|
111
|
+
// Unknown user or unknown repository -- determinate, and refused. NOT the usual non-collaborator
|
|
112
|
+
// answer: see the `permission: "none"` note below.
|
|
113
|
+
return { authorized: false };
|
|
114
|
+
}
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
// Status only. What is NOT here: the response body -- a GitHub error body can echo the request,
|
|
117
|
+
// and the request carried the token.
|
|
118
|
+
return { indeterminate: `status-${res.status}` };
|
|
119
|
+
}
|
|
120
|
+
let body;
|
|
121
|
+
try {
|
|
122
|
+
body = await res.json();
|
|
123
|
+
} catch {
|
|
124
|
+
// A fixed token, deliberately WITHOUT the parse error's message -- a divergence from the Forgejo
|
|
125
|
+
// resolver worth its own line: V8's JSON.parse errors quote the offending input, and the input
|
|
126
|
+
// here is the response body, which must never reach a returned string.
|
|
127
|
+
return { indeterminate: "collaborator permission lookup returned unparseable JSON" };
|
|
128
|
+
}
|
|
129
|
+
const permission = body?.permission;
|
|
130
|
+
if (typeof permission !== "string" || permission === "") {
|
|
131
|
+
// A 200 whose shape we do not recognise is not a refusal. Answering `false` here would turn an
|
|
132
|
+
// upstream schema change into a silent, permanent refusal of every close trigger.
|
|
133
|
+
return { indeterminate: "collaborator permission lookup returned no permission string" };
|
|
134
|
+
}
|
|
135
|
+
// The NORMAL answer for a non-collaborator is a 200 with `permission: "none"` -- GitHub answers the
|
|
136
|
+
// question for any visible user rather than 404ing strangers -- so this line, not the 404 arm above,
|
|
137
|
+
// is where most unauthorized closers are refused.
|
|
138
|
+
return { authorized: WRITE_PERMISSIONS.has(permission) };
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/gitlab-members.mjs
CHANGED
|
@@ -78,7 +78,10 @@ export function makeResolveAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
|
78
78
|
try {
|
|
79
79
|
body = await res.json();
|
|
80
80
|
} catch (err) {
|
|
81
|
-
|
|
81
|
+
// A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
|
|
82
|
+
// so a failed res.json() here would carry response-body bytes into a log line -- the
|
|
83
|
+
// no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
|
|
84
|
+
return { indeterminate: "members lookup returned unparseable JSON" };
|
|
82
85
|
}
|
|
83
86
|
const level = body?.access_level;
|
|
84
87
|
if (!Number.isInteger(level)) {
|
package/src/poller.mjs
CHANGED
|
@@ -43,9 +43,18 @@
|
|
|
43
43
|
* a closed PR has nothing left to act on, the same call `merge`/`close` get in the
|
|
44
44
|
* action vocabulary. REST spells `state` in upper case where the webhook spells it
|
|
45
45
|
* lower; `parseSubset` folds it, so both transports produce the same job.
|
|
46
|
+
* - closed: the SAME /issues/events feed as `label` -- `closed` entries carry the CLOSER as
|
|
47
|
+
* `actor` and cover issues AND PRs (`issue.pull_request` is the discriminator again,
|
|
48
|
+
* and a MERGED PR emits `closed` here too, which is what lets a close trigger release
|
|
49
|
+
* post-merge work). Consumed only when a close rule is armed (hasCloseTriggers), so an
|
|
50
|
+
* unarmed deployment's cycle stays byte-identical to a pre-#231 run: no PR fetch, no
|
|
51
|
+
* permission traffic. The closer's write access is resolved via the shared
|
|
52
|
+
* collaborator-permission lookup BEFORE the gate, mirroring the webhook arm
|
|
53
|
+
* (issue #231; see `gate` for the indeterminate-lookup retry bound).
|
|
46
54
|
*
|
|
47
55
|
* DEDUP IDS (REQ-DEDUP-BY-DELIVERY-GUID): polling has no delivery GUID, so each source mints a
|
|
48
|
-
* deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (
|
|
56
|
+
* deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (the /issues/events
|
|
57
|
+
* feed: label AND close entries, one feed so one id family),
|
|
49
58
|
* `poll-c<commentId>` (comments), `poll-pr<number>-<headSha7>` (PR actions; sha-keyed so a retried
|
|
50
59
|
* cycle cannot double-enqueue while a real new push mints a new id -- with the honest corollary that
|
|
51
60
|
* a same-sha reopen inside the retention window coalesces with its own `opened` job), and
|
|
@@ -62,6 +71,10 @@
|
|
|
62
71
|
* poll:<owner/repo>:etag:reviews hash: PR number -> that PR's reviews-endpoint validator. A HASH
|
|
63
72
|
* rather than a key per PR, so the family below stays enumerable
|
|
64
73
|
* and `touchRepo` can still refresh it as a unit.
|
|
74
|
+
* poll:<owner/repo>:close-gate:<delivery> consecutive INDETERMINATE closer-lookup attempts for ONE
|
|
75
|
+
* close event (issue #231). Deliberately OUTSIDE the touched
|
|
76
|
+
* family, with a short ~1-day TTL of its own: the counter must
|
|
77
|
+
* decay with the outage it measures, not live with the repo.
|
|
65
78
|
* All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful repo poll. 35 days
|
|
66
79
|
* deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
|
|
67
80
|
* the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
|
|
@@ -98,7 +111,8 @@ import { configError } from "@edgehero/pi-dispatch/config";
|
|
|
98
111
|
import { parseConnection } from "@edgehero/pi-dispatch/connection";
|
|
99
112
|
import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
|
|
100
113
|
import { enqueueGitHubJob, makeQueue } from "@edgehero/pi-dispatch/queue";
|
|
101
|
-
import { filter } from "./filter.mjs";
|
|
114
|
+
import { filter, hasCloseTriggers, wantsCloserAuthority } from "./filter.mjs";
|
|
115
|
+
import { makeResolveGitHubAuthority } from "./github-members.mjs";
|
|
102
116
|
import { parseSubset } from "./receiver.mjs";
|
|
103
117
|
import { loadPollerConfig } from "./poller-config.mjs";
|
|
104
118
|
|
|
@@ -119,6 +133,13 @@ const MAX_PR_PAGES = 10;
|
|
|
119
133
|
const MAX_REVIEW_PRS = 50;
|
|
120
134
|
// The hash value marking a PR that left the open list. Cannot collide with a head sha (hex only).
|
|
121
135
|
const CLOSED_MARKER = "closed";
|
|
136
|
+
// The closer-authority retry bound (issue #231): how many consecutive cycles an INDETERMINATE
|
|
137
|
+
// collaborator-permission lookup may hold the events cursor before the close is dropped loudly.
|
|
138
|
+
// ~20 cycles at the default 60s interval is a real outage, not a blip; see `gate` for the tradeoff.
|
|
139
|
+
const CLOSE_GATE_MAX_ATTEMPTS = 20;
|
|
140
|
+
// The retry counter's own TTL: it measures ONE outage around ONE event, so it decays in a day rather
|
|
141
|
+
// than riding the 35-day cursor family (touchRepo never refreshes it -- see the module header).
|
|
142
|
+
const CLOSE_GATE_TTL_SECONDS = 24 * 3600;
|
|
122
143
|
|
|
123
144
|
/** Thrown by the API helper when the credential's quota is exhausted; carries the reset time in ms. */
|
|
124
145
|
class RateLimited extends Error {
|
|
@@ -151,6 +172,7 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
151
172
|
fsDeps = {},
|
|
152
173
|
makeAuth = makeGitHubAuth,
|
|
153
174
|
makeQueueFn = makeQueue,
|
|
175
|
+
makeResolveGitHubAuthority: makeResolveGitHubAuthorityFn = makeResolveGitHubAuthority,
|
|
154
176
|
} = deps;
|
|
155
177
|
|
|
156
178
|
const cfg = loadPollerConfig(env, fsDeps);
|
|
@@ -173,6 +195,16 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
173
195
|
? makeAppInstallationTokenFn(cfg.github, { fetchFn, readFile, now })
|
|
174
196
|
: async () => (await getAuth()).mintToken());
|
|
175
197
|
|
|
198
|
+
// The closer-authority resolver (issue #231), built over the poller's OWN mint above -- the injected
|
|
199
|
+
// factory default, start.mjs's convention. On the app source that mint hands back the CACHED,
|
|
200
|
+
// UNSCOPED installation token (makeAppInstallationTokenFn below), and the resolver's job-shaped
|
|
201
|
+
// `{ repo }` argument is simply ignored by it. That is fine for what this token does here: one
|
|
202
|
+
// read-only permission lookup that never leaves this process. The webhook arm's per-delivery
|
|
203
|
+
// metadata:read narrowing (start.mjs) is the stricter posture; the poller's cache is the deliberate
|
|
204
|
+
// cost tradeoff its own header already records (the credential must read every polled repo anyway),
|
|
205
|
+
// and github-members.mjs names this cache as the recorded fallback to per-delivery minting.
|
|
206
|
+
const resolveCloserAuthority = makeResolveGitHubAuthorityFn({ mintToken, fetchFn });
|
|
207
|
+
|
|
176
208
|
// Cursor store. ioredis is imported lazily so tests injecting a fake never load the driver. The
|
|
177
209
|
// client rides out disconnects (ioredis reconnects on its own) -- same posture as the receiver's
|
|
178
210
|
// queue connection: a long-running producer should survive a Valkey restart.
|
|
@@ -198,7 +230,7 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
198
230
|
if (ownRedis) await redisClient.quit();
|
|
199
231
|
};
|
|
200
232
|
|
|
201
|
-
const ctx = { cfg, selfId, fetchFn, redis: redisClient, enqueue, out, now, random };
|
|
233
|
+
const ctx = { cfg, selfId, fetchFn, redis: redisClient, enqueue, out, now, random, resolveCloserAuthority };
|
|
202
234
|
|
|
203
235
|
// The repo set: explicit POLL_REPOS, or the App installation's list (cfg.repos === null only when
|
|
204
236
|
// the source is app -- poller-config enforces it). An empty discovery is a config error, not an
|
|
@@ -493,9 +525,29 @@ async function pollLabelEvents(ctx, api, repo, stats) {
|
|
|
493
525
|
for (const ev of fresh) {
|
|
494
526
|
if (ev.event === "labeled" && ev.issue) {
|
|
495
527
|
await handleLabeledEvent(ctx, api, repo, ev, stats);
|
|
528
|
+
} else if (ev.event === "closed" && ev.issue && hasCloseTriggers(ctx.cfg?.triggers?.github)) {
|
|
529
|
+
// The coarse hasCloseTriggers guard IS the byte-identity switch (issue #231): with no close
|
|
530
|
+
// rule armed, a `closed` entry takes the same do-nothing path every unhandled event always
|
|
531
|
+
// has -- no PR fetch, no permission traffic, a cycle indistinguishable from a pre-#231 run.
|
|
532
|
+
// An entry with no `issue` field is malformed and skipped the same way, never thrown on.
|
|
533
|
+
try {
|
|
534
|
+
await handleClosedEvent(ctx, api, repo, ev, stats);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
// The scoped catch that keeps a close-gate spell from starving the WHOLE repo: a throw
|
|
537
|
+
// here (an indeterminate closer lookup below its bound, a PR fetch blip) must hold THIS
|
|
538
|
+
// feed's cursor before the failed event -- a monotone cursor cannot skip one entry and
|
|
539
|
+
// come back -- but the comment and pull feeds have their OWN cursors and their own real
|
|
540
|
+
// work, so ending only the events feed for this cycle lets pollRepo continue to them.
|
|
541
|
+
// RateLimited still propagates: the quota is credential-global and pollRepo's caller
|
|
542
|
+
// sleeps the whole roster out, which no per-feed catch may swallow.
|
|
543
|
+
if (err instanceof RateLimited) throw err;
|
|
544
|
+
ctx.out({ event: "poll_close_gate_retry", repo, delivery: `poll-e${ev.id}`, reason: err?.message });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
496
547
|
}
|
|
497
548
|
// Advance ONLY after the event is handled: an enqueue/fetch failure above leaves the cursor on
|
|
498
|
-
// the last success, so the retry next cycle resumes at the exact failed event.
|
|
549
|
+
// the last success, so the retry next cycle resumes at the exact failed event. Unmatched and
|
|
550
|
+
// unarmed closes advance past here exactly like every unhandled event always has.
|
|
499
551
|
await setWithTtl(ctx, k.events, String(ev.id));
|
|
500
552
|
}
|
|
501
553
|
}
|
|
@@ -527,6 +579,42 @@ async function handleLabeledEvent(ctx, api, repo, ev, stats) {
|
|
|
527
579
|
}
|
|
528
580
|
}
|
|
529
581
|
|
|
582
|
+
/**
|
|
583
|
+
* One `closed` event -> the webhook payload it corresponds to -> the unchanged gate (issue #231).
|
|
584
|
+
* handleLabeledEvent's twin, split on the same discriminator: the events feed hands us the ISSUE
|
|
585
|
+
* view, and `issue.pull_request` marks a PR. An issue routes as `issues closed` with the issue object
|
|
586
|
+
* as-is; a PR must route as `pull_request closed`, and the issue view lacks the PR fields the job's
|
|
587
|
+
* target carries (title/body/head/base as the webhook subset shapes them), so the PR object is
|
|
588
|
+
* fetched once per event -- field-for-field parity again, paid only on NEW closed events under an
|
|
589
|
+
* ARMED close rule (the caller's hasCloseTriggers guard). A MERGED PR emits `closed` in this feed
|
|
590
|
+
* too, exactly as the webhook's `closed` action covers merged -- which is what lets a prClose
|
|
591
|
+
* trigger release post-merge work over polling.
|
|
592
|
+
*
|
|
593
|
+
* A failing PR fetch rides the labeled twin's own retry discipline: the throw holds the events
|
|
594
|
+
* cursor and retries next cycle (scoped to this feed since #231). Unbounded on purpose -- unlike an
|
|
595
|
+
* indeterminate LOOKUP, a fetch failure here has no counter, because the one input that could make
|
|
596
|
+
* it permanent (a deleted PR) is a GitHub-support-only operation the labeled path has carried
|
|
597
|
+
* unbounded since it shipped, and a bound would spend its complexity on a case nobody can produce.
|
|
598
|
+
*
|
|
599
|
+
* `sender` carries the closer's LOGIN as well as the id, alone among this module's synthesized
|
|
600
|
+
* payloads: the collaborator-permission lookup is by username because that is the only key GitHub's
|
|
601
|
+
* endpoint takes. Same justification and same obligation as the webhook subset's `sender.login`
|
|
602
|
+
* (parseSubset): it exists to have been asked about, it is never logged, and the job literal keeps
|
|
603
|
+
* `trigger.sender` at `{ id }` alone.
|
|
604
|
+
*/
|
|
605
|
+
async function handleClosedEvent(ctx, api, repo, ev, stats) {
|
|
606
|
+
const deliveryId = `poll-e${ev.id}`;
|
|
607
|
+
const sender = { id: ev.actor?.id, login: ev.actor?.login };
|
|
608
|
+
if (ev.issue.pull_request != null) {
|
|
609
|
+
const pr = (await api.get(`/repos/${repo}/pulls/${ev.issue.number}`)).json;
|
|
610
|
+
const payload = { action: "closed", sender, pull_request: pr, repository: { full_name: repo } };
|
|
611
|
+
await gate(ctx, "pull_request", payload, deliveryId, stats);
|
|
612
|
+
} else {
|
|
613
|
+
const payload = { action: "closed", sender, issue: ev.issue, repository: { full_name: repo } };
|
|
614
|
+
await gate(ctx, "issues", payload, deliveryId, stats);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
530
618
|
/**
|
|
531
619
|
* The comment feed: /issues/comments?since=<cursor>, cursor = newest processed updated_at.
|
|
532
620
|
*
|
|
@@ -770,12 +858,59 @@ async function pollReviews(ctx, api, repo, open, stats) {
|
|
|
770
858
|
|
|
771
859
|
/**
|
|
772
860
|
* The single choke point every synthesized payload passes through, and deliberately the receiver's
|
|
773
|
-
* exact pipeline: parseSubset ->
|
|
774
|
-
* enqueueGitHubJob, with the receiver's own log
|
|
775
|
-
* same reason it is there: the failed cycle
|
|
861
|
+
* exact pipeline: parseSubset -> (closer-authority resolution, close deliveries only) -> filter
|
|
862
|
+
* (UNCHANGED, same cfg/selfId) -> replica fanout -> enqueueGitHubJob, with the receiver's own log
|
|
863
|
+
* shapes. Partial replica failure is idempotent for the same reason it is there: the failed cycle
|
|
864
|
+
* re-runs, replicas 1..k-1 dedup on their taken jobIds.
|
|
865
|
+
*
|
|
866
|
+
* The authority step (issue #231) mirrors the webhook arm's placement exactly -- after the subset,
|
|
867
|
+
* before the gate -- and `wantsCloserAuthority` is the same shared derivation, so only a close
|
|
868
|
+
* delivery an armed close rule matches ever costs a lookup: it is false by construction for every
|
|
869
|
+
* other source this module synthesizes (label, comment, PR diff, review), for a self-close, and for
|
|
870
|
+
* a close nothing wants. A DETERMINATE answer rides into filter as the sixth argument, so a
|
|
871
|
+
* stranger's close is the filter's own `closer-not-allowed` drop and the events cursor advances
|
|
872
|
+
* past it -- an unauthorized close never wedges the feed.
|
|
873
|
+
*
|
|
874
|
+
* An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (503, forge
|
|
875
|
+
* redelivers) has no analogue here -- the poller IS its own redelivery. So: bounded retry, then a
|
|
876
|
+
* loud skip. Below the bound this THROWS, on purpose, into pollLabelEvents' scoped catch: the cycle
|
|
877
|
+
* logs `poll_close_gate_retry`, the events cursor is still sitting BEFORE this event (the caller
|
|
878
|
+
* advances it only after a handler returns), so the next cycle retries exactly this close -- and
|
|
879
|
+
* only the EVENTS feed ends for the cycle, because a monotone cursor cannot skip an entry and come
|
|
880
|
+
* back, while the comment and pull feeds run on their own cursors and must not starve behind a
|
|
881
|
+
* close-gate spell. The attempt counter lives in redis (INCR + its own short TTL), not in memory,
|
|
882
|
+
* so a restart mid-outage cannot reset the bound. AT the bound the close is dropped WITHOUT
|
|
883
|
+
* enqueueing and the cursor advances: one close dropped loudly (`poll_close_gate_gave_up`, with the
|
|
884
|
+
* delivery id an operator can act on) beats later label and close events wedged forever behind a
|
|
885
|
+
* lookup that may never come back -- after ~20 cycles this is an outage, not a blip. A crash
|
|
886
|
+
* between the give-up log and the cursor write re-logs the give-up once on restart (attempt 21):
|
|
887
|
+
* self-limiting, and preferable to advancing before the operator has a line to act on. The counter
|
|
888
|
+
* is not deleted on a determinate answer; its TTL decays it, and the cursor has moved past the
|
|
889
|
+
* delivery id for good.
|
|
776
890
|
*/
|
|
777
891
|
async function gate(ctx, eventName, payload, deliveryId, stats) {
|
|
778
|
-
const
|
|
892
|
+
const subset = parseSubset(payload);
|
|
893
|
+
|
|
894
|
+
let closerAuthorized;
|
|
895
|
+
if (wantsCloserAuthority(eventName, subset, ctx.cfg?.triggers?.github, ctx.selfId)) {
|
|
896
|
+
const repo = subset.repository?.full_name;
|
|
897
|
+
const resolved = await ctx.resolveCloserAuthority(repo, subset.sender?.login);
|
|
898
|
+
if (resolved.indeterminate) {
|
|
899
|
+
const counterKey = `poll:${repo}:close-gate:${deliveryId}`;
|
|
900
|
+
const attempts = await ctx.redis.incr(counterKey);
|
|
901
|
+
await ctx.redis.expire(counterKey, CLOSE_GATE_TTL_SECONDS);
|
|
902
|
+
if (attempts < CLOSE_GATE_MAX_ATTEMPTS) {
|
|
903
|
+
// The reason names the lookup, never the actor (no-pii-in-logs): resolver reasons are
|
|
904
|
+
// fixed tokens, and this message becomes pollRepo's poll_repo_failed line.
|
|
905
|
+
throw new Error(`closer permission lookup indeterminate (${resolved.indeterminate})`);
|
|
906
|
+
}
|
|
907
|
+
ctx.out({ event: "poll_close_gate_gave_up", repo, delivery: deliveryId, reason: resolved.indeterminate });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
closerAuthorized = resolved.authorized;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const result = filter(eventName, subset, ctx.cfg, ctx.selfId, deliveryId, closerAuthorized);
|
|
779
914
|
if (!result.enqueue) {
|
|
780
915
|
ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
|
|
781
916
|
return;
|
package/src/receiver.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { makeVerifiedHandler } from "./verify.mjs";
|
|
23
|
-
import { filter } from "./filter.mjs";
|
|
23
|
+
import { filter, wantsCloserAuthority } from "./filter.mjs";
|
|
24
24
|
import { enqueueForgeJob, enqueueGitHubJob, enqueueGitLabJob } from "@edgehero/pi-dispatch/queue";
|
|
25
25
|
import { filterGitLab } from "./filter-gitlab.mjs";
|
|
26
26
|
import { parseGitLabSubset } from "./gitlab-subset.mjs";
|
|
@@ -51,7 +51,14 @@ export function parseSubset(payload) {
|
|
|
51
51
|
const pr = payload.pull_request;
|
|
52
52
|
return {
|
|
53
53
|
action: payload.action,
|
|
54
|
-
|
|
54
|
+
// `login` is CARRIED since issue #231, where this subset deliberately excluded it before. It is
|
|
55
|
+
// personal data, and it is here for exactly one consumer -- the CLOSER's collaborator-permission
|
|
56
|
+
// lookup, which is by username because that is the only key GitHub's endpoint takes. Same
|
|
57
|
+
// justification, and same obligation, as `sender.login` in the Forgejo subset: it exists to have
|
|
58
|
+
// been asked about, it is never logged (no-pii-in-logs covers everything the resolution path
|
|
59
|
+
// writes down), and it is never enqueued -- the filter's job literal keeps `trigger.sender` at
|
|
60
|
+
// `{ id }` alone.
|
|
61
|
+
sender: { id: payload.sender?.id, login: payload.sender?.login },
|
|
55
62
|
issue: {
|
|
56
63
|
number: payload.issue?.number,
|
|
57
64
|
title: payload.issue?.title,
|
|
@@ -127,12 +134,16 @@ export function parseSubset(payload) {
|
|
|
127
134
|
* undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
|
|
128
135
|
* failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
|
|
129
136
|
*/
|
|
130
|
-
export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null }) {
|
|
137
|
+
export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority }) {
|
|
131
138
|
// Built only when the deployment serves GitHub. Construction is not free of the secret either: the
|
|
132
139
|
// `new Webhooks({ secret })` inside makeVerifiedHandler throws "options.secret required" on an absent
|
|
133
140
|
// one, so not building the arm is what lets a github-free deployment legitimately have no secret --
|
|
134
141
|
// no placeholder, nothing papered over, and no handler holding a secret nobody chose.
|
|
135
|
-
|
|
142
|
+
//
|
|
143
|
+
// `resolveAuthority` is the GITHUB closer resolver (issue #231), riding top-level beside `selfId`
|
|
144
|
+
// because github's dependencies always have -- the other forges bundle theirs in per-forge objects.
|
|
145
|
+
// Optional, because only a close delivery an armed close rule matches ever consults it.
|
|
146
|
+
const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) : null;
|
|
136
147
|
|
|
137
148
|
// A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
|
|
138
149
|
// it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
|
|
@@ -204,8 +215,15 @@ async function fanout(job, enqueue) {
|
|
|
204
215
|
* The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
|
|
205
216
|
* request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
|
|
206
217
|
* precondition D2 guarantees before it runs.
|
|
218
|
+
*
|
|
219
|
+
* One step the pre-#231 arm did not have, and the ONLY GitHub path that pays a network call before the
|
|
220
|
+
* gate: a close delivery an armed close rule matches has the CLOSER's authority resolved here, between
|
|
221
|
+
* verification and the gate, because the payload carries no association for the closer at all
|
|
222
|
+
* (github-members.mjs). `wantsCloserAuthority` is the same findCloseRule derivation the filter's route
|
|
223
|
+
* uses, so a lookup is never spent on a delivery the gate then ignores -- every label, comment, PR and
|
|
224
|
+
* review delivery, and every close nothing wants, stays payload-only and byte-identical to before.
|
|
207
225
|
*/
|
|
208
|
-
function makeGitHubHandler({ queue, selfId, cfg, log }) {
|
|
226
|
+
function makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) {
|
|
209
227
|
return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
|
|
210
228
|
let subset;
|
|
211
229
|
try {
|
|
@@ -214,7 +232,30 @@ function makeGitHubHandler({ queue, selfId, cfg, log }) {
|
|
|
214
232
|
return respond(res, 400, { error: "invalid-json" });
|
|
215
233
|
}
|
|
216
234
|
|
|
217
|
-
|
|
235
|
+
let closerAuthorized;
|
|
236
|
+
if (wantsCloserAuthority(event, subset, cfg?.triggers?.github, selfId)) {
|
|
237
|
+
if (!resolveAuthority) {
|
|
238
|
+
// Defensive only: unreachable in a wired receiver -- start.mjs hard-fails on github auth
|
|
239
|
+
// before `/` is ever mounted, and the resolver is built over that same auth object. Fail
|
|
240
|
+
// closed but RETRYABLE, because a wiring fault must not read on the wire as a stranger
|
|
241
|
+
// being correctly refused.
|
|
242
|
+
return respond(res, 503, { error: "permission-lookup-failed" });
|
|
243
|
+
}
|
|
244
|
+
const resolved = await resolveAuthority(subset.repository?.full_name, subset.sender?.login);
|
|
245
|
+
if (resolved.indeterminate) {
|
|
246
|
+
// The reason names the lookup, never the actor -- `sender.login` is personal data and exists
|
|
247
|
+
// here only to have been asked about (no-pii-in-logs).
|
|
248
|
+
log?.({ event: "github_permission_lookup_failed", delivery, reason: resolved.indeterminate });
|
|
249
|
+
// 503: GitHub redelivers, the GUID jobId coalesces the retry. GitHub's auto-redelivery is
|
|
250
|
+
// weaker than GitLab's, so a lookup outage outlasting the window can lose a webhook-only
|
|
251
|
+
// deployment's close -- the polling transport's closed source is the backstop where the
|
|
252
|
+
// poller runs, with its own bounded retry against the same lookup.
|
|
253
|
+
return respond(res, 503, { error: "permission-lookup-failed" });
|
|
254
|
+
}
|
|
255
|
+
closerAuthorized = resolved.authorized;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const result = filter(event, subset, cfg, selfId, delivery, closerAuthorized);
|
|
218
259
|
if (!result.enqueue) {
|
|
219
260
|
log?.({ event: "dropped", delivery, reason: result.reason });
|
|
220
261
|
return respond(res, 204);
|
package/src/start.mjs
CHANGED
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
* issue #99) -- an arm whose endpoint does not exist has no guard to arm, and the invariant that matters is
|
|
16
16
|
* that the two are decided by the SAME property, never separately.
|
|
17
17
|
*
|
|
18
|
-
* The receiver
|
|
19
|
-
*
|
|
18
|
+
* The receiver holds no JOB credentials. Minting a job's scoped token is the worker's business, per
|
|
19
|
+
* container, per job (CONST-TOKEN-SCOPED-PER-JOB), and that claim keeps its full force. What the
|
|
20
|
+
* receiver ALSO uses, since issue #231, is a per-close-delivery token for a permission QUESTION --
|
|
21
|
+
* does the account that closed this item hold write access (CONST-TRIGGER-AUTHOR-GATE's close arm).
|
|
22
|
+
* On the App source that token is minted repo-scoped AND narrowed to metadata:read (the mint passes
|
|
23
|
+
* the narrowing through, so a leak of it can write nothing); on pat/gh it is the operator's own
|
|
24
|
+
* standing token, already resident in this process's env, used for one read. Never a job credential
|
|
25
|
+
* on any source: no container ever receives it, and it exists only for the lookup it served.
|
|
20
26
|
*
|
|
21
27
|
* DES-ADMIN-VIA-PI-EXTENSION: this process exposes exactly one surface, the webhook handler. There is no
|
|
22
28
|
* admin, dashboard, or admin-extension route here -- the admin surface is a pi extension in the
|
|
@@ -36,6 +42,7 @@ import { resolveAzureSelfId } from "@edgehero/pi-dispatch/azure-identity";
|
|
|
36
42
|
import { makeResolveAuthority } from "./gitlab-members.mjs";
|
|
37
43
|
import { makeResolveForgejoAuthority } from "./forgejo-members.mjs";
|
|
38
44
|
import { makeResolveAzureAuthority } from "./azure-members.mjs";
|
|
45
|
+
import { makeResolveGitHubAuthority } from "./github-members.mjs";
|
|
39
46
|
import { makeQueue } from "@edgehero/pi-dispatch/queue";
|
|
40
47
|
import { parseConnection } from "@edgehero/pi-dispatch/connection";
|
|
41
48
|
|
|
@@ -55,6 +62,7 @@ export async function startReceiver(
|
|
|
55
62
|
makeResolveForgejoAuthority: makeResolveForgejoAuthorityFn = makeResolveForgejoAuthority,
|
|
56
63
|
resolveAzureSelfId: resolveAzureSelfIdFn = resolveAzureSelfId,
|
|
57
64
|
makeResolveAzureAuthority: makeResolveAzureAuthorityFn = makeResolveAzureAuthority,
|
|
65
|
+
makeResolveGitHubAuthority: makeResolveGitHubAuthorityFn = makeResolveGitHubAuthority,
|
|
58
66
|
} = {},
|
|
59
67
|
) {
|
|
60
68
|
// Single-object log line: `makeReceiver` calls `log?.({ event, ... })`, so the sink takes ONE object.
|
|
@@ -74,12 +82,25 @@ export async function startReceiver(
|
|
|
74
82
|
// harness's own completion comments would re-trigger jobs forever. Read that as: never make one of these
|
|
75
83
|
// two conditions unconditional without the other.
|
|
76
84
|
let selfId;
|
|
85
|
+
let resolveAuthority;
|
|
77
86
|
if (cfg.servesGithub) {
|
|
78
87
|
// HARD-FAIL identity resolution -- NO try/catch. A throw here (absent/bad github auth, unresolvable
|
|
79
88
|
// id) propagates and the server below is never created: without selfId the bot-loop guard cannot
|
|
80
89
|
// run, so refusing to boot is the only safe outcome.
|
|
81
|
-
|
|
90
|
+
//
|
|
91
|
+
// The WHOLE auth object is kept, not just selfId: the closer resolver below mints its per-delivery
|
|
92
|
+
// metadata-read token through this same object (issue #231), so identity and mint capability stay
|
|
93
|
+
// one credential decision -- an arm that resolved its identity is exactly the arm that can answer
|
|
94
|
+
// a permission question. This is also why the github handler's missing-resolver 503 is unreachable
|
|
95
|
+
// in a wired receiver: a boot that fails here mounts no `/` at all.
|
|
96
|
+
const auth = await makeAuth(cfg.github);
|
|
97
|
+
selfId = auth.selfId;
|
|
82
98
|
log({ event: "self_identity", id: selfId, source: cfg.github.source });
|
|
99
|
+
// The lookup token asks the mint to narrow to metadata:read -- the App path honors it GitHub-side,
|
|
100
|
+
// so the token this process holds for the permission question cannot write even if leaked; the
|
|
101
|
+
// pat/gh sources cannot narrow (the operator's standing token is what it is, and it already lives
|
|
102
|
+
// in this process's env), which is why the header above words the claim per source.
|
|
103
|
+
resolveAuthority = makeResolveGitHubAuthorityFn({ mintToken: (job) => auth.mintToken({ ...job, permissions: { metadata: "read" } }) });
|
|
83
104
|
} else {
|
|
84
105
|
// Said out loud, because the alternative is an operator staring at a label trigger that does nothing.
|
|
85
106
|
// The two ways out are the two signals `decideServesGithub` reads, so the line names both.
|
|
@@ -137,7 +158,7 @@ export async function startReceiver(
|
|
|
137
158
|
};
|
|
138
159
|
}
|
|
139
160
|
|
|
140
|
-
const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure });
|
|
161
|
+
const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
|
|
141
162
|
const server = createServer(handler);
|
|
142
163
|
server.listen(cfg.port, cfg.bind, () =>
|
|
143
164
|
log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl }),
|