Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions packages/base/src/helpers/__tests__/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ jest.mock('../glob', () => {
globSync: jest.fn<string[], [string]>(() => []),
}
})
jest.mock('../git/get-git-data', () => {
const actual = jest.requireActual('../git/get-git-data')

return {
...actual,
gitRawSync: jest.fn(),
}
})

import {
getCIEnv,
Expand All @@ -28,6 +36,7 @@ import {
isInteractive,
shouldGetGithubJobDisplayName,
} from '../ci'
import {gitRawSync} from '../git/get-git-data'
import {globSync} from '../glob'
import {
CI_ENV_VARS,
Expand All @@ -37,13 +46,15 @@ import {
GIT_PULL_REQUEST_BASE_BRANCH,
GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA,
GIT_PULL_REQUEST_BASE_BRANCH_SHA,
GIT_SHA,
PR_NUMBER,
} from '../tags'
import {getUserCISpanTags, getUserGitSpanTags} from '../user-provided-git'

import {createMockContext} from './testing-tools'

const mockedGlobSync = globSync as jest.MockedFunction<typeof globSync>
const mockedGitRawSync = gitRawSync as jest.MockedFunction<typeof gitRawSync>

// Synthetic hosted-runner diag dirs used to exercise the glob-expansion path.
// The real patterns live in githubWellKnownDiagnosticDirPatternsUnix/Win; these
Expand Down Expand Up @@ -216,6 +227,175 @@ describe('ci spec', () => {
expect(tags).toEqual({})
})

describe('bitbucket pull request head sha', () => {
const syntheticMergeSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const sourceBranchSha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
const destinationBranchSha = 'cccccccccccccccccccccccccccccccccccccccc'
const sourceMergeCommitSha = 'dddddddddddddddddddddddddddddddddddddddd'

const setBitbucketPullRequestEnv = () => {
process.env = {
BITBUCKET_BRANCH: 'feature/one',
BITBUCKET_BUILD_NUMBER: 'bitbucket-build-num',
BITBUCKET_CLONE_DIR: '/foo/bar',
BITBUCKET_COMMIT: syntheticMergeSha,
BITBUCKET_GIT_HTTP_ORIGIN: 'https://bitbucket-repo-url.com/repo.git',
BITBUCKET_PIPELINE_UUID: '{bitbucket-uuid}',
BITBUCKET_PR_DESTINATION_BRANCH: 'main',
BITBUCKET_PR_ID: '42',
BITBUCKET_REPO_FULL_NAME: 'bitbucket-repo',
}
}

beforeEach(() => {
mockedGitRawSync.mockReset()
mockedGitRawSync.mockReturnValue(undefined)
setBitbucketPullRequestEnv()
})

afterEach(() => {
mockedGitRawSync.mockReset()
})

it('keeps BITBUCKET_COMMIT when it is the pull request source branch head', () => {
process.env.BITBUCKET_COMMIT = sourceMergeCommitSha
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return sourceMergeCommitSha
}
if (gitArgs[0] === 'rev-list') {
return `${sourceMergeCommitSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') {
return `${sourceMergeCommitSha}\trefs/heads/feature/one`
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_SHA]).toBe(sourceMergeCommitSha)
expect(tags[GIT_HEAD_SHA]).toBe(sourceMergeCommitSha)
})

it('uses the first parent of a verified synthetic merge commit', () => {
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return syntheticMergeSha
}
if (gitArgs[0] === 'rev-list') {
return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') {
return sourceBranchSha
}
if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') {
return destinationBranchSha
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_HEAD_SHA]).toBe(sourceBranchSha)
})

it('falls back to remote branch refs when local refs are unavailable', () => {
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return syntheticMergeSha
}
if (gitArgs[0] === 'rev-list') {
return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') {
return undefined
}
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'refs/heads/feature/one^{commit}') {
return undefined
}
if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/main') {
return `${destinationBranchSha}\trefs/heads/main`
}
if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') {
return `${sourceBranchSha}\trefs/heads/feature/one`
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_HEAD_SHA]).toBe(sourceBranchSha)
})

it('does not infer from merge parents when the destination parent cannot be verified', () => {
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return syntheticMergeSha
}
if (gitArgs[0] === 'rev-list') {
return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') {
return sourceBranchSha
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_HEAD_SHA]).toBeUndefined()
})

it('does not use a source branch ref that no longer matches the checked-out commit graph', () => {
const newerSourceBranchSha = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return syntheticMergeSha
}
if (gitArgs[0] === 'rev-list') {
return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') {
return `${newerSourceBranchSha}\trefs/heads/feature/one`
}
if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') {
return destinationBranchSha
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_HEAD_SHA]).toBeUndefined()
})

it('does not infer from merge parents when the source branch head cannot be verified', () => {
mockedGitRawSync.mockImplementation((gitArgs) => {
if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') {
return syntheticMergeSha
}
if (gitArgs[0] === 'rev-list') {
return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}`
}
if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') {
return destinationBranchSha
}

return undefined
})

const tags = getCISpanTags() as SpanTags

expect(tags[GIT_HEAD_SHA]).toBeUndefined()
})
})

CI_PROVIDERS.forEach((ciProvider) => {
const assertions = require(upath.join(__dirname, 'ci-env', ciProvider)) as [
{[key: string]: string},
Expand Down
107 changes: 107 additions & 0 deletions packages/base/src/helpers/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {BaseContext} from 'clipanion'
import chalk from 'chalk'
import upath from 'upath'

import {gitRawSync} from './git/get-git-data'
import {globSync, hasMagic} from './glob'
import {
CI_ENV_VARS,
Expand Down Expand Up @@ -239,6 +240,106 @@ const resolveTilde = (filePath: string | undefined) => {
return filePath
}

const gitShaRegex = /^[0-9a-f]{7,64}$/i

const getFirstGitSha = (output: string | undefined): string | undefined => {
const sha = output?.split(/\s+/)[0]

return sha && gitShaRegex.test(sha) ? sha : undefined
}

const getGitShas = (output: string | undefined): string[] =>
output?.split(/\s+/).filter((sha) => gitShaRegex.test(sha)) ?? []

const normalizeBranchName = (branchName: string | undefined): string | undefined =>
branchName
?.replace(/^refs\/heads\//, '')
.replace(/^refs\/remotes\/origin\//, '')
.replace(/^origin\//, '')

const getRemoteBranchHeadSha = (branchName: string | undefined, cwd?: string): string | undefined => {
const normalizedBranchName = normalizeBranchName(branchName)
if (!normalizedBranchName) {
return undefined
}

return getFirstGitSha(gitRawSync(['ls-remote', 'origin', `refs/heads/${normalizedBranchName}`], cwd))
}

const getLocalBranchHeadShas = (branchName: string | undefined, cwd?: string): string[] => {
const normalizedBranchName = normalizeBranchName(branchName)
if (!normalizedBranchName) {
return []
}

return uniq(
[
getFirstGitSha(
gitRawSync(['rev-parse', '--verify', `refs/remotes/origin/${normalizedBranchName}^{commit}`], cwd)
),
getFirstGitSha(gitRawSync(['rev-parse', '--verify', `refs/heads/${normalizedBranchName}^{commit}`], cwd)),
].filter((sha): sha is string => !!sha)
)
}

const findMatchingBranchHeadSha = (
branchName: string | undefined,
candidateShas: string[],
cwd?: string
): string | undefined => {
const localBranchHeadSha = getLocalBranchHeadShas(branchName, cwd).find((sha) => candidateShas.includes(sha))
if (localBranchHeadSha) {
return localBranchHeadSha
}

const remoteBranchHeadSha = getRemoteBranchHeadSha(branchName, cwd)

return remoteBranchHeadSha && candidateShas.includes(remoteBranchHeadSha) ? remoteBranchHeadSha : undefined
}

const getBitbucketPullRequestHeadSha = (
sourceBranchName: string | undefined,
bitbucketCommit: string | undefined,
destinationBranchName: string | undefined,
cwd?: string
): string | undefined => {
if (!sourceBranchName || !bitbucketCommit) {
return undefined
}

const headSha = getFirstGitSha(gitRawSync(['rev-parse', '--verify', 'HEAD^{commit}'], cwd))
if (headSha !== bitbucketCommit) {
return undefined
}

const commitWithParents = getGitShas(gitRawSync(['rev-list', '--parents', '-n', '1', 'HEAD'], cwd))
const firstParent = commitWithParents[1]
const sourceBranchHeadSha = findMatchingBranchHeadSha(
sourceBranchName,
[bitbucketCommit, firstParent].filter((sha): sha is string => !!sha),
cwd
)

if (sourceBranchHeadSha === bitbucketCommit) {
return bitbucketCommit
}

if (!destinationBranchName || commitWithParents[0] !== bitbucketCommit || commitWithParents.length !== 3) {
return undefined
}

const destinationParent = commitWithParents[2]
const destinationBranchHeadSha = findMatchingBranchHeadSha(destinationBranchName, [destinationParent], cwd)

if (sourceBranchHeadSha !== firstParent || destinationBranchHeadSha !== destinationParent) {
return undefined
}

// Bitbucket PR pipelines synthesize this merge by merging the destination
// branch into the source branch, so the first parent is the PR source head.
return firstParent
}

export const getCISpanTags = (fallbackGithubJobName?: string, fallbackGithubJobID?: string): SpanTags | undefined => {
const env = process.env
let tags: SpanTags = {}
Expand Down Expand Up @@ -697,6 +798,12 @@ export const getCISpanTags = (fallbackGithubJobName?: string, fallbackGithubJobI
if (BITBUCKET_PR_ID) {
tags[PR_NUMBER] = BITBUCKET_PR_ID
tags[GIT_PULL_REQUEST_BASE_BRANCH] = BITBUCKET_PR_DESTINATION_BRANCH
tags[GIT_HEAD_SHA] = getBitbucketPullRequestHeadSha(
BITBUCKET_BRANCH,
BITBUCKET_COMMIT,
BITBUCKET_PR_DESTINATION_BRANCH,
resolveTilde(BITBUCKET_CLONE_DIR)
)
}
}

Expand Down
15 changes: 15 additions & 0 deletions packages/base/src/helpers/git/get-git-data.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import {execFileSync} from 'child_process'
import {URL} from 'url'

import type {GitAuthorAndCommitterMetadata} from '../interfaces'
import type * as simpleGit from 'simple-git'
import type {BranchSummary} from 'simple-git'

export const gitRawSync = (args: string[], cwd?: string): string | undefined => {
try {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: {...process.env, GIT_TERMINAL_PROMPT: '0'},
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim()
} catch {
return undefined
}
}

// Returns the remote of the current repository.
export const gitRemote = async (git: simpleGit.SimpleGit): Promise<string> => {
const remotes = await git.getRemotes(true)
Expand Down
Loading