@edgehero/pi-dispatch-receiver 4.3.0 → 5.0.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/README.md +12 -2
- package/package.json +2 -2
- package/src/azure-members.mjs +6 -3
- package/src/boot-retry.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +45 -57
- package/src/filter-azure.mjs +150 -99
- package/src/filter-forgejo.mjs +148 -88
- package/src/filter-gitlab.mjs +149 -90
- package/src/filter.mjs +168 -113
- package/src/forgejo-members.mjs +43 -9
- package/src/github-members.mjs +2 -1
- package/src/gitlab-members.mjs +2 -1
- package/src/held.mjs +120 -0
- package/src/poller.mjs +62 -15
- package/src/predicate.mjs +37 -0
- package/src/receiver.mjs +290 -60
- package/src/start.mjs +31 -6
- package/src/steer.mjs +221 -0
- package/src/trigger-groups.mjs +57 -0
- package/src/verify-azure.mjs +1 -1
- package/src/verify-gitlab.mjs +5 -4
- package/src/verify-watch.mjs +40 -0
- package/src/verify.mjs +1 -1
- package/src/watch-pull.mjs +94 -0
- package/src/watch-puller.mjs +82 -0
- package/src/watch.mjs +60 -0
- package/src/http-body.mjs +0 -73
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# @edgehero/pi-dispatch-receiver
|
|
2
2
|
|
|
3
3
|
The trigger edge of **[pi-dispatch](https://github.com/edgehero/pi-dispatch)**, which runs the
|
|
4
|
-
[pi](https://github.com/earendil-works/pi) coding agent
|
|
4
|
+
[pi](https://github.com/earendil-works/pi) coding agent for you in the background. Most people want the
|
|
5
|
+
[main README](https://github.com/edgehero/pi-dispatch#readme) first: it explains why and how to use it.
|
|
5
6
|
|
|
6
7
|
The receiver turns forge activity into queued jobs for the
|
|
7
8
|
[worker](https://www.npmjs.com/package/@edgehero/pi-dispatch). It reads the same `triggers.json` as the
|
|
8
9
|
worker, checks each delivery, and queues at most one job per event (one per replica when a trigger sets
|
|
9
10
|
`run.replicas`). It never runs a job itself.
|
|
10
11
|
|
|
11
|
-
##
|
|
12
|
+
## Three ways to run it
|
|
12
13
|
|
|
13
14
|
```bash
|
|
14
15
|
npx @edgehero/pi-dispatch-receiver # serve: the webhook receiver (the default)
|
|
15
16
|
npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub with your own credential
|
|
17
|
+
npx @edgehero/pi-dispatch-receiver watch # watch: pulls findings from your own alert sources
|
|
16
18
|
```
|
|
17
19
|
|
|
18
20
|
- **`serve`** verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks (at `/`, `/gitlab`, `/forgejo`
|
|
@@ -23,6 +25,14 @@ npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub w
|
|
|
23
25
|
- **`poll`** fetches GitHub issue events, comments and pull requests about once a minute, over TLS, with
|
|
24
26
|
the same gates and the same queue. A new poller starts from now and never replays old labels.
|
|
25
27
|
|
|
28
|
+
- **`watch`** pulls findings from the sources in `triggers.json`. `serve` takes them pushed to `/watch/<source>` as well. A finding starts a
|
|
29
|
+
read only investigation or files an issue, and cannot choose a job's permissions
|
|
30
|
+
([watch](https://github.com/edgehero/pi-dispatch/blob/main/docs/watch.md)).
|
|
31
|
+
|
|
32
|
+
**Steering from a comment.** While a job of a steer armed trigger runs on an issue or pull request, a comment with your comment trigger's
|
|
33
|
+
phrase goes to that running job instead of starting a second one, once the comment trigger has `"steer": true` as well
|
|
34
|
+
([steering](https://github.com/edgehero/pi-dispatch/blob/main/docs/steering.md#steering-from-a-comment)).
|
|
35
|
+
|
|
26
36
|
The receiver checks each delivery's signature, the bot loop guard, the forge's own permission check and
|
|
27
37
|
dedup. The worker then applies quiet hours, the image preflight, branch protection and the spend caps.
|
|
28
38
|
Who may fire a trigger is decided by your forge. See
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.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": "^
|
|
51
|
+
"@edgehero/pi-dispatch": "^5.0.0",
|
|
52
52
|
"bullmq": "5.80.4",
|
|
53
53
|
"ioredis": "5.11.1"
|
|
54
54
|
}
|
package/src/azure-members.mjs
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* it. `OQ-013` is amended to say so rather than leaving it implied.
|
|
16
16
|
*
|
|
17
17
|
* The verdict shape is every other forge's: `{ authorized }` | `{ indeterminate }`. A determinate "not a
|
|
18
|
-
* member" refuses; anything that could not be answered is a 503
|
|
18
|
+
* member" refuses; anything that could not be answered is a 503, which Azure resends up to 8 times over about
|
|
19
|
+
* 3 minutes, then retries on probation up to 7 more times over about 36 hours while new events are lost
|
|
20
|
+
* (receiver.mjs, WHAT A 503 MEANS).
|
|
19
21
|
*
|
|
20
22
|
* WHY MEMBERSHIP AND NOT A PERMISSION EVALUATION. Azure's Security namespace API can answer "may this
|
|
21
23
|
* subject contribute to this repository" exactly, which is closer to the property the constitution wants.
|
|
@@ -153,8 +155,9 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
|
|
|
153
155
|
* `null`, which the caller refuses. This is the honest refusal: the entire directory was read.
|
|
154
156
|
* - the PAGE CAP was reached while Azure was still offering more -> `{ indeterminate }`, never `null`.
|
|
155
157
|
* The search was abandoned, not completed, and the module's own docblock is explicit that
|
|
156
|
-
* indeterminate and unauthorized must not be conflated: this way the receiver answers 503
|
|
157
|
-
*
|
|
158
|
+
* indeterminate and unauthorized must not be conflated: this way the receiver answers 503, which Azure
|
|
159
|
+
* resends for about 3 minutes and then, on probation, for about 36 hours, instead of burying an
|
|
160
|
+
* exhausted search inside a 204 that reads as a policy decision.
|
|
158
161
|
*
|
|
159
162
|
* A FILTERED LOOKUP WOULD BE BETTER AND IS NOT USED. Nothing in the Graph surface this module already
|
|
160
163
|
* speaks offers a by-mail filter on `graph/users` (`subjectTypes` selects kinds of subject, not
|
package/src/boot-retry.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
|
66
66
|
* same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
|
|
67
67
|
* supervisor restarts into a fresh window.
|
|
68
68
|
*
|
|
69
|
-
* `now` and `sleep` are seams (
|
|
69
|
+
* `now` and `sleep` are seams (AGENTS.md: a window takes an injected clock, always; issue #284);
|
|
70
70
|
* `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
|
|
71
71
|
* the clearing rather than trust it.
|
|
72
72
|
*/
|
package/src/cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { isEntryModule } from "@edgehero/pi-dispatch/entry";
|
|
|
19
19
|
const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs
|
|
20
20
|
|
|
21
21
|
pi-dispatch-receiver serve start the webhook receiver (the default when no command is given)
|
|
22
|
+
pi-dispatch-receiver watch poll reviewed file, journald and Loki watch sources
|
|
22
23
|
pi-dispatch-receiver poll start the polling producer — no public URL, no DNS, no tunnel: it
|
|
23
24
|
reads api.github.com with the operator's own credential instead
|
|
24
25
|
|
|
@@ -38,7 +39,7 @@ serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches an
|
|
|
38
39
|
// injects a collector instead of reassigning `process.stdout.write`. That matters because `node --test`
|
|
39
40
|
// runs each file in a child process that serialises its own results over that same stdout, so a test
|
|
40
41
|
// holding a replacement across an `await` swallows the runner's result frames (issue #266).
|
|
41
|
-
export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll, write = (chunk) => process.stdout.write(chunk) } = {}) {
|
|
42
|
+
export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll, startWatch, write = (chunk) => process.stdout.write(chunk) } = {}) {
|
|
42
43
|
const cmd = argv[0];
|
|
43
44
|
|
|
44
45
|
if (cmd === undefined || cmd === "serve") {
|
|
@@ -58,6 +59,13 @@ export async function main(argv = process.argv.slice(2), env = process.env, { st
|
|
|
58
59
|
return 0;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
if (cmd === "watch") {
|
|
63
|
+
const startWatchPuller = startWatch ?? (await import("./watch-puller.mjs")).startWatchPuller;
|
|
64
|
+
const puller = await startWatchPuller(env);
|
|
65
|
+
await puller?.done;
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
write(`${USAGE}\n`);
|
|
62
70
|
return cmd === "--help" || cmd === "-h" ? 0 : 1; // asked-for help is success; a typo is not
|
|
63
71
|
}
|
package/src/config.mjs
CHANGED
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { existsSync, readFileSync } from "node:fs";
|
|
34
|
+
import { groupTriggers } from "./trigger-groups.mjs";
|
|
34
35
|
import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
|
|
35
|
-
import { FORGE_KINDS,
|
|
36
|
+
import { FORGE_KINDS, parseTriggersDocument } from "@edgehero/pi-dispatch/triggers";
|
|
36
37
|
|
|
37
38
|
// Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
|
|
38
39
|
// default must be the file init just told the operator it created, not a demo buried in the repo.
|
|
@@ -100,6 +101,19 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The forges this receiver serves, in `FORGE_KINDS` order, for its status record (`INT-RECEIVER-STATUS-KEY`, issue
|
|
106
|
+
* #824): GitHub by `servesGithub` (`cfg.github` is its auth block, present whether GitHub is served or not), and every
|
|
107
|
+
* other forge by its own block, which the loader sets to null when nothing names the forge and which `startReceiver`
|
|
108
|
+
* arms a route for exactly when it is set. A kind with no block at all is a forge added to the table that this loader
|
|
109
|
+
* does not read yet, so nothing arms a route for it, and it is unserved. Throwing instead would cost the receiver its
|
|
110
|
+
* whole record, every forge's counts, at boot, for a gap the operator cannot close. The developer who adds the forge
|
|
111
|
+
* learns it from receiver/test/receiver-status-start.test.mjs, which requires a block for every `FORGE_KINDS` member.
|
|
112
|
+
*/
|
|
113
|
+
export function servedForges(cfg, kinds = FORGE_KINDS) {
|
|
114
|
+
return kinds.filter((kind) => kind === "github" ? cfg.servesGithub === true : Object.hasOwn(cfg, kind) && Boolean(cfg[kind]));
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
/**
|
|
104
118
|
* Does this deployment actually SERVE GitHub? Three things hang off the answer -- the WEBHOOK_SECRET
|
|
105
119
|
* requirement above, whether `/` is mounted (receiver.mjs), and whether boot resolves the harness's own
|
|
@@ -166,7 +180,10 @@ function loadGitLabConfig(env) {
|
|
|
166
180
|
}
|
|
167
181
|
// The gate needs this token BEFORE any job runs: the actor's access level is what authorises a GitLab
|
|
168
182
|
// trigger at all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and
|
|
169
|
-
// every delivery 503s.
|
|
183
|
+
// every delivery 503s, which GitLab never resends. After 4 in a row GitLab disables the hook for a while
|
|
184
|
+
// (on gitlab.com, and a group hook anywhere; a self-managed project hook only where the
|
|
185
|
+
// `auto_disabling_web_hooks` feature flag is on). Refusing at boot is the difference between one clear
|
|
186
|
+
// message and every delivery failing, and perhaps the hook switched off.
|
|
170
187
|
if (typeof token !== "string" || token.trim() === "") {
|
|
171
188
|
throw configError("GITLAB_TOKEN is required for gitlab triggers -- the receiver resolves the actor's project access level before it may enqueue");
|
|
172
189
|
}
|
|
@@ -184,6 +201,16 @@ function loadGitLabConfig(env) {
|
|
|
184
201
|
* `FORGEJO_BOT_ID` is optional and exists for one reason: a repository-scoped token cannot call
|
|
185
202
|
* `GET /user` (see worker/src/forgejo-identity.mjs). Without it, following the token-scoping advice would
|
|
186
203
|
* make the receiver unable to identify itself, and identity is not optional here.
|
|
204
|
+
*
|
|
205
|
+
* `FORGEJO_PERMISSION_TOKEN` is optional and is the author gate's alone (OQ-043). Forgejo answers a lookup of
|
|
206
|
+
* ANOTHER user's repository permission only to a site admin or a repository admin, so whichever token asks must
|
|
207
|
+
* belong to one. FORGEJO_TOKEN is also every forgejo job's token, so making ITS account an admin hands each job
|
|
208
|
+
* a token that can lift branch protection. This variable lets a DIFFERENT account, an admin of the repositories,
|
|
209
|
+
* answer that one GET with a `read:repository` token, while the job's account stays a `write` collaborator.
|
|
210
|
+
* Read here and nowhere else: it never reaches the worker's job env, and the worker refuses it in PI_FORWARD_ENV
|
|
211
|
+
* and run.secrets (WORKER_ONLY_SECRET_VARS). Unset (or blank), the lookup falls back to FORGEJO_TOKEN, which is
|
|
212
|
+
* the behaviour before it existed and works only if that account is an admin; `lookupTokenVar` names which one
|
|
213
|
+
* is in use, so the boot line and a 403's reason can say so.
|
|
187
214
|
*/
|
|
188
215
|
function loadForgejoConfig(env) {
|
|
189
216
|
const secret = env.FORGEJO_WEBHOOK_SECRET;
|
|
@@ -202,11 +229,20 @@ function loadForgejoConfig(env) {
|
|
|
202
229
|
}
|
|
203
230
|
// Needed BEFORE any job runs: the actor's repository permission is what authorises a forgejo trigger at
|
|
204
231
|
// all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and every delivery
|
|
205
|
-
// 503s. Refusing at boot is the difference between one clear message and
|
|
232
|
+
// 503s, which Forgejo never resends. Refusing at boot is the difference between one clear message and
|
|
233
|
+
// every delivery failing in a history nobody is watching.
|
|
206
234
|
if (typeof token !== "string" || token.trim() === "") {
|
|
207
235
|
throw configError("FORGEJO_TOKEN is required for forgejo triggers -- the receiver resolves the actor's repository permission before it may enqueue");
|
|
208
236
|
}
|
|
209
|
-
|
|
237
|
+
const permissionToken = typeof env.FORGEJO_PERMISSION_TOKEN === "string" ? env.FORGEJO_PERMISSION_TOKEN.trim() : "";
|
|
238
|
+
return {
|
|
239
|
+
secret,
|
|
240
|
+
token,
|
|
241
|
+
apiUrl: apiUrl.trim(),
|
|
242
|
+
botId,
|
|
243
|
+
lookupToken: permissionToken !== "" ? permissionToken : token,
|
|
244
|
+
lookupTokenVar: permissionToken !== "" ? "FORGEJO_PERMISSION_TOKEN" : "FORGEJO_TOKEN",
|
|
245
|
+
};
|
|
210
246
|
}
|
|
211
247
|
|
|
212
248
|
/**
|
|
@@ -293,61 +329,13 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
293
329
|
throw configError(`triggers file not found: ${path} -- run \`pi-dispatch init\` in this folder to scaffold one, or set PI_TRIGGERS_FILE to yours`);
|
|
294
330
|
}
|
|
295
331
|
|
|
296
|
-
const
|
|
332
|
+
const document = parseTriggersDocument(readFile(path, "utf8"), path);
|
|
333
|
+
const parsed = document.triggers;
|
|
334
|
+
const watchSources = document.watchSources ?? {};
|
|
335
|
+
for (const source of Object.values(watchSources)) if (typeof env[source.secretEnv] !== "string" || !env[source.secretEnv].trim()) throw configError("watch: a configured source secret is missing");
|
|
297
336
|
void 0;
|
|
298
337
|
|
|
299
|
-
|
|
300
|
-
// `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
|
|
301
|
-
//
|
|
302
|
-
// Built FROM the forge table rather than written out, because the failure of forgetting one is unusually
|
|
303
|
-
// quiet: `groups[run.kind]` would be undefined, `group.label.push` would throw, and `reloadTriggers`
|
|
304
|
-
// below catches everything and KEEPS the previously-loaded triggers. The operator would see one
|
|
305
|
-
// "invalid" message and their old rules would go on firing indefinitely. A test asserts this object's
|
|
306
|
-
// keys are exactly FORGE_KINDS, so the two can never drift.
|
|
307
|
-
const groups = Object.fromEntries(FORGE_KINDS.map((kind) => [kind, emptyGroup()]));
|
|
308
|
-
const knownFlows = new Set();
|
|
309
|
-
|
|
310
|
-
for (const [index, { on, run }] of parsed.entries()) {
|
|
311
|
-
if (on.type === "cron") continue; // the worker owns cron; the receiver never fires it -- but it keeps its index
|
|
312
|
-
// Guarded because a command trigger (issue #189) has NO run.flow, and `Set.add(undefined)` would put
|
|
313
|
-
// undefined into the comment `<phrase> <flow>` override allowlist. That set exists to bound which
|
|
314
|
-
// names a collaborator's comment may summon; polluting it with a non-name is how a later loose
|
|
315
|
-
// membership test (or a `[...set]` enumeration in a message) starts treating "no flow" as a flow.
|
|
316
|
-
if (typeof run.flow === "string") {
|
|
317
|
-
knownFlows.add(run.flow);
|
|
318
|
-
}
|
|
319
|
-
const group = groups[run.kind];
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
|
|
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, 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
|
-
} else if (on.type === "pull_request") {
|
|
325
|
-
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
326
|
-
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
327
|
-
// groups and neither route ever re-derives the distinction -- the loader already refused any
|
|
328
|
-
// list mixing the close word with another action, which is what makes this split total. The
|
|
329
|
-
// split reads the SHARED close-word table, never a re-typed word.
|
|
330
|
-
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
333
|
-
} else {
|
|
334
|
-
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
337
|
-
}
|
|
338
|
-
} else if (on.type === "issue") {
|
|
339
|
-
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
340
|
-
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
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, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return { ...groups, knownFlows };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function emptyGroup() {
|
|
350
|
-
return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
|
|
338
|
+
return groupTriggers(parsed, { watchSources });
|
|
351
339
|
}
|
|
352
340
|
|
|
353
341
|
/** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
|
package/src/filter-azure.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
28
|
-
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
28
|
+
import { escapeRegExp, firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, matchesRule, steerModeOf } from "./predicate.mjs";
|
|
29
29
|
import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
|
|
30
30
|
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
31
31
|
|
|
@@ -80,95 +80,120 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
80
80
|
return { enqueue: false, reason: "unhandled-event" };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// A drop is returned as it is, except a phrase-free reply that failed ONLY for the missing parked marker (issue #638,
|
|
84
|
+
// DES-AWAIT-INPUT-ENDS-THE-JOB): that one also carries `heldCandidate`, the job the parked-reply route would have
|
|
85
|
+
// built, through the SAME `build` below, so the receiver can hold it for a run that is parking right now and the job
|
|
86
|
+
// that run starts is byte for byte the one the marker would have produced. Still a drop: nothing here enqueues it.
|
|
87
|
+
if (!resolved.enqueue) return heldCandidateOr(resolved, build);
|
|
88
|
+
return build(resolved);
|
|
84
89
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
// The job from a resolved route. One function for every route and for the held candidate (issue #638), declared
|
|
91
|
+
// here so it closes over this delivery's subset and ids.
|
|
92
|
+
function build(resolved) {
|
|
93
|
+
// WHICH REPOSITORY the job clones. A pull-request event names its own and that is authoritative. A WORK
|
|
94
|
+
// ITEM names none -- Azure work items belong to a project, and a project may hold many repositories --
|
|
95
|
+
// so the matched rule's `run.repository` supplies it. The loader requires that field on exactly the
|
|
96
|
+
// azure trigger types a work item can fire, so reaching here without one is not possible; the guard is
|
|
97
|
+
// here anyway because "not possible" and "cannot happen" are different claims and only one is testable.
|
|
98
|
+
const scope = resolveScope(subset, resolved);
|
|
99
|
+
if (scope === null) {
|
|
100
|
+
return { enqueue: false, reason: "no-repository" };
|
|
101
|
+
}
|
|
102
|
+
resolved.repo = scope.repo;
|
|
103
|
+
resolved.azure = scope.azure;
|
|
96
104
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
105
|
+
const job = {
|
|
106
|
+
// `<project>/<repository>`, WITHOUT the organization -- exactly as a gitlab job's `repo` omits the
|
|
107
|
+
// instance. A deployment has one AZURE_ORG_URL, so the org is constant across every azure job here
|
|
108
|
+
// and repeating it in a pause-window scope and a run-record label would be noise, not information.
|
|
109
|
+
repo: resolved.repo,
|
|
110
|
+
// The structured triple the API paths need, alongside the human-readable label -- the same split
|
|
111
|
+
// gitlab makes when it carries `projectId` next to `repo`.
|
|
112
|
+
azure: resolved.azure,
|
|
113
|
+
target: resolved.target,
|
|
114
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
115
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
116
|
+
// resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
|
|
117
|
+
// literal byte-identical to what it always was -- the same move filter.mjs makes.
|
|
118
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
119
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
120
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
121
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
122
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
123
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
124
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
125
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
126
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
127
|
+
// #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
|
|
128
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
129
|
+
// Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
|
|
130
|
+
...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
|
|
131
|
+
...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
|
|
132
|
+
// REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
|
|
133
|
+
// the job without its servers.
|
|
134
|
+
...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
|
|
135
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
136
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
137
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
138
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
139
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
140
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
141
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
|
|
142
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
143
|
+
// while the file reads as narrowed.
|
|
144
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
145
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
146
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
147
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
148
|
+
// basename(folder) restraint already exists to prevent.
|
|
149
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
150
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
151
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
152
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
153
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
154
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
155
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
156
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
157
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
158
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
159
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
160
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
161
|
+
...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
|
|
162
|
+
// Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
|
|
163
|
+
...(resolved.progress === true ? { progress: true } : {}),
|
|
164
|
+
...(resolved.statusComment === true ? { statusComment: true } : {}),
|
|
165
|
+
// Issue #645: durable runs, present only when armed, the same rule.
|
|
166
|
+
...(resolved.recover === true ? { recover: true } : {}),
|
|
167
|
+
// Issue #646: steering a running job, present only when armed, the same rule.
|
|
168
|
+
...(resolved.steer === true ? { steer: true } : {}),
|
|
169
|
+
// Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
|
|
170
|
+
// route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
|
|
171
|
+
// inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
|
|
172
|
+
// inside `trigger`, which is copied verbatim into /job/event.json.
|
|
173
|
+
...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
|
|
174
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
175
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
176
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
177
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
178
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
179
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
180
|
+
trigger: {
|
|
181
|
+
event,
|
|
182
|
+
action: resolved.action,
|
|
183
|
+
deliveryId,
|
|
184
|
+
// A GUID when the delivery carried one, else a HASH of the address -- never the address itself.
|
|
185
|
+
// A work-item payload offers nothing but `"Display Name <email>"`, and this object is copied
|
|
186
|
+
// verbatim into /job/event.json and summarised into the durable run record, both of which are
|
|
187
|
+
// PII-free BY CONSTRUCTION. Hashing keeps "the same actor twice" answerable without putting
|
|
188
|
+
// somebody's email in a file the agent reads and an operator greps -- the same move
|
|
189
|
+
// `session-key.mjs` makes for branch names.
|
|
190
|
+
sender: { id: hasId ? actor.id : stableActorId(actor.email) },
|
|
191
|
+
matched: resolved.matched,
|
|
192
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
return { enqueue: true, job };
|
|
196
|
+
}
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
/**
|
|
@@ -212,15 +237,19 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
212
237
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
213
238
|
repository: rule.repository,
|
|
214
239
|
packages: rule.packages,
|
|
215
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
216
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
240
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
241
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
217
242
|
skillsDir: rule.skillsDir,
|
|
218
243
|
secrets: rule.secrets,
|
|
219
244
|
secretsProfile: rule.secretsProfile,
|
|
220
245
|
waitFor: rule.waitFor,
|
|
221
246
|
instructions: rule.instructions,
|
|
222
247
|
resume: rule.resume,
|
|
223
|
-
awaitInput: rule.awaitInput,
|
|
248
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
249
|
+
progress: rule.progress,
|
|
250
|
+
statusComment: rule.statusComment,
|
|
251
|
+
recover: rule.recover,
|
|
252
|
+
steer: rule.steer,
|
|
224
253
|
replicas: rule.replicas,
|
|
225
254
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
226
255
|
target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
|
|
@@ -228,7 +257,7 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
/** Comment path, on either a work item or a pull request. The authority gate above has already run. */
|
|
231
|
-
function routeComment(subset, triggers, knownFlows, targetType) {
|
|
260
|
+
function routeComment(subset, triggers, knownFlows, targetType, assumeParked = false) {
|
|
232
261
|
const phrase = triggers.comment?.phrase;
|
|
233
262
|
const body = subset.comment;
|
|
234
263
|
const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
|
|
@@ -237,8 +266,17 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
237
266
|
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
238
267
|
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
239
268
|
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
240
|
-
const
|
|
269
|
+
const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && targetType === "issue";
|
|
270
|
+
const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.tags));
|
|
241
271
|
if (!phraseHit && !parkedReply) {
|
|
272
|
+
// Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
|
|
273
|
+
// the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
|
|
274
|
+
// same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
|
|
275
|
+
// run that is parking on this target, or drops it exactly as today.
|
|
276
|
+
if (parkedCandidate) {
|
|
277
|
+
const held = routeComment(subset, triggers, knownFlows, targetType, true);
|
|
278
|
+
if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
|
|
279
|
+
}
|
|
242
280
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
243
281
|
}
|
|
244
282
|
// On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
|
|
@@ -265,18 +303,27 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
265
303
|
...(command !== undefined ? { command } : { flow }),
|
|
266
304
|
repository: triggers.comment.repository,
|
|
267
305
|
packages: triggers.comment.packages,
|
|
268
|
-
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
|
|
269
|
-
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
306
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
|
|
307
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
|
|
270
308
|
skillsDir: triggers.comment.skillsDir,
|
|
271
309
|
secrets: triggers.comment.secrets,
|
|
272
310
|
secretsProfile: triggers.comment.secretsProfile,
|
|
273
311
|
waitFor: triggers.comment.waitFor,
|
|
274
312
|
instructions: triggers.comment.instructions,
|
|
275
313
|
resume: triggers.comment.resume,
|
|
276
|
-
awaitInput: triggers.comment.awaitInput,
|
|
314
|
+
awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
|
|
315
|
+
progress: triggers.comment.progress,
|
|
316
|
+
statusComment: triggers.comment.statusComment,
|
|
317
|
+
recover: triggers.comment.recover,
|
|
318
|
+
steer: triggers.comment.steer,
|
|
277
319
|
replicas: triggers.comment.replicas,
|
|
278
320
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
279
321
|
...(parkedReply && { parkedReply: true }),
|
|
322
|
+
// Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
|
|
323
|
+
// phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
|
|
324
|
+
// `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
|
|
325
|
+
// ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
|
|
326
|
+
...(phraseHit && command === undefined && triggers.comment.steer === true && { steerMode: steerModeOf(body, phrase) }),
|
|
280
327
|
// No author_association: Azure has none, and the authority that admitted this comment was resolved
|
|
281
328
|
// from the graph, not read off the body.
|
|
282
329
|
comment: { body },
|
|
@@ -301,15 +348,19 @@ function routePullRequest(subset, triggers, action) {
|
|
|
301
348
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
302
349
|
repository: rule.repository,
|
|
303
350
|
packages: rule.packages,
|
|
304
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
305
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
351
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
352
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
306
353
|
skillsDir: rule.skillsDir,
|
|
307
354
|
secrets: rule.secrets,
|
|
308
355
|
secretsProfile: rule.secretsProfile,
|
|
309
356
|
waitFor: rule.waitFor,
|
|
310
357
|
instructions: rule.instructions,
|
|
311
358
|
resume: rule.resume,
|
|
312
|
-
awaitInput: rule.awaitInput,
|
|
359
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
360
|
+
progress: rule.progress,
|
|
361
|
+
statusComment: rule.statusComment,
|
|
362
|
+
recover: rule.recover,
|
|
363
|
+
steer: rule.steer,
|
|
313
364
|
replicas: rule.replicas,
|
|
314
365
|
matched: { index: rule.index, type: "pull_request", action },
|
|
315
366
|
target: {
|