@sweny-ai/core 0.1.45 → 0.1.47
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/__tests__/version-consistency.test.js +87 -0
- package/dist/cli/main.js +5 -2
- package/dist/cli/output.d.ts +20 -7
- package/dist/cli/output.js +152 -69
- package/dist/cli/output.test.js +82 -10
- package/dist/mermaid.js +1 -1
- package/dist/mermaid.test.js +1 -1
- package/package.json +1 -1
|
@@ -196,4 +196,91 @@ describe("docs action code blocks", () => {
|
|
|
196
196
|
allIssues.map((i) => ` ${i}`).join("\n"));
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
|
+
it("every swenyai/sweny@v5 code block has a workflow: input", () => {
|
|
200
|
+
const issues = [];
|
|
201
|
+
for (const f of ACTION_DOC_FILES) {
|
|
202
|
+
const filePath = resolve(ROOT, f);
|
|
203
|
+
if (!existsSync(filePath))
|
|
204
|
+
continue;
|
|
205
|
+
const content = readFileSync(filePath, "utf-8");
|
|
206
|
+
const blocks = content.match(/```ya?ml[\s\S]*?```/g) || [];
|
|
207
|
+
for (const block of blocks) {
|
|
208
|
+
if (!block.includes(`swenyai/sweny@${CURRENT_ACTION_VERSION}`))
|
|
209
|
+
continue;
|
|
210
|
+
if (!block.includes("workflow:")) {
|
|
211
|
+
issues.push(`${relative(ROOT, filePath)}: code block uses swenyai/sweny@${CURRENT_ACTION_VERSION} without workflow: input`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (issues.length > 0) {
|
|
216
|
+
expect.fail(`The generic runner requires a workflow: input.\n` + issues.map((i) => ` ${i}`).join("\n"));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// ─── 5. Internal doc links resolve ──────────────────────────────────
|
|
221
|
+
describe("docs internal links", () => {
|
|
222
|
+
const DOCS_ROOT = resolve(ROOT, "packages/web/src/content/docs");
|
|
223
|
+
/**
|
|
224
|
+
* Extract markdown links like [text](/path/) and verify the target exists.
|
|
225
|
+
* Only checks internal absolute links (starting with /).
|
|
226
|
+
*/
|
|
227
|
+
function findBrokenLinks(filePath) {
|
|
228
|
+
if (!existsSync(filePath))
|
|
229
|
+
return [];
|
|
230
|
+
const content = readFileSync(filePath, "utf-8");
|
|
231
|
+
const broken = [];
|
|
232
|
+
// Match [text](/path/) and [text](/path/#anchor) but skip external URLs
|
|
233
|
+
const linkPattern = /\[([^\]]*)\]\(\/([^)#]*?)\/?\)/g;
|
|
234
|
+
let match;
|
|
235
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
236
|
+
const linkPath = match[2];
|
|
237
|
+
// Map /action/ → action/index.md, /action/inputs/ → action/inputs.md
|
|
238
|
+
const candidates = [
|
|
239
|
+
resolve(DOCS_ROOT, linkPath, "index.md"),
|
|
240
|
+
resolve(DOCS_ROOT, linkPath, "index.mdx"),
|
|
241
|
+
resolve(DOCS_ROOT, `${linkPath}.md`),
|
|
242
|
+
resolve(DOCS_ROOT, `${linkPath}.mdx`),
|
|
243
|
+
];
|
|
244
|
+
if (!candidates.some((c) => existsSync(c))) {
|
|
245
|
+
broken.push(`${relative(ROOT, filePath)}: broken link [${match[1]}](/${linkPath}/)`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return broken;
|
|
249
|
+
}
|
|
250
|
+
const KEY_DOCS = [
|
|
251
|
+
"packages/web/src/content/docs/action/index.md",
|
|
252
|
+
"packages/web/src/content/docs/action/inputs.md",
|
|
253
|
+
"packages/web/src/content/docs/action/examples.md",
|
|
254
|
+
"packages/web/src/content/docs/action/scheduling.md",
|
|
255
|
+
"packages/web/src/content/docs/action/service-map.md",
|
|
256
|
+
"packages/web/src/content/docs/index.mdx",
|
|
257
|
+
"packages/web/src/content/docs/getting-started/quick-start.md",
|
|
258
|
+
];
|
|
259
|
+
it("key action docs have no broken internal links", () => {
|
|
260
|
+
const allBroken = KEY_DOCS.flatMap((f) => findBrokenLinks(resolve(ROOT, f)));
|
|
261
|
+
if (allBroken.length > 0) {
|
|
262
|
+
expect.fail(`Found ${allBroken.length} broken link(s):\n` + allBroken.map((b) => ` ${b}`).join("\n"));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
// ─── 6. Three-action architecture documented ────────────────────────
|
|
267
|
+
describe("three-action architecture", () => {
|
|
268
|
+
it("action/index.md documents all three actions", () => {
|
|
269
|
+
const content = readFileSync(resolve(ROOT, "packages/web/src/content/docs/action/index.md"), "utf-8");
|
|
270
|
+
expect(content).toContain("swenyai/triage@v1");
|
|
271
|
+
expect(content).toContain("swenyai/e2e@v1");
|
|
272
|
+
expect(content).toContain(`swenyai/sweny@${CURRENT_ACTION_VERSION}`);
|
|
273
|
+
});
|
|
274
|
+
it("action/inputs.md has sections for all three actions", () => {
|
|
275
|
+
const content = readFileSync(resolve(ROOT, "packages/web/src/content/docs/action/inputs.md"), "utf-8");
|
|
276
|
+
expect(content).toMatch(/## Generic runner inputs/);
|
|
277
|
+
expect(content).toMatch(/## Triage action inputs/);
|
|
278
|
+
expect(content).toMatch(/## E2E action inputs/);
|
|
279
|
+
});
|
|
280
|
+
it("README Actions table lists all three actions", () => {
|
|
281
|
+
const readme = readFileSync(resolve(ROOT, "README.md"), "utf-8");
|
|
282
|
+
expect(readme).toContain("swenyai/triage@v1");
|
|
283
|
+
expect(readme).toContain("swenyai/e2e@v1");
|
|
284
|
+
expect(readme).toContain(`swenyai/sweny@${CURRENT_ACTION_VERSION}`);
|
|
285
|
+
});
|
|
199
286
|
});
|
package/dist/cli/main.js
CHANGED
|
@@ -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,
|
|
@@ -353,7 +353,10 @@ triageCmd.action(async (options) => {
|
|
|
353
353
|
// GitHub Actions step summary
|
|
354
354
|
if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
|
|
355
355
|
try {
|
|
356
|
-
const md = formatDagResultMarkdown(results, durationMs, config
|
|
356
|
+
const md = formatDagResultMarkdown(results, durationMs, config, {
|
|
357
|
+
workflow: triageWorkflow,
|
|
358
|
+
trace,
|
|
359
|
+
});
|
|
357
360
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
|
|
358
361
|
}
|
|
359
362
|
catch (err) {
|
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;
|
|
@@ -27,10 +27,23 @@ export declare function formatResultJson(results: Map<string, NodeResult>): stri
|
|
|
27
27
|
/**
|
|
28
28
|
* Format triage results as GitHub-flavored markdown for `$GITHUB_STEP_SUMMARY`.
|
|
29
29
|
*
|
|
30
|
-
* Designed to render
|
|
31
|
-
* - Status
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
35
42
|
*/
|
|
36
|
-
export
|
|
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,19 +405,10 @@ export function formatCheckResults(results) {
|
|
|
404
405
|
export function formatResultJson(results) {
|
|
405
406
|
return JSON.stringify(Object.fromEntries(results), null, 2);
|
|
406
407
|
}
|
|
407
|
-
|
|
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) {
|
|
408
|
+
export function formatDagResultMarkdown(results, durationMs, config, options = {}) {
|
|
418
409
|
const duration = formatDuration(durationMs);
|
|
419
410
|
const lines = [];
|
|
411
|
+
const { workflow, trace } = options;
|
|
420
412
|
// Detect failure anywhere in the DAG
|
|
421
413
|
let failedNodeId;
|
|
422
414
|
let failedResult;
|
|
@@ -427,33 +419,24 @@ export function formatDagResultMarkdown(results, durationMs, config) {
|
|
|
427
419
|
break;
|
|
428
420
|
}
|
|
429
421
|
}
|
|
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
422
|
const investigateData = results.get("investigate")?.data;
|
|
444
423
|
const issueData = results.get("create_issue")?.data;
|
|
445
424
|
const prData = results.get("create_pr")?.data;
|
|
446
425
|
const novelCount = investigateData?.novel_count ?? 0;
|
|
447
426
|
const highestSeverity = investigateData?.highest_severity;
|
|
448
427
|
const findings = investigateData?.findings;
|
|
449
|
-
|
|
450
|
-
|
|
428
|
+
const isDryRun = Boolean(config?.dryRun);
|
|
429
|
+
// ── Header ────────────────────────────────────────────────────
|
|
430
|
+
if (failedResult) {
|
|
431
|
+
lines.push(`## ❌ SWEny Triage Failed`);
|
|
432
|
+
}
|
|
433
|
+
else if (prData?.prUrl) {
|
|
451
434
|
lines.push(`## ✅ SWEny Triage — PR opened`);
|
|
452
435
|
}
|
|
453
436
|
else if (issueData?.issueIdentifier || issueData?.issueUrl) {
|
|
454
437
|
lines.push(`## ✅ SWEny Triage — Issue created`);
|
|
455
438
|
}
|
|
456
|
-
else if (
|
|
439
|
+
else if (isDryRun) {
|
|
457
440
|
lines.push(`## 🔍 SWEny Triage — Dry run complete`);
|
|
458
441
|
}
|
|
459
442
|
else if (novelCount === 0) {
|
|
@@ -463,67 +446,167 @@ export function formatDagResultMarkdown(results, durationMs, config) {
|
|
|
463
446
|
lines.push(`## ℹ️ SWEny Triage — No action taken`);
|
|
464
447
|
}
|
|
465
448
|
lines.push("");
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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}`);
|
|
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";
|
|
489
455
|
}
|
|
456
|
+
lines.push(toMermaidBlock(workflow, { state, trace, title: workflow.name }));
|
|
457
|
+
lines.push("");
|
|
490
458
|
}
|
|
491
|
-
|
|
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
|
+
}
|
|
492
500
|
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
501
|
}
|
|
497
|
-
//
|
|
498
|
-
if (
|
|
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
|
|
499
512
|
lines.push("");
|
|
513
|
+
appendNodeDetails(lines, results);
|
|
514
|
+
return lines.join("\n") + "\n";
|
|
515
|
+
}
|
|
516
|
+
// ── Findings table ────────────────────────────────────────────
|
|
517
|
+
if (findings && findings.length > 0) {
|
|
500
518
|
lines.push("### 🔎 Findings");
|
|
501
519
|
lines.push("");
|
|
502
|
-
lines.push("| Severity | Title | Status |");
|
|
503
|
-
lines.push("| --- | --- | --- |");
|
|
504
|
-
for (
|
|
520
|
+
lines.push("| # | Severity | Title | Complexity | Status |");
|
|
521
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
522
|
+
for (let i = 0; i < findings.length; i++) {
|
|
523
|
+
const f = findings[i];
|
|
505
524
|
const sev = String(f.severity ?? "—");
|
|
506
|
-
const title = String(f.title ?? "").replace(/\|/g, "\\|");
|
|
507
|
-
const
|
|
508
|
-
|
|
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} |`);
|
|
509
529
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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}` : ""}`);
|
|
513
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("");
|
|
514
549
|
}
|
|
515
|
-
// Recommendation / next step
|
|
550
|
+
// ── Recommendation / next step ────────────────────────────────
|
|
516
551
|
const rec = investigateData?.recommendation;
|
|
517
552
|
if (rec) {
|
|
518
|
-
lines.push("");
|
|
519
553
|
lines.push(`**Next:** ${String(rec)}`);
|
|
520
|
-
}
|
|
521
|
-
if (config?.dryRun) {
|
|
522
554
|
lines.push("");
|
|
555
|
+
}
|
|
556
|
+
if (isDryRun) {
|
|
523
557
|
lines.push("_Dry run mode — no side effects were taken._");
|
|
558
|
+
lines.push("");
|
|
524
559
|
}
|
|
560
|
+
// ── Node execution details (collapsible) ──────────────────────
|
|
561
|
+
appendNodeDetails(lines, results);
|
|
525
562
|
return lines.join("\n") + "\n";
|
|
526
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
|
+
}
|
|
527
610
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
528
611
|
function formatDuration(ms) {
|
|
529
612
|
const s = Math.round(ms / 1000);
|
package/dist/cli/output.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { formatDagResultMarkdown } from "./output.js";
|
|
3
|
+
import { triageWorkflow } from "../workflows/index.js";
|
|
3
4
|
function success(data = {}) {
|
|
4
5
|
return { status: "success", data, toolCalls: [] };
|
|
5
6
|
}
|
|
@@ -10,10 +11,41 @@ function minimalConfig(overrides = {}) {
|
|
|
10
11
|
return {
|
|
11
12
|
timeRange: "6h",
|
|
12
13
|
serviceFilter: "permit-service",
|
|
14
|
+
repository: "acme/app",
|
|
15
|
+
observabilityProvider: "betterstack",
|
|
16
|
+
issueTrackerProvider: "linear",
|
|
13
17
|
dryRun: false,
|
|
14
18
|
...overrides,
|
|
15
19
|
};
|
|
16
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
|
+
}
|
|
17
49
|
describe("formatDagResultMarkdown", () => {
|
|
18
50
|
it("renders a success header with issue + PR when both exist", () => {
|
|
19
51
|
const results = new Map([
|
|
@@ -33,13 +65,15 @@ describe("formatDagResultMarkdown", () => {
|
|
|
33
65
|
]);
|
|
34
66
|
const md = formatDagResultMarkdown(results, 15_000, minimalConfig());
|
|
35
67
|
expect(md).toContain("## ✅ SWEny Triage — PR opened");
|
|
36
|
-
expect(md).toContain("
|
|
37
|
-
expect(md).toContain("
|
|
38
|
-
expect(md).toContain("
|
|
68
|
+
expect(md).toContain("| Duration | 15s |");
|
|
69
|
+
expect(md).toContain("| Service filter | `permit-service` |");
|
|
70
|
+
expect(md).toContain("| Highest severity | high |");
|
|
39
71
|
expect(md).toContain("OFF-1234");
|
|
40
72
|
expect(md).toContain("https://linear.app/acme/issue/OFF-1234");
|
|
41
73
|
expect(md).toContain("#42");
|
|
42
74
|
expect(md).toContain("https://github.com/acme/app/pull/42");
|
|
75
|
+
expect(md).toContain("**Issue created:**");
|
|
76
|
+
expect(md).toContain("**PR opened:**");
|
|
43
77
|
});
|
|
44
78
|
it("renders an issue-only result when no PR was opened", () => {
|
|
45
79
|
const results = new Map([
|
|
@@ -52,13 +86,13 @@ describe("formatDagResultMarkdown", () => {
|
|
|
52
86
|
const md = formatDagResultMarkdown(results, 5_000, minimalConfig());
|
|
53
87
|
expect(md).toContain("## ✅ SWEny Triage — Issue created");
|
|
54
88
|
expect(md).toContain("OFF-5");
|
|
55
|
-
expect(md).not.toContain("
|
|
89
|
+
expect(md).not.toContain("**PR opened:**");
|
|
56
90
|
});
|
|
57
91
|
it("renders a clean no-incident result when novel_count is 0", () => {
|
|
58
92
|
const results = new Map([["investigate", success({ novel_count: 0 })]]);
|
|
59
93
|
const md = formatDagResultMarkdown(results, 3_000, minimalConfig());
|
|
60
94
|
expect(md).toContain("## ✅ SWEny Triage — No new incidents");
|
|
61
|
-
expect(md).toContain("
|
|
95
|
+
expect(md).toContain("| Novel findings | 0 |");
|
|
62
96
|
});
|
|
63
97
|
it("renders a findings table in dry-run mode", () => {
|
|
64
98
|
const results = new Map([
|
|
@@ -68,18 +102,19 @@ describe("formatDagResultMarkdown", () => {
|
|
|
68
102
|
novel_count: 2,
|
|
69
103
|
highest_severity: "high",
|
|
70
104
|
findings: [
|
|
71
|
-
{ severity: "high", title: "OOM in worker", is_duplicate: false },
|
|
72
|
-
{ severity: "low", title: "Stale metric", is_duplicate: true },
|
|
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" },
|
|
73
107
|
],
|
|
74
108
|
}),
|
|
75
109
|
],
|
|
76
110
|
]);
|
|
77
111
|
const md = formatDagResultMarkdown(results, 9_000, minimalConfig({ dryRun: true }));
|
|
78
112
|
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 |
|
|
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 |");
|
|
82
116
|
expect(md).toContain("_Dry run mode — no side effects were taken._");
|
|
117
|
+
expect(md).toContain("| Mode | dry run |");
|
|
83
118
|
});
|
|
84
119
|
it("renders a failure summary when a node failed", () => {
|
|
85
120
|
const results = new Map([
|
|
@@ -104,4 +139,41 @@ describe("formatDagResultMarkdown", () => {
|
|
|
104
139
|
const md = formatDagResultMarkdown(results, 1_000, minimalConfig({ dryRun: true }));
|
|
105
140
|
expect(md).toContain("broken \\| pipe \\| title");
|
|
106
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
|
+
});
|
|
107
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]");
|