diff --git a/__snapshots__/commit-info-spec.js b/__snapshots__/commit-info-spec.js index 84decac..c59cd37 100644 --- a/__snapshots__/commit-info-spec.js +++ b/__snapshots__/commit-info-spec.js @@ -8,7 +8,10 @@ exports['commit-info no environment variables has certain api 1'] = [ "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "resolvePullRequestCi", + "PROVIDER_GITHUB_ACTIONS", + "PROVIDER_AZURE_PIPELINES" ] exports['commit-info no environment variables returns information 1'] = { @@ -41,7 +44,10 @@ exports['commit-info combination with environment variables has certain api 1'] "getRemoteOrigin", "getSubject", "getTimestamp", - "getBody" + "getBody", + "resolvePullRequestCi", + "PROVIDER_GITHUB_ACTIONS", + "PROVIDER_AZURE_PIPELINES" ] exports['commit-info combination with environment variables returns information 1'] = { diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..63e2efe --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,40 @@ +# Azure Pipelines — Azure Repos (Git in Azure DevOps) +# +# First-time setup: +# 1. Pipelines → New pipeline → Azure Repos Git → select this repo +# 2. Existing Azure Pipelines YAML file → branch → /azure-pipelines.yml +# +# Push builds: only for branches listed under trigger (main/master here). +# PR builds: create a Pull Request targeting main/master; BUILD_REASON=PullRequest. +# + +trigger: + branches: + include: + - main + - master + +pr: + branches: + include: + - main + - master + +pool: + vmImage: ubuntu-latest + +steps: + - task: NodeTool@0 + displayName: Use Node.js 18 + inputs: + versionSpec: 18.x + + - script: npm ci + displayName: npm ci + + - script: npm test + displayName: Unit tests + + # Optional: SYSTEM_ACCESSTOKEN: $(System.AccessToken) enriches prTitle via REST when allowed. + - script: npm run ado-pr-verify + displayName: Verify Azure PR → commit-info adoEventData diff --git a/package.json b/package.json index 6190462..7658ab1 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", "test": "npm run unit", "unit": "mocha src/*-spec.js", + "ado-pr-verify": "node scripts/verify-ado-pr-info.js", "gha-e2e": "mocha src/utils-e2e.js", "semantic-release": "semantic-release pre && npm publish --access public && semantic-release post" }, diff --git a/scripts/verify-ado-pr-info.js b/scripts/verify-ado-pr-info.js new file mode 100644 index 0000000..126212b --- /dev/null +++ b/scripts/verify-ado-pr-info.js @@ -0,0 +1,44 @@ +'use strict' + +/** + * Run in Azure Pipelines on PR builds to ensure commit-info reads + * SYSTEM_PULLREQUEST_* variables into adoEventData. + * + * Non-PR runs (push to main, manual) skip with a log line. + */ + +const la = require('lazy-ass') +const { commitInfo } = require('../src') + +const reason = process.env.BUILD_REASON || '' + +if (reason !== 'PullRequest') { + console.log( + 'ado-pr-verify: skip (BUILD_REASON=%s; only PullRequest runs assertions)', + reason || '(empty)' + ) + process.exit(0) +} + +commitInfo(process.cwd(), { pullRequestProvider: 'azure-pipelines' }) + .then(info => { + const ado = info.adoEventData + console.log('ado-pr-verify: adoEventData =', JSON.stringify(ado, null, 2)) + la( + ado, + 'expected adoEventData on Azure PR build; is SYSTEM_PULLREQUEST_PULLREQUESTID set?' + ) + la(ado.pullRequestNumber, 'expected pullRequestNumber from env') + la( + ado.pullRequestId, + 'expected pullRequestId (PR URL); SYSTEM_TEAMFOUNDATIONCOLLECTIONURI / TEAMPROJECT / BUILD_REPOSITORY_NAME missing?' + ) + la( + ado.pullRequestId === ado.htmlUrl, + 'pullRequestId must equal htmlUrl (canonical PR URL)' + ) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/src/index.js b/src/index.js index dd8143d..52f21ef 100644 --- a/src/index.js +++ b/src/index.js @@ -11,18 +11,27 @@ const { getTimestamp, getRemoteOrigin } = require('./git-api') +const { getBranch, getCommitInfoFromEnvironment } = require('./utils') const { - getBranch, - getCommitInfoFromEnvironment, - getGhaEventData -} = require('./utils') + resolvePullRequestCi, + enrichAzurePullRequestCi, + PROVIDER_GITHUB_ACTIONS, + PROVIDER_AZURE_PIPELINES, + withoutProvider +} = require('./pull-request-ci') const Promise = require('bluebird') const { mergeWith, or } = require('ramda') -function commitInfo (folder) { +function commitInfo (folder, options = {}) { folder = folder || process.cwd() + const { pullRequestProvider = 'auto' } = options debug('commit-info in folder', folder) + const pullRequestCi = resolvePullRequestCi({ + env: process.env, + provider: pullRequestProvider + }) + return Promise.props({ branch: getBranch(folder), message: getMessage(folder), @@ -31,15 +40,35 @@ function commitInfo (folder) { sha: getSha(folder), timestamp: getTimestamp(folder), remote: getRemoteOrigin(folder), - ghaEventData: getGhaEventData( - process.env.GITHUB_EVENT_PATH, - process.env.GITHUB_ACTIONS - ) + pullRequestCi }).then(info => { - const envVariables = getCommitInfoFromEnvironment() - debug('git commit: %o', info) - debug('env commit: %o', envVariables) - return mergeWith(or, envVariables, info) + const finish = prCi => { + const next = { + ...info, + pullRequestCi: prCi, + ghaEventData: + prCi && prCi.provider === PROVIDER_GITHUB_ACTIONS + ? withoutProvider(prCi) + : undefined, + adoEventData: + prCi && prCi.provider === PROVIDER_AZURE_PIPELINES + ? withoutProvider(prCi) + : undefined + } + const envVariables = getCommitInfoFromEnvironment() + debug('git commit: %o', next) + debug('env commit: %o', envVariables) + return mergeWith(or, envVariables, next) + } + if ( + info.pullRequestCi && + info.pullRequestCi.provider === PROVIDER_AZURE_PIPELINES + ) { + return enrichAzurePullRequestCi(info.pullRequestCi, process.env).then( + finish + ) + } + return finish(info.pullRequestCi) }) } @@ -53,5 +82,8 @@ module.exports = { getRemoteOrigin, getSubject, getTimestamp, - getBody + getBody, + resolvePullRequestCi, + PROVIDER_GITHUB_ACTIONS, + PROVIDER_AZURE_PIPELINES } diff --git a/src/pull-request-ci-spec.js b/src/pull-request-ci-spec.js new file mode 100644 index 0000000..ec6287c --- /dev/null +++ b/src/pull-request-ci-spec.js @@ -0,0 +1,323 @@ +'use strict' + +/* eslint-env mocha */ +const la = require('lazy-ass') +const sinon = require('sinon') +const fs = require('fs') +const { + resolvePullRequestCi, + PROVIDER_GITHUB_ACTIONS, + PROVIDER_AZURE_PIPELINES, + readGithubActionsPullRequest, + adoPrTitleFromEnv, + adoGetPullRequestApiUrl, + enrichAzurePullRequestCi +} = require('./pull-request-ci') + +describe('pull-request-ci', () => { + const githubEvent = { + pull_request: { + head: { ref: 'head-ref', sha: 'head-sha' }, + base: { ref: 'base-ref', sha: 'base-sha' }, + issue_url: 'issue', + html_url: 'html', + title: 'title' + }, + sender: { + avatar_url: 'av', + html_url: 'sender' + } + } + + describe('resolvePullRequestCi', () => { + let readStub + + beforeEach(() => { + readStub = sinon + .stub(fs, 'readFileSync') + .returns(JSON.stringify(githubEvent)) + }) + + afterEach(() => { + readStub.restore() + }) + + it('auto prefers GitHub Actions when event file is present', () => { + const r = resolvePullRequestCi({ + env: { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_PATH: '/tmp/event.json', + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '99' + }, + fs, + provider: 'auto' + }) + + la(r.provider === PROVIDER_GITHUB_ACTIONS, r) + la(r.headRef === 'head-ref', r) + }) + + it('auto falls back to Azure when GitHub is not active', () => { + const r = resolvePullRequestCi({ + env: { + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '7', + SYSTEM_PULLREQUEST_SOURCEBRANCH: 'refs/heads/f' + }, + fs, + provider: 'auto' + }) + + la(r.provider === PROVIDER_AZURE_PIPELINES, r) + la(r.pullRequestNumber === '7', r) + la(r.pullRequestId == null, r) + }) + + it('github-actions skips Azure even if ADO vars are set', () => { + const r = resolvePullRequestCi({ + env: { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_PATH: '/tmp/event.json', + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '99' + }, + fs, + provider: PROVIDER_GITHUB_ACTIONS + }) + + la(r.provider === PROVIDER_GITHUB_ACTIONS, r) + }) + + it('azure-pipelines ignores GitHub event file', () => { + const r = resolvePullRequestCi({ + env: { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_PATH: '/tmp/event.json', + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '3' + }, + fs, + provider: PROVIDER_AZURE_PIPELINES + }) + + la(r.provider === PROVIDER_AZURE_PIPELINES, r) + la(r.pullRequestNumber === '3', r) + la(r.pullRequestId == null, r) + la(readStub.called === false, 'should not read GitHub event JSON') + }) + + it('azure-pipelines pullRequestId is htmlUrl when collection/project/repo are set', () => { + const url = 'https://dev.azure.com/acme/P/_git/r/pullrequest/3' + const r = resolvePullRequestCi({ + env: { + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '3', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://dev.azure.com/acme/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_NAME: 'r' + }, + fs, + provider: PROVIDER_AZURE_PIPELINES + }) + + la(r.pullRequestId === url, r) + la(r.htmlUrl === url, r) + la(r.pullRequestNumber === '3', r) + }) + + it('github-actions returns undefined when not a GHA run', () => { + const r = resolvePullRequestCi({ + env: {}, + fs, + provider: PROVIDER_GITHUB_ACTIONS + }) + + la(r === undefined, r) + }) + }) + + describe('adoGetPullRequestApiUrl', () => { + it('builds REST URL when required env is set', () => { + const u = adoGetPullRequestApiUrl({ + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://fab.visualstudio.com/', + SYSTEM_TEAMPROJECT: 'My Proj', + BUILD_REPOSITORY_ID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + SYSTEM_PULLREQUEST_PULLREQUESTID: '7' + }) + la( + u === + 'https://fab.visualstudio.com/My%20Proj/_apis/git/repositories/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/pullRequests/7?api-version=7.0', + u + ) + }) + + it('returns null when repository id is missing', () => { + la( + adoGetPullRequestApiUrl({ + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://x/', + SYSTEM_TEAMPROJECT: 'P', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1' + }) == null + ) + }) + }) + + describe('enrichAzurePullRequestCi', () => { + it('merges title and author from REST response', () => { + const ci = { + provider: PROVIDER_AZURE_PIPELINES, + prTitle: 'from env', + senderAvatarUrl: null, + senderHtmlUrl: null + } + const env = { + SYSTEM_ACCESSTOKEN: 't', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://fab.visualstudio.com/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_ID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1' + } + const fetchJson = () => + Promise.resolve({ + title: 'UI title', + createdBy: { + imageUrl: 'https://img', + id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } + }) + return enrichAzurePullRequestCi(ci, env, { fetchJson }).then(out => { + la(out.prTitle === 'UI title', out) + la(out.senderAvatarUrl === 'https://img', out) + la( + out.senderHtmlUrl === + 'https://fab.visualstudio.com/_usersSettings/about?userId=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + out + ) + }) + }) + + it('backfills baseSha (and headSha) from REST lastMerge*Commit', () => { + const ci = { + provider: PROVIDER_AZURE_PIPELINES, + prTitle: 'x', + baseSha: null, + headSha: null + } + const env = { + SYSTEM_ACCESSTOKEN: 't', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://fab.visualstudio.com/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_ID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1' + } + const fetchJson = () => + Promise.resolve({ + lastMergeTargetCommit: { commitId: 'cafebabe' }, + lastMergeSourceCommit: { commitId: 'deadbeef' } + }) + return enrichAzurePullRequestCi(ci, env, { fetchJson }).then(out => { + la(out.baseSha === 'cafebabe', out) + la(out.headSha === 'deadbeef', out) + }) + }) + + it('does not overwrite headSha already provided by env', () => { + const ci = { + provider: PROVIDER_AZURE_PIPELINES, + prTitle: 'x', + baseSha: null, + headSha: 'from-env' + } + const env = { + SYSTEM_ACCESSTOKEN: 't', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://fab.visualstudio.com/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_ID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1' + } + const fetchJson = () => + Promise.resolve({ + lastMergeTargetCommit: { commitId: 'cafebabe' }, + lastMergeSourceCommit: { commitId: 'rest-head' } + }) + return enrichAzurePullRequestCi(ci, env, { fetchJson }).then(out => { + la(out.baseSha === 'cafebabe', out) + la(out.headSha === 'from-env', out) + }) + }) + + it('returns same ci when token is missing', () => { + const ci = { provider: PROVIDER_AZURE_PIPELINES, prTitle: 'x' } + return enrichAzurePullRequestCi(ci, {}, {}).then(out => { + la(out === ci, out) + }) + }) + + it('returns same ci when REST fails', () => { + const ci = { provider: PROVIDER_AZURE_PIPELINES, prTitle: 'keep' } + const env = { + SYSTEM_ACCESSTOKEN: 't', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://fab.visualstudio.com/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_ID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1' + } + const fetchJson = () => Promise.reject(new Error('401')) + return enrichAzurePullRequestCi(ci, env, { fetchJson }).then(out => { + la(out.prTitle === 'keep', out) + la(out === ci, out) + }) + }) + }) + + describe('adoPrTitleFromEnv', () => { + it('prefers SYSTEM_PULLREQUEST_TITLE', () => { + la( + adoPrTitleFromEnv({ + SYSTEM_PULLREQUEST_TITLE: 'Explicit', + BUILD_SOURCEVERSIONMESSAGE: 'Merged PR 1: ignored' + }) === 'Explicit' + ) + }) + + it('strips Merged PR prefix from BUILD_SOURCEVERSIONMESSAGE', () => { + la( + adoPrTitleFromEnv({ + BUILD_SOURCEVERSIONMESSAGE: 'Merged PR 42: my title' + }) === 'my title' + ) + }) + + it('uses full first line when not a Merged PR message', () => { + la( + adoPrTitleFromEnv({ BUILD_SOURCEVERSIONMESSAGE: 'plain subject' }) === + 'plain subject' + ) + }) + + it('returns null for Git default merge pull request subject', () => { + la( + adoPrTitleFromEnv({ + BUILD_SOURCEVERSIONMESSAGE: + 'Merge pull request 1 from feat/detect-azure-ci into master' + }) == null + ) + }) + }) + + describe('readGithubActionsPullRequest', () => { + it('injects fs for tests without stubbing global fs', () => { + const fakeFs = { + readFileSync: () => JSON.stringify(githubEvent) + } + const r = readGithubActionsPullRequest( + '/x', + 'true', + /** @type {any} */ (fakeFs) + ) + + la(r.provider === PROVIDER_GITHUB_ACTIONS, r) + }) + }) +}) diff --git a/src/pull-request-ci.js b/src/pull-request-ci.js new file mode 100644 index 0000000..6beef87 --- /dev/null +++ b/src/pull-request-ci.js @@ -0,0 +1,354 @@ +'use strict' + +const http = require('http') +const https = require('https') + +const debug = require('debug')('commit-info') + +const PROVIDER_GITHUB_ACTIONS = 'github-actions' +const PROVIDER_AZURE_PIPELINES = 'azure-pipelines' + +function withoutProvider (record) { + if (!record) return + const rest = {} + const keys = Object.keys(record) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + if (key !== 'provider') { + rest[key] = record[key] + } + } + return rest +} + +/** + * Azure Repos PR builds rarely set SYSTEM_PULLREQUEST_TITLE; merge commit message + * is usually "Merged PR {id}: {title}" (first line only in BUILD_SOURCEVERSIONMESSAGE). + * @param {NodeJS.ProcessEnv} env + */ +function adoPrTitleFromEnv (env) { + const explicit = env.SYSTEM_PULLREQUEST_TITLE + if (explicit) { + return explicit + } + const msg = env.BUILD_SOURCEVERSIONMESSAGE + if (!msg) { + return null + } + const trimmed = msg.trim() + // Git default merge message on PR refs — not the PR title in Azure DevOps UI. + if (/^Merge pull request \d+ from .+ into .+$/i.test(trimmed)) { + return null + } + const merged = msg.match(/^Merged PR \d+: ?(.*)$/) + if (merged) { + return merged[1] || null + } + return msg +} + +/** + * @param {string | null | undefined} collectionUri + * @param {string | null | undefined} userId Build.RequestedForId + */ +function adoSenderHtmlUrl (collectionUri, userId) { + if (!collectionUri || !userId) { + return null + } + const root = collectionUri.replace(/\/$/, '') + return `${root}/_usersSettings/about?userId=${encodeURIComponent(userId)}` +} + +/** + * Graph profile avatar (may require auth to fetch; same pattern as ADO UI). + * @param {string | null | undefined} collectionUri + * @param {string | null | undefined} userId Build.RequestedForId + */ +function adoSenderAvatarUrl (collectionUri, userId) { + if (!collectionUri || !userId) { + return null + } + const root = collectionUri.replace(/\/$/, '') + return `${root}/_apis/GraphProfile/MemberAvatars/${encodeURIComponent( + userId + )}?size=2&api-version=5.1-preview.1` +} + +/** + * @param {string} eventFilePath + * @param {string | undefined} isGha + * @param {typeof import('fs')} fs + */ +function readGithubActionsPullRequest (eventFilePath, isGha, fs) { + try { + if (!eventFilePath || isGha !== 'true') { + return + } + + debug('Retreiving GitHub Actions data from %s', eventFilePath) + const data = JSON.parse(fs.readFileSync(eventFilePath)) + + return { + provider: PROVIDER_GITHUB_ACTIONS, + headRef: data.pull_request.head.ref, + headSha: data.pull_request.head.sha, + baseRef: data.pull_request.base.ref, + baseSha: data.pull_request.base.sha, + issueUrl: data.pull_request.issue_url, + htmlUrl: data.pull_request.html_url, + prTitle: data.pull_request.title, + senderAvatarUrl: data.sender.avatar_url, + senderHtmlUrl: data.sender.html_url + } + } catch (e) { + debug('Retreiving GitHub Actions data error: %s', e) + } +} + +/** + * @param {NodeJS.ProcessEnv} env + */ +function readAzurePipelinesPullRequest (env) { + try { + if (env.BUILD_REASON !== 'PullRequest') { + return + } + + const pullRequestNumber = env.SYSTEM_PULLREQUEST_PULLREQUESTID + if (!pullRequestNumber) { + return + } + + const headRef = env.SYSTEM_PULLREQUEST_SOURCEBRANCH || null + const baseRef = env.SYSTEM_PULLREQUEST_TARGETBRANCH || null + const headSha = env.SYSTEM_PULLREQUEST_SOURCECOMMITID || null + // Azure Pipelines exposes no predefined variable for the PR base (merge + // target) commit SHA. Env-only callers get null; commitInfo() calls + // enrichAzurePullRequestCi(), which fills baseSha from the Git REST API + // (lastMergeTargetCommit.commitId) when SYSTEM_ACCESSTOKEN and repo id exist. + const baseSha = null + const buildSourceBranch = env.BUILD_SOURCEBRANCH || null + + let htmlUrl = null + const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI + const teamProject = env.SYSTEM_TEAMPROJECT + const repositoryName = env.BUILD_REPOSITORY_NAME + if (collectionUri && teamProject && repositoryName) { + const base = collectionUri.replace(/\/$/, '') + htmlUrl = `${base}/${encodeURIComponent( + teamProject + )}/_git/${encodeURIComponent( + repositoryName + )}/pullrequest/${pullRequestNumber}` + } + + // pullRequestId: canonical PR URL only; null if env cannot build it (no numeric fallback). + const pullRequestId = htmlUrl + const requestedForId = env.BUILD_REQUESTEDFORID || null + + return { + provider: PROVIDER_AZURE_PIPELINES, + pullRequestId, + pullRequestNumber, + buildSourceBranch, + headRef, + headSha, + baseRef, + baseSha, + issueUrl: null, + htmlUrl, + prTitle: adoPrTitleFromEnv(env), + senderAvatarUrl: adoSenderAvatarUrl(collectionUri, requestedForId), + senderHtmlUrl: adoSenderHtmlUrl(collectionUri, requestedForId) + } + } catch (e) { + debug('Retrieving Azure DevOps PR data error: %s', e) + } +} + +/** + * @param {object} [opts] + * @param {NodeJS.ProcessEnv} [opts.env] + * @param {typeof import('fs')} [opts.fs] + * @param {string} [opts.githubEventPath] + * @param {string | undefined} [opts.githubActions] + * @param {'auto' | typeof PROVIDER_GITHUB_ACTIONS | typeof PROVIDER_AZURE_PIPELINES} [opts.provider] + */ +function resolvePullRequestCi ({ + env = process.env, + fs = require('fs'), + githubEventPath = env.GITHUB_EVENT_PATH, + githubActions = env.GITHUB_ACTIONS, + provider = 'auto' +} = {}) { + if (provider === 'auto' || provider === PROVIDER_GITHUB_ACTIONS) { + const gha = readGithubActionsPullRequest(githubEventPath, githubActions, fs) + if (gha) { + return gha + } + if (provider === PROVIDER_GITHUB_ACTIONS) { + return + } + } + + if (provider === 'auto' || provider === PROVIDER_AZURE_PIPELINES) { + return readAzurePipelinesPullRequest(env) + } +} + +function adoAccessTokenFromEnv (env) { + return ( + env.SYSTEM_ACCESSTOKEN || + env.SYSTEM_ACCESS_TOKEN || + env.ENDPOINT_AUTH_PARAMETER_SYSTEMVSSCONNECTION_ACCESSTOKEN || + null + ) +} + +function adoGetPullRequestApiUrl (env) { + const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI + const teamProject = env.SYSTEM_TEAMPROJECT + const repositoryId = env.BUILD_REPOSITORY_ID + const pullRequestId = env.SYSTEM_PULLREQUEST_PULLREQUESTID + if (!collectionUri || !teamProject || !repositoryId || !pullRequestId) { + return null + } + const root = collectionUri.replace(/\/$/, '') + return `${root}/${encodeURIComponent( + teamProject + )}/_apis/git/repositories/${encodeURIComponent( + repositoryId + )}/pullRequests/${encodeURIComponent(pullRequestId)}?api-version=7.0` +} + +function httpGetJson (href, token) { + return new Promise((resolve, reject) => { + let settled = false + const refs = {} + const settle = (fn, value) => { + if (settled) return + settled = true + if (refs.req) refs.req.setTimeout(0) + fn(value) + } + const u = new URL(href) + const lib = u.protocol === 'https:' ? https : http + const defaultPort = u.protocol === 'https:' ? 443 : 80 + const port = u.port ? parseInt(u.port, 10) : defaultPort + const opts = { + hostname: u.hostname, + port, + path: u.pathname + u.search, + method: 'GET', + headers: { + Authorization: 'Bearer ' + token, + Accept: 'application/json' + } + } + refs.req = lib.request(opts, res => { + let body = '' + res.setEncoding('utf8') + res.on('data', chunk => { + body += chunk + }) + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + settle(resolve, JSON.parse(body)) + } catch (err) { + settle(reject, err) + } + } else { + settle( + reject, + new Error('HTTP ' + res.statusCode + ': ' + body.slice(0, 240)) + ) + } + }) + }) + const req = refs.req + req.setTimeout(30000, () => { + req.destroy() + settle(reject, new Error(`HTTP GET timed out after 30000ms: ${href}`)) + }) + req.on('error', err => { + settle(reject, err) + }) + req.end() + }) +} + +/** + * Optional: richer prTitle / createdBy from Git REST when token + repo id exist. + * Never throws; on any failure returns the original ci unchanged. + * @param {object | undefined} ci + * @param {NodeJS.ProcessEnv} env + * @param {{ fetchJson?: (href: string, token: string) => Promise }} [options] + */ +function enrichAzurePullRequestCi (ci, env, options = {}) { + if (!ci || ci.provider !== PROVIDER_AZURE_PIPELINES) { + return Promise.resolve(ci) + } + let token + let href + try { + token = adoAccessTokenFromEnv(env) + href = adoGetPullRequestApiUrl(env) + } catch (e) { + debug('ADO PR REST enrich skipped: %s', e.message) + return Promise.resolve(ci) + } + const fetchJson = options.fetchJson || httpGetJson + if (!token || !href) { + return Promise.resolve(ci) + } + return fetchJson(href, token) + .then(data => { + try { + const createdBy = data && data.createdBy ? data.createdBy : {} + const lastMergeTargetCommit = + data && data.lastMergeTargetCommit ? data.lastMergeTargetCommit : {} + const lastMergeSourceCommit = + data && data.lastMergeSourceCommit ? data.lastMergeSourceCommit : {} + return { + ...ci, + prTitle: + data && data.title != null && String(data.title) !== '' + ? data.title + : ci.prTitle, + // Documented PR target/source commit IDs from the Git PR REST payload; + // backfills baseSha (which has no documented env-var source) and only + // overrides headSha when env did not already provide one. + baseSha: lastMergeTargetCommit.commitId || ci.baseSha, + headSha: ci.headSha || lastMergeSourceCommit.commitId || null, + senderAvatarUrl: createdBy.imageUrl || ci.senderAvatarUrl, + senderHtmlUrl: + adoSenderHtmlUrl( + env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI, + createdBy.id + ) || ci.senderHtmlUrl + } + } catch (e) { + debug('ADO PR REST enrich parse error: %s', e.message) + return ci + } + }) + .catch(err => { + debug('ADO PR REST enrich failed: %s', err.message) + return ci + }) +} + +module.exports = { + PROVIDER_GITHUB_ACTIONS, + PROVIDER_AZURE_PIPELINES, + withoutProvider, + adoPrTitleFromEnv, + adoSenderHtmlUrl, + adoSenderAvatarUrl, + adoGetPullRequestApiUrl, + enrichAzurePullRequestCi, + readGithubActionsPullRequest, + readAzurePipelinesPullRequest, + resolvePullRequestCi +} diff --git a/src/utils-spec.js b/src/utils-spec.js index d4f1422..4db64af 100644 --- a/src/utils-spec.js +++ b/src/utils-spec.js @@ -125,4 +125,111 @@ describe('utils', () => { la(eventData === undefined, eventData) }) }) + + describe('getAdoPrEventData', () => { + const { getAdoPrEventData } = require('./utils') + + it('returns undefined when build is not a pull request', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'IndividualCI' + }) + + la(eventData === undefined, eventData) + }) + + it('returns undefined when pull request id is missing', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'PullRequest' + }) + + la(eventData === undefined, eventData) + }) + + it('returns PR metadata from Azure Pipelines env vars', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '123', + SYSTEM_PULLREQUEST_SOURCEBRANCH: 'refs/heads/feature-name', + SYSTEM_PULLREQUEST_TARGETBRANCH: 'refs/heads/main', + BUILD_SOURCEBRANCH: 'refs/pull/123/merge', + SYSTEM_PULLREQUEST_SOURCECOMMITID: 'deadbeef', + SYSTEM_PULLREQUEST_TARGETCOMMITID: 'cafebabe', // undocumented — ignored + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://dev.azure.com/org/', + SYSTEM_TEAMPROJECT: 'My Project', + BUILD_REPOSITORY_NAME: 'my-repo', + SYSTEM_PULLREQUEST_TITLE: 'Fix the thing', + BUILD_REQUESTEDFORID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + }) + + la(eventData.pullRequestNumber === '123', eventData) + la( + eventData.pullRequestId === + 'https://dev.azure.com/org/My%20Project/_git/my-repo/pullrequest/123', + eventData + ) + la(eventData.pullRequestId === eventData.htmlUrl, eventData) + la(eventData.buildSourceBranch === 'refs/pull/123/merge', eventData) + la(eventData.headRef === 'refs/heads/feature-name', eventData) + la(eventData.baseRef === 'refs/heads/main', eventData) + la(eventData.headSha === 'deadbeef', eventData) + la(eventData.baseSha === null, eventData) + la(eventData.prTitle === 'Fix the thing', eventData) + la( + eventData.htmlUrl === + 'https://dev.azure.com/org/My%20Project/_git/my-repo/pullrequest/123', + eventData + ) + la(eventData.issueUrl === null, eventData) + la( + eventData.senderHtmlUrl === + 'https://dev.azure.com/org/_usersSettings/about?userId=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + eventData + ) + la( + eventData.senderAvatarUrl === + 'https://dev.azure.com/org/_apis/GraphProfile/MemberAvatars/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa?size=2&api-version=5.1-preview.1', + eventData + ) + }) + + it('derives prTitle from merge commit message when title env is unset', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://dev.azure.com/org/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_NAME: 'r', + BUILD_SOURCEVERSIONMESSAGE: 'Merged PR 1: feat: hello from ado' + }) + + la(eventData.prTitle === 'feat: hello from ado', eventData) + }) + + it('prTitle null when only Git merge-pull-request subject is available', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1', + SYSTEM_TEAMFOUNDATIONCOLLECTIONURI: 'https://dev.azure.com/org/', + SYSTEM_TEAMPROJECT: 'P', + BUILD_REPOSITORY_NAME: 'r', + BUILD_SOURCEVERSIONMESSAGE: + 'Merge pull request 1 from feat/x into master' + }) + + la(eventData.prTitle == null, eventData) + }) + + it('leaves headSha null when SOURCECOMMITID absent (not BUILD_SOURCEVERSION)', () => { + const eventData = getAdoPrEventData({ + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_PULLREQUESTID: '1', + BUILD_SOURCEVERSION: 'abc123' + }) + + la(eventData.headSha == null, eventData) + la(eventData.pullRequestNumber === '1', eventData) + la(eventData.pullRequestId == null, eventData) + la(eventData.htmlUrl == null, eventData) + }) + }) }) diff --git a/src/utils.js b/src/utils.js index ad0e1a7..0f9a05f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,6 +1,11 @@ const { getGitBranch } = require('./git-api') const debug = require('debug')('commit-info') const fs = require('fs') +const { + readGithubActionsPullRequest, + readAzurePipelinesPullRequest, + withoutProvider +} = require('./pull-request-ci') function firstFoundValue (keys, object = process.env) { const found = keys.find(key => { @@ -57,28 +62,25 @@ function getFields () { * @returns {headRef: string; headSha: string; baseRef: string; baseSha: string; issueUrl: string; htmlUrl: string; prTitle: string; senderAvatarUrl: string; senderHtmlUrl: string;} */ function getGhaEventData (eventFilePath, isGha) { - try { - if (!eventFilePath || isGha !== 'true') { - return - } - - debug('Retreiving GitHub Actions data from %s', eventFilePath) - const data = JSON.parse(fs.readFileSync(eventFilePath)) + const record = readGithubActionsPullRequest(eventFilePath, isGha, fs) + return withoutProvider(record) +} - return { - headRef: data.pull_request.head.ref, - headSha: data.pull_request.head.sha, - baseRef: data.pull_request.base.ref, - baseSha: data.pull_request.base.sha, - issueUrl: data.pull_request.issue_url, - htmlUrl: data.pull_request.html_url, - prTitle: data.pull_request.title, - senderAvatarUrl: data.sender.avatar_url, - senderHtmlUrl: data.sender.html_url - } - } catch (e) { - debug('Retreiving GitHub Actions data error: %s', e) - } +/** + * Pull request metadata from Azure Pipelines when the job is PR-triggered. + * Uses predefined variables as env vars (dots → underscores, uppercase). + * + * **`baseSha` is always `null`.** Azure Pipelines does not document any predefined + * variable for the PR base-commit SHA; `commitInfo()` loads it from the Git REST + * API (`lastMergeTargetCommit`) via `enrichAzurePullRequestCi` when a token is + * available (`SYSTEM_ACCESSTOKEN` plus `BUILD_REPOSITORY_ID`). + * + * @param {NodeJS.ProcessEnv} [env=process.env] + * @returns {object | undefined} PR fields; Azure: pullRequestId is htmlUrl or null (never a numeric fallback) + */ +function getAdoPrEventData (env = process.env) { + const record = readAzurePipelinesPullRequest(env) + return withoutProvider(record) } module.exports = { @@ -86,5 +88,6 @@ module.exports = { getBranch, getCommitInfoFromEnvironment, getFields, - getGhaEventData + getGhaEventData, + getAdoPrEventData }