Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/friendly-rivers-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@changesets/ghcommit": minor
---

Improve force-push handling so updating an existing branch no longer temporarily resets the target branch to the base commit, avoiding cases where GitHub closes open pull requests during the update.
267 changes: 175 additions & 92 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import {
createCommitOnBranchQuery,
createRefMutation,
getRepositoryMetadata,
updateRefMutation,
} from "./github/graphql/queries.js";
import type {
CreateCommitOnBranchMutationVariables,
GetRepositoryMetadataQuery,
} from "./github/graphql/generated/operations.js";
import type { GetRepositoryMetadataQuery } from "./github/graphql/generated/operations.js";
import {
CommitFilesFromBase64Args,
CommitFilesResult,
Expand All @@ -21,6 +16,8 @@ const getBaseRef = (base: GitBase): string => {
} else if ("tag" in base) {
return `refs/tags/${base.tag}`;
} else {
// For explicit commit bases we don't resolve the base oid from a ref,
// but the shared metadata query still expects a valid qualified ref name.
return "HEAD";
}
};
Expand All @@ -45,6 +42,47 @@ const getOidFromRef = (
return ref.target.oid;
};

const isAlreadyExistingRefError = (error: unknown) =>
typeof error === "object" &&
error !== null &&
"status" in error &&
"message" in error &&
typeof error.status === "number" &&
typeof error.message === "string" &&
error.status === 422 &&
error.message.includes("Reference already exists");

const createCommit = async ({
octokit,
refId,
baseOid,
message,
fileChanges,
}: Pick<CommitFilesFromBase64Args, "octokit" | "message" | "fileChanges"> & {
refId: string;
baseOid: string;
}) => {
const normalizedMessage: CommitMessage =
typeof message === "string"
? {
headline: message.split("\n")[0]?.trim() ?? "",
body: message.split("\n").slice(1).join("\n").trim(),
}
: message;

// we have to stick to GraphQL here as with REST, each file change would become a separate API call
return createCommitOnBranchQuery(octokit, {
Comment thread
Andarist marked this conversation as resolved.
input: {
branch: {
id: refId,
},
expectedHeadOid: baseOid,
message: normalizedMessage,
fileChanges,
},
});
};

export const commitFilesFromBase64 = async ({
octokit,
owner,
Expand All @@ -70,115 +108,160 @@ export const commitFilesFromBase64 = async ({
log?.debug(`Repo info: ${JSON.stringify(info, null, 2)}`);

if (!info) {
throw new Error(`Repository ${repositoryNameWithOwner} not found`);
throw new Error(
`Repository ${JSON.stringify(repositoryNameWithOwner)} not found`,
);
}
if (!("commit" in base) && !info.baseRef) {
throw new Error(`Ref ${JSON.stringify(baseRef)} not found`);
}

const repositoryId = info.id;
const resolvedBaseRef = info.baseRef;

/**
* The commit oid to base the new commit on.
*
* Used both to create / update the new branch (if necessary),
* and to ensure no changes have been made as we push the new commit.
* Used both to create the new commit,
* and to determine whether an existing branch can be updated.
*/
const baseOid = getOidFromRef(base, info.baseRef);
const targetOid = info.targetBranch?.target?.oid ?? null;
const sameBranchBase = "branch" in base && base.branch === branch;

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
let mode: "create" | "update" | "force-update";

if (!info.baseRef) {
throw new Error(`Ref ${baseRef} not found`);
}
refId = info.baseRef.id;
if (sameBranchBase) {
mode = force ? "force-update" : "update";
} else if (targetOid === null) {
// TODO: legit *creation* failure should be retried if `force === true`
mode = "create";
} else if (force) {
mode = "force-update";
} else if (targetOid === baseOid) {
mode = "update";
} else {
// 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,
force: true,
},
});

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,
},
throw new Error(
`Branch ${branch} exists already and does not match base ${baseOid}, force is set to false`,
);
}

if (mode === "force-update") {
// Use a stable temp branch name so a later run can recover and reuse it
// if an earlier run failed before cleanup completed.
const tempBranch = `changesets-ghcommit-temp/${branch}`;

let tempRefId: string;

try {
const createdTempRef = await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${tempBranch}`,
sha: baseOid,
});

log?.debug(
`Created branch with refId ${JSON.stringify(refIdCreation, null, 2)}`,
);
const refIdStr = createdTempRef.data.node_id;

const refIdStr = refIdCreation.createRef?.ref?.id;
if (!refIdStr) {
throw new Error(`Failed to create temporary branch ${tempBranch}`);
}

tempRefId = refIdStr;
} catch (error) {
if (!isAlreadyExistingRefError(error)) {
throw error;
}

const updatedTempRef = await octokit.rest.git.updateRef({
owner,
repo,
ref: `heads/${tempBranch}`,
sha: baseOid,
force: true,
});

const refIdStr = updatedTempRef.data.node_id;

if (!refIdStr) {
throw new Error(`Failed to create branch ${branch}`);
throw new Error(`Failed to update temporary branch ${tempBranch}`);
}

refId = refIdStr;
tempRefId = refIdStr;
}

log?.debug(`Creating commit on branch ${tempBranch}`);
const tempCommit = await createCommit({
octokit,
refId: tempRefId,
baseOid,
message,
fileChanges,
});

const tempHeadOid = tempCommit.createCommitOnBranch?.commit?.oid;

if (!tempHeadOid) {
throw new Error(
`Failed to determine head commit of temporary branch ${tempBranch}`,
);
}

const updatedTargetRef = await octokit.rest.git.updateRef({
owner,
repo,
ref: `heads/${branch}`,
sha: tempHeadOid,
force: true,
});

const updatedTargetRefId = updatedTargetRef.data.node_id;

if (!updatedTargetRefId) {
throw new Error(`Failed to update branch ${branch}`);
}

await octokit.rest.git.deleteRef({
owner,
repo,
ref: `heads/${tempBranch}`,
});

return {
refId: updatedTargetRefId,
};
}

const finalMessage: CommitMessage =
typeof message === "string"
? {
headline: message.split("\n")[0]?.trim() ?? "",
body: message.split("\n").slice(1).join("\n").trim(),
}
: message;
let refId: string;

if (mode === "create") {
const createdRef = await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha: baseOid,
});

const refIdStr = createdRef.data.node_id;

if (!refIdStr) {
throw new Error(`Failed to create branch ${branch}`);
}

refId = refIdStr;
} else {
refId = sameBranchBase ? resolvedBaseRef!.id : info.targetBranch!.id;
}

log?.debug(`Creating commit on branch ${branch}`);
const createCommitMutation: CreateCommitOnBranchMutationVariables = {
input: {
branch: {
id: refId,
},
expectedHeadOid: baseOid,
message: finalMessage,
fileChanges,
},
};
log?.debug(JSON.stringify(createCommitMutation, null, 2));
const newCommit = await createCommit({
octokit,
refId,
baseOid,
message,
fileChanges,
});

const result = await createCommitOnBranchQuery(octokit, createCommitMutation);
return {
refId: result.createCommitOnBranch?.ref?.id ?? null,
refId: newCommit.createCommitOnBranch?.ref?.id ?? null,
};
};
9 changes: 6 additions & 3 deletions src/github/graphql/queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type GitHubClient = {
graphql: <T>(query: string, variables: any) => Promise<T>;
};
export type GitHubClient = ReturnType<
typeof import("@actions/github").getOctokit
>;

import type {
CreateCommitOnBranchMutation,
Expand Down Expand Up @@ -78,6 +78,9 @@ const DELETE_REF = /* GraphQL */ `
const CREATE_COMMIT_ON_BRANCH = /* GraphQL */ `
mutation createCommitOnBranch($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
}
ref {
id
}
Expand Down
Loading