diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index da84fea..40a8914 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,34 +3,70 @@ on:
pull_request:
jobs:
- build-and-test:
+ test:
permissions:
pull-requests: write
+
+ strategy:
+ matrix:
+ branch:
+ - ${{ github.head_ref }}
+ - "main"
+
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ ref: ${{ matrix.branch }}
+ ## Set repository to correctly checkout from forks
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: "Install Node"
uses: actions/setup-node@v4
with:
node-version: "20.x"
+ cache: "npm"
- name: "Install Deps"
- run: npm install
- - name: "Build"
- run: npm run build
+ run: npm ci
- name: "Test"
run: npm run test:coverage
- # Remove node_modules to see if this action runs entirely compiled
+ - name: "Upload Coverage"
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-${{ matrix.branch }}
+ path: coverage
+
+ build-and-report:
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+ - name: "Install Node"
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20.x"
+ cache: "npm"
+ - name: "Install Deps"
+ run: npm ci
+ - name: "Build"
+ run: npm run build
+ # Remove node_modules to see if this action runs entirely compiled
- name: "Remove Node Modules"
run: rm -rf node_modules
- - name: "Test Working Directory Option"
- uses: ./
+
+ - name: "Download Coverage Artifacts for ${{ github.head_ref}}"
+ uses: actions/download-artifact@v4
with:
- working-directory: "./test/mockReports"
- name: "Mock Reports"
- - name: "Test Default Action"
- # run step also on failure of the previous step
- if: always()
+ name: coverage-${{ github.head_ref }}
+ path: coverage
+
+ - name: "Download Coverage Artifacts for main"
+ uses: actions/download-artifact@v4
+ with:
+ name: coverage-main
+ path: coverage-main
+
+ - name: "Test Action by genearting coverage"
uses: ./
with:
file-coverage-mode: "all"
- name: "Root"
+ json-summary-compare-path: coverage-main/coverage-summary.json
diff --git a/README.md b/README.md
index 7e9d350..9e050f7 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,8 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
+ ## Set repository to correctly checkout from forks
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: "Install Node"
uses: actions/setup-node@v4
with:
diff --git a/src/index.ts b/src/index.ts
index efe62a3..5002b4d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,8 @@ import {
parseVitestJsonFinal,
parseVitestJsonSummary,
} from "./inputs/parseJsonReports.js";
+import { createOctokit } from "./octokit.js";
+import { generateCommitSHAUrl } from "./report/generateCommitSHAUrl.js";
import { generateFileCoverageHtml } from "./report/generateFileCoverageHtml.js";
import { generateHeadline } from "./report/generateHeadline.js";
import { generateSummaryTableHtml } from "./report/generateSummaryTableHtml.js";
@@ -15,57 +17,69 @@ import type { JsonSummary } from "./types/JsonSummary.js";
import { writeSummaryToPR } from "./writeSummaryToPR.js";
const run = async () => {
- const {
- fileCoverageMode,
- jsonFinalPath,
- jsonSummaryPath,
- jsonSummaryComparePath,
- name,
- thresholds,
- workingDirectory,
- processedPrNumber,
- } = await readOptions();
+ const octokit = createOctokit();
- const jsonSummary = await parseVitestJsonSummary(jsonSummaryPath);
+ const options = await readOptions(octokit);
+ core.info(`Using options: ${JSON.stringify(options, null, 2)}`);
+
+ const jsonSummary = await parseVitestJsonSummary(options.jsonSummaryPath);
let jsonSummaryCompare: JsonSummary | undefined;
- if (jsonSummaryComparePath) {
- jsonSummaryCompare = await parseVitestJsonSummary(jsonSummaryComparePath);
+ if (options.jsonSummaryComparePath) {
+ jsonSummaryCompare = await parseVitestJsonSummary(
+ options.jsonSummaryComparePath,
+ );
}
- const tableData = generateSummaryTableHtml(
- jsonSummary.total,
- thresholds,
- jsonSummaryCompare?.total,
- );
const summary = core.summary
- .addHeading(generateHeadline({ workingDirectory, name }), 2)
- .addRaw(tableData);
+ .addHeading(
+ generateHeadline({
+ workingDirectory: options.workingDirectory,
+ name: options.name,
+ }),
+ 2,
+ )
+ .addRaw(
+ generateSummaryTableHtml(
+ jsonSummary.total,
+ options.thresholds,
+ jsonSummaryCompare?.total,
+ ),
+ );
- if (fileCoverageMode !== FileCoverageMode.None) {
+ if (options.fileCoverageMode !== FileCoverageMode.None) {
const pullChanges = await getPullChanges({
- fileCoverageMode,
- prNumber: processedPrNumber,
+ fileCoverageMode: options.fileCoverageMode,
+ prNumber: options.prNumber,
+ octokit,
});
- const jsonFinal = await parseVitestJsonFinal(jsonFinalPath);
+
+ const jsonFinal = await parseVitestJsonFinal(options.jsonFinalPath);
const fileTable = generateFileCoverageHtml({
jsonSummary,
jsonFinal,
- fileCoverageMode,
+ fileCoverageMode: options.fileCoverageMode,
pullChanges,
+ commitSHA: options.commitSHA,
});
summary.addDetails("File Coverage", fileTable);
}
+ const commitSHAUrl = generateCommitSHAUrl(options.commitSHA);
+
summary.addRaw(
- `Generated in workflow #${github.context.runNumber}`,
+ `Generated in workflow #${github.context.runNumber} for commit ${options.commitSHA.substring(0, 7)} by the Vitest Coverage Report Action`,
);
try {
await writeSummaryToPR({
+ octokit,
summary,
- markerPostfix: getMarkerPostfix({ name, workingDirectory }),
- userDefinedPrNumber: processedPrNumber,
+ markerPostfix: getMarkerPostfix({
+ name: options.name,
+ workingDirectory: options.workingDirectory,
+ }),
+ prNumber: options.prNumber,
});
} catch (error) {
if (
@@ -74,8 +88,8 @@ const run = async () => {
) {
core.warning(
`Couldn't write a comment to the pull-request. Please make sure your job has the permission 'pull-request: write'.
- Original Error was: [${error.name}] - ${error.message}
- `,
+ Original Error was: [${error.name}] - ${error.message}
+ `,
);
} else {
// Rethrow to handle it in the catch block of the run()-call.
diff --git a/src/inputs/getCommitSHA.test.ts b/src/inputs/getCommitSHA.test.ts
new file mode 100644
index 0000000..8a4e105
--- /dev/null
+++ b/src/inputs/getCommitSHA.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { getCommitSHA } from "./getCommitSHA";
+
+const mockContext = vi.hoisted(() => ({
+ repo: {
+ owner: "owner",
+ repo: "repo",
+ },
+ payload: {},
+ eventName: "",
+ sha: "defaultsha",
+}));
+vi.mock("@actions/github", () => ({
+ context: mockContext,
+}));
+
+describe("getCommitSHA()", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockContext.payload = {};
+ mockContext.eventName = "";
+ mockContext.sha = "defaultsha";
+ });
+
+ afterEach(() => {
+ mockContext.payload = {};
+ mockContext.eventName = "";
+ mockContext.sha = "defaultsha";
+ vi.unstubAllEnvs();
+ });
+
+ it("if in pull-request context, returns the head sha", () => {
+ mockContext.eventName = "pull_request";
+ mockContext.payload = {
+ pull_request: {
+ head: {
+ sha: "prsha",
+ },
+ },
+ };
+
+ const result = getCommitSHA();
+ expect(result).toBe("prsha");
+ });
+
+ it("if in pull_request_target context, returns the head sha", () => {
+ mockContext.eventName = "pull_request_target";
+ mockContext.payload = {
+ pull_request: {
+ head: {
+ sha: "prsha",
+ },
+ },
+ };
+
+ const result = getCommitSHA();
+ expect(result).toBe("prsha");
+ });
+
+ it("if in workflow_run context, returns the SHA from workflow_run context if found", () => {
+ mockContext.eventName = "workflow_run";
+ mockContext.payload = {
+ workflow_run: {
+ head_commit: {
+ id: "workflowsha",
+ },
+ },
+ };
+
+ const result = getCommitSHA();
+ expect(result).toBe("workflowsha");
+ });
+
+ it("returns the default SHA for other events", () => {
+ mockContext.eventName = "push";
+ mockContext.sha = "pushsha";
+
+ const result = getCommitSHA();
+ expect(result).toBe("pushsha");
+ });
+});
diff --git a/src/inputs/getCommitSHA.ts b/src/inputs/getCommitSHA.ts
new file mode 100644
index 0000000..04d7b18
--- /dev/null
+++ b/src/inputs/getCommitSHA.ts
@@ -0,0 +1,33 @@
+import * as github from "@actions/github";
+
+type Context = typeof github.context;
+type Payload = Context["payload"];
+type PRPayload = NonNullable;
+
+type PRContext = Context & {
+ payload: Payload & {
+ pull_request: PRPayload;
+ };
+};
+
+function isPRContext(context: typeof github.context): context is PRContext {
+ return (
+ context.eventName === "pull_request" ||
+ context.eventName === "pull_request_target"
+ );
+}
+
+function getCommitSHA(): string {
+ if (isPRContext(github.context)) {
+ return github.context.payload.pull_request.head.sha;
+ }
+
+ if (github.context.eventName === "workflow_run") {
+ return github.context.payload.workflow_run.head_commit.id;
+ }
+
+ // For all other events, just return the current SHA
+ return github.context.sha;
+}
+
+export { getCommitSHA };
diff --git a/src/inputs/getPullChanges.test.ts b/src/inputs/getPullChanges.test.ts
index ad3a223..bec0baa 100644
--- a/src/inputs/getPullChanges.test.ts
+++ b/src/inputs/getPullChanges.test.ts
@@ -1,15 +1,10 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Mock, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Octokit } from "../octokit";
import { FileCoverageMode } from "./FileCoverageMode";
import { getPullChanges } from "./getPullChanges";
-const mockGetInput = vi.hoisted(() => vi.fn());
-vi.mock("@actions/core", () => ({
- getInput: mockGetInput,
- endGroup: vi.fn(),
- startGroup: vi.fn(),
- info: vi.fn(),
- debug: vi.fn(),
-}));
+// Avoid logs
+vi.mock("@actions/core");
const mockContext = vi.hoisted(() => ({
repo: {
@@ -18,17 +13,15 @@ const mockContext = vi.hoisted(() => ({
},
payload: {},
}));
-const mockGetOctokit = vi.hoisted(() => vi.fn());
vi.mock("@actions/github", () => ({
context: mockContext,
- getOctokit: mockGetOctokit,
}));
-describe("getPullChanges", () => {
+describe("getPullChanges()", () => {
+ let mockOctokit: Octokit;
beforeEach(() => {
vi.clearAllMocks();
- mockGetInput.mockReturnValue("fake-token");
- const mockOctokit = {
+ mockOctokit = {
paginate: {
iterator: vi.fn().mockReturnValue([
{
@@ -44,38 +37,32 @@ describe("getPullChanges", () => {
listFiles: vi.fn(),
},
},
- };
- mockGetOctokit.mockReturnValue(mockOctokit);
+ } as unknown as Octokit;
});
- it("should return an empty array if fileCoverageMode is None", async () => {
+ it("returns an empty array if fileCoverageMode is None", async () => {
const result = await getPullChanges({
fileCoverageMode: FileCoverageMode.None,
+ octokit: mockOctokit,
});
expect(result).toEqual([]);
});
- it("should return an empty array if prNumber is not provided and context payload has no pull request number", async () => {
+ it("returns an empty array if prNumber is not provided", async () => {
mockContext.payload = {};
const result = await getPullChanges({
fileCoverageMode: FileCoverageMode.All,
+ octokit: mockOctokit,
});
expect(result).toEqual([]);
});
- it("should fetch and return changed files when prNumber is provided but not in the context", async () => {
+ it("fetches and returns the changed files when prNumber is provided", async () => {
mockContext.payload = {};
const result = await getPullChanges({
- fileCoverageMode: FileCoverageMode.All,
+ fileCoverageMode: FileCoverageMode.Changes,
prNumber: 123,
- });
- expect(result).toEqual(["file1.ts", "file2.ts"]);
- });
-
- it("should fetch and return changed files when prNumber is in the context but not provided", async () => {
- mockContext.payload = { pull_request: { number: 123 } };
- const result = await getPullChanges({
- fileCoverageMode: FileCoverageMode.All,
+ octokit: mockOctokit,
});
expect(result).toEqual(["file1.ts", "file2.ts"]);
});
diff --git a/src/inputs/getPullChanges.ts b/src/inputs/getPullChanges.ts
index 09f25d2..8febb38 100644
--- a/src/inputs/getPullChanges.ts
+++ b/src/inputs/getPullChanges.ts
@@ -1,18 +1,19 @@
import * as core from "@actions/core";
import * as github from "@actions/github";
import { RequestError } from "@octokit/request-error";
+import type { Octokit } from "../octokit";
import { FileCoverageMode } from "./FileCoverageMode";
-type Octokit = ReturnType;
-
interface Params {
fileCoverageMode: FileCoverageMode;
prNumber?: number;
+ octokit: Octokit;
}
export async function getPullChanges({
fileCoverageMode,
- prNumber = github.context.payload.pull_request?.number,
+ prNumber,
+ octokit,
}: Params): Promise {
// Skip Changes collection if we don't need it
if (fileCoverageMode === FileCoverageMode.None) {
@@ -23,9 +24,7 @@ export async function getPullChanges({
return [];
}
- const gitHubToken = core.getInput("github-token").trim();
try {
- const octokit: Octokit = github.getOctokit(gitHubToken);
const paths: string[] = [];
core.startGroup(
diff --git a/src/inputs/getPullRequestNumber.test.ts b/src/inputs/getPullRequestNumber.test.ts
new file mode 100644
index 0000000..b646de2
--- /dev/null
+++ b/src/inputs/getPullRequestNumber.test.ts
@@ -0,0 +1,117 @@
+import type * as core from "@actions/core";
+import {
+ type Mock,
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import type { Octokit } from "../octokit";
+import { getPullRequestNumber } from "./getPullRequestNumber";
+
+type Core = typeof core;
+
+// Avoid logs
+vi.mock("@actions/core", async (importOriginal): Promise => {
+ const original: Core = await importOriginal();
+ return {
+ ...original,
+ startGroup: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ debug: vi.fn(),
+ };
+});
+
+const mockContext = vi.hoisted(() => ({
+ repo: {
+ owner: "owner",
+ repo: "repo",
+ },
+ payload: {},
+ eventName: "",
+}));
+vi.mock("@actions/github", () => ({
+ context: mockContext,
+}));
+
+describe("getPullRequestNumber()", () => {
+ let mockOctokit: Octokit;
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOctokit = {
+ paginate: {
+ iterator: vi.fn(),
+ },
+ rest: {
+ pulls: {
+ list: vi.fn(),
+ },
+ },
+ } as unknown as Octokit;
+ });
+
+ afterEach(() => {
+ mockContext.payload = {};
+ mockContext.eventName = "";
+ vi.unstubAllEnvs();
+ });
+
+ it("returns the PR number from the input 'pr-number' if valid ", async () => {
+ vi.stubEnv("INPUT_PR-NUMBER", "123");
+
+ const result = await getPullRequestNumber(mockOctokit);
+ expect(result).toBe(123);
+ });
+
+ it("returns the PR number from payload.pull_request context if found", async () => {
+ mockContext.payload = {
+ pull_request: {
+ number: 456,
+ },
+ };
+
+ const result = await getPullRequestNumber(mockOctokit);
+ expect(result).toBe(456);
+ });
+
+ it("returns the PR number from payload.workflow_run context if found", async () => {
+ mockContext.eventName = "workflow_run";
+ mockContext.payload = {
+ workflow_run: {
+ pull_requests: [{ number: 789 }],
+ head_sha: "testsha",
+ },
+ };
+
+ const result = await getPullRequestNumber(mockOctokit);
+ expect(result).toBe(789);
+ });
+
+ it("calls the API to find PR number by the head_sha of the payload.workflow_run when called from a fork", async () => {
+ mockContext.eventName = "workflow_run";
+ mockContext.payload = {
+ workflow_run: {
+ pull_requests: [],
+ head_sha: "testsha",
+ },
+ };
+
+ (mockOctokit.paginate.iterator as Mock).mockReturnValue([
+ {
+ data: [{ number: 101, head: { sha: "testsha" } }],
+ },
+ ]);
+
+ const result = await getPullRequestNumber(mockOctokit);
+ expect(result).toBe(101);
+ });
+
+ it("returns undefined if no pr number is found", async () => {
+ mockContext.payload = {};
+ const result = await getPullRequestNumber(mockOctokit);
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/src/inputs/getPullRequestNumber.ts b/src/inputs/getPullRequestNumber.ts
new file mode 100644
index 0000000..743f55c
--- /dev/null
+++ b/src/inputs/getPullRequestNumber.ts
@@ -0,0 +1,79 @@
+import * as core from "@actions/core";
+import * as github from "@actions/github";
+import type { Octokit } from "../octokit";
+
+async function getPullRequestNumber(
+ octokit: Octokit,
+): Promise {
+ // Get the user-defined pull-request number and perform input validation
+ const prNumberFromInput = core.getInput("pr-number");
+ const processedPrNumber: number | undefined = Number(prNumberFromInput);
+
+ // Check if it is a full integer. Check for non-null as qhen the option is not set, the parsed input will be an empty string
+ // which becomes 0 when parsed to a number.
+ if (Number.isSafeInteger(processedPrNumber) && processedPrNumber !== 0) {
+ core.info(`Received pull-request number: ${processedPrNumber}`);
+ return processedPrNumber;
+ }
+
+ if (github.context.payload.pull_request) {
+ core.info(
+ `Found pull-request number in the action's "payload.pull_request" context: ${github.context.payload.pull_request.number}`,
+ );
+ return github.context.payload.pull_request.number;
+ }
+
+ if (github.context.eventName === "workflow_run") {
+ // Workflow_runs triggered from non-forked PRs will have the PR number in the payload
+ if (github.context.payload.workflow_run.pull_requests.length > 0) {
+ core.debug(
+ `Found pull-request number in the action's "payload.workflow_run" context: ${github.context.payload.workflow_run.pull_requests[0].number}`,
+ );
+ return github.context.payload.workflow_run.pull_requests[0].number;
+ }
+
+ // ... in all other cases, we have to call the API to get a matching PR number
+ core.debug(
+ "Trying to find pull-request number in payload.workflow_run context by calling the API",
+ );
+ return await findPullRequestBySHA(
+ octokit,
+ github.context.payload.workflow_run.head_sha,
+ );
+ }
+
+ core.info("No pull-request number found. Comment creation will be skipped!");
+ return undefined;
+}
+
+async function findPullRequestBySHA(
+ octokit: Octokit,
+ headSha: string,
+): Promise {
+ core.startGroup("Querying REST API for pull-requests.");
+ const pullRequestsIterator = octokit.paginate.iterator(
+ octokit.rest.pulls.list,
+ {
+ owner: github.context.repo.owner,
+ repo: github.context.repo.repo,
+ per_page: 30,
+ },
+ );
+
+ for await (const { data: pullRequests } of pullRequestsIterator) {
+ core.info(`Found ${pullRequests.length} pull-requests in this page.`);
+ for (const pullRequest of pullRequests) {
+ core.debug(
+ `Comparing: ${pullRequest.number} sha: ${pullRequest.head.sha} with expected: ${headSha}.`,
+ );
+ if (pullRequest.head.sha === headSha) {
+ return pullRequest.number;
+ }
+ }
+ }
+ core.endGroup();
+ core.info(`Could not find the pull-request for commit "${headSha}".`);
+ return undefined;
+}
+
+export { getPullRequestNumber };
diff --git a/src/inputs/getViteConfigPath.test.ts b/src/inputs/getViteConfigPath.test.ts
index 9803352..db8746d 100644
--- a/src/inputs/getViteConfigPath.test.ts
+++ b/src/inputs/getViteConfigPath.test.ts
@@ -37,7 +37,7 @@ describe("getViteConfigPath", () => {
it("resolves Vitest workspace file", async (): Promise => {
await expect(
- getViteConfigPath(mockWorkingDirectory, "vitest.workspace.js"),
- ).resolves.toMatch("test/mockConfig/vitest.workspace.js");
+ getViteConfigPath(mockWorkingDirectory, "vitest.mock.workspace.js"),
+ ).resolves.toMatch("test/mockConfig/vitest.mock.workspace.js");
});
});
diff --git a/src/inputs/options.ts b/src/inputs/options.ts
index 34e84bc..86117ee 100644
--- a/src/inputs/options.ts
+++ b/src/inputs/options.ts
@@ -1,10 +1,26 @@
import * as path from "node:path";
import * as core from "@actions/core";
-import { getCoverageModeFrom } from "./FileCoverageMode";
+import type { Octokit } from "../octokit";
+import type { Thresholds } from "../types/Threshold";
+import { type FileCoverageMode, getCoverageModeFrom } from "./FileCoverageMode";
+import { getPullRequestNumber } from "./getPullRequestNumber";
import { getViteConfigPath } from "./getViteConfigPath";
import { parseCoverageThresholds } from "./parseCoverageThresholds";
+import { getCommitSHA } from "./getCommitSHA";
-async function readOptions() {
+type Options = {
+ fileCoverageMode: FileCoverageMode;
+ jsonFinalPath: string;
+ jsonSummaryPath: string;
+ jsonSummaryComparePath: string | null;
+ name: string;
+ thresholds: Thresholds;
+ workingDirectory: string;
+ prNumber: number | undefined;
+ commitSHA: string;
+};
+
+async function readOptions(octokit: Octokit): Promise {
// Working directory can be used to modify all default/provided paths (for monorepos, etc)
const workingDirectory = core.getInput("working-directory");
@@ -15,6 +31,7 @@ async function readOptions() {
workingDirectory,
core.getInput("json-summary-path"),
);
+
const jsonFinalPath = path.resolve(
workingDirectory,
core.getInput("json-final-path"),
@@ -31,25 +48,20 @@ async function readOptions() {
const name = core.getInput("name");
- // Get the user-defined pull-request number and perform input validation
- const prNumber = core.getInput("pr-number");
- let processedPrNumber: number | undefined = Number(prNumber);
- if (!Number.isSafeInteger(processedPrNumber) || processedPrNumber <= 0) {
- processedPrNumber = undefined;
- }
- if (processedPrNumber) {
- core.info(`Received pull-request number: ${processedPrNumber}`);
- }
-
// ViteConfig is optional, as it is only required for thresholds. If no vite config is provided, we will not include thresholds in the final report.
const viteConfigPath = await getViteConfigPath(
workingDirectory,
core.getInput("vite-config-path"),
);
+
const thresholds = viteConfigPath
? await parseCoverageThresholds(viteConfigPath)
: {};
+ const commitSHA = getCommitSHA();
+ // Get the user-defined pull-request number and perform input validation
+ const prNumber = await getPullRequestNumber(octokit);
+
return {
fileCoverageMode,
jsonFinalPath,
@@ -58,7 +70,8 @@ async function readOptions() {
name,
thresholds,
workingDirectory,
- processedPrNumber,
+ prNumber,
+ commitSHA,
};
}
diff --git a/src/inputs/parseCoverageThresholds.ts b/src/inputs/parseCoverageThresholds.ts
index 42751c3..fd3c656 100644
--- a/src/inputs/parseCoverageThresholds.ts
+++ b/src/inputs/parseCoverageThresholds.ts
@@ -43,7 +43,7 @@ const parseCoverageThresholds = async (
};
} catch (err: unknown) {
core.warning(
- `Could not read vite config file for tresholds due to an error:\n ${err}`,
+ `Could not read vite config file for thresholds due to an error:\n ${err}`,
);
return {};
}
diff --git a/src/octokit.ts b/src/octokit.ts
new file mode 100644
index 0000000..1b23c5c
--- /dev/null
+++ b/src/octokit.ts
@@ -0,0 +1,13 @@
+import * as core from "@actions/core";
+import * as github from "@actions/github";
+
+type Octokit = ReturnType;
+
+const createOctokit = (): Octokit => {
+ const token = core.getInput("github-token").trim();
+ return github.getOctokit(token);
+};
+
+export { createOctokit };
+
+export type { Octokit };
diff --git a/src/report/generateCommitSHAUrl.test.ts b/src/report/generateCommitSHAUrl.test.ts
new file mode 100644
index 0000000..724dddb
--- /dev/null
+++ b/src/report/generateCommitSHAUrl.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from "vitest";
+import { generateCommitSHAUrl } from "./generateCommitSHAUrl";
+
+const mockContext = vi.hoisted(() => ({
+ repo: {
+ owner: "owner",
+ repo: "repo",
+ },
+ serverUrl: "https://github.com",
+ payload: {},
+}));
+vi.mock("@actions/github", () => ({
+ context: mockContext,
+}));
+
+describe("generateCommitSHAUrl", () => {
+ it("should generate the correct commit SHA URL", () => {
+ const commitSHA = "abcdef123456";
+ const expectedUrl = "https://github.com/owner/repo/commit/abcdef123456";
+
+ const url = generateCommitSHAUrl(commitSHA);
+
+ expect(url).toBe(expectedUrl);
+ });
+});
diff --git a/src/report/generateCommitSHAUrl.ts b/src/report/generateCommitSHAUrl.ts
new file mode 100644
index 0000000..b0ff750
--- /dev/null
+++ b/src/report/generateCommitSHAUrl.ts
@@ -0,0 +1,13 @@
+import * as github from "@actions/github";
+
+const generateCommitSHAUrl = (commitSHA: string) => {
+ return [
+ github.context.serverUrl,
+ github.context.repo.owner,
+ github.context.repo.repo,
+ "commit",
+ commitSHA,
+ ].join("/");
+};
+
+export { generateCommitSHAUrl };
diff --git a/src/report/generateFileCoverageHtml.ts b/src/report/generateFileCoverageHtml.ts
index a04436b..0c23a29 100644
--- a/src/report/generateFileCoverageHtml.ts
+++ b/src/report/generateFileCoverageHtml.ts
@@ -14,6 +14,7 @@ type FileCoverageInputs = {
jsonFinal: JsonFinal;
fileCoverageMode: FileCoverageMode;
pullChanges: string[];
+ commitSHA: string;
};
const workspacePath = process.cwd();
@@ -22,6 +23,7 @@ const generateFileCoverageHtml = ({
jsonFinal,
fileCoverageMode,
pullChanges,
+ commitSHA,
}: FileCoverageInputs) => {
const filePaths = Object.keys(jsonSummary).filter((key) => key !== "total");
@@ -34,7 +36,7 @@ const generateFileCoverageHtml = ({
? getUncoveredLinesFromStatements(jsonFinal[filePath])
: [];
const relativeFilePath = path.relative(workspacePath, filePath);
- const url = generateBlobFileUrl(relativeFilePath);
+ const url = generateBlobFileUrl(relativeFilePath, commitSHA);
return `
diff --git a/src/report/generateFileUrl.ts b/src/report/generateFileUrl.ts
index 424f780..c5345be 100644
--- a/src/report/generateFileUrl.ts
+++ b/src/report/generateFileUrl.ts
@@ -1,16 +1,12 @@
import * as github from "@actions/github";
-const generateBlobFileUrl = (relativeFilePath: string) => {
- const sha = github.context.payload.pull_request
- ? github.context.payload.pull_request.head.sha
- : github.context.sha;
-
+const generateBlobFileUrl = (relativeFilePath: string, commitSHA: string) => {
return [
github.context.serverUrl,
github.context.repo.owner,
github.context.repo.repo,
"blob",
- sha,
+ commitSHA,
relativeFilePath,
].join("/");
};
diff --git a/src/writeSummaryToPR.test.ts b/src/writeSummaryToPR.test.ts
new file mode 100644
index 0000000..a8000e8
--- /dev/null
+++ b/src/writeSummaryToPR.test.ts
@@ -0,0 +1,101 @@
+import * as core from "@actions/core";
+import * as github from "@actions/github";
+import { Mock, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Octokit } from "./octokit";
+import { writeSummaryToPR } from "./writeSummaryToPR";
+
+vi.mock("@actions/core");
+
+const mockContext = vi.hoisted(() => ({
+ repo: {
+ owner: "owner",
+ repo: "repo",
+ },
+ payload: {},
+}));
+vi.mock("@actions/github", () => ({
+ context: mockContext,
+}));
+
+describe("writeSummaryToPR()", () => {
+ let mockOctokit: Octokit;
+ let mockSummary: typeof core.summary;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOctokit = {
+ paginate: {
+ iterator: vi.fn().mockReturnValue([
+ {
+ data: [
+ {
+ id: 1,
+ body: "existing comment ",
+ },
+ ],
+ },
+ ]),
+ },
+ rest: {
+ issues: {
+ listComments: vi.fn(),
+ updateComment: vi.fn(),
+ createComment: vi.fn(),
+ },
+ },
+ } as unknown as Octokit;
+
+ mockSummary = {
+ stringify: vi.fn().mockReturnValue("summary content"),
+ } as unknown as typeof core.summary;
+ });
+
+ it("skips comment creation if prNumber is not provided", async () => {
+ await writeSummaryToPR({
+ octokit: mockOctokit,
+ summary: mockSummary,
+ });
+ expect(core.info).toHaveBeenCalledWith(
+ "No pull-request-number found. Skipping comment creation.",
+ );
+ expect(mockOctokit.rest.issues.createComment).not.toHaveBeenCalled();
+ expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
+ });
+
+ it("updates an existing comment if found", async () => {
+ await writeSummaryToPR({
+ octokit: mockOctokit,
+ summary: mockSummary,
+ prNumber: 123,
+ });
+ expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
+ owner: "owner",
+ repo: "repo",
+ comment_id: 1,
+ body: "summary content\n\n",
+ });
+ expect(mockOctokit.rest.issues.createComment).not.toHaveBeenCalled();
+ });
+
+ it("creates a new comment if no existing comment is found", async () => {
+ mockOctokit.paginate.iterator = vi.fn().mockReturnValue([
+ {
+ data: [],
+ },
+ ]);
+
+ await writeSummaryToPR({
+ octokit: mockOctokit,
+ summary: mockSummary,
+ prNumber: 123,
+ });
+
+ expect(mockOctokit.rest.issues.createComment).toHaveBeenCalledWith({
+ owner: "owner",
+ repo: "repo",
+ issue_number: 123,
+ body: "summary content\n\n",
+ });
+ expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/writeSummaryToPR.ts b/src/writeSummaryToPR.ts
index 5bb0900..0e51b99 100644
--- a/src/writeSummaryToPR.ts
+++ b/src/writeSummaryToPR.ts
@@ -1,36 +1,23 @@
import * as core from "@actions/core";
import * as github from "@actions/github";
+import type { Octokit } from "./octokit";
-const gitHubToken = core.getInput("github-token").trim();
-const octokit: Octokit = github.getOctokit(gitHubToken);
const COMMENT_MARKER = (markerPostfix = "root") =>
``;
-type Octokit = ReturnType;
const writeSummaryToPR = async ({
+ octokit,
summary,
markerPostfix,
- userDefinedPrNumber,
+ prNumber,
}: {
+ octokit: Octokit;
summary: typeof core.summary;
markerPostfix?: string;
- userDefinedPrNumber?: number;
+ prNumber?: number;
}) => {
// The user-defined pull request number takes precedence
- let pullRequestNumber = userDefinedPrNumber;
-
- if (!pullRequestNumber) {
- // If in the context of a pull-request, get the pull-request number
- pullRequestNumber = github.context.payload.pull_request?.number;
-
- // This is to allow commenting on pull_request from forks
- if (github.context.eventName === "workflow_run") {
- pullRequestNumber =
- await getPullRequestNumberFromTriggeringWorkflow(octokit);
- }
- }
-
- if (!pullRequestNumber) {
+ if (!prNumber) {
core.info("No pull-request-number found. Skipping comment creation.");
return;
}
@@ -39,7 +26,7 @@ const writeSummaryToPR = async ({
const existingComment = await findCommentByBody(
octokit,
COMMENT_MARKER(markerPostfix),
- pullRequestNumber,
+ prNumber,
);
if (existingComment) {
@@ -53,7 +40,7 @@ const writeSummaryToPR = async ({
await octokit.rest.issues.createComment({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
- issue_number: pullRequestNumber,
+ issue_number: prNumber,
body: commentBody,
});
}
@@ -83,89 +70,4 @@ async function findCommentByBody(
return;
}
-async function getPullRequestNumberFromTriggeringWorkflow(
- octokit: Octokit,
-): Promise {
- core.info(
- "Trying to get the triggering workflow in order to find the pull-request-number to comment the results on...",
- );
-
- if (!github.context.payload.workflow_run) {
- core.info(
- "The triggering workflow does not have a workflow_run payload. Skipping comment creation.",
- );
- return undefined;
- }
-
- const originalWorkflowRunId = github.context.payload.workflow_run.id;
-
- const { data: originalWorkflowRun } =
- await octokit.rest.actions.getWorkflowRun({
- owner: github.context.repo.owner,
- repo: github.context.repo.repo,
- run_id: originalWorkflowRunId,
- });
-
- if (originalWorkflowRun.event !== "pull_request") {
- core.info(
- "The triggering workflow is not a pull-request. Skipping comment creation.",
- );
- return undefined;
- }
-
- // When the actual pull-request is not coming from a fork, the pull_request object is correctly populated and we can shortcut here
- if (
- originalWorkflowRun.pull_requests &&
- originalWorkflowRun.pull_requests.length > 0
- ) {
- return originalWorkflowRun.pull_requests[0].number;
- }
-
- // When the actual pull-request is coming from a fork, the pull_request object is not populated (see https://github.com/orgs/community/discussions/25220)
- core.info(
- `Trying to find the pull-request for the triggering workflow run with id: ${originalWorkflowRunId} (${originalWorkflowRun.url}) with HEAD_SHA ${originalWorkflowRun.head_sha}...`,
- );
-
- // The way to find the pull-request in this scenario is to query all existing pull_requests on the target repository and find the one with the same HEAD_SHA as the original workflow run
- const pullRequest = await findPullRequest(
- octokit,
- originalWorkflowRun.head_sha,
- );
-
- if (!pullRequest) {
- core.info(
- "Could not find the pull-request for the triggering workflow run. Skipping comment creation.",
- );
- return undefined;
- }
-
- return pullRequest.number;
-}
-
-async function findPullRequest(octokit: Octokit, headSha: string) {
- core.startGroup("Querying REST API for Pull-Requests.");
- const pullRequestsIterator = octokit.paginate.iterator(
- octokit.rest.pulls.list,
- {
- owner: github.context.repo.owner,
- repo: github.context.repo.repo,
- per_page: 30,
- },
- );
-
- for await (const { data: pullRequests } of pullRequestsIterator) {
- core.info(`Found ${pullRequests.length} pull-requests in this page.`);
- for (const pullRequest of pullRequests) {
- core.debug(
- `Comparing: ${pullRequest.number} sha: ${pullRequest.head.sha} with expected: ${headSha}.`,
- );
- if (pullRequest.head.sha === headSha) {
- return pullRequest;
- }
- }
- }
- core.endGroup();
- return undefined;
-}
-
export { writeSummaryToPR };
diff --git a/test/mockConfig/vitest.workspace.js b/test/mockConfig/vitest.mock.workspace.js
similarity index 100%
rename from test/mockConfig/vitest.workspace.js
rename to test/mockConfig/vitest.mock.workspace.js
diff --git a/vite.config.mjs b/vitest.config.mjs
similarity index 74%
rename from vite.config.mjs
rename to vitest.config.mjs
index ceb9047..8c80a6c 100644
--- a/vite.config.mjs
+++ b/vitest.config.mjs
@@ -4,7 +4,7 @@ export default defineConfig({
test: {
coverage: {
all: true,
- reporter: ["text-summary", "json-summary", "json"],
+ reporter: ["text", "text-summary", "json-summary", "json"],
include: ["src"],
exclude: ["src/types", "**/*.test.ts"],
},