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"], },