@edgehero/pi-dispatch-receiver 1.5.0 → 2.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 +141 -0
- package/src/cli.mjs +12 -5
- package/src/config.mjs +30 -5
- package/src/filter-azure.mjs +12 -4
- package/src/filter-forgejo.mjs +14 -6
- package/src/filter-gitlab.mjs +14 -6
- package/src/filter.mjs +16 -5
- package/src/poller-config.mjs +6 -1
- package/src/poller.mjs +83 -14
- package/src/receiver.mjs +44 -19
- package/src/start.mjs +231 -84
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": "2.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": "^2.0.0",
|
|
49
52
|
"bullmq": "5.80.4",
|
|
50
53
|
"ioredis": "5.11.1"
|
|
51
54
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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.
|
|
45
|
+
*
|
|
46
|
+
* Total wall clock: the helper settles within `windowMs + attemptTimeoutMs`.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
// The first gap is what `RestartSec=5` gave, now in-process: a blip recovers exactly as fast as the
|
|
50
|
+
// supervisor loop it replaces. It doubles to a cap borrowed from the worker's AUTH_RETRY_COOLDOWN_MS
|
|
51
|
+
// rationale (worker/src/start.mjs): short enough to pick the forge up within one gap of it coming
|
|
52
|
+
// back, long enough that a ten-minute window opens a couple dozen identity calls, not 120.
|
|
53
|
+
export const IDENTITY_RETRY_DELAY_MS = 5_000;
|
|
54
|
+
export const IDENTITY_RETRY_DELAY_CAP_MS = 30_000;
|
|
55
|
+
// The per-attempt fuse, AUTH_RESOLVE_TIMEOUT_MS's rationale: the resolvers pass no AbortSignal and
|
|
56
|
+
// undici's default header timeout is five minutes, so without a fuse a forge that accepts and never
|
|
57
|
+
// answers would spend the whole window on one attempt.
|
|
58
|
+
export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Retry `resolve` (a nullary async identity resolution) until it answers, throws a tagged
|
|
62
|
+
* (determinate) error, or the window closes. Returns the resolution; rethrows the tagged error
|
|
63
|
+
* same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
|
|
64
|
+
* supervisor restarts into a fresh window.
|
|
65
|
+
*
|
|
66
|
+
* `now` and `sleep` are seams (CLAUDE.md: a window takes an injected clock, always; issue #284);
|
|
67
|
+
* `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
|
|
68
|
+
* the clearing rather than trust it.
|
|
69
|
+
*/
|
|
70
|
+
export async function retryIdentity(
|
|
71
|
+
resolve,
|
|
72
|
+
{
|
|
73
|
+
forge,
|
|
74
|
+
windowMs,
|
|
75
|
+
log,
|
|
76
|
+
now = Date.now,
|
|
77
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
78
|
+
setTimeoutFn = setTimeout,
|
|
79
|
+
clearTimeoutFn = clearTimeout,
|
|
80
|
+
attemptTimeoutMs = IDENTITY_ATTEMPT_TIMEOUT_MS,
|
|
81
|
+
delayMs = IDENTITY_RETRY_DELAY_MS,
|
|
82
|
+
delayCapMs = IDENTITY_RETRY_DELAY_CAP_MS,
|
|
83
|
+
} = {},
|
|
84
|
+
) {
|
|
85
|
+
// Fail loud on a windowless caller. A NaN or missing window would make the remaining-time test
|
|
86
|
+
// below never exhaust, and an unbounded boot retry is the silent no-op this project refuses. The
|
|
87
|
+
// loaders floor and validate the real value; this guard is for the caller no loader sits in front
|
|
88
|
+
// of.
|
|
89
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
90
|
+
throw new Error(`retryIdentity: windowMs must be a positive finite number, got ${windowMs}`);
|
|
91
|
+
}
|
|
92
|
+
const deadline = now() + windowMs;
|
|
93
|
+
let delay = delayMs;
|
|
94
|
+
let attempt = 0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
attempt += 1;
|
|
97
|
+
try {
|
|
98
|
+
let fuse = null;
|
|
99
|
+
try {
|
|
100
|
+
// A SYNCHRONOUS throw from the resolver lands in the outer catch with no fuse armed
|
|
101
|
+
// (`fuse` is still null, and clearTimeout(null) is a no-op), which is why the fuse is
|
|
102
|
+
// armed only after `resolve()` has returned.
|
|
103
|
+
//
|
|
104
|
+
// No explicit `.catch` rides the attempt, and its absence is measured, not an oversight:
|
|
105
|
+
// `Promise.race` subscribes a reject handler to EVERY contestant, so when the fuse wins,
|
|
106
|
+
// the abandoned attempt's late rejection lands in the race's own subscription and can
|
|
107
|
+
// never surface as an unhandledRejection (pinned by this module's test). The worker's
|
|
108
|
+
// `ensureAuth` DOES need one, because it stores its promise in a map beyond the race and
|
|
109
|
+
// a cooldown-path caller can throw without ever racing it; nothing here outlives the
|
|
110
|
+
// race expression.
|
|
111
|
+
const inflight = resolve();
|
|
112
|
+
return await Promise.race([
|
|
113
|
+
inflight,
|
|
114
|
+
new Promise((_, reject) => {
|
|
115
|
+
fuse = setTimeoutFn(() => reject(new Error(`${forge} identity did not answer within ${attemptTimeoutMs}ms`)), attemptTimeoutMs);
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
} finally {
|
|
119
|
+
// The moment the race settles -- win, loss or throw -- the fuse dies. Ref'd and
|
|
120
|
+
// uncleared, it would otherwise hold the loop for the full term (issue #295's lesson,
|
|
121
|
+
// the ref'd twin).
|
|
122
|
+
clearTimeoutFn(fuse);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Determinate refusals pass through untouched and unslowed: the tag is the decision, and
|
|
126
|
+
// re-wrapping would cost the entry the piDispatchConfig mapping to EXIT_POLICY.
|
|
127
|
+
if (err?.piDispatchConfig === true) throw err;
|
|
128
|
+
const remainingMs = deadline - now();
|
|
129
|
+
if (remainingMs <= 0) {
|
|
130
|
+
log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message });
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
// Clamped, so the last gap ends exactly at the deadline and the final attempt runs at the
|
|
134
|
+
// window's edge -- see the header for the measured 35s-of-60s failure the clamp removes.
|
|
135
|
+
const gapMs = Math.min(delay, remainingMs);
|
|
136
|
+
log({ event: "identity_retry", forge, attempt, reason: err?.message, delayMs: gapMs });
|
|
137
|
+
await sleep(gapMs);
|
|
138
|
+
delay = Math.min(delay * 2, delayCapMs);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
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, 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, 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, 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, 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, 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,14 @@ 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 } : {}),
|
|
112
120
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
113
121
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
114
122
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -185,7 +193,7 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
185
193
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
186
194
|
repository: rule.repository,
|
|
187
195
|
packages: rule.packages,
|
|
188
|
-
image: rule.image,
|
|
196
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
189
197
|
skillsDir: rule.skillsDir,
|
|
190
198
|
secrets: rule.secrets,
|
|
191
199
|
secretsProfile: rule.secretsProfile,
|
|
@@ -229,7 +237,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
229
237
|
...(command !== undefined ? { command } : { flow }),
|
|
230
238
|
repository: triggers.comment.repository,
|
|
231
239
|
packages: triggers.comment.packages,
|
|
232
|
-
image: triggers.comment.image,
|
|
240
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
233
241
|
skillsDir: triggers.comment.skillsDir,
|
|
234
242
|
secrets: triggers.comment.secrets,
|
|
235
243
|
secretsProfile: triggers.comment.secretsProfile,
|
|
@@ -262,7 +270,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
262
270
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
263
271
|
repository: rule.repository,
|
|
264
272
|
packages: rule.packages,
|
|
265
|
-
image: rule.image,
|
|
273
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
266
274
|
skillsDir: rule.skillsDir,
|
|
267
275
|
secrets: rule.secrets,
|
|
268
276
|
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,14 @@ 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 } : {}),
|
|
126
134
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
127
135
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
128
136
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -165,7 +173,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
165
173
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
166
174
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
167
175
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
168
|
-
image: rule.image,
|
|
176
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
169
177
|
skillsDir: rule.skillsDir,
|
|
170
178
|
secrets: rule.secrets,
|
|
171
179
|
secretsProfile: rule.secretsProfile,
|
|
@@ -214,7 +222,7 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
214
222
|
enqueue: true,
|
|
215
223
|
...(command !== undefined ? { command } : { flow }),
|
|
216
224
|
packages: triggers.comment.packages,
|
|
217
|
-
image: triggers.comment.image,
|
|
225
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
218
226
|
skillsDir: triggers.comment.skillsDir,
|
|
219
227
|
secrets: triggers.comment.secrets,
|
|
220
228
|
secretsProfile: triggers.comment.secretsProfile,
|
|
@@ -245,7 +253,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
245
253
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
246
254
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
247
255
|
packages: rule.packages,
|
|
248
|
-
image: rule.image,
|
|
256
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
249
257
|
skillsDir: rule.skillsDir,
|
|
250
258
|
secrets: rule.secrets,
|
|
251
259
|
secretsProfile: rule.secretsProfile,
|
|
@@ -300,7 +308,7 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
300
308
|
enqueue: true,
|
|
301
309
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
302
310
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
303
|
-
image: rule.image,
|
|
311
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
304
312
|
skillsDir: rule.skillsDir,
|
|
305
313
|
secrets: rule.secrets,
|
|
306
314
|
secretsProfile: rule.secretsProfile,
|
|
@@ -336,7 +344,7 @@ function routePullRequestClose(subset, triggers, authorized) {
|
|
|
336
344
|
enqueue: true,
|
|
337
345
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
338
346
|
packages: rule.packages,
|
|
339
|
-
image: rule.image,
|
|
347
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
340
348
|
skillsDir: rule.skillsDir,
|
|
341
349
|
secrets: rule.secrets,
|
|
342
350
|
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,14 @@ 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 } : {}),
|
|
129
137
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
130
138
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
131
139
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -175,7 +183,7 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
175
183
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
176
184
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
177
185
|
packages: rule.packages,
|
|
178
|
-
image: rule.image,
|
|
186
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
179
187
|
skillsDir: rule.skillsDir,
|
|
180
188
|
secrets: rule.secrets,
|
|
181
189
|
secretsProfile: rule.secretsProfile,
|
|
@@ -239,7 +247,7 @@ function mrResult(subset, rule, matched) {
|
|
|
239
247
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
240
248
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
241
249
|
packages: rule.packages,
|
|
242
|
-
image: rule.image,
|
|
250
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
243
251
|
skillsDir: rule.skillsDir,
|
|
244
252
|
secrets: rule.secrets,
|
|
245
253
|
secretsProfile: rule.secretsProfile,
|
|
@@ -285,7 +293,7 @@ function routeIssueClose(subset, triggers, authorized) {
|
|
|
285
293
|
enqueue: true,
|
|
286
294
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
287
295
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
288
|
-
image: rule.image,
|
|
296
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
289
297
|
skillsDir: rule.skillsDir,
|
|
290
298
|
secrets: rule.secrets,
|
|
291
299
|
secretsProfile: rule.secretsProfile,
|
|
@@ -319,7 +327,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
|
|
|
319
327
|
enqueue: true,
|
|
320
328
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
321
329
|
packages: rule.packages,
|
|
322
|
-
image: rule.image,
|
|
330
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
323
331
|
skillsDir: rule.skillsDir,
|
|
324
332
|
secrets: rule.secrets,
|
|
325
333
|
secretsProfile: rule.secretsProfile,
|
|
@@ -371,7 +379,7 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
371
379
|
enqueue: true,
|
|
372
380
|
...(command !== undefined ? { command } : { flow }),
|
|
373
381
|
packages: triggers.comment.packages,
|
|
374
|
-
image: triggers.comment.image,
|
|
382
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
375
383
|
skillsDir: triggers.comment.skillsDir,
|
|
376
384
|
secrets: triggers.comment.secrets,
|
|
377
385
|
secretsProfile: triggers.comment.secretsProfile,
|
package/src/filter.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule }
|
|
|
50
50
|
|
|
51
51
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
52
52
|
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
53
|
-
const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
53
|
+
export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
54
54
|
const PR_AUTO_ACTIONS = new Set(["opened", "synchronize", "reopened"]);
|
|
55
55
|
// The triggers.json word for a submitted review, and the raw action GitHub sends on the
|
|
56
56
|
// `pull_request_review` event. They differ on purpose -- see the routing block below.
|
|
@@ -145,6 +145,17 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
|
|
|
145
145
|
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
146
146
|
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
147
147
|
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
148
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
149
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
150
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
151
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
152
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
153
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
154
|
+
// #291. A separate spread, the backend line's rule one field over: folded into a neighbour's
|
|
155
|
+
// conditional, a trigger that named only exclusions would have them silently dropped here -- and a
|
|
156
|
+
// dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
|
|
157
|
+
// refuses a misspelling for, arriving through the plumbing instead of the spelling.
|
|
158
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
148
159
|
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
149
160
|
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
150
161
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
@@ -199,7 +210,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
199
210
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
200
211
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
201
212
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
202
|
-
image: rule.image,
|
|
213
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
203
214
|
skillsDir: rule.skillsDir,
|
|
204
215
|
secrets: rule.secrets,
|
|
205
216
|
secretsProfile: rule.secretsProfile,
|
|
@@ -259,7 +270,7 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
259
270
|
// The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
|
|
260
271
|
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
261
272
|
packages: triggers.comment.packages,
|
|
262
|
-
image: triggers.comment.image,
|
|
273
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
|
|
263
274
|
skillsDir: triggers.comment.skillsDir,
|
|
264
275
|
secrets: triggers.comment.secrets,
|
|
265
276
|
secretsProfile: triggers.comment.secretsProfile,
|
|
@@ -357,7 +368,7 @@ function routePullRequest(subset, triggers, action) {
|
|
|
357
368
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
358
369
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
359
370
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
360
|
-
image: rule.image,
|
|
371
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
361
372
|
skillsDir: rule.skillsDir,
|
|
362
373
|
secrets: rule.secrets,
|
|
363
374
|
secretsProfile: rule.secretsProfile,
|
|
@@ -473,7 +484,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
|
473
484
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
474
485
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
475
486
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
476
|
-
image: rule.image,
|
|
487
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
|
|
477
488
|
skillsDir: rule.skillsDir,
|
|
478
489
|
secrets: rule.secrets,
|
|
479
490
|
secretsProfile: rule.secretsProfile,
|
package/src/poller-config.mjs
CHANGED
|
@@ -46,7 +46,8 @@ const POLL_INTERVAL_DEFAULT_SECONDS = 60;
|
|
|
46
46
|
* Parse the poller's config from `env`. Filesystem access is injected (`readFile`, `fileExists`) and
|
|
47
47
|
* forwarded to the receiver loader, so the whole thing is hermetically testable.
|
|
48
48
|
*
|
|
49
|
-
* Returns `{ valkeyUrl, triggers, github, repos, intervalSeconds }` -- and
|
|
49
|
+
* Returns `{ valkeyUrl, triggers, github, repos, intervalSeconds, identityRetryWindowMs }` -- and
|
|
50
|
+
* deliberately nothing else.
|
|
50
51
|
* `webhookSecret`, `port`, `bind` and the other-forge blocks are receiver-only concerns: the poller
|
|
51
52
|
* binds no port and speaks only GitHub (the other forges' producers stay webhook-armed).
|
|
52
53
|
* `repos === null` means "discover from the App installation each boot" and is only reachable when
|
|
@@ -76,6 +77,10 @@ export function loadPollerConfig(env = process.env, { readFile = readFileSync, f
|
|
|
76
77
|
github: base.github, // validated by the shared loadGitHubAuth, exactly as serve validates it
|
|
77
78
|
repos,
|
|
78
79
|
intervalSeconds: Math.max(POLL_INTERVAL_FLOOR_SECONDS, positiveInt(env, "POLL_INTERVAL_SECONDS", POLL_INTERVAL_DEFAULT_SECONDS)),
|
|
80
|
+
// Passed THROUGH the receiver loader rather than re-parsed: one parse, one floor (issue #318).
|
|
81
|
+
// The poller's boot identity gate is the same hard-fail gate serve has, so it retries under the
|
|
82
|
+
// same window -- a pure-polling deployment loses deliveries to a stopped process just as surely.
|
|
83
|
+
identityRetryWindowMs: base.identityRetryWindowMs,
|
|
79
84
|
};
|
|
80
85
|
}
|
|
81
86
|
|