Compare commits
18 commits
main
...
signed-com
Author | SHA1 | Date | |
---|---|---|---|
|
714e9be377 | ||
|
35dbaca7d5 | ||
|
8101ce8ff4 | ||
|
a636b8113c | ||
|
0db6c3cf7b | ||
|
36e042a736 | ||
|
6284ea5854 | ||
|
03266d3789 | ||
|
404696dda5 | ||
|
0e209053e0 | ||
|
548d90536f | ||
|
3e7e19f0eb | ||
|
7c0b09154e | ||
|
9a6173b25c | ||
|
13c3ab4d5e | ||
|
081c241f6e | ||
|
5bb83f1307 | ||
|
70815fee7e |
12 changed files with 27053 additions and 2311 deletions
|
@ -74,6 +74,7 @@ All inputs are **optional**. If not set, sensible defaults will be used.
|
|||
| `team-reviewers` | A comma or newline-separated list of GitHub teams to request a review from. Note that a `repo` scoped [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), or equivalent [GitHub App permissions](docs/concepts-guidelines.md#authenticating-with-github-app-generated-tokens), are required. | |
|
||||
| `milestone` | The number of the milestone to associate this pull request with. | |
|
||||
| `draft` | Create a [draft pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests#draft-pull-requests). It is not possible to change draft status after creation except through the web interface. | `false` |
|
||||
| `sign-commit` | Sign the commit as bot [refer: [Signature verification for bots](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#signature-verification-for-bots)]. This can be useful if your repo or org has enforced commit-signing. | `false` |
|
||||
|
||||
#### commit-message
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
createOrUpdateBranch,
|
||||
tryFetch,
|
||||
getWorkingBaseAndType
|
||||
getWorkingBaseAndType,
|
||||
buildBranchFileChanges
|
||||
} from '../lib/create-or-update-branch'
|
||||
import * as fs from 'fs'
|
||||
import {GitCommandManager} from '../lib/git-command-manager'
|
||||
|
@ -229,6 +230,80 @@ describe('create-or-update-branch tests', () => {
|
|||
expect(workingBaseType).toEqual('commit')
|
||||
})
|
||||
|
||||
it('tests buildBranchFileChanges with no diff', async () => {
|
||||
await git.checkout(BRANCH, BASE)
|
||||
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
|
||||
expect(branchFileChanges.additions.length).toEqual(0)
|
||||
expect(branchFileChanges.deletions.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('tests buildBranchFileChanges with addition and modification', async () => {
|
||||
await git.checkout(BRANCH, BASE)
|
||||
const changes = await createChanges()
|
||||
await git.exec(['add', '-A'])
|
||||
await git.commit(['-m', 'Test changes'])
|
||||
|
||||
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
|
||||
|
||||
expect(branchFileChanges.additions).toEqual([
|
||||
{
|
||||
path: TRACKED_FILE,
|
||||
contents: Buffer.from(changes.tracked, 'binary').toString('base64')
|
||||
},
|
||||
{
|
||||
path: UNTRACKED_FILE,
|
||||
contents: Buffer.from(changes.untracked, 'binary').toString('base64')
|
||||
}
|
||||
])
|
||||
expect(branchFileChanges.deletions.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('tests buildBranchFileChanges with addition and deletion', async () => {
|
||||
await git.checkout(BRANCH, BASE)
|
||||
const changes = await createChanges()
|
||||
const TRACKED_FILE_NEW_PATH = 'c/tracked-file.txt'
|
||||
const filepath = path.join(REPO_PATH, TRACKED_FILE_NEW_PATH)
|
||||
await fs.promises.mkdir(path.dirname(filepath), {recursive: true})
|
||||
await fs.promises.rename(path.join(REPO_PATH, TRACKED_FILE), filepath)
|
||||
await git.exec(['add', '-A'])
|
||||
await git.commit(['-m', 'Test changes'])
|
||||
|
||||
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
|
||||
|
||||
expect(branchFileChanges.additions).toEqual([
|
||||
{
|
||||
path: UNTRACKED_FILE,
|
||||
contents: Buffer.from(changes.untracked, 'binary').toString('base64')
|
||||
},
|
||||
{
|
||||
path: TRACKED_FILE_NEW_PATH,
|
||||
contents: Buffer.from(changes.tracked, 'binary').toString('base64')
|
||||
}
|
||||
])
|
||||
expect(branchFileChanges.deletions).toEqual([{path: TRACKED_FILE}])
|
||||
})
|
||||
|
||||
it('tests buildBranchFileChanges with binary files', async () => {
|
||||
await git.checkout(BRANCH, BASE)
|
||||
const filename = 'c/untracked-binary-file'
|
||||
const filepath = path.join(REPO_PATH, filename)
|
||||
const binaryData = Buffer.from([0x00, 0xff, 0x10, 0x20])
|
||||
await fs.promises.mkdir(path.dirname(filepath), {recursive: true})
|
||||
await fs.promises.writeFile(filepath, binaryData)
|
||||
await git.exec(['add', '-A'])
|
||||
await git.commit(['-m', 'Test changes'])
|
||||
|
||||
const branchFileChanges = await buildBranchFileChanges(git, BASE, BRANCH)
|
||||
|
||||
expect(branchFileChanges.additions).toEqual([
|
||||
{
|
||||
path: filename,
|
||||
contents: binaryData.toString('base64')
|
||||
}
|
||||
])
|
||||
expect(branchFileChanges.deletions.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('tests no changes resulting in no new branch being created', async () => {
|
||||
const commitMessage = uuidv4()
|
||||
const result = await createOrUpdateBranch(
|
||||
|
|
|
@ -74,6 +74,9 @@ inputs:
|
|||
draft:
|
||||
description: 'Create a draft pull request. It is not possible to change draft status after creation except through the web interface'
|
||||
default: false
|
||||
sign-commit:
|
||||
description: 'Sign the commit as github-actions bot (and as custom app if a different github-token is provided)'
|
||||
default: true
|
||||
outputs:
|
||||
pull-request-number:
|
||||
description: 'The pull request number'
|
||||
|
|
25379
dist/index.js
vendored
25379
dist/index.js
vendored
File diff suppressed because one or more lines are too long
3601
package-lock.json
generated
3601
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -32,6 +32,8 @@
|
|||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@octokit/core": "^4.2.4",
|
||||
"@octokit/graphql": "^8.1.1",
|
||||
"@octokit/graphql-schema": "^15.25.0",
|
||||
"@octokit/plugin-paginate-rest": "^5.0.1",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^6.8.1",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
|
@ -41,7 +43,8 @@
|
|||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^18.19.42",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
||||
"@typescript-eslint/parser": "^7.17.0",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
|
@ -55,6 +58,6 @@
|
|||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"ts-jest": "^29.2.3",
|
||||
"typescript": "^4.9.5"
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as core from '@actions/core'
|
||||
import {GitCommandManager} from './git-command-manager'
|
||||
import {v4 as uuidv4} from 'uuid'
|
||||
import * as utils from './utils'
|
||||
|
||||
const CHERRYPICK_EMPTY =
|
||||
'The previous cherry-pick is now empty, possibly due to conflict resolution.'
|
||||
|
@ -47,6 +48,38 @@ export async function tryFetch(
|
|||
}
|
||||
}
|
||||
|
||||
export async function buildBranchFileChanges(
|
||||
git: GitCommandManager,
|
||||
base: string,
|
||||
branch: string
|
||||
): Promise<BranchFileChanges> {
|
||||
const branchFileChanges: BranchFileChanges = {
|
||||
additions: [],
|
||||
deletions: []
|
||||
}
|
||||
const changedFiles = await git.getChangedFiles([
|
||||
'--diff-filter=AM',
|
||||
`${base}..${branch}`
|
||||
])
|
||||
const deletedFiles = await git.getChangedFiles([
|
||||
'--diff-filter=D',
|
||||
`${base}..${branch}`
|
||||
])
|
||||
const repoPath = git.getWorkingDirectory()
|
||||
for (const file of changedFiles) {
|
||||
branchFileChanges.additions!.push({
|
||||
path: file,
|
||||
contents: utils.readFileBase64([repoPath, file])
|
||||
})
|
||||
}
|
||||
for (const file of deletedFiles) {
|
||||
branchFileChanges.deletions!.push({
|
||||
path: file
|
||||
})
|
||||
}
|
||||
return branchFileChanges
|
||||
}
|
||||
|
||||
// Return the number of commits that branch2 is ahead of branch1
|
||||
async function commitsAhead(
|
||||
git: GitCommandManager,
|
||||
|
@ -110,11 +143,22 @@ function splitLines(multilineString: string): string[] {
|
|||
.filter(x => x !== '')
|
||||
}
|
||||
|
||||
export interface BranchFileChanges {
|
||||
additions: {
|
||||
path: string
|
||||
contents: string
|
||||
}[]
|
||||
deletions: {
|
||||
path: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CreateOrUpdateBranchResult {
|
||||
action: string
|
||||
base: string
|
||||
hasDiffWithBase: boolean
|
||||
headSha: string
|
||||
branchFileChanges?: BranchFileChanges
|
||||
}
|
||||
|
||||
export async function createOrUpdateBranch(
|
||||
|
@ -289,6 +333,9 @@ export async function createOrUpdateBranch(
|
|||
result.hasDiffWithBase = await isAhead(git, base, branch)
|
||||
}
|
||||
|
||||
// Build the branch file changes
|
||||
result.branchFileChanges = await buildBranchFileChanges(git, base, branch)
|
||||
|
||||
// Get the pull request branch SHA
|
||||
result.headSha = await git.revParse('HEAD')
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ export interface Inputs {
|
|||
teamReviewers: string[]
|
||||
milestone: number
|
||||
draft: boolean
|
||||
signCommit: boolean
|
||||
}
|
||||
|
||||
export async function createPullRequest(inputs: Inputs): Promise<void> {
|
||||
|
@ -185,6 +186,8 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
|
|||
inputs.signoff,
|
||||
inputs.addPaths
|
||||
)
|
||||
// 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)) {
|
||||
|
@ -192,17 +195,24 @@ export async function createPullRequest(inputs: Inputs): Promise<void> {
|
|||
core.startGroup(
|
||||
`Pushing pull request branch to '${branchRemoteName}/${inputs.branch}'`
|
||||
)
|
||||
await git.push([
|
||||
'--force-with-lease',
|
||||
branchRemoteName,
|
||||
`${inputs.branch}:refs/heads/${inputs.branch}`
|
||||
])
|
||||
if (inputs.signCommit) {
|
||||
await githubHelper.pushSignedCommit(
|
||||
branchRepository,
|
||||
inputs.branch,
|
||||
inputs.base,
|
||||
inputs.commitMessage,
|
||||
result.branchFileChanges
|
||||
)
|
||||
} else {
|
||||
await git.push([
|
||||
'--force-with-lease',
|
||||
branchRemoteName,
|
||||
`${inputs.branch}:refs/heads/${inputs.branch}`
|
||||
])
|
||||
}
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
// 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
|
||||
core.startGroup('Create or update the pull request')
|
||||
|
|
|
@ -166,6 +166,15 @@ export class GitCommandManager {
|
|||
return output.exitCode === 1
|
||||
}
|
||||
|
||||
async getChangedFiles(options?: string[]): Promise<string[]> {
|
||||
const args = ['diff', '--name-only']
|
||||
if (options) {
|
||||
args.push(...options)
|
||||
}
|
||||
const output = await this.exec(args)
|
||||
return output.stdout.split('\n').filter(filename => filename != '')
|
||||
}
|
||||
|
||||
async isDirty(untracked: boolean, pathspec?: string[]): Promise<boolean> {
|
||||
const pathspecArgs = pathspec ? ['--', ...pathspec] : []
|
||||
// Check untracked changes
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import * as core from '@actions/core'
|
||||
import {Inputs} from './create-pull-request'
|
||||
import {Octokit, OctokitOptions} from './octokit-client'
|
||||
import type {
|
||||
Repository as TempRepository,
|
||||
Ref,
|
||||
Commit,
|
||||
FileChanges
|
||||
} from '@octokit/graphql-schema'
|
||||
import {BranchFileChanges} from './create-or-update-branch'
|
||||
import * as utils from './utils'
|
||||
|
||||
const ERROR_PR_REVIEW_TOKEN_SCOPE =
|
||||
|
@ -184,4 +191,204 @@ export class GitHubHelper {
|
|||
|
||||
return pull
|
||||
}
|
||||
|
||||
async pushSignedCommit(
|
||||
branchRepository: string,
|
||||
branch: string,
|
||||
base: string,
|
||||
commitMessage: string,
|
||||
branchFileChanges?: BranchFileChanges
|
||||
): Promise<void> {
|
||||
core.info(`Use API to push a signed commit`)
|
||||
|
||||
const [repoOwner, repoName] = branchRepository.split('/')
|
||||
core.debug(`repoOwner: '${repoOwner}', repoName: '${repoName}'`)
|
||||
const refQuery = `
|
||||
query GetRefId($repoName: String!, $repoOwner: String!, $branchName: String!) {
|
||||
repository(owner: $repoOwner, name: $repoName){
|
||||
id
|
||||
ref(qualifiedName: $branchName){
|
||||
id
|
||||
name
|
||||
prefix
|
||||
target{
|
||||
id
|
||||
oid
|
||||
commitUrl
|
||||
commitResourcePath
|
||||
abbreviatedOid
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
let branchRef = await this.octokit.graphql<{repository: TempRepository}>(
|
||||
refQuery,
|
||||
{
|
||||
repoOwner: repoOwner,
|
||||
repoName: repoName,
|
||||
branchName: branch
|
||||
}
|
||||
)
|
||||
core.debug(
|
||||
`Fetched information for branch '${branch}' - '${JSON.stringify(branchRef)}'`
|
||||
)
|
||||
|
||||
const branchExists = branchRef.repository.ref != null
|
||||
|
||||
// if the branch does not exist, then first we need to create the branch from base
|
||||
if (!branchExists) {
|
||||
core.debug(`Branch does not exist - '${branch}'`)
|
||||
branchRef = await this.octokit.graphql<{repository: TempRepository}>(
|
||||
refQuery,
|
||||
{
|
||||
repoOwner: repoOwner,
|
||||
repoName: repoName,
|
||||
branchName: base
|
||||
}
|
||||
)
|
||||
core.debug(
|
||||
`Fetched information for base branch '${base}' - '${JSON.stringify(branchRef)}'`
|
||||
)
|
||||
|
||||
core.info(
|
||||
`Creating new branch '${branch}' from '${base}', with ref '${JSON.stringify(branchRef.repository.ref!.target!.oid)}'`
|
||||
)
|
||||
if (branchRef.repository.ref != null) {
|
||||
core.debug(`Send request for creating new branch`)
|
||||
const newBranchMutation = `
|
||||
mutation CreateNewBranch($branchName: String!, $oid: GitObjectID!, $repoId: ID!) {
|
||||
createRef(input: {
|
||||
name: $branchName,
|
||||
oid: $oid,
|
||||
repositoryId: $repoId
|
||||
}) {
|
||||
ref {
|
||||
id
|
||||
name
|
||||
prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const newBranch = await this.octokit.graphql<{createRef: {ref: Ref}}>(
|
||||
newBranchMutation,
|
||||
{
|
||||
repoId: branchRef.repository.id,
|
||||
oid: branchRef.repository.ref.target!.oid,
|
||||
branchName: 'refs/heads/' + branch
|
||||
}
|
||||
)
|
||||
core.debug(
|
||||
`Created new branch '${branch}': '${JSON.stringify(newBranch.createRef.ref)}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
core.info(
|
||||
`Hash ref of branch '${branch}' is '${JSON.stringify(branchRef.repository.ref!.target!.oid)}'`
|
||||
)
|
||||
|
||||
const fileChanges = <FileChanges>{
|
||||
additions: branchFileChanges!.additions,
|
||||
deletions: branchFileChanges!.deletions
|
||||
}
|
||||
|
||||
const pushCommitMutation = `
|
||||
mutation PushCommit(
|
||||
$repoNameWithOwner: String!,
|
||||
$branchName: String!,
|
||||
$headOid: GitObjectID!,
|
||||
$commitMessage: String!,
|
||||
$fileChanges: FileChanges
|
||||
) {
|
||||
createCommitOnBranch(input: {
|
||||
branch: {
|
||||
repositoryNameWithOwner: $repoNameWithOwner,
|
||||
branchName: $branchName,
|
||||
}
|
||||
fileChanges: $fileChanges
|
||||
message: {
|
||||
headline: $commitMessage
|
||||
}
|
||||
expectedHeadOid: $headOid
|
||||
}){
|
||||
clientMutationId
|
||||
ref{
|
||||
id
|
||||
name
|
||||
prefix
|
||||
}
|
||||
commit{
|
||||
id
|
||||
abbreviatedOid
|
||||
oid
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const pushCommitVars = {
|
||||
branchName: branch,
|
||||
repoNameWithOwner: repoOwner + '/' + repoName,
|
||||
headOid: branchRef.repository.ref!.target!.oid,
|
||||
commitMessage: commitMessage,
|
||||
fileChanges: fileChanges
|
||||
}
|
||||
|
||||
const pushCommitVarsWithoutContents = {
|
||||
...pushCommitVars,
|
||||
fileChanges: {
|
||||
...pushCommitVars.fileChanges,
|
||||
additions: pushCommitVars.fileChanges.additions?.map(addition => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {contents, ...rest} = addition
|
||||
return rest
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
core.debug(
|
||||
`Push commit with payload: '${JSON.stringify(pushCommitVarsWithoutContents)}'`
|
||||
)
|
||||
|
||||
const commit = await this.octokit.graphql<{
|
||||
createCommitOnBranch: {ref: Ref; commit: Commit}
|
||||
}>(pushCommitMutation, pushCommitVars)
|
||||
|
||||
core.debug(`Pushed commit - '${JSON.stringify(commit)}'`)
|
||||
core.info(
|
||||
`Pushed commit with hash - '${commit.createCommitOnBranch.commit.oid}' on branch - '${commit.createCommitOnBranch.ref.name}'`
|
||||
)
|
||||
|
||||
if (branchExists) {
|
||||
// The branch existed so update the branch ref to point to the new commit
|
||||
// This is the same behavior as force pushing the branch
|
||||
core.info(
|
||||
`Updating branch '${branch}' to commit '${commit.createCommitOnBranch.commit.oid}'`
|
||||
)
|
||||
const updateBranchMutation = `
|
||||
mutation UpdateBranch($branchId: ID!, $commitOid: GitObjectID!) {
|
||||
updateRef(input: {
|
||||
refId: $branchId,
|
||||
oid: $commitOid,
|
||||
force: true
|
||||
}) {
|
||||
ref {
|
||||
id
|
||||
name
|
||||
prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const updatedBranch = await this.octokit.graphql<{updateRef: {ref: Ref}}>(
|
||||
updateBranchMutation,
|
||||
{
|
||||
branchId: branchRef.repository.ref!.id,
|
||||
commitOid: commit.createCommitOnBranch.commit.oid
|
||||
}
|
||||
)
|
||||
core.debug(`Updated branch - '${JSON.stringify(updatedBranch)}'`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ async function run(): Promise<void> {
|
|||
reviewers: utils.getInputAsArray('reviewers'),
|
||||
teamReviewers: utils.getInputAsArray('team-reviewers'),
|
||||
milestone: Number(core.getInput('milestone')),
|
||||
draft: core.getBooleanInput('draft')
|
||||
draft: core.getBooleanInput('draft'),
|
||||
signCommit: core.getBooleanInput('sign-commit')
|
||||
}
|
||||
core.debug(`Inputs: ${inspect(inputs)}`)
|
||||
|
||||
|
|
|
@ -126,6 +126,10 @@ export function readFile(path: string): string {
|
|||
return fs.readFileSync(path, 'utf-8')
|
||||
}
|
||||
|
||||
export function readFileBase64(pathParts: string[]): string {
|
||||
return fs.readFileSync(path.resolve(...pathParts)).toString('base64')
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function hasErrorCode(error: any): error is {code: string} {
|
||||
return typeof (error && error.code) === 'string'
|
||||
|
|
Loading…
Reference in a new issue