From 7e92d2041cadf6eeee1519ddae486e83eeabacf8 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Thu, 5 Mar 2026 20:35:12 +0100 Subject: [PATCH] feat(render): add project coverage summary and base-head diff table --- src/index.ts | 7 ++- src/render.ts | 137 +++++++++++++++++++++++++++++++++++++++++++-- src/render_test.ts | 135 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 269 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 25be8aa..013eb4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,6 +119,7 @@ async function run(): Promise { // Process each tool const toolReports = []; let anyDecrease = false; + let baseSha: string | undefined; for (const input of inputs) { core.info(`Processing ${input.tool} coverage from ${input.path}`); @@ -134,6 +135,10 @@ async function run(): Promise { core.warning(`Could not restore base artifact for ${input.tool}`); } + if (baseArtifact && !baseSha) { + baseSha = baseArtifact.commitSha; + } + const report = buildToolReport(input.tool, headFiles, baseArtifact, warnings); toolReports.push(report); @@ -163,7 +168,7 @@ async function run(): Promise { // Render markdown const { owner, repo } = github.context.repo; - const commitInfo = showCommitLink ? { sha: commitSha, owner, repo } : undefined; + const commitInfo = showCommitLink ? { sha: commitSha, baseSha, owner, repo } : undefined; const markdown = renderReport(fullReport, marker, colorize, commitInfo); // Set outputs diff --git a/src/render.ts b/src/render.ts index b7562eb..53d71b4 100644 --- a/src/render.ts +++ b/src/render.ts @@ -5,6 +5,7 @@ import type { export interface CommitInfo { sha: string; + baseSha?: string; owner: string; repo: string; } @@ -59,6 +60,112 @@ function renderToolSection(report: ToolCoverageReport, colorize: boolean): strin return lines.join("\n"); } +function renderCoverageDiff(report: CoverageReport): string | null { + const o = report.overall; + if (o.basePercent === null) return null; + + const baseCovered = report.tools.reduce((s, t) => s + (t.summary.baseCoveredLines ?? 0), 0); + const baseTotal = report.tools.reduce((s, t) => s + (t.summary.baseTotalLines ?? 0), 0); + const headFiles = report.tools.reduce( + (s, t) => s + t.files.filter((f) => f.totalLines > 0).length, + 0, + ); + const baseFiles = report.tools.reduce( + (s, t) => s + t.files.filter((f) => (f.baseTotalLines ?? 0) > 0).length, + 0, + ); + + function fmtDiff(head: number, base: number): string { + const d = head - base; + if (d === 0) return ""; + return d > 0 ? `+${d}` : String(d); + } + + function fmtPctDiff(d: number | null): string { + if (d === null || d === 0) return ""; + return `${d > 0 ? "+" : ""}${d.toFixed(2)}%`; + } + + const hitsDelta = o.coveredLines - baseCovered; + + type RowData = { prefix: string; label: string; base: string; head: string; diff: string; }; + const rows: (RowData | "sep")[] = [ + { + prefix: " ", + label: "Coverage", + base: `${o.basePercent.toFixed(2)}%`, + head: `${o.percent.toFixed(2)}%`, + diff: fmtPctDiff(o.delta), + }, + "sep", + { + prefix: " ", + label: "Files", + base: String(baseFiles), + head: String(headFiles), + diff: fmtDiff(headFiles, baseFiles), + }, + { + prefix: " ", + label: "Lines", + base: String(baseTotal), + head: String(o.totalLines), + diff: fmtDiff(o.totalLines, baseTotal), + }, + "sep", + { + prefix: hitsDelta > 0 ? "+" : hitsDelta < 0 ? "-" : " ", + label: "Hits", + base: String(baseCovered), + head: String(o.coveredLines), + diff: fmtDiff(o.coveredLines, baseCovered), + }, + ]; + + const actual = rows.filter((r): r is RowData => r !== "sep"); + const lw = Math.max(...actual.map((r) => r.label.length)); + const bw = Math.max(4, ...actual.map((r) => r.base.length)); + const hw = Math.max(4, ...actual.map((r) => r.head.length)); + const dw = Math.max(3, ...actual.map((r) => r.diff.length)); + + function fmtRow(r: RowData): string { + return `${r.prefix} ${r.label.padEnd(lw)} ${r.base.padStart(bw)} ${ + r.head.padStart(hw) + } ${r.diff.padStart(dw)}`; + } + + const rowLen = fmtRow(actual[0]).length; + const w = Math.max(rowLen + 4, 40); + + const sep = "=".repeat(w); + + const title = "Coverage Diff"; + const innerW = w - 4; + const tPad = innerW - title.length; + const tl = Math.floor(tPad / 2); + const tr = tPad - tl; + + const colInner = ` ${" ".repeat(lw)} ${"base".padStart(bw)} ${"head".padStart(hw)} ${ + " +/-".padStart(dw) + }`; + + const lines: string[] = [ + `@@${" ".repeat(tl)}${title}${" ".repeat(tr)}@@`, + `##${colInner.padEnd(innerW)}##`, + ]; + + for (const row of rows) { + if (row === "sep") { + lines.push(sep); + } else { + lines.push(fmtRow(row)); + } + } + lines.push(sep); + + return lines.join("\n"); +} + export function renderReport( report: CoverageReport, marker: string, @@ -70,11 +177,33 @@ export function renderReport( parts.push(marker); parts.push("## Coverage Report\n"); - if (commitInfo) { - const short = commitInfo.sha.slice(0, 7); - const url = + // Project coverage summary line + const pct = report.overall.percent.toFixed(2); + if (commitInfo?.baseSha) { + const headShort = commitInfo.sha.slice(0, 7); + const headUrl = + `https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.sha}`; + const baseShort = commitInfo.baseSha.slice(0, 7); + const baseUrl = + `https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.baseSha}`; + parts.push( + `Project coverage is ${pct}%. Comparing base ([\`${baseShort}\`](${baseUrl})) to head ([\`${headShort}\`](${headUrl})).\n`, + ); + } else if (commitInfo) { + const headShort = commitInfo.sha.slice(0, 7); + const headUrl = `https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.sha}`; - parts.push(`[Commit ${short}](${url})\n`); + parts.push(`Project coverage is ${pct}%. Commit [\`${headShort}\`](${headUrl}).\n`); + } else { + parts.push(`Project coverage is ${pct}%.\n`); + } + + // Coverage Diff table (only when base data is available) + const diffTable = renderCoverageDiff(report); + if (diffTable) { + parts.push("```diff"); + parts.push(diffTable); + parts.push("```\n"); } for (const tool of report.tools) { diff --git a/src/render_test.ts b/src/render_test.ts index 5188fa4..dbe22d8 100644 --- a/src/render_test.ts +++ b/src/render_test.ts @@ -37,11 +37,20 @@ describe("renderReport", () => { expect(md).toContain(""); expect(md).toContain("## Coverage Report"); + expect(md).toContain("Project coverage is 90.00%."); expect(md).toContain("src/index.ts"); expect(md).toContain("src/utils.ts"); expect(md).toContain("[+]"); // positive delta expect(md).toContain("Bun Coverage: 90.00%"); expect(md).toContain("xseman/coverage"); + // Coverage Diff table + expect(md).toContain("Coverage Diff"); + expect(md).toContain("```diff"); + expect(md).toContain("@@"); + expect(md).toContain("Coverage"); + expect(md).toContain("Files"); + expect(md).toContain("Lines"); + expect(md).toContain("Hits"); // Single tool — no separate "Total Coverage" line expect(md).not.toContain("**Total Coverage:"); }); @@ -57,6 +66,9 @@ describe("renderReport", () => { expect(md).not.toContain("[+]"); expect(md).not.toContain("[-]"); expect(md).toContain("Go Coverage: 50.00%"); + expect(md).toContain("Project coverage is 50.00%."); + // No base — no diff table + expect(md).not.toContain("Coverage Diff"); }); test("renders warning rows", () => { @@ -91,31 +103,144 @@ describe("renderReport", () => { expect(md).toContain("**Total Coverage: 65.00%**"); }); - test("renders commit link when commitInfo is provided", () => { + test("renders project coverage with base and head commit links", () => { const head: FileCoverage[] = [ { file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 }, ]; - const toolReport = buildToolReport("bun", head, null, []); + const base: CoverageArtifact = { + tool: "bun", + files: [{ file: "src/index.ts", coveredLines: 7, totalLines: 10, percent: 70 }], + commitSha: "base123", + branch: "main", + timestamp: "2025-01-01T00:00:00Z", + }; + const toolReport = buildToolReport("bun", head, base, []); const fullReport = buildFullReport([toolReport]); const commitInfo: CommitInfo = { sha: "abc1234567890", + baseSha: "def9876543210", owner: "myorg", repo: "myrepo", }; const md = renderReport(fullReport, "", true, commitInfo); - expect(md).toContain("[Commit abc1234]"); + expect(md).toContain("Project coverage is 80.00%."); + expect(md).toContain("Comparing base ([`def9876`]"); + expect(md).toContain("to head ([`abc1234`]"); + expect(md).toContain("https://github.com/myorg/myrepo/commit/def9876543210"); expect(md).toContain("https://github.com/myorg/myrepo/commit/abc1234567890"); }); - test("omits commit link when commitInfo is not provided", () => { + test("renders commit link without base when baseSha is absent", () => { const head: FileCoverage[] = [ { file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 }, ]; const toolReport = buildToolReport("bun", head, null, []); const fullReport = buildFullReport([toolReport]); + const commitInfo: CommitInfo = { + sha: "abc1234567890", + owner: "myorg", + repo: "myrepo", + }; + const md = renderReport(fullReport, "", true, commitInfo); + + expect(md).toContain("Project coverage is 80.00%."); + expect(md).toContain("Commit [`abc1234`]"); + expect(md).toContain("https://github.com/myorg/myrepo/commit/abc1234567890"); + expect(md).not.toContain("Comparing base"); + }); + + test("renders project coverage without commit info", () => { + const head: FileCoverage[] = [ + { file: "a.ts", coveredLines: 5, totalLines: 10, percent: 50 }, + ]; + const toolReport = buildToolReport("bun", head, null, []); + const fullReport = buildFullReport([toolReport]); + const md = renderReport(fullReport, "", true); + + expect(md).toContain("Project coverage is 50.00%."); + expect(md).not.toContain("Commit"); + expect(md).not.toContain("Comparing"); + }); + + test("coverage diff table shows correct values", () => { + const head: FileCoverage[] = [ + { file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 }, + { file: "src/utils.ts", coveredLines: 10, totalLines: 10, percent: 100 }, + ]; + const base: CoverageArtifact = { + tool: "bun", + files: [ + { file: "src/index.ts", coveredLines: 7, totalLines: 10, percent: 70 }, + { file: "src/utils.ts", coveredLines: 9, totalLines: 10, percent: 90 }, + ], + commitSha: "abc", + branch: "main", + timestamp: "2025-01-01T00:00:00Z", + }; + const toolReport = buildToolReport("bun", head, base, []); + const fullReport = buildFullReport([toolReport]); + const md = renderReport(fullReport, "", true); + + // Diff table header + expect(md).toContain("@@"); + expect(md).toContain("Coverage Diff"); + expect(md).toContain("base"); + expect(md).toContain("head"); + expect(md).toContain("+/-"); + // Data values + expect(md).toContain("80.00%"); // base coverage + expect(md).toContain("90.00%"); // head coverage + expect(md).toContain("+10.00%"); // coverage delta + // File/line/hit counts + expect(md).toContain("Hits"); + expect(md).toContain("16"); // baseCovered + expect(md).toContain("18"); // headCovered + expect(md).toContain("+2"); // hit delta + }); + + test("coverage diff table uses + prefix on hits when coverage improves", () => { + const head: FileCoverage[] = [ + { file: "a.ts", coveredLines: 9, totalLines: 10, percent: 90 }, + ]; + + const base: CoverageArtifact = { + tool: "bun", + files: [{ file: "a.ts", coveredLines: 7, totalLines: 10, percent: 70 }], + commitSha: "abc", + branch: "main", + timestamp: "2025-01-01T00:00:00Z", + }; + + const toolReport = buildToolReport("bun", head, base, []); + const fullReport = buildFullReport([toolReport]); + const md = renderReport(fullReport, "", true); + + // Hits row should start with "+" for improvement + const diffBlock = md.split("```diff")[1].split("```")[0]; + const hitsLine = diffBlock.split("\n").find((l) => l.includes("Hits"))!; + expect(hitsLine.startsWith("+")).toBe(true); + }); + + test("coverage diff table uses - prefix on hits when coverage decreases", () => { + const head: FileCoverage[] = [ + { file: "a.ts", coveredLines: 5, totalLines: 10, percent: 50 }, + ]; + + const base: CoverageArtifact = { + tool: "bun", + files: [{ file: "a.ts", coveredLines: 7, totalLines: 10, percent: 70 }], + commitSha: "abc", + branch: "main", + timestamp: "2025-01-01T00:00:00Z", + }; + + const toolReport = buildToolReport("bun", head, base, []); + const fullReport = buildFullReport([toolReport]); const md = renderReport(fullReport, "", true); - expect(md).not.toContain("[Commit"); + const diffBlock = md.split("```diff")[1].split("```")[0]; + const hitsLine = diffBlock.split("\n").find((l) => l.includes("Hits"))!; + expect(hitsLine.startsWith("-")).toBe(true); }); });