diff --git a/jest.integration.config.cjs b/jest.integration.config.cjs index e6d178d..0ce1a64 100644 --- a/jest.integration.config.cjs +++ b/jest.integration.config.cjs @@ -1,3 +1,10 @@ +const { randomBytes } = require("crypto"); +const path = require("path"); +const os = require("os"); + +const ROOT_TEST_BRANCH_PREFIX = `test-${randomBytes(4).toString("hex")}`; +const ROOT_TEMP_DIRECTORY = path.join(os.tmpdir(), ROOT_TEST_BRANCH_PREFIX); + module.exports = { preset: "ts-jest", testEnvironment: "node", @@ -17,4 +24,9 @@ module.exports = { "^(.+).js$": "$1", }, testMatch: ["/src/test/integration/**/*.test.ts"], + globals: { + ROOT_TEST_BRANCH_PREFIX, + ROOT_TEMP_DIRECTORY, + }, + globalTeardown: "/src/test/integration/jest.globalTeardown.ts", }; diff --git a/package.json b/package.json index c829eb1..e08ae85 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,11 @@ } }, "scripts": { - "build": "pnpm codegen:github && tsc --noEmit && tsup", + "build": "rm -rf dist && pnpm codegen:github && tsc --noEmit && tsup", "codegen:github": "graphql-codegen --config src/github/codegen.ts", "format:check": "prettier --check \"**/*.{ts,tsx,md}\"", "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"", "lint": "eslint . --max-warnings 0", - "test": "jest", - "test:watch": "jest --watch", "test:integration": "jest --config jest.integration.config.cjs" }, "devDependencies": { diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..81a352b --- /dev/null +++ b/src/core.ts @@ -0,0 +1,100 @@ +import type { + CommitMessage, + FileChanges, +} from "./github/graphql/generated/types.js"; +import { + createCommitOnBranchQuery, + createRefMutation, + getRepositoryMetadata, + GitHubClient, +} from "./github/graphql/queries.js"; +import type { CreateCommitOnBranchMutationVariables } from "./github/graphql/generated/operations.js"; +import type { Logger } from "./logging.js"; + +export type CommitFilesResult = { + refId: string | null; +}; + +export type CommitFilesFromBase64Args = { + octokit: GitHubClient; + owner: string; + repository: string; + branch: string; + /** + * The current commit that the target branch is at + */ + baseBranch: string; + /** + * The commit message + */ + message: CommitMessage; + fileChanges: FileChanges; + log?: Logger; +}; + +export const commitFilesFromBase64 = async ({ + octokit, + owner, + repository, + branch, + baseBranch, + message, + fileChanges, + log, +}: CommitFilesFromBase64Args): Promise => { + const repositoryNameWithOwner = `${owner}/${repository}`; + const baseRef = `refs/heads/${baseBranch}`; + + log?.debug(`Getting repo info ${repositoryNameWithOwner}`); + const info = await getRepositoryMetadata(octokit, { + owner, + name: repository, + ref: baseRef, + }); + log?.debug(`Repo info: ${JSON.stringify(info, null, 2)}`); + + if (!info) { + throw new Error(`Repository ${repositoryNameWithOwner} not found`); + } + + const oid = info.ref?.target?.oid; + + if (!info) { + throw new Error(`Ref ${baseRef} not found`); + } + + log?.debug(`Creating branch ${branch} from commit ${oid}}`); + const refId = await createRefMutation(octokit, { + input: { + repositoryId: info.id, + name: `refs/heads/${branch}`, + oid, + }, + }); + + log?.debug(`Created branch with refId ${JSON.stringify(refId, null, 2)}`); + + const refIdStr = refId.createRef?.ref?.id; + + if (!refIdStr) { + throw new Error(`Failed to create branch ${branch}`); + } + + await log?.debug(`Creating commit on branch ${branch}`); + const createCommitMutation: CreateCommitOnBranchMutationVariables = { + input: { + branch: { + id: refIdStr, + }, + expectedHeadOid: oid, + message, + fileChanges, + }, + }; + log?.debug(JSON.stringify(createCommitMutation, null, 2)); + + const result = await createCommitOnBranchQuery(octokit, createCommitMutation); + return { + refId: result.createCommitOnBranch?.ref?.id ?? null, + }; +}; diff --git a/src/fs.ts b/src/fs.ts index 522179f..1aa136b 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,126 +1,43 @@ import { promises as fs } from "fs"; import * as path from "path"; -import type { - CommitMessage, - FileAddition, - FileDeletion, -} from "./github/graphql/generated/types"; -import { - createCommitOnBranchQuery, - createRefMutation, - getRepositoryMetadata, - GitHubClient, -} from "./github/graphql/queries"; -import type { CreateCommitOnBranchMutationVariables } from "./github/graphql/generated/operations"; -import type { Logger } from "./logging"; - -export const commitFilesFromDirectory = async (args: { - octokit: GitHubClient; +import type { FileAddition } from "./github/graphql/generated/types.js"; +import { CommitFilesFromBase64Args, CommitFilesResult } from "./core.js"; +import { commitFilesFromBuffers } from "./node.js"; + +export type CommitFilesFromDirectoryArgs = Omit< + CommitFilesFromBase64Args, + "fileChanges" +> & { /** - * The root of the github repository. + * The directory to consider the root of the repository when calculating + * file paths */ workingDirectory?: string; - owner: string; - repository: string; - branch: string; - /** - * The current commit that the target branch is at - */ - baseBranch: string; - /** - * The commit message - */ - message: CommitMessage; fileChanges: { - /** - * File paths (relative to the repository root) - */ additions?: string[]; deletions?: string[]; }; - log?: Logger; -}) => { - const { - octokit, - workingDirectory = process.cwd(), - owner, - repository, - branch, - baseBranch, - message, - fileChanges, - log, - } = args; - const repositoryNameWithOwner = `${owner}/${repository}`; - const baseRef = `refs/heads/${baseBranch}`; +}; +export const commitFilesFromDirectory = async ({ + workingDirectory = process.cwd(), + fileChanges, + ...otherArgs +}: CommitFilesFromDirectoryArgs): Promise => { const additions: FileAddition[] = await Promise.all( (fileChanges.additions || []).map(async (p) => { - const fileContents = await fs.readFile(path.join(workingDirectory, p)); - const base64Contents = Buffer.from(fileContents).toString("base64"); return { path: p, - contents: base64Contents, + contents: await fs.readFile(path.join(workingDirectory, p)), }; }), ); - const deletions: FileDeletion[] = - fileChanges.deletions?.map((p) => ({ - path: p, - })) ?? []; - - log?.debug(`Getting repo info ${repositoryNameWithOwner}`); - const info = await getRepositoryMetadata(octokit, { - owner: args.owner, - name: args.repository, - ref: baseRef, - }); - log?.debug(`Repo info: ${JSON.stringify(info, null, 2)}`); - - if (!info) { - throw new Error(`Repository ${repositoryNameWithOwner} not found`); - } - - const oid = info.ref?.target?.oid; - - if (!info) { - throw new Error(`Ref ${baseRef} not found`); - } - - log?.debug(`Creating branch ${branch} from commit ${oid}}`); - const refId = await createRefMutation(octokit, { - input: { - repositoryId: info.id, - name: `refs/heads/${branch}`, - oid, + return commitFilesFromBuffers({ + ...otherArgs, + fileChanges: { + additions, + deletions: fileChanges.deletions, }, }); - - log?.debug(`Created branch with refId ${JSON.stringify(refId, null, 2)}`); - - const refIdStr = refId.createRef?.ref?.id; - - if (!refIdStr) { - throw new Error(`Failed to create branch ${branch}`); - } - - await log?.debug(`Creating commit on branch ${args.branch}`); - const createCommitMutation: CreateCommitOnBranchMutationVariables = { - input: { - branch: { - id: refIdStr, - }, - expectedHeadOid: oid, - message, - fileChanges: { - additions, - deletions, - }, - }, - }; - log?.debug(JSON.stringify(createCommitMutation, null, 2)); - - const result = await createCommitOnBranchQuery(octokit, createCommitMutation); - return result.createCommitOnBranch?.ref?.id ?? null; }; diff --git a/src/github/graphql/queries.ts b/src/github/graphql/queries.ts index 50783e5..04caea3 100644 --- a/src/github/graphql/queries.ts +++ b/src/github/graphql/queries.ts @@ -1,6 +1,6 @@ -import type { GitHub } from "@actions/github/lib/utils"; - -export type GitHubClient = InstanceType; +export type GitHubClient = { + graphql: (query: string, variables: any) => Promise; +}; import type { CreateCommitOnBranchMutation, @@ -11,7 +11,7 @@ import type { DeleteRefMutationVariables, GetRepositoryMetadataQuery, GetRepositoryMetadataQueryVariables, -} from "./generated/operations"; +} from "./generated/operations.js"; const GET_REPOSITORY_METADATA = /* GraphQL */ ` query getRepositoryMetadata($owner: String!, $name: String!, $ref: String!) { diff --git a/src/index.ts b/src/index.ts index 247698c..f0f0c05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export * as queries from "./github/graphql/queries"; -export { commitFilesFromDirectory } from "./fs"; +export * as queries from "./github/graphql/queries.js"; +export { commitFilesFromDirectory } from "./fs.js"; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..fb07095 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,34 @@ +import { + commitFilesFromBase64, + CommitFilesFromBase64Args, + CommitFilesResult, +} from "./core.js"; + +export type CommitFilesFromBuffersArgs = Omit< + CommitFilesFromBase64Args, + "fileChanges" +> & { + fileChanges: { + additions?: Array<{ + path: string; + contents: Buffer; + }>; + deletions?: string[]; + }; +}; + +export const commitFilesFromBuffers = async ({ + fileChanges, + ...otherArgs +}: CommitFilesFromBuffersArgs): Promise => { + return commitFilesFromBase64({ + ...otherArgs, + fileChanges: { + additions: fileChanges.additions?.map(({ path, contents }) => ({ + path, + contents: contents.toString("base64"), + })), + deletions: fileChanges.deletions?.map((path) => ({ path })), + }, + }); +}; diff --git a/src/test/integration/env.ts b/src/test/integration/env.ts new file mode 100644 index 0000000..8074d44 --- /dev/null +++ b/src/test/integration/env.ts @@ -0,0 +1,37 @@ +import { pino } from "pino"; +import { configDotenv } from "dotenv"; + +declare namespace global { + const ROOT_TEST_BRANCH_PREFIX: string; + const ROOT_TEMP_DIRECTORY: string; +} + +export const ROOT_TEST_BRANCH_PREFIX = global.ROOT_TEST_BRANCH_PREFIX; +export const ROOT_TEMP_DIRECTORY = global.ROOT_TEMP_DIRECTORY; + +configDotenv(); + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN must be set"); +} + +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; + +const [owner, repository] = GITHUB_REPOSITORY?.split("/") || []; +if (!owner || !repository) { + throw new Error("GITHUB_REPOSITORY must be set"); +} + +export const ENV = { + GITHUB_TOKEN, +}; + +export const REPO = { owner, repository }; + +export const log = pino({ + level: process.env.RUNNER_DEBUG === "1" ? "debug" : "info", + transport: { + target: "pino-pretty", + }, +}); diff --git a/src/test/integration/fs.test.ts b/src/test/integration/fs.test.ts index f62ea14..386392c 100644 --- a/src/test/integration/fs.test.ts +++ b/src/test/integration/fs.test.ts @@ -1,60 +1,34 @@ -/** - * This file includes tests that will be run in CI. - */ -import * as os from "os"; import * as path from "path"; import { promises as fs } from "fs"; -import { getOctokit } from "@actions/github/lib/github"; -import pino from "pino"; -import { configDotenv } from "dotenv"; +import { getOctokit } from "@actions/github/lib/github.js"; -import { commitFilesFromDirectory } from "../../fs"; -import { randomBytes } from "crypto"; +import { commitFilesFromDirectory } from "../../fs.js"; import { - deleteRefMutation, - getRepositoryMetadata, -} from "../../github/graphql/queries"; + ENV, + REPO, + ROOT_TEMP_DIRECTORY, + ROOT_TEST_BRANCH_PREFIX, + log, +} from "./env.js"; +import { deleteBranches } from "./util.js"; -configDotenv(); - -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -if (!GITHUB_TOKEN) { - throw new Error("GITHUB_TOKEN must be set"); -} - -const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; - -const [owner, repository] = GITHUB_REPOSITORY?.split("/") || []; -if (!owner || !repository) { - throw new Error("GITHUB_REPOSITORY must be set"); -} - -const log = pino({ - level: process.env.RUNNER_DEBUG === "1" ? "debug" : "info", - transport: { - target: "pino-pretty", - }, -}); - -const octokit = getOctokit(GITHUB_TOKEN); - -const TEST_BRANCH_PREFIX = `test-${randomBytes(4).toString("hex")}`; +const octokit = getOctokit(ENV.GITHUB_TOKEN); const TEST_BRANCHES = { - COMMIT_FILE: `${TEST_BRANCH_PREFIX}-commit-file`, + COMMIT_FILE: `${ROOT_TEST_BRANCH_PREFIX}-fs-commit-file`, } as const; describe("fs", () => { describe("commitFilesFromDirectory", () => { it("should commit a file", async () => { // Create test directory - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "test-")); + await fs.mkdir(ROOT_TEMP_DIRECTORY, { recursive: true }); + const tmpDir = await fs.mkdtemp(path.join(ROOT_TEMP_DIRECTORY, "test-")); await fs.writeFile(path.join(tmpDir, "foo.txt"), "Hello, world!"); await commitFilesFromDirectory({ octokit, - owner, - repository, + ...REPO, branch: TEST_BRANCHES.COMMIT_FILE, baseBranch: "main", message: { @@ -73,31 +47,6 @@ describe("fs", () => { afterAll(async () => { console.info("Cleaning up test branches"); - await Promise.all( - Object.values(TEST_BRANCHES).map(async (branch) => { - console.debug(`Deleting branch ${branch}`); - // Get Ref - const ref = await getRepositoryMetadata(octokit, { - owner, - name: repository, - ref: `refs/heads/${branch}`, - }); - - const refId = ref?.ref?.id; - - if (!refId) { - console.warn(`Branch ${branch} not found`); - return; - } - - await deleteRefMutation(octokit, { - input: { - refId, - }, - }); - - console.debug(`Deleted branch ${branch}`); - }), - ); + await deleteBranches(octokit, Object.values(TEST_BRANCHES)); }); }); diff --git a/src/test/integration/jest.globalTeardown.ts b/src/test/integration/jest.globalTeardown.ts new file mode 100644 index 0000000..926531a --- /dev/null +++ b/src/test/integration/jest.globalTeardown.ts @@ -0,0 +1,12 @@ +import { promises as fs } from "fs"; +import { Config } from "jest"; + +module.exports = async (_: unknown, projectConfig: Config) => { + const directory = projectConfig.globals?.ROOT_TEMP_DIRECTORY; + if (!(typeof directory == "string")) { + throw new Error("ROOT_TEMP_DIRECTORY must be set"); + } + console.log(`Deleting directory: ${directory}`); + + await fs.rm(directory, { recursive: true }); +}; diff --git a/src/test/integration/node.test.ts b/src/test/integration/node.test.ts new file mode 100644 index 0000000..9b0ab87 --- /dev/null +++ b/src/test/integration/node.test.ts @@ -0,0 +1,60 @@ +import { getOctokit } from "@actions/github/lib/github.js"; + +import { ENV, REPO, ROOT_TEST_BRANCH_PREFIX, log } from "./env.js"; +import { commitFilesFromBuffers } from "../../node.js"; +import { deleteBranches } from "./util.js"; + +const octokit = getOctokit(ENV.GITHUB_TOKEN); + +const TEST_BRANCH_PREFIX = `${ROOT_TEST_BRANCH_PREFIX}-node`; + +describe("node", () => { + const branches: string[] = []; + + // Set timeout to 1 minute + jest.setTimeout(60 * 1000); + + describe("commitFilesFromBuffers", () => { + describe("can commit single file of various sizes", () => { + const SIZES_BYTES = { + "1KiB": 1024, + "1MiB": 1024 * 1024, + "10MiB": 1024 * 1024 * 10, + }; + + for (const [sizeName, sizeBytes] of Object.entries(SIZES_BYTES)) { + it(`Can commit a ${sizeName}`, async () => { + const branch = `${TEST_BRANCH_PREFIX}-${sizeName}`; + branches.push(branch); + const contents = Buffer.alloc(sizeBytes, "Hello, world!"); + + await commitFilesFromBuffers({ + octokit, + ...REPO, + branch, + baseBranch: "main", + message: { + headline: "Test commit", + body: "This is a test commit", + }, + fileChanges: { + additions: [ + { + path: `${sizeName}.txt`, + contents, + }, + ], + }, + log, + }); + }); + } + }); + }); + + afterAll(async () => { + console.info("Cleaning up test branches"); + + await deleteBranches(octokit, branches); + }); +}); diff --git a/src/test/integration/util.ts b/src/test/integration/util.ts new file mode 100644 index 0000000..5a52271 --- /dev/null +++ b/src/test/integration/util.ts @@ -0,0 +1,37 @@ +import { + deleteRefMutation, + getRepositoryMetadata, + GitHubClient, +} from "../../github/graphql/queries.js"; +import { REPO } from "./env.js"; + +export const deleteBranches = async ( + octokit: GitHubClient, + branches: string[], +) => + Promise.all( + branches.map(async (branch) => { + console.debug(`Deleting branch ${branch}`); + // Get Ref + const ref = await getRepositoryMetadata(octokit, { + owner: REPO.owner, + name: REPO.repository, + ref: `refs/heads/${branch}`, + }); + + const refId = ref?.ref?.id; + + if (!refId) { + console.warn(`Branch ${branch} not found`); + return; + } + + await deleteRefMutation(octokit, { + input: { + refId, + }, + }); + + console.debug(`Deleted branch ${branch}`); + }), + ); diff --git a/tsup.config.ts b/tsup.config.ts index 00fa86f..2a0b31c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/fs.ts"], + entry: ["src/index.ts", "src/core.ts", "src/fs.ts", "src/node.ts"], format: ["cjs", "esm"], dts: true, });