@gr8ful/spf 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,6 +151,124 @@ test("declining the final confirm returns null — nothing to write", async () =
151
151
  const result = await runInterview(asker, ctx);
152
152
  assert.equal(result, null);
153
153
  });
154
+ test("notifications: declining the gate leaves config.notifications undefined", async () => {
155
+ const ctx = gatherContext(dir, new Map());
156
+ const asker = createFakeAsker({
157
+ select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
158
+ confirm: {
159
+ 'Add a "typecheck"': false,
160
+ 'Add a "lint"': false,
161
+ 'Add a "build"': false,
162
+ 'Add a "test"': false,
163
+ "Enable spf watch": false,
164
+ "Send notifications": false,
165
+ "Configure advanced": false,
166
+ "Write .spf": true,
167
+ },
168
+ });
169
+ const result = await runInterview(asker, ctx);
170
+ assert.ok(result);
171
+ const config = result.config;
172
+ assert.equal(config.notifications, undefined);
173
+ });
174
+ test("notifications: accepting collects the scope, a channel, and its webhook env key", async () => {
175
+ const ctx = gatherContext(dir, new Map());
176
+ const asker = createFakeAsker({
177
+ select: {
178
+ "backend runs": "claude_code",
179
+ "Model (Claude": "sonnet",
180
+ Authentication: "login",
181
+ "Notify on": "all",
182
+ Channel: "slack",
183
+ },
184
+ confirm: {
185
+ 'Add a "typecheck"': false,
186
+ 'Add a "lint"': false,
187
+ 'Add a "build"': false,
188
+ 'Add a "test"': false,
189
+ "Enable spf watch": false,
190
+ "Send notifications": true,
191
+ "Add another channel": false,
192
+ "Configure advanced": false,
193
+ "Write .spf": true,
194
+ },
195
+ secret: { SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXXX" },
196
+ });
197
+ const result = await runInterview(asker, ctx);
198
+ assert.ok(result);
199
+ const config = result.config;
200
+ assert.equal(config.notifications.events, "all");
201
+ assert.deepEqual(config.notifications.channels, [{ kind: "slack", webhook_url_env: "SLACK_WEBHOOK_URL" }]);
202
+ assert.equal(result.env.SLACK_WEBHOOK_URL, "https://hooks.slack.com/services/T000/B000/XXXX");
203
+ assert.ok(result.envExampleKeys.includes("SLACK_WEBHOOK_URL"));
204
+ // The written document must still merge and validate cleanly, same
205
+ // pipeline `spf doctor` runs.
206
+ const configPath = mergedConfigPath();
207
+ const { stringify } = await import("yaml");
208
+ writeFileSync(configPath, stringify(config));
209
+ const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
210
+ assert.equal(cfg.notifications.events, "all");
211
+ assert.equal(cfg.notifications.channels[0].kind, "slack");
212
+ });
213
+ test("customize models per agent: declining keeps today's behavior — only the three pinned agents, all matching defaults.model", async () => {
214
+ const ctx = gatherContext(dir, new Map());
215
+ const asker = createFakeAsker({
216
+ select: { "backend runs": "claude_code", "Model (Claude": "opus", Authentication: "login" },
217
+ confirm: {
218
+ 'Add a "typecheck"': false,
219
+ 'Add a "lint"': false,
220
+ 'Add a "build"': false,
221
+ 'Add a "test"': false,
222
+ "Enable spf watch": false,
223
+ "Customize models per agent": false,
224
+ "Send notifications": false,
225
+ "Configure advanced": false,
226
+ "Write .spf": true,
227
+ },
228
+ });
229
+ const result = await runInterview(asker, ctx);
230
+ assert.ok(result);
231
+ const config = result.config;
232
+ assert.deepEqual(config.agents.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
233
+ for (const a of config.agents)
234
+ assert.equal(a.model, "opus");
235
+ });
236
+ test("customize models per agent: accepting patches the pinned three and appends builder/scout", async () => {
237
+ const ctx = gatherContext(dir, new Map());
238
+ assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "reviewer", "scout"]);
239
+ const asker = createFakeAsker({
240
+ select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
241
+ text: {
242
+ " planner": "opus",
243
+ " builder": "sonnet",
244
+ " scout": "haiku",
245
+ " reviewer": "opus",
246
+ " documenter": "sonnet",
247
+ },
248
+ confirm: {
249
+ 'Add a "typecheck"': false,
250
+ 'Add a "lint"': false,
251
+ 'Add a "build"': false,
252
+ 'Add a "test"': false,
253
+ "Enable spf watch": false,
254
+ "Customize models per agent": true,
255
+ "Send notifications": false,
256
+ "Configure advanced": false,
257
+ "Write .spf": true,
258
+ },
259
+ });
260
+ const result = await runInterview(asker, ctx);
261
+ assert.ok(result);
262
+ const config = result.config;
263
+ const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
264
+ assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku" });
265
+ const configPath = mergedConfigPath();
266
+ const { stringify } = await import("yaml");
267
+ writeFileSync(configPath, stringify(config));
268
+ const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
269
+ validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
270
+ assert.equal(cfg.agents.find((a) => a.name === "scout").model, "haiku");
271
+ });
154
272
  test("an existing .env value is offered back as the default when a secret is left blank", async () => {
155
273
  const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
156
274
  const asker = createFakeAsker({
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The notifier: the `events` filter matrix, per-channel scope override, a
3
+ * real webhook POST against a local `node:http` receiver (not a `fetch`
4
+ * mock — proves the actual bytes on the wire), a rejecting channel never
5
+ * propagating, `flush()` actually awaiting pending sends, and an unset env
6
+ * var degrading to a warning instead of a crash.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { createServer } from "node:http";
11
+ import * as v from "valibot";
12
+ import { Notifier, resolveNotifier, DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
13
+ import { SFConfigSchema } from "../core/data_types.js";
14
+ function baseConfig(overrides = {}) {
15
+ return v.parse(SFConfigSchema, { notifications: { events: "errors", channels: [], ...overrides } });
16
+ }
17
+ function infoEvent() {
18
+ return { kind: "run_started", level: "info", title: "run started", fields: [] };
19
+ }
20
+ function errorEvent() {
21
+ return { kind: "run_failed", level: "error", title: "run failed", fields: [] };
22
+ }
23
+ class RecordingChannel {
24
+ received = [];
25
+ label = "recording";
26
+ async send(event) {
27
+ this.received.push(event);
28
+ }
29
+ }
30
+ class RejectingChannel {
31
+ label = "rejecting";
32
+ async send() {
33
+ throw new Error("boom");
34
+ }
35
+ }
36
+ class BlockedChannel {
37
+ label = "blocked";
38
+ release;
39
+ gate = new Promise((resolve) => (this.release = resolve));
40
+ async send() {
41
+ await this.gate;
42
+ }
43
+ }
44
+ test("events: off never sends, regardless of level", () => {
45
+ const channel = new RecordingChannel();
46
+ const notifier = new Notifier([{ channel, scope: "off" }], 1000, false, () => { });
47
+ notifier.send(infoEvent());
48
+ notifier.send(errorEvent());
49
+ assert.deepEqual(channel.received, []);
50
+ });
51
+ test("events: errors sends only level=error", () => {
52
+ const channel = new RecordingChannel();
53
+ const notifier = new Notifier([{ channel, scope: "errors" }], 1000, false, () => { });
54
+ notifier.send(infoEvent());
55
+ notifier.send(errorEvent());
56
+ assert.equal(channel.received.length, 1);
57
+ assert.equal(channel.received[0].level, "error");
58
+ });
59
+ test("events: all sends both info and error", () => {
60
+ const channel = new RecordingChannel();
61
+ const notifier = new Notifier([{ channel, scope: "all" }], 1000, false, () => { });
62
+ notifier.send(infoEvent());
63
+ notifier.send(errorEvent());
64
+ assert.equal(channel.received.length, 2);
65
+ });
66
+ test("a per-channel scope overrides the top-level scope", () => {
67
+ const loud = new RecordingChannel();
68
+ const quiet = new RecordingChannel();
69
+ const notifier = new Notifier([
70
+ { channel: loud, scope: "all" },
71
+ { channel: quiet, scope: "errors" },
72
+ ], 1000, false, () => { });
73
+ notifier.send(infoEvent());
74
+ assert.equal(loud.received.length, 1);
75
+ assert.equal(quiet.received.length, 0);
76
+ });
77
+ test("dry-run logs and sends nothing to any channel", () => {
78
+ const channel = new RecordingChannel();
79
+ const logs = [];
80
+ const notifier = new Notifier([{ channel, scope: "all" }], 1000, true, (m) => logs.push(m));
81
+ notifier.send(infoEvent());
82
+ assert.deepEqual(channel.received, []);
83
+ assert.equal(logs.length, 1);
84
+ assert.match(logs[0], /would notify/);
85
+ });
86
+ test("a rejecting channel logs one line and never throws or blocks other channels", async () => {
87
+ const rejecting = new RejectingChannel();
88
+ const ok = new RecordingChannel();
89
+ const logs = [];
90
+ const notifier = new Notifier([
91
+ { channel: rejecting, scope: "all" },
92
+ { channel: ok, scope: "all" },
93
+ ], 1000, false, (m) => logs.push(m));
94
+ assert.doesNotThrow(() => notifier.send(infoEvent()));
95
+ await notifier.flush();
96
+ assert.equal(ok.received.length, 1);
97
+ assert.equal(logs.length, 1);
98
+ assert.match(logs[0], /rejecting notification failed: boom/);
99
+ });
100
+ test("flush() actually awaits pending sends before returning", async () => {
101
+ const blocked = new BlockedChannel();
102
+ const notifier = new Notifier([{ channel: blocked, scope: "all" }], 1000, false, () => { });
103
+ notifier.send(infoEvent());
104
+ let flushed = false;
105
+ const flushPromise = notifier.flush().then(() => {
106
+ flushed = true;
107
+ });
108
+ await new Promise((r) => setTimeout(r, 20));
109
+ assert.equal(flushed, false, "flush must not resolve before the pending send does");
110
+ blocked.release();
111
+ await flushPromise;
112
+ assert.equal(flushed, true);
113
+ });
114
+ test("resolveNotifier returns null when events is off", () => {
115
+ const cfg = baseConfig({ events: "off", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK" }] });
116
+ assert.equal(resolveNotifier(cfg), null);
117
+ });
118
+ test("resolveNotifier returns null when no channels are configured", () => {
119
+ const cfg = baseConfig({ events: "all", channels: [] });
120
+ assert.equal(resolveNotifier(cfg), null);
121
+ });
122
+ test("resolveNotifier skips a channel whose env var is unset, with one warning naming the key — never throws", () => {
123
+ delete process.env["SPF_TEST_WEBHOOK_UNSET"];
124
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK_UNSET" }] });
125
+ const logs = [];
126
+ const notifier = resolveNotifier(cfg, { log: (m) => logs.push(m) });
127
+ assert.equal(notifier, null);
128
+ assert.equal(logs.length, 1);
129
+ assert.match(logs[0], /SPF_TEST_WEBHOOK_UNSET is not set/);
130
+ });
131
+ test("resolveNotifier falls back to the kind's default env key when webhook_url_env is empty", () => {
132
+ process.env["SLACK_WEBHOOK_URL"] = "http://127.0.0.1:1/unused";
133
+ try {
134
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "slack" }] });
135
+ const notifier = resolveNotifier(cfg);
136
+ assert.notEqual(notifier, null);
137
+ assert.equal(DEFAULT_NOTIFY_ENV_KEY["slack"], "SLACK_WEBHOOK_URL");
138
+ }
139
+ finally {
140
+ delete process.env["SLACK_WEBHOOK_URL"];
141
+ }
142
+ });
143
+ test("a webhook channel posts the real NotifyEvent JSON over the wire to a local receiver", async () => {
144
+ let receivedBody = "";
145
+ let receivedContentType = "";
146
+ const server = createServer((req, res) => {
147
+ receivedContentType = req.headers["content-type"] ?? "";
148
+ let body = "";
149
+ req.on("data", (chunk) => (body += chunk));
150
+ req.on("end", () => {
151
+ receivedBody = body;
152
+ res.writeHead(200);
153
+ res.end("ok");
154
+ });
155
+ });
156
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
157
+ const port = server.address().port;
158
+ const envKey = "SPF_TEST_WEBHOOK_LIVE";
159
+ process.env[envKey] = `http://127.0.0.1:${port}/hook`;
160
+ try {
161
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: envKey }] });
162
+ const notifier = resolveNotifier(cfg);
163
+ assert.notEqual(notifier, null);
164
+ const event = { kind: "issue_claimed", level: "info", title: "issue 42 claimed", fields: [["issue", "42"]] };
165
+ notifier.send(event);
166
+ await notifier.flush();
167
+ assert.equal(receivedContentType, "application/json");
168
+ assert.deepEqual(JSON.parse(receivedBody), event);
169
+ }
170
+ finally {
171
+ delete process.env[envKey];
172
+ await new Promise((resolve) => server.close(() => resolve()));
173
+ }
174
+ });
@@ -1,7 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
- import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
4
+ import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans, tick } from "../core/watch.js";
5
5
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
6
6
  class FakeProvider {
7
7
  entries = new Map();
@@ -96,9 +96,15 @@ function makeDeps(provider, codeHost, overrides = {}) {
96
96
  dryRun: false,
97
97
  runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
98
98
  log: () => { },
99
+ notify: () => { },
99
100
  ...overrides,
100
101
  };
101
102
  }
103
+ /** Collects every `deps.notify(...)` call, for asserting kinds/levels without a real channel. */
104
+ function collectNotifications() {
105
+ const events = [];
106
+ return { events, notify: (event) => events.push(event) };
107
+ }
102
108
  async function waitUntil(predicate, timeoutMs = 2000) {
103
109
  const start = Date.now();
104
110
  while (!predicate()) {
@@ -281,3 +287,110 @@ test("finishReviews: a still-open PR leaves the issue in review", async () => {
281
287
  assert.equal(provider.transitions.length, 0);
282
288
  assert.equal(provider.entries.get("42").state, "review");
283
289
  });
290
+ // ── notifications ────────────────────────────────────────────────────────────
291
+ test("claim -> PR opened -> merged fires issue_claimed, pr_opened, then issue_done (all info-level)", async () => {
292
+ const provider = new FakeProvider();
293
+ provider.addIssue("50", "Add a /health endpoint");
294
+ const codeHost = new FakeCodeHost();
295
+ const state = createWatchState();
296
+ const { events, notify } = collectNotifications();
297
+ const deps = makeDeps(provider, codeHost, { notify });
298
+ await claimNewWork(deps, state);
299
+ await waitUntil(() => state.inflight.size === 0);
300
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened"]);
301
+ assert.ok(events.every((e) => e.level === "info"));
302
+ codeHost.prs.set(1000, { merged: true, state: "closed", ciStatus: "success" });
303
+ await finishReviews(deps);
304
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened", "issue_done"]);
305
+ assert.equal(events.at(-1).level, "info");
306
+ });
307
+ test("a rejected chain run fires issue_blocked at error level", async () => {
308
+ const provider = new FakeProvider();
309
+ provider.addIssue("51", "Flaky feature");
310
+ const codeHost = new FakeCodeHost();
311
+ const state = createWatchState();
312
+ const { events, notify } = collectNotifications();
313
+ const deps = makeDeps(provider, codeHost, {
314
+ notify,
315
+ runChain: async () => ({ accepted: false, adwId: "issue-51", detail: "build-test failed" }),
316
+ });
317
+ await claimNewWork(deps, state);
318
+ await waitUntil(() => state.inflight.size === 0);
319
+ const blocked = events.filter((e) => e.kind === "issue_blocked");
320
+ assert.equal(blocked.length, 1);
321
+ assert.equal(blocked[0].level, "error");
322
+ assert.equal(blocked[0].detail, "build-test failed");
323
+ });
324
+ test("an accepted run with nothing committed fires issue_blocked", async () => {
325
+ const provider = new FakeProvider();
326
+ provider.addIssue("52", "No-op request");
327
+ const codeHost = new FakeCodeHost();
328
+ const state = createWatchState();
329
+ const { events, notify } = collectNotifications();
330
+ const deps = makeDeps(provider, codeHost, { notify, worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
331
+ await claimNewWork(deps, state);
332
+ await waitUntil(() => state.inflight.size === 0);
333
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "issue_blocked"]);
334
+ assert.match(events[1].detail ?? "", /no committed changes/);
335
+ });
336
+ test("an exception mid-runIssue fires watch_error at error level", async () => {
337
+ const provider = new FakeProvider();
338
+ provider.addIssue("53", "Explodes");
339
+ const codeHost = new FakeCodeHost();
340
+ const state = createWatchState();
341
+ const { events, notify } = collectNotifications();
342
+ const deps = makeDeps(provider, codeHost, {
343
+ notify,
344
+ runChain: async () => {
345
+ throw new Error("kaboom");
346
+ },
347
+ });
348
+ await claimNewWork(deps, state);
349
+ await waitUntil(() => state.inflight.size === 0);
350
+ const errors = events.filter((e) => e.kind === "watch_error");
351
+ assert.equal(errors.length, 1);
352
+ assert.equal(errors[0].level, "error");
353
+ assert.match(errors[0].detail ?? "", /kaboom/);
354
+ });
355
+ test("finishReviews: a closed-without-merging PR fires issue_blocked", async () => {
356
+ const provider = new FakeProvider();
357
+ provider.addIssue("54", "rejected", "review", { pr: 700, branch: "spf-watch/54-x" });
358
+ const codeHost = new FakeCodeHost();
359
+ codeHost.prs.set(700, { merged: false, state: "closed", ciStatus: "failure" });
360
+ const { events, notify } = collectNotifications();
361
+ await finishReviews(makeDeps(provider, codeHost, { notify }));
362
+ assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
363
+ assert.equal(events[0].level, "error");
364
+ });
365
+ test("reconcileOrphans: giving up past the retry cap fires issue_blocked", async () => {
366
+ const provider = new FakeProvider();
367
+ provider.addIssue("55", "orphaned, no marker", "working", { attempt: 2 }); // one more push exceeds MAX_ORPHAN_ATTEMPTS (2)
368
+ const codeHost = new FakeCodeHost();
369
+ const state = createWatchState();
370
+ const { events, notify } = collectNotifications();
371
+ await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
372
+ assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
373
+ assert.match(events[0].detail ?? "", /Gave up after 2 orphaned attempts/);
374
+ });
375
+ test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only the terminal give-up does", async () => {
376
+ const provider = new FakeProvider();
377
+ provider.addIssue("56", "orphaned, resumable", "working", { pr: 800, branch: "spf-watch/56-x" });
378
+ const codeHost = new FakeCodeHost();
379
+ codeHost.prs.set(800, { merged: false, state: "open", ciStatus: "pending" });
380
+ const state = createWatchState();
381
+ const { events, notify } = collectNotifications();
382
+ await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
383
+ assert.deepEqual(events, []);
384
+ });
385
+ test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
386
+ const provider = new FakeProvider();
387
+ const codeHost = new FakeCodeHost();
388
+ const state = createWatchState();
389
+ const { events, notify } = collectNotifications();
390
+ provider.listEligible = async () => {
391
+ throw new Error("listEligible exploded");
392
+ };
393
+ await tick(makeDeps(provider, codeHost, { notify }), state);
394
+ assert.deepEqual(events.map((e) => e.kind), ["watch_error"]);
395
+ assert.match(events[0].detail ?? "", /listEligible exploded/);
396
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",