@sweny-ai/core 0.1.30 → 0.1.32

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.
@@ -0,0 +1,664 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { collectCredentials, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, } from "./init.js";
7
+ // ── detectGitRemote ────────────────────────────────────────────────────
8
+ describe("detectGitRemote", () => {
9
+ const tmpDirs = [];
10
+ function makeTempRepo(gitConfigContent) {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-init-test-"));
12
+ tmpDirs.push(dir);
13
+ const gitDir = path.join(dir, ".git");
14
+ fs.mkdirSync(gitDir, { recursive: true });
15
+ fs.writeFileSync(path.join(gitDir, "config"), gitConfigContent);
16
+ return dir;
17
+ }
18
+ afterEach(() => {
19
+ for (const d of tmpDirs) {
20
+ fs.rmSync(d, { recursive: true, force: true });
21
+ }
22
+ tmpDirs.length = 0;
23
+ });
24
+ it("detects GitHub HTTPS remote", () => {
25
+ const cwd = makeTempRepo(`
26
+ [remote "origin"]
27
+ \turl = https://github.com/acme/widgets.git
28
+ \tfetch = +refs/heads/*:refs/remotes/origin/*
29
+ `);
30
+ const info = detectGitRemote(cwd);
31
+ expect(info).toEqual({
32
+ provider: "github",
33
+ remote: "github.com/acme/widgets",
34
+ });
35
+ });
36
+ it("detects GitHub SSH remote", () => {
37
+ const cwd = makeTempRepo(`
38
+ [remote "origin"]
39
+ \turl = git@github.com:acme/widgets.git
40
+ \tfetch = +refs/heads/*:refs/remotes/origin/*
41
+ `);
42
+ const info = detectGitRemote(cwd);
43
+ expect(info).toEqual({
44
+ provider: "github",
45
+ remote: "github.com/acme/widgets",
46
+ });
47
+ });
48
+ it("detects GitLab HTTPS remote", () => {
49
+ const cwd = makeTempRepo(`
50
+ [core]
51
+ \tbare = false
52
+ [remote "origin"]
53
+ \turl = https://gitlab.com/team/project.git
54
+ \tfetch = +refs/heads/*:refs/remotes/origin/*
55
+ `);
56
+ const info = detectGitRemote(cwd);
57
+ expect(info).toEqual({
58
+ provider: "gitlab",
59
+ remote: "gitlab.com/team/project",
60
+ });
61
+ });
62
+ it("returns null when no .git directory exists", () => {
63
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-init-test-"));
64
+ tmpDirs.push(dir);
65
+ expect(detectGitRemote(dir)).toBeNull();
66
+ });
67
+ it("returns null for unknown host (bitbucket)", () => {
68
+ const cwd = makeTempRepo(`
69
+ [remote "origin"]
70
+ \turl = git@bitbucket.org:acme/widgets.git
71
+ `);
72
+ expect(detectGitRemote(cwd)).toBeNull();
73
+ });
74
+ it("handles HTTPS URLs without .git suffix", () => {
75
+ const cwd = makeTempRepo(`
76
+ [remote "origin"]
77
+ \turl = https://github.com/acme/widgets
78
+ `);
79
+ const info = detectGitRemote(cwd);
80
+ expect(info).toEqual({
81
+ provider: "github",
82
+ remote: "github.com/acme/widgets",
83
+ });
84
+ });
85
+ });
86
+ // ── collectCredentials ─────────────────────────────────────────────────
87
+ describe("collectCredentials", () => {
88
+ it("always includes ANTHROPIC_API_KEY", () => {
89
+ const creds = collectCredentials({
90
+ sourceControl: "github",
91
+ observability: null,
92
+ issueTracker: "github-issues",
93
+ notification: "console",
94
+ githubAction: false,
95
+ cronExpression: null,
96
+ });
97
+ expect(creds.map((c) => c.key)).toContain("ANTHROPIC_API_KEY");
98
+ });
99
+ it("includes provider-specific credentials", () => {
100
+ const creds = collectCredentials({
101
+ sourceControl: "github",
102
+ observability: "datadog",
103
+ issueTracker: "linear",
104
+ notification: "slack",
105
+ githubAction: true,
106
+ cronExpression: "0 8 * * 1",
107
+ });
108
+ const keys = creds.map((c) => c.key);
109
+ expect(keys).toContain("GITHUB_TOKEN");
110
+ expect(keys).toContain("DD_API_KEY");
111
+ expect(keys).toContain("DD_APP_KEY");
112
+ expect(keys).toContain("DD_SITE");
113
+ expect(keys).toContain("LINEAR_API_KEY");
114
+ expect(keys).toContain("LINEAR_TEAM_ID");
115
+ expect(keys).toContain("SLACK_BOT_TOKEN");
116
+ });
117
+ it("deduplicates credentials (github + github-issues)", () => {
118
+ const creds = collectCredentials({
119
+ sourceControl: "github",
120
+ observability: null,
121
+ issueTracker: "github-issues",
122
+ notification: "console",
123
+ githubAction: false,
124
+ cronExpression: null,
125
+ });
126
+ const keys = creds.map((c) => c.key);
127
+ const githubTokenCount = keys.filter((k) => k === "GITHUB_TOKEN").length;
128
+ expect(githubTokenCount).toBe(1);
129
+ });
130
+ it("handles empty credential providers (console, github-issues)", () => {
131
+ const creds = collectCredentials({
132
+ sourceControl: "github",
133
+ observability: null,
134
+ issueTracker: "github-issues",
135
+ notification: "console",
136
+ githubAction: false,
137
+ cronExpression: null,
138
+ });
139
+ expect(creds).toHaveLength(2);
140
+ });
141
+ });
142
+ // ── PROVIDER_CREDENTIALS table sanity ──────────────────────────────────
143
+ describe("PROVIDER_CREDENTIALS", () => {
144
+ it("has entries for all expected providers", () => {
145
+ const expected = [
146
+ "github",
147
+ "gitlab",
148
+ "datadog",
149
+ "sentry",
150
+ "betterstack",
151
+ "newrelic",
152
+ "cloudwatch",
153
+ "github-issues",
154
+ "linear",
155
+ "jira",
156
+ "console",
157
+ "slack",
158
+ "discord",
159
+ "teams",
160
+ "webhook",
161
+ ];
162
+ for (const name of expected) {
163
+ expect(PROVIDER_CREDENTIALS).toHaveProperty(name);
164
+ }
165
+ });
166
+ it("github-issues and console have empty credential arrays", () => {
167
+ expect(PROVIDER_CREDENTIALS["github-issues"]).toEqual([]);
168
+ expect(PROVIDER_CREDENTIALS["console"]).toEqual([]);
169
+ });
170
+ });
171
+ // ── buildSwenyYml ──────────────────────────────────────────────────────
172
+ describe("buildSwenyYml", () => {
173
+ it("includes all selected providers", () => {
174
+ const yml = buildSwenyYml({
175
+ sourceControl: "github",
176
+ observability: "datadog",
177
+ issueTracker: "linear",
178
+ notification: "slack",
179
+ githubAction: true,
180
+ cronExpression: "0 8 * * 1",
181
+ });
182
+ expect(yml).toContain("source-control-provider: github");
183
+ expect(yml).toContain("observability-provider: datadog");
184
+ expect(yml).toContain("issue-tracker-provider: linear");
185
+ expect(yml).toContain("notification-provider: slack");
186
+ });
187
+ it("omits observability-provider when null", () => {
188
+ const yml = buildSwenyYml({
189
+ sourceControl: "github",
190
+ observability: null,
191
+ issueTracker: "github-issues",
192
+ notification: "slack",
193
+ githubAction: false,
194
+ cronExpression: null,
195
+ });
196
+ expect(yml).not.toContain("observability-provider");
197
+ });
198
+ it("omits notification-provider when console (default)", () => {
199
+ const yml = buildSwenyYml({
200
+ sourceControl: "github",
201
+ observability: "sentry",
202
+ issueTracker: "github-issues",
203
+ notification: "console",
204
+ githubAction: false,
205
+ cronExpression: null,
206
+ });
207
+ expect(yml).not.toContain("notification-provider");
208
+ });
209
+ it("includes header comments", () => {
210
+ const yml = buildSwenyYml({
211
+ sourceControl: "github",
212
+ observability: null,
213
+ issueTracker: "github-issues",
214
+ notification: "console",
215
+ githubAction: false,
216
+ cronExpression: null,
217
+ });
218
+ expect(yml).toContain("# .sweny.yml");
219
+ });
220
+ });
221
+ // ── buildEnvTemplate ───────────────────────────────────────────────────
222
+ describe("buildEnvTemplate", () => {
223
+ it("includes url and hint comments", () => {
224
+ const creds = collectCredentials({
225
+ sourceControl: "github",
226
+ observability: null,
227
+ issueTracker: "github-issues",
228
+ notification: "console",
229
+ githubAction: false,
230
+ cronExpression: null,
231
+ });
232
+ const template = buildEnvTemplate(creds);
233
+ expect(template).toContain("# https://console.anthropic.com/settings/api-keys");
234
+ expect(template).toContain("# Claude API key");
235
+ expect(template).toContain("ANTHROPIC_API_KEY=");
236
+ expect(template).toContain("# https://github.com/settings/tokens");
237
+ expect(template).toContain("# repo + issues scopes");
238
+ expect(template).toContain("GITHUB_TOKEN=");
239
+ });
240
+ it("pre-fills defaults", () => {
241
+ const creds = collectCredentials({
242
+ sourceControl: "gitlab",
243
+ observability: "datadog",
244
+ issueTracker: "jira",
245
+ notification: "console",
246
+ githubAction: false,
247
+ cronExpression: null,
248
+ });
249
+ const template = buildEnvTemplate(creds);
250
+ expect(template).toContain("GITLAB_URL=https://gitlab.com");
251
+ expect(template).toContain("DD_SITE=datadoghq.com");
252
+ });
253
+ it("includes header comment", () => {
254
+ const template = buildEnvTemplate([]);
255
+ expect(template).toContain("# .env");
256
+ });
257
+ });
258
+ // ── buildActionWorkflow ────────────────────────────────────────────────
259
+ describe("buildActionWorkflow", () => {
260
+ it("generates a valid workflow with weekly cron", () => {
261
+ const creds = collectCredentials({
262
+ sourceControl: "github",
263
+ observability: null,
264
+ issueTracker: "github-issues",
265
+ notification: "console",
266
+ githubAction: true,
267
+ cronExpression: "0 8 * * 1",
268
+ });
269
+ const workflow = buildActionWorkflow(creds, "0 8 * * 1");
270
+ expect(workflow).toContain("name: SWEny Triage");
271
+ expect(workflow).toContain('cron: "0 8 * * 1"');
272
+ expect(workflow).toContain("workflow_dispatch:");
273
+ expect(workflow).toContain("actions/checkout@v4");
274
+ expect(workflow).toContain("swenyai/sweny@v4");
275
+ });
276
+ it("generates a workflow with daily cron", () => {
277
+ const creds = collectCredentials({
278
+ sourceControl: "github",
279
+ observability: "sentry",
280
+ issueTracker: "linear",
281
+ notification: "slack",
282
+ githubAction: true,
283
+ cronExpression: "0 9 * * *",
284
+ });
285
+ const workflow = buildActionWorkflow(creds, "0 9 * * *");
286
+ expect(workflow).toContain('cron: "0 9 * * *"');
287
+ });
288
+ it("skips credentials with defaults from secrets", () => {
289
+ const creds = collectCredentials({
290
+ sourceControl: "github",
291
+ observability: "datadog",
292
+ issueTracker: "github-issues",
293
+ notification: "console",
294
+ githubAction: true,
295
+ cronExpression: "0 8 * * 1",
296
+ });
297
+ const workflow = buildActionWorkflow(creds, "0 8 * * 1");
298
+ // DD_SITE has a default, so it should NOT appear in secrets
299
+ expect(workflow).not.toContain("secrets.DD_SITE");
300
+ // DD_API_KEY has no default, so it should appear
301
+ expect(workflow).toContain("secrets.DD_API_KEY");
302
+ expect(workflow).toContain("secrets.DD_APP_KEY");
303
+ expect(workflow).toContain("secrets.ANTHROPIC_API_KEY");
304
+ expect(workflow).toContain("secrets.GITHUB_TOKEN");
305
+ });
306
+ it("maps credentials to ${{ secrets.KEY }} format", () => {
307
+ const creds = collectCredentials({
308
+ sourceControl: "github",
309
+ observability: null,
310
+ issueTracker: "linear",
311
+ notification: "slack",
312
+ githubAction: true,
313
+ cronExpression: "0 8 * * 1",
314
+ });
315
+ const workflow = buildActionWorkflow(creds, "0 8 * * 1");
316
+ expect(workflow).toContain("ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}");
317
+ expect(workflow).toContain("GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}");
318
+ expect(workflow).toContain("LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}");
319
+ expect(workflow).toContain("SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}");
320
+ });
321
+ });
322
+ // ═══════════════════════════════════════════════════════════════════════
323
+ // Additional tests: edge cases, correctness, security
324
+ // ═══════════════════════════════════════════════════════════════════════
325
+ // ── detectGitRemote: additional edge cases ────────────────────────────
326
+ describe("detectGitRemote — edge cases", () => {
327
+ const tmpDirs = [];
328
+ function makeTempRepo(gitConfigContent) {
329
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-init-test-"));
330
+ tmpDirs.push(dir);
331
+ const gitDir = path.join(dir, ".git");
332
+ fs.mkdirSync(gitDir, { recursive: true });
333
+ fs.writeFileSync(path.join(gitDir, "config"), gitConfigContent);
334
+ return dir;
335
+ }
336
+ afterEach(() => {
337
+ for (const d of tmpDirs) {
338
+ fs.rmSync(d, { recursive: true, force: true });
339
+ }
340
+ tmpDirs.length = 0;
341
+ });
342
+ it("detects ssh:// scheme GitHub remote", () => {
343
+ const cwd = makeTempRepo(`
344
+ [remote "origin"]
345
+ \turl = ssh://git@github.com/acme/widgets.git
346
+ `);
347
+ const info = detectGitRemote(cwd);
348
+ expect(info).toEqual({
349
+ provider: "github",
350
+ remote: "github.com/acme/widgets",
351
+ });
352
+ });
353
+ it("detects ssh:// scheme GitLab remote", () => {
354
+ const cwd = makeTempRepo(`
355
+ [remote "origin"]
356
+ \turl = ssh://git@gitlab.com/team/project.git
357
+ `);
358
+ const info = detectGitRemote(cwd);
359
+ expect(info).toEqual({
360
+ provider: "gitlab",
361
+ remote: "gitlab.com/team/project",
362
+ });
363
+ });
364
+ it("returns null for ssh:// with unknown host", () => {
365
+ const cwd = makeTempRepo(`
366
+ [remote "origin"]
367
+ \turl = ssh://git@bitbucket.org/acme/widgets.git
368
+ `);
369
+ expect(detectGitRemote(cwd)).toBeNull();
370
+ });
371
+ it("handles git config with multiple remotes (uses origin)", () => {
372
+ const cwd = makeTempRepo(`
373
+ [remote "upstream"]
374
+ \turl = https://gitlab.com/upstream/project.git
375
+ [remote "origin"]
376
+ \turl = https://github.com/acme/widgets.git
377
+ `);
378
+ const info = detectGitRemote(cwd);
379
+ expect(info).toEqual({
380
+ provider: "github",
381
+ remote: "github.com/acme/widgets",
382
+ });
383
+ });
384
+ it("handles nested org paths (gitlab subgroups)", () => {
385
+ const cwd = makeTempRepo(`
386
+ [remote "origin"]
387
+ \turl = https://gitlab.com/org/team/subteam/project.git
388
+ `);
389
+ const info = detectGitRemote(cwd);
390
+ expect(info).toEqual({
391
+ provider: "gitlab",
392
+ remote: "gitlab.com/org/team/subteam/project",
393
+ });
394
+ });
395
+ it("handles empty git config file", () => {
396
+ const cwd = makeTempRepo("");
397
+ expect(detectGitRemote(cwd)).toBeNull();
398
+ });
399
+ it("handles git config with no origin remote", () => {
400
+ const cwd = makeTempRepo(`
401
+ [remote "upstream"]
402
+ \turl = https://github.com/acme/widgets.git
403
+ `);
404
+ expect(detectGitRemote(cwd)).toBeNull();
405
+ });
406
+ });
407
+ // ── buildSwenyYml: YAML validity ──────────────────────────────────────
408
+ describe("buildSwenyYml — YAML validity", () => {
409
+ it("generates valid YAML that can be parsed", () => {
410
+ const yml = buildSwenyYml({
411
+ sourceControl: "github",
412
+ observability: "datadog",
413
+ issueTracker: "linear",
414
+ notification: "slack",
415
+ githubAction: false,
416
+ cronExpression: null,
417
+ });
418
+ const parsed = parseYaml(yml);
419
+ expect(parsed["source-control-provider"]).toBe("github");
420
+ expect(parsed["observability-provider"]).toBe("datadog");
421
+ expect(parsed["issue-tracker-provider"]).toBe("linear");
422
+ expect(parsed["notification-provider"]).toBe("slack");
423
+ });
424
+ it("generates valid YAML with minimal selections", () => {
425
+ const yml = buildSwenyYml({
426
+ sourceControl: "github",
427
+ observability: null,
428
+ issueTracker: "github-issues",
429
+ notification: "console",
430
+ githubAction: false,
431
+ cronExpression: null,
432
+ });
433
+ const parsed = parseYaml(yml);
434
+ expect(parsed["source-control-provider"]).toBe("github");
435
+ expect(parsed["issue-tracker-provider"]).toBe("github-issues");
436
+ expect(parsed["observability-provider"]).toBeUndefined();
437
+ expect(parsed["notification-provider"]).toBeUndefined();
438
+ });
439
+ it("uses correct key names matching config parser expectations", () => {
440
+ const yml = buildSwenyYml({
441
+ sourceControl: "gitlab",
442
+ observability: "sentry",
443
+ issueTracker: "jira",
444
+ notification: "teams",
445
+ githubAction: false,
446
+ cronExpression: null,
447
+ });
448
+ // These are the exact keys the config parser reads
449
+ expect(yml).toContain("source-control-provider:");
450
+ expect(yml).toContain("observability-provider:");
451
+ expect(yml).toContain("issue-tracker-provider:");
452
+ expect(yml).toContain("notification-provider:");
453
+ // Must NOT contain the short form
454
+ expect(yml).not.toMatch(/^source-control:/m);
455
+ expect(yml).not.toMatch(/^issue-tracker:/m);
456
+ });
457
+ it("every provider combination produces valid YAML", () => {
458
+ const sourceControls = ["github", "gitlab"];
459
+ const observabilities = ["datadog", "sentry", "betterstack", "newrelic", "cloudwatch", null];
460
+ const issueTrackers = ["github-issues", "linear", "jira"];
461
+ const notifications = ["console", "slack", "discord", "teams", "webhook"];
462
+ for (const sc of sourceControls) {
463
+ for (const obs of observabilities) {
464
+ for (const it of issueTrackers) {
465
+ for (const notif of notifications) {
466
+ const yml = buildSwenyYml({
467
+ sourceControl: sc,
468
+ observability: obs,
469
+ issueTracker: it,
470
+ notification: notif,
471
+ githubAction: false,
472
+ cronExpression: null,
473
+ });
474
+ // Should not throw
475
+ const parsed = parseYaml(yml);
476
+ expect(parsed["source-control-provider"]).toBe(sc);
477
+ }
478
+ }
479
+ }
480
+ }
481
+ });
482
+ });
483
+ // ── buildEnvTemplate: correctness ─────────────────────────────────────
484
+ describe("buildEnvTemplate — correctness", () => {
485
+ it("every credential has exactly one KEY= line", () => {
486
+ const creds = collectCredentials({
487
+ sourceControl: "github",
488
+ observability: "datadog",
489
+ issueTracker: "linear",
490
+ notification: "slack",
491
+ githubAction: false,
492
+ cronExpression: null,
493
+ });
494
+ const template = buildEnvTemplate(creds);
495
+ for (const cred of creds) {
496
+ const matches = template.match(new RegExp(`^${cred.key}=`, "gm"));
497
+ expect(matches, `${cred.key} should appear exactly once`).toHaveLength(1);
498
+ }
499
+ });
500
+ it("does not include empty KEY= lines for credentials with defaults", () => {
501
+ const creds = collectCredentials({
502
+ sourceControl: "github",
503
+ observability: "datadog",
504
+ issueTracker: "github-issues",
505
+ notification: "console",
506
+ githubAction: false,
507
+ cronExpression: null,
508
+ });
509
+ const template = buildEnvTemplate(creds);
510
+ // DD_SITE has a default — should be pre-filled, not empty
511
+ expect(template).toContain("DD_SITE=datadoghq.com");
512
+ expect(template).not.toMatch(/^DD_SITE=$/m);
513
+ });
514
+ it("header warns about not committing", () => {
515
+ const template = buildEnvTemplate([]);
516
+ expect(template).toContain("DO NOT COMMIT");
517
+ });
518
+ it("header mentions sweny check", () => {
519
+ const template = buildEnvTemplate([]);
520
+ expect(template).toContain("sweny check");
521
+ });
522
+ });
523
+ // ── buildActionWorkflow: YAML validity ────────────────────────────────
524
+ describe("buildActionWorkflow — YAML validity", () => {
525
+ it("generates valid GitHub Actions YAML", () => {
526
+ const creds = collectCredentials({
527
+ sourceControl: "github",
528
+ observability: "datadog",
529
+ issueTracker: "linear",
530
+ notification: "console",
531
+ githubAction: true,
532
+ cronExpression: "0 9 * * 1",
533
+ });
534
+ const workflow = buildActionWorkflow(creds, "0 9 * * 1");
535
+ const parsed = parseYaml(workflow);
536
+ expect(parsed.name).toBe("SWEny Triage");
537
+ expect(parsed.on.schedule[0].cron).toBe("0 9 * * 1");
538
+ expect(parsed.on.workflow_dispatch).toBeDefined();
539
+ expect(parsed.jobs.triage["runs-on"]).toBe("ubuntu-latest");
540
+ });
541
+ it("includes with.workflow: triage", () => {
542
+ const creds = collectCredentials({
543
+ sourceControl: "github",
544
+ observability: null,
545
+ issueTracker: "github-issues",
546
+ notification: "console",
547
+ githubAction: true,
548
+ cronExpression: "0 9 * * 1",
549
+ });
550
+ const workflow = buildActionWorkflow(creds, "0 9 * * 1");
551
+ expect(workflow).toContain("workflow: triage");
552
+ });
553
+ it("omits env block when all credentials have defaults", () => {
554
+ // Only credentials with defaults — should produce no env block
555
+ const workflow = buildActionWorkflow([
556
+ { key: "DD_SITE", default: "datadoghq.com" },
557
+ { key: "AWS_REGION", default: "us-east-1" },
558
+ ], "0 9 * * 1");
559
+ expect(workflow).not.toContain("env:");
560
+ });
561
+ it("generates valid YAML for every provider combination", () => {
562
+ const combos = [
563
+ {
564
+ sourceControl: "github",
565
+ observability: "datadog",
566
+ issueTracker: "linear",
567
+ notification: "slack",
568
+ githubAction: true,
569
+ cronExpression: "0 9 * * 1",
570
+ },
571
+ {
572
+ sourceControl: "gitlab",
573
+ observability: "sentry",
574
+ issueTracker: "jira",
575
+ notification: "teams",
576
+ githubAction: true,
577
+ cronExpression: "0 9 * * *",
578
+ },
579
+ {
580
+ sourceControl: "github",
581
+ observability: "cloudwatch",
582
+ issueTracker: "github-issues",
583
+ notification: "discord",
584
+ githubAction: true,
585
+ cronExpression: "0 8 * * 1-5",
586
+ },
587
+ {
588
+ sourceControl: "github",
589
+ observability: null,
590
+ issueTracker: "github-issues",
591
+ notification: "console",
592
+ githubAction: true,
593
+ cronExpression: "0 9 * * 1",
594
+ },
595
+ ];
596
+ for (const sel of combos) {
597
+ const creds = collectCredentials(sel);
598
+ const workflow = buildActionWorkflow(creds, sel.cronExpression);
599
+ // Should not throw
600
+ const parsed = parseYaml(workflow);
601
+ expect(parsed.name).toBe("SWEny Triage");
602
+ expect(parsed.jobs.triage).toBeDefined();
603
+ }
604
+ });
605
+ });
606
+ // ── collectCredentials: additional cases ──────────────────────────────
607
+ describe("collectCredentials — additional cases", () => {
608
+ it("ANTHROPIC_API_KEY is always first", () => {
609
+ const creds = collectCredentials({
610
+ sourceControl: "github",
611
+ observability: "datadog",
612
+ issueTracker: "linear",
613
+ notification: "slack",
614
+ githubAction: false,
615
+ cronExpression: null,
616
+ });
617
+ expect(creds[0].key).toBe("ANTHROPIC_API_KEY");
618
+ });
619
+ it("ANTHROPIC_API_KEY has correct url", () => {
620
+ const creds = collectCredentials({
621
+ sourceControl: "github",
622
+ observability: null,
623
+ issueTracker: "github-issues",
624
+ notification: "console",
625
+ githubAction: false,
626
+ cronExpression: null,
627
+ });
628
+ const anthropic = creds.find((c) => c.key === "ANTHROPIC_API_KEY");
629
+ expect(anthropic?.url).toBe("https://console.anthropic.com/settings/api-keys");
630
+ });
631
+ it("handles unknown provider gracefully (custom observability)", () => {
632
+ const creds = collectCredentials({
633
+ sourceControl: "github",
634
+ observability: "splunk",
635
+ issueTracker: "github-issues",
636
+ notification: "console",
637
+ githubAction: false,
638
+ cronExpression: null,
639
+ });
640
+ // Should still have ANTHROPIC_API_KEY + GITHUB_TOKEN, no crash
641
+ const keys = creds.map((c) => c.key);
642
+ expect(keys).toContain("ANTHROPIC_API_KEY");
643
+ expect(keys).toContain("GITHUB_TOKEN");
644
+ });
645
+ it("every provider in PROVIDER_CREDENTIALS has valid credential entries", () => {
646
+ for (const [provider, creds] of Object.entries(PROVIDER_CREDENTIALS)) {
647
+ expect(Array.isArray(creds), `${provider} should have an array`).toBe(true);
648
+ for (const cred of creds) {
649
+ expect(typeof cred.key, `${provider}.${cred.key} should be a string`).toBe("string");
650
+ expect(cred.key.length, `${provider} has an empty key`).toBeGreaterThan(0);
651
+ expect(cred.key, `${provider}.${cred.key} should be UPPER_SNAKE_CASE`).toMatch(/^[A-Z][A-Z0-9_]+$/);
652
+ }
653
+ }
654
+ });
655
+ it("credential URLs are valid https URLs when present", () => {
656
+ for (const [provider, creds] of Object.entries(PROVIDER_CREDENTIALS)) {
657
+ for (const cred of creds) {
658
+ if (cred.url) {
659
+ expect(cred.url, `${provider}.${cred.key} URL should start with https://`).toMatch(/^https:\/\//);
660
+ }
661
+ }
662
+ }
663
+ });
664
+ });