Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dprint.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"lineWidth": 100,
"lineWidth": 1000,
"useTabs": true,
"indentWidth": 4,
"yaml": {
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
"fmt": "dprint fmt",
"fmt:check": "dprint check",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"test": "bun test --concurrent",
"test:watch": "bun test --watch --concurrent",
"test:coverage": "bun test --coverage --concurrent",
"lint": "bun run typecheck && bun run fmt:check"
},
"dependencies": {
Expand Down
34 changes: 24 additions & 10 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import * as github from "@actions/github";

type Context = typeof github.context;

function isPrEvent(context: Context): boolean {
return context.eventName === "pull_request" || context.eventName === "pull_request_target";
}

function stripRefsPrefix(ref: string): string {
return ref.replace("refs/heads/", "");
}

/**
* Resolve the PR number from the event context using a priority chain:
*
Expand All @@ -24,7 +32,7 @@ export async function resolvePrNumber(
}

// 2. Direct PR trigger
if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
if (isPrEvent(context)) {
const num = context.payload.pull_request?.number;
if (typeof num === "number") return num;
}
Expand Down Expand Up @@ -83,24 +91,30 @@ export function resolveHeadSha(context: Context = github.context): string {
* Resolve the base branch for cache key scoping.
*
* Under `workflow_run`, `context.ref` points to the *default* branch, not the
* PR base. Use `head_branch` from the triggering run instead. Falls back to the
* explicit `base-branch` input or `main`.
* PR base. Prefer the base ref of the triggering PR when it is available.
* Falls back to the explicit `base-branch` input or the current ref.
*/
export function resolveBaseBranch(
inputBaseBranch: string,
context: Context = github.context,
): string {
if (inputBaseBranch) return inputBaseBranch;

if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
if (isPrEvent(context)) {
return context.payload.pull_request?.base?.ref ?? "main";
}

if (context.eventName === "workflow_run") {
return context.payload.workflow_run?.head_branch ?? "main";
const prs: unknown[] | undefined = context.payload.workflow_run?.pull_requests;
if (Array.isArray(prs) && prs.length > 0) {
const first = prs[0] as { base?: { ref?: string; }; };
if (first.base?.ref) return first.base.ref;
}

return stripRefsPrefix(context.ref) || "main";
}

return context.ref.replace("refs/heads/", "") || "main";
return stripRefsPrefix(context.ref) || "main";
}

/**
Expand All @@ -109,15 +123,15 @@ export function resolveBaseBranch(
* Under `workflow_run`, the head branch comes from the triggering workflow.
*/
export function resolveCurrentBranch(context: Context = github.context): string {
if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
if (isPrEvent(context)) {
return context.payload.pull_request?.head?.ref
?? context.ref.replace("refs/heads/", "");
?? stripRefsPrefix(context.ref);
}

if (context.eventName === "workflow_run") {
return context.payload.workflow_run?.head_branch
?? context.ref.replace("refs/heads/", "");
?? stripRefsPrefix(context.ref);
}

return context.ref.replace("refs/heads/", "");
return stripRefsPrefix(context.ref);
}
15 changes: 10 additions & 5 deletions src/context_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ describe("resolveHeadSha", () => {
test("returns workflow_run.head_sha for workflow_run event", () => {
const ctx = makeContext({
eventName: "workflow_run",
sha: "default-sha",
payload: { workflow_run: { head_sha: "wr-sha-456" } },
});
expect(resolveHeadSha(ctx)).toBe("wr-sha-456");
Expand Down Expand Up @@ -171,10 +170,15 @@ describe("resolveBaseBranch", () => {
expect(resolveBaseBranch("", ctx)).toBe("develop");
});

test("returns workflow_run.head_branch for workflow_run event", () => {
test("returns workflow_run pull request base ref for workflow_run event", () => {
const ctx = makeContext({
eventName: "workflow_run",
payload: { workflow_run: { head_branch: "wr-base" } },
payload: {
workflow_run: {
head_branch: "feature-branch",
pull_requests: [{ base: { ref: "wr-base" } }],
},
},
});
expect(resolveBaseBranch("", ctx)).toBe("wr-base");
});
Expand All @@ -184,12 +188,13 @@ describe("resolveBaseBranch", () => {
expect(resolveBaseBranch("", ctx)).toBe("main");
});

test("returns main when workflow_run has no head_branch", () => {
test("falls back to current ref when workflow_run has no PR base ref", () => {
const ctx = makeContext({
eventName: "workflow_run",
ref: "refs/heads/default-branch",
payload: { workflow_run: {} },
});
expect(resolveBaseBranch("", ctx)).toBe("main");
expect(resolveBaseBranch("", ctx)).toBe("default-branch");
});

test("input override takes precedence over PR payload", () => {
Expand Down
23 changes: 13 additions & 10 deletions src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
calculatePercent,
roundDelta,
} from "./percent.js";
import type {
CoverageArtifact,
CoverageReport,
Expand Down Expand Up @@ -29,7 +33,7 @@ export function computeFileDiffs(
baseCoveredLines: base?.coveredLines ?? null,
baseTotalLines: base?.totalLines ?? null,
basePercent: base?.percent ?? null,
delta: base ? Math.round((head.percent - base.percent) * 100) / 100 : null,
delta: base ? roundDelta(head.percent, base.percent) : null,
});
}

Expand Down Expand Up @@ -70,7 +74,7 @@ export function buildToolReport(

const coveredLines = headFiles.reduce((s, f) => s + f.coveredLines, 0);
const totalLines = headFiles.reduce((s, f) => s + f.totalLines, 0);
const percent = totalLines > 0 ? Math.round((coveredLines / totalLines) * 10000) / 100 : 100;
const percent = calculatePercent(coveredLines, totalLines);

let baseCoveredLines: number | null = null;
let baseTotalLines: number | null = null;
Expand All @@ -80,10 +84,8 @@ export function buildToolReport(
if (baseFiles && baseFiles.length > 0) {
baseCoveredLines = baseFiles.reduce((s, f) => s + f.coveredLines, 0);
baseTotalLines = baseFiles.reduce((s, f) => s + f.totalLines, 0);
basePercent = baseTotalLines > 0
? Math.round((baseCoveredLines / baseTotalLines) * 10000) / 100
: 100;
delta = Math.round((percent - basePercent) * 100) / 100;
basePercent = calculatePercent(baseCoveredLines, baseTotalLines);
delta = roundDelta(percent, basePercent);
}

return {
Expand All @@ -108,15 +110,16 @@ export function buildToolReport(
export function buildFullReport(toolReports: ToolCoverageReport[]): CoverageReport {
const coveredLines = toolReports.reduce((s, t) => s + t.summary.coveredLines, 0);
const totalLines = toolReports.reduce((s, t) => s + t.summary.totalLines, 0);
const percent = totalLines > 0 ? Math.round((coveredLines / totalLines) * 10000) / 100 : 100;
const percent = calculatePercent(coveredLines, totalLines);

let basePercent: number | null = null;
let delta: number | null = null;
const allToolsComparable = toolReports.every((t) => t.summary.baseTotalLines !== null);
const baseCovered = toolReports.reduce((s, t) => s + (t.summary.baseCoveredLines ?? 0), 0);
const baseTotal = toolReports.reduce((s, t) => s + (t.summary.baseTotalLines ?? 0), 0);
if (baseTotal > 0) {
basePercent = Math.round((baseCovered / baseTotal) * 10000) / 100;
delta = Math.round((percent - basePercent) * 100) / 100;
if (allToolsComparable && baseTotal > 0) {
basePercent = calculatePercent(baseCovered, baseTotal);
delta = roundDelta(percent, basePercent);
}

return {
Expand Down
26 changes: 26 additions & 0 deletions src/diff_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,30 @@ describe("buildFullReport", () => {
expect(report.overall.percent).toBe(65);
expect(report.tools).toHaveLength(2);
});

test("omits overall delta when any tool lacks base data", () => {
const withBase = buildToolReport(
"bun",
[{ file: "a.ts", coveredLines: 8, totalLines: 10, percent: 80 }],
{
tool: "bun",
files: [{ file: "a.ts", coveredLines: 7, totalLines: 10, percent: 70 }],
commitSha: "abc123",
branch: "main",
timestamp: "2025-01-01T00:00:00Z",
},
[],
);
const withoutBase = buildToolReport(
"go",
[{ file: "b.go", coveredLines: 8, totalLines: 10, percent: 80 }],
null,
[],
);

const report = buildFullReport([withBase, withoutBase]);

expect(report.overall.basePercent).toBeNull();
expect(report.overall.delta).toBeNull();
});
});
3 changes: 2 additions & 1 deletion src/go.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculatePercent } from "./percent.js";
import type { FileCoverage } from "./types.js";

/**
Expand Down Expand Up @@ -59,7 +60,7 @@ export function parseGoCover(content: string): FileCoverage[] {

const results: FileCoverage[] = [];
for (const [file, { covered, total }] of fileMap) {
const percent = total > 0 ? Math.round((covered / total) * 10000) / 100 : 100;
const percent = calculatePercent(covered, total);
results.push({ file, coveredLines: covered, totalLines: total, percent });
}

Expand Down
43 changes: 24 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ import {
buildFullReport,
buildToolReport,
} from "./diff.js";
import { parseGoCover } from "./go.js";
import { parseLcov } from "./lcov.js";
import {
getCoverageParser,
getSupportedCoverageTools,
} from "./parser.js";
import {
formatPercent,
formatPercentValue,
} from "./percent.js";
import { renderReport } from "./render.js";
import type {
ArtifactInput,
Expand Down Expand Up @@ -69,18 +75,19 @@ function parseFile(tool: string, filePath: string): { files: FileCoverage[]; war
}

try {
switch (tool) {
case "lcov":
case "bun":
case "node":
return { files: parseLcov(content), warnings };
case "go":
case "gocover":
return { files: parseGoCover(content), warnings };
default:
warnings.push(`Unknown tool "${tool}". Supported: bun, lcov, node, go, gocover.`);
return { files: [], warnings };
const parser = getCoverageParser(tool);
if (!parser) {
warnings.push(`Unknown tool "${tool}". Supported: ${getSupportedCoverageTools().join(", ")}.`);
return {
files: [],
warnings,
};
}

return {
files: parser(content),
warnings,
};
} catch (err: unknown) {
warnings.push(
`Failed to parse \`${filePath}\` as ${tool} coverage: ${(err as Error).message}`,
Expand Down Expand Up @@ -142,8 +149,8 @@ async function run(): Promise<void> {
const report = buildToolReport(input.tool, headFiles, baseArtifact, warnings);
toolReports.push(report);

// Check for decreases
if (report.summary.delta !== null && report.summary.delta < 0) {
// Match the public contract: any file-level decrease trips the flag.
if (report.files.some((file) => file.delta !== null && file.delta < 0)) {
anyDecrease = true;
}

Expand Down Expand Up @@ -172,7 +179,7 @@ async function run(): Promise<void> {
const markdown = renderReport(fullReport, marker, colorize, commitInfo);

// Set outputs
core.setOutput("overall-coverage", fullReport.overall.percent.toFixed(2));
core.setOutput("overall-coverage", formatPercentValue(fullReport.overall.percent));
core.setOutput("coverage-decreased", anyDecrease ? "true" : "false");

// Post / update PR comment
Expand Down Expand Up @@ -209,9 +216,7 @@ async function run(): Promise<void> {
// Threshold check
if (threshold > 0 && fullReport.overall.percent < threshold) {
core.setFailed(
`Overall coverage ${
fullReport.overall.percent.toFixed(2)
}% is below threshold ${threshold}%`,
`Overall coverage ${formatPercent(fullReport.overall.percent)} is below threshold ${threshold}%`,
);
return;
}
Expand Down
5 changes: 2 additions & 3 deletions src/lcov.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculatePercent } from "./percent.js";
import type { FileCoverage } from "./types.js";

/**
Expand Down Expand Up @@ -62,9 +63,7 @@ export function parseLcov(content: string): FileCoverage[] {
coveredLines = lhProvided ?? 0;
}

const percent = totalLines > 0
? Math.round((coveredLines / totalLines) * 10000) / 100
: 100;
const percent = calculatePercent(coveredLines, totalLines);

results.push({ file, coveredLines, totalLines, percent });
}
Expand Down
23 changes: 23 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { parseGoCover } from "./go.js";
import { parseLcov } from "./lcov.js";
import type { FileCoverage } from "./types.js";

export type CoverageParser = (content: string) => FileCoverage[];

const COVERAGE_PARSER_ENTRIES: [string, CoverageParser][] = [
["lcov", parseLcov],
["bun", parseLcov],
["node", parseLcov],
["go", parseGoCover],
["gocover", parseGoCover],
];

const COVERAGE_PARSERS = new Map<string, CoverageParser>(COVERAGE_PARSER_ENTRIES);

export function getCoverageParser(tool: string): CoverageParser | undefined {
return COVERAGE_PARSERS.get(tool);
}

export function getSupportedCoverageTools(): string[] {
return COVERAGE_PARSER_ENTRIES.map(([tool]) => tool);
}
Loading