@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.
- package/dist/cli/config.js +6 -3
- package/dist/cli/config.test.d.ts +1 -0
- package/dist/cli/config.test.js +111 -0
- package/dist/cli/main.js +26 -2
- package/dist/cli/output.d.ts +24 -1
- package/dist/cli/output.js +203 -0
- package/dist/cli/output.test.d.ts +1 -0
- package/dist/cli/output.test.js +179 -0
- package/dist/mermaid.js +1 -1
- package/dist/mermaid.test.js +1 -1
- package/package.json +1 -1
package/dist/cli/config.js
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/dist/cli/output.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/output.js
CHANGED
|
@@ -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 ? "
|
|
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}` : "";
|
package/dist/mermaid.test.js
CHANGED
|
@@ -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
|
|
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]");
|