From 9d92b03fa0dc80087da431eb4f75ac10540155b6 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 26 Feb 2025 10:32:22 +0100 Subject: [PATCH] refactor(create-pull-request): extract methods to adhere to SRP and enhance maintainability Signed-off-by: Emilien Escalle --- dist/index.js | 432 ++++++++++++---------- src/create-or-update-branch.ts | 2 +- src/create-pull-request.ts | 647 ++++++++++++++++++++------------- src/git-config-helper.ts | 2 +- 4 files changed, 637 insertions(+), 446 deletions(-) diff --git a/dist/index.js b/dist/index.js index d1bb9582..54663160 100644 --- a/dist/index.js +++ b/dist/index.js @@ -397,204 +397,22 @@ function createPullRequest(inputs) { return __awaiter(this, void 0, void 0, function* () { let gitConfigHelper, git; try { - core.startGroup('Prepare git configuration'); - const repoPath = utils.getRepoPath(inputs.path); - git = yield git_command_manager_1.GitCommandManager.create(repoPath); - gitConfigHelper = yield git_config_helper_1.GitConfigHelper.create(git); - core.endGroup(); - core.startGroup('Determining the base and head repositories'); - const baseRemote = gitConfigHelper.getGitRemote(); - // Init the GitHub clients - const ghBranch = new github_helper_1.GitHubHelper(baseRemote.hostname, inputs.branchToken); - const ghPull = new github_helper_1.GitHubHelper(baseRemote.hostname, inputs.token); - // Determine the head repository; the target for the pull request branch - const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin'; - const branchRepository = inputs.pushToFork - ? inputs.pushToFork - : baseRemote.repository; - if (inputs.pushToFork) { - // Check if the supplied fork is really a fork of the base - core.info(`Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'`); - const baseParentRepository = yield ghBranch.getRepositoryParent(baseRemote.repository); - const branchParentRepository = yield ghBranch.getRepositoryParent(branchRepository); - if (branchParentRepository == null) { - throw new Error(`Repository '${branchRepository}' is not a fork. Unable to continue.`); - } - if (branchParentRepository != baseRemote.repository && - baseParentRepository != branchParentRepository) { - throw new Error(`Repository '${branchRepository}' is not a fork of '${baseRemote.repository}', nor are they siblings. Unable to continue.`); - } - // Add a remote for the fork - const remoteUrl = utils.getRemoteUrl(baseRemote.protocol, baseRemote.hostname, branchRepository); - yield git.exec(['remote', 'add', 'fork', remoteUrl]); - } - core.endGroup(); - core.info(`Pull request branch target repository set to ${branchRepository}`); - // Configure auth - if (baseRemote.protocol == 'HTTPS') { - core.startGroup('Configuring credential for HTTPS authentication'); - yield gitConfigHelper.configureToken(inputs.branchToken); - core.endGroup(); - } - core.startGroup('Checking the base repository state'); - const [workingBase, workingBaseType] = yield (0, create_or_update_branch_1.getWorkingBaseAndType)(git); - core.info(`Working base is ${workingBaseType} '${workingBase}'`); - // When in detached HEAD state (checked out on a commit), we need to - // know the 'base' branch in order to rebase changes. - if (workingBaseType == create_or_update_branch_1.WorkingBaseType.Commit && !inputs.base) { - throw new Error(`When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.`); - } - // If the base is not specified it is assumed to be the working base. - const base = inputs.base ? inputs.base : workingBase; - // Throw an error if the base and branch are not different branches - // of the 'origin' remote. An identically named branch in the `fork` - // remote is perfectly fine. - if (branchRemoteName == 'origin' && base == inputs.branch) { - throw new Error(`The 'base' and 'branch' for a pull request must be different branches. Unable to continue.`); - } - // For self-hosted runners the repository state persists between runs. - // This command prunes the stale remote ref when the pull request branch was - // deleted after being merged or closed. Without this the push using - // '--force-with-lease' fails due to "stale info." - // https://github.com/peter-evans/create-pull-request/issues/633 - yield git.exec(['remote', 'prune', branchRemoteName]); - core.endGroup(); + const { git, gitConfigHelper, repoPath } = yield prepareGitConfiguration(inputs); + const { branchRemoteName, ghBranch, branchRepository, ghPull, baseRemote } = yield initializePullRequestContext(inputs, gitConfigHelper, git); // Apply the branch suffix if set - if (inputs.branchSuffix) { - switch (inputs.branchSuffix) { - case 'short-commit-hash': - // Suffix with the short SHA1 hash - inputs.branch = `${inputs.branch}-${yield git.revParse('HEAD', [ - '--short' - ])}`; - break; - case 'timestamp': - // Suffix with the current timestamp - inputs.branch = `${inputs.branch}-${utils.secondsSinceEpoch()}`; - break; - case 'random': - // Suffix with a 7 character random string - inputs.branch = `${inputs.branch}-${utils.randomString()}`; - break; - default: - throw new Error(`Branch suffix '${inputs.branchSuffix}' is not a valid value. Unable to continue.`); - } - } + yield defineInputBranch(inputs, git); // Output head branch core.info(`Pull request branch to create or update set to '${inputs.branch}'`); // Configure the committer and author - core.startGroup('Configuring the committer and author'); - const parsedAuthor = utils.parseDisplayNameEmail(inputs.author); - const parsedCommitter = utils.parseDisplayNameEmail(inputs.committer); - git.setIdentityGitOptions([ - '-c', - `author.name=${parsedAuthor.name}`, - '-c', - `author.email=${parsedAuthor.email}`, - '-c', - `committer.name=${parsedCommitter.name}`, - '-c', - `committer.email=${parsedCommitter.email}` - ]); - core.info(`Configured git committer as '${parsedCommitter.name} <${parsedCommitter.email}>'`); - core.info(`Configured git author as '${parsedAuthor.name} <${parsedAuthor.email}>'`); - core.endGroup(); + configureGitIdentity(inputs, git); // Action outputs const outputs = new Map(); outputs.set('pull-request-branch', inputs.branch); outputs.set('pull-request-operation', 'none'); // Create or update the pull request branch - core.startGroup('Create or update the pull request branch'); - const result = yield (0, create_or_update_branch_1.createOrUpdateBranch)(git, inputs.commitMessage, inputs.base, inputs.branch, branchRemoteName, inputs.signoff, inputs.addPaths); - outputs.set('pull-request-head-sha', result.headSha); - // Set the base. It would have been '' if not specified as an input - inputs.base = result.base; - core.endGroup(); - if (['created', 'updated'].includes(result.action)) { - // The branch was created or updated - core.startGroup(`Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'`); - if (inputs.signCommits) { - // Create signed commits via the GitHub API - const stashed = yield git.stashPush(['--include-untracked']); - yield git.checkout(inputs.branch); - const pushSignedCommitsResult = yield ghBranch.pushSignedCommits(git, result.branchCommits, result.baseCommit, repoPath, branchRepository, inputs.branch); - outputs.set('pull-request-head-sha', pushSignedCommitsResult.sha); - outputs.set('pull-request-commits-verified', pushSignedCommitsResult.verified.toString()); - yield git.checkout('-'); - if (stashed) { - yield git.stashPop(); - } - } - else { - yield git.push([ - '--force-with-lease', - branchRemoteName, - `${inputs.branch}:refs/heads/${inputs.branch}` - ]); - } - core.endGroup(); - } - if (result.hasDiffWithBase) { - core.startGroup('Create or update the pull request'); - const pull = yield ghPull.createOrUpdatePullRequest(inputs, baseRemote.repository, branchRepository); - outputs.set('pull-request-number', pull.number.toString()); - outputs.set('pull-request-url', pull.html_url); - if (pull.created) { - outputs.set('pull-request-operation', 'created'); - } - else if (result.action == 'updated') { - outputs.set('pull-request-operation', 'updated'); - // The pull request was updated AND the branch was updated. - // Convert back to draft if 'draft: always-true' is set. - if (inputs.draft.always && pull.draft !== undefined && !pull.draft) { - yield ghPull.convertToDraft(pull.node_id); - } - } - core.endGroup(); - } - else { - // There is no longer a diff with the base - // Check we are in a state where a branch exists - if (['updated', 'not-updated'].includes(result.action)) { - core.info(`Branch '${inputs.branch}' no longer differs from base branch '${inputs.base}'`); - if (inputs.deleteBranch) { - core.info(`Deleting branch '${inputs.branch}'`); - yield git.push([ - '--delete', - '--force', - branchRemoteName, - `refs/heads/${inputs.branch}` - ]); - outputs.set('pull-request-operation', 'closed'); - } - } - } - core.startGroup('Setting outputs'); - // If the head commit is signed, get its verification status if we don't already know it. - // This can happen if the branch wasn't updated (action = 'not-updated'), or GPG commit signing is in use. - if (!outputs.has('pull-request-commits-verified') && - result.branchCommits.length > 0 && - result.branchCommits[result.branchCommits.length - 1].signed) { - // Using the local head commit SHA because in this case commits have not been pushed via the API. - core.info(`Checking verification status of head commit ${result.headSha}`); - try { - const headCommit = yield ghBranch.getCommit(result.headSha, branchRepository); - outputs.set('pull-request-commits-verified', headCommit.verified.toString()); - } - catch (error) { - core.warning('Failed to check verification status of head commit.'); - core.debug(utils.getErrorMessage(error)); - } - } - if (!outputs.has('pull-request-commits-verified')) { - outputs.set('pull-request-commits-verified', 'false'); - } - // Set outputs - for (const [key, value] of outputs) { - core.info(`${key} = ${value}`); - core.setOutput(key, value); - } - core.endGroup(); + const result = yield createOrUpdatePullRequestBranch(git, inputs, branchRemoteName); + yield processPullRequestBranch(result, branchRemoteName, inputs, git, ghBranch, repoPath, branchRepository, outputs, ghPull, baseRemote); + yield exportOutputs(outputs, result, ghBranch, branchRepository); } catch (error) { core.setFailed(utils.getErrorMessage(error)); @@ -602,13 +420,245 @@ function createPullRequest(inputs) { finally { core.startGroup('Restore git configuration'); if (inputs.pushToFork) { - yield git.exec(['remote', 'rm', 'fork']); + yield (git === null || git === void 0 ? void 0 : git.exec(['remote', 'rm', 'fork'])); } - yield gitConfigHelper.close(); + yield (gitConfigHelper === null || gitConfigHelper === void 0 ? void 0 : gitConfigHelper.close()); core.endGroup(); } }); } +function prepareGitConfiguration(inputs) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup('Prepare git configuration'); + const repoPath = utils.getRepoPath(inputs.path); + const git = yield git_command_manager_1.GitCommandManager.create(repoPath); + const gitConfigHelper = yield git_config_helper_1.GitConfigHelper.create(git); + core.endGroup(); + return { + repoPath, + git, + gitConfigHelper + }; + }); +} +function initializePullRequestContext(inputs, gitConfigHelper, git) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup('Determining the base and head repositories'); + const baseRemote = gitConfigHelper.getGitRemote(); + // Init the GitHub clients + const ghBranch = new github_helper_1.GitHubHelper(baseRemote.hostname, inputs.branchToken); + const ghPull = new github_helper_1.GitHubHelper(baseRemote.hostname, inputs.token); + // Determine the head repository; the target for the pull request branch + const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin'; + const branchRepository = inputs.pushToFork + ? inputs.pushToFork + : baseRemote.repository; + if (inputs.pushToFork) { + // Check if the supplied fork is really a fork of the base + core.info(`Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'`); + const baseParentRepository = yield ghBranch.getRepositoryParent(baseRemote.repository); + const branchParentRepository = yield ghBranch.getRepositoryParent(branchRepository); + if (branchParentRepository == null) { + throw new Error(`Repository '${branchRepository}' is not a fork. Unable to continue.`); + } + if (branchParentRepository != baseRemote.repository && + baseParentRepository != branchParentRepository) { + throw new Error(`Repository '${branchRepository}' is not a fork of '${baseRemote.repository}', nor are they siblings. Unable to continue.`); + } + // Add a remote for the fork + const remoteUrl = utils.getRemoteUrl(baseRemote.protocol, baseRemote.hostname, branchRepository); + yield git.exec(['remote', 'add', 'fork', remoteUrl]); + } + core.endGroup(); + core.info(`Pull request branch target repository set to ${branchRepository}`); + // Configure auth + if (baseRemote.protocol == 'HTTPS') { + core.startGroup('Configuring credential for HTTPS authentication'); + yield gitConfigHelper.configureToken(inputs.branchToken); + core.endGroup(); + } + core.startGroup('Checking the base repository state'); + const [workingBase, workingBaseType] = yield (0, create_or_update_branch_1.getWorkingBaseAndType)(git); + core.info(`Working base is ${workingBaseType} '${workingBase}'`); + // When in detached HEAD state (checked out on a commit), we need to + // know the 'base' branch in order to rebase changes. + if (workingBaseType == create_or_update_branch_1.WorkingBaseType.Commit && !inputs.base) { + throw new Error(`When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.`); + } + // If the base is not specified it is assumed to be the working base. + const base = inputs.base ? inputs.base : workingBase; + // Throw an error if the base and branch are not different branches + // of the 'origin' remote. An identically named branch in the `fork` + // remote is perfectly fine. + if (branchRemoteName == 'origin' && base == inputs.branch) { + throw new Error(`The 'base' and 'branch' for a pull request must be different branches. Unable to continue.`); + } + // For self-hosted runners the repository state persists between runs. + // This command prunes the stale remote ref when the pull request branch was + // deleted after being merged or closed. Without this the push using + // '--force-with-lease' fails due to "stale info." + // https://github.com/peter-evans/create-pull-request/issues/633 + yield git.exec(['remote', 'prune', branchRemoteName]); + core.endGroup(); + return { branchRemoteName, branchRepository, baseRemote, ghBranch, ghPull }; + }); +} +function defineInputBranch(inputs, git) { + return __awaiter(this, void 0, void 0, function* () { + if (inputs.branchSuffix) { + switch (inputs.branchSuffix) { + case 'short-commit-hash': + // Suffix with the short SHA1 hash + inputs.branch = `${inputs.branch}-${yield git.revParse('HEAD', [ + '--short' + ])}`; + break; + case 'timestamp': + // Suffix with the current timestamp + inputs.branch = `${inputs.branch}-${utils.secondsSinceEpoch()}`; + break; + case 'random': + // Suffix with a 7 character random string + inputs.branch = `${inputs.branch}-${utils.randomString()}`; + break; + default: + throw new Error(`Branch suffix '${inputs.branchSuffix}' is not a valid value. Unable to continue.`); + } + } + }); +} +function configureGitIdentity(inputs, git) { + core.startGroup('Configuring the committer and author'); + const parsedAuthor = utils.parseDisplayNameEmail(inputs.author); + const parsedCommitter = utils.parseDisplayNameEmail(inputs.committer); + git.setIdentityGitOptions([ + '-c', + `author.name=${parsedAuthor.name}`, + '-c', + `author.email=${parsedAuthor.email}`, + '-c', + `committer.name=${parsedCommitter.name}`, + '-c', + `committer.email=${parsedCommitter.email}` + ]); + core.info(`Configured git committer as '${parsedCommitter.name} <${parsedCommitter.email}>'`); + core.info(`Configured git author as '${parsedAuthor.name} <${parsedAuthor.email}>'`); + core.endGroup(); +} +function createOrUpdatePullRequestBranch(git, inputs, branchRemoteName) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup('Create or update the pull request branch'); + const result = yield (0, create_or_update_branch_1.createOrUpdateBranch)(git, inputs.commitMessage, inputs.base, inputs.branch, branchRemoteName, inputs.signoff, inputs.addPaths); + core.endGroup(); + return result; + }); +} +function processPullRequestBranch(result, branchRemoteName, inputs, git, ghBranch, repoPath, branchRepository, outputs, ghPull, baseRemote) { + return __awaiter(this, void 0, void 0, function* () { + outputs.set('pull-request-head-sha', result.headSha); + // Set the base. It would have been '' if not specified as an input + inputs.base = result.base; + if (['created', 'updated'].includes(result.action)) { + // The branch was created or updated + yield pushPullRequestBranch(branchRemoteName, inputs, git, ghBranch, result, repoPath, branchRepository, outputs); + } + if (result.hasDiffWithBase) { + yield createOrUpdatePullRequest(ghPull, inputs, baseRemote, branchRepository, outputs, result); + } + else { + // There is no longer a diff with the base + // Check we are in a state where a branch exists + if (['updated', 'not-updated'].includes(result.action)) { + core.info(`Branch '${inputs.branch}' no longer differs from base branch '${inputs.base}'`); + if (inputs.deleteBranch) { + core.info(`Deleting branch '${inputs.branch}'`); + yield git.push([ + '--delete', + '--force', + branchRemoteName, + `refs/heads/${inputs.branch}` + ]); + outputs.set('pull-request-operation', 'closed'); + } + } + } + }); +} +function createOrUpdatePullRequest(ghPull, inputs, baseRemote, branchRepository, outputs, result) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup('Create or update the pull request'); + const pull = yield ghPull.createOrUpdatePullRequest(inputs, baseRemote.repository, branchRepository); + outputs.set('pull-request-number', pull.number.toString()); + outputs.set('pull-request-url', pull.html_url); + if (pull.created) { + outputs.set('pull-request-operation', 'created'); + } + else if (result.action == 'updated') { + outputs.set('pull-request-operation', 'updated'); + // The pull request was updated AND the branch was updated. + // Convert back to draft if 'draft: always-true' is set. + if (inputs.draft.always && pull.draft !== undefined && !pull.draft) { + yield ghPull.convertToDraft(pull.node_id); + } + } + core.endGroup(); + }); +} +function pushPullRequestBranch(branchRemoteName, inputs, git, ghBranch, result, repoPath, branchRepository, outputs) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup(`Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'`); + if (inputs.signCommits) { + // Create signed commits via the GitHub API + const stashed = yield git.stashPush(['--include-untracked']); + yield git.checkout(inputs.branch); + const pushSignedCommitsResult = yield ghBranch.pushSignedCommits(git, result.branchCommits, result.baseCommit, repoPath, branchRepository, inputs.branch); + outputs.set('pull-request-head-sha', pushSignedCommitsResult.sha); + outputs.set('pull-request-commits-verified', pushSignedCommitsResult.verified.toString()); + yield git.checkout('-'); + if (stashed) { + yield git.stashPop(); + } + } + else { + yield git.push([ + '--force-with-lease', + branchRemoteName, + `${inputs.branch}:refs/heads/${inputs.branch}` + ]); + } + core.endGroup(); + }); +} +function exportOutputs(outputs, result, ghBranch, branchRepository) { + return __awaiter(this, void 0, void 0, function* () { + core.startGroup('Setting outputs'); + // If the head commit is signed, get its verification status if we don't already know it. + // This can happen if the branch wasn't updated (action = 'not-updated'), or GPG commit signing is in use. + if (!outputs.has('pull-request-commits-verified') && + result.branchCommits.length > 0 && + result.branchCommits[result.branchCommits.length - 1].signed) { + // Using the local head commit SHA because in this case commits have not been pushed via the API. + core.info(`Checking verification status of head commit ${result.headSha}`); + try { + const headCommit = yield ghBranch.getCommit(result.headSha, branchRepository); + outputs.set('pull-request-commits-verified', headCommit.verified.toString()); + } + catch (error) { + core.warning('Failed to check verification status of head commit.'); + core.debug(utils.getErrorMessage(error)); + } + } + if (!outputs.has('pull-request-commits-verified')) { + outputs.set('pull-request-commits-verified', 'false'); + } + // Set outputs + for (const [key, value] of outputs) { + core.info(`${key} = ${value}`); + core.setOutput(key, value); + } + core.endGroup(); + }); +} /***/ }), diff --git a/src/create-or-update-branch.ts b/src/create-or-update-branch.ts index 746a3a03..789de2fb 100644 --- a/src/create-or-update-branch.ts +++ b/src/create-or-update-branch.ts @@ -157,7 +157,7 @@ function splitLines(multilineString: string): string[] { .filter(x => x !== '') } -interface CreateOrUpdateBranchResult { +export interface CreateOrUpdateBranchResult { action: string base: string hasDiffWithBase: boolean diff --git a/src/create-pull-request.ts b/src/create-pull-request.ts index 0ab4f8cc..12fbb018 100644 --- a/src/create-pull-request.ts +++ b/src/create-pull-request.ts @@ -1,12 +1,13 @@ import * as core from '@actions/core' import { createOrUpdateBranch, + CreateOrUpdateBranchResult, getWorkingBaseAndType, WorkingBaseType } from './create-or-update-branch' import {GitHubHelper} from './github-helper' import {GitCommandManager} from './git-command-manager' -import {GitConfigHelper} from './git-config-helper' +import {GitConfigHelper, GitRemote} from './git-config-helper' import * as utils from './utils' export interface Inputs { @@ -39,119 +40,21 @@ export interface Inputs { maintainerCanModify: boolean } +type Ouputs = Map + export async function createPullRequest(inputs: Inputs): Promise { - let gitConfigHelper, git + let gitConfigHelper: GitConfigHelper | undefined, + git: GitCommandManager | undefined + try { - core.startGroup('Prepare git configuration') - const repoPath = utils.getRepoPath(inputs.path) - git = await GitCommandManager.create(repoPath) - gitConfigHelper = await GitConfigHelper.create(git) - core.endGroup() + const {git, gitConfigHelper, repoPath} = + await prepareGitConfiguration(inputs) - core.startGroup('Determining the base and head repositories') - const baseRemote = gitConfigHelper.getGitRemote() - // Init the GitHub clients - const ghBranch = new GitHubHelper(baseRemote.hostname, inputs.branchToken) - const ghPull = new GitHubHelper(baseRemote.hostname, inputs.token) - // Determine the head repository; the target for the pull request branch - const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin' - const branchRepository = inputs.pushToFork - ? inputs.pushToFork - : baseRemote.repository - if (inputs.pushToFork) { - // Check if the supplied fork is really a fork of the base - core.info( - `Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'` - ) - const baseParentRepository = await ghBranch.getRepositoryParent( - baseRemote.repository - ) - const branchParentRepository = - await ghBranch.getRepositoryParent(branchRepository) - if (branchParentRepository == null) { - throw new Error( - `Repository '${branchRepository}' is not a fork. Unable to continue.` - ) - } - if ( - branchParentRepository != baseRemote.repository && - baseParentRepository != branchParentRepository - ) { - throw new Error( - `Repository '${branchRepository}' is not a fork of '${baseRemote.repository}', nor are they siblings. Unable to continue.` - ) - } - // Add a remote for the fork - const remoteUrl = utils.getRemoteUrl( - baseRemote.protocol, - baseRemote.hostname, - branchRepository - ) - await git.exec(['remote', 'add', 'fork', remoteUrl]) - } - core.endGroup() - core.info( - `Pull request branch target repository set to ${branchRepository}` - ) - - // Configure auth - if (baseRemote.protocol == 'HTTPS') { - core.startGroup('Configuring credential for HTTPS authentication') - await gitConfigHelper.configureToken(inputs.branchToken) - core.endGroup() - } - - core.startGroup('Checking the base repository state') - const [workingBase, workingBaseType] = await getWorkingBaseAndType(git) - core.info(`Working base is ${workingBaseType} '${workingBase}'`) - // When in detached HEAD state (checked out on a commit), we need to - // know the 'base' branch in order to rebase changes. - if (workingBaseType == WorkingBaseType.Commit && !inputs.base) { - throw new Error( - `When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.` - ) - } - // If the base is not specified it is assumed to be the working base. - const base = inputs.base ? inputs.base : workingBase - // Throw an error if the base and branch are not different branches - // of the 'origin' remote. An identically named branch in the `fork` - // remote is perfectly fine. - if (branchRemoteName == 'origin' && base == inputs.branch) { - throw new Error( - `The 'base' and 'branch' for a pull request must be different branches. Unable to continue.` - ) - } - // For self-hosted runners the repository state persists between runs. - // This command prunes the stale remote ref when the pull request branch was - // deleted after being merged or closed. Without this the push using - // '--force-with-lease' fails due to "stale info." - // https://github.com/peter-evans/create-pull-request/issues/633 - await git.exec(['remote', 'prune', branchRemoteName]) - core.endGroup() + const {branchRemoteName, ghBranch, branchRepository, ghPull, baseRemote} = + await initializePullRequestContext(inputs, gitConfigHelper, git) // Apply the branch suffix if set - if (inputs.branchSuffix) { - switch (inputs.branchSuffix) { - case 'short-commit-hash': - // Suffix with the short SHA1 hash - inputs.branch = `${inputs.branch}-${await git.revParse('HEAD', [ - '--short' - ])}` - break - case 'timestamp': - // Suffix with the current timestamp - inputs.branch = `${inputs.branch}-${utils.secondsSinceEpoch()}` - break - case 'random': - // Suffix with a 7 character random string - inputs.branch = `${inputs.branch}-${utils.randomString()}` - break - default: - throw new Error( - `Branch suffix '${inputs.branchSuffix}' is not a valid value. Unable to continue.` - ) - } - } + await defineInputBranch(inputs, git) // Output head branch core.info( @@ -159,166 +62,404 @@ export async function createPullRequest(inputs: Inputs): Promise { ) // Configure the committer and author - core.startGroup('Configuring the committer and author') - const parsedAuthor = utils.parseDisplayNameEmail(inputs.author) - const parsedCommitter = utils.parseDisplayNameEmail(inputs.committer) - git.setIdentityGitOptions([ - '-c', - `author.name=${parsedAuthor.name}`, - '-c', - `author.email=${parsedAuthor.email}`, - '-c', - `committer.name=${parsedCommitter.name}`, - '-c', - `committer.email=${parsedCommitter.email}` - ]) - core.info( - `Configured git committer as '${parsedCommitter.name} <${parsedCommitter.email}>'` - ) - core.info( - `Configured git author as '${parsedAuthor.name} <${parsedAuthor.email}>'` - ) - core.endGroup() + configureGitIdentity(inputs, git) // Action outputs - const outputs = new Map() + const outputs: Ouputs = new Map() outputs.set('pull-request-branch', inputs.branch) outputs.set('pull-request-operation', 'none') // Create or update the pull request branch - core.startGroup('Create or update the pull request branch') - const result = await createOrUpdateBranch( + const result = await createOrUpdatePullRequestBranch( git, - inputs.commitMessage, - inputs.base, - inputs.branch, - branchRemoteName, - inputs.signoff, - inputs.addPaths + inputs, + branchRemoteName ) - outputs.set('pull-request-head-sha', result.headSha) - // Set the base. It would have been '' if not specified as an input - inputs.base = result.base - core.endGroup() - if (['created', 'updated'].includes(result.action)) { - // The branch was created or updated - core.startGroup( - `Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'` - ) - if (inputs.signCommits) { - // Create signed commits via the GitHub API - const stashed = await git.stashPush(['--include-untracked']) - await git.checkout(inputs.branch) - const pushSignedCommitsResult = await ghBranch.pushSignedCommits( - git, - result.branchCommits, - result.baseCommit, - repoPath, - branchRepository, - inputs.branch - ) - outputs.set('pull-request-head-sha', pushSignedCommitsResult.sha) - outputs.set( - 'pull-request-commits-verified', - pushSignedCommitsResult.verified.toString() - ) - await git.checkout('-') - if (stashed) { - await git.stashPop() - } - } else { - await git.push([ - '--force-with-lease', - branchRemoteName, - `${inputs.branch}:refs/heads/${inputs.branch}` - ]) - } - core.endGroup() - } + await processPullRequestBranch( + result, + branchRemoteName, + inputs, + git, + ghBranch, + repoPath, + branchRepository, + outputs, + ghPull, + baseRemote + ) - if (result.hasDiffWithBase) { - core.startGroup('Create or update the pull request') - const pull = await ghPull.createOrUpdatePullRequest( - inputs, - baseRemote.repository, - branchRepository - ) - outputs.set('pull-request-number', pull.number.toString()) - outputs.set('pull-request-url', pull.html_url) - if (pull.created) { - outputs.set('pull-request-operation', 'created') - } else if (result.action == 'updated') { - outputs.set('pull-request-operation', 'updated') - // The pull request was updated AND the branch was updated. - // Convert back to draft if 'draft: always-true' is set. - if (inputs.draft.always && pull.draft !== undefined && !pull.draft) { - await ghPull.convertToDraft(pull.node_id) - } - } - core.endGroup() - } else { - // There is no longer a diff with the base - // Check we are in a state where a branch exists - if (['updated', 'not-updated'].includes(result.action)) { - core.info( - `Branch '${inputs.branch}' no longer differs from base branch '${inputs.base}'` - ) - if (inputs.deleteBranch) { - core.info(`Deleting branch '${inputs.branch}'`) - await git.push([ - '--delete', - '--force', - branchRemoteName, - `refs/heads/${inputs.branch}` - ]) - outputs.set('pull-request-operation', 'closed') - } - } - } - - core.startGroup('Setting outputs') - // If the head commit is signed, get its verification status if we don't already know it. - // This can happen if the branch wasn't updated (action = 'not-updated'), or GPG commit signing is in use. - if ( - !outputs.has('pull-request-commits-verified') && - result.branchCommits.length > 0 && - result.branchCommits[result.branchCommits.length - 1].signed - ) { - // Using the local head commit SHA because in this case commits have not been pushed via the API. - core.info(`Checking verification status of head commit ${result.headSha}`) - try { - const headCommit = await ghBranch.getCommit( - result.headSha, - branchRepository - ) - outputs.set( - 'pull-request-commits-verified', - headCommit.verified.toString() - ) - } catch (error) { - core.warning('Failed to check verification status of head commit.') - core.debug(utils.getErrorMessage(error)) - } - } - if (!outputs.has('pull-request-commits-verified')) { - outputs.set('pull-request-commits-verified', 'false') - } - - // Set outputs - for (const [key, value] of outputs) { - core.info(`${key} = ${value}`) - core.setOutput(key, value) - } - core.endGroup() + await exportOutputs(outputs, result, ghBranch, branchRepository) } catch (error) { core.setFailed(utils.getErrorMessage(error)) } finally { core.startGroup('Restore git configuration') if (inputs.pushToFork) { - await git.exec(['remote', 'rm', 'fork']) + await git?.exec(['remote', 'rm', 'fork']) } - await gitConfigHelper.close() + await gitConfigHelper?.close() core.endGroup() } } + +async function prepareGitConfiguration(inputs: Inputs): Promise<{ + repoPath: string + git: GitCommandManager + gitConfigHelper: GitConfigHelper +}> { + core.startGroup('Prepare git configuration') + const repoPath = utils.getRepoPath(inputs.path) + const git = await GitCommandManager.create(repoPath) + const gitConfigHelper = await GitConfigHelper.create(git) + core.endGroup() + return { + repoPath, + git, + gitConfigHelper + } +} + +async function initializePullRequestContext( + inputs: Inputs, + gitConfigHelper: GitConfigHelper, + git: GitCommandManager +): Promise<{ + branchRemoteName: string + branchRepository: string + baseRemote: GitRemote + ghBranch: GitHubHelper + ghPull: GitHubHelper +}> { + core.startGroup('Determining the base and head repositories') + + const baseRemote = gitConfigHelper.getGitRemote() + + // Init the GitHub clients + const ghBranch = new GitHubHelper(baseRemote.hostname, inputs.branchToken) + const ghPull = new GitHubHelper(baseRemote.hostname, inputs.token) + + // Determine the head repository; the target for the pull request branch + const branchRemoteName = inputs.pushToFork ? 'fork' : 'origin' + const branchRepository = inputs.pushToFork + ? inputs.pushToFork + : baseRemote.repository + + if (inputs.pushToFork) { + // Check if the supplied fork is really a fork of the base + core.info( + `Checking if '${branchRepository}' is a fork of '${baseRemote.repository}'` + ) + const baseParentRepository = await ghBranch.getRepositoryParent( + baseRemote.repository + ) + const branchParentRepository = + await ghBranch.getRepositoryParent(branchRepository) + if (branchParentRepository == null) { + throw new Error( + `Repository '${branchRepository}' is not a fork. Unable to continue.` + ) + } + if ( + branchParentRepository != baseRemote.repository && + baseParentRepository != branchParentRepository + ) { + throw new Error( + `Repository '${branchRepository}' is not a fork of '${baseRemote.repository}', nor are they siblings. Unable to continue.` + ) + } + + // Add a remote for the fork + const remoteUrl = utils.getRemoteUrl( + baseRemote.protocol, + baseRemote.hostname, + branchRepository + ) + await git.exec(['remote', 'add', 'fork', remoteUrl]) + } + core.endGroup() + core.info(`Pull request branch target repository set to ${branchRepository}`) + + // Configure auth + if (baseRemote.protocol == 'HTTPS') { + core.startGroup('Configuring credential for HTTPS authentication') + await gitConfigHelper.configureToken(inputs.branchToken) + core.endGroup() + } + + core.startGroup('Checking the base repository state') + const [workingBase, workingBaseType] = await getWorkingBaseAndType(git) + core.info(`Working base is ${workingBaseType} '${workingBase}'`) + + // When in detached HEAD state (checked out on a commit), we need to + // know the 'base' branch in order to rebase changes. + if (workingBaseType == WorkingBaseType.Commit && !inputs.base) { + throw new Error( + `When the repository is checked out on a commit instead of a branch, the 'base' input must be supplied.` + ) + } + + // If the base is not specified it is assumed to be the working base. + const base = inputs.base ? inputs.base : workingBase + + // Throw an error if the base and branch are not different branches + // of the 'origin' remote. An identically named branch in the `fork` + // remote is perfectly fine. + if (branchRemoteName == 'origin' && base == inputs.branch) { + throw new Error( + `The 'base' and 'branch' for a pull request must be different branches. Unable to continue.` + ) + } + + // For self-hosted runners the repository state persists between runs. + // This command prunes the stale remote ref when the pull request branch was + // deleted after being merged or closed. Without this the push using + // '--force-with-lease' fails due to "stale info." + // https://github.com/peter-evans/create-pull-request/issues/633 + await git.exec(['remote', 'prune', branchRemoteName]) + + core.endGroup() + + return {branchRemoteName, branchRepository, baseRemote, ghBranch, ghPull} +} + +async function defineInputBranch(inputs: Inputs, git: GitCommandManager) { + if (inputs.branchSuffix) { + switch (inputs.branchSuffix) { + case 'short-commit-hash': + // Suffix with the short SHA1 hash + inputs.branch = `${inputs.branch}-${await git.revParse('HEAD', [ + '--short' + ])}` + break + case 'timestamp': + // Suffix with the current timestamp + inputs.branch = `${inputs.branch}-${utils.secondsSinceEpoch()}` + break + case 'random': + // Suffix with a 7 character random string + inputs.branch = `${inputs.branch}-${utils.randomString()}` + break + default: + throw new Error( + `Branch suffix '${inputs.branchSuffix}' is not a valid value. Unable to continue.` + ) + } + } +} + +function configureGitIdentity(inputs: Inputs, git: GitCommandManager) { + core.startGroup('Configuring the committer and author') + const parsedAuthor = utils.parseDisplayNameEmail(inputs.author) + const parsedCommitter = utils.parseDisplayNameEmail(inputs.committer) + git.setIdentityGitOptions([ + '-c', + `author.name=${parsedAuthor.name}`, + '-c', + `author.email=${parsedAuthor.email}`, + '-c', + `committer.name=${parsedCommitter.name}`, + '-c', + `committer.email=${parsedCommitter.email}` + ]) + core.info( + `Configured git committer as '${parsedCommitter.name} <${parsedCommitter.email}>'` + ) + core.info( + `Configured git author as '${parsedAuthor.name} <${parsedAuthor.email}>'` + ) + core.endGroup() +} + +async function createOrUpdatePullRequestBranch( + git: GitCommandManager, + inputs: Inputs, + branchRemoteName: string +) { + core.startGroup('Create or update the pull request branch') + const result = await createOrUpdateBranch( + git, + inputs.commitMessage, + inputs.base, + inputs.branch, + branchRemoteName, + inputs.signoff, + inputs.addPaths + ) + core.endGroup() + return result +} + +async function processPullRequestBranch( + result: CreateOrUpdateBranchResult, + branchRemoteName: string, + inputs: Inputs, + git: GitCommandManager, + ghBranch: GitHubHelper, + repoPath: string, + branchRepository: any, + outputs: Ouputs, + ghPull: GitHubHelper, + baseRemote: GitRemote +) { + outputs.set('pull-request-head-sha', result.headSha) + // Set the base. It would have been '' if not specified as an input + inputs.base = result.base + + if (['created', 'updated'].includes(result.action)) { + // The branch was created or updated + await pushPullRequestBranch( + branchRemoteName, + inputs, + git, + ghBranch, + result, + repoPath, + branchRepository, + outputs + ) + } + + if (result.hasDiffWithBase) { + await createOrUpdatePullRequest( + ghPull, + inputs, + baseRemote, + branchRepository, + outputs, + result + ) + } else { + // There is no longer a diff with the base + // Check we are in a state where a branch exists + if (['updated', 'not-updated'].includes(result.action)) { + core.info( + `Branch '${inputs.branch}' no longer differs from base branch '${inputs.base}'` + ) + if (inputs.deleteBranch) { + core.info(`Deleting branch '${inputs.branch}'`) + await git.push([ + '--delete', + '--force', + branchRemoteName, + `refs/heads/${inputs.branch}` + ]) + outputs.set('pull-request-operation', 'closed') + } + } + } +} + +async function createOrUpdatePullRequest( + ghPull: GitHubHelper, + inputs: Inputs, + baseRemote: GitRemote, + branchRepository: any, + outputs: Ouputs, + result: CreateOrUpdateBranchResult +) { + core.startGroup('Create or update the pull request') + const pull = await ghPull.createOrUpdatePullRequest( + inputs, + baseRemote.repository, + branchRepository + ) + outputs.set('pull-request-number', pull.number.toString()) + outputs.set('pull-request-url', pull.html_url) + if (pull.created) { + outputs.set('pull-request-operation', 'created') + } else if (result.action == 'updated') { + outputs.set('pull-request-operation', 'updated') + // The pull request was updated AND the branch was updated. + // Convert back to draft if 'draft: always-true' is set. + if (inputs.draft.always && pull.draft !== undefined && !pull.draft) { + await ghPull.convertToDraft(pull.node_id) + } + } + core.endGroup() +} + +async function pushPullRequestBranch( + branchRemoteName: string, + inputs: Inputs, + git: GitCommandManager, + ghBranch: GitHubHelper, + result: CreateOrUpdateBranchResult, + repoPath: string, + branchRepository: any, + outputs: Ouputs +) { + core.startGroup( + `Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'` + ) + if (inputs.signCommits) { + // Create signed commits via the GitHub API + const stashed = await git.stashPush(['--include-untracked']) + await git.checkout(inputs.branch) + const pushSignedCommitsResult = await ghBranch.pushSignedCommits( + git, + result.branchCommits, + result.baseCommit, + repoPath, + branchRepository, + inputs.branch + ) + outputs.set('pull-request-head-sha', pushSignedCommitsResult.sha) + outputs.set( + 'pull-request-commits-verified', + pushSignedCommitsResult.verified.toString() + ) + await git.checkout('-') + if (stashed) { + await git.stashPop() + } + } else { + await git.push([ + '--force-with-lease', + branchRemoteName, + `${inputs.branch}:refs/heads/${inputs.branch}` + ]) + } + core.endGroup() +} + +async function exportOutputs( + outputs: Ouputs, + result: CreateOrUpdateBranchResult, + ghBranch: GitHubHelper, + branchRepository: string +) { + core.startGroup('Setting outputs') + // If the head commit is signed, get its verification status if we don't already know it. + // This can happen if the branch wasn't updated (action = 'not-updated'), or GPG commit signing is in use. + if ( + !outputs.has('pull-request-commits-verified') && + result.branchCommits.length > 0 && + result.branchCommits[result.branchCommits.length - 1].signed + ) { + // Using the local head commit SHA because in this case commits have not been pushed via the API. + core.info(`Checking verification status of head commit ${result.headSha}`) + try { + const headCommit = await ghBranch.getCommit( + result.headSha, + branchRepository + ) + outputs.set( + 'pull-request-commits-verified', + headCommit.verified.toString() + ) + } catch (error) { + core.warning('Failed to check verification status of head commit.') + core.debug(utils.getErrorMessage(error)) + } + } + if (!outputs.has('pull-request-commits-verified')) { + outputs.set('pull-request-commits-verified', 'false') + } + + // Set outputs + for (const [key, value] of outputs) { + core.info(`${key} = ${value}`) + core.setOutput(key, value) + } + core.endGroup() +} diff --git a/src/git-config-helper.ts b/src/git-config-helper.ts index f5f484b8..01c69816 100644 --- a/src/git-config-helper.ts +++ b/src/git-config-helper.ts @@ -5,7 +5,7 @@ import * as path from 'path' import {URL} from 'url' import * as utils from './utils' -interface GitRemote { +export interface GitRemote { hostname: string protocol: string repository: string