@edgehero/pi-dispatch-receiver 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
  }
@@ -126,6 +155,7 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
126
155
  // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
127
156
  ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
128
157
  ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
158
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
129
159
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
130
160
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
131
161
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
@@ -173,6 +203,7 @@ function routeIssueLabel(subset, triggers) {
173
203
  skillsDir: rule.skillsDir,
174
204
  secrets: rule.secrets,
175
205
  secretsProfile: rule.secretsProfile,
206
+ waitFor: rule.waitFor,
176
207
  instructions: rule.instructions,
177
208
  resume: rule.resume,
178
209
  replicas: rule.replicas,
@@ -232,6 +263,7 @@ function routeComment(subset, triggers, knownFlows) {
232
263
  skillsDir: triggers.comment.skillsDir,
233
264
  secrets: triggers.comment.secrets,
234
265
  secretsProfile: triggers.comment.secretsProfile,
266
+ waitFor: triggers.comment.waitFor,
235
267
  instructions: triggers.comment.instructions,
236
268
  resume: triggers.comment.resume,
237
269
  replicas: triggers.comment.replicas,
@@ -329,6 +361,7 @@ function routePullRequest(subset, triggers, action) {
329
361
  skillsDir: rule.skillsDir,
330
362
  secrets: rule.secrets,
331
363
  secretsProfile: rule.secretsProfile,
364
+ waitFor: rule.waitFor,
332
365
  instructions: rule.instructions,
333
366
  resume: rule.resume,
334
367
  replicas: rule.replicas,
@@ -358,3 +391,136 @@ function buildPrTarget(pr) {
358
391
  if (pr.base) target.base = { ref: pr.base.ref };
359
392
  return target;
360
393
  }
394
+
395
+ /**
396
+ * Issue close path (issue #231). `matched.number` is the CLOSED ITEM's number, not the rule's
397
+ * narrowing -- an unnarrowed rule fires for any issue, and the decision record must still name which
398
+ * one spent it, or a once disarm is unexplainable back to an item.
399
+ */
400
+ function routeIssueClose(subset, triggers, closerAuthorized) {
401
+ const number = subset.issue?.number;
402
+ // Integer or refuse, the PR arm's missing-object guard sharpened for BOTH arms: parseSubset always
403
+ // fabricates `subset.issue` as an object, so object presence proves nothing here, and an UNNARROWED
404
+ // rule matches on the action alone -- without this line a signed body replayed under a swapped
405
+ // event header enqueues a paid job whose target has no number at all (and a crafted string number
406
+ // would ride verbatim into event.json). GitHub only ever sends integers; anything else is a shape
407
+ // this route must not spend on.
408
+ if (!Number.isInteger(number)) {
409
+ return { enqueue: false, reason: "missing-issue-number" };
410
+ }
411
+ return routeClose(
412
+ triggers.issue,
413
+ number,
414
+ closerAuthorized,
415
+ (rule) => ({ index: rule.index, type: "issue", action: CLOSE_ACTION, number, ...(rule.once === true && { once: true }) }),
416
+ () => ({ type: "issue", number, title: subset.issue?.title, body: subset.issue?.body }),
417
+ );
418
+ }
419
+
420
+ /**
421
+ * PR close path (issue #231), the same guard shape routePullRequest opens with: no pull_request
422
+ * object is a malformed delivery, refused before any rule is consulted. `matched` carries no number
423
+ * here -- the target does, exactly as on every other pull_request match.
424
+ */
425
+ function routePrClose(subset, triggers, closerAuthorized) {
426
+ const pr = subset.pull_request;
427
+ // Object AND integer number, the issue arm's rule: a pull_request object without an integer
428
+ // number is the same malformed delivery wearing a shape, and the dedup key would otherwise read
429
+ // `#undefined`. One reason for both cases -- to an operator they are one fact.
430
+ if (pr === null || typeof pr !== "object" || !Number.isInteger(pr.number)) {
431
+ return { enqueue: false, reason: "missing-pull-request" };
432
+ }
433
+ return routeClose(
434
+ triggers.prClose,
435
+ pr.number,
436
+ closerAuthorized,
437
+ (rule) => ({ index: rule.index, type: "pull_request", action: CLOSE_ACTION, ...(rule.once === true && { once: true }) }),
438
+ () => buildPrTarget(pr),
439
+ );
440
+ }
441
+
442
+ /**
443
+ * The shared close route: one body for both arms, driving the ONE rule derivation in ./close.mjs --
444
+ * the same findCloseRule call `wantsCloserAuthority` makes, so "which close does a rule want" can
445
+ * never drift between the receiver's pre-lookup question and this routing answer.
446
+ *
447
+ * `once` rides `matched` only when literally true, mirroring how every optional job field is absent
448
+ * rather than present-and-false: matched is the downstream disarm signal, and its consumers test
449
+ * presence, not truthiness.
450
+ */
451
+ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
452
+ const found = findCloseRule(rules, CLOSE_ACTION, number);
453
+ if (found.rule === undefined) {
454
+ return { enqueue: false, reason: found.reason };
455
+ }
456
+ // The authority gate sits INSIDE the route and AFTER the match, deliberately. The receiver performs
457
+ // the closer's permission lookup only for a delivery some close rule actually wants (that is what
458
+ // `wantsCloserAuthority` exists for), so a gate BEFORE the rule loop would emit `closer-not-allowed`
459
+ // for closes no rule matches -- a security token no lookup ever backed, telling an operator an
460
+ // authority decision was made about a delivery nobody ever resolved. A close nothing wants is
461
+ // `no-matching-close-trigger`, whoever closed it.
462
+ //
463
+ // Strict `!== true`, never truthiness: the value is the receiver's RESOLVED answer, and anything
464
+ // else reaching here -- undefined from an unwired caller, an indeterminate lookup, a "true" string
465
+ // or a count from a parse bug -- is a wiring fault that must fail CLOSED, the direction every gate
466
+ // in this file already takes (CONST-TRIGGER-AUTHOR-GATE).
467
+ if (closerAuthorized !== true) {
468
+ return { enqueue: false, reason: "closer-not-allowed" };
469
+ }
470
+ const rule = found.rule;
471
+ return {
472
+ enqueue: true,
473
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
474
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
475
+ packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
476
+ image: rule.image,
477
+ skillsDir: rule.skillsDir,
478
+ secrets: rule.secrets,
479
+ secretsProfile: rule.secretsProfile,
480
+ waitFor: rule.waitFor,
481
+ instructions: rule.instructions,
482
+ resume: rule.resume,
483
+ replicas: rule.replicas,
484
+ matched: matchedFor(rule),
485
+ target: targetFor(),
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Does this forge group arm any close trigger at all? Pure, for the receiver/poller arm: whether the
491
+ * closer-authority machinery is worth wiring up for a delivery stream is a per-group fact, and
492
+ * deriving it anywhere else would be a second spelling of "which groups are the close groups".
493
+ */
494
+ export function hasCloseTriggers(group) {
495
+ return group?.issue?.length > 0 || group?.prClose?.length > 0;
496
+ }
497
+
498
+ /**
499
+ * Should the receiver spend a permission lookup on this delivery before calling `filter`? Pure, and
500
+ * the SINGLE derivation shared with the route above: it calls the same findCloseRule over the same
501
+ * groups, so the pre-lookup question and the routing answer cannot drift -- a delivery never costs a
502
+ * lookup the route then ignores, and never routes a close the lookup never gated (./close.mjs's
503
+ * header names exactly this hazard).
504
+ */
505
+ export function wantsCloserAuthority(eventName, subset, group, selfId) {
506
+ // Filter's own step-0/1 guards, replicated FIRST and in the same order (fail-closed identity, then
507
+ // the unconditional bot-loop guard): the harness closing its own issue -- the natural last act of
508
+ // the very flow a close trigger arms -- must not cost a lookup, and an INDETERMINATE lookup on a
509
+ // self-delivery would 503 (so: redeliver, retry, loop) traffic the filter would only ever drop as
510
+ // `self`.
511
+ if (typeof subset?.sender?.id !== "number") return false;
512
+ if (subset.sender.id === selfId) return false;
513
+ if (eventName === "issues" && subset.action === CLOSE_ACTION) {
514
+ // The routes' own shape guards, replicated for the same one-derivation reason as the step-0/1
515
+ // guards above: an UNNARROWED rule matches an undefined number, so without these lines a
516
+ // degenerate payload costs a token mint and a lookup the route then drops -- the exact "lookup
517
+ // the route ignores" this function exists to make impossible.
518
+ if (!Number.isInteger(subset.issue?.number)) return false;
519
+ return findCloseRule(group?.issue, CLOSE_ACTION, subset.issue.number).rule !== undefined;
520
+ }
521
+ if (eventName === "pull_request" && subset.action === CLOSE_ACTION) {
522
+ if (subset.pull_request === null || typeof subset.pull_request !== "object" || !Number.isInteger(subset.pull_request.number)) return false;
523
+ return findCloseRule(group?.prClose, CLOSE_ACTION, subset.pull_request.number).rule !== undefined;
524
+ }
525
+ return false;
526
+ }
@@ -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
- return { indeterminate: `collaborator permission lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
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 === "") {
@@ -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
- /** Recognised, and deliberately not actionable. Named so the drop reason can say which it was. */
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
+ }
@@ -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
- return { indeterminate: `members lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
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>` (label events),
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 -> filter (UNCHANGED, same cfg/selfId) -> replica fanout ->
774
- * enqueueGitHubJob, with the receiver's own log shapes. Partial replica failure is idempotent for the
775
- * same reason it is there: the failed cycle re-runs, replicas 1..k-1 dedup on their taken jobIds.
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 result = filter(eventName, parseSubset(payload), ctx.cfg, ctx.selfId, deliveryId);
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;