@sweny-ai/core 0.1.43 → 0.1.45

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.
@@ -189,11 +189,7 @@ describe("docs action code blocks", () => {
189
189
  "packages/web/src/content/docs/advanced/mcp-servers.md",
190
190
  "packages/web/src/content/docs/advanced/troubleshooting.md",
191
191
  ];
192
- // NOTE: This test is currently expected to fail — the action docs
193
- // still show triage-specific inputs under swenyai/sweny@v5.
194
- // This is tracked as a known issue from the multi-repo split.
195
- // Remove .todo once the docs are migrated to use swenyai/triage@v1.
196
- it.todo("no docs code block uses swenyai/sweny@v5 with triage-only inputs", () => {
192
+ it("no docs code block uses swenyai/sweny@v5 with triage-only inputs", () => {
197
193
  const allIssues = ACTION_DOC_FILES.flatMap((f) => findMisusedBlocks(resolve(ROOT, f)));
198
194
  if (allIssues.length > 0) {
199
195
  expect.fail(`Found ${allIssues.length} code block(s) mixing swenyai/sweny@${CURRENT_ACTION_VERSION} with triage-only inputs:\n` +
@@ -11,7 +11,7 @@ export function registerTriageCommand(program) {
11
11
  .option("--observability-provider <provider>", "Observability provider (default: datadog)")
12
12
  .option("--issue-tracker-provider <provider>", "Issue tracker provider (default: github-issues)")
13
13
  .option("--source-control-provider <provider>", "Source control provider (default: github)")
14
- .option("--notification-provider <provider>", "Notification provider (default: console)")
14
+ .option("--notification-provider <provider>", "Notification provider: console, github-summary, slack, teams, discord, webhook, email, file (default: console)")
15
15
  .option("--time-range <range>", "Time range to analyze (default: 24h)")
16
16
  .option("--severity-focus <focus>", "Severity level focus (default: errors)")
17
17
  .option("--service-filter <filter>", "Service filter pattern (default: *)")
@@ -355,7 +355,10 @@ export function validateInputs(config) {
355
355
  // Notification credentials by provider
356
356
  switch (config.notificationProvider) {
357
357
  case "console":
358
- // No credentials needed
358
+ case "github-summary":
359
+ // No credentials needed. github-summary writes a markdown summary to the
360
+ // file at $GITHUB_STEP_SUMMARY when running inside GitHub Actions; outside
361
+ // Actions it behaves like console.
359
362
  break;
360
363
  case "slack":
361
364
  case "teams":
@@ -376,7 +379,7 @@ export function validateInputs(config) {
376
379
  // No external credentials needed
377
380
  break;
378
381
  default:
379
- errors.push(`Unknown --notification-provider "${config.notificationProvider}". Valid values: console, slack, teams, discord, webhook, email, file`);
382
+ errors.push(`Unknown --notification-provider "${config.notificationProvider}". Valid values: console, github-summary, slack, teams, discord, webhook, email, file`);
380
383
  }
381
384
  // Workspace tools: reject unknown names early
382
385
  for (const tool of config.workspaceTools) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateInputs } from "./config.js";
3
+ /**
4
+ * Focused tests for validateInputs() — specifically the notification-provider
5
+ * switch. Regressions here break every `swenyai/triage` action run because
6
+ * that action passes --notification-provider github-summary by default.
7
+ */
8
+ function baseConfig(overrides = {}) {
9
+ return {
10
+ codingAgentProvider: "claude",
11
+ anthropicApiKey: "sk-ant-test",
12
+ claudeOauthToken: "",
13
+ openaiApiKey: "",
14
+ geminiApiKey: "",
15
+ observabilityProvider: "file",
16
+ observabilityCredentials: {},
17
+ issueTrackerProvider: "file",
18
+ linearApiKey: "",
19
+ linearTeamId: "",
20
+ linearBugLabelId: "",
21
+ linearTriageLabelId: "",
22
+ linearStateBacklog: "",
23
+ linearStateInProgress: "",
24
+ linearStatePeerReview: "",
25
+ timeRange: "24h",
26
+ severityFocus: "errors",
27
+ serviceFilter: "*",
28
+ investigationDepth: "standard",
29
+ maxInvestigateTurns: 50,
30
+ maxImplementTurns: 30,
31
+ baseBranch: "main",
32
+ prLabels: ["agent"],
33
+ issueLabels: [],
34
+ dryRun: false,
35
+ reviewMode: "review",
36
+ noveltyMode: true,
37
+ issueOverride: "",
38
+ additionalInstructions: "",
39
+ serviceMapPath: ".github/service-map.yml",
40
+ githubToken: "ghp_test",
41
+ botToken: "",
42
+ sourceControlProvider: "github",
43
+ jiraBaseUrl: "",
44
+ jiraEmail: "",
45
+ jiraApiToken: "",
46
+ gitlabToken: "",
47
+ gitlabProjectId: "",
48
+ gitlabBaseUrl: "https://gitlab.com",
49
+ notificationProvider: "console",
50
+ notificationWebhookUrl: "",
51
+ sendgridApiKey: "",
52
+ emailFrom: "",
53
+ emailTo: "",
54
+ webhookSigningSecret: "",
55
+ repository: "acme/app",
56
+ repositoryOwner: "",
57
+ json: false,
58
+ stream: false,
59
+ bell: false,
60
+ cacheDir: ".sweny/cache",
61
+ cacheTtl: 86400,
62
+ noCache: false,
63
+ outputDir: ".sweny/output",
64
+ mcpServers: {},
65
+ workspaceTools: [],
66
+ rules: [],
67
+ context: [],
68
+ ...overrides,
69
+ };
70
+ }
71
+ describe("validateInputs — notification provider", () => {
72
+ it("accepts console (no credentials required)", () => {
73
+ const errors = validateInputs(baseConfig({ notificationProvider: "console" }));
74
+ expect(errors.filter((e) => e.includes("notification-provider"))).toEqual([]);
75
+ });
76
+ it("accepts github-summary (no credentials required)", () => {
77
+ // Regression guard: the `swenyai/triage` composite action passes this as
78
+ // its default. If the validator stops accepting it every scheduled run
79
+ // across every consumer immediately breaks.
80
+ const errors = validateInputs(baseConfig({ notificationProvider: "github-summary" }));
81
+ expect(errors.filter((e) => e.includes("notification-provider"))).toEqual([]);
82
+ });
83
+ it("accepts file (no credentials required)", () => {
84
+ const errors = validateInputs(baseConfig({ notificationProvider: "file" }));
85
+ expect(errors.filter((e) => e.includes("notification-provider"))).toEqual([]);
86
+ });
87
+ it("requires NOTIFICATION_WEBHOOK_URL for slack", () => {
88
+ const errors = validateInputs(baseConfig({ notificationProvider: "slack", notificationWebhookUrl: "" }));
89
+ expect(errors.some((e) => e.includes("NOTIFICATION_WEBHOOK_URL"))).toBe(true);
90
+ });
91
+ it("accepts slack when NOTIFICATION_WEBHOOK_URL is set", () => {
92
+ const errors = validateInputs(baseConfig({
93
+ notificationProvider: "slack",
94
+ notificationWebhookUrl: "https://hooks.slack.com/services/T/B/X",
95
+ }));
96
+ expect(errors.filter((e) => e.includes("notification"))).toEqual([]);
97
+ });
98
+ it("requires sendgrid credentials for email", () => {
99
+ const errors = validateInputs(baseConfig({ notificationProvider: "email" }));
100
+ expect(errors.some((e) => e.includes("SENDGRID_API_KEY"))).toBe(true);
101
+ expect(errors.some((e) => e.includes("EMAIL_FROM"))).toBe(true);
102
+ expect(errors.some((e) => e.includes("EMAIL_TO"))).toBe(true);
103
+ });
104
+ it("rejects unknown providers and lists github-summary in the error", () => {
105
+ const errors = validateInputs(baseConfig({ notificationProvider: "bogus" }));
106
+ const err = errors.find((e) => e.includes("Unknown --notification-provider"));
107
+ expect(err).toBeDefined();
108
+ expect(err).toContain("github-summary");
109
+ expect(err).toContain("console");
110
+ });
111
+ });
package/dist/cli/main.js CHANGED
@@ -25,7 +25,7 @@ import { buildCredentialMap } from "./credentials.js";
25
25
  import { runInit } from "./init.js";
26
26
  import { runE2eInit, runE2eRun } from "./e2e.js";
27
27
  import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
28
- import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
28
+ import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
29
29
  import { checkProviderConnectivity } from "./check.js";
30
30
  import { registerSetupCommand } from "./setup.js";
31
31
  // ── Stream observer (NDJSON) ────────────────────────────────────────
@@ -350,6 +350,17 @@ triageCmd.action(async (options) => {
350
350
  else {
351
351
  console.log(formatDagResultHuman(results, durationMs, config));
352
352
  }
353
+ // GitHub Actions step summary
354
+ if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
355
+ try {
356
+ const md = formatDagResultMarkdown(results, durationMs, config);
357
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
358
+ }
359
+ catch (err) {
360
+ // Don't fail the run if the summary file can't be written
361
+ console.error(c.subtle(` ⚠ could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}`));
362
+ }
363
+ }
353
364
  // Terminal bell
354
365
  if (config.bell)
355
366
  process.stderr.write("\x07");
@@ -364,6 +375,16 @@ triageCmd.action(async (options) => {
364
375
  else {
365
376
  console.error(formatCrashError(error));
366
377
  }
378
+ // Best-effort GitHub Actions step summary on crash
379
+ if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
380
+ try {
381
+ const msg = error instanceof Error ? error.message : "Unknown error";
382
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${msg}\n\`\`\`\n`);
383
+ }
384
+ catch {
385
+ // ignore
386
+ }
387
+ }
367
388
  // Terminal bell even on crash
368
389
  if (config.bell)
369
390
  process.stderr.write("\x07");
@@ -24,3 +24,13 @@ export declare function extractCredentialHint(err: unknown): string | null;
24
24
  export declare function formatCrashError(error: unknown): string;
25
25
  export declare function formatCheckResults(results: CheckResult[]): string;
26
26
  export declare function formatResultJson(results: Map<string, NodeResult>): string;
27
+ /**
28
+ * Format triage results as GitHub-flavored markdown for `$GITHUB_STEP_SUMMARY`.
29
+ *
30
+ * Designed to render nicely in the GitHub Actions job summary panel:
31
+ * - Status line with an emoji for quick visual scanning
32
+ * - Links to any created issues or PRs
33
+ * - Findings table (for dry runs or no-action results)
34
+ * - Error detail when a node failed
35
+ */
36
+ export declare function formatDagResultMarkdown(results: Map<string, NodeResult>, durationMs: number, config?: CliConfig): string;
@@ -404,6 +404,126 @@ export function formatCheckResults(results) {
404
404
  export function formatResultJson(results) {
405
405
  return JSON.stringify(Object.fromEntries(results), null, 2);
406
406
  }
407
+ // ── Markdown output (for GitHub Actions step summary) ───────────
408
+ /**
409
+ * Format triage results as GitHub-flavored markdown for `$GITHUB_STEP_SUMMARY`.
410
+ *
411
+ * Designed to render nicely in the GitHub Actions job summary panel:
412
+ * - Status line with an emoji for quick visual scanning
413
+ * - Links to any created issues or PRs
414
+ * - Findings table (for dry runs or no-action results)
415
+ * - Error detail when a node failed
416
+ */
417
+ export function formatDagResultMarkdown(results, durationMs, config) {
418
+ const duration = formatDuration(durationMs);
419
+ const lines = [];
420
+ // Detect failure anywhere in the DAG
421
+ let failedNodeId;
422
+ let failedResult;
423
+ for (const [nodeId, result] of results) {
424
+ if (result.status === "failed") {
425
+ failedNodeId = nodeId;
426
+ failedResult = result;
427
+ break;
428
+ }
429
+ }
430
+ if (failedResult) {
431
+ lines.push(`## ❌ SWEny Triage Failed`);
432
+ lines.push("");
433
+ lines.push(`**Failed at:** \`${failedNodeId}\` `);
434
+ lines.push(`**Duration:** ${duration}`);
435
+ if (failedResult.data?.error) {
436
+ lines.push("");
437
+ lines.push("```");
438
+ lines.push(String(failedResult.data.error));
439
+ lines.push("```");
440
+ }
441
+ return lines.join("\n") + "\n";
442
+ }
443
+ const investigateData = results.get("investigate")?.data;
444
+ const issueData = results.get("create_issue")?.data;
445
+ const prData = results.get("create_pr")?.data;
446
+ const novelCount = investigateData?.novel_count ?? 0;
447
+ const highestSeverity = investigateData?.highest_severity;
448
+ const findings = investigateData?.findings;
449
+ // Header — pick the most informative title
450
+ if (prData?.prUrl) {
451
+ lines.push(`## ✅ SWEny Triage — PR opened`);
452
+ }
453
+ else if (issueData?.issueIdentifier || issueData?.issueUrl) {
454
+ lines.push(`## ✅ SWEny Triage — Issue created`);
455
+ }
456
+ else if (config?.dryRun) {
457
+ lines.push(`## 🔍 SWEny Triage — Dry run complete`);
458
+ }
459
+ else if (novelCount === 0) {
460
+ lines.push(`## ✅ SWEny Triage — No new incidents`);
461
+ }
462
+ else {
463
+ lines.push(`## ℹ️ SWEny Triage — No action taken`);
464
+ }
465
+ lines.push("");
466
+ lines.push(`**Duration:** ${duration} `);
467
+ if (config?.serviceFilter && config.serviceFilter !== "*") {
468
+ lines.push(`**Service filter:** \`${config.serviceFilter}\` `);
469
+ }
470
+ if (config?.timeRange) {
471
+ lines.push(`**Time range:** ${config.timeRange} `);
472
+ }
473
+ if (highestSeverity) {
474
+ lines.push(`**Highest severity:** ${highestSeverity} `);
475
+ }
476
+ lines.push(`**Novel findings:** ${novelCount}`);
477
+ // Issue and PR links
478
+ if (issueData?.issueIdentifier || issueData?.issueUrl) {
479
+ lines.push("");
480
+ lines.push("### 📋 Issue");
481
+ const id = issueData.issueIdentifier ? `**${String(issueData.issueIdentifier)}** — ` : "";
482
+ const title = issueData.issueTitle ? String(issueData.issueTitle) : "";
483
+ const url = issueData.issueUrl ? String(issueData.issueUrl) : "";
484
+ if (url) {
485
+ lines.push(`${id}[${title || url}](${url})`);
486
+ }
487
+ else {
488
+ lines.push(`${id}${title}`);
489
+ }
490
+ }
491
+ if (prData?.prUrl) {
492
+ lines.push("");
493
+ lines.push("### 🔧 Pull request");
494
+ const num = prData.prNumber ? `#${String(prData.prNumber)}` : "";
495
+ lines.push(`[${num || String(prData.prUrl)}](${String(prData.prUrl)})`);
496
+ }
497
+ // Findings table — mostly useful for dry runs and no-action results
498
+ if (findings && findings.length > 0 && !prData?.prUrl) {
499
+ lines.push("");
500
+ lines.push("### 🔎 Findings");
501
+ lines.push("");
502
+ lines.push("| Severity | Title | Status |");
503
+ lines.push("| --- | --- | --- |");
504
+ for (const f of findings.slice(0, 10)) {
505
+ const sev = String(f.severity ?? "—");
506
+ const title = String(f.title ?? "").replace(/\|/g, "\\|");
507
+ const status = f.is_duplicate ? "duplicate" : "novel";
508
+ lines.push(`| ${sev} | ${title} | ${status} |`);
509
+ }
510
+ if (findings.length > 10) {
511
+ lines.push("");
512
+ lines.push(`_…and ${findings.length - 10} more_`);
513
+ }
514
+ }
515
+ // Recommendation / next step
516
+ const rec = investigateData?.recommendation;
517
+ if (rec) {
518
+ lines.push("");
519
+ lines.push(`**Next:** ${String(rec)}`);
520
+ }
521
+ if (config?.dryRun) {
522
+ lines.push("");
523
+ lines.push("_Dry run mode — no side effects were taken._");
524
+ }
525
+ return lines.join("\n") + "\n";
526
+ }
407
527
  // ── Helpers ─────────────────────────────────────────────────────
408
528
  function formatDuration(ms) {
409
529
  const s = Math.round(ms / 1000);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatDagResultMarkdown } from "./output.js";
3
+ function success(data = {}) {
4
+ return { status: "success", data, toolCalls: [] };
5
+ }
6
+ function failed(data = {}) {
7
+ return { status: "failed", data, toolCalls: [] };
8
+ }
9
+ function minimalConfig(overrides = {}) {
10
+ return {
11
+ timeRange: "6h",
12
+ serviceFilter: "permit-service",
13
+ dryRun: false,
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("formatDagResultMarkdown", () => {
18
+ it("renders a success header with issue + PR when both exist", () => {
19
+ const results = new Map([
20
+ [
21
+ "investigate",
22
+ success({ novel_count: 1, highest_severity: "high", findings: [{ severity: "high", title: "500 on /parse" }] }),
23
+ ],
24
+ [
25
+ "create_issue",
26
+ success({
27
+ issueIdentifier: "OFF-1234",
28
+ issueTitle: "Parse failure",
29
+ issueUrl: "https://linear.app/acme/issue/OFF-1234",
30
+ }),
31
+ ],
32
+ ["create_pr", success({ prNumber: 42, prUrl: "https://github.com/acme/app/pull/42" })],
33
+ ]);
34
+ const md = formatDagResultMarkdown(results, 15_000, minimalConfig());
35
+ expect(md).toContain("## ✅ SWEny Triage — PR opened");
36
+ expect(md).toContain("**Duration:** 15s");
37
+ expect(md).toContain("**Service filter:** `permit-service`");
38
+ expect(md).toContain("**Highest severity:** high");
39
+ expect(md).toContain("OFF-1234");
40
+ expect(md).toContain("https://linear.app/acme/issue/OFF-1234");
41
+ expect(md).toContain("#42");
42
+ expect(md).toContain("https://github.com/acme/app/pull/42");
43
+ });
44
+ it("renders an issue-only result when no PR was opened", () => {
45
+ const results = new Map([
46
+ ["investigate", success({ novel_count: 1, highest_severity: "medium" })],
47
+ [
48
+ "create_issue",
49
+ success({ issueIdentifier: "OFF-5", issueTitle: "Investigate warning", issueUrl: "https://linear.app/x/5" }),
50
+ ],
51
+ ]);
52
+ const md = formatDagResultMarkdown(results, 5_000, minimalConfig());
53
+ expect(md).toContain("## ✅ SWEny Triage — Issue created");
54
+ expect(md).toContain("OFF-5");
55
+ expect(md).not.toContain("Pull request");
56
+ });
57
+ it("renders a clean no-incident result when novel_count is 0", () => {
58
+ const results = new Map([["investigate", success({ novel_count: 0 })]]);
59
+ const md = formatDagResultMarkdown(results, 3_000, minimalConfig());
60
+ expect(md).toContain("## ✅ SWEny Triage — No new incidents");
61
+ expect(md).toContain("**Novel findings:** 0");
62
+ });
63
+ it("renders a findings table in dry-run mode", () => {
64
+ const results = new Map([
65
+ [
66
+ "investigate",
67
+ success({
68
+ novel_count: 2,
69
+ highest_severity: "high",
70
+ findings: [
71
+ { severity: "high", title: "OOM in worker", is_duplicate: false },
72
+ { severity: "low", title: "Stale metric", is_duplicate: true },
73
+ ],
74
+ }),
75
+ ],
76
+ ]);
77
+ const md = formatDagResultMarkdown(results, 9_000, minimalConfig({ dryRun: true }));
78
+ expect(md).toContain("## 🔍 SWEny Triage — Dry run complete");
79
+ expect(md).toContain("| Severity | Title | Status |");
80
+ expect(md).toContain("| high | OOM in worker | novel |");
81
+ expect(md).toContain("| low | Stale metric | duplicate |");
82
+ expect(md).toContain("_Dry run mode — no side effects were taken._");
83
+ });
84
+ it("renders a failure summary when a node failed", () => {
85
+ const results = new Map([
86
+ ["gather", success()],
87
+ ["investigate", failed({ error: "Rate limited by Anthropic API" })],
88
+ ]);
89
+ const md = formatDagResultMarkdown(results, 2_000, minimalConfig());
90
+ expect(md).toContain("## ❌ SWEny Triage Failed");
91
+ expect(md).toContain("**Failed at:** `investigate`");
92
+ expect(md).toContain("Rate limited by Anthropic API");
93
+ });
94
+ it("escapes pipe characters in finding titles so the table stays valid", () => {
95
+ const results = new Map([
96
+ [
97
+ "investigate",
98
+ success({
99
+ novel_count: 1,
100
+ findings: [{ severity: "high", title: "broken | pipe | title" }],
101
+ }),
102
+ ],
103
+ ]);
104
+ const md = formatDagResultMarkdown(results, 1_000, minimalConfig({ dryRun: true }));
105
+ expect(md).toContain("broken \\| pipe \\| title");
106
+ });
107
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"