@edgehero/pi-dispatch-receiver 1.5.0 → 3.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 +41 -0
- package/package.json +7 -4
- package/src/boot-retry.mjs +144 -0
- package/src/cli.mjs +12 -5
- package/src/config.mjs +30 -5
- package/src/filter-azure.mjs +25 -4
- package/src/filter-forgejo.mjs +29 -6
- package/src/filter-gitlab.mjs +29 -6
- package/src/filter.mjs +32 -6
- package/src/poller-config.mjs +6 -1
- package/src/poller.mjs +85 -15
- package/src/receiver.mjs +44 -19
- package/src/start.mjs +234 -85
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @edgehero/pi-dispatch-receiver
|
|
2
|
+
|
|
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 as a self hosted background service.
|
|
5
|
+
|
|
6
|
+
The receiver turns forge activity into queued jobs for the
|
|
7
|
+
[worker](https://www.npmjs.com/package/@edgehero/pi-dispatch). It reads the same `triggers.json` as the
|
|
8
|
+
worker, checks each delivery, and queues at most one job per event (one per replica when a trigger sets
|
|
9
|
+
`run.replicas`). It never runs a job itself.
|
|
10
|
+
|
|
11
|
+
## Two ways to run it
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @edgehero/pi-dispatch-receiver # serve: the webhook receiver (the default)
|
|
15
|
+
npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub with your own credential
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **`serve`** verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks (at `/`, `/gitlab`, `/forgejo`
|
|
19
|
+
and `/azure`). It listens on all interfaces by default (`RECEIVER_BIND=0.0.0.0`); set
|
|
20
|
+
`RECEIVER_BIND=127.0.0.1` when a local reverse proxy or tunnel does the public exposure. `pi-dispatch
|
|
21
|
+
service install --receiver`
|
|
22
|
+
runs it as a service, and a container image is published as `ghcr.io/edgehero/pi-dispatch-receiver`.
|
|
23
|
+
- **`poll`** fetches GitHub issue events, comments and pull requests about once a minute, over TLS, with
|
|
24
|
+
the same gates and the same queue. A new poller starts from now and never replays old labels.
|
|
25
|
+
|
|
26
|
+
The receiver checks each delivery's signature, the bot loop guard, the forge's own permission check and
|
|
27
|
+
dedup. The worker then applies quiet hours, the image preflight, branch protection and the spend caps.
|
|
28
|
+
Who may fire a trigger is decided by your forge. See
|
|
29
|
+
[`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) for each forge's rule.
|
|
30
|
+
|
|
31
|
+
Configuration comes from the environment (the worker's `.env.example` lists all of it). The main settings
|
|
32
|
+
are `WEBHOOK_SECRET`, `VALKEY_URL` and `VALKEY_PASSWORD`, `PI_TRIGGERS_FILE`, `RECEIVER_PORT` and
|
|
33
|
+
`RECEIVER_BIND`, `POLL_REPOS` with `POLL_INTERVAL_SECONDS` for polling, and each forge's token and
|
|
34
|
+
webhook secret (see its setup guide). It needs `@edgehero/pi-dispatch` of the same major
|
|
35
|
+
version, which it installs as a dependency.
|
|
36
|
+
|
|
37
|
+
Setup guides: [GitHub](https://github.com/edgehero/pi-dispatch/blob/main/docs/github.md),
|
|
38
|
+
[GitLab](https://github.com/edgehero/pi-dispatch/blob/main/docs/gitlab.md),
|
|
39
|
+
[Forgejo](https://github.com/edgehero/pi-dispatch/blob/main/docs/forgejo.md),
|
|
40
|
+
[Azure DevOps](https://github.com/edgehero/pi-dispatch/blob/main/docs/azure-devops.md),
|
|
41
|
+
[polling](https://github.com/edgehero/pi-dispatch/blob/main/docs/polling.md). MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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": [
|
|
7
7
|
"pi",
|
|
8
8
|
"pi-coding-agent",
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"gitea",
|
|
17
17
|
"azure-devops",
|
|
18
18
|
"job-queue",
|
|
19
|
-
"bullmq"
|
|
19
|
+
"bullmq",
|
|
20
|
+
"docker",
|
|
21
|
+
"podman",
|
|
22
|
+
"polling"
|
|
20
23
|
],
|
|
21
24
|
"license": "MIT",
|
|
22
25
|
"author": "Rob Boerman",
|
|
@@ -45,7 +48,7 @@
|
|
|
45
48
|
},
|
|
46
49
|
"dependencies": {
|
|
47
50
|
"@octokit/webhooks": "14.2.0",
|
|
48
|
-
"@edgehero/pi-dispatch": "^
|
|
51
|
+
"@edgehero/pi-dispatch": "^3.0.0",
|
|
49
52
|
"bullmq": "5.80.4",
|
|
50
53
|
"ioredis": "5.11.1"
|
|
51
54
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded in-process retry for the boot's HARD-FAIL identity resolutions (issue #318).
|
|
3
|
+
*
|
|
4
|
+
* Issue #316 made a transient identity failure at boot exit 1 so the supervisor restarts through a
|
|
5
|
+
* forge outage -- and then the shipped unit bounded that recovery at about 25 seconds
|
|
6
|
+
* (`RestartSec=5` against `StartLimitBurst=5`), while launchd and nssm bound it not at all. A forge
|
|
7
|
+
* restart takes minutes, so the receiver still ended up stopped, with the bound a property of
|
|
8
|
+
* whichever supervisor the operator happened to install. This module moves the bound INTO the
|
|
9
|
+
* process: one window, read from `RECEIVER_IDENTITY_RETRY_SECONDS` (config.mjs), identical under
|
|
10
|
+
* systemd, launchd, nssm and compose.
|
|
11
|
+
*
|
|
12
|
+
* What it retries is decided by the tag, nothing else. A `piDispatchConfig` throw is a determinate
|
|
13
|
+
* refusal and rethrows SAME-TICK -- no log line, no sleep, so `entryExitCode` still maps it to
|
|
14
|
+
* EXIT_POLICY (2) as fast as before. Everything untagged is transient by `transientError`'s own
|
|
15
|
+
* contract (worker/src/transient.mjs: the absence of the tag is the whole payload). That module is
|
|
16
|
+
* deliberately not imported -- it is not in the worker's export map, and the tag test is the
|
|
17
|
+
* sanctioned discriminator the receiver's `entryExitCode` already uses.
|
|
18
|
+
*
|
|
19
|
+
* Import-free, like the module whose rule it extends: nothing here can drag `node:fs` or a queue
|
|
20
|
+
* into a caller that has neither.
|
|
21
|
+
*
|
|
22
|
+
* The shape, and why each piece is what it is:
|
|
23
|
+
*
|
|
24
|
+
* - The per-attempt fuse is REF'D, never unref'd. The github arm runs before the queue, the server
|
|
25
|
+
* or any watcher exists, so this await can be the only thing on the event loop -- exactly
|
|
26
|
+
* `settleWithin`'s documented bare-context boundary (worker/src/start.mjs): an unref'd fuse that
|
|
27
|
+
* is the only pending handle never fires and node abandons the await silently, exit 13. Ref'd
|
|
28
|
+
* costs at most `attemptTimeoutMs` of held loop on a path that is about to exit anyway, and the
|
|
29
|
+
* inner `finally` clears it the moment the race settles, so even that is normally unreachable.
|
|
30
|
+
* - A fused-out attempt throws a plain UNTAGGED Error, by decision: a resolver that does not answer
|
|
31
|
+
* within the fuse is indistinguishable from a forge that is down, which is the transient case.
|
|
32
|
+
* None of the resolvers takes an AbortSignal, so the losing attempt itself has no cancel; its
|
|
33
|
+
* late settlement is absorbed by the race's own subscription, and the residual (a pending socket
|
|
34
|
+
* that can outlive a FINAL refusal by up to undici's header timeout) is recorded in
|
|
35
|
+
* `DES-BOOT-IDENTITY-RETRY-IN-PROCESS` -- strictly better than the unbounded hang a mute forge
|
|
36
|
+
* produced before this module existed.
|
|
37
|
+
* - The remaining window is checked BEFORE sleeping and the gap is CLAMPED to it, so no sleep timer
|
|
38
|
+
* can outlive the final attempt (issue #300's discipline: a component that stops has released what
|
|
39
|
+
* it armed) and the final attempt lands at the window's EDGE. The clamp is measured, not neatness:
|
|
40
|
+
* without it a 60s window exhausted at 35s, because the un-clamped 30s gap would have crossed the
|
|
41
|
+
* deadline and was skipped -- a forge that came back at t=50 was missed, and "retries for the
|
|
42
|
+
* whole window" was false in the letter. The clamp is also what makes an uncancellable injected
|
|
43
|
+
* `sleep` safe: every sleep awaited here runs to completion, always followed by one more attempt.
|
|
44
|
+
* - Log lines carry `err?.message` only, never a value -- the `receiver_start_failed` posture. A GitHub
|
|
45
|
+
* answer adds its status and request id (`githubFailureFields`, issue #530), never a header beyond that.
|
|
46
|
+
*
|
|
47
|
+
* Total wall clock: the helper settles within `windowMs + attemptTimeoutMs`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
|
|
51
|
+
|
|
52
|
+
// The first gap is what `RestartSec=5` gave, now in-process: a blip recovers exactly as fast as the
|
|
53
|
+
// supervisor loop it replaces. It doubles to a cap borrowed from the worker's AUTH_RETRY_COOLDOWN_MS
|
|
54
|
+
// rationale (worker/src/start.mjs): short enough to pick the forge up within one gap of it coming
|
|
55
|
+
// back, long enough that a ten-minute window opens a couple dozen identity calls, not 120.
|
|
56
|
+
export const IDENTITY_RETRY_DELAY_MS = 5_000;
|
|
57
|
+
export const IDENTITY_RETRY_DELAY_CAP_MS = 30_000;
|
|
58
|
+
// The per-attempt fuse, AUTH_RESOLVE_TIMEOUT_MS's rationale: the resolvers pass no AbortSignal and
|
|
59
|
+
// undici's default header timeout is five minutes, so without a fuse a forge that accepts and never
|
|
60
|
+
// answers would spend the whole window on one attempt.
|
|
61
|
+
export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Retry `resolve` (a nullary async identity resolution) until it answers, throws a tagged
|
|
65
|
+
* (determinate) error, or the window closes. Returns the resolution; rethrows the tagged error
|
|
66
|
+
* same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
|
|
67
|
+
* supervisor restarts into a fresh window.
|
|
68
|
+
*
|
|
69
|
+
* `now` and `sleep` are seams (CLAUDE.md: a window takes an injected clock, always; issue #284);
|
|
70
|
+
* `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
|
|
71
|
+
* the clearing rather than trust it.
|
|
72
|
+
*/
|
|
73
|
+
export async function retryIdentity(
|
|
74
|
+
resolve,
|
|
75
|
+
{
|
|
76
|
+
forge,
|
|
77
|
+
windowMs,
|
|
78
|
+
log,
|
|
79
|
+
now = Date.now,
|
|
80
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
81
|
+
setTimeoutFn = setTimeout,
|
|
82
|
+
clearTimeoutFn = clearTimeout,
|
|
83
|
+
attemptTimeoutMs = IDENTITY_ATTEMPT_TIMEOUT_MS,
|
|
84
|
+
delayMs = IDENTITY_RETRY_DELAY_MS,
|
|
85
|
+
delayCapMs = IDENTITY_RETRY_DELAY_CAP_MS,
|
|
86
|
+
} = {},
|
|
87
|
+
) {
|
|
88
|
+
// Fail loud on a windowless caller. A NaN or missing window would make the remaining-time test
|
|
89
|
+
// below never exhaust, and an unbounded boot retry is the silent no-op this project refuses. The
|
|
90
|
+
// loaders floor and validate the real value; this guard is for the caller no loader sits in front
|
|
91
|
+
// of.
|
|
92
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
93
|
+
throw new Error(`retryIdentity: windowMs must be a positive finite number, got ${windowMs}`);
|
|
94
|
+
}
|
|
95
|
+
const deadline = now() + windowMs;
|
|
96
|
+
let delay = delayMs;
|
|
97
|
+
let attempt = 0;
|
|
98
|
+
for (;;) {
|
|
99
|
+
attempt += 1;
|
|
100
|
+
try {
|
|
101
|
+
let fuse = null;
|
|
102
|
+
try {
|
|
103
|
+
// A SYNCHRONOUS throw from the resolver lands in the outer catch with no fuse armed
|
|
104
|
+
// (`fuse` is still null, and clearTimeout(null) is a no-op), which is why the fuse is
|
|
105
|
+
// armed only after `resolve()` has returned.
|
|
106
|
+
//
|
|
107
|
+
// No explicit `.catch` rides the attempt, and its absence is measured, not an oversight:
|
|
108
|
+
// `Promise.race` subscribes a reject handler to EVERY contestant, so when the fuse wins,
|
|
109
|
+
// the abandoned attempt's late rejection lands in the race's own subscription and can
|
|
110
|
+
// never surface as an unhandledRejection (pinned by this module's test). The worker's
|
|
111
|
+
// `ensureAuth` DOES need one, because it stores its promise in a map beyond the race and
|
|
112
|
+
// a cooldown-path caller can throw without ever racing it; nothing here outlives the
|
|
113
|
+
// race expression.
|
|
114
|
+
const inflight = resolve();
|
|
115
|
+
return await Promise.race([
|
|
116
|
+
inflight,
|
|
117
|
+
new Promise((_, reject) => {
|
|
118
|
+
fuse = setTimeoutFn(() => reject(new Error(`${forge} identity did not answer within ${attemptTimeoutMs}ms`)), attemptTimeoutMs);
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
} finally {
|
|
122
|
+
// The moment the race settles -- win, loss or throw -- the fuse dies. Ref'd and
|
|
123
|
+
// uncleared, it would otherwise hold the loop for the full term (issue #295's lesson,
|
|
124
|
+
// the ref'd twin).
|
|
125
|
+
clearTimeoutFn(fuse);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Determinate refusals pass through untouched and unslowed: the tag is the decision, and
|
|
129
|
+
// re-wrapping would cost the entry the piDispatchConfig mapping to EXIT_POLICY.
|
|
130
|
+
if (err?.piDispatchConfig === true) throw err;
|
|
131
|
+
const remainingMs = deadline - now();
|
|
132
|
+
if (remainingMs <= 0) {
|
|
133
|
+
log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message, ...githubFailureFields(err) });
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// Clamped, so the last gap ends exactly at the deadline and the final attempt runs at the
|
|
137
|
+
// window's edge -- see the header for the measured 35s-of-60s failure the clamp removes.
|
|
138
|
+
const gapMs = Math.min(delay, remainingMs);
|
|
139
|
+
log({ event: "identity_retry", forge, attempt, reason: err?.message, ...githubFailureFields(err), delayMs: gapMs });
|
|
140
|
+
await sleep(gapMs);
|
|
141
|
+
delay = Math.min(delay * 2, delayCapMs);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* Thin by design, mirroring worker/src/cli.mjs: recognise the command, lazy-import the real work.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { EXIT_POLICY } from "@edgehero/pi-dispatch/exit-code";
|
|
16
|
+
import { EXIT_POLICY, installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code";
|
|
17
|
+
import { isEntryModule } from "@edgehero/pi-dispatch/entry";
|
|
17
18
|
|
|
18
19
|
const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs
|
|
19
20
|
|
|
@@ -33,7 +34,11 @@ serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches an
|
|
|
33
34
|
* or touching Valkey -- and the help/unknown paths stay runnable even where the queue deps are not
|
|
34
35
|
* installed.
|
|
35
36
|
*/
|
|
36
|
-
|
|
37
|
+
// Where this command's output goes. Defaults to the real stdout, so the CLI is byte-identical; a test
|
|
38
|
+
// injects a collector instead of reassigning `process.stdout.write`. That matters because `node --test`
|
|
39
|
+
// runs each file in a child process that serialises its own results over that same stdout, so a test
|
|
40
|
+
// 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) } = {}) {
|
|
37
42
|
const cmd = argv[0];
|
|
38
43
|
|
|
39
44
|
if (cmd === undefined || cmd === "serve") {
|
|
@@ -53,7 +58,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, { st
|
|
|
53
58
|
return 0;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
write(`${USAGE}\n`);
|
|
57
62
|
return cmd === "--help" || cmd === "-h" ? 0 : 1; // asked-for help is success; a typo is not
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -70,8 +75,10 @@ export function entryExitCode(err) {
|
|
|
70
75
|
|
|
71
76
|
// Entry point when run as a bin. Kept out of the exported main so tests can call main() directly.
|
|
72
77
|
// The error line mirrors start.mjs's own entry guard: `err.message` only -- never a secret or PII.
|
|
73
|
-
// (start.mjs's guard
|
|
74
|
-
if (import.meta.url
|
|
78
|
+
// (start.mjs's guard asks whether argv[1] IS start.mjs once links resolve, so importing it from here never double-boots.)
|
|
79
|
+
if (isEntryModule(import.meta.url)) {
|
|
80
|
+
// An unhandled rejection is printed as its message alone (PR #475's review), never Node's print of the whole reason.
|
|
81
|
+
installRejectionPrinter();
|
|
75
82
|
main()
|
|
76
83
|
.then((code) => {
|
|
77
84
|
if (code) process.exitCode = code;
|
package/src/config.mjs
CHANGED
|
@@ -38,6 +38,29 @@ import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispa
|
|
|
38
38
|
// default must be the file init just told the operator it created, not a demo buried in the repo.
|
|
39
39
|
const DEFAULT_TRIGGERS_PATH = "./triggers.json";
|
|
40
40
|
|
|
41
|
+
// The transient-boot retry window (issue #318). The FLOOR is load-bearing, not a courtesy: systemd's
|
|
42
|
+
// start limit is a fixed window from the first counted start (`ratelimit_below`: elapsed past the
|
|
43
|
+
// interval resets the counter), so boots that retry for at least StartLimitIntervalSec (60s in
|
|
44
|
+
// deploy/receiver.service) exit at least window + RestartSec apart, every start opens a fresh
|
|
45
|
+
// interval, and `StartLimitBurst=5` becomes unreachable for a transient failure by systemd's own
|
|
46
|
+
// accounting. A sub-60s window would put fast exit-1s back inside one interval and re-open the ~25s
|
|
47
|
+
// bound this setting exists to remove. The default outlasts an ordinary self-hosted forge restart,
|
|
48
|
+
// and since an exhausted window exits 1 and the supervisor starts another, it bounds one CYCLE, not
|
|
49
|
+
// recovery -- while a misclassified determinate fault resurfaces in the journal every window rather
|
|
50
|
+
// than hiding.
|
|
51
|
+
export const IDENTITY_RETRY_FLOOR_SECONDS = 60;
|
|
52
|
+
export const IDENTITY_RETRY_DEFAULT_SECONDS = 600;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The boot identity-retry window in milliseconds, from `RECEIVER_IDENTITY_RETRY_SECONDS`. Parsed in
|
|
56
|
+
* ONE place for both loaders (serve and poll), so the floor cannot drift between them. `positiveInt`
|
|
57
|
+
* refuses 0, negatives, fractions and junk with the config tag (a typo'd window is configuration,
|
|
58
|
+
* exit 2); a positive value below the floor is raised to it, the POLL_INTERVAL_SECONDS house pattern.
|
|
59
|
+
*/
|
|
60
|
+
export function identityRetryWindowMs(env = process.env) {
|
|
61
|
+
return 1000 * Math.max(IDENTITY_RETRY_FLOOR_SECONDS, positiveInt(env, "RECEIVER_IDENTITY_RETRY_SECONDS", IDENTITY_RETRY_DEFAULT_SECONDS));
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
/**
|
|
42
65
|
* Parse the receiver's config from `env` (default process.env). Filesystem access is injected
|
|
43
66
|
* (`readFile`, `fileExists`) so the loader is hermetically testable and never touches disk in tests.
|
|
@@ -68,6 +91,7 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
|
|
|
68
91
|
valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379", // mirrors worker config: producer and consumer share one queue
|
|
69
92
|
port: positiveInt(env, "RECEIVER_PORT", 3000),
|
|
70
93
|
bind: env.RECEIVER_BIND ?? "0.0.0.0",
|
|
94
|
+
identityRetryWindowMs: identityRetryWindowMs(env),
|
|
71
95
|
triggers,
|
|
72
96
|
github,
|
|
73
97
|
gitlab: loadGitLabConfig(env),
|
|
@@ -270,6 +294,7 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
270
294
|
}
|
|
271
295
|
|
|
272
296
|
const parsed = parseTriggers(readFile(path, "utf8"), path); // fail-loud
|
|
297
|
+
void 0;
|
|
273
298
|
|
|
274
299
|
// Every forge gets a group whether or not the file names it, so the filter can read
|
|
275
300
|
// `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
|
|
@@ -293,9 +318,9 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
293
318
|
}
|
|
294
319
|
const group = groups[run.kind];
|
|
295
320
|
if (on.type === "label") {
|
|
296
|
-
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, 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, 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 });
|
|
297
322
|
} else if (on.type === "comment") {
|
|
298
|
-
group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, 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, 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
|
|
299
324
|
} else if (on.type === "pull_request") {
|
|
300
325
|
// The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
|
|
301
326
|
// resolved authority) and every other PR rule through the author gate, so they live in separate
|
|
@@ -304,17 +329,17 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
304
329
|
// split reads the SHARED close-word table, never a re-typed word.
|
|
305
330
|
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
306
331
|
if (closeWord !== undefined && on.action.includes(closeWord)) {
|
|
307
|
-
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, 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, 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 });
|
|
308
333
|
} else {
|
|
309
334
|
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
310
335
|
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
311
|
-
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, 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, 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 });
|
|
312
337
|
}
|
|
313
338
|
} else if (on.type === "issue") {
|
|
314
339
|
// The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
|
|
315
340
|
// loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
|
|
316
341
|
// normalized them -- the filter's close route reads them, nothing else does.
|
|
317
|
-
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, 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, 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 });
|
|
318
343
|
}
|
|
319
344
|
}
|
|
320
345
|
|
package/src/filter-azure.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule }
|
|
|
29
29
|
import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
|
|
30
30
|
|
|
31
31
|
/** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
|
|
32
|
-
const PR_ACTION_FOR = {
|
|
32
|
+
export const PR_ACTION_FOR = {
|
|
33
33
|
"git.pullrequest.created": "created",
|
|
34
34
|
"git.pullrequest.updated": "updated",
|
|
35
35
|
};
|
|
@@ -109,6 +109,24 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
109
109
|
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
110
110
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
111
111
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
112
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
113
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
114
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
115
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
116
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
117
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
118
|
+
// #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
|
|
119
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
120
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
121
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
122
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
123
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
124
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
125
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
126
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}),
|
|
127
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
128
|
+
// while the file reads as narrowed.
|
|
129
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
112
130
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
113
131
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
114
132
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -185,7 +203,8 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
185
203
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
186
204
|
repository: rule.repository,
|
|
187
205
|
packages: rule.packages,
|
|
188
|
-
image: rule.image,
|
|
206
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
207
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
189
208
|
skillsDir: rule.skillsDir,
|
|
190
209
|
secrets: rule.secrets,
|
|
191
210
|
secretsProfile: rule.secretsProfile,
|
|
@@ -229,7 +248,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
229
248
|
...(command !== undefined ? { command } : { flow }),
|
|
230
249
|
repository: triggers.comment.repository,
|
|
231
250
|
packages: triggers.comment.packages,
|
|
232
|
-
image: triggers.comment.image,
|
|
251
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
252
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
233
253
|
skillsDir: triggers.comment.skillsDir,
|
|
234
254
|
secrets: triggers.comment.secrets,
|
|
235
255
|
secretsProfile: triggers.comment.secretsProfile,
|
|
@@ -262,7 +282,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
262
282
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
263
283
|
repository: rule.repository,
|
|
264
284
|
packages: rule.packages,
|
|
265
|
-
image: rule.image,
|
|
285
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
286
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
266
287
|
skillsDir: rule.skillsDir,
|
|
267
288
|
secrets: rule.secrets,
|
|
268
289
|
secretsProfile: rule.secretsProfile,
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { findCloseRule } from "./close.mjs";
|
|
|
38
38
|
import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
|
|
39
39
|
|
|
40
40
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
41
|
-
const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
41
|
+
export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
|
|
@@ -123,6 +123,24 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
123
123
|
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
124
124
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
125
125
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
126
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
127
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
128
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
129
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
130
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
131
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
132
|
+
// #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
|
|
133
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
134
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
135
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
136
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
137
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
138
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
139
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
140
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}),
|
|
141
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
142
|
+
// while the file reads as narrowed.
|
|
143
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
126
144
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
127
145
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
128
146
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -165,7 +183,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
165
183
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
166
184
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
167
185
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
168
|
-
image: rule.image,
|
|
186
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
187
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
169
188
|
skillsDir: rule.skillsDir,
|
|
170
189
|
secrets: rule.secrets,
|
|
171
190
|
secretsProfile: rule.secretsProfile,
|
|
@@ -214,7 +233,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
214
233
|
enqueue: true,
|
|
215
234
|
...(command !== undefined ? { command } : { flow }),
|
|
216
235
|
packages: triggers.comment.packages,
|
|
217
|
-
image: triggers.comment.image,
|
|
236
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
237
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
218
238
|
skillsDir: triggers.comment.skillsDir,
|
|
219
239
|
secrets: triggers.comment.secrets,
|
|
220
240
|
secretsProfile: triggers.comment.secretsProfile,
|
|
@@ -245,7 +265,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
245
265
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
246
266
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
247
267
|
packages: rule.packages,
|
|
248
|
-
image: rule.image,
|
|
268
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
269
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
249
270
|
skillsDir: rule.skillsDir,
|
|
250
271
|
secrets: rule.secrets,
|
|
251
272
|
secretsProfile: rule.secretsProfile,
|
|
@@ -300,7 +321,8 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
300
321
|
enqueue: true,
|
|
301
322
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
302
323
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
303
|
-
image: rule.image,
|
|
324
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
325
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
304
326
|
skillsDir: rule.skillsDir,
|
|
305
327
|
secrets: rule.secrets,
|
|
306
328
|
secretsProfile: rule.secretsProfile,
|
|
@@ -336,7 +358,8 @@ function routePullRequestClose(subset, triggers, authorized) {
|
|
|
336
358
|
enqueue: true,
|
|
337
359
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
338
360
|
packages: rule.packages,
|
|
339
|
-
image: rule.image,
|
|
361
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
362
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
340
363
|
skillsDir: rule.skillsDir,
|
|
341
364
|
secrets: rule.secrets,
|
|
342
365
|
secretsProfile: rule.secretsProfile,
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import { findCloseRule } from "./close.mjs";
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
/** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
|
|
48
|
-
const MR_ACTIONS = new Set(["open", "update", "reopen", "approved"]);
|
|
48
|
+
export const MR_ACTIONS = new Set(["open", "update", "reopen", "approved"]);
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
|
|
@@ -126,6 +126,24 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
126
126
|
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
127
127
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
128
128
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
129
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
130
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
131
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
132
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
133
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
134
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
135
|
+
// #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
|
|
136
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
137
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
138
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
139
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
140
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
141
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
142
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
143
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}),
|
|
144
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
145
|
+
// while the file reads as narrowed.
|
|
146
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
129
147
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
130
148
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
131
149
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -175,7 +193,8 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
175
193
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
176
194
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
177
195
|
packages: rule.packages,
|
|
178
|
-
image: rule.image,
|
|
196
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
197
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
179
198
|
skillsDir: rule.skillsDir,
|
|
180
199
|
secrets: rule.secrets,
|
|
181
200
|
secretsProfile: rule.secretsProfile,
|
|
@@ -239,7 +258,8 @@ function mrResult(subset, rule, matched) {
|
|
|
239
258
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
240
259
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
241
260
|
packages: rule.packages,
|
|
242
|
-
image: rule.image,
|
|
261
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
262
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
243
263
|
skillsDir: rule.skillsDir,
|
|
244
264
|
secrets: rule.secrets,
|
|
245
265
|
secretsProfile: rule.secretsProfile,
|
|
@@ -285,7 +305,8 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
285
305
|
enqueue: true,
|
|
286
306
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
287
307
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
288
|
-
image: rule.image,
|
|
308
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
309
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
289
310
|
skillsDir: rule.skillsDir,
|
|
290
311
|
secrets: rule.secrets,
|
|
291
312
|
secretsProfile: rule.secretsProfile,
|
|
@@ -319,7 +340,8 @@ function routeMergeRequestClose(subset, triggers, authorized) {
|
|
|
319
340
|
enqueue: true,
|
|
320
341
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
321
342
|
packages: rule.packages,
|
|
322
|
-
image: rule.image,
|
|
343
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
344
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
323
345
|
skillsDir: rule.skillsDir,
|
|
324
346
|
secrets: rule.secrets,
|
|
325
347
|
secretsProfile: rule.secretsProfile,
|
|
@@ -371,7 +393,8 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
371
393
|
enqueue: true,
|
|
372
394
|
...(command !== undefined ? { command } : { flow }),
|
|
373
395
|
packages: triggers.comment.packages,
|
|
374
|
-
image: triggers.comment.image,
|
|
396
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
397
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
375
398
|
skillsDir: triggers.comment.skillsDir,
|
|
376
399
|
secrets: triggers.comment.secrets,
|
|
377
400
|
secretsProfile: triggers.comment.secretsProfile,
|