diff --git a/src/core.ts b/src/core.ts index d52eace..4ff9686 100644 --- a/src/core.ts +++ b/src/core.ts @@ -7,6 +7,7 @@ import { createRefMutation, getRepositoryMetadata, GitHubClient, + updateRefMutation, } from "./github/graphql/queries.js"; import type { CreateCommitOnBranchMutationVariables, @@ -38,6 +39,11 @@ export type CommitFilesFromBase64Args = { * The current branch, tag or commit that the new branch should be based on. */ base: GitBase; + /** + * Push the commit even if the branch exists and does not match what was + * specified as the base. + */ + force?: boolean; /** * The commit message */ @@ -58,7 +64,8 @@ const getBaseRef = (base: GitBase): string => { const getOidFromRef = ( base: GitBase, - ref: (GetRepositoryMetadataQuery["repository"] & Record)["ref"], + ref: (GetRepositoryMetadataQuery["repository"] & + Record)["baseRef"], ) => { if ("commit" in base) { return base.commit; @@ -81,18 +88,21 @@ export const commitFilesFromBase64 = async ({ repository, branch, base, + force = false, message, fileChanges, log, }: CommitFilesFromBase64Args): Promise => { const repositoryNameWithOwner = `${owner}/${repository}`; const baseRef = getBaseRef(base); + const targetRef = `refs/heads/${branch}`; log?.debug(`Getting repo info ${repositoryNameWithOwner}`); const info = await getRepositoryMetadata(octokit, { owner, name: repository, - ref: baseRef, + baseRef, + targetRef, }); log?.debug(`Repo info: ${JSON.stringify(info, null, 2)}`); @@ -100,7 +110,7 @@ export const commitFilesFromBase64 = async ({ throw new Error(`Repository ${repositoryNameWithOwner} not found`); } - if (!info.ref) { + if (!info.baseRef) { throw new Error(`Ref ${baseRef} not found`); } @@ -109,39 +119,77 @@ export const commitFilesFromBase64 = async ({ * The commit oid to base the new commit on. * * Used both to create / update the new branch (if necessary), - * and th ensure no changes have been made as we push the new commit. + * and to ensure no changes have been made as we push the new commit. */ - const baseOid = getOidFromRef(base, info.ref); + const baseOid = getOidFromRef(base, info.baseRef); let refId: string; if ("branch" in base && base.branch === branch) { log?.debug(`Committing to the same branch as base: ${branch} (${baseOid})`); // Get existing branch refId - refId = info.ref.id; + refId = info.baseRef.id; } else { - // Create branch as not committing to same branch - // TODO: detect if branch already exists, and overwrite if so - log?.debug(`Creating branch ${branch} from commit ${baseOid}}`); - const refIdCreation = await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: baseOid, - }, - }); - - log?.debug( - `Created branch with refId ${JSON.stringify(refIdCreation, null, 2)}`, - ); - - const refIdStr = refIdCreation.createRef?.ref?.id; - - if (!refIdStr) { - throw new Error(`Failed to create branch ${branch}`); + // Determine if the branch needs to be created or not + if (info.targetBranch?.target?.oid) { + // Branch already exists, check if it matches the base + if (info.targetBranch.target.oid !== baseOid) { + if (force) { + log?.debug( + `Branch ${branch} exists but does not match base ${baseOid}, forcing update to base`, + ); + const refIdUpdate = await updateRefMutation(octokit, { + input: { + refId: info.targetBranch.id, + oid: baseOid, + }, + }); + + log?.debug( + `Updated branch with refId ${JSON.stringify(refIdUpdate, null, 2)}`, + ); + + const refIdStr = refIdUpdate.updateRef?.ref?.id; + + if (!refIdStr) { + throw new Error(`Failed to create branch ${branch}`); + } + + refId = refIdStr; + } else { + throw new Error( + `Branch ${branch} exists already and does not match base ${baseOid}, force is set to false`, + ); + } + } else { + log?.debug( + `Branch ${branch} already exists and matches base ${baseOid}`, + ); + refId = info.targetBranch.id; + } + } else { + // Create branch as it does not exist yet + log?.debug(`Creating branch ${branch} from commit ${baseOid}}`); + const refIdCreation = await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: baseOid, + }, + }); + + log?.debug( + `Created branch with refId ${JSON.stringify(refIdCreation, null, 2)}`, + ); + + const refIdStr = refIdCreation.createRef?.ref?.id; + + if (!refIdStr) { + throw new Error(`Failed to create branch ${branch}`); + } + + refId = refIdStr; } - - refId = refIdStr; } await log?.debug(`Creating commit on branch ${branch}`); diff --git a/src/github/graphql/queries.ts b/src/github/graphql/queries.ts index 6562f35..1fa372a 100644 --- a/src/github/graphql/queries.ts +++ b/src/github/graphql/queries.ts @@ -11,13 +11,20 @@ import type { DeleteRefMutationVariables, GetRepositoryMetadataQuery, GetRepositoryMetadataQueryVariables, + UpdateRefMutation, + UpdateRefMutationVariables, } from "./generated/operations.js"; const GET_REPOSITORY_METADATA = /* GraphQL */ ` - query getRepositoryMetadata($owner: String!, $name: String!, $ref: String!) { + query getRepositoryMetadata( + $owner: String! + $name: String! + $baseRef: String! + $targetRef: String! + ) { repository(owner: $owner, name: $name) { id - ref(qualifiedName: $ref) { + baseRef: ref(qualifiedName: $baseRef) { id target { oid @@ -28,6 +35,12 @@ const GET_REPOSITORY_METADATA = /* GraphQL */ ` } } } + targetBranch: ref(qualifiedName: $targetRef) { + id + target { + oid + } + } } } `; @@ -42,6 +55,16 @@ const CREATE_REF = /* GraphQL */ ` } `; +const UPDATE_REF = /* GraphQL */ ` + mutation updateRef($input: UpdateRefInput!) { + updateRef(input: $input) { + ref { + id + } + } + } +`; + const DELETE_REF = /* GraphQL */ ` mutation deleteRef($input: DeleteRefInput!) { deleteRef(input: $input) { @@ -77,6 +100,12 @@ export const createRefMutation = async ( ): Promise => await o.graphql(CREATE_REF, v); +export const updateRefMutation = async ( + o: GitHubClient, + v: UpdateRefMutationVariables, +): Promise => + await o.graphql(UPDATE_REF, v); + export const deleteRefMutation = async ( o: GitHubClient, v: DeleteRefMutationVariables, diff --git a/src/test/integration/jest.globalTeardown.ts b/src/test/integration/jest.globalTeardown.ts index 926531a..3fa2f2f 100644 --- a/src/test/integration/jest.globalTeardown.ts +++ b/src/test/integration/jest.globalTeardown.ts @@ -8,5 +8,7 @@ module.exports = async (_: unknown, projectConfig: Config) => { } console.log(`Deleting directory: ${directory}`); - await fs.rm(directory, { recursive: true }); + await fs.rm(directory, { recursive: true }).catch((err) => { + console.error(`Error deleting directory: ${err}`); + }); }; diff --git a/src/test/integration/node.test.ts b/src/test/integration/node.test.ts index 1c239ea..4115f1c 100644 --- a/src/test/integration/node.test.ts +++ b/src/test/integration/node.test.ts @@ -3,6 +3,10 @@ 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"; +import { + createRefMutation, + getRepositoryMetadata, +} from "../../github/graphql/queries.js"; const octokit = getOctokit(ENV.GITHUB_TOKEN); @@ -14,6 +18,38 @@ describe("node", () => { // Set timeout to 1 minute jest.setTimeout(60 * 1000); + const contents = Buffer.alloc(1024, "Hello, world!"); + const BASIC_FILE_CONTENTS = { + message: { + headline: "Test commit", + body: "This is a test commit", + }, + fileChanges: { + additions: [ + { + path: `foo.txt`, + contents, + }, + ], + }, + log, + }; + + let repositoryId: string; + + beforeAll(async () => { + const response = await getRepositoryMetadata(octokit, { + owner: REPO.owner, + name: REPO.repository, + baseRef: "HEAD", + targetRef: "HEAD", + }); + if (!response?.id) { + throw new Error("Repository not found"); + } + repositoryId = response.id; + }); + describe("commitFilesFromBuffers", () => { describe("can commit single file of various sizes", () => { const SIZES_BYTES = { @@ -56,7 +92,6 @@ describe("node", () => { it("can commit using tag as a base", async () => { const branch = `${TEST_BRANCH_PREFIX}-tag-base`; branches.push(branch); - const contents = Buffer.alloc(1024, "Hello, world!"); await commitFilesFromBuffers({ octokit, @@ -65,26 +100,13 @@ describe("node", () => { base: { tag: "v0.1.0", }, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - fileChanges: { - additions: [ - { - path: `foo.txt`, - contents, - }, - ], - }, - log, + ...BASIC_FILE_CONTENTS, }); }); it("can commit using commit as a base", async () => { const branch = `${TEST_BRANCH_PREFIX}-commit-base`; branches.push(branch); - const contents = Buffer.alloc(1024, "Hello, world!"); await commitFilesFromBuffers({ octokit, @@ -93,19 +115,110 @@ describe("node", () => { base: { commit: "fce2760017eab6d85388ed5cfdfac171559d80b3", }, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - fileChanges: { - additions: [ - { - path: `foo.txt`, - contents, + ...BASIC_FILE_CONTENTS, + }); + }); + + describe("existing branches", () => { + it("can commit to existing branch when force is true", async () => { + const branch = `${TEST_BRANCH_PREFIX}-existing-branch-force`; + branches.push(branch); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: "31ded45f25a07726e02fd111d4c230718b49fa2a", + }, + }); + + await commitFilesFromBuffers({ + octokit, + ...REPO, + branch, + base: { + commit: "fce2760017eab6d85388ed5cfdfac171559d80b3", + }, + ...BASIC_FILE_CONTENTS, + force: true, + }); + }); + + it("cannot commit to existing branch when force is false", async () => { + const branch = `${TEST_BRANCH_PREFIX}-existing-branch-no-force`; + branches.push(branch); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: "31ded45f25a07726e02fd111d4c230718b49fa2a", + }, + }); + + expect(() => + commitFilesFromBuffers({ + octokit, + ...REPO, + branch, + base: { + commit: "fce2760017eab6d85388ed5cfdfac171559d80b3", }, - ], - }, - log, + ...BASIC_FILE_CONTENTS, + }), + ).rejects.toThrow( + `Branch ${branch} exists already and does not match base`, + ); + }); + + it("can commit to existing branch when force is false and target matches base", async () => { + const branch = `${TEST_BRANCH_PREFIX}-existing-branch-matching-base`; + branches.push(branch); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: "31ded45f25a07726e02fd111d4c230718b49fa2a", + }, + }); + + await commitFilesFromBuffers({ + octokit, + ...REPO, + branch, + base: { + commit: "31ded45f25a07726e02fd111d4c230718b49fa2a", + }, + ...BASIC_FILE_CONTENTS, + }); + }); + + it("can commit to same branch as base", async () => { + const branch = `${TEST_BRANCH_PREFIX}-same-branch-as-base`; + branches.push(branch); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: "31ded45f25a07726e02fd111d4c230718b49fa2a", + }, + }); + + await commitFilesFromBuffers({ + octokit, + ...REPO, + branch, + base: { + branch, + }, + ...BASIC_FILE_CONTENTS, + }); }); }); }); diff --git a/src/test/integration/util.ts b/src/test/integration/util.ts index 5a52271..ef1cd15 100644 --- a/src/test/integration/util.ts +++ b/src/test/integration/util.ts @@ -16,10 +16,11 @@ export const deleteBranches = async ( const ref = await getRepositoryMetadata(octokit, { owner: REPO.owner, name: REPO.repository, - ref: `refs/heads/${branch}`, + baseRef: `refs/heads/${branch}`, + targetRef: `refs/heads/${branch}`, }); - const refId = ref?.ref?.id; + const refId = ref?.baseRef?.id; if (!refId) { console.warn(`Branch ${branch} not found`);