diff --git a/.gitignore b/.gitignore index 6dadc5b7..43abc846 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist/ # Storybook *storybook.log # Turborepo -.turbo \ No newline at end of file +.turbo +# NPM +.npmrc \ No newline at end of file diff --git a/packages/core/src/ci-environment/github.test.ts b/packages/core/src/ci-environment/github.test.ts new file mode 100644 index 00000000..e7668987 --- /dev/null +++ b/packages/core/src/ci-environment/github.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import type { Context } from "./types"; +import { + getPullRequestFromHeadSha, + getPullRequestFromPrNumber, + getPRNumberFromMergeGroupBranch, + type GitHubPullRequest, +} from "./github"; + +const mockPullRequest: GitHubPullRequest = { + number: 123, + head: { + ref: "feature-branch", + sha: "abc123def456", + }, + base: { + ref: "main", + }, +}; + +const server = setupServer( + http.get("https://api.github.com/repos/:owner/:repo/pulls", () => { + return HttpResponse.json([mockPullRequest]); + }), + http.get("https://api.github.com/repos/:owner/:repo/pulls/:prNumber", () => { + return HttpResponse.json(mockPullRequest); + }), +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("getPullRequestFromHeadSha", () => { + it("should find pull request by head sha", async () => { + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + const result = await getPullRequestFromHeadSha(ctx, "abc123def456"); + expect(result).toEqual(mockPullRequest); + }); + + it("should return null when no pull request found", async () => { + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + const result = await getPullRequestFromHeadSha(ctx, "nonexistent"); + expect(result).toBeNull(); + }); + + it("should return null when no token available", async () => { + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + DISABLE_GITHUB_TOKEN_WARNING: "true", + }, + }; + + const result = await getPullRequestFromHeadSha(ctx, "abc123def456"); + expect(result).toBeNull(); + }); + + it("should throw on 500 response", async () => { + server.use( + http.get("https://api.github.com/repos/:owner/:repo/pulls", () => { + return HttpResponse.json(null, { status: 500 }); + }), + ); + + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + await expect( + getPullRequestFromHeadSha(ctx, "abc123def456"), + ).rejects.toThrow(/Non-OK response/); + }); +}); + +describe("getPullRequestFromPrNumber", () => { + it("should fetch pull request by number", async () => { + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + const result = await getPullRequestFromPrNumber(ctx, 123); + expect(result).toEqual(mockPullRequest); + }); + + it("should return null when no token available", async () => { + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + DISABLE_GITHUB_TOKEN_WARNING: "true", + }, + }; + + const result = await getPullRequestFromPrNumber(ctx, 123); + expect(result).toBeNull(); + }); + + it("should return null on 404 response", async () => { + server.use( + http.get( + "https://api.github.com/repos/:owner/:repo/pulls/:prNumber", + () => { + return HttpResponse.json(null, { status: 404 }); + }, + ), + ); + + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + const result = await getPullRequestFromPrNumber(ctx, 999); + expect(result).toBeNull(); + }); + + it("should throw on 500 response", async () => { + server.use( + http.get( + "https://api.github.com/repos/:owner/:repo/pulls/:prNumber", + () => { + return HttpResponse.json(null, { status: 500 }); + }, + ), + ); + + const ctx: Context = { + env: { + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token123", + }, + }; + + await expect(getPullRequestFromPrNumber(ctx, 123)).rejects.toThrow( + /Non-OK response/, + ); + }); +}); + +describe("getPRNumberFromMergeGroupBranch", () => { + it("should extract PR number from merge group branch", () => { + const branch = + "gh-readonly-queue/merge-queue-argos/pr-1559-0bccfee0e5c6d7b3f72d0cab06cc79fc70666e08"; + const result = getPRNumberFromMergeGroupBranch(branch); + expect(result).toBe(1559); + }); + + it("should return null for non-merge group branch", () => { + const result = getPRNumberFromMergeGroupBranch("feature-branch"); + expect(result).toBeNull(); + }); + + it("should return null for invalid merge group format", () => { + const result = getPRNumberFromMergeGroupBranch( + "gh-readonly-queue/master/invalid", + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/ci-environment/github.ts b/packages/core/src/ci-environment/github.ts new file mode 100644 index 00000000..b188150c --- /dev/null +++ b/packages/core/src/ci-environment/github.ts @@ -0,0 +1,173 @@ +import type { Context } from "./types"; +import { debug } from "../debug"; + +export type GitHubPullRequest = { + number: number; + head: { + ref: string; + sha: string; + }; + base: { + ref: string; + }; +}; + +/** + * Get the full repository name (account/repo) from environment variable. + */ +export function getGitHubRepository(ctx: Context): string | null { + return ctx.env.GITHUB_REPOSITORY || null; +} + +/** + * Get the full repository name (account/repo) from environment variable or throws. + */ +function assertGitHubRepository(ctx: Context): string { + const repo = getGitHubRepository(ctx); + if (!repo) { + throw new Error("GITHUB_REPOSITORY is missing"); + } + return repo; +} + +/** + * Get a GitHub token from environment variables. + */ +function getGitHubToken({ env }: Context): string | null { + if (!env.GITHUB_TOKEN) { + // For security reasons, people don't want to expose their GITHUB_TOKEN + // That's why we allow to disable this warning. + if (!env.DISABLE_GITHUB_TOKEN_WARNING) { + console.log( + ` +Argos couldn’t find a relevant pull request in the current environment. +To resolve this, Argos requires a GITHUB_TOKEN to fetch the pull request associated with the head SHA. Please ensure the following environment variable is added: + +GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} + +For more details, check out the documentation: Read more at https://argos-ci.com/docs/run-on-preview-deployment + +If you want to disable this warning, you can set the following environment variable: + +DISABLE_GITHUB_TOKEN_WARNING: true +`.trim(), + ); + } + return null; + } + + return env.GITHUB_TOKEN; +} + +/** + * Fetch GitHub API. + */ +async function fetchGitHubAPI( + ctx: Context, + url: URL | string, +): Promise { + const githubToken = getGitHubToken(ctx); + if (!githubToken) { + return null; + } + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + }); + return response; +} + +const GITHUB_API_BASE_URL = "https://api.github.com"; + +/** + * Get a pull request from a head sha. + * Fetch the last 30 pull requests sorted by updated date + * then try to find the one that matches the head sha. + * If no pull request is found, return null. + */ +export async function getPullRequestFromHeadSha( + ctx: Context, + sha: string, +): Promise { + debug(`Fetching pull request details from head sha: ${sha}`); + const githubRepository = assertGitHubRepository(ctx); + const url = new URL(`/repos/${githubRepository}/pulls`, GITHUB_API_BASE_URL); + url.search = new URLSearchParams({ + state: "open", + sort: "updated", + per_page: "30", + page: "1", + }).toString(); + const response = await fetchGitHubAPI(ctx, url); + if (!response) { + return null; + } + if (!response.ok) { + throw new Error( + `Non-OK response (status: ${response.status}) while fetching pull request details from head sha (${sha})`, + ); + } + const result: GitHubPullRequest[] = await response.json(); + if (result.length === 0) { + debug("No results, no pull request found"); + return null; + } + const matchingPr = result.find((pr) => pr.head.sha === sha); + if (matchingPr) { + debug("Pull request found", matchingPr); + return matchingPr; + } + debug("No matching pull request found"); + return null; +} + +/** + * Get a pull request from a PR number. + */ +export async function getPullRequestFromPrNumber( + ctx: Context, + prNumber: number, +): Promise { + debug(`Fetching pull request #${prNumber}`); + const githubRepository = assertGitHubRepository(ctx); + const response = await fetchGitHubAPI( + ctx, + new URL( + `/repos/${githubRepository}/pulls/${prNumber}`, + GITHUB_API_BASE_URL, + ), + ); + if (!response) { + return null; + } + if (response.status === 404) { + debug( + "No pull request found, pr detection from branch was probably a mistake", + ); + return null; + } + if (!response.ok) { + throw new Error( + `Non-OK response (status: ${response.status}) while fetching pull request #${prNumber}`, + ); + } + const result: GitHubPullRequest = await response.json(); + return result; +} + +/** + * Get the PR number from a merge group branch. + * Example: gh-readonly-queue/master/pr-1529-c1c25caabaade7a8ddc1178c449b872b5d3e51a4 + */ +export function getPRNumberFromMergeGroupBranch(branch: string) { + const prMatch = /queue\/[^/]*\/pr-(\d+)-/.exec(branch); + if (prMatch) { + const prNumber = Number(prMatch[1]); + return prNumber; + } + return null; +} diff --git a/packages/core/src/ci-environment/services/github-actions.ts b/packages/core/src/ci-environment/services/github-actions.ts index 85b32dd8..efff64e1 100644 --- a/packages/core/src/ci-environment/services/github-actions.ts +++ b/packages/core/src/ci-environment/services/github-actions.ts @@ -1,147 +1,78 @@ import { existsSync, readFileSync } from "node:fs"; import type { Service, Context } from "../types"; -import { debug } from "../../debug"; import { getMergeBaseCommitSha, listParentCommits } from "../git"; import type * as webhooks from "@octokit/webhooks"; import type { RepositoryDispatchContext } from "@vercel/repository-dispatch/context"; +import { + getGitHubRepository, + getPRNumberFromMergeGroupBranch, + getPullRequestFromHeadSha, + getPullRequestFromPrNumber, + type GitHubPullRequest, +} from "../github"; +import { debug } from "../../debug"; type EventPayload = webhooks.EmitterWebhookEvent["payload"]; -type GitHubPullRequest = { - number: number; - head: { - ref: string; - sha: string; - }; - base: { - ref: string; - }; -}; - -function getGitHubRepository({ env }: Context) { - if (!env.GITHUB_REPOSITORY) { - throw new Error("GITHUB_REPOSITORY is missing"); - } - return env.GITHUB_REPOSITORY; -} - -function getGitHubToken({ env }: Context) { - if (!env.GITHUB_TOKEN) { - // For security reasons, people don't want to expose their GITHUB_TOKEN - // That's why we allow to disable this warning. - if (!env.DISABLE_GITHUB_TOKEN_WARNING) { - console.log( - ` -Argos couldn’t find a relevant pull request in the current environment. -To resolve this, Argos requires a GITHUB_TOKEN to fetch the pull request associated with the head SHA. Please ensure the following environment variable is added: - -GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} - -For more details, check out the documentation: Read more at https://argos-ci.com/docs/run-on-preview-deployment - -If you want to disable this warning, you can set the following environment variable: - -DISABLE_GITHUB_TOKEN_WARNING: true -`.trim(), - ); - } +/** + * Read the event payload. + */ +function readEventPayload({ env }: Context): null | EventPayload { + if (!env.GITHUB_EVENT_PATH) { return null; } - return env.GITHUB_TOKEN; -} - -function getGhAPIHeaders(ctx: Context) { - const githubToken = getGitHubToken(ctx); - if (!githubToken) { + if (!existsSync(env.GITHUB_EVENT_PATH)) { return null; } - return { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken}`, - "X-GitHub-Api-Version": "2022-11-28", - }; -} -async function fetchGitHubAPI(ctx: Context, url: URL | string) { - const headers = getGhAPIHeaders(ctx); - if (!headers) { - return null; - } - const response = await fetch(url, { - headers, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) { - throw new Error(`Failed to fetch GitHub API: ${response.statusText}`); - } - return (await response.json()) as T; + return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")); } +type VercelDeploymentPayload = RepositoryDispatchContext["payload"]; + /** - * Get a pull request from a head sha. - * Fetch the last 30 pull requests sorted by updated date - * then try to find the one that matches the head sha. - * If no pull request is found, return null. + * Get a payload from a Vercel deployment "repository_dispatch" + * @see https://vercel.com/docs/git/vercel-for-github#repository-dispatch-events */ -async function getPullRequestFromHeadSha(ctx: Context, sha: string) { - debug("Fetching pull request details from head sha", sha); - const githubRepository = getGitHubRepository(ctx); - try { - const url = new URL( - `https://api.github.com/repos/${githubRepository}/pulls`, - ); - url.search = new URLSearchParams({ - state: "open", - sort: "updated", - per_page: "30", - page: "1", - }).toString(); - const result = await fetchGitHubAPI(ctx, url); - if (!result) { - return null; - } - if (result.length === 0) { - debug("Aborting because no pull request found"); - return null; - } - const matchingPr = result.find((pr) => pr.head.sha === sha); - if (matchingPr) { - debug("Pull request found", matchingPr); - return matchingPr; - } - debug("Aborting because no pull request found"); - return null; - } catch (error) { - debug("Error while fetching pull request details from head sha", error); - return null; +function getVercelDeploymentPayload( + payload: EventPayload | null, +): VercelDeploymentPayload | null { + if ( + process.env.GITHUB_EVENT_NAME === "repository_dispatch" && + payload && + "action" in payload && + payload.action === "vercel.deployment.success" + ) { + return payload as unknown as VercelDeploymentPayload; } + return null; } +type MergeGroupEventPayload = + webhooks.EmitterWebhookEvent<"merge_group.checks_requested">["payload"]; + /** - * Get a pull request from a PR number. + * Get a merge group payload from a "merge_group" event. */ -async function getPullRequestFromPrNumber(ctx: Context, prNumber: number) { - debug("Fetching pull request details from pull request number", prNumber); - const githubRepository = getGitHubRepository(ctx); - const headers = getGhAPIHeaders(ctx); - if (!headers) { - return null; - } - try { - return await fetchGitHubAPI( - ctx, - `https://api.github.com/repos/${githubRepository}/pulls/${prNumber}`, - ); - } catch (error) { - debug( - "Error while fetching pull request details from pull request number", - error, - ); - return null; +function getMergeGroupPayload( + payload: EventPayload | null, +): MergeGroupEventPayload | null { + if ( + payload && + process.env.GITHUB_EVENT_NAME === "merge_group" && + "action" in payload && + payload.action === "checks_requested" + ) { + return payload as unknown as webhooks.EmitterWebhookEvent<"merge_group.checks_requested">["payload"]; } + + return null; } +/** + * Get the branch from the local context. + */ function getBranchFromContext(context: Context): string | null { const { env } = context; @@ -159,18 +90,8 @@ function getBranchFromContext(context: Context): string | null { } /** - * Get the PR number from a merge group branch. - * Example: gh-readonly-queue/master/pr-1529-c1c25caabaade7a8ddc1178c449b872b5d3e51a4 + * Get the branch from the payload. */ -function getPRNumberFromMergeGroupBranch(branch: string) { - const prMatch = /queue\/[^/]*\/pr-(\d+)-/.exec(branch); - if (prMatch) { - const prNumber = Number(prMatch[1]); - return prNumber; - } - return null; -} - function getBranchFromPayload(payload: EventPayload): string | null { if ("workflow_run" in payload && payload.workflow_run) { return payload.workflow_run.head_branch; @@ -184,6 +105,54 @@ function getBranchFromPayload(payload: EventPayload): string | null { return null; } +/** + * Get the branch. + */ +function getBranch(args: { + payload: EventPayload | null; + mergeGroupPayload: MergeGroupEventPayload | null; + vercelPayload: VercelDeploymentPayload | null; + pullRequest: GitHubPullRequest | null; + context: Context; +}) { + const { payload, mergeGroupPayload, vercelPayload, pullRequest, context } = + args; + + // If there's a merge group and a PR detected, use the PR branch. + if (mergeGroupPayload && pullRequest?.head.ref) { + return pullRequest.head.ref; + } + + // If there's a Vercel payload, use it. + if (vercelPayload) { + return vercelPayload.client_payload.git.ref; + } + + // Or from the payload. + if (payload) { + const fromPayload = getBranchFromPayload(payload); + if (fromPayload) { + return fromPayload; + } + } + + // Or from the context (environment variables). + const fromContext = getBranchFromContext(context); + if (fromContext) { + return fromContext; + } + + // Or from the PR if available. + if (pullRequest) { + return pullRequest.head.ref; + } + + return null; +} + +/** + * Get the repository either from payload or from environment variables. + */ function getRepository( context: Context, payload: EventPayload | null, @@ -196,32 +165,31 @@ function getRepository( } } - return getOriginalRepository(context); + return getGitHubRepository(context); } -function getOriginalRepository(context: Context): string | null { - const { env } = context; - return env.GITHUB_REPOSITORY || null; -} - -function readEventPayload({ env }: Context): null | EventPayload { - if (!env.GITHUB_EVENT_PATH) { - return null; +/** + * Get the head sha. + */ +function getSha( + context: Context, + vercelPayload: VercelDeploymentPayload | null, +): string { + if (vercelPayload) { + return vercelPayload.client_payload.git.sha; } - if (!existsSync(env.GITHUB_EVENT_PATH)) { - return null; + if (!context.env.GITHUB_SHA) { + throw new Error(`GITHUB_SHA is missing`); } - return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")); + return context.env.GITHUB_SHA; } /** * Get the pull request from an event payload. */ -function getPullRequestFromPayload( - payload: EventPayload, -): GitHubPullRequest | null { +function getPullRequestFromPayload(payload: EventPayload) { if ( "pull_request" in payload && payload.pull_request && @@ -250,53 +218,39 @@ function getPullRequestFromPayload( return null; } -type VercelDeploymentPayload = RepositoryDispatchContext["payload"]; - -/** - * Get a payload from a Vercel deployment "repository_dispatch" - * @see https://vercel.com/docs/git/vercel-for-github#repository-dispatch-events - */ -function getVercelDeploymentPayload(payload: EventPayload | null) { - if ( - process.env.GITHUB_EVENT_NAME === "repository_dispatch" && - payload && - "action" in payload && - payload.action === "vercel.deployment.success" - ) { - return payload as unknown as VercelDeploymentPayload; - } - return null; -} - /** - * Get a merge group payload from a "merge_group" event. + * Get the pull request either from payload or local fetching. */ -function getMergeGroupPayload(payload: EventPayload | null) { - if ( - payload && - process.env.GITHUB_EVENT_NAME === "merge_group" && - "action" in payload && - payload.action === "checks_requested" - ) { - return payload as unknown as webhooks.EmitterWebhookEvent<"merge_group.checks_requested">["payload"]; +async function getPullRequest(args: { + payload: EventPayload | null; + vercelPayload: VercelDeploymentPayload | null; + mergeGroupPayload: MergeGroupEventPayload | null; + context: Context; + sha: string; +}) { + const { payload, vercelPayload, mergeGroupPayload, context, sha } = args; + + if (vercelPayload || !payload) { + return getPullRequestFromHeadSha(context, sha); } - return null; -} - -function getSha( - context: Context, - vercelPayload: VercelDeploymentPayload | null, -): string { - if (vercelPayload) { - return vercelPayload.client_payload.git.sha; - } - - if (!context.env.GITHUB_SHA) { - throw new Error(`GITHUB_SHA is missing`); + if (mergeGroupPayload) { + const prNumber = getPRNumberFromMergeGroupBranch( + mergeGroupPayload.merge_group.head_ref, + ); + if (!prNumber) { + debug( + `No PR found from merge group head ref: ${mergeGroupPayload.merge_group.head_ref}`, + ); + return null; + } + debug( + `PR #${prNumber} found from merge group head ref (${mergeGroupPayload.merge_group.head_ref})`, + ); + return getPullRequestFromPrNumber(context, prNumber); } - return context.env.GITHUB_SHA; + return getPullRequestFromPayload(payload); } const service: Service = { @@ -309,46 +263,36 @@ const service: Service = { const vercelPayload = getVercelDeploymentPayload(payload); const mergeGroupPayload = getMergeGroupPayload(payload); const sha = getSha(context, vercelPayload); - - const pullRequest = await (() => { - if (vercelPayload || !payload) { - return getPullRequestFromHeadSha(context, sha); - } - - if (mergeGroupPayload) { - const prNumber = getPRNumberFromMergeGroupBranch( - mergeGroupPayload.merge_group.head_ref, - ); - if (!prNumber) { - return null; - } - return getPullRequestFromPrNumber(context, prNumber); - } - - return getPullRequestFromPayload(payload); - })(); + const pullRequest = await getPullRequest({ + payload, + vercelPayload, + mergeGroupPayload, + sha, + context, + }); + const branch = getBranch({ + payload, + vercelPayload, + mergeGroupPayload, + context, + pullRequest, + }); return { commit: sha, repository: getRepository(context, payload), - originalRepository: getOriginalRepository(context), + originalRepository: getGitHubRepository(context), jobId: env.GITHUB_JOB || null, runId: env.GITHUB_RUN_ID || null, runAttempt: env.GITHUB_RUN_ATTEMPT ? Number(env.GITHUB_RUN_ATTEMPT) : null, nonce: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, - branch: - (mergeGroupPayload ? pullRequest?.head.ref : null) || - vercelPayload?.client_payload?.git?.ref || - getBranchFromContext(context) || - pullRequest?.head.ref || - (payload ? getBranchFromPayload(payload) : null) || - null, + branch, prNumber: pullRequest?.number || null, prHeadCommit: pullRequest?.head.sha ?? null, prBaseBranch: pullRequest?.base.ref ?? null, - mergeQueue: mergeGroupPayload?.action === "checks_requested", + mergeQueue: Boolean(mergeGroupPayload), }; }, getMergeBaseCommitSha,