@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "4.2.0",
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.2.0",
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
- return { secret, token, apiUrl: apiUrl.trim(), botId };
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
 
@@ -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
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
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: {
@@ -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
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
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.
@@ -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
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
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
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
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(),
@@ -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 analogue of
8
- * `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the property
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
- * Build the resolver. `token` is the same operator-supplied access token the worker uses; `fetchFn` is
40
- * injected so the whole module is testable offline.
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.token }),
221
+ resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.lookupToken, tokenVar: cfg.forgejo.lookupTokenVar }),
218
222
  };
219
223
  }
220
224