@edgehero/pi-dispatch-receiver 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@octokit/webhooks": "14.2.0",
48
- "@edgehero/pi-dispatch": "^0.1.0",
48
+ "@edgehero/pi-dispatch": "^0.2.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
package/src/config.mjs CHANGED
@@ -275,11 +275,13 @@ function loadTriggers(env, readFile, fileExists) {
275
275
  knownFlows.add(run.flow);
276
276
  const group = groups[run.kind];
277
277
  if (on.type === "label") {
278
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas, repository: run.repository });
278
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
279
279
  } else if (on.type === "comment") {
280
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
280
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
281
281
  } else if (on.type === "pull_request") {
282
- group.pullRequest.push({ index, actions: new Set(on.action), predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, resume: run.resume, replicas: run.replicas });
282
+ // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
283
+ // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
284
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
283
285
  }
284
286
  }
285
287
 
@@ -105,6 +105,12 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
105
105
  flow: resolved.flow,
106
106
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
107
107
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
108
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
109
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
110
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
111
+ // basename(folder) restraint already exists to prevent.
112
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
113
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
108
114
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
109
115
  trigger: {
110
116
  event,
@@ -165,6 +171,8 @@ function matchLabelRules(subset, triggers, labels, action) {
165
171
  repository: rule.repository,
166
172
  packages: rule.packages,
167
173
  image: rule.image,
174
+ skillsDir: rule.skillsDir,
175
+ instructions: rule.instructions,
168
176
  resume: rule.resume,
169
177
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
170
178
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
@@ -193,6 +201,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
193
201
  repository: triggers.comment.repository,
194
202
  packages: triggers.comment.packages,
195
203
  image: triggers.comment.image,
204
+ skillsDir: triggers.comment.skillsDir,
205
+ instructions: triggers.comment.instructions,
196
206
  resume: triggers.comment.resume,
197
207
  matched: { index: triggers.comment.index, type: "comment", phrase },
198
208
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
@@ -219,6 +229,8 @@ function routePullRequest(subset, triggers, action) {
219
229
  repository: rule.repository,
220
230
  packages: rule.packages,
221
231
  image: rule.image,
232
+ skillsDir: rule.skillsDir,
233
+ instructions: rule.instructions,
222
234
  resume: rule.resume,
223
235
  matched: { index: rule.index, type: "pull_request", action },
224
236
  target: {
@@ -107,6 +107,12 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
107
107
  flow: resolved.flow,
108
108
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
109
109
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
110
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
111
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
112
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
113
+ // basename(folder) restraint already exists to prevent.
114
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
115
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
110
116
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
111
117
  trigger: {
112
118
  event: eventName,
@@ -133,6 +139,8 @@ function routeIssueLabel(subset, triggers) {
133
139
  flow: rule.flow,
134
140
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
135
141
  image: rule.image,
142
+ skillsDir: rule.skillsDir,
143
+ instructions: rule.instructions,
136
144
  resume: rule.resume,
137
145
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
138
146
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -166,6 +174,8 @@ function routeComment(subset, triggers, knownFlows) {
166
174
  flow,
167
175
  packages: triggers.comment.packages,
168
176
  image: triggers.comment.image,
177
+ skillsDir: triggers.comment.skillsDir,
178
+ instructions: triggers.comment.instructions,
169
179
  resume: triggers.comment.resume,
170
180
  matched: { index: triggers.comment.index, type: "comment", phrase },
171
181
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
@@ -190,6 +200,8 @@ function routePullRequest(subset, triggers, action) {
190
200
  flow: rule.flow,
191
201
  packages: rule.packages,
192
202
  image: rule.image,
203
+ skillsDir: rule.skillsDir,
204
+ instructions: rule.instructions,
193
205
  resume: rule.resume,
194
206
  matched: { index: rule.index, type: "pull_request", action },
195
207
  target: {
@@ -98,6 +98,12 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
98
98
  flow: resolved.flow,
99
99
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
100
100
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
101
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
102
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
103
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
104
+ // basename(folder) restraint already exists to prevent.
105
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
106
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
101
107
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
102
108
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
103
109
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
@@ -131,6 +137,8 @@ function routeLabel(subset, triggers, targetType) {
131
137
  flow: rule.flow,
132
138
  packages: rule.packages,
133
139
  image: rule.image,
140
+ skillsDir: rule.skillsDir,
141
+ instructions: rule.instructions,
134
142
  resume: rule.resume,
135
143
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
136
144
  target: buildTarget(subset, targetType),
@@ -188,6 +196,8 @@ function mrResult(subset, rule, matched) {
188
196
  flow: rule.flow,
189
197
  packages: rule.packages,
190
198
  image: rule.image,
199
+ skillsDir: rule.skillsDir,
200
+ instructions: rule.instructions,
191
201
  resume: rule.resume,
192
202
  matched,
193
203
  target: buildTarget(subset, "pull_request"),
@@ -222,6 +232,8 @@ function routeNote(subset, triggers, knownFlows) {
222
232
  flow,
223
233
  packages: triggers.comment.packages,
224
234
  image: triggers.comment.image,
235
+ skillsDir: triggers.comment.skillsDir,
236
+ instructions: triggers.comment.instructions,
225
237
  resume: triggers.comment.resume,
226
238
  matched: { index: triggers.comment.index, type: "comment", phrase },
227
239
  target: buildTarget(subset, targetType),
package/src/filter.mjs CHANGED
@@ -20,11 +20,18 @@
20
20
  * check would reintroduce exactly that loop.
21
21
  * 2. Only then route on event + action: issue label, author-gated comment, or pull_request.
22
22
  *
23
- * PR AUTHOR GATE (security-critical): auto actions (`opened|synchronize|reopened`) fire ONLY when the PR
24
- * `author_association` is a collaborator. This is hard-coded here, never config-optional -- a fork PR from
25
- * a stranger would otherwise launch an unbounded paid run (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
26
- * PR labeling is self-gating: only collaborators can apply labels, so the label predicate IS the approval,
27
- * exactly as on the issue label path.
23
+ * PR GATE (security-critical), three arms, and WHICH FIELD each reads is the load-bearing part:
24
+ * - `labeled` is self-gating: only collaborators can apply labels, so the label predicate IS the
25
+ * approval, exactly as on the issue label path. No author check at all, deliberately.
26
+ * - auto actions (`opened|synchronize|reopened`) fire only when the PR `author_association` is a
27
+ * collaborator, so a stranger's fork PR never auto-fires.
28
+ * - a submitted review (`review_submitted`, issue #66) fires on the REVIEWER's
29
+ * `review.author_association`, NEVER the PR author's. This is the first GitHub event where the actor
30
+ * and the PR author are DIFFERENT PEOPLE, which is why the auto-action shortcut of gating on the PR
31
+ * author does not carry: a collaborator reviewing a stranger's fork PR must run, and a stranger
32
+ * reviewing their own PR must not. Reading `pr.author_association` here inverts both halves at once.
33
+ * All three are hard-coded here, never config-optional -- an ungated auto-trigger is an unbounded paid run
34
+ * started by whoever opens a fork PR (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
28
35
  *
29
36
  * `selfId` is the numeric id of whichever identity posts as the harness (the App's bot user, or the PAT
30
37
  * user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
@@ -35,6 +42,12 @@ const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
35
42
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
36
43
  const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
37
44
  const PR_AUTO_ACTIONS = new Set(["opened", "synchronize", "reopened"]);
45
+ // The triggers.json word for a submitted review, and the raw action GitHub sends on the
46
+ // `pull_request_review` event. They differ on purpose -- see the routing block below.
47
+ const REVIEW_ACTION = "review_submitted";
48
+ const REVIEW_EVENT_ACTION = "submitted";
49
+ // PR_AUTO_ACTIONS is deliberately NOT extended with REVIEW_ACTION: it exists only to select the
50
+ // `pr-author-not-allowed` drop reason, and the review path has reasons of its own.
38
51
 
39
52
  export function filter(eventName, subset, cfg, selfId, deliveryId) {
40
53
  // (0) Fail-closed on identity. MUST precede the self compare -- see header, ordering constraint.
@@ -61,6 +74,17 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
61
74
  resolved = routeComment(subset, triggers, cfg?.triggers?.knownFlows);
62
75
  } else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
63
76
  resolved = routePullRequest(subset, triggers, action);
77
+ } else if (eventName === "pull_request_review" && action === REVIEW_EVENT_ACTION) {
78
+ // A SECOND event name on the SAME route, not a fifth on.type (issue #66): a review is an event about
79
+ // a pull request, and GitLab's analogue `approved` already rides on.type "pull_request", so a new
80
+ // type would make one forge's review a type and the other's an action. The route is handed the
81
+ // CANONICAL word because that is what triggers.json spells and what the rule loop matches --
82
+ // `submitted` alone would be meaningless in a pull_request action list.
83
+ //
84
+ // `edited` and `dismissed` fall through to unhandled-event deliberately: an edit would re-fire on
85
+ // text the harness has already been paid to read, and a dismissal removes a verdict rather than
86
+ // stating one.
87
+ resolved = routePullRequest(subset, triggers, REVIEW_ACTION);
64
88
  } else {
65
89
  return { enqueue: false, reason: "unhandled-event" };
66
90
  }
@@ -87,6 +111,12 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
87
111
  flow: resolved.flow,
88
112
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
89
113
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
114
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
115
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
116
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
117
+ // basename(folder) restraint already exists to prevent.
118
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
119
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
90
120
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
91
121
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
92
122
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
@@ -102,6 +132,16 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
102
132
  sender: { id: subset.sender.id },
103
133
  matched: resolved.matched,
104
134
  ...(resolved.comment ? { comment: resolved.comment } : {}),
135
+ // The invoking review, present only on the review route (issue #66), sibling of `comment` and
136
+ // carried for the same reason: without it a "Request changes: rename the helper" review starts a
137
+ // job that cannot know what was asked. All four fields are named by INT-WEBHOOK-PAYLOAD-SUBSET and
138
+ // the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
139
+ //
140
+ // NOTE the pair above: `event` is `pull_request_review` and `action` is the raw `submitted`,
141
+ // byte-for-byte what GitHub sent. `matched.action` is the canonical `review_submitted`, because
142
+ // `matched` names the triggers.json entry that fired rather than the payload. This is the first
143
+ // GitHub case where the two differ, and INT-CONTAINER-JOB-INPUTS says so.
144
+ ...(resolved.review ? { review: resolved.review } : {}),
105
145
  },
106
146
  };
107
147
  return { enqueue: true, job };
@@ -119,6 +159,8 @@ function routeIssueLabel(subset, triggers) {
119
159
  flow: rule.flow,
120
160
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
121
161
  image: rule.image,
162
+ skillsDir: rule.skillsDir,
163
+ instructions: rule.instructions,
122
164
  resume: rule.resume,
123
165
  replicas: rule.replicas,
124
166
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
@@ -163,6 +205,8 @@ function routeComment(subset, triggers, knownFlows) {
163
205
  // <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
164
206
  packages: triggers.comment.packages,
165
207
  image: triggers.comment.image,
208
+ skillsDir: triggers.comment.skillsDir,
209
+ instructions: triggers.comment.instructions,
166
210
  resume: triggers.comment.resume,
167
211
  replicas: triggers.comment.replicas,
168
212
  matched: { index: triggers.comment.index, type: "comment", phrase },
@@ -174,10 +218,43 @@ function routeComment(subset, triggers, knownFlows) {
174
218
  }
175
219
 
176
220
  /**
177
- * Pull-request path. `labeled` is gated by the label predicate (collaborator-applied label = approval);
178
- * auto actions (`opened|synchronize|reopened`) are gated by the PR author_association (hard-coded, never
179
- * config-optional). A trigger's optional predicate only narrows an auto action; an empty predicate is
180
- * vacuously true. First matching rule (in file order) wins.
221
+ * Whether the actor behind a PR event clears the write-access gate -- and WHICH actor that is.
222
+ *
223
+ * One expression rather than a branch inside the rule loop, deliberately (issue #66). A review is the
224
+ * REVIEWER's say-so, never the PR author's: a collaborator reviewing a stranger's fork PR must run, and a
225
+ * stranger reviewing their own PR must not. Reading `pr.author_association` for a review inverts both
226
+ * halves at once, and an `authorOk` that means two different things depending on which `if` you are
227
+ * standing in is exactly how that inversion gets silently reintroduced later. `labeled` reaches neither
228
+ * branch: it is gated by its label predicate instead.
229
+ */
230
+ function prAuthorOk(action, pr, review) {
231
+ if (action === REVIEW_ACTION) return AUTHOR_ALLOWLIST.has(review?.author_association);
232
+ return AUTHOR_ALLOWLIST.has(pr?.author_association);
233
+ }
234
+
235
+ /**
236
+ * A `commented` review carrying nothing to act on.
237
+ *
238
+ * A review made only of INLINE comments arrives here with an empty body, because those comments ride
239
+ * `pull_request_review_comment` -- an event this project does not ingest. The payload carries no
240
+ * line-comment count and this module is pure, so it cannot tell "empty because it has line comments" from
241
+ * "empty because it is empty", and buying a container for an empty string is the worse of the two errors.
242
+ * The residual (a Comment-type review of line comments only never fires) is stated in SECURITY.md rather
243
+ * than left to a drop reason.
244
+ *
245
+ * `approved` and `changes_requested` still fire with no body: there the verdict IS the signal.
246
+ * Case-insensitive on `state` as belt-and-braces; `parseSubset` has already folded it.
247
+ */
248
+ function isEmptyCommentedReview(review) {
249
+ return String(review?.state ?? "").toLowerCase() === "commented" && String(review?.body ?? "").trim() === "";
250
+ }
251
+
252
+ /**
253
+ * Pull-request path, three gates by action. `labeled` is gated by the label predicate
254
+ * (collaborator-applied label = approval); auto actions (`opened|synchronize|reopened`) by the PR
255
+ * author_association; `review_submitted` by the REVIEWER's (see `prAuthorOk`). All hard-coded, never
256
+ * config-optional. A trigger's optional predicate only narrows; an empty predicate is vacuously true.
257
+ * First matching rule (in file order) wins.
181
258
  */
182
259
  function routePullRequest(subset, triggers, action) {
183
260
  const pr = subset.pull_request;
@@ -185,29 +262,59 @@ function routePullRequest(subset, triggers, action) {
185
262
  return { enqueue: false, reason: "missing-pull-request" };
186
263
  }
187
264
  const L = labelSet(pr.labels);
188
- const authorOk = AUTHOR_ALLOWLIST.has(pr.author_association);
265
+ const isReview = action === REVIEW_ACTION;
266
+ const review = subset.review;
267
+ const authorOk = prAuthorOk(action, pr, review);
189
268
 
269
+ // The review path refuses BEFORE the rule loop, in the order routeComment uses (author, then is there
270
+ // anything to act on, then which rule). Two consequences worth stating rather than discovering:
271
+ // security beats content when both are true, so a stranger's empty review reports the author refusal;
272
+ // and an operator with no review rule armed sees these reasons rather than `no-matching-pr-trigger`,
273
+ // which `pr-author-not-allowed` below already does and which is the right trade -- "nobody may start a
274
+ // job this way" and "there is nothing here to act on" are both facts about the DELIVERY, true whatever
275
+ // the trigger file says.
276
+ if (isReview) {
277
+ if (!authorOk) return { enqueue: false, reason: "review-author-not-allowed" };
278
+ if (isEmptyCommentedReview(review)) return { enqueue: false, reason: "no-review-body" };
279
+ }
280
+
281
+ let stateSkipped = false;
190
282
  for (const rule of triggers.pullRequest ?? []) {
191
283
  if (!rule.actions.has(action)) continue;
192
284
  if (action === "labeled") {
193
285
  if (!matchesRule(L, rule.predicate)) continue;
194
286
  } else {
195
- // Auto action -- author_association is the hard gate; the predicate (if any) narrows scope.
287
+ // Auto action or review -- author_association is the hard gate; the predicate (if any) narrows.
196
288
  if (!authorOk) continue;
197
289
  if (!matchesRule(L, rule.predicate)) continue;
198
290
  }
291
+ // The optional `on.reviewState` narrowing, checked LAST among a rule's tests so the flag below means
292
+ // the verdict was the ONLY thing that failed. Unnarrowed rules carry null and fire on every verdict.
293
+ if (isReview && rule.reviewStates !== null && rule.reviewStates !== undefined && !rule.reviewStates.has(review?.state)) {
294
+ stateSkipped = true;
295
+ continue;
296
+ }
199
297
  return {
200
298
  enqueue: true,
201
299
  flow: rule.flow,
202
300
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
203
301
  image: rule.image,
302
+ skillsDir: rule.skillsDir,
303
+ instructions: rule.instructions,
204
304
  resume: rule.resume,
205
305
  replicas: rule.replicas,
206
306
  matched: { index: rule.index, type: "pull_request", action },
307
+ ...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
207
308
  target: buildPrTarget(pr),
208
309
  };
209
310
  }
210
311
 
312
+ // "Your rule exists, this verdict was not in your list" is a different operator response from "no rule
313
+ // matched at all", so it gets its own reason -- the same call filter-forgejo.mjs makes for a recognised
314
+ // but unactionable action.
315
+ if (stateSkipped) {
316
+ return { enqueue: false, reason: "review-state-not-matched" };
317
+ }
211
318
  // Surface the security-relevant author drop distinctly from a plain no-match so it is observable.
212
319
  if (PR_AUTO_ACTIONS.has(action) && !authorOk) {
213
320
  return { enqueue: false, reason: "pr-author-not-allowed" };
package/src/poller.mjs CHANGED
@@ -35,20 +35,33 @@
35
35
  * presence, not transitions, so close->reopen inside one cycle is invisible, and a
36
36
  * reopen that also pushed fires a single `reopened` where webhooks would fire two
37
37
  * events -- the fresh sha still rides the job's target).
38
+ * - reviews: GET /repos/{o}/{r}/pulls/{n}/reviews for each OPEN pr, cursor = last processed
39
+ * review id (numeric and monotonic, so the events discipline applies). Issue #66's
40
+ * fourth source, and it exists because the alternative was a `review_submitted`
41
+ * trigger that loads clean under polling and can never fire -- the silently dead
42
+ * trigger this project refuses everywhere else. Only open PRs are swept: a review on
43
+ * a closed PR has nothing left to act on, the same call `merge`/`close` get in the
44
+ * action vocabulary. REST spells `state` in upper case where the webhook spells it
45
+ * lower; `parseSubset` folds it, so both transports produce the same job.
38
46
  *
39
47
  * DEDUP IDS (REQ-DEDUP-BY-DELIVERY-GUID): polling has no delivery GUID, so each source mints a
40
48
  * deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (label events),
41
49
  * `poll-c<commentId>` (comments), `poll-pr<number>-<headSha7>` (PR actions; sha-keyed so a retried
42
50
  * cycle cannot double-enqueue while a real new push mints a new id -- with the honest corollary that
43
- * a same-sha reopen inside the retention window coalesces with its own `opened` job). The shared
51
+ * a same-sha reopen inside the retention window coalesces with its own `opened` job), and
52
+ * `poll-rv<reviewId>` (reviews; a review id is immutable, so the id alone is the identity). The shared
44
53
  * `gh-` prefix is added by enqueueGitHubJob's jobId path, same as for webhook deliveries.
45
54
  *
46
55
  * CURSORS AND ETAGS live in redis, namespaced per repo:
47
56
  * poll:<owner/repo>:cursor:events last processed /issues/events id (numeric, monotonic)
48
57
  * poll:<owner/repo>:cursor:comments newest processed comment updated_at (second-precision ISO)
49
58
  * poll:<owner/repo>:cursor:prs ISO of when the PR snapshot was armed (presence = armed)
59
+ * poll:<owner/repo>:cursor:reviews last processed review id (numeric, monotonic; presence = armed)
50
60
  * poll:<owner/repo>:prs hash: PR number -> head sha while open, "closed" once gone
51
61
  * poll:<owner/repo>:etag:<endpoint> conditional-GET validator (endpoint: events|comments|pulls)
62
+ * poll:<owner/repo>:etag:reviews hash: PR number -> that PR's reviews-endpoint validator. A HASH
63
+ * rather than a key per PR, so the family below stays enumerable
64
+ * and `touchRepo` can still refresh it as a unit.
52
65
  * All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful repo poll. 35 days
53
66
  * deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
54
67
  * the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
@@ -100,6 +113,10 @@ const DISCOVERY_EVERY = 10;
100
113
  // next cycle picks up the rest); the open-PR list just caps how many open PRs the diff can see.
101
114
  const MAX_EVENT_PAGES = 5;
102
115
  const MAX_PR_PAGES = 10;
116
+ // How many open PRs one cycle sweeps for reviews. This is a REQUEST bound, not a page bound: the reviews
117
+ // endpoint is per-PR, so an unbounded sweep would spend one request per open PR per cycle. 50 covers any
118
+ // repo a single poller realistically services, and the overflow is logged rather than silently dropped.
119
+ const MAX_REVIEW_PRS = 50;
103
120
  // The hash value marking a PR that left the open list. Cannot collide with a head sha (hex only).
104
121
  const CLOSED_MARKER = "closed";
105
122
 
@@ -320,6 +337,8 @@ function keyNames(repo) {
320
337
  etagEvents: `${p}:etag:events`,
321
338
  etagComments: `${p}:etag:comments`,
322
339
  etagPulls: `${p}:etag:pulls`,
340
+ reviews: `${p}:cursor:reviews`,
341
+ etagReviews: `${p}:etag:reviews`,
323
342
  };
324
343
  }
325
344
 
@@ -330,7 +349,7 @@ async function setWithTtl(ctx, key, value) {
330
349
  /** Refresh the whole key family together -- coherence over per-key precision (see module header). */
331
350
  async function touchRepo(ctx, repo) {
332
351
  const k = keyNames(repo);
333
- for (const key of [k.events, k.comments, k.prsArmed, k.prs, k.etagEvents, k.etagComments, k.etagPulls]) {
352
+ for (const key of [k.events, k.comments, k.prsArmed, k.prs, k.etagEvents, k.etagComments, k.etagPulls, k.reviews, k.etagReviews]) {
334
353
  await ctx.redis.expire(key, CURSOR_TTL_SECONDS);
335
354
  }
336
355
  }
@@ -349,6 +368,11 @@ function isoSeconds(ms) {
349
368
  * never sends one -- arming needs the body, and a stray stored validator answering 304 on an unarmed
350
369
  * endpoint would leave it unarmed forever.
351
370
  *
371
+ * `etagKey` may also be `{ key, field }`, which stores the validator in a HASH field instead. That form
372
+ * exists for the per-PR reviews endpoint (issue #66): one validator per open PR would otherwise mean an
373
+ * unbounded key family that `touchRepo` cannot enumerate, and the whole TTL argument in the header rests
374
+ * on the family being refreshable as a unit. The conditional-GET discipline stays in this one place.
375
+ *
352
376
  * An exhausted quota (403/429 with x-ratelimit-remaining: 0) throws RateLimited for the cycle loop
353
377
  * to sleep out; any other non-2xx throws a plain error for per-repo isolation to log.
354
378
  */
@@ -362,7 +386,7 @@ function makeApi(ctx, token, stats) {
362
386
  authorization: `Bearer ${token}`,
363
387
  };
364
388
  if (etagKey && revalidate) {
365
- const etag = await ctx.redis.get(etagKey);
389
+ const etag = etagKey.field === undefined ? await ctx.redis.get(etagKey) : await ctx.redis.hget(etagKey.key, etagKey.field);
366
390
  if (etag) headers["if-none-match"] = etag;
367
391
  }
368
392
  const res = await ctx.fetchFn(`${API_URL}${path}`, { headers });
@@ -383,7 +407,13 @@ function makeApi(ctx, token, stats) {
383
407
  }
384
408
  if (etagKey) {
385
409
  const tag = res.headers?.get?.("etag");
386
- if (tag) await ctx.redis.set(etagKey, tag, "EX", CURSOR_TTL_SECONDS);
410
+ if (tag && etagKey.field === undefined) {
411
+ await ctx.redis.set(etagKey, tag, "EX", CURSOR_TTL_SECONDS);
412
+ } else if (tag) {
413
+ // The hash carries no TTL of its own here; `touchRepo` expires it with the rest of the family.
414
+ await ctx.redis.hset(etagKey.key, etagKey.field, tag);
415
+ await ctx.redis.expire(etagKey.key, CURSOR_TTL_SECONDS);
416
+ }
387
417
  }
388
418
  stats.fetched += 1;
389
419
  return { json: await res.json() };
@@ -580,7 +610,15 @@ async function pollPulls(ctx, api, repo, stats) {
580
610
  const armed = (await ctx.redis.get(k.prsArmed)) !== null;
581
611
 
582
612
  const first = await api.get(`/repos/${repo}/pulls?state=open&sort=updated&direction=asc&per_page=100`, k.etagPulls, armed);
583
- if (first.notModified) return;
613
+ if (first.notModified) {
614
+ // Reviews still sweep on this path, and that is deliberate rather than defensive. Whether submitting
615
+ // a review perturbs the open-PR LIST is GitHub's business, not a property this project should bet
616
+ // correctness on: if it does not, an unswept 304 cycle would mean review triggers that fire only when
617
+ // something else happens to touch the PR. `null` tells pollReviews to take its sweep set from the
618
+ // snapshot hash instead of a list it does not have.
619
+ await pollReviews(ctx, api, repo, null, stats);
620
+ return;
621
+ }
584
622
  let open = Array.isArray(first.json) ? first.json : [];
585
623
  let batch = open;
586
624
  let page = 2;
@@ -601,6 +639,10 @@ async function pollPulls(ctx, api, repo, stats) {
601
639
  await ctx.redis.expire(k.prs, CURSOR_TTL_SECONDS);
602
640
  await setWithTtl(ctx, k.prsArmed, isoSeconds(ctx.now()));
603
641
  ctx.out({ event: "poll_armed", repo, endpoint: "pulls", open: open.length });
642
+ // Reviews arm on this cycle too. Returning without them would leave the reviews cursor unset for a
643
+ // whole extra cycle, and the first cycle that DID arm it would silently swallow every review
644
+ // submitted in between -- arming twice against one snapshot, losing the window between the two.
645
+ await pollReviews(ctx, api, repo, open, stats);
604
646
  return;
605
647
  }
606
648
 
@@ -630,6 +672,100 @@ async function pollPulls(ctx, api, repo, stats) {
630
672
  await ctx.redis.hset(k.prs, field, CLOSED_MARKER);
631
673
  }
632
674
  }
675
+
676
+ // Reviews ride the SAME open-PR list this function already paid for -- one /pulls response feeds both
677
+ // sources, which is the whole reason this call lives here rather than in its own pollRepo step.
678
+ await pollReviews(ctx, api, repo, open, stats);
679
+ }
680
+
681
+ /**
682
+ * The review feed (issue #66): GET /repos/{o}/{r}/pulls/{n}/reviews per OPEN pr, cursor = last processed
683
+ * review id.
684
+ *
685
+ * Review ids are numeric and monotonic, but this sweep reads MANY endpoints (one per open PR) whose ids
686
+ * interleave, so the cursor is persisted ONCE at the end -- the comments-feed discipline, not the label
687
+ * feed's per-item one. Advancing per review would be actively wrong here: PR #1's review 200 followed by
688
+ * PR #2's review 150 would leave the cursor at 150 and re-enqueue 200 next cycle, or (writing the running
689
+ * max) would strand 150 forever if the sweep died between the two. Persisting once means a mid-sweep
690
+ * failure retries the WHOLE sweep, and the reviews already enqueued dedup on their `gh-poll-rv<id>`
691
+ * jobIds -- the same idempotence the replica fanout in `gate` leans on.
692
+ *
693
+ * COST is why the per-PR ETag exists. Without it a 50-open-PR repo spends 50 requests a cycle forever;
694
+ * with it the idle steady state is 50 * 304, which GitHub does not charge against the rate limit. The
695
+ * validators live in one hash (see keyNames) so the key family stays enumerable for touchRepo.
696
+ *
697
+ * A review on a CLOSED pr is never seen, deliberately: the open list is the sweep set, and a job started
698
+ * by a review of a merged PR has nothing left to act on -- the same call `merge` and `close` get in the
699
+ * action vocabulary.
700
+ */
701
+ async function pollReviews(ctx, api, repo, open, stats) {
702
+ const k = keyNames(repo);
703
+ const cursorRaw = await ctx.redis.get(k.reviews);
704
+ const armed = cursorRaw !== null;
705
+ const cursor = armed ? Number(cursorRaw) : 0;
706
+
707
+ // The sweep set. `open` is the list pollPulls just fetched; `null` means it got a 304 and the snapshot
708
+ // hash is the only record of which PRs are open. Numbers are enough to drive the sweep -- the PR OBJECT
709
+ // is only needed for a PR that turns out to have a new review, and is fetched lazily below, which is
710
+ // the same once-per-new-event fetch handleLabeledEvent and handleComment already do.
711
+ let numbers;
712
+ if (open !== null) {
713
+ numbers = open.filter((pr) => pr?.number != null).map((pr) => pr.number);
714
+ } else {
715
+ const known = await ctx.redis.hgetall(k.prs);
716
+ numbers = Object.entries(known)
717
+ .filter(([, sha]) => sha !== CLOSED_MARKER)
718
+ .map(([field]) => Number(field))
719
+ .filter((n) => Number.isFinite(n))
720
+ .sort((a, b) => a - b);
721
+ }
722
+
723
+ // Bounded, and said out loud when it bites: a silent cap reads as coverage.
724
+ if (numbers.length > MAX_REVIEW_PRS) {
725
+ ctx.out({ event: "poll_reviews_gap", repo, open: numbers.length, swept: MAX_REVIEW_PRS });
726
+ }
727
+ const bounded = numbers.slice(0, MAX_REVIEW_PRS);
728
+ const byNumber = new Map((open ?? []).filter((pr) => pr?.number != null).map((pr) => [pr.number, pr]));
729
+
730
+ let maxId = cursor;
731
+ for (const number of bounded) {
732
+ const field = String(number);
733
+ // While ARMING, `revalidate: false` for the same reason every other source uses it: arming needs
734
+ // the body, and a stored validator answering 304 on an unarmed endpoint would strand it unarmed.
735
+ const res = await api.get(`/repos/${repo}/pulls/${number}/reviews?per_page=100`, { key: k.etagReviews, field }, armed);
736
+ if (res.notModified) continue;
737
+ const reviews = Array.isArray(res.json) ? res.json : [];
738
+
739
+ let pr = byNumber.get(number) ?? null;
740
+ for (const review of reviews) {
741
+ if (typeof review?.id !== "number" || review.id <= cursor) continue;
742
+ if (review.id > maxId) maxId = review.id;
743
+ if (!armed) continue; // ARM WITHOUT REPLAY: learn the high-water mark, enqueue nothing.
744
+
745
+ // Lazily, and at most once per PR per cycle: only a PR with a genuinely new review costs this.
746
+ if (pr === null) {
747
+ pr = (await api.get(`/repos/${repo}/pulls/${number}`)).json ?? null;
748
+ byNumber.set(number, pr);
749
+ }
750
+
751
+ // The webhook's own payload shape, so the SAME parseSubset + filter decide. `sender` is the
752
+ // REVIEWER, which is what keeps the bot-loop guard correct: a review the harness itself posted
753
+ // carries our own id and drops as `self` before any gate runs. `state` arrives upper-case from
754
+ // REST and parseSubset folds it; that fold is what makes this job identical to the webhook twin's.
755
+ const payload = { action: "submitted", sender: { id: review.user?.id }, pull_request: pr, review, repository: { full_name: repo } };
756
+ await gate(ctx, "pull_request_review", payload, `poll-rv${review.id}`, stats);
757
+ }
758
+ }
759
+
760
+ // Once, after the whole sweep -- see the header of this function for why per-item would be wrong.
761
+ // Arming ALWAYS writes, even at 0: a repo whose open PRs carry no reviews yet must still become armed,
762
+ // or it stays on the `revalidate: false` path and re-fetches every PR in full, every cycle, forever.
763
+ if (!armed) {
764
+ await setWithTtl(ctx, k.reviews, String(maxId));
765
+ ctx.out({ event: "poll_armed", repo, endpoint: "reviews", cursor: maxId });
766
+ } else if (maxId !== cursor) {
767
+ await setWithTtl(ctx, k.reviews, String(maxId));
768
+ }
633
769
  }
634
770
 
635
771
  /**
package/src/receiver.mjs CHANGED
@@ -75,6 +75,28 @@ export function parseSubset(payload) {
75
75
  base: { ref: pr.base?.ref },
76
76
  }
77
77
  : undefined,
78
+ // pull_request_review event fields (issue #66). Shape-gated like `pull_request` above rather than
79
+ // event-gated, for the same reason: this function never sees the event name, and a payload either
80
+ // carries a review or it does not.
81
+ //
82
+ // `id` is here because a review's INLINE comments ride `pull_request_review_comment`, an event this
83
+ // project deliberately does not ingest -- so the id is the only handle a flow has on them. No
84
+ // `review.user`: the bot-loop guard reads `sender.id` and nothing else, a second identity field is a
85
+ // second thing to keep in sync, and a login is PII with no reader (see issue.pull_request above).
86
+ review: payload.review
87
+ ? {
88
+ id: payload.review.id,
89
+ body: payload.review.body,
90
+ // Folded to lower case HERE, the one place both sources converge. The webhook sends
91
+ // `approved`; GET /pulls/{n}/reviews sends `APPROVED`. The poller feeds this same function
92
+ // with REST-derived payloads, so without the fold the identical logical review would produce
93
+ // two different jobs depending on transport, and a polled `COMMENTED` would slip past the
94
+ // empty-body check and buy a container for an empty string. (GitHub docs, not source at a
95
+ // pin: filter.test.mjs drives both casings so the claim is pinned by behaviour.)
96
+ state: typeof payload.review.state === "string" ? payload.review.state.toLowerCase() : payload.review.state,
97
+ author_association: payload.review.author_association,
98
+ }
99
+ : undefined,
78
100
  repository: { full_name: payload.repository?.full_name },
79
101
  };
80
102
  }