@edgehero/pi-dispatch-receiver 4.2.0 → 4.3.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 -5
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
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.0",
|
|
52
52
|
"bullmq": "5.80.4",
|
|
53
53
|
"ioredis": "5.11.1"
|
|
54
54
|
}
|
package/src/config.mjs
CHANGED
|
@@ -318,9 +318,9 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
318
318
|
}
|
|
319
319
|
const group = groups[run.kind];
|
|
320
320
|
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 });
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
|
|
322
322
|
} 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
|
|
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, 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
324
|
} else if (on.type === "pull_request") {
|
|
325
325
|
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
326
326
|
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
@@ -329,17 +329,17 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
329
329
|
// split reads the SHARED close-word table, never a re-typed word.
|
|
330
330
|
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
331
331
|
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 });
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
333
333
|
} else {
|
|
334
334
|
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
335
335
|
// 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 });
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
337
337
|
}
|
|
338
338
|
} else if (on.type === "issue") {
|
|
339
339
|
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
340
340
|
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
341
341
|
// 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 });
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
|
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(),
|