signed commits via graphql

This commit is contained in:
Peter Evans 2024-08-09 15:00:59 +01:00
parent 3707121594
commit 9b00b13f5b
15 changed files with 27410 additions and 2315 deletions

View file

@ -1,6 +1,7 @@
import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager'
import {GitCommandManager, Commit} 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,56 @@ export async function tryFetch(
}
}
export async function buildBranchCommits(
git: GitCommandManager,
base: string,
branch: string
): Promise<Commit[]> {
const output = await git.exec(['log', '--format=%H', `${base}..${branch}`])
const shas = output.stdout
.split('\n')
.filter(x => x !== '')
.reverse()
const commits: Commit[] = []
for (const sha of shas) {
const commit = await git.getCommit(sha)
commits.push(commit)
}
return commits
}
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 +161,23 @@ 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
branchCommits: Commit[]
}
export async function createOrUpdateBranch(
@ -144,7 +207,8 @@ export async function createOrUpdateBranch(
action: 'none',
base: base,
hasDiffWithBase: false,
headSha: ''
headSha: '',
branchCommits: []
}
// Save the working base changes to a temporary branch
@ -289,6 +353,12 @@ export async function createOrUpdateBranch(
result.hasDiffWithBase = await isAhead(git, base, branch)
}
// Build the branch commits
result.branchCommits = await buildBranchCommits(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')

View file

@ -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,37 @@ 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) {
// Stash any uncommitted tracked and untracked changes
const stashed = await git.stashPush(['--include-untracked'])
await git.checkout(inputs.branch)
await githubHelper.pushSignedCommits(
result.branchCommits,
repoPath,
branchRepository,
inputs.branch
)
// await githubHelper.pushSignedCommit(
// branchRepository,
// inputs.branch,
// inputs.base,
// inputs.commitMessage,
// result.branchFileChanges
// )
await git.checkout('-')
if (stashed) {
await git.stashPop()
}
} 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')

View file

@ -5,6 +5,19 @@ import * as path from 'path'
const tagsRefSpec = '+refs/tags/*:refs/tags/*'
export type Commit = {
sha: string
tree: string
parents: string[]
subject: string
body: string
changes: {
mode: string
status: 'A' | 'M' | 'D'
path: string
}[]
}
export class GitCommandManager {
private gitPath: string
private workingDirectory: string
@ -138,6 +151,43 @@ export class GitCommandManager {
await this.exec(args)
}
async getCommit(ref: string): Promise<Commit> {
const endOfBody = '###EOB###'
const output = await this.exec([
'show',
'--raw',
'--cc',
'--diff-filter=AMD',
`--format=%H%n%T%n%P%n%s%n%b%n${endOfBody}`,
ref
])
const lines = output.stdout.split('\n')
const endOfBodyIndex = lines.lastIndexOf(endOfBody)
const detailLines = lines.slice(0, endOfBodyIndex)
return <Commit>{
sha: detailLines[0],
tree: detailLines[1],
parents: detailLines[2].split(' '),
subject: detailLines[3],
body: detailLines.slice(4, endOfBodyIndex).join('\n'),
changes: lines.slice(endOfBodyIndex + 2, -1).map(line => {
const change = line.match(
/^:(\d{6}) (\d{6}) \w{7} \w{7} ([AMD])\s+(.*)$/
)
if (change) {
return {
mode: change[3] === 'D' ? change[1] : change[2],
status: change[3],
path: change[4]
}
} else {
throw new Error(`Unexpected line format: ${line}`)
}
})
}
}
async getConfigValue(configKey: string, configValue = '.'): Promise<string> {
const output = await this.exec([
'config',
@ -166,6 +216,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

View file

@ -1,6 +1,14 @@
import * as core from '@actions/core'
import {Inputs} from './create-pull-request'
import {Commit} from './git-command-manager'
import {Octokit, OctokitOptions} from './octokit-client'
import type {
Repository as TempRepository,
Ref,
Commit as CommitTemp,
FileChanges
} from '@octokit/graphql-schema'
import {BranchFileChanges} from './create-or-update-branch'
import * as utils from './utils'
const ERROR_PR_REVIEW_TOKEN_SCOPE =
@ -17,6 +25,13 @@ interface Pull {
created: boolean
}
type TreeObject = {
path: string
mode: '100644' | '100755' | '040000' | '160000' | '120000'
sha: string | null
type: 'blob'
}
export class GitHubHelper {
private octokit: InstanceType<typeof Octokit>
@ -184,4 +199,268 @@ export class GitHubHelper {
return pull
}
async pushSignedCommits(
branchCommits: Commit[],
repoPath: string,
branchRepository: string,
branch: string
): Promise<void> {
let headSha = ''
for (const commit of branchCommits) {
headSha = await this.createCommit(commit, repoPath, branchRepository)
}
await this.createOrUpdateRef(branchRepository, branch, headSha)
}
private async createCommit(
commit: Commit,
repoPath: string,
branchRepository: string
): Promise<string> {
const repository = this.parseRepository(branchRepository)
let treeSha = commit.tree
if (commit.changes.length > 0) {
core.debug(`Creating tree objects for local commit ${commit.sha}`)
const treeObjects = await Promise.all(
commit.changes.map(async ({path, mode, status}) => {
let sha: string | null = null
if (status === 'A' || status === 'M') {
core.debug(`Creating blob for file '${path}'`)
const {data: blob} = await this.octokit.rest.git.createBlob({
...repository,
content: utils.readFileBase64([repoPath, path]),
encoding: 'base64'
})
sha = blob.sha
}
return <TreeObject>{
path,
mode,
sha,
type: 'blob'
}
})
)
core.debug(`Creating tree for local commit ${commit.sha}`)
const {data: tree} = await this.octokit.rest.git.createTree({
...repository,
base_tree: commit.parents[0],
tree: treeObjects
})
treeSha = tree.sha
core.debug(`Created tree ${treeSha} for local commit ${commit.sha}`)
}
const {data: remoteCommit} = await this.octokit.rest.git.createCommit({
...repository,
parents: commit.parents,
tree: treeSha,
message: `${commit.subject}\n\n${commit.body}`
})
core.debug(
`Created commit ${remoteCommit.sha} for local commit ${commit.sha}`
)
return remoteCommit.sha
}
private async createOrUpdateRef(
branchRepository: string,
branch: string,
newHead: string
) {
const repository = this.parseRepository(branchRepository)
const branchExists = await this.octokit.rest.git
.getRef({
...repository,
ref: branch
})
.then(
() => true,
() => false
)
if (branchExists) {
core.debug(`Branch ${branch} exists, updating ref`)
await this.octokit.rest.git.updateRef({
...repository,
sha: newHead,
ref: `heads/${branch}`
})
} else {
core.debug(`Branch ${branch} does not exist, creating ref`)
await this.octokit.rest.git.createRef({
...repository,
sha: newHead,
ref: `refs/heads/${branch}`
})
}
}
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)}'`
)
// if the branch does not exist, then first we need to create the branch from base
if (branchRef.repository.ref == null) {
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: CommitTemp}
}>(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}'`
)
}
}

View file

@ -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)}`)

View file

@ -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'