@gr8ful/spf 0.13.0 → 0.14.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 +16 -0
- package/assets/skill/references/config.md +1 -0
- package/dist/cli/commands/doctor.js +9 -0
- package/dist/cli/interview.js +9 -0
- package/dist/core/data_types.d.ts +3 -0
- package/dist/core/data_types.js +7 -0
- package/dist/core/notify/notifier.d.ts +3 -2
- package/dist/core/notify/notifier.js +32 -3
- package/dist/core/utils.d.ts +5 -5
- package/dist/core/utils.js +14 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -839,6 +839,22 @@ Getting each channel's URL:
|
|
|
839
839
|
- **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
|
|
840
840
|
n8n, Zapier, a homegrown receiver.
|
|
841
841
|
|
|
842
|
+
One webhook, many `spf` instances: if several repos' `spf watch` (or
|
|
843
|
+
`spf run`) all post to the same Slack/Teams/webhook endpoint, set
|
|
844
|
+
`notifications.project` to a short label so messages from each are
|
|
845
|
+
distinguishable — it prefixes every title (`[api] watch: reconcileOrphans
|
|
846
|
+
error`) and adds a `repo` field. Left unset, it falls back to `watch.repo`,
|
|
847
|
+
so most `spf watch` setups need nothing extra; set it explicitly when
|
|
848
|
+
`watch.repo` is blank or two watched repos share a basename.
|
|
849
|
+
|
|
850
|
+
```yaml
|
|
851
|
+
notifications:
|
|
852
|
+
events: attention
|
|
853
|
+
project: api # optional; defaults to watch.repo
|
|
854
|
+
channels:
|
|
855
|
+
- kind: slack
|
|
856
|
+
```
|
|
857
|
+
|
|
842
858
|
Delivery never blocks or fails a run: an unconfigured/misconfigured channel
|
|
843
859
|
is skipped with one warning, and a failed POST logs one line and is
|
|
844
860
|
swallowed — never changes a run's exit code. One thing worth knowing under
|
|
@@ -277,6 +277,7 @@ default; adding it is entirely additive.
|
|
|
277
277
|
|---|---|---|
|
|
278
278
|
| `events` | `"off"` \| `"errors"` \| `"attention"` \| `"all"` | The whole filter, narrowest to widest. `off` (default): nothing. `errors`: only true failures — failed runs/phases (`run_failed`/`phase_failed`), `watch_error`. `attention`: `errors` plus anything needing a human but not itself a failure — a blocked issue (`issue_blocked`) or a spec needing feedback (`spec_needs_feedback`). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus `attention` and `errors`. |
|
|
279
279
|
| `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
|
|
280
|
+
| `project` | string | Label prefixed onto every outbound title (`[api] watch: ...`) and added as a `repo` field — for disambiguating multiple `spf` instances that share one webhook. Default `""`, which falls back to `watch.repo`. |
|
|
280
281
|
| `channels[]` | array | See below. |
|
|
281
282
|
|
|
282
283
|
`channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
|
|
@@ -1023,6 +1023,15 @@ export async function doctorCommand(argv) {
|
|
|
1023
1023
|
const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
|
|
1024
1024
|
check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
|
|
1025
1025
|
}
|
|
1026
|
+
// See `NotificationsConfigSchema.project`'s doc comment / `resolveNotifier`'s
|
|
1027
|
+
// same fallback — informational only (never fails doctor), since an
|
|
1028
|
+
// unset project tag is harmless unless this webhook ends up shared.
|
|
1029
|
+
if (cfg.notifications.channels.length > 0) {
|
|
1030
|
+
const project = cfg.notifications.project.trim() || cfg.watch.repo.trim();
|
|
1031
|
+
check(report, "notifications.project", true, project
|
|
1032
|
+
? `tag: "${project}"`
|
|
1033
|
+
: "not set, and watch.repo is empty — outbound messages won't carry a repo/project tag; if this webhook is ever shared across multiple spf instances, set notifications.project to tell them apart", project ? "info" : "warn");
|
|
1034
|
+
}
|
|
1026
1035
|
}
|
|
1027
1036
|
return finish(report, flags["json"]);
|
|
1028
1037
|
}
|
package/dist/cli/interview.js
CHANGED
|
@@ -527,6 +527,15 @@ export async function runInterview(asker, ctx) {
|
|
|
527
527
|
const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
|
|
528
528
|
if (timeoutMs !== "5000")
|
|
529
529
|
notifications.timeout_ms = Number(timeoutMs);
|
|
530
|
+
// Only matters when one Slack/Teams/webhook endpoint is shared across
|
|
531
|
+
// several `spf` instances — defaults to watch.repo (resolveNotifier's
|
|
532
|
+
// own fallback), so most single-repo setups can just accept it and
|
|
533
|
+
// write nothing extra into the generated config.
|
|
534
|
+
const repoDefault = watch?.repo || "";
|
|
535
|
+
asker.note("Tags every outbound title/field so messages are distinguishable if this webhook is shared across repos — defaults to watch.repo.");
|
|
536
|
+
const project = await asker.text("notifications.project", { default: repoDefault });
|
|
537
|
+
if (project !== repoDefault)
|
|
538
|
+
notifications.project = project;
|
|
530
539
|
}
|
|
531
540
|
}
|
|
532
541
|
// ── 6. review + confirm ─────────────────────────────────────────────────────
|
|
@@ -1099,6 +1099,7 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
|
|
|
1099
1099
|
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
1100
1100
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1101
1101
|
}, undefined>, undefined>, () => never[]>;
|
|
1102
|
+
readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1102
1103
|
}, undefined>;
|
|
1103
1104
|
export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
|
|
1104
1105
|
/**
|
|
@@ -1443,6 +1444,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1443
1444
|
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
1444
1445
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1445
1446
|
}, undefined>, undefined>, () => never[]>;
|
|
1447
|
+
readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1446
1448
|
}, undefined>, () => {
|
|
1447
1449
|
events: "all" | "attention" | "errors" | "off";
|
|
1448
1450
|
timeout_ms: number;
|
|
@@ -1452,6 +1454,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1452
1454
|
events?: "all" | "attention" | "errors" | "off" | null | undefined;
|
|
1453
1455
|
name: string;
|
|
1454
1456
|
}[];
|
|
1457
|
+
project: string;
|
|
1455
1458
|
}>;
|
|
1456
1459
|
readonly review: v.OptionalSchema<v.ObjectSchema<{
|
|
1457
1460
|
readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
package/dist/core/data_types.js
CHANGED
|
@@ -834,6 +834,13 @@ export const NotificationsConfigSchema = v.object({
|
|
|
834
834
|
events: v.optional(NotifyScopeSchema, "off"),
|
|
835
835
|
timeout_ms: v.optional(v.number(), 5_000),
|
|
836
836
|
channels: v.optional(v.array(NotifyChannelSchema), () => []),
|
|
837
|
+
// Prefixed onto every outbound title (e.g. "[api] watch: ..." ) and added
|
|
838
|
+
// as a `repo` field, so one Slack/Teams/webhook endpoint shared across
|
|
839
|
+
// several `spf watch` instances (one per repo) can tell them apart.
|
|
840
|
+
// Empty means "derive from watch.repo" (see `resolveNotifier`) — set this
|
|
841
|
+
// explicitly only when that repo string isn't a good enough label, e.g.
|
|
842
|
+
// it's blank, or two watched repos share a basename.
|
|
843
|
+
project: v.optional(v.string(), ""),
|
|
837
844
|
});
|
|
838
845
|
/**
|
|
839
846
|
* `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
|
|
@@ -18,13 +18,14 @@ export declare class Notifier {
|
|
|
18
18
|
private readonly timeoutMs;
|
|
19
19
|
private readonly dryRun;
|
|
20
20
|
private readonly log;
|
|
21
|
+
private readonly project;
|
|
21
22
|
private pending;
|
|
22
23
|
constructor(channels: Array<{
|
|
23
24
|
channel: NotificationChannel;
|
|
24
25
|
scope: NotifyScope;
|
|
25
|
-
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
|
|
26
|
+
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void, project?: string);
|
|
26
27
|
/** Sync, fire-and-forget — every call site is sync and must stay that way. */
|
|
27
|
-
send(
|
|
28
|
+
send(rawEvent: NotifyEvent): void;
|
|
28
29
|
/** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
|
|
29
30
|
flush(): Promise<void>;
|
|
30
31
|
/**
|
|
@@ -25,15 +25,21 @@ export class Notifier {
|
|
|
25
25
|
timeoutMs;
|
|
26
26
|
dryRun;
|
|
27
27
|
log;
|
|
28
|
+
project;
|
|
28
29
|
pending = new Set();
|
|
29
|
-
constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m)
|
|
30
|
+
constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m),
|
|
31
|
+
// See `NotificationsConfigSchema.project`'s doc comment — empty disables
|
|
32
|
+
// tagging entirely, so a single-repo setup's outbound JSON is unchanged.
|
|
33
|
+
project = "") {
|
|
30
34
|
this.channels = channels;
|
|
31
35
|
this.timeoutMs = timeoutMs;
|
|
32
36
|
this.dryRun = dryRun;
|
|
33
37
|
this.log = log;
|
|
38
|
+
this.project = project;
|
|
34
39
|
}
|
|
35
40
|
/** Sync, fire-and-forget — every call site is sync and must stay that way. */
|
|
36
|
-
send(
|
|
41
|
+
send(rawEvent) {
|
|
42
|
+
const event = this.project ? tagEvent(rawEvent, this.project) : rawEvent;
|
|
37
43
|
for (const { channel, scope } of this.channels) {
|
|
38
44
|
if (!scopeAllows(scope, event.level))
|
|
39
45
|
continue;
|
|
@@ -79,6 +85,23 @@ export class Notifier {
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Prefixes `event.title` with `[project]` and adds a `repo` field, so a
|
|
90
|
+
* webhook shared by several `spf watch` instances reads clearly even
|
|
91
|
+
* collapsed to one line (Slack's notification/thread-list view only shows
|
|
92
|
+
* `title`, never `fields`). Skips the field when one's already there
|
|
93
|
+
* (`watch_started`/`watch_stopped` already carry their own `repo` field —
|
|
94
|
+
* see `cli/commands/watch.ts`) rather than emit a duplicate key with the
|
|
95
|
+
* same value.
|
|
96
|
+
*/
|
|
97
|
+
function tagEvent(event, project) {
|
|
98
|
+
const hasRepo = event.fields.some(([key]) => key === "repo");
|
|
99
|
+
return {
|
|
100
|
+
...event,
|
|
101
|
+
title: `[${project}] ${event.title}`,
|
|
102
|
+
fields: hasRepo ? event.fields : [["repo", project], ...event.fields],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
82
105
|
function makeChannel(kind, url, name) {
|
|
83
106
|
switch (kind) {
|
|
84
107
|
case "slack":
|
|
@@ -121,7 +144,13 @@ export function resolveNotifier(cfg, opts = {}) {
|
|
|
121
144
|
}
|
|
122
145
|
if (resolved.length === 0)
|
|
123
146
|
return null;
|
|
124
|
-
|
|
147
|
+
// See `NotificationsConfigSchema.project`'s doc comment: an explicit
|
|
148
|
+
// `notifications.project` wins; otherwise fall back to `watch.repo`
|
|
149
|
+
// (present whenever `spf watch` is what's sending — the common case for a
|
|
150
|
+
// shared webhook), and empty (a one-off `spf run` with no watch.repo set)
|
|
151
|
+
// disables tagging, same as today.
|
|
152
|
+
const project = nc.project.trim() || cfg.watch.repo.trim();
|
|
153
|
+
const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log, project);
|
|
125
154
|
LIVE.push(notifier);
|
|
126
155
|
return notifier;
|
|
127
156
|
}
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -24,11 +24,11 @@ export declare function newId(length?: number): string;
|
|
|
24
24
|
* retry before it's allowed to throw. Node's global `fetch` (undici) pools
|
|
25
25
|
* keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
|
|
26
26
|
* Bitbucket) close idle ones from their end, which surfaces here as
|
|
27
|
-
* `ECONNRESET` the next time a long-lived poller (`spf
|
|
28
|
-
* a stale-socket race, not a real problem with the
|
|
29
|
-
* error codes that mean "the transport failed," never
|
|
30
|
-
* (a 4xx/5xx response is not a thrown error here, and
|
|
31
|
-
* on the first attempt so callers see it immediately).
|
|
27
|
+
* `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
|
|
28
|
+
* watch`) reuses one — a stale-socket race, not a real problem with the
|
|
29
|
+
* request. Only retries error codes that mean "the transport failed," never
|
|
30
|
+
* an HTTP error status (a 4xx/5xx response is not a thrown error here, and
|
|
31
|
+
* must keep surfacing on the first attempt so callers see it immediately).
|
|
32
32
|
*/
|
|
33
33
|
export declare function fetchRetryTransient(input: string, init?: RequestInit): Promise<Response>;
|
|
34
34
|
/** `process.kill(pid, 0)` sends no signal — it throws iff `pid` isn't running (or isn't ours to signal), the standard Node liveness probe. */
|
package/dist/core/utils.js
CHANGED
|
@@ -31,18 +31,25 @@ export function operatorEnv() {
|
|
|
31
31
|
export function newId(length = 8) {
|
|
32
32
|
return randomBytes(Math.floor(length / 2)).toString("hex");
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Transport-level blips worth one silent retry — see `fetchRetryTransient`
|
|
36
|
+
* below. `UND_ERR_SOCKET` is undici's own code for the same stale-keep-alive
|
|
37
|
+
* race as `ECONNRESET` (the far end closes a pooled socket between calls);
|
|
38
|
+
* without it here, every `spf watch` poll tick that lands on one of those
|
|
39
|
+
* sockets surfaces as a bare, undiagnosable "fetch failed (UND_ERR_SOCKET)"
|
|
40
|
+
* notification instead of being retried away like its `ECONNRESET` sibling.
|
|
41
|
+
*/
|
|
42
|
+
const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_SOCKET"]);
|
|
36
43
|
/**
|
|
37
44
|
* `fetch`, but a transport-level blip on the FIRST attempt gets one silent
|
|
38
45
|
* retry before it's allowed to throw. Node's global `fetch` (undici) pools
|
|
39
46
|
* keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
|
|
40
47
|
* Bitbucket) close idle ones from their end, which surfaces here as
|
|
41
|
-
* `ECONNRESET` the next time a long-lived poller (`spf
|
|
42
|
-
* a stale-socket race, not a real problem with the
|
|
43
|
-
* error codes that mean "the transport failed," never
|
|
44
|
-
* (a 4xx/5xx response is not a thrown error here, and
|
|
45
|
-
* on the first attempt so callers see it immediately).
|
|
48
|
+
* `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
|
|
49
|
+
* watch`) reuses one — a stale-socket race, not a real problem with the
|
|
50
|
+
* request. Only retries error codes that mean "the transport failed," never
|
|
51
|
+
* an HTTP error status (a 4xx/5xx response is not a thrown error here, and
|
|
52
|
+
* must keep surfacing on the first attempt so callers see it immediately).
|
|
46
53
|
*/
|
|
47
54
|
export async function fetchRetryTransient(input, init) {
|
|
48
55
|
try {
|