@sweny-ai/core 0.1.44 → 0.1.46

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.
@@ -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) ────────────────────────────────────────
@@ -336,7 +336,7 @@ triageCmd.action(async (options) => {
336
336
  contextUrls,
337
337
  };
338
338
  try {
339
- const { results } = await execute(triageWorkflow, workflowInput, {
339
+ const { results, trace } = await execute(triageWorkflow, workflowInput, {
340
340
  skills,
341
341
  claude,
342
342
  observer,
@@ -350,6 +350,20 @@ 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
+ workflow: triageWorkflow,
358
+ trace,
359
+ });
360
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
361
+ }
362
+ catch (err) {
363
+ // Don't fail the run if the summary file can't be written
364
+ console.error(c.subtle(` ⚠ could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}`));
365
+ }
366
+ }
353
367
  // Terminal bell
354
368
  if (config.bell)
355
369
  process.stderr.write("\x07");
@@ -364,6 +378,16 @@ triageCmd.action(async (options) => {
364
378
  else {
365
379
  console.error(formatCrashError(error));
366
380
  }
381
+ // Best-effort GitHub Actions step summary on crash
382
+ if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
383
+ try {
384
+ const msg = error instanceof Error ? error.message : "Unknown error";
385
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${msg}\n\`\`\`\n`);
386
+ }
387
+ catch {
388
+ // ignore
389
+ }
390
+ }
367
391
  // Terminal bell even on crash
368
392
  if (config.bell)
369
393
  process.stderr.write("\x07");
@@ -1,6 +1,6 @@
1
1
  import type { CliConfig } from "./config.js";
2
2
  import type { CheckResult } from "./check.js";
3
- import type { NodeResult } from "../types.js";
3
+ import type { NodeResult, ExecutionTrace, Workflow } from "../types.js";
4
4
  export declare const c: {
5
5
  brand: import("chalk").ChalkInstance;
6
6
  brandDim: import("chalk").ChalkInstance;
@@ -24,3 +24,26 @@ 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 richly in the GitHub Actions job summary panel:
31
+ * - Status header with an emoji
32
+ * - Mermaid workflow diagram colored by node execution state (when a
33
+ * workflow + trace are supplied; this is the headline feature of the
34
+ * job summary view)
35
+ * - Config table (repository, providers, time range, mode)
36
+ * - Workflow path showing actual execution sequence including loop iterations
37
+ * - Routing decisions (collapsible) when the DAG had conditional edges
38
+ * - Findings table
39
+ * - Actions taken (issue/PR links)
40
+ * - Recommendation / next step
41
+ * - Node execution details (collapsible) with tool-call summaries
42
+ */
43
+ export interface FormatMarkdownOptions {
44
+ /** Workflow definition — required for the Mermaid diagram. */
45
+ workflow?: Workflow;
46
+ /** Execution trace from `execute()` — colors the diagram and drives the path/routing sections. */
47
+ trace?: ExecutionTrace;
48
+ }
49
+ export declare function formatDagResultMarkdown(results: Map<string, NodeResult>, durationMs: number, config?: CliConfig, options?: FormatMarkdownOptions): string;
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { toMermaidBlock } from "../mermaid.js";
2
3
  // ── Color palette ───────────────────────────────────────────────
3
4
  export const c = {
4
5
  brand: chalk.hex("#FF6B2B"),
@@ -404,6 +405,208 @@ export function formatCheckResults(results) {
404
405
  export function formatResultJson(results) {
405
406
  return JSON.stringify(Object.fromEntries(results), null, 2);
406
407
  }
408
+ export function formatDagResultMarkdown(results, durationMs, config, options = {}) {
409
+ const duration = formatDuration(durationMs);
410
+ const lines = [];
411
+ const { workflow, trace } = options;
412
+ // Detect failure anywhere in the DAG
413
+ let failedNodeId;
414
+ let failedResult;
415
+ for (const [nodeId, result] of results) {
416
+ if (result.status === "failed") {
417
+ failedNodeId = nodeId;
418
+ failedResult = result;
419
+ break;
420
+ }
421
+ }
422
+ const investigateData = results.get("investigate")?.data;
423
+ const issueData = results.get("create_issue")?.data;
424
+ const prData = results.get("create_pr")?.data;
425
+ const novelCount = investigateData?.novel_count ?? 0;
426
+ const highestSeverity = investigateData?.highest_severity;
427
+ const findings = investigateData?.findings;
428
+ const isDryRun = Boolean(config?.dryRun);
429
+ // ── Header ────────────────────────────────────────────────────
430
+ if (failedResult) {
431
+ lines.push(`## ❌ SWEny Triage Failed`);
432
+ }
433
+ else if (prData?.prUrl) {
434
+ lines.push(`## ✅ SWEny Triage — PR opened`);
435
+ }
436
+ else if (issueData?.issueIdentifier || issueData?.issueUrl) {
437
+ lines.push(`## ✅ SWEny Triage — Issue created`);
438
+ }
439
+ else if (isDryRun) {
440
+ lines.push(`## 🔍 SWEny Triage — Dry run complete`);
441
+ }
442
+ else if (novelCount === 0) {
443
+ lines.push(`## ✅ SWEny Triage — No new incidents`);
444
+ }
445
+ else {
446
+ lines.push(`## ℹ️ SWEny Triage — No action taken`);
447
+ }
448
+ lines.push("");
449
+ // ── Workflow diagram (Mermaid) ────────────────────────────────
450
+ // Requires a workflow definition; trace is optional but makes it much more useful.
451
+ if (workflow) {
452
+ const state = {};
453
+ for (const [nodeId, result] of results) {
454
+ state[nodeId] = result.status === "success" ? "success" : result.status === "failed" ? "failed" : "skipped";
455
+ }
456
+ lines.push(toMermaidBlock(workflow, { state, trace, title: workflow.name }));
457
+ lines.push("");
458
+ }
459
+ // ── Config / run metadata table ───────────────────────────────
460
+ const rows = [];
461
+ if (config?.repository)
462
+ rows.push(["Repository", `\`${config.repository}\``]);
463
+ if (config?.observabilityProvider)
464
+ rows.push(["Observability", config.observabilityProvider]);
465
+ if (config?.issueTrackerProvider)
466
+ rows.push(["Issue tracker", config.issueTrackerProvider]);
467
+ if (config?.timeRange)
468
+ rows.push(["Time range", config.timeRange]);
469
+ if (config?.serviceFilter && config.serviceFilter !== "*") {
470
+ rows.push(["Service filter", `\`${config.serviceFilter}\``]);
471
+ }
472
+ if (highestSeverity)
473
+ rows.push(["Highest severity", highestSeverity]);
474
+ rows.push(["Novel findings", String(novelCount)]);
475
+ rows.push(["Duration", duration]);
476
+ rows.push(["Mode", isDryRun ? "dry run" : "live"]);
477
+ lines.push("| Setting | Value |");
478
+ lines.push("| --- | --- |");
479
+ for (const [k, v] of rows)
480
+ lines.push(`| ${k} | ${v} |`);
481
+ lines.push("");
482
+ // ── Workflow path (actual execution sequence including loops) ─
483
+ if (trace && trace.steps.length > 0) {
484
+ const pathSteps = trace.steps.map((s) => {
485
+ const tag = s.iteration > 1 ? ` ×${s.iteration}` : "";
486
+ return `\`${s.node}${tag}\``;
487
+ });
488
+ lines.push(`**Workflow path:** ${pathSteps.join(" → ")}`);
489
+ const routingLines = trace.edges
490
+ .filter((e) => e.reason !== "only path")
491
+ .map((e) => `- \`${e.from}\` → \`${e.to}\` *(${e.reason})*`);
492
+ if (routingLines.length > 0) {
493
+ lines.push("");
494
+ lines.push("<details><summary>Routing decisions</summary>");
495
+ lines.push("");
496
+ lines.push(...routingLines);
497
+ lines.push("");
498
+ lines.push("</details>");
499
+ }
500
+ lines.push("");
501
+ }
502
+ // ── Failure detail (short-circuit) ────────────────────────────
503
+ if (failedResult) {
504
+ lines.push(`**Failed at:** \`${failedNodeId}\``);
505
+ if (failedResult.data?.error) {
506
+ lines.push("");
507
+ lines.push("```");
508
+ lines.push(String(failedResult.data.error));
509
+ lines.push("```");
510
+ }
511
+ // Still render node details below so debugging is easy
512
+ lines.push("");
513
+ appendNodeDetails(lines, results);
514
+ return lines.join("\n") + "\n";
515
+ }
516
+ // ── Findings table ────────────────────────────────────────────
517
+ if (findings && findings.length > 0) {
518
+ lines.push("### 🔎 Findings");
519
+ lines.push("");
520
+ lines.push("| # | Severity | Title | Complexity | Status |");
521
+ lines.push("| --- | --- | --- | --- | --- |");
522
+ for (let i = 0; i < findings.length; i++) {
523
+ const f = findings[i];
524
+ const sev = String(f.severity ?? "—");
525
+ const title = String(f.title ?? "—").replace(/\|/g, "\\|");
526
+ const complexity = String(f.fix_complexity ?? "—");
527
+ const status = f.is_duplicate ? `dup of ${f.duplicate_of ?? "existing"}` : "novel";
528
+ lines.push(`| ${i + 1} | ${sev} | ${title} | ${complexity} | ${status} |`);
529
+ }
530
+ lines.push("");
531
+ }
532
+ // ── Actions taken ─────────────────────────────────────────────
533
+ if (issueData?.issueIdentifier || issueData?.issueUrl || prData?.prUrl) {
534
+ lines.push("### Actions taken");
535
+ lines.push("");
536
+ if (issueData?.issueIdentifier || issueData?.issueUrl) {
537
+ const id = issueData.issueIdentifier ? String(issueData.issueIdentifier) : "";
538
+ const title = issueData.issueTitle ? String(issueData.issueTitle) : "";
539
+ const url = issueData.issueUrl ? String(issueData.issueUrl) : "";
540
+ const link = url ? `[${id || url}](${url})` : id;
541
+ lines.push(`- **Issue created:** ${link}${title ? ` — ${title}` : ""}`);
542
+ }
543
+ if (prData?.prUrl) {
544
+ const num = prData.prNumber ? `#${String(prData.prNumber)}` : "";
545
+ const link = `[${num || String(prData.prUrl)}](${String(prData.prUrl)})`;
546
+ lines.push(`- **PR opened:** ${link}`);
547
+ }
548
+ lines.push("");
549
+ }
550
+ // ── Recommendation / next step ────────────────────────────────
551
+ const rec = investigateData?.recommendation;
552
+ if (rec) {
553
+ lines.push(`**Next:** ${String(rec)}`);
554
+ lines.push("");
555
+ }
556
+ if (isDryRun) {
557
+ lines.push("_Dry run mode — no side effects were taken._");
558
+ lines.push("");
559
+ }
560
+ // ── Node execution details (collapsible) ──────────────────────
561
+ appendNodeDetails(lines, results);
562
+ return lines.join("\n") + "\n";
563
+ }
564
+ function appendNodeDetails(lines, results) {
565
+ if (results.size === 0)
566
+ return;
567
+ lines.push("<details><summary>Node execution details</summary>");
568
+ lines.push("");
569
+ for (const [nodeId, result] of results) {
570
+ const icon = result.status === "success" ? "✓" : result.status === "failed" ? "✗" : "—";
571
+ const toolCount = result.toolCalls?.length ?? 0;
572
+ lines.push(`#### ${icon} \`${nodeId}\` — ${toolCount} tool call${toolCount === 1 ? "" : "s"}`);
573
+ lines.push("");
574
+ if (toolCount > 0 && result.toolCalls) {
575
+ lines.push("| Tool | Input (truncated) |");
576
+ lines.push("| --- | --- |");
577
+ for (const tc of result.toolCalls.slice(0, 20)) {
578
+ const name = tc.tool ?? "—";
579
+ const input = summarizeToolInput(tc.input).replace(/\|/g, "\\|");
580
+ lines.push(`| \`${name}\` | ${input || "—"} |`);
581
+ }
582
+ if (toolCount > 20) {
583
+ lines.push(`| … | _${toolCount - 20} more tool calls_ |`);
584
+ }
585
+ lines.push("");
586
+ }
587
+ }
588
+ lines.push("</details>");
589
+ lines.push("");
590
+ }
591
+ function summarizeToolInput(input) {
592
+ if (input === undefined || input === null)
593
+ return "";
594
+ if (typeof input === "string")
595
+ return truncate(input, 100);
596
+ if (typeof input !== "object")
597
+ return truncate(String(input), 100);
598
+ const parts = [];
599
+ for (const [k, v] of Object.entries(input)) {
600
+ if (typeof v === "string")
601
+ parts.push(`${k}=${truncate(v, 40)}`);
602
+ else if (typeof v === "number" || typeof v === "boolean")
603
+ parts.push(`${k}=${v}`);
604
+ }
605
+ return truncate(parts.join(", "), 100);
606
+ }
607
+ function truncate(s, n) {
608
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
609
+ }
407
610
  // ── Helpers ─────────────────────────────────────────────────────
408
611
  function formatDuration(ms) {
409
612
  const s = Math.round(ms / 1000);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatDagResultMarkdown } from "./output.js";
3
+ import { triageWorkflow } from "../workflows/index.js";
4
+ function success(data = {}) {
5
+ return { status: "success", data, toolCalls: [] };
6
+ }
7
+ function failed(data = {}) {
8
+ return { status: "failed", data, toolCalls: [] };
9
+ }
10
+ function minimalConfig(overrides = {}) {
11
+ return {
12
+ timeRange: "6h",
13
+ serviceFilter: "permit-service",
14
+ repository: "acme/app",
15
+ observabilityProvider: "betterstack",
16
+ issueTrackerProvider: "linear",
17
+ dryRun: false,
18
+ ...overrides,
19
+ };
20
+ }
21
+ /** Build a minimal fake workflow for tests that don't need the full triage graph. */
22
+ function fakeWorkflow() {
23
+ return {
24
+ id: "test",
25
+ name: "Test Workflow",
26
+ description: "Minimal fixture for output.test.ts — not a real DAG.",
27
+ entry: "investigate",
28
+ nodes: {
29
+ investigate: { name: "Investigate", instruction: "noop", skills: [] },
30
+ create_issue: { name: "Create Issue", instruction: "noop", skills: [] },
31
+ create_pr: { name: "Open PR", instruction: "noop", skills: [] },
32
+ },
33
+ edges: [
34
+ { from: "investigate", to: "create_issue" },
35
+ { from: "create_issue", to: "create_pr" },
36
+ ],
37
+ };
38
+ }
39
+ function fakeTrace(steps) {
40
+ return {
41
+ steps: steps.map((s) => ({
42
+ node: s.node,
43
+ status: "success",
44
+ iteration: s.iteration ?? 1,
45
+ })),
46
+ edges: [],
47
+ };
48
+ }
49
+ describe("formatDagResultMarkdown", () => {
50
+ it("renders a success header with issue + PR when both exist", () => {
51
+ const results = new Map([
52
+ [
53
+ "investigate",
54
+ success({ novel_count: 1, highest_severity: "high", findings: [{ severity: "high", title: "500 on /parse" }] }),
55
+ ],
56
+ [
57
+ "create_issue",
58
+ success({
59
+ issueIdentifier: "OFF-1234",
60
+ issueTitle: "Parse failure",
61
+ issueUrl: "https://linear.app/acme/issue/OFF-1234",
62
+ }),
63
+ ],
64
+ ["create_pr", success({ prNumber: 42, prUrl: "https://github.com/acme/app/pull/42" })],
65
+ ]);
66
+ const md = formatDagResultMarkdown(results, 15_000, minimalConfig());
67
+ expect(md).toContain("## ✅ SWEny Triage — PR opened");
68
+ expect(md).toContain("| Duration | 15s |");
69
+ expect(md).toContain("| Service filter | `permit-service` |");
70
+ expect(md).toContain("| Highest severity | high |");
71
+ expect(md).toContain("OFF-1234");
72
+ expect(md).toContain("https://linear.app/acme/issue/OFF-1234");
73
+ expect(md).toContain("#42");
74
+ expect(md).toContain("https://github.com/acme/app/pull/42");
75
+ expect(md).toContain("**Issue created:**");
76
+ expect(md).toContain("**PR opened:**");
77
+ });
78
+ it("renders an issue-only result when no PR was opened", () => {
79
+ const results = new Map([
80
+ ["investigate", success({ novel_count: 1, highest_severity: "medium" })],
81
+ [
82
+ "create_issue",
83
+ success({ issueIdentifier: "OFF-5", issueTitle: "Investigate warning", issueUrl: "https://linear.app/x/5" }),
84
+ ],
85
+ ]);
86
+ const md = formatDagResultMarkdown(results, 5_000, minimalConfig());
87
+ expect(md).toContain("## ✅ SWEny Triage — Issue created");
88
+ expect(md).toContain("OFF-5");
89
+ expect(md).not.toContain("**PR opened:**");
90
+ });
91
+ it("renders a clean no-incident result when novel_count is 0", () => {
92
+ const results = new Map([["investigate", success({ novel_count: 0 })]]);
93
+ const md = formatDagResultMarkdown(results, 3_000, minimalConfig());
94
+ expect(md).toContain("## ✅ SWEny Triage — No new incidents");
95
+ expect(md).toContain("| Novel findings | 0 |");
96
+ });
97
+ it("renders a findings table in dry-run mode", () => {
98
+ const results = new Map([
99
+ [
100
+ "investigate",
101
+ success({
102
+ novel_count: 2,
103
+ highest_severity: "high",
104
+ findings: [
105
+ { severity: "high", title: "OOM in worker", is_duplicate: false, fix_complexity: "medium" },
106
+ { severity: "low", title: "Stale metric", is_duplicate: true, duplicate_of: "OFF-99" },
107
+ ],
108
+ }),
109
+ ],
110
+ ]);
111
+ const md = formatDagResultMarkdown(results, 9_000, minimalConfig({ dryRun: true }));
112
+ expect(md).toContain("## 🔍 SWEny Triage — Dry run complete");
113
+ expect(md).toContain("| # | Severity | Title | Complexity | Status |");
114
+ expect(md).toContain("| 1 | high | OOM in worker | medium | novel |");
115
+ expect(md).toContain("| 2 | low | Stale metric | — | dup of OFF-99 |");
116
+ expect(md).toContain("_Dry run mode — no side effects were taken._");
117
+ expect(md).toContain("| Mode | dry run |");
118
+ });
119
+ it("renders a failure summary when a node failed", () => {
120
+ const results = new Map([
121
+ ["gather", success()],
122
+ ["investigate", failed({ error: "Rate limited by Anthropic API" })],
123
+ ]);
124
+ const md = formatDagResultMarkdown(results, 2_000, minimalConfig());
125
+ expect(md).toContain("## ❌ SWEny Triage Failed");
126
+ expect(md).toContain("**Failed at:** `investigate`");
127
+ expect(md).toContain("Rate limited by Anthropic API");
128
+ });
129
+ it("escapes pipe characters in finding titles so the table stays valid", () => {
130
+ const results = new Map([
131
+ [
132
+ "investigate",
133
+ success({
134
+ novel_count: 1,
135
+ findings: [{ severity: "high", title: "broken | pipe | title" }],
136
+ }),
137
+ ],
138
+ ]);
139
+ const md = formatDagResultMarkdown(results, 1_000, minimalConfig({ dryRun: true }));
140
+ expect(md).toContain("broken \\| pipe \\| title");
141
+ });
142
+ it("embeds a mermaid workflow diagram when workflow is supplied", () => {
143
+ const results = new Map([
144
+ ["investigate", success({ novel_count: 0 })],
145
+ ["create_issue", success({ issueIdentifier: "OFF-7", issueUrl: "https://linear.app/x/7" })],
146
+ ]);
147
+ const workflow = fakeWorkflow();
148
+ const md = formatDagResultMarkdown(results, 4_000, minimalConfig(), { workflow });
149
+ expect(md).toContain("```mermaid");
150
+ expect(md).toContain("graph TB");
151
+ expect(md).toContain("title: Test Workflow");
152
+ // Status classes should be applied for nodes that ran successfully
153
+ expect(md).toContain("class investigate,create_issue success");
154
+ });
155
+ it("renders the workflow path and iteration counts from the execution trace", () => {
156
+ const results = new Map([
157
+ ["investigate", success({ novel_count: 1 })],
158
+ ["create_issue", success({ issueIdentifier: "OFF-8" })],
159
+ ]);
160
+ const workflow = fakeWorkflow();
161
+ const trace = fakeTrace([
162
+ { node: "investigate" },
163
+ { node: "create_issue" },
164
+ { node: "create_issue", iteration: 2 },
165
+ ]);
166
+ const md = formatDagResultMarkdown(results, 8_000, minimalConfig(), { workflow, trace });
167
+ expect(md).toContain("**Workflow path:** `investigate` → `create_issue` → `create_issue ×2`");
168
+ });
169
+ it("accepts the real triageWorkflow without throwing", () => {
170
+ // Regression guard: the real workflow must serialise cleanly through toMermaidBlock
171
+ // so we do not ship a broken diagram to every downstream consumer.
172
+ const results = new Map([["investigate", success({ novel_count: 0 })]]);
173
+ const md = formatDagResultMarkdown(results, 1_000, minimalConfig(), {
174
+ workflow: triageWorkflow,
175
+ });
176
+ expect(md).toContain("```mermaid");
177
+ expect(md).toContain(`title: ${triageWorkflow.name}`);
178
+ });
179
+ });
package/dist/mermaid.js CHANGED
@@ -43,7 +43,7 @@ export function toMermaid(workflow, options = {}) {
43
43
  for (const [id, node] of Object.entries(workflow.nodes)) {
44
44
  let label = escapeLabel(node.name);
45
45
  const isEntry = id === workflow.entry;
46
- const entryTag = isEntry ? " \\u25B6" : "";
46
+ const entryTag = isEntry ? " \u25B6" : "";
47
47
  // Show iteration count for nodes that ran multiple times
48
48
  const iterations = nodeIterations.get(id);
49
49
  const iterTag = iterations && iterations > 1 ? ` ×${iterations}` : "";
@@ -55,7 +55,7 @@ describe("toMermaid", () => {
55
55
  const result = toMermaid(simple);
56
56
  expect(result).toContain("graph TB");
57
57
  // Entry node gets stadium shape
58
- expect(result).toContain("a([Triage \\u25B6])");
58
+ expect(result).toContain("a([Triage \u25B6])");
59
59
  // Non-entry nodes get default shape
60
60
  expect(result).toContain("b[Investigate]");
61
61
  expect(result).toContain("c[Report]");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"