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