import * as core from '@actions/core' import { createOrUpdateBranch, 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 * as utils from './utils' export interface Inputs { token: string branchToken: string path: string addPaths: string[] commitMessage: string committer: string author: string signoff: boolean branch: string deleteBranch: boolean branchSuffix: string base: string pushToFork: string signCommits: boolean title: string body: string bodyPath: string labels: string[] assignees: string[] reviewers: string[] teamReviewers: string[] milestone: number draft: { value: boolean always: boolean } maintainerCanModify: boolean } export async function createPullRequest(inputs: Inputs): Promise { let gitConfigHelper, git try { core.startGroup('Prepare git configuration') const repoPath = utils.getRepoPath(inputs.path) git = await GitCommandManager.create(repoPath) gitConfigHelper = await GitConfigHelper.create(git) core.endGroup() core.startGroup('Determining the base and head repositories') const baseRemote = gitConfigHelper.getGitRemote() // Init the GitHub clients const apiUrl = await GitHubHelper.determineApiUrl(baseRemote.hostname) core.info(`Using API base URL: ${apiUrl}`) const ghBranch = new GitHubHelper(apiUrl, inputs.branchToken) const ghPull = new GitHubHelper(apiUrl, 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() // 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.` ) } } // 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() // 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 = await 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 = await git.stashPush(['--include-untracked']) await git.checkout(inputs.branch) const pushSignedCommitsResult = await ghBranch.pushSignedCommits( 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() } 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() } catch (error) { core.setFailed(utils.getErrorMessage(error)) } finally { core.startGroup('Restore git configuration') if (inputs.pushToFork) { await git.exec(['remote', 'rm', 'fork']) } await gitConfigHelper.close() core.endGroup() } }