@sweny-ai/core 0.1.30 → 0.1.31

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,53 @@
1
+ /**
2
+ * sweny init — Interactive setup wizard
3
+ *
4
+ * Pure functions for file generation + thin @clack/prompts interactive layer.
5
+ * Tests cover the pure functions; the interactive wizard is thin glue.
6
+ */
7
+ export interface Credential {
8
+ key: string;
9
+ hint?: string;
10
+ url?: string;
11
+ default?: string;
12
+ }
13
+ export interface InitSelections {
14
+ sourceControl: string;
15
+ observability: string | null;
16
+ issueTracker: string;
17
+ notification: string;
18
+ githubAction: boolean;
19
+ cronExpression: string | null;
20
+ }
21
+ export interface GitRemoteInfo {
22
+ provider: "github" | "gitlab";
23
+ remote: string;
24
+ }
25
+ export declare const PROVIDER_CREDENTIALS: Record<string, Credential[]>;
26
+ /**
27
+ * Gather all unique credentials required for the selected providers.
28
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
29
+ */
30
+ export declare function collectCredentials(selections: InitSelections): Credential[];
31
+ /**
32
+ * Detect the git remote origin from `.git/config`.
33
+ * Returns provider + normalised remote (e.g. "github.com/owner/repo"), or null.
34
+ */
35
+ export declare function detectGitRemote(cwd: string): GitRemoteInfo | null;
36
+ /**
37
+ * Generate .sweny.yml content from selections.
38
+ */
39
+ export declare function buildSwenyYml(selections: InitSelections): string;
40
+ /**
41
+ * Generate .env template with comments for each credential.
42
+ */
43
+ export declare function buildEnvTemplate(credentials: Credential[]): string;
44
+ /**
45
+ * Generate a GitHub Actions workflow YAML for SWEny.
46
+ * Only includes credentials WITHOUT defaults as secrets.
47
+ */
48
+ export declare function buildActionWorkflow(credentials: Credential[], cronExpression: string): string;
49
+ /**
50
+ * Interactive setup wizard — prompts the user through provider selection,
51
+ * then generates `.sweny.yml`, `.env`, and an optional GitHub Action workflow.
52
+ */
53
+ export declare function runInit(): Promise<void>;
@@ -0,0 +1,521 @@
1
+ /**
2
+ * sweny init — Interactive setup wizard
3
+ *
4
+ * Pure functions for file generation + thin @clack/prompts interactive layer.
5
+ * Tests cover the pure functions; the interactive wizard is thin glue.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as p from "@clack/prompts";
10
+ import chalk from "chalk";
11
+ // ── Credential table ───────────────────────────────────────────────────
12
+ export const PROVIDER_CREDENTIALS = {
13
+ github: [
14
+ {
15
+ key: "GITHUB_TOKEN",
16
+ url: "https://github.com/settings/tokens",
17
+ hint: "repo + issues scopes",
18
+ },
19
+ ],
20
+ gitlab: [
21
+ {
22
+ key: "GITLAB_TOKEN",
23
+ hint: "api scope",
24
+ url: "https://gitlab.com/-/profile/personal_access_tokens",
25
+ },
26
+ { key: "GITLAB_URL", hint: "e.g. https://gitlab.com", default: "https://gitlab.com" },
27
+ ],
28
+ datadog: [
29
+ {
30
+ key: "DD_API_KEY",
31
+ url: "https://app.datadoghq.com/organization-settings",
32
+ hint: "Organization Settings > API Keys",
33
+ },
34
+ { key: "DD_APP_KEY", hint: "Organization Settings > Application Keys" },
35
+ { key: "DD_SITE", hint: "datadoghq.com, datadoghq.eu, etc.", default: "datadoghq.com" },
36
+ ],
37
+ sentry: [
38
+ {
39
+ key: "SENTRY_AUTH_TOKEN",
40
+ url: "https://sentry.io/settings/auth-tokens/",
41
+ },
42
+ { key: "SENTRY_ORG", hint: "sentry.io/organizations/slug/" },
43
+ ],
44
+ betterstack: [
45
+ {
46
+ key: "BETTERSTACK_API_TOKEN",
47
+ url: "https://betterstack.com/docs/logs/api",
48
+ },
49
+ ],
50
+ newrelic: [{ key: "NR_API_KEY", url: "https://one.newrelic.com/api-keys" }],
51
+ cloudwatch: [
52
+ { key: "AWS_ACCESS_KEY_ID", hint: "IAM user access key" },
53
+ { key: "AWS_SECRET_ACCESS_KEY", hint: "IAM user secret key" },
54
+ { key: "AWS_REGION", hint: "e.g. us-east-1", default: "us-east-1" },
55
+ ],
56
+ "github-issues": [],
57
+ linear: [
58
+ {
59
+ key: "LINEAR_API_KEY",
60
+ url: "https://linear.app/settings/api",
61
+ },
62
+ {
63
+ key: "LINEAR_TEAM_ID",
64
+ hint: "Settings > Teams > copy ID from URL",
65
+ },
66
+ ],
67
+ jira: [
68
+ { key: "JIRA_BASE_URL", hint: "e.g. https://your-org.atlassian.net" },
69
+ { key: "JIRA_EMAIL", hint: "your Atlassian account email" },
70
+ {
71
+ key: "JIRA_API_TOKEN",
72
+ url: "https://id.atlassian.com/manage-profile/security/api-tokens",
73
+ },
74
+ ],
75
+ console: [],
76
+ slack: [{ key: "SLACK_BOT_TOKEN", url: "https://api.slack.com/apps" }],
77
+ discord: [{ key: "DISCORD_WEBHOOK_URL", hint: "Server Settings > Integrations > Webhooks" }],
78
+ teams: [{ key: "TEAMS_WEBHOOK_URL", hint: "Channel > Connectors > Incoming Webhook" }],
79
+ webhook: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
80
+ };
81
+ const ALWAYS_CREDENTIALS = [
82
+ {
83
+ key: "ANTHROPIC_API_KEY",
84
+ url: "https://console.anthropic.com/settings/api-keys",
85
+ hint: "Claude API key",
86
+ },
87
+ ];
88
+ // ── Functions ──────────────────────────────────────────────────────────
89
+ /**
90
+ * Gather all unique credentials required for the selected providers.
91
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
92
+ */
93
+ export function collectCredentials(selections) {
94
+ const seen = new Map();
95
+ // Always-present credentials first
96
+ for (const cred of ALWAYS_CREDENTIALS) {
97
+ seen.set(cred.key, cred);
98
+ }
99
+ const providers = [
100
+ selections.sourceControl,
101
+ selections.observability,
102
+ selections.issueTracker,
103
+ selections.notification,
104
+ ].filter((p) => p !== null);
105
+ for (const provider of providers) {
106
+ const creds = PROVIDER_CREDENTIALS[provider];
107
+ if (!creds)
108
+ continue;
109
+ for (const cred of creds) {
110
+ if (!seen.has(cred.key)) {
111
+ seen.set(cred.key, cred);
112
+ }
113
+ }
114
+ }
115
+ return Array.from(seen.values());
116
+ }
117
+ /**
118
+ * Detect the git remote origin from `.git/config`.
119
+ * Returns provider + normalised remote (e.g. "github.com/owner/repo"), or null.
120
+ */
121
+ export function detectGitRemote(cwd) {
122
+ const configPath = path.join(cwd, ".git", "config");
123
+ let content;
124
+ try {
125
+ content = fs.readFileSync(configPath, "utf-8");
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ // Find [remote "origin"] section and extract url
131
+ const remoteMatch = content.match(/\[remote\s+"origin"\]\s*\n(?:\s+[^\[]*?\n)*?\s*url\s*=\s*(.+)/);
132
+ if (!remoteMatch)
133
+ return null;
134
+ const url = remoteMatch[1].trim();
135
+ // SSH: git@github.com:owner/repo.git
136
+ const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
137
+ if (sshMatch) {
138
+ const host = sshMatch[1];
139
+ const repoPath = sshMatch[2];
140
+ if (host === "github.com") {
141
+ return { provider: "github", remote: `github.com/${repoPath}` };
142
+ }
143
+ if (host === "gitlab.com") {
144
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
145
+ }
146
+ return null;
147
+ }
148
+ // SSH scheme: ssh://git@github.com/owner/repo.git
149
+ const sshSchemeMatch = url.match(/^ssh:\/\/git@([^/]+)\/(.+?)(?:\.git)?$/);
150
+ if (sshSchemeMatch) {
151
+ const host = sshSchemeMatch[1];
152
+ const repoPath = sshSchemeMatch[2];
153
+ if (host === "github.com") {
154
+ return { provider: "github", remote: `github.com/${repoPath}` };
155
+ }
156
+ if (host === "gitlab.com") {
157
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
158
+ }
159
+ return null;
160
+ }
161
+ // HTTPS: https://github.com/owner/repo.git
162
+ const httpsMatch = url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
163
+ if (httpsMatch) {
164
+ const host = httpsMatch[1];
165
+ const repoPath = httpsMatch[2];
166
+ if (host === "github.com") {
167
+ return { provider: "github", remote: `github.com/${repoPath}` };
168
+ }
169
+ if (host === "gitlab.com") {
170
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
171
+ }
172
+ return null;
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * Generate .sweny.yml content from selections.
178
+ */
179
+ export function buildSwenyYml(selections) {
180
+ const lines = [];
181
+ lines.push("# .sweny.yml — SWEny project configuration");
182
+ lines.push("# Secrets (API keys, tokens) go in .env (gitignored).");
183
+ lines.push("# Docs: https://docs.sweny.ai/cli");
184
+ lines.push("");
185
+ lines.push(`source-control-provider: ${selections.sourceControl}`);
186
+ if (selections.observability !== null) {
187
+ lines.push(`observability-provider: ${selections.observability}`);
188
+ }
189
+ lines.push(`issue-tracker-provider: ${selections.issueTracker}`);
190
+ // Omit notification-provider when "console" (it's the default)
191
+ if (selections.notification !== "console") {
192
+ lines.push(`notification-provider: ${selections.notification}`);
193
+ }
194
+ lines.push("");
195
+ return lines.join("\n");
196
+ }
197
+ /**
198
+ * Generate .env template with comments for each credential.
199
+ */
200
+ export function buildEnvTemplate(credentials) {
201
+ const lines = [];
202
+ lines.push("# .env — SWEny credentials (DO NOT COMMIT)");
203
+ lines.push("# Fill in each value, then run: sweny check");
204
+ lines.push("");
205
+ for (const cred of credentials) {
206
+ if (cred.url) {
207
+ lines.push(`# ${cred.url}`);
208
+ }
209
+ if (cred.hint) {
210
+ lines.push(`# ${cred.hint}`);
211
+ }
212
+ if (cred.default !== undefined) {
213
+ lines.push(`${cred.key}=${cred.default}`);
214
+ }
215
+ else {
216
+ lines.push(`${cred.key}=`);
217
+ }
218
+ lines.push("");
219
+ }
220
+ return lines.join("\n");
221
+ }
222
+ /**
223
+ * Generate a GitHub Actions workflow YAML for SWEny.
224
+ * Only includes credentials WITHOUT defaults as secrets.
225
+ */
226
+ export function buildActionWorkflow(credentials, cronExpression) {
227
+ const secretCreds = credentials.filter((c) => c.default === undefined);
228
+ const lines = [];
229
+ lines.push("name: SWEny Triage");
230
+ lines.push("");
231
+ lines.push("on:");
232
+ lines.push(" schedule:");
233
+ lines.push(` - cron: "${cronExpression}"`);
234
+ lines.push(" workflow_dispatch:");
235
+ lines.push("");
236
+ lines.push("jobs:");
237
+ lines.push(" triage:");
238
+ lines.push(" runs-on: ubuntu-latest");
239
+ lines.push(" steps:");
240
+ lines.push(" - uses: actions/checkout@v4");
241
+ lines.push(" - uses: swenyai/sweny@v4");
242
+ lines.push(" with:");
243
+ lines.push(" workflow: triage");
244
+ if (secretCreds.length > 0) {
245
+ lines.push(" env:");
246
+ for (const cred of secretCreds) {
247
+ lines.push(` ${cred.key}: \${{ secrets.${cred.key} }}`);
248
+ }
249
+ }
250
+ lines.push("");
251
+ return lines.join("\n");
252
+ }
253
+ // ── Interactive wizard ────────────────────────────────────────────────
254
+ function cancel() {
255
+ p.cancel("Setup cancelled.");
256
+ process.exit(0);
257
+ }
258
+ /**
259
+ * Interactive setup wizard — prompts the user through provider selection,
260
+ * then generates `.sweny.yml`, `.env`, and an optional GitHub Action workflow.
261
+ */
262
+ export async function runInit() {
263
+ const cwd = process.cwd();
264
+ // ── Intro ───────────────────────────────────────────────────────────
265
+ p.intro("Let's set up SWEny");
266
+ const gitInfo = detectGitRemote(cwd);
267
+ if (gitInfo) {
268
+ p.log.info(`Detected git remote: ${chalk.cyan(gitInfo.remote)}`);
269
+ }
270
+ // ── Existing file check ─────────────────────────────────────────────
271
+ const configPath = path.join(cwd, ".sweny.yml");
272
+ if (fs.existsSync(configPath)) {
273
+ const overwrite = await p.confirm({
274
+ message: ".sweny.yml already exists. Overwrite?",
275
+ initialValue: false,
276
+ });
277
+ if (p.isCancel(overwrite))
278
+ cancel();
279
+ if (!overwrite) {
280
+ p.cancel("Setup cancelled — existing config preserved.");
281
+ process.exit(0);
282
+ }
283
+ }
284
+ // ── Screen 1: Source control ────────────────────────────────────────
285
+ const sourceControl = await p.select({
286
+ message: "Source control provider",
287
+ initialValue: gitInfo?.provider ?? "github",
288
+ options: [
289
+ {
290
+ value: "github",
291
+ label: "GitHub" + (gitInfo?.provider === "github" ? " (detected)" : ""),
292
+ },
293
+ {
294
+ value: "gitlab",
295
+ label: "GitLab" + (gitInfo?.provider === "gitlab" ? " (detected)" : ""),
296
+ },
297
+ ],
298
+ });
299
+ if (p.isCancel(sourceControl))
300
+ cancel();
301
+ // ── Screen 2: Observability ─────────────────────────────────────────
302
+ const obsRaw = await p.select({
303
+ message: "Observability provider",
304
+ options: [
305
+ { value: "datadog", label: "Datadog" },
306
+ { value: "sentry", label: "Sentry" },
307
+ { value: "betterstack", label: "BetterStack" },
308
+ { value: "newrelic", label: "New Relic" },
309
+ { value: "cloudwatch", label: "CloudWatch" },
310
+ { value: "__other", label: "Other" },
311
+ { value: "__none", label: "None" },
312
+ ],
313
+ });
314
+ if (p.isCancel(obsRaw))
315
+ cancel();
316
+ let observability;
317
+ if (obsRaw === "__none") {
318
+ observability = null;
319
+ }
320
+ else if (obsRaw === "__other") {
321
+ const custom = await p.text({
322
+ message: "Enter your observability provider name",
323
+ validate: (v) => {
324
+ if (!v || v.trim().length === 0)
325
+ return "Provider name is required";
326
+ if (!/^[a-z0-9-]+$/.test(v.trim()))
327
+ return "Provider name must be lowercase letters, numbers, and hyphens only";
328
+ return undefined;
329
+ },
330
+ });
331
+ if (p.isCancel(custom))
332
+ cancel();
333
+ observability = custom.trim().toLowerCase();
334
+ }
335
+ else {
336
+ observability = obsRaw;
337
+ }
338
+ // ── Screen 3: Issue tracker ─────────────────────────────────────────
339
+ const issueTracker = await p.select({
340
+ message: "Issue tracker",
341
+ initialValue: sourceControl === "github" ? "github-issues" : undefined,
342
+ options: [
343
+ { value: "github-issues", label: "GitHub Issues" },
344
+ { value: "linear", label: "Linear" },
345
+ { value: "jira", label: "Jira" },
346
+ ],
347
+ });
348
+ if (p.isCancel(issueTracker))
349
+ cancel();
350
+ // ── Screen 4: Notification ──────────────────────────────────────────
351
+ const notification = await p.select({
352
+ message: "Notification channel",
353
+ initialValue: "console",
354
+ options: [
355
+ { value: "console", label: "Console (default)" },
356
+ { value: "slack", label: "Slack" },
357
+ { value: "discord", label: "Discord" },
358
+ { value: "teams", label: "Microsoft Teams" },
359
+ { value: "webhook", label: "Webhook" },
360
+ ],
361
+ });
362
+ if (p.isCancel(notification))
363
+ cancel();
364
+ // ── Screen 5: GitHub Action ─────────────────────────────────────────
365
+ const wantAction = await p.confirm({
366
+ message: "Set up a GitHub Action workflow?",
367
+ initialValue: true,
368
+ });
369
+ if (p.isCancel(wantAction))
370
+ cancel();
371
+ let cronExpression = null;
372
+ if (wantAction) {
373
+ const schedule = await p.select({
374
+ message: "How often should the triage run?",
375
+ options: [
376
+ { value: "0 9 * * *", label: "Daily (9 AM UTC)" },
377
+ { value: "0 9 * * 1", label: "Weekly (Monday 9 AM UTC)" },
378
+ { value: "__custom", label: "Custom cron expression" },
379
+ ],
380
+ });
381
+ if (p.isCancel(schedule))
382
+ cancel();
383
+ if (schedule === "__custom") {
384
+ const customCron = await p.text({
385
+ message: "Enter cron expression (e.g. 0 8 * * 1-5)",
386
+ validate: (v) => {
387
+ if (!v || v.trim().length === 0)
388
+ return "Cron expression is required";
389
+ if (!/^[\d*,\/-]+(\s+[\d*,\/-]+){4}$/.test(v.trim()))
390
+ return "Must be a valid 5-field cron expression";
391
+ return undefined;
392
+ },
393
+ });
394
+ if (p.isCancel(customCron))
395
+ cancel();
396
+ cronExpression = customCron.trim();
397
+ }
398
+ else {
399
+ cronExpression = schedule;
400
+ }
401
+ }
402
+ // ── Assemble selections ─────────────────────────────────────────────
403
+ const selections = {
404
+ sourceControl: sourceControl,
405
+ observability,
406
+ issueTracker: issueTracker,
407
+ notification: notification,
408
+ githubAction: !!wantAction,
409
+ cronExpression,
410
+ };
411
+ const credentials = collectCredentials(selections);
412
+ // ── Screen 6: Summary ───────────────────────────────────────────────
413
+ const files = [".sweny.yml", ".env"];
414
+ if (selections.githubAction)
415
+ files.push(".github/workflows/sweny.yml");
416
+ p.log.message([
417
+ chalk.bold("Files to create:"),
418
+ ...files.map((f) => ` ${chalk.cyan(f)}`),
419
+ "",
420
+ chalk.bold("Providers:"),
421
+ ` Source control: ${selections.sourceControl}`,
422
+ ` Observability: ${selections.observability ?? "none"}`,
423
+ ` Issue tracker: ${selections.issueTracker}`,
424
+ ` Notification: ${selections.notification}`,
425
+ selections.githubAction ? ` GitHub Action: ${selections.cronExpression}` : " GitHub Action: no",
426
+ ].join("\n"));
427
+ const confirmed = await p.confirm({
428
+ message: "Create these files?",
429
+ initialValue: true,
430
+ });
431
+ if (p.isCancel(confirmed))
432
+ cancel();
433
+ if (!confirmed) {
434
+ p.cancel("Setup cancelled.");
435
+ process.exit(0);
436
+ }
437
+ // ── Screen 7: Write files ──────────────────────────────────────────
438
+ // 1. Write .sweny.yml
439
+ fs.writeFileSync(configPath, buildSwenyYml(selections), "utf-8");
440
+ p.log.success("Created .sweny.yml");
441
+ // 2. Write .env
442
+ const envPath = path.join(cwd, ".env");
443
+ if (fs.existsSync(envPath)) {
444
+ const existing = fs.readFileSync(envPath, "utf-8");
445
+ // Parse defined keys (ignore comment lines)
446
+ const definedKeys = new Set(existing
447
+ .split("\n")
448
+ .filter((l) => !l.trimStart().startsWith("#") && l.includes("="))
449
+ .map((l) => l.split("=")[0].trim()));
450
+ const newKeys = [];
451
+ for (const cred of credentials) {
452
+ if (!definedKeys.has(cred.key)) {
453
+ newKeys.push(cred);
454
+ }
455
+ }
456
+ if (newKeys.length > 0) {
457
+ const appendBlock = "\n" + buildEnvTemplate(newKeys);
458
+ fs.appendFileSync(envPath, appendBlock, "utf-8");
459
+ p.log.success(`Appended ${newKeys.length} new key(s) to .env`);
460
+ }
461
+ else {
462
+ p.log.info(".env already contains all required keys — skipped");
463
+ }
464
+ }
465
+ else {
466
+ fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
467
+ p.log.success("Created .env");
468
+ }
469
+ // 3. GitHub Action workflow
470
+ if (selections.githubAction && selections.cronExpression) {
471
+ const workflowDir = path.join(cwd, ".github", "workflows");
472
+ const workflowPath = path.join(workflowDir, "sweny.yml");
473
+ if (fs.existsSync(workflowPath)) {
474
+ const overwriteWf = await p.confirm({
475
+ message: ".github/workflows/sweny.yml already exists. Overwrite?",
476
+ initialValue: false,
477
+ });
478
+ if (p.isCancel(overwriteWf))
479
+ cancel();
480
+ if (!overwriteWf) {
481
+ p.log.info("Skipped GitHub Action workflow");
482
+ }
483
+ else {
484
+ fs.mkdirSync(workflowDir, { recursive: true });
485
+ fs.writeFileSync(workflowPath, buildActionWorkflow(credentials, selections.cronExpression), "utf-8");
486
+ p.log.success("Created .github/workflows/sweny.yml");
487
+ }
488
+ }
489
+ else {
490
+ fs.mkdirSync(workflowDir, { recursive: true });
491
+ fs.writeFileSync(workflowPath, buildActionWorkflow(credentials, selections.cronExpression), "utf-8");
492
+ p.log.success("Created .github/workflows/sweny.yml");
493
+ }
494
+ }
495
+ // 4. .gitignore check
496
+ const gitignorePath = path.join(cwd, ".gitignore");
497
+ if (fs.existsSync(gitignorePath)) {
498
+ const gitignore = fs.readFileSync(gitignorePath, "utf-8");
499
+ const envIgnored = gitignore.split("\n").some((line) => line.trim() === ".env");
500
+ if (!envIgnored) {
501
+ p.log.warn(chalk.yellow(".env is not in .gitignore — add it to avoid committing secrets"));
502
+ }
503
+ }
504
+ else {
505
+ p.log.warn(chalk.yellow("No .gitignore found — make sure .env is not committed"));
506
+ }
507
+ // 5. Next steps
508
+ const docUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
509
+ p.note([
510
+ "1. Fill in your API keys in .env:",
511
+ ...docUrls,
512
+ "",
513
+ "2. Verify connectivity:",
514
+ " sweny check",
515
+ "",
516
+ "3. Run a dry-run triage:",
517
+ " sweny triage --dry-run",
518
+ ].join("\n"), "Next steps");
519
+ // 6. Done
520
+ p.outro("You're all set!");
521
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
package/dist/cli/main.js CHANGED
@@ -19,7 +19,8 @@ import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
19
19
  import { toMermaid, toMermaidBlock } from "../mermaid.js";
20
20
  import { DagRenderer } from "./renderer.js";
21
21
  import * as readline from "node:readline";
22
- import { loadDotenv, loadConfigFile, STARTER_CONFIG } from "./config-file.js";
22
+ import { loadDotenv, loadConfigFile } from "./config-file.js";
23
+ import { runInit } from "./init.js";
23
24
  import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
24
25
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
25
26
  import { checkProviderConnectivity } from "./check.js";
@@ -55,16 +56,9 @@ const program = new Command()
55
56
  // ── sweny init ────────────────────────────────────────────────────────
56
57
  program
57
58
  .command("init")
58
- .description("Create a starter .sweny.yml config file")
59
- .action(() => {
60
- const target = path.join(process.cwd(), ".sweny.yml");
61
- if (fs.existsSync(target)) {
62
- console.error(chalk.yellow(" .sweny.yml already exists — skipping."));
63
- process.exit(1);
64
- }
65
- fs.writeFileSync(target, STARTER_CONFIG, "utf-8");
66
- console.log(chalk.green(" Created .sweny.yml"));
67
- console.log(chalk.dim(" Add your secrets to .env and run: sweny triage --dry-run"));
59
+ .description("Interactive setup wizard — creates .sweny.yml, .env template, and optional GitHub Action")
60
+ .action(async () => {
61
+ await runInit();
68
62
  });
69
63
  // ── sweny check ───────────────────────────────────────────────────────
70
64
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"
@@ -49,6 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@anthropic-ai/claude-agent-sdk": "^0.2.84",
52
+ "@clack/prompts": "^1.2.0",
52
53
  "chalk": "^5",
53
54
  "commander": "^13",
54
55
  "yaml": "^2",