Skip to content

Commit 1d408cb

Browse files
committed
ci(danger): better markdown format
1 parent 3ca595b commit 1d408cb

File tree

2 files changed

+263
-0
lines changed

2 files changed

+263
-0
lines changed

util/danger/format.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// Licensed under the Apache License v2.0 with LLVM Exceptions.
3+
// See https://llvm.org/LICENSE.txt for license information.
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Copyright (c) 2025 Alan de Freitas ([email protected])
7+
//
8+
// Official repository: https://github.com/cppalliance/mrdocs
9+
//
10+
import { describe, expect, it } from "vitest";
11+
import { renderDangerReport } from "./format";
12+
import { summarizeScopes, type DangerResult } from "./logic";
13+
14+
describe("renderDangerReport", () => {
15+
it("puts warnings at the top as GitHub admonitions", () => {
16+
const summary = summarizeScopes([
17+
{ filename: "src/lib/example.cpp", additions: 10, deletions: 2 },
18+
{ filename: "docs/index.adoc", additions: 1, deletions: 0 },
19+
]);
20+
const result: DangerResult = {
21+
warnings: ["First issue", "Second issue"],
22+
summary,
23+
};
24+
25+
const output = renderDangerReport(result);
26+
27+
expect(output.startsWith("> 🚧 Danger.js checks for MrDocs")).toBe(true);
28+
expect(output).toContain("## ⚠️ Warnings");
29+
expect((output.match(/> \[!WARNING\]/g) || []).length).toBe(2);
30+
expect(output).toContain("## 🧾 Changes by Scope");
31+
expect(output).toMatch(/\|\s*\*\*Total\*\*\s*\|/);
32+
expect(output).toContain("Legend: Files + (added), Files ~ (modified), Files ↔ (renamed), Files - (removed)");
33+
expect(output).toContain("## 🔝 Top Files");
34+
});
35+
36+
it("formats scope totals with bold metrics and consistent churn", () => {
37+
const summary = summarizeScopes([
38+
{ filename: "src/lib/example.cpp", additions: 3, deletions: 1 },
39+
{ filename: "src/test/example_test.cpp", additions: 2, deletions: 0 },
40+
]);
41+
const result: DangerResult = { warnings: [], summary };
42+
43+
const output = renderDangerReport(result);
44+
45+
expect(output).toMatch(/\|\s*Source\s*\|\s*\*\*4\*\*\s*\|\s*3\s*\|\s*1\s*\|\s*\*\*1\*\*\s*\|\s*-\s*\|\s*1\s*\|\s*-\s*\|\s*-\s*\|/);
46+
expect(output).toMatch(/\|\s*Tests\s*\|\s*\*\*2\*\*\s*\|\s*2\s*\|\s*-\s*\|\s*\*\*1\*\*\s*\|\s*-\s*\|\s*1\s*\|\s*-\s*\|\s*-\s*\|/);
47+
expect(output).toMatch(/\|\s*\*\*Total\*\*\s*\|\s*\*\*6\*\*\s*\|\s*5\s*\|\s*1\s*\|\s*\*\*2\*\*\s*\|/);
48+
expect(output).toContain("## ✨ Highlights");
49+
expect(output.trim().startsWith("> 🚧 Danger.js checks for MrDocs")).toBe(true);
50+
});
51+
});

util/danger/format.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//
2+
// Licensed under the Apache License v2.0 with LLVM Exceptions.
3+
// See https://llvm.org/LICENSE.txt for license information.
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Copyright (c) 2025 Alan de Freitas ([email protected])
7+
//
8+
// Official repository: https://github.com/cppalliance/mrdocs
9+
//
10+
import { formatChurn, scopeDisplayOrder, type DangerResult, type ScopeReport, type ScopeTotals } from "./logic";
11+
12+
const notice = "> 🚧 Danger.js checks for MrDocs are experimental; expect some rough edges while we tune the rules.";
13+
14+
const scopeLabels: Record<string, string> = {
15+
source: "Source",
16+
tests: "Tests",
17+
"golden-tests": "Golden Tests",
18+
docs: "Docs",
19+
ci: "CI / Roadmap",
20+
build: "Build / Toolchain",
21+
tooling: "Tooling",
22+
"third-party": "Third-party",
23+
other: "Other",
24+
};
25+
26+
function labelForScope(scope: string): string {
27+
return scopeLabels[scope] ?? scope;
28+
}
29+
30+
/**
31+
* Pad cells so pipes align in the raw Markdown.
32+
*/
33+
function renderAlignedTable(headers: string[], alignRight: boolean[], rows: string[][]): string {
34+
const allRows = [headers, ...rows];
35+
const widths = headers.map((_, col) => Math.max(...allRows.map((r) => r[col].length)));
36+
37+
const formatCell = (text: string, col: number, right: boolean): string => {
38+
const width = widths[col];
39+
return right ? text.padStart(width, " ") : text.padEnd(width, " ");
40+
};
41+
42+
const renderRow = (cells: string[], isHeader = false): string =>
43+
`| ${cells
44+
.map((cell, idx) => formatCell(cell, idx, alignRight[idx]))
45+
.join(" | ")} |`;
46+
47+
const headerRow = renderRow(headers, true);
48+
const separatorCells = headers.map((_, idx) => {
49+
const width = Math.max(3, widths[idx]);
50+
if (alignRight[idx]) {
51+
const dashes = Math.max(3, width) - 1;
52+
return "-".repeat(dashes) + ":";
53+
}
54+
return "-".repeat(width);
55+
});
56+
const separatorRow = `| ${separatorCells.join(" | ")} |`;
57+
const bodyRows = rows.map((r) => renderRow(r));
58+
59+
return [headerRow, separatorRow, ...bodyRows].join("\n");
60+
}
61+
62+
/**
63+
* Format counts so zeros stay quiet while non-zero values draw attention.
64+
*/
65+
function formatCount(value: number, bold: boolean = true): string {
66+
if (value === 0) {
67+
return "-";
68+
}
69+
return bold ? `**${value}**` : `${value}`;
70+
}
71+
72+
/**
73+
* Render warnings as GitHub admonition blocks grouped under a heading.
74+
*/
75+
function renderWarnings(warnings: string[]): string {
76+
if (warnings.length === 0) {
77+
return "";
78+
}
79+
const blocks = warnings.map((message) => ["> [!WARNING]", `> ${message}`].join("\n"));
80+
return ["## ⚠️ Warnings", blocks.join("\n\n")].join("\n");
81+
}
82+
83+
/**
84+
* Render a single table combining change summary and per-scope breakdown.
85+
*/
86+
function renderChangeTable(summary: ScopeReport): string {
87+
const headers = ["Scope", "Lines Δ", "Lines +", "Lines -", "Files Δ", "Files +", "Files ~", "Files ↔", "Files -"];
88+
const alignRight = [false, true, true, true, true, true, true, true, true];
89+
90+
const sortedScopes = [...scopeDisplayOrder].sort((a, b) => {
91+
const ta = summary.totals[a];
92+
const tb = summary.totals[b];
93+
const churnA = ta.additions + ta.deletions;
94+
const churnB = tb.additions + tb.deletions;
95+
if (churnA === churnB) {
96+
return tb.files - ta.files;
97+
}
98+
return churnB - churnA;
99+
});
100+
101+
const scopeHasChange = (totals: ScopeTotals): boolean => {
102+
const churn = totals.additions + totals.deletions;
103+
const fileDelta = totals.status.added + totals.status.modified + totals.status.renamed - totals.status.removed;
104+
return churn !== 0 || fileDelta !== 0;
105+
};
106+
107+
const scopeRows = sortedScopes.filter((scope) => scopeHasChange(summary.totals[scope])).map((scope) => {
108+
const scoped: ScopeTotals = summary.totals[scope];
109+
const s = scoped.status;
110+
const fileDelta = s.added + s.modified + s.renamed - s.removed;
111+
const churn = scoped.additions + scoped.deletions;
112+
const fileDeltaBold = formatCount(fileDelta); // bold delta
113+
const label = labelForScope(scope);
114+
return [
115+
label,
116+
formatCount(churn),
117+
formatCount(scoped.additions, false),
118+
formatCount(scoped.deletions, false),
119+
fileDeltaBold,
120+
formatCount(s.added, false),
121+
formatCount(s.modified, false),
122+
formatCount(s.renamed, false),
123+
formatCount(s.removed, false),
124+
];
125+
});
126+
127+
const total = summary.overall;
128+
const totalStatus = total.status;
129+
const totalChurn = total.additions + total.deletions;
130+
const totalFileDelta = totalStatus.added + totalStatus.modified + totalStatus.renamed - totalStatus.removed;
131+
const totalRow = [
132+
"**Total**",
133+
formatCount(totalChurn),
134+
formatCount(total.additions, false),
135+
formatCount(total.deletions, false),
136+
formatCount(totalFileDelta),
137+
formatCount(totalStatus.added, false),
138+
formatCount(totalStatus.modified, false),
139+
formatCount(totalStatus.renamed, false),
140+
formatCount(totalStatus.removed, false),
141+
];
142+
143+
const rows = scopeRows.length > 0 ? scopeRows.concat([totalRow]) : [["(no changes)", "-", "-", "-", "-", "-", "-", "-", "-"]];
144+
145+
const table = renderAlignedTable(headers, alignRight, rows);
146+
const legend = "Legend: Files + (added), Files ~ (modified), Files ↔ (renamed), Files - (removed)";
147+
148+
return ["## 🧾 Changes by Scope", table, "", `> ${legend}`].join("\n");
149+
}
150+
151+
/**
152+
* Render highlight bullets, keeping the section compact.
153+
*/
154+
function renderHighlights(highlights: string[]): string {
155+
if (highlights.length === 0) {
156+
return "## ✨ Highlights\n- None noted.";
157+
}
158+
const decorated = highlights.map((note) => {
159+
const lower = note.toLowerCase();
160+
if (lower.includes("golden")) {
161+
return `- 🧪 ${note}`;
162+
}
163+
if (lower.includes("test")) {
164+
return `- 🧪 ${note}`;
165+
}
166+
if (lower.includes("doc")) {
167+
return `- 📄 ${note}`;
168+
}
169+
if (lower.includes("source")) {
170+
return `- 🛠️ ${note}`;
171+
}
172+
if (lower.includes("ci") || lower.includes("workflow") || lower.includes("pipeline")) {
173+
return `- ⚙️ ${note}`;
174+
}
175+
if (lower.includes("build")) {
176+
return `- 🏗️ ${note}`;
177+
}
178+
return `- ✨ ${note}`;
179+
});
180+
return ["## ✨ Highlights", ...decorated].join("\n");
181+
}
182+
183+
/**
184+
* Render a short "Top changes" summary from the highest-churn scopes.
185+
*/
186+
function renderTopChanges(summary: ScopeReport): string {
187+
if (!summary.topFiles || summary.topFiles.length === 0) {
188+
return "";
189+
}
190+
191+
const bullets = summary.topFiles.map((file) => {
192+
const scopeLabel = labelForScope(file.scope);
193+
return `- ${file.filename} (${scopeLabel}): **${file.churn}** lines Δ (+${file.additions} / -${file.deletions})`;
194+
});
195+
196+
return ["### 🔝 Top Files", ...bullets].join("\n");
197+
}
198+
199+
/**
200+
* Build the full Danger Markdown report as a single, structured comment.
201+
*/
202+
export function renderDangerReport(result: DangerResult): string {
203+
const sections = [
204+
notice,
205+
renderWarnings(result.warnings),
206+
renderHighlights(result.summary.highlights),
207+
renderChangeTable(result.summary),
208+
renderTopChanges(result.summary),
209+
].filter(Boolean);
210+
211+
return sections.join("\n\n");
212+
}

0 commit comments

Comments
 (0)