Convert action to typescript

This commit is contained in:
Peter Evans 2020-07-16 17:57:13 +09:00
parent 40e70b8f7b
commit 4ba9ca3d10
19 changed files with 12517 additions and 4466 deletions

View file

@ -0,0 +1,192 @@
import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager'
import {v4 as uuidv4} from 'uuid'
const CHERRYPICK_EMPTY =
'The previous cherry-pick is now empty, possibly due to conflict resolution.'
export async function tryFetch(
git: GitCommandManager,
branch: string
): Promise<boolean> {
try {
await git.fetch([`${branch}:refs/remotes/origin/${branch}`])
return true
} catch {
return false
}
}
// Return true if branch2 is ahead of branch1
async function isAhead(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
const result = await git.revList(
[`${branch1}...${branch2}`],
['--right-only', '--count']
)
return Number(result) > 0
}
// Return true if branch2 is behind branch1
async function isBehind(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
const result = await git.revList(
[`${branch1}...${branch2}`],
['--left-only', '--count']
)
return Number(result) > 0
}
// Return true if branch2 is even with branch1
async function isEven(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
return (
!(await isAhead(git, branch1, branch2)) &&
!(await isBehind(git, branch1, branch2))
)
}
async function hasDiff(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
const result = await git.diff([`${branch1}..${branch2}`])
return result.length > 0
}
function splitLines(multilineString: string): string[] {
return multilineString
.split('\n')
.map(s => s.trim())
.filter(x => x !== '')
}
export async function createOrUpdateBranch(
git: GitCommandManager,
commitMessage: string,
baseInput: string,
branch: string
): Promise<CreateOrUpdateBranchResult> {
// Get the working base. This may or may not be the actual base.
const workingBase = await git.symbolicRef('HEAD', ['--short'])
// If the base is not specified it is assumed to be the working base.
const base = baseInput ? baseInput : workingBase
// Set the default return values
const result: CreateOrUpdateBranchResult = {
action: 'none',
base: base,
hasDiffWithBase: false
}
// Save the working base changes to a temporary branch
const tempBranch = uuidv4()
await git.checkout(tempBranch, 'HEAD')
// Commit any uncomitted changes
if (await git.isDirty(true)) {
core.info('Uncommitted changes found. Adding a commit.')
await git.exec(['add', '-A'])
await git.commit(['-m', commitMessage])
}
// Perform fetch and reset the working base
// Commits made during the workflow will be removed
await git.fetch([`${workingBase}:${workingBase}`], 'origin', ['--force'])
// If the working base is not the base, rebase the temp branch commits
if (workingBase != base) {
core.info(
`Rebasing commits made to branch '${workingBase}' on to base branch '${base}'`
)
// Checkout the actual base
await git.fetch([`${base}:${base}`], 'origin', ['--force'])
await git.checkout(base)
// Cherrypick commits from the temporary branch starting from the working base
const commits = await git.revList(
[`${workingBase}..${tempBranch}`, '.'],
['--reverse']
)
for (const commit of splitLines(commits)) {
const result = await git.cherryPick(
['--strategy=recursive', '--strategy-option=theirs', commit],
true
)
if (result.exitCode != 0 && !result.stderr.includes(CHERRYPICK_EMPTY)) {
throw new Error(`Unexpected error: ${result.stderr}`)
}
}
// Reset the temp branch to the working index
await git.checkout(tempBranch, 'HEAD')
// Reset the base
await git.fetch([`${base}:${base}`], 'origin', ['--force'])
}
// Try to fetch the pull request branch
if (!(await tryFetch(git, branch))) {
// The pull request branch does not exist
core.info(`Pull request branch '${branch}' does not exist yet.`)
// Create the pull request branch
await git.checkout(branch, 'HEAD')
// Check if the pull request branch is ahead of the base
result.hasDiffWithBase = await isAhead(git, base, branch)
if (result.hasDiffWithBase) {
result.action = 'created'
core.info(`Created branch '${branch}'`)
} else {
core.info(
`Branch '${branch}' is not ahead of base '${base}' and will not be created`
)
}
} else {
// The pull request branch exists
core.info(
`Pull request branch '${branch}' already exists as remote branch 'origin/${branch}'`
)
// Checkout the pull request branch
await git.checkout(branch)
if (await hasDiff(git, branch, tempBranch)) {
// If the branch differs from the recreated temp version then the branch is reset
// For changes on base this action is similar to a rebase of the pull request branch
core.info(`Resetting '${branch}'`)
// Alternatively, git switch -C branch tempBranch
await git.checkout(branch, tempBranch)
}
// Check if the pull request branch has been updated
// If the branch was reset or updated it will be ahead
// It may be behind if a reset now results in no diff with the base
if (!(await isEven(git, `origin/${branch}`, branch))) {
result.action = 'updated'
core.info(`Updated branch '${branch}'`)
} else {
core.info(
`Branch '${branch}' is even with its remote and will not be updated`
)
}
// Check if the pull request branch is ahead of the base
result.hasDiffWithBase = await isAhead(git, base, branch)
}
// Delete the temporary branch
await git.exec(['branch', '--delete', '--force', tempBranch])
return result
}
interface CreateOrUpdateBranchResult {
action: string
base: string
hasDiffWithBase: boolean
}

232
src/create-pull-request.ts Normal file
View file

@ -0,0 +1,232 @@
import * as core from '@actions/core'
import {createOrUpdateBranch} from './create-or-update-branch'
import {GitHubHelper} from './github-helper'
import {GitCommandManager} from './git-command-manager'
import {ConfigOption, GitConfigHelper} from './git-config-helper'
import {GitIdentityHelper} from './git-identity-helper'
import * as utils from './utils'
const EXTRAHEADER_OPTION = 'http.https://github.com/.extraheader'
const EXTRAHEADER_VALUE_REGEX = '^AUTHORIZATION:'
const DEFAULT_COMMIT_MESSAGE = '[create-pull-request] automated change'
const DEFAULT_TITLE = 'Changes by create-pull-request action'
const DEFAULT_BODY =
'Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action'
const DEFAULT_BRANCH = 'create-pull-request/patch'
export interface Inputs {
token: string
path: string
commitMessage: string
committer: string
author: string
title: string
body: string
labels: string[]
assignees: string[]
reviewers: string[]
teamReviewers: string[]
milestone: number
draft: boolean
branch: string
requestToParent: boolean
base: string
branchSuffix: string
}
export async function createPullRequest(inputs: Inputs): Promise<void> {
let gitConfigHelper
let extraHeaderOption = new ConfigOption()
try {
// Get the repository path
const repoPath = utils.getRepoPath(inputs.path)
// Create a git command manager
const git = await GitCommandManager.create(repoPath)
// Unset and save the extraheader config option if it exists
gitConfigHelper = new GitConfigHelper(git)
extraHeaderOption = await gitConfigHelper.getAndUnsetConfigOption(
EXTRAHEADER_OPTION,
EXTRAHEADER_VALUE_REGEX
)
//github_token = inputs.token
//path = repoPath
// Set defaults
inputs.commitMessage = inputs.commitMessage
? inputs.commitMessage
: DEFAULT_COMMIT_MESSAGE
inputs.title = inputs.title ? inputs.title : DEFAULT_TITLE
inputs.body = inputs.body ? inputs.body : DEFAULT_BODY
inputs.branch = inputs.branch ? inputs.branch : DEFAULT_BRANCH
// Determine the GitHub repository from git config
// This will be the target repository for the pull request branch
const remoteOriginUrlConfig = await gitConfigHelper.getConfigOption(
'remote.origin.url'
)
const remote = await utils.getRemoteDetail(remoteOriginUrlConfig.value)
core.info(
`Pull request branch target repository set to ${remote.repository}`
)
if (remote.protocol == 'HTTPS') {
core.debug('Using HTTPS protocol')
// Encode and configure the basic credential for HTTPS access
const basicCredential = Buffer.from(
`x-access-token:${inputs.token}`,
'utf8'
).toString('base64')
core.setSecret(basicCredential)
git.setAuthGitOptions([
'-c',
`http.https://github.com/.extraheader=AUTHORIZATION: basic ${basicCredential}`
])
}
// Determine if the checked out ref is a valid base for a pull request
// The action needs the checked out HEAD ref to be a branch
// This check will fail in the following cases:
// - HEAD is detached
// - HEAD is a merge commit (pull_request events)
// - HEAD is a tag
const symbolicRefResult = await git.exec(
['symbolic-ref', 'HEAD', '--short'],
true
)
if (symbolicRefResult.exitCode != 0) {
core.debug(`${symbolicRefResult.stderr}`)
throw new Error(
'The checked out ref is not a valid base for a pull request. Unable to continue.'
)
}
const workingBase = symbolicRefResult.stdout.trim()
// Exit if the working base is a PR branch created by this action.
// This may occur when using a PAT instead of GITHUB_TOKEN because
// a PAT allows workflow actions to trigger further events.
if (workingBase.startsWith(inputs.branch)) {
throw new Error(
`Working base branch '${workingBase}' was created by this action. Unable to continue.`
)
}
// 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}'`
)
// Determine the committer and author
const gitIdentityHelper = new GitIdentityHelper(git)
const identity = await gitIdentityHelper.getIdentity(
inputs.author,
inputs.committer
)
git.setIdentityGitOptions([
'-c',
`author.name=${identity.authorName}`,
'-c',
`author.email=${identity.authorEmail}`,
'-c',
`committer.name=${identity.committerName}`,
'-c',
`committer.email=${identity.committerEmail}`
])
core.info(
`Configured git committer as '${identity.committerName} <${identity.committerEmail}>'`
)
core.info(
`Configured git author as '${identity.authorName} <${identity.authorEmail}>'`
)
// Create or update the pull request branch
const result = await createOrUpdateBranch(
git,
inputs.commitMessage,
inputs.base,
inputs.branch
)
if (['created', 'updated'].includes(result.action)) {
// The branch was created or updated
core.info(`Pushing pull request branch to 'origin/${inputs.branch}'`)
await git.push(['--force', 'origin', `HEAD:refs/heads/${inputs.branch}`])
// Set the base. It would have been '' if not specified as an input
inputs.base = result.base
if (result.hasDiffWithBase) {
// Create or update the pull request
const githubHelper = new GitHubHelper(inputs.token)
await githubHelper.createOrUpdatePullRequest(inputs, remote.repository)
// coupr.create_or_update_pull_request(
// github_token,
// github_repository,
// branch,
// base,
// title,
// body,
// os.environ.get("CPR_LABELS"),
// os.environ.get("CPR_ASSIGNEES"),
// os.environ.get("CPR_MILESTONE"),
// os.environ.get("CPR_REVIEWERS"),
// os.environ.get("CPR_TEAM_REVIEWERS"),
// os.environ.get("CPR_PROJECT_NAME"),
// os.environ.get("CPR_PROJECT_COLUMN_NAME"),
// os.environ.get("CPR_DRAFT"),
// os.environ.get("CPR_REQUEST_TO_PARENT"),
// )
} else {
// If there is no longer a diff with the base delete the branch
core.info(
`Branch '${inputs.branch}' no longer differs from base branch '${inputs.base}'`
)
core.info(`Closing pull request and deleting branch '${inputs.branch}'`)
await git.push([
'--delete',
'--force',
'origin',
`refs/heads/${inputs.branch}`
])
}
}
} catch (error) {
core.setFailed(error.message)
} finally {
// Restore the extraheader config option
if (extraHeaderOption.value != '') {
if (
await gitConfigHelper.addConfigOption(
EXTRAHEADER_OPTION,
extraHeaderOption.value
)
)
core.debug(`Restored config option '${EXTRAHEADER_OPTION}'`)
}
}
}

258
src/git-command-manager.ts Normal file
View file

@ -0,0 +1,258 @@
import * as exec from '@actions/exec'
import * as io from '@actions/io'
const tagsRefSpec = '+refs/tags/*:refs/tags/*'
export class GitCommandManager {
private gitPath: string
private workingDirectory: string
// Git options used when commands require auth
private authGitOptions?: string[]
// Git options used when commands require an identity
private identityGitOptions?: string[]
private constructor(workingDirectory: string, gitPath: string) {
this.workingDirectory = workingDirectory
this.gitPath = gitPath
}
static async create(workingDirectory: string): Promise<GitCommandManager> {
const gitPath = await io.which('git', true)
return new GitCommandManager(workingDirectory, gitPath)
}
setAuthGitOptions(authGitOptions: string[]): void {
this.authGitOptions = authGitOptions
}
setIdentityGitOptions(identityGitOptions: string[]): void {
this.identityGitOptions = identityGitOptions
}
async checkout(ref: string, startPoint?: string): Promise<void> {
const args = ['checkout', '--progress']
if (startPoint) {
args.push('-B', ref, startPoint)
} else {
args.push(ref)
}
await this.exec(args)
}
async cherryPick(
options?: string[],
allowAllExitCodes = false
): Promise<GitOutput> {
const args = ['cherry-pick']
if (this.identityGitOptions) {
args.unshift(...this.identityGitOptions)
}
if (options) {
args.push(...options)
}
return await this.exec(args, allowAllExitCodes)
}
async commit(options?: string[]): Promise<void> {
const args = ['commit']
if (this.identityGitOptions) {
args.unshift(...this.identityGitOptions)
}
if (options) {
args.push(...options)
}
await this.exec(args)
}
async diff(options?: string[]): Promise<string> {
const args = ['-c', 'core.pager=cat', 'diff']
if (options) {
args.push(...options)
}
const output = await this.exec(args)
return output.stdout.trim()
}
async fetch(
refSpec: string[],
remoteName?: string,
options?: string[]
): Promise<void> {
const args = ['-c', 'protocol.version=2']
if (this.authGitOptions) {
args.push(...this.authGitOptions)
}
args.push('fetch')
if (!refSpec.some(x => x === tagsRefSpec)) {
args.push('--no-tags')
}
args.push('--progress', '--no-recurse-submodules')
if (options) {
args.push(...options)
}
if (remoteName) {
args.push(remoteName)
} else {
args.push('origin')
}
for (const arg of refSpec) {
args.push(arg)
}
await this.exec(args)
}
getWorkingDirectory(): string {
return this.workingDirectory
}
async isDirty(untracked: boolean): Promise<boolean> {
const diffArgs = ['--abbrev=40', '--full-index', '--raw']
// Check staged changes
if (await this.diff([...diffArgs, '--staged'])) {
return true
}
// Check working index changes
if (await this.diff(diffArgs)) {
return true
}
// Check untracked changes
if (untracked && (await this.status(['--porcelain', '-unormal']))) {
return true
}
return false
}
async push(options?: string[]): Promise<void> {
const args = ['push']
if (this.authGitOptions) {
args.unshift(...this.authGitOptions)
}
if (options) {
args.push(...options)
}
await this.exec(args)
}
async revList(
commitExpression: string[],
options?: string[]
): Promise<string> {
const args = ['rev-list']
if (options) {
args.push(...options)
}
args.push(...commitExpression)
const output = await this.exec(args)
return output.stdout.trim()
}
async revParse(ref: string, options?: string[]): Promise<string> {
const args = ['rev-parse']
if (options) {
args.push(...options)
}
args.push(ref)
const output = await this.exec(args)
return output.stdout.trim()
}
async status(options?: string[]): Promise<string> {
const args = ['status']
if (options) {
args.push(...options)
}
const output = await this.exec(args)
return output.stdout.trim()
}
async symbolicRef(ref: string, options?: string[]): Promise<string> {
const args = ['symbolic-ref', ref]
if (options) {
args.push(...options)
}
const output = await this.exec(args)
return output.stdout.trim()
}
async tryConfigUnset(
configKey: string,
globalConfig?: boolean
): Promise<boolean> {
const output = await this.exec(
[
'config',
globalConfig ? '--global' : '--local',
'--unset-all',
configKey
],
true
)
return output.exitCode === 0
}
async tryGetFetchUrl(): Promise<string> {
const output = await this.exec(
['config', '--local', '--get', 'remote.origin.url'],
true
)
if (output.exitCode !== 0) {
return ''
}
const stdout = output.stdout.trim()
if (stdout.includes('\n')) {
return ''
}
return stdout
}
async exec(args: string[], allowAllExitCodes = false): Promise<GitOutput> {
const result = new GitOutput()
const env = {}
for (const key of Object.keys(process.env)) {
env[key] = process.env[key]
}
const stdout: string[] = []
const stderr: string[] = []
const options = {
cwd: this.workingDirectory,
env,
ignoreReturnCode: allowAllExitCodes,
listeners: {
stdout: (data: Buffer) => {
stdout.push(data.toString())
},
stderr: (data: Buffer) => {
stderr.push(data.toString())
}
}
}
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
result.stdout = stdout.join('')
result.stderr = stderr.join('')
return result
}
}
class GitOutput {
stdout = ''
stderr = ''
exitCode = 0
}

64
src/git-config-helper.ts Normal file
View file

@ -0,0 +1,64 @@
import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager'
export class ConfigOption {
name = ''
value = ''
}
export class GitConfigHelper {
private git: GitCommandManager
constructor(git: GitCommandManager) {
this.git = git
}
async addConfigOption(name: string, value: string): Promise<boolean> {
const result = await this.git.exec(
['config', '--local', '--add', name, value],
true
)
return result.exitCode === 0
}
async unsetConfigOption(name: string, valueRegex = '.'): Promise<boolean> {
const result = await this.git.exec(
['config', '--local', '--unset', name, valueRegex],
true
)
return result.exitCode === 0
}
async configOptionExists(name: string, valueRegex = '.'): Promise<boolean> {
const result = await this.git.exec(
['config', '--local', '--name-only', '--get-regexp', name, valueRegex],
true
)
return result.exitCode === 0
}
async getConfigOption(name: string, valueRegex = '.'): Promise<ConfigOption> {
const option = new ConfigOption()
const result = await this.git.exec(
['config', '--local', '--get-regexp', name, valueRegex],
true
)
option.name = name
option.value = result.stdout.trim().split(`${name} `)[1]
return option
}
async getAndUnsetConfigOption(
name: string,
valueRegex = '.'
): Promise<ConfigOption> {
if (await this.configOptionExists(name, valueRegex)) {
const option = await this.getConfigOption(name, valueRegex)
if (await this.unsetConfigOption(name, valueRegex)) {
core.debug(`Unset config option '${name}'`)
return option
}
}
return new ConfigOption()
}
}

105
src/git-identity-helper.ts Normal file
View file

@ -0,0 +1,105 @@
import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager'
import {GitConfigHelper} from './git-config-helper'
import * as utils from './utils'
// Default the committer and author to the GitHub Actions bot
const DEFAULT_COMMITTER = 'GitHub <noreply@github.com>'
const DEFAULT_AUTHOR =
'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>'
interface GitIdentity {
authorName: string
authorEmail: string
committerName: string
committerEmail: string
}
export class GitIdentityHelper {
private git: GitCommandManager
constructor(git: GitCommandManager) {
this.git = git
}
private async getGitIdentityFromConfig(): Promise<GitIdentity | undefined> {
const gitConfigHelper = new GitConfigHelper(this.git)
if (
(await gitConfigHelper.configOptionExists('user.name')) &&
(await gitConfigHelper.configOptionExists('user.email'))
) {
const userName = await gitConfigHelper.getConfigOption('user.name')
const userEmail = await gitConfigHelper.getConfigOption('user.email')
return {
authorName: userName.value,
authorEmail: userEmail.value,
committerName: userName.value,
committerEmail: userEmail.value
}
}
if (
(await gitConfigHelper.configOptionExists('committer.name')) &&
(await gitConfigHelper.configOptionExists('committer.email')) &&
(await gitConfigHelper.configOptionExists('author.name')) &&
(await gitConfigHelper.configOptionExists('author.email'))
) {
const committerName = await gitConfigHelper.getConfigOption(
'committer.name'
)
const committerEmail = await gitConfigHelper.getConfigOption(
'committer.email'
)
const authorName = await gitConfigHelper.getConfigOption('author.name')
const authorEmail = await gitConfigHelper.getConfigOption('author.email')
return {
authorName: authorName.value,
authorEmail: authorEmail.value,
committerName: committerName.value,
committerEmail: committerEmail.value
}
}
return undefined
}
async getIdentity(author: string, committer: string): Promise<GitIdentity> {
// If either committer or author is supplied they will be cross used
if (!committer && author) {
core.info('Supplied author will also be used as the committer.')
committer = author
}
if (!author && committer) {
core.info('Supplied committer will also be used as the author.')
author = committer
}
// If no committer/author has been supplied, try and fetch identity
// configuration already existing in git config.
if (!committer && !author) {
const identity = await this.getGitIdentityFromConfig()
if (identity) {
core.info('Retrieved a pre-configured git identity.')
return identity
}
}
// Set defaults if no committer/author has been supplied and no
// existing identity configuration was found.
if (!committer && !author) {
core.info('Action defaults set for the author and committer.')
committer = DEFAULT_COMMITTER
author = DEFAULT_AUTHOR
}
const parsedAuthor = utils.parseDisplayNameEmail(author)
const parsedCommitter = utils.parseDisplayNameEmail(committer)
return {
authorName: parsedAuthor.name,
authorEmail: parsedAuthor.email,
committerName: parsedCommitter.name,
committerEmail: parsedCommitter.email
}
}
}

View file

@ -1,121 +0,0 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as path from 'path'
class GitOutput {
stdout = ''
exitCode = 0
}
export class ConfigOption {
name = ''
value = ''
}
export function getRepoPath(relativePath?: string): string {
let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
if (!githubWorkspacePath) {
throw new Error('GITHUB_WORKSPACE not defined')
}
githubWorkspacePath = path.resolve(githubWorkspacePath)
core.debug(`githubWorkspacePath: ${githubWorkspacePath}`)
let repoPath = githubWorkspacePath
if (relativePath) repoPath = path.resolve(repoPath, relativePath)
core.debug(`repoPath: ${repoPath}`)
return repoPath
}
export async function execGit(
repoPath: string,
args: string[],
ignoreReturnCode = false
): Promise<GitOutput> {
const result = new GitOutput()
const stdout: string[] = []
const options = {
cwd: repoPath,
ignoreReturnCode: ignoreReturnCode,
listeners: {
stdout: (data: Buffer): void => {
stdout.push(data.toString())
}
}
}
result.exitCode = await exec.exec('git', args, options)
result.stdout = stdout.join('')
return result
}
export async function addConfigOption(
repoPath: string,
name: string,
value: string
): Promise<boolean> {
const result = await execGit(
repoPath,
['config', '--local', '--add', name, value],
true
)
return result.exitCode === 0
}
export async function unsetConfigOption(
repoPath: string,
name: string,
valueRegex = '.'
): Promise<boolean> {
const result = await execGit(
repoPath,
['config', '--local', '--unset', name, valueRegex],
true
)
return result.exitCode === 0
}
export async function configOptionExists(
repoPath: string,
name: string,
valueRegex = '.'
): Promise<boolean> {
const result = await execGit(
repoPath,
['config', '--local', '--name-only', '--get-regexp', name, valueRegex],
true
)
return result.exitCode === 0
}
export async function getConfigOption(
repoPath: string,
name: string,
valueRegex = '.'
): Promise<ConfigOption> {
const option = new ConfigOption()
const result = await execGit(
repoPath,
['config', '--local', '--get-regexp', name, valueRegex],
true
)
option.name = name
option.value = result.stdout.trim().split(`${name} `)[1]
return option
}
export async function getAndUnsetConfigOption(
repoPath: string,
name: string,
valueRegex = '.'
): Promise<ConfigOption> {
if (await configOptionExists(repoPath, name, valueRegex)) {
const option = await getConfigOption(repoPath, name, valueRegex)
if (await unsetConfigOption(repoPath, name, valueRegex)) {
core.debug(`Unset config option '${name}'`)
return option
}
}
return new ConfigOption()
}

157
src/github-helper.ts Normal file
View file

@ -0,0 +1,157 @@
import * as core from '@actions/core'
import {Inputs} from './create-pull-request'
import {Octokit, OctokitOptions} from './octokit-client'
const ERROR_PR_REVIEW_FROM_AUTHOR =
'Review cannot be requested from pull request author'
interface Repository {
owner: string
repo: string
}
export class GitHubHelper {
private octokit: InstanceType<typeof Octokit>
constructor(token: string) {
const options: OctokitOptions = {}
if (token) {
options.auth = `${token}`
}
this.octokit = new Octokit(options)
}
private parseRepository(repository: string): Repository {
const [owner, repo] = repository.split('/')
return {
owner: owner,
repo: repo
}
}
private async createOrUpdate(
inputs: Inputs,
baseRepository: string,
headBranch: string
): Promise<number> {
// Try to create the pull request
try {
const {data: pull} = await this.octokit.pulls.create({
...this.parseRepository(baseRepository),
title: inputs.title,
head: headBranch,
base: inputs.base,
body: inputs.body,
draft: inputs.draft
})
core.info(
`Created pull request #${pull.number} (${headBranch} => ${inputs.base})`
)
return pull.number
} catch (e) {
if (
!e.message ||
!e.message.includes(`A pull request already exists for ${headBranch}`)
) {
throw e
}
}
// Update the pull request that exists for this branch and base
const {data: pulls} = await this.octokit.pulls.list({
...this.parseRepository(baseRepository),
state: 'open',
head: headBranch,
base: inputs.base
})
const {data: pull} = await this.octokit.pulls.update({
...this.parseRepository(baseRepository),
pull_number: pulls[0].number,
title: inputs.title,
body: inputs.body,
draft: inputs.draft
})
core.info(
`Updated pull request #${pull.number} (${headBranch} => ${inputs.base})`
)
return pull.number
}
async createOrUpdatePullRequest(
inputs: Inputs,
headRepository: string
): Promise<void> {
const {data: headRepo} = await this.octokit.repos.get({
...this.parseRepository(headRepository)
})
if (inputs.requestToParent && !headRepo.parent) {
throw new Error(
`The checked out repository is not a fork. Input 'request-to-parent' should be set to 'false'.`
)
}
const baseRepository = inputs.requestToParent
? headRepo.parent.full_name
: headRepository
const headBranch = `${headRepo.owner.login}:${inputs.branch}`
// Create or update the pull request
const pullNumber = await this.createOrUpdate(
inputs,
baseRepository,
headBranch
)
// Set output
core.setOutput('pull-request-number', pullNumber)
// Set milestone, labels and assignees
const updateIssueParams = {}
if (inputs.milestone) {
updateIssueParams['milestone'] = inputs.milestone
core.info(`Applying milestone '${inputs.milestone}'`)
}
if (inputs.labels.length > 0) {
updateIssueParams['labels'] = inputs.labels
core.info(`Applying labels '${inputs.labels}'`)
}
if (inputs.assignees.length > 0) {
updateIssueParams['assignees'] = inputs.assignees
core.info(`Applying assignees '${inputs.assignees}'`)
}
if (Object.keys(updateIssueParams).length > 0) {
await this.octokit.issues.update({
...this.parseRepository(baseRepository),
issue_number: pullNumber,
...updateIssueParams
})
}
// Request reviewers and team reviewers
const requestReviewersParams = {}
if (inputs.reviewers.length > 0) {
requestReviewersParams['reviewers'] = inputs.reviewers
core.info(`Requesting reviewers '${inputs.reviewers}'`)
}
if (inputs.teamReviewers.length > 0) {
requestReviewersParams['team_reviewers'] = inputs.teamReviewers
core.info(`Requesting team reviewers '${inputs.teamReviewers}'`)
}
if (Object.keys(requestReviewersParams).length > 0) {
try {
await this.octokit.pulls.requestReviewers({
...this.parseRepository(baseRepository),
pull_number: pullNumber,
...requestReviewersParams
})
} catch (e) {
if (e.message && e.message.includes(ERROR_PR_REVIEW_FROM_AUTHOR)) {
core.warning(ERROR_PR_REVIEW_FROM_AUTHOR)
} else {
throw e
}
}
}
}
}

View file

@ -1,56 +1,11 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import {isDocker} from './isDocker'
import {setupPython} from './setupPython'
import {
ConfigOption,
getRepoPath,
getAndUnsetConfigOption,
addConfigOption
} from './git'
import {Inputs, createPullRequest} from './create-pull-request'
import {inspect} from 'util'
const EXTRAHEADER_OPTION = 'http.https://github.com/.extraheader'
const EXTRAHEADER_VALUE_REGEX = '^AUTHORIZATION:'
import * as utils from './utils'
async function run(): Promise<void> {
let repoPath
let extraHeaderOption = new ConfigOption()
try {
// Python assets
const cpr = `${__dirname}/cpr`
core.debug(`cpr: ${cpr}`)
// Determine how to access python and pip
const {pip, python} = (function (): {pip: string; python: string} {
if (isDocker()) {
core.info('Running inside a Docker container')
// Python 3 assumed to be installed and on the PATH
return {
pip: 'pip3',
python: 'python3'
}
} else {
// Setup Python from the tool cache
setupPython('3.x', 'x64')
return {
pip: 'pip',
python: 'python'
}
}
})()
// Install requirements
await exec.exec(pip, [
'install',
'--requirement',
`${cpr}/requirements.txt`,
'--no-index',
`--find-links=${__dirname}/vendor`
])
// Fetch action inputs
const inputs = {
const inputs: Inputs = {
token: core.getInput('token'),
path: core.getInput('path'),
commitMessage: core.getInput('commit-message'),
@ -58,71 +13,22 @@ async function run(): Promise<void> {
author: core.getInput('author'),
title: core.getInput('title'),
body: core.getInput('body'),
labels: core.getInput('labels'),
assignees: core.getInput('assignees'),
reviewers: core.getInput('reviewers'),
teamReviewers: core.getInput('team-reviewers'),
milestone: core.getInput('milestone'),
project: core.getInput('project'),
projectColumn: core.getInput('project-column'),
draft: core.getInput('draft'),
labels: utils.getInputAsArray('labels'),
assignees: utils.getInputAsArray('assignees'),
reviewers: utils.getInputAsArray('reviewers'),
teamReviewers: utils.getInputAsArray('team-reviewers'),
milestone: Number(core.getInput('milestone')),
draft: core.getInput('draft') === 'true',
branch: core.getInput('branch'),
requestToParent: core.getInput('request-to-parent'),
requestToParent: core.getInput('request-to-parent') === 'true',
base: core.getInput('base'),
branchSuffix: core.getInput('branch-suffix')
}
core.debug(`Inputs: ${inspect(inputs)}`)
// Set environment variables from inputs.
if (inputs.token) process.env.GITHUB_TOKEN = inputs.token
if (inputs.path) process.env.CPR_PATH = inputs.path
if (inputs.commitMessage)
process.env.CPR_COMMIT_MESSAGE = inputs.commitMessage
if (inputs.committer) process.env.CPR_COMMITTER = inputs.committer
if (inputs.author) process.env.CPR_AUTHOR = inputs.author
if (inputs.title) process.env.CPR_TITLE = inputs.title
if (inputs.body) process.env.CPR_BODY = inputs.body
if (inputs.labels) process.env.CPR_LABELS = inputs.labels
if (inputs.assignees) process.env.CPR_ASSIGNEES = inputs.assignees
if (inputs.reviewers) process.env.CPR_REVIEWERS = inputs.reviewers
if (inputs.teamReviewers)
process.env.CPR_TEAM_REVIEWERS = inputs.teamReviewers
if (inputs.milestone) process.env.CPR_MILESTONE = inputs.milestone
if (inputs.project) process.env.CPR_PROJECT_NAME = inputs.project
if (inputs.projectColumn)
process.env.CPR_PROJECT_COLUMN_NAME = inputs.projectColumn
if (inputs.draft) process.env.CPR_DRAFT = inputs.draft
if (inputs.branch) process.env.CPR_BRANCH = inputs.branch
if (inputs.requestToParent)
process.env.CPR_REQUEST_TO_PARENT = inputs.requestToParent
if (inputs.base) process.env.CPR_BASE = inputs.base
if (inputs.branchSuffix) process.env.CPR_BRANCH_SUFFIX = inputs.branchSuffix
// Get the repository path
repoPath = getRepoPath(inputs.path)
// Get the extraheader config option if it exists
extraHeaderOption = await getAndUnsetConfigOption(
repoPath,
EXTRAHEADER_OPTION,
EXTRAHEADER_VALUE_REGEX
)
// Execute create pull request
await exec.exec(python, [`${cpr}/create_pull_request.py`])
await createPullRequest(inputs)
} catch (error) {
core.setFailed(error.message)
} finally {
// Restore the extraheader config option
if (extraHeaderOption.value != '') {
if (
await addConfigOption(
repoPath,
EXTRAHEADER_OPTION,
extraHeaderOption.value
)
)
core.debug(`Restored config option '${EXTRAHEADER_OPTION}'`)
}
}
}

7
src/octokit-client.ts Normal file
View file

@ -0,0 +1,7 @@
import {Octokit as Core} from '@octokit/core'
import {paginateRest} from '@octokit/plugin-paginate-rest'
import {restEndpointMethods} from '@octokit/plugin-rest-endpoint-methods'
export {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods'
export {OctokitOptions} from '@octokit/core/dist-types/types'
export const Octokit = Core.plugin(paginateRest, restEndpointMethods)

107
src/utils.ts Normal file
View file

@ -0,0 +1,107 @@
import * as core from '@actions/core'
import * as path from 'path'
export function getInputAsArray(
name: string,
options?: core.InputOptions
): string[] {
return getStringAsArray(core.getInput(name, options))
}
export function getStringAsArray(str: string): string[] {
return str
.split(/[\n,]+/)
.map(s => s.trim())
.filter(x => x !== '')
}
export function getRepoPath(relativePath?: string): string {
let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
if (!githubWorkspacePath) {
throw new Error('GITHUB_WORKSPACE not defined')
}
githubWorkspacePath = path.resolve(githubWorkspacePath)
core.debug(`githubWorkspacePath: ${githubWorkspacePath}`)
let repoPath = githubWorkspacePath
if (relativePath) repoPath = path.resolve(repoPath, relativePath)
core.debug(`repoPath: ${repoPath}`)
return repoPath
}
interface RemoteDetail {
protocol: string
repository: string
}
export function getRemoteDetail(remoteUrl: string): RemoteDetail {
// Parse the protocol and github repository from a URL
// e.g. HTTPS, peter-evans/create-pull-request
const httpsUrlPattern = /^https:\/\/.*@?github.com\/(.+\/.+)$/i
const sshUrlPattern = /^git@github.com:(.+\/.+).git$/i
const httpsMatch = remoteUrl.match(httpsUrlPattern)
if (httpsMatch) {
return {
protocol: 'HTTPS',
repository: httpsMatch[1]
}
}
const sshMatch = remoteUrl.match(sshUrlPattern)
if (sshMatch) {
return {
protocol: 'SSH',
repository: sshMatch[1]
}
}
throw new Error(
`The format of '${remoteUrl}' is not a valid GitHub repository URL`
)
}
export function secondsSinceEpoch(): number {
const now = new Date()
return Math.round(now.getTime() / 1000)
}
export function randomString(): string {
return Math.random().toString(36).substr(2, 7)
}
interface DisplayNameEmail {
name: string
email: string
}
export function parseDisplayNameEmail(
displayNameEmail: string
): DisplayNameEmail {
// Parse the name and email address from a string in the following format
// Display Name <email@address.com>
const pattern = /^([^<]+)\s*<([^>]+)>$/i
// Check we have a match
const match = displayNameEmail.match(pattern)
if (!match) {
throw new Error(
`The format of '${displayNameEmail}' is not a valid email address with display name`
)
}
// Check that name and email are not just whitespace
const name = match[1].trim()
const email = match[2].trim()
if (!name || !email) {
throw new Error(
`The format of '${displayNameEmail}' is not a valid email address with display name`
)
}
return {
name: name,
email: email
}
}