Skip to content

Commit 13b37ad

Browse files
KryptosAIclaude
andauthored
feat: compact pr-comment format for GitHub Action PR comments (#80)
Add --format pr-comment — a compact, severity-tiered output for PR comments (10 lines vs 146 for markdown). Updates GitHub Action to use it by default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fbd4746 commit 13b37ad

7 files changed

Lines changed: 404 additions & 16 deletions

File tree

action/action.yml

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ runs:
113113
echo "artifact_path=" >> "$GITHUB_OUTPUT"
114114
fi
115115
116-
# Generate markdown report
116+
# Generate PR comment report
117117
if [ -n "$ARTIFACT_PATH" ]; then
118-
mcp-observatory report "$ARTIFACT_PATH" --format markdown --no-color > "${ARTIFACT_DIR}/report.md" 2>/dev/null || true
118+
mcp-observatory report "$ARTIFACT_PATH" --format pr-comment --no-color > "${ARTIFACT_DIR}/report.md" 2>/dev/null || true
119119
fi
120120
121121
- name: Verify against baseline
@@ -161,16 +161,9 @@ runs:
161161
exit 0
162162
fi
163163
164-
# Build comment body file to avoid shell expansion issues
164+
# pr-comment format already includes header and footer
165165
COMMENT_FILE="${RUNNER_TEMP}/observatory/comment.md"
166-
{
167-
echo "## MCP Observatory Report"
168-
echo ""
169-
cat "$REPORT_FILE"
170-
echo ""
171-
echo "---"
172-
echo "<sub>Generated by <a href=\"https://github.com/KryptosAI/mcp-observatory\">MCP Observatory</a> action</sub>"
173-
} > "$COMMENT_FILE"
166+
cp "$REPORT_FILE" "$COMMENT_FILE"
174167
175168
# Use --body-file to avoid shell injection via report content
176169
gh pr comment "$PR_NUMBER" \

src/commands/diff.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ export function registerDiffCommands(program: Command): void {
1212
.description("Compare two runs and show regressions and schema drift.")
1313
.argument("<base>", "Base run artifact JSON file.")
1414
.argument("<head>", "Head run artifact JSON file.")
15-
.option("--format <format>", "terminal, json, markdown, html, junit, or sarif", "terminal")
15+
.option("--format <format>", "terminal, json, markdown, pr-comment, html, junit, or sarif", "terminal")
1616
.option("--output <file>", "Write to file instead of stdout.")
1717
.option("--no-color", "Disable colored output.")
1818
.option("--fail-on-regression", "Exit with code 1 when regressions are present.", false)
1919
.action(
2020
async (base: string, head: string, options: {
2121
failOnRegression?: boolean;
22-
format: "html" | "json" | "junit" | "markdown" | "sarif" | "terminal";
22+
format: "html" | "json" | "junit" | "markdown" | "pr-comment" | "sarif" | "terminal";
2323
output?: string;
2424
}) => {
2525
const baseArtifact = await readArtifact(base);

src/commands/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type TargetConfig,
99
} from "../index.js";
1010
import { renderJUnit } from "../reporters/junit.js";
11+
import { renderPrComment } from "../reporters/pr-comment.js";
1112
import { renderSarif } from "../reporters/sarif.js";
1213
import { validateTargetConfig } from "../validate.js";
1314

@@ -121,10 +122,11 @@ export async function resolveTarget(options: { target?: string }): Promise<Targe
121122

122123
export function formatOutput(
123124
artifact: Parameters<typeof renderTerminal>[0],
124-
format: "html" | "json" | "junit" | "markdown" | "sarif" | "terminal",
125+
format: "html" | "json" | "junit" | "markdown" | "pr-comment" | "sarif" | "terminal",
125126
): string {
126127
if (format === "json") return JSON.stringify(artifact, null, 2);
127128
if (format === "markdown") return renderMarkdown(artifact);
129+
if (format === "pr-comment") return renderPrComment(artifact);
128130
if (format === "html") return renderHtml(artifact);
129131
if (format === "junit" && artifact.artifactType === "run") return renderJUnit(artifact);
130132
if (format === "sarif" && artifact.artifactType === "run") return renderSarif(artifact);

src/commands/legacy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ export function registerLegacyCommands(program: Command): void {
7272
.command("report", { hidden: true })
7373
.description("Render a run artifact.")
7474
.requiredOption("--run <artifact>", "Run artifact JSON.")
75-
.option("--format <format>", "terminal, markdown, json, or html", "terminal")
75+
.option("--format <format>", "terminal, markdown, pr-comment, json, or html", "terminal")
7676
.option("--output <file>", "Write to file instead of stdout.")
7777
.option("--no-color", "Disable colored output.")
7878
.action(
7979
async (options: {
80-
format: "html" | "json" | "junit" | "markdown" | "sarif" | "terminal";
80+
format: "html" | "json" | "junit" | "markdown" | "pr-comment" | "sarif" | "terminal";
8181
output?: string;
8282
run: string;
8383
}) => {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { scanForTargets } from "./discovery.js";
1515
export { renderHtml } from "./reporters/html.js";
1616
export { renderJUnit } from "./reporters/junit.js";
1717
export { renderMarkdown } from "./reporters/markdown.js";
18+
export { renderPrComment } from "./reporters/pr-comment.js";
1819
export { renderSarif } from "./reporters/sarif.js";
1920
export { renderTerminal, renderWatchFirstRun, renderWatchNoChanges, renderWatchChanges } from "./reporters/terminal.js";
2021
export { runTarget, runTargetRecording, type RunOptions, type RunResult } from "./runner.js";

src/reporters/pr-comment.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import type { CheckResult, DiffArtifact, RunArtifact, SchemaDriftEntry } from "../types.js";
2+
import { findChecksByStatus } from "./common.js";
3+
4+
// ── Types ───────────────────────────────────────────────────────────────────
5+
6+
interface ParsedFinding {
7+
severity: string;
8+
message: string;
9+
}
10+
11+
// ── Constants ───────────────────────────────────────────────────────────────
12+
13+
const MAX_ITEMS_PER_SECTION = 5;
14+
const REPO_URL = "https://github.com/KryptosAI/mcp-observatory";
15+
16+
// ── Extraction helpers ──────────────────────────────────────────────────────
17+
18+
function extractSecurityFindings(checks: CheckResult[]): ParsedFinding[] {
19+
const securityChecks = checks.filter(c => c.id === "security" || c.id === "security-lite");
20+
const findings: ParsedFinding[] = [];
21+
for (const check of securityChecks) {
22+
for (const ev of check.evidence) {
23+
for (const diag of ev.diagnostics ?? []) {
24+
const match = diag.match(/^\[(high|medium|low)]\s+(.+)$/);
25+
if (match) {
26+
findings.push({ severity: match[1]!, message: match[2]! });
27+
}
28+
}
29+
}
30+
}
31+
return findings;
32+
}
33+
34+
function extractQualityFindings(checks: CheckResult[]): ParsedFinding[] {
35+
const qualityChecks = checks.filter(c => c.id === "schema-quality");
36+
const findings: ParsedFinding[] = [];
37+
for (const check of qualityChecks) {
38+
for (const ev of check.evidence) {
39+
for (const diag of ev.diagnostics ?? []) {
40+
const match = diag.match(/^\[(warning|info)]\s+(.+)$/i);
41+
if (match) {
42+
findings.push({ severity: match[1]!, message: match[2]! });
43+
}
44+
}
45+
}
46+
}
47+
return findings;
48+
}
49+
50+
function countCapabilities(checks: CheckResult[]): { tools: number; prompts: number; resources: number } {
51+
const get = (id: string) => {
52+
const check = checks.find(c => c.id === id);
53+
return check?.evidence[0]?.itemCount ?? 0;
54+
};
55+
return { tools: get("tools"), prompts: get("prompts"), resources: get("resources") };
56+
}
57+
58+
function conformanceSummary(checks: CheckResult[]): string | undefined {
59+
const check = checks.find(c => c.id === "conformance");
60+
if (!check || check.status === "pass") return undefined;
61+
return check.message;
62+
}
63+
64+
// ── Formatting helpers ──────────────────────────────────────────────────────
65+
66+
function blockquoteList(items: string[], max = MAX_ITEMS_PER_SECTION): string {
67+
const shown = items.slice(0, max);
68+
const lines = shown.map(item => `> ${item}`);
69+
const remaining = items.length - max;
70+
if (remaining > 0) {
71+
lines.push(`> ...and ${remaining} more`);
72+
}
73+
return lines.join("\n");
74+
}
75+
76+
function footer(): string {
77+
return [
78+
"",
79+
"---",
80+
`<sub>🔭 <a href="${REPO_URL}">MCP Observatory</a> — test your MCP servers for breaking changes · <a href="${REPO_URL}">⭐ Star</a></sub>`,
81+
].join("\n");
82+
}
83+
84+
// ── Run artifact rendering ──────────────────────────────────────────────────
85+
86+
function renderRunComment(artifact: RunArtifact): string {
87+
const sections: string[] = [];
88+
const security = extractSecurityFindings(artifact.checks);
89+
const quality = extractQualityFindings(artifact.checks);
90+
const failingChecks = findChecksByStatus(artifact.checks, "fail")
91+
.filter(c => c.id !== "security" && c.id !== "security-lite");
92+
const conformance = conformanceSummary(artifact.checks);
93+
94+
const highMedSecurity = security.filter(f => f.severity === "high" || f.severity === "medium");
95+
const issueCount = highMedSecurity.length + failingChecks.length + quality.length + (conformance ? 1 : 0);
96+
97+
// Header
98+
if (issueCount === 0) {
99+
sections.push("## 🔭 MCP Observatory — All clear ✅");
100+
sections.push("");
101+
sections.push("All checks passed. No security issues, no schema quality warnings.");
102+
} else {
103+
sections.push(`## 🔭 MCP Observatory — ${issueCount} issue${issueCount === 1 ? "" : "s"} found`);
104+
}
105+
106+
// Security (red)
107+
if (highMedSecurity.length > 0) {
108+
const highCount = highMedSecurity.filter(f => f.severity === "high").length;
109+
const medCount = highMedSecurity.filter(f => f.severity === "medium").length;
110+
const parts: string[] = [];
111+
if (highCount > 0) parts.push(`${highCount} high`);
112+
if (medCount > 0) parts.push(`${medCount} medium`);
113+
sections.push("");
114+
sections.push(`### 🔴 SECURITY (${parts.join(", ")})`);
115+
sections.push(blockquoteList(highMedSecurity.map(f => `\`${f.severity}\` ${f.message}`)));
116+
}
117+
118+
// Failing checks (red)
119+
if (failingChecks.length > 0) {
120+
sections.push("");
121+
sections.push(`### 🔴 FAILING (${failingChecks.length})`);
122+
sections.push(blockquoteList(failingChecks.map(c => `**${c.id}**: ${c.message}`)));
123+
}
124+
125+
// Quality warnings (yellow)
126+
const qualityItems: string[] = [];
127+
for (const f of quality) {
128+
qualityItems.push(f.message);
129+
}
130+
if (conformance) {
131+
qualityItems.push(`Conformance: ${conformance}`);
132+
}
133+
if (qualityItems.length > 0) {
134+
sections.push("");
135+
sections.push(`### ⚠️ QUALITY (${qualityItems.length} warning${qualityItems.length === 1 ? "" : "s"})`);
136+
sections.push(blockquoteList(qualityItems));
137+
}
138+
139+
// Summary stats
140+
const caps = countCapabilities(artifact.checks);
141+
const statsLine = [
142+
artifact.healthScore
143+
? `Health: **${artifact.healthScore.grade}** (${artifact.healthScore.overall})`
144+
: `Gate: **${artifact.gate}**`,
145+
`${caps.tools} tools`,
146+
`${caps.prompts} prompts`,
147+
`${caps.resources} resources`,
148+
].join(" · ");
149+
150+
sections.push("");
151+
sections.push(`### 📊 Summary`);
152+
sections.push(`> ${statsLine}`);
153+
154+
sections.push(footer());
155+
return sections.join("\n");
156+
}
157+
158+
// ── Diff artifact rendering ─────────────────────────────────────────────────
159+
160+
function renderDiffComment(artifact: DiffArtifact): string {
161+
const sections: string[] = [];
162+
const { regressions, recoveries, schemaDrift } = artifact;
163+
const driftCount = schemaDrift?.length ?? 0;
164+
const totalIssues = regressions.length + driftCount;
165+
166+
// Header
167+
if (totalIssues === 0) {
168+
sections.push("## 🔭 MCP Observatory — No regressions ✅");
169+
sections.push("");
170+
sections.push("All checks stable. No regressions, no schema drift.");
171+
} else {
172+
const parts: string[] = [];
173+
if (regressions.length > 0) parts.push(`${regressions.length} regression${regressions.length === 1 ? "" : "s"}`);
174+
if (driftCount > 0) parts.push(`${driftCount} schema change${driftCount === 1 ? "" : "s"}`);
175+
sections.push(`## 🔭 MCP Observatory — ${parts.join(", ")} detected`);
176+
}
177+
178+
// Regressions (red)
179+
if (regressions.length > 0) {
180+
sections.push("");
181+
sections.push(`### 🔴 REGRESSIONS (${regressions.length})`);
182+
sections.push(blockquoteList(regressions.map(r => {
183+
const transition = r.fromStatus && r.toStatus ? `${r.fromStatus}${r.toStatus}` : "";
184+
return `**${r.id}**: ${transition}${transition ? " — " : ""}${r.message}`;
185+
})));
186+
}
187+
188+
// Schema drift (red)
189+
if (schemaDrift && schemaDrift.length > 0) {
190+
sections.push("");
191+
sections.push(`### 🔴 SCHEMA DRIFT (${schemaDrift.length})`);
192+
const driftLines = flattenDrift(schemaDrift);
193+
sections.push(blockquoteList(driftLines));
194+
}
195+
196+
// Recoveries (green)
197+
if (recoveries.length > 0) {
198+
sections.push("");
199+
sections.push(`### ✅ RECOVERED (${recoveries.length})`);
200+
sections.push(blockquoteList(recoveries.map(r => {
201+
const transition = r.fromStatus && r.toStatus ? `${r.fromStatus}${r.toStatus}` : "";
202+
return `**${r.id}**: ${transition}`;
203+
})));
204+
}
205+
206+
// Summary
207+
const { summary } = artifact;
208+
const statsLine = [
209+
`Gate: **${artifact.gate}**`,
210+
`Regressions: ${summary.regressions}`,
211+
`Recoveries: ${summary.recoveries}`,
212+
`Unchanged: ${summary.unchanged}`,
213+
].join(" · ");
214+
215+
sections.push("");
216+
sections.push(`### 📊 Summary`);
217+
sections.push(`> ${statsLine}`);
218+
219+
sections.push(footer());
220+
return sections.join("\n");
221+
}
222+
223+
function flattenDrift(drift: SchemaDriftEntry[]): string[] {
224+
const lines: string[] = [];
225+
for (const entry of drift) {
226+
for (const change of entry.changes) {
227+
lines.push(`**${entry.name}**: ${change}`);
228+
}
229+
}
230+
return lines;
231+
}
232+
233+
// ── Public API ──────────────────────────────────────────────────────────────
234+
235+
export function renderPrComment(artifact: RunArtifact | DiffArtifact): string {
236+
return artifact.artifactType === "run"
237+
? renderRunComment(artifact)
238+
: renderDiffComment(artifact);
239+
}

0 commit comments

Comments
 (0)