@edgehero/pi-dispatch-receiver 4.2.0 → 4.3.1
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 +24 -6
- package/src/filter-azure.mjs +18 -1
- package/src/filter-forgejo.mjs +20 -1
- package/src/filter-gitlab.mjs +20 -1
- package/src/filter.mjs +19 -1
- package/src/forgejo-members.mjs +41 -8
- package/src/start.mjs +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch trigger edge: verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks, or polls GitHub with no public URL, and queues one pi coding agent job per event (per replica, if set) for the worker.",
|
|
6
6
|
"keywords": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@octokit/webhooks": "14.2.0",
|
|
51
|
-
"@edgehero/pi-dispatch": "^4.
|
|
51
|
+
"@edgehero/pi-dispatch": "^4.3.1",
|
|
52
52
|
"bullmq": "5.80.4",
|
|
53
53
|
"ioredis": "5.11.1"
|
|
54
54
|
}
|
package/src/config.mjs
CHANGED
|
@@ -184,6 +184,16 @@ function loadGitLabConfig(env) {
|
|
|
184
184
|
* `FORGEJO_BOT_ID` is optional and exists for one reason: a repository-scoped token cannot call
|
|
185
185
|
* `GET /user` (see worker/src/forgejo-identity.mjs). Without it, following the token-scoping advice would
|
|
186
186
|
* make the receiver unable to identify itself, and identity is not optional here.
|
|
187
|
+
*
|
|
188
|
+
* `FORGEJO_PERMISSION_TOKEN` is optional and is the author gate's alone (OQ-043). Forgejo answers a lookup of
|
|
189
|
+
* ANOTHER user's repository permission only to a site admin or a repository admin, so whichever token asks must
|
|
190
|
+
* belong to one. FORGEJO_TOKEN is also every forgejo job's token, so making ITS account an admin hands each job
|
|
191
|
+
* a token that can lift branch protection. This variable lets a DIFFERENT account, an admin of the repositories,
|
|
192
|
+
* answer that one GET with a `read:repository` token, while the job's account stays a `write` collaborator.
|
|
193
|
+
* Read here and nowhere else: it never reaches the worker's job env, and the worker refuses it in PI_FORWARD_ENV
|
|
194
|
+
* and run.secrets (WORKER_ONLY_SECRET_VARS). Unset (or blank), the lookup falls back to FORGEJO_TOKEN, which is
|
|
195
|
+
* the behaviour before it existed and works only if that account is an admin; `lookupTokenVar` names which one
|
|
196
|
+
* is in use, so the boot line and a 403's reason can say so.
|
|
187
197
|
*/
|
|
188
198
|
function loadForgejoConfig(env) {
|
|
189
199
|
const secret = env.FORGEJO_WEBHOOK_SECRET;
|
|
@@ -206,7 +216,15 @@ function loadForgejoConfig(env) {
|
|
|
206
216
|
if (typeof token !== "string" || token.trim() === "") {
|
|
207
217
|
throw configError("FORGEJO_TOKEN is required for forgejo triggers -- the receiver resolves the actor's repository permission before it may enqueue");
|
|
208
218
|
}
|
|
209
|
-
|
|
219
|
+
const permissionToken = typeof env.FORGEJO_PERMISSION_TOKEN === "string" ? env.FORGEJO_PERMISSION_TOKEN.trim() : "";
|
|
220
|
+
return {
|
|
221
|
+
secret,
|
|
222
|
+
token,
|
|
223
|
+
apiUrl: apiUrl.trim(),
|
|
224
|
+
botId,
|
|
225
|
+
lookupToken: permissionToken !== "" ? permissionToken : token,
|
|
226
|
+
lookupTokenVar: permissionToken !== "" ? "FORGEJO_PERMISSION_TOKEN" : "FORGEJO_TOKEN",
|
|
227
|
+
};
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
/**
|
|
@@ -318,9 +336,9 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
318
336
|
}
|
|
319
337
|
const group = groups[run.kind];
|
|
320
338
|
if (on.type === "label") {
|
|
321
|
-
group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
|
|
339
|
+
group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
|
|
322
340
|
} else if (on.type === "comment") {
|
|
323
|
-
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
341
|
+
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
324
342
|
} else if (on.type === "pull_request") {
|
|
325
343
|
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
326
344
|
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
@@ -329,17 +347,17 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
329
347
|
// split reads the SHARED close-word table, never a re-typed word.
|
|
330
348
|
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
331
349
|
if (closeWord !== undefined && on.action.includes(closeWord)) {
|
|
332
|
-
group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
350
|
+
group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
333
351
|
} else {
|
|
334
352
|
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
335
353
|
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
336
|
-
group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
354
|
+
group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
337
355
|
}
|
|
338
356
|
} else if (on.type === "issue") {
|
|
339
357
|
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
340
358
|
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
341
359
|
// normalized them -- the filter's close route reads them, nothing else does.
|
|
342
|
-
group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
360
|
+
group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
343
361
|
}
|
|
344
362
|
}
|
|
345
363
|
|
package/src/filter-azure.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
28
28
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
29
29
|
import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
|
|
30
|
+
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
30
31
|
|
|
31
32
|
/** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
|
|
32
33
|
export const PR_ACTION_FOR = {
|
|
@@ -143,6 +144,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
143
144
|
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
144
145
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
145
146
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
147
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
148
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
149
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
150
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
151
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
146
152
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
147
153
|
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
148
154
|
// unflagged job's data must stay byte-identical to today's.
|
|
@@ -214,6 +220,7 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
214
220
|
waitFor: rule.waitFor,
|
|
215
221
|
instructions: rule.instructions,
|
|
216
222
|
resume: rule.resume,
|
|
223
|
+
awaitInput: rule.awaitInput,
|
|
217
224
|
replicas: rule.replicas,
|
|
218
225
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
219
226
|
target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
|
|
@@ -224,7 +231,14 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
224
231
|
function routeComment(subset, triggers, knownFlows, targetType) {
|
|
225
232
|
const phrase = triggers.comment?.phrase;
|
|
226
233
|
const body = subset.comment;
|
|
227
|
-
|
|
234
|
+
const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
|
|
235
|
+
// A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
|
|
236
|
+
// needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
|
|
237
|
+
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
238
|
+
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
239
|
+
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
240
|
+
const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && targetType === "issue" && hasParkedLabel(subset.tags);
|
|
241
|
+
if (!phraseHit && !parkedReply) {
|
|
228
242
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
229
243
|
}
|
|
230
244
|
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
@@ -259,8 +273,10 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
259
273
|
waitFor: triggers.comment.waitFor,
|
|
260
274
|
instructions: triggers.comment.instructions,
|
|
261
275
|
resume: triggers.comment.resume,
|
|
276
|
+
awaitInput: triggers.comment.awaitInput,
|
|
262
277
|
replicas: triggers.comment.replicas,
|
|
263
278
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
279
|
+
...(parkedReply && { parkedReply: true }),
|
|
264
280
|
// No author_association: Azure has none, and the authority that admitted this comment was resolved
|
|
265
281
|
// from the graph, not read off the body.
|
|
266
282
|
comment: { body },
|
|
@@ -293,6 +309,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
293
309
|
waitFor: rule.waitFor,
|
|
294
310
|
instructions: rule.instructions,
|
|
295
311
|
resume: rule.resume,
|
|
312
|
+
awaitInput: rule.awaitInput,
|
|
296
313
|
replicas: rule.replicas,
|
|
297
314
|
matched: { index: rule.index, type: "pull_request", action },
|
|
298
315
|
target: {
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
37
37
|
import { findCloseRule } from "./close.mjs";
|
|
38
38
|
import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
|
|
39
|
+
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
39
40
|
|
|
40
41
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
41
42
|
export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
@@ -157,6 +158,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
157
158
|
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
158
159
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
159
160
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
161
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
162
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
163
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
164
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
165
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
160
166
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
161
167
|
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
162
168
|
// unflagged job's data must stay byte-identical to today's.
|
|
@@ -194,6 +200,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
194
200
|
waitFor: rule.waitFor,
|
|
195
201
|
instructions: rule.instructions,
|
|
196
202
|
resume: rule.resume,
|
|
203
|
+
awaitInput: rule.awaitInput,
|
|
197
204
|
replicas: rule.replicas,
|
|
198
205
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
199
206
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
@@ -204,7 +211,14 @@ function routeIssueLabel(subset, triggers) {
|
|
|
204
211
|
function routeComment(subset, triggers, knownFlows) {
|
|
205
212
|
const phrase = triggers.comment?.phrase;
|
|
206
213
|
const body = subset.comment?.body;
|
|
207
|
-
|
|
214
|
+
const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
|
|
215
|
+
// A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
|
|
216
|
+
// needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
|
|
217
|
+
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
218
|
+
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
219
|
+
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
220
|
+
const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.isPull !== true && hasParkedLabel(subset.issue?.labels);
|
|
221
|
+
if (!phraseHit && !parkedReply) {
|
|
208
222
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
209
223
|
}
|
|
210
224
|
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
@@ -244,8 +258,10 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
244
258
|
waitFor: triggers.comment.waitFor,
|
|
245
259
|
instructions: triggers.comment.instructions,
|
|
246
260
|
resume: triggers.comment.resume,
|
|
261
|
+
awaitInput: triggers.comment.awaitInput,
|
|
247
262
|
replicas: triggers.comment.replicas,
|
|
248
263
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
264
|
+
...(parkedReply && { parkedReply: true }),
|
|
249
265
|
// The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
|
|
250
266
|
// authority that admitted this comment was resolved from the API, not read off the body.
|
|
251
267
|
comment: { body: subset.comment.body },
|
|
@@ -276,6 +292,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
276
292
|
waitFor: rule.waitFor,
|
|
277
293
|
instructions: rule.instructions,
|
|
278
294
|
resume: rule.resume,
|
|
295
|
+
awaitInput: rule.awaitInput,
|
|
279
296
|
replicas: rule.replicas,
|
|
280
297
|
matched: { index: rule.index, type: "pull_request", action },
|
|
281
298
|
target: {
|
|
@@ -332,6 +349,7 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
332
349
|
waitFor: rule.waitFor,
|
|
333
350
|
instructions: rule.instructions,
|
|
334
351
|
resume: rule.resume,
|
|
352
|
+
awaitInput: rule.awaitInput,
|
|
335
353
|
replicas: rule.replicas,
|
|
336
354
|
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
337
355
|
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
@@ -369,6 +387,7 @@ function routePullRequestClose(subset, triggers, authorized) {
|
|
|
369
387
|
waitFor: rule.waitFor,
|
|
370
388
|
instructions: rule.instructions,
|
|
371
389
|
resume: rule.resume,
|
|
390
|
+
awaitInput: rule.awaitInput,
|
|
372
391
|
replicas: rule.replicas,
|
|
373
392
|
// No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
|
|
374
393
|
// number is the target's business; the issue route carries it because the github design does.
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
|
|
44
44
|
import { findCloseRule } from "./close.mjs";
|
|
45
|
+
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
/** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
|
|
@@ -162,6 +163,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
162
163
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
163
164
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
164
165
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
166
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
167
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
168
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
169
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
170
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
165
171
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
166
172
|
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
167
173
|
// unflagged job's data must stay byte-identical to today's.
|
|
@@ -204,6 +210,7 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
204
210
|
waitFor: rule.waitFor,
|
|
205
211
|
instructions: rule.instructions,
|
|
206
212
|
resume: rule.resume,
|
|
213
|
+
awaitInput: rule.awaitInput,
|
|
207
214
|
replicas: rule.replicas,
|
|
208
215
|
matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
|
|
209
216
|
target: buildTarget(subset, targetType),
|
|
@@ -269,6 +276,7 @@ function mrResult(subset, rule, matched) {
|
|
|
269
276
|
waitFor: rule.waitFor,
|
|
270
277
|
instructions: rule.instructions,
|
|
271
278
|
resume: rule.resume,
|
|
279
|
+
awaitInput: rule.awaitInput,
|
|
272
280
|
replicas: rule.replicas,
|
|
273
281
|
matched,
|
|
274
282
|
target: buildTarget(subset, "pull_request"),
|
|
@@ -316,6 +324,7 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
316
324
|
waitFor: rule.waitFor,
|
|
317
325
|
instructions: rule.instructions,
|
|
318
326
|
resume: rule.resume,
|
|
327
|
+
awaitInput: rule.awaitInput,
|
|
319
328
|
replicas: rule.replicas,
|
|
320
329
|
// `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
|
|
321
330
|
// can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
|
|
@@ -351,6 +360,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
|
|
|
351
360
|
waitFor: rule.waitFor,
|
|
352
361
|
instructions: rule.instructions,
|
|
353
362
|
resume: rule.resume,
|
|
363
|
+
awaitInput: rule.awaitInput,
|
|
354
364
|
replicas: rule.replicas,
|
|
355
365
|
// No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
|
|
356
366
|
// number is the target's business; the issue route carries it because the github design does.
|
|
@@ -370,7 +380,14 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
370
380
|
|
|
371
381
|
const phrase = triggers?.comment?.phrase;
|
|
372
382
|
const body = subset.note;
|
|
373
|
-
|
|
383
|
+
const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
|
|
384
|
+
// A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
|
|
385
|
+
// needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
|
|
386
|
+
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
387
|
+
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
388
|
+
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
389
|
+
const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers?.comment?.resume === true && triggers?.comment?.command === undefined && subset.noteableType !== "MergeRequest" && hasParkedLabel(subset.target?.labels);
|
|
390
|
+
if (!phraseHit && !parkedReply) {
|
|
374
391
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
375
392
|
}
|
|
376
393
|
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
@@ -404,8 +421,10 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
404
421
|
waitFor: triggers.comment.waitFor,
|
|
405
422
|
instructions: triggers.comment.instructions,
|
|
406
423
|
resume: triggers.comment.resume,
|
|
424
|
+
awaitInput: triggers.comment.awaitInput,
|
|
407
425
|
replicas: triggers.comment.replicas,
|
|
408
426
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
427
|
+
...(parkedReply && { parkedReply: true }),
|
|
409
428
|
target: buildTarget(subset, targetType),
|
|
410
429
|
// The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
|
|
411
430
|
// counterpart exists on GitLab: the approval is the resolved access level, which is a harness
|
package/src/filter.mjs
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import { findCloseRule } from "./close.mjs";
|
|
49
49
|
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
50
|
+
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
50
51
|
|
|
51
52
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
52
53
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
@@ -185,6 +186,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
|
|
|
185
186
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
186
187
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
187
188
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
189
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
190
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
191
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
192
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
193
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
188
194
|
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
|
|
189
195
|
// inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
|
|
190
196
|
// receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
|
|
@@ -232,6 +238,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
232
238
|
waitFor: rule.waitFor,
|
|
233
239
|
instructions: rule.instructions,
|
|
234
240
|
resume: rule.resume,
|
|
241
|
+
awaitInput: rule.awaitInput,
|
|
235
242
|
replicas: rule.replicas,
|
|
236
243
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
237
244
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
@@ -250,7 +257,14 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
250
257
|
}
|
|
251
258
|
const phrase = triggers.comment?.phrase;
|
|
252
259
|
const body = subset.comment?.body;
|
|
253
|
-
|
|
260
|
+
const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
|
|
261
|
+
// A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
|
|
262
|
+
// needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
|
|
263
|
+
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
264
|
+
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
265
|
+
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
266
|
+
const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true && hasParkedLabel(subset.issue?.labels);
|
|
267
|
+
if (!phraseHit && !parkedReply) {
|
|
254
268
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
255
269
|
}
|
|
256
270
|
// On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
|
|
@@ -293,8 +307,10 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
293
307
|
waitFor: triggers.comment.waitFor,
|
|
294
308
|
instructions: triggers.comment.instructions,
|
|
295
309
|
resume: triggers.comment.resume,
|
|
310
|
+
awaitInput: triggers.comment.awaitInput,
|
|
296
311
|
replicas: triggers.comment.replicas,
|
|
297
312
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
313
|
+
...(parkedReply && { parkedReply: true }),
|
|
298
314
|
// The invoking comment rides on the trigger: body and author_association are both named by
|
|
299
315
|
// INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
300
316
|
comment: { body: subset.comment.body, author_association: subset.comment.author_association },
|
|
@@ -392,6 +408,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
392
408
|
waitFor: rule.waitFor,
|
|
393
409
|
instructions: rule.instructions,
|
|
394
410
|
resume: rule.resume,
|
|
411
|
+
awaitInput: rule.awaitInput,
|
|
395
412
|
replicas: rule.replicas,
|
|
396
413
|
matched: { index: rule.index, type: "pull_request", action },
|
|
397
414
|
...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
|
|
@@ -509,6 +526,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
|
509
526
|
waitFor: rule.waitFor,
|
|
510
527
|
instructions: rule.instructions,
|
|
511
528
|
resume: rule.resume,
|
|
529
|
+
awaitInput: rule.awaitInput,
|
|
512
530
|
replicas: rule.replicas,
|
|
513
531
|
matched: matchedFor(rule),
|
|
514
532
|
target: targetFor(),
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Forgejo has no `author_association`; the concept is GitHub's. What it has instead is
|
|
6
6
|
* `GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission`, which answers
|
|
7
|
-
* `{ permission: "admin"|"write"|"read"|"none" }`. `admin` and `write` are the honest
|
|
8
|
-
* `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
-
* the constitution actually requires.
|
|
7
|
+
* `{ permission: "owner"|"admin"|"write"|"read"|"none" }`. `owner`, `admin` and `write` are the honest
|
|
8
|
+
* analogue of `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
+
* property the constitution actually requires. `owner` is not hypothetical and not rare: measured on
|
|
10
|
+
* Forgejo 14.0.5, the owner of a personal repository asking about themselves gets
|
|
11
|
+
* `{"permission":"owner","role_name":"owner",...}`, and a set without it dropped every trigger they set
|
|
12
|
+
* as `author-not-allowed`.
|
|
10
13
|
*
|
|
11
14
|
* The shape of this module is the GitLab resolver's, for the same three reasons, and they are worth
|
|
12
15
|
* restating rather than cross-referencing:
|
|
@@ -31,18 +34,31 @@ const API_PREFIX = "/api/v1";
|
|
|
31
34
|
/**
|
|
32
35
|
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
33
36
|
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
34
|
-
* draws.
|
|
37
|
+
* draws. These are the strings of Forgejo's access modes (none, read, write, admin, owner); `owner` is what
|
|
38
|
+
* a personal repository's owner is answered with (measured, see the header).
|
|
35
39
|
*/
|
|
36
|
-
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
40
|
+
const WRITE_PERMISSIONS = new Set(["owner", "admin", "write"]);
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* What a 403 tells the operator to fix, by which variable's token asked. Fixed strings, never the body.
|
|
44
|
+
* With the dedicated lookup token, its account is the one that must be an admin. With the fallback, the right repair
|
|
45
|
+
* is NOT to make the job's account an admin (that hands every job a token able to lift branch protection, OQ-043) but
|
|
46
|
+
* to give the lookup its own token.
|
|
47
|
+
*/
|
|
48
|
+
const FORBIDDEN_REASON = Object.freeze({
|
|
49
|
+
FORGEJO_PERMISSION_TOKEN: "collaborator permission lookup returned 403 (the account behind FORGEJO_PERMISSION_TOKEN must be an admin or the owner of the repository, and the token must carry read:repository)",
|
|
50
|
+
FORGEJO_TOKEN: "collaborator permission lookup returned 403 (FORGEJO_TOKEN's account is not an admin of the repository: set FORGEJO_PERMISSION_TOKEN to a read:repository token of an account that is, docs/forgejo.md)",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the resolver. `token` is the lookup token (FORGEJO_PERMISSION_TOKEN, or FORGEJO_TOKEN as the fallback) and
|
|
55
|
+
* `tokenVar` names which, for a 403's reason; `fetchFn` is injected so the whole module is testable offline.
|
|
41
56
|
*
|
|
42
57
|
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
43
58
|
* -- the same shape every forge's resolver returns.
|
|
44
59
|
*/
|
|
45
|
-
export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
60
|
+
export function makeResolveForgejoAuthority({ apiUrl, token, tokenVar = "FORGEJO_TOKEN", fetchFn = fetch }) {
|
|
61
|
+
const forbidden = FORBIDDEN_REASON[tokenVar] ?? FORBIDDEN_REASON.FORGEJO_TOKEN;
|
|
46
62
|
return async function resolveAuthority(repoFullName, login) {
|
|
47
63
|
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
48
64
|
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
@@ -68,6 +84,23 @@ export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch })
|
|
|
68
84
|
// a refusal rather than a failure.
|
|
69
85
|
return { authorized: false };
|
|
70
86
|
}
|
|
87
|
+
if (res.status === 403) {
|
|
88
|
+
// A CONFIGURATION error, named as one. Forgejo lets only a site admin, a repository admin, or the
|
|
89
|
+
// user themselves read a permission (measured on 14.0.5: a `write` collaborator's token gets 403
|
|
90
|
+
// {"message":"Only admins can query all permissions, repo admins can query all repo permissions,
|
|
91
|
+
// collaborators can query only their own"}; the same account raised to repo admin gets 200). The
|
|
92
|
+
// other 403s this route can give are the token's scope (`token does not have at least one of
|
|
93
|
+
// required scope(s)`, `token scope is limited to public repos`), so the dedicated token's reason names its scope too.
|
|
94
|
+
// Still indeterminate, and that is a decision, not an oversight. A retry cannot heal it, but the
|
|
95
|
+
// retry costs nothing (no token is minted and nothing spends before this answer), while the
|
|
96
|
+
// alternative is worse in the way this file's header names: a determinate refusal answers 204, which
|
|
97
|
+
// Forgejo records as a successful delivery, so the one place the operator looks would show every
|
|
98
|
+
// trigger "delivered" while nothing ran. A 503 marks each delivery failed there, the log line below
|
|
99
|
+
// names the repair, and a delivery that failed this way still describes real work to redo once the
|
|
100
|
+
// account is fixed. The body is still not read: the fixed reason is enough, and a body is not ours
|
|
101
|
+
// to log. The reason names the variable whose token asked (FORBIDDEN_REASON).
|
|
102
|
+
return { indeterminate: forbidden };
|
|
103
|
+
}
|
|
71
104
|
if (!res.ok) {
|
|
72
105
|
// Note what is NOT here: the response body. A Forgejo error body can echo the request, and the
|
|
73
106
|
// request carried the token.
|
package/src/start.mjs
CHANGED
|
@@ -211,10 +211,14 @@ export async function startReceiver(
|
|
|
211
211
|
if (cfg.forgejo) {
|
|
212
212
|
const forgejoSelfId = await retryIdentity(() => resolveForgejoSelfIdFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token, botId: cfg.forgejo.botId }), { forge: "forgejo", ...retryOpts });
|
|
213
213
|
log({ event: "self_identity", forge: "forgejo", id: forgejoSelfId, source: cfg.forgejo.botId ? "FORGEJO_BOT_ID" : "api" });
|
|
214
|
+
// The author gate's lookup token, by NAME only (OQ-043): FORGEJO_PERMISSION_TOKEN when set, else FORGEJO_TOKEN,
|
|
215
|
+
// whose account must then be a repository admin. Identity above stays FORGEJO_TOKEN's: it is the account the
|
|
216
|
+
// jobs post as, which the bot-loop guard must recognise.
|
|
217
|
+
log({ event: "forgejo_permission_token", source: cfg.forgejo.lookupTokenVar });
|
|
214
218
|
forgejo = {
|
|
215
219
|
secret: cfg.forgejo.secret,
|
|
216
220
|
selfId: forgejoSelfId,
|
|
217
|
-
resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.
|
|
221
|
+
resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.lookupToken, tokenVar: cfg.forgejo.lookupTokenVar }),
|
|
218
222
|
};
|
|
219
223
|
}
|
|
220
224
|
|