fix(docs-infra): handle CircleCI API v2 responses in preview server (#45934)

In PR #45349 we switched to using version 2 of the CircleCI API. It
turns out that this version of the API (in addition to different URLs)
also returns different info from some endpoints, which we have failed to
account for.

More specifically, the v2 API response for a job does not contain info
that we need in [BuildRetriever][1].

As an example, see the API responses for an `aio_preview` run:
- [API v1.1][2]
- [API v2][3]

This commit updates the code to handle API v2 responses. In addition,
since the info we need is not present in the job info (as it was with
the previous version of the API), we now also retrieve the pipeline
info.

NOTE:
This issue did not manifest earlier, because the preview server code on
the VM was not updated to the latest version (that tried to use API v2)
due to a different error. This error was fixed with PR #45895, which
allowed the preview server to be updated on the VM and uncovered the API
v2 incompatibility.

[1]: https://github.com/angular/angular/blob/baa3e18812127e7266580f4cd202a4cb3204cbcb/aio/aio-builds-setup/dockerbuild/scripts-js/lib/preview-server/build-retriever.ts#L39-L45
[2]: https://circleci.com/api/v1.1/project/github/angular/angular/1163816
[3]: https://circleci.com/api/v2/project/gh/angular/angular/job/1163816

Fixes #45931

PR Close #45934
This commit is contained in:
George Kalpakas
2022-05-09 19:49:35 +03:00
committed by Andrew Kushnir
parent af253cc2ea
commit c4340970c7
7 changed files with 211 additions and 84 deletions
@@ -4,28 +4,72 @@ import fetch, {RequestInit} from 'node-fetch';
import {assertNotMissingOrEmpty} from './utils'; import {assertNotMissingOrEmpty} from './utils';
// Constants // Constants
const CIRCLE_CI_API_URL = 'https://circleci.com/api/v2/project/gh'; const CIRCLE_CI_BASE_API_URL = 'https://circleci.com/api/v2';
const CIRCLE_CI_BUILD_API_URL = `${CIRCLE_CI_BASE_API_URL}/project/gh`;
const CIRCLE_CI_PIPELINE_API_URL = `${CIRCLE_CI_BASE_API_URL}/pipeline`;
// Interfaces - Types // Interfaces - Types
// API docs: https://circleci.com/docs/api/v2#operation/getJobArtifacts
// Example: https://circleci.com/api/v2/project/gh/angular/angular/1163941/artifacts
export interface ArtifactInfo { export interface ArtifactInfo {
/** The path of this build artifacts. */
path: string; path: string;
pretty_path: string;
node_index: number; /** The full URL where this artifact can be downloaded from. */
url: string; url: string;
// There are other fields but they are not used in this code.
} }
export type ArtifactResponse = ArtifactInfo[]; export type ArtifactResponse = {items: ArtifactInfo[]};
// API docs: https://circleci.com/docs/api/v2#operation/getJobDetails
// Example: https://circleci.com/api/v2/project/gh/angular/angular/job/1163941
export interface BuildInfo { export interface BuildInfo {
reponame: string; /** The job number. */
failed: boolean; number: number;
branch: string;
username: string; /** The job name (e.g. `'aio_preview'`). */
build_num: number; name: string;
has_artifacts: boolean;
outcome: string; // e.g. 'success' /** The job status (e.g. `'success'` or `'failed'`). */
vcs_revision: string; // HEAD SHA status: string;
// there are other fields but they are not used in this code
/** Info about the organization which the project related to this job belongs to. */
organization: {
name: string;
};
/** Info about the project related to this job. */
project: {
name: string;
};
/** Info about the [pipeline](https://circleci.com/docs/2.0/pipelines/) that this job is part of. */
pipeline: {
id: string;
};
// There are other fields but they are not used in this code.
}
// API docs: https://circleci.com/docs/api/v2#operation/getPipelineById
// https://circleci.com/api/v2/pipeline/356227c0-32f6-4f99-bfc2-3938db90a147
export interface PipelineInfo {
/** The pipeline ID. */
id: string;
/** Info related to/retrieved from the version control system provider (e.g. GitHub). */
vcs: {
/** The PR number. */
review_id: string;
/** The HEAD SHA. */
revision: string;
};
// There are other fields but they are not used in this code.
} }
/** /**
@@ -59,13 +103,13 @@ export class CircleCiApi {
} }
/** /**
* Get the info for a build from the CircleCI API * Get the info for a build (aka job) from the CircleCI API.
* @param buildNumber The CircleCI build number that generated the artifact. * @param buildNumber The CircleCI build number that generated the artifact.
* @returns A promise to the info about the build * @returns A promise to the info about the build.
*/ */
public async getBuildInfo(buildNumber: number): Promise<BuildInfo> { public async getBuildInfo(buildNumber: number): Promise<BuildInfo> {
try { try {
const url = `${CIRCLE_CI_API_URL}/${this.githubOrg}/${this.githubRepo}/job/${buildNumber}`; const url = `${CIRCLE_CI_BUILD_API_URL}/${this.githubOrg}/${this.githubRepo}/job/${buildNumber}`;
const response = await this.fetchFromCircleCi(url); const response = await this.fetchFromCircleCi(url);
return response.json(); return response.json();
} catch (error) { } catch (error) {
@@ -79,10 +123,10 @@ export class CircleCiApi {
* @returns A promise to the URL that can be requested to download the actual build artifact file. * @returns A promise to the URL that can be requested to download the actual build artifact file.
*/ */
public async getBuildArtifactUrl(buildNumber: number, artifactPath: string): Promise<string> { public async getBuildArtifactUrl(buildNumber: number, artifactPath: string): Promise<string> {
const baseUrl = `${CIRCLE_CI_API_URL}/${this.githubOrg}/${this.githubRepo}/${buildNumber}`; const baseUrl = `${CIRCLE_CI_BUILD_API_URL}/${this.githubOrg}/${this.githubRepo}/${buildNumber}`;
try { try {
const response = await this.fetchFromCircleCi(`${baseUrl}/artifacts`); const response = await this.fetchFromCircleCi(`${baseUrl}/artifacts`);
const artifacts = await response.json() as {items: ArtifactResponse}; const artifacts = await response.json() as ArtifactResponse;
const artifact = artifacts.items.find(item => item.path === artifactPath); const artifact = artifacts.items.find(item => item.path === artifactPath);
if (!artifact) { if (!artifact) {
throw new Error(`Missing artifact (${artifactPath}) for CircleCI build: ${buildNumber}`); throw new Error(`Missing artifact (${artifactPath}) for CircleCI build: ${buildNumber}`);
@@ -92,4 +136,19 @@ export class CircleCiApi {
throw new Error(`CircleCI artifact URL request failed (${(error as Error).message})`); throw new Error(`CircleCI artifact URL request failed (${(error as Error).message})`);
} }
} }
/**
* Get the info for a [pipeline](https://circleci.com/docs/2.0/pipelines/) from the CircleCI API.
* @param pipelineId The CircleCI pipeline ID that generated the artifact.
* @returns A promise to the info about the pipeline.
*/
public async getPipelineInfo(pipelineId: string): Promise<PipelineInfo> {
try {
const url = `${CIRCLE_CI_PIPELINE_API_URL}/${pipelineId}`;
const response = await this.fetchFromCircleCi(url);
return response.json();
} catch (error) {
throw new Error(`CircleCI pipeline info request failed (${(error as Error).message})`);
}
}
} }
@@ -36,12 +36,13 @@ export class BuildRetriever {
*/ */
public async getGithubInfo(buildNum: number): Promise<GithubInfo> { public async getGithubInfo(buildNum: number): Promise<GithubInfo> {
const buildInfo = await this.api.getBuildInfo(buildNum); const buildInfo = await this.api.getBuildInfo(buildNum);
const pipelineInfo = await this.api.getPipelineInfo(buildInfo.pipeline.id);
const githubInfo: GithubInfo = { const githubInfo: GithubInfo = {
org: buildInfo.username, org: buildInfo.organization.name,
pr: getPrFromBranch(buildInfo.branch), pr: +pipelineInfo.vcs.review_id,
repo: buildInfo.reponame, repo: buildInfo.project.name,
sha: buildInfo.vcs_revision, sha: pipelineInfo.vcs.revision,
success: !buildInfo.failed, success: buildInfo.status === 'success',
}; };
return githubInfo; return githubInfo;
} }
@@ -76,12 +77,3 @@ export class BuildRetriever {
} }
} }
} }
function getPrFromBranch(branch: string): number {
// CircleCI only exposes PR numbers via the `branch` field :-(
const match = /^pull\/(\d+)$/.exec(branch);
if (!match) {
throw new Error(`No PR found in branch field: ${branch}`);
}
return +match[1];
}
@@ -4,6 +4,8 @@ export const enum BuildNums {
BUILD_INFO_BUILD_FAILED, BUILD_INFO_BUILD_FAILED,
BUILD_INFO_INVALID_GH_ORG, BUILD_INFO_INVALID_GH_ORG,
BUILD_INFO_INVALID_GH_REPO, BUILD_INFO_INVALID_GH_REPO,
PIPELINE_INFO_ERROR,
PIPELINE_INFO_404,
CHANGED_FILES_ERROR, CHANGED_FILES_ERROR,
CHANGED_FILES_404, CHANGED_FILES_404,
CHANGED_FILES_NONE, CHANGED_FILES_NONE,
@@ -21,6 +23,17 @@ export const enum BuildNums {
TRUST_CHECK_INACTIVE_TRUSTED_USER, TRUST_CHECK_INACTIVE_TRUSTED_USER,
} }
export const enum PipelineIds {
PIPELINE_INFO_ERROR = 'pipeline-error',
PIPELINE_INFO_404 = 'pipeline-404',
CHANGED_FILES_ERROR = 'pipeline-cfe',
CHANGED_FILES_404 = 'pipeline-cf404',
CHANGED_FILES_NONE = 'pipeline-cfn',
TRUST_CHECK_ERROR = 'pipeline-tce',
TRUST_CHECK_UNTRUSTED = 'pipeline-tcu',
PIPELINE_INFO_OK = 'pipeline-ok',
}
export const enum PrNums { export const enum PrNums {
CHANGED_FILES_ERROR = 1, CHANGED_FILES_ERROR = 1,
CHANGED_FILES_404, CHANGED_FILES_404,
@@ -3,7 +3,7 @@ import * as nock from 'nock';
import * as tar from 'tar-stream'; import * as tar from 'tar-stream';
import {gzipSync} from 'zlib'; import {gzipSync} from 'zlib';
import {getEnvVar, Logger} from '../common/utils'; import {getEnvVar, Logger} from '../common/utils';
import {BuildNums, PrNums, SHA} from './constants'; import {BuildNums, PipelineIds, PrNums, SHA} from './constants';
// We are using the `nock` library to fake responses from REST requests, when testing. // We are using the `nock` library to fake responses from REST requests, when testing.
// This is necessary, because the test preview-server runs as a separate node process to // This is necessary, because the test preview-server runs as a separate node process to
@@ -30,11 +30,18 @@ const INACTIVE_TRUSTED_USER = 'inactive-trusted-user';
const UNTRUSTED_USER = 'untrusted-user'; const UNTRUSTED_USER = 'untrusted-user';
const BASIC_BUILD_INFO = { const BASIC_BUILD_INFO = {
branch: `pull/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`, name: 'test_job',
failed: false, status: 'success',
reponame: AIO_GITHUB_REPO, organization: {name: AIO_GITHUB_ORGANIZATION},
username: AIO_GITHUB_ORGANIZATION, project: {name: AIO_GITHUB_REPO},
vcs_revision: SHA, pipeline: {id: PipelineIds.PIPELINE_INFO_OK},
};
const BASIC_PIPELINE_INFO = {
id: PipelineIds.PIPELINE_INFO_OK,
vcs: {
review_id: `${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`,
revision: SHA,
},
}; };
const ISSUE_INFO_TRUSTED_LABEL = { labels: [{ name: AIO_TRUSTED_PR_LABEL }], user: { login: UNTRUSTED_USER } }; const ISSUE_INFO_TRUSTED_LABEL = { labels: [{ name: AIO_TRUSTED_PR_LABEL }], user: { login: UNTRUSTED_USER } };
@@ -57,10 +64,12 @@ const ARTIFACT_VALID_TRUSTED_LABEL = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_C
const ARTIFACT_VALID_UNTRUSTED = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/valid/untrusted`, _urlPath: '/artifacts/valid/untrusted' }; const ARTIFACT_VALID_UNTRUSTED = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/valid/untrusted`, _urlPath: '/artifacts/valid/untrusted' };
const CIRCLE_CI_BUILD_INFO_URL = `/api/v2/project/gh/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}`; const CIRCLE_CI_BUILD_INFO_URL = `/api/v2/project/gh/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}`;
const CIRCLE_CI_PIPELINE_INFO_URL = '/api/v2/pipeline';
const buildInfoUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/job/${buildNum}`; const buildInfoUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/job/${buildNum}`;
const buildArtifactsUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/${buildNum}/artifacts`; const buildArtifactsUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/${buildNum}/artifacts`;
const buildInfo = (prNum: number) => ({ ...BASIC_BUILD_INFO, branch: `pull/${prNum}` }); const pipelineInfoUrl = (pipelineId: string) => `${CIRCLE_CI_PIPELINE_INFO_URL}/${pipelineId}`;
const buildInfo = (pipelineId: string) => ({ ...BASIC_BUILD_INFO, pipeline: { id: pipelineId } });
const GITHUB_API_HOST = 'https://api.github.com'; const GITHUB_API_HOST = 'https://api.github.com';
const GITHUB_ISSUES_URL = `/repos/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}/issues`; const GITHUB_ISSUES_URL = `/repos/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}/issues`;
@@ -72,6 +81,15 @@ const getFilesUrl = (prNum: number, pageNum = 1) => `${GITHUB_PULLS_URL}/${prNum
const getCommentUrl = (prNum: number) => `${getIssueUrl(prNum)}/comments`; const getCommentUrl = (prNum: number) => `${getIssueUrl(prNum)}/comments`;
const getTeamMembershipUrl = (teamId: number, username: string) => `/teams/${teamId}/memberships/${username}`; const getTeamMembershipUrl = (teamId: number, username: string) => `/teams/${teamId}/memberships/${username}`;
const setUpCircleCiApiForBuild =
(buildNum: number, pipelineId: string, prNum = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, sha = SHA) => {
circleCiApi.get(buildInfoUrl(buildNum)).reply(200, buildInfo(pipelineId));
circleCiApi.get(pipelineInfoUrl(pipelineId)).reply(200, {
id: pipelineId,
vcs: { review_id: `${prNum}`, revision: sha },
});
};
const createArchive = (buildNum: number, prNum: number, sha: string) => { const createArchive = (buildNum: number, prNum: number, sha: string) => {
logger.log('createArchive', buildNum, prNum, sha); logger.log('createArchive', buildNum, prNum, sha);
const pack = tar.pack(); const pack = tar.pack();
@@ -91,42 +109,50 @@ const githubApi = nock(GITHUB_API_HOST).persist().matchHeader('Authorization', `
// GENERAL responses // GENERAL responses
githubApi.get(GITHUB_TEAMS_URL + '?page=1&per_page=100').reply(200, TEST_TEAM_INFO); githubApi.get(GITHUB_TEAMS_URL + '?page=1&per_page=100').reply(200, TEST_TEAM_INFO);
githubApi.post(getCommentUrl(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200); githubApi.post(getCommentUrl(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200);
circleCiApi.get(pipelineInfoUrl(PipelineIds.PIPELINE_INFO_OK)).reply(200, BASIC_PIPELINE_INFO);
// BUILD_INFO errors // BUILD INFO errors
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_ERROR)).replyWithError('BUILD_INFO_ERROR'); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_ERROR)).replyWithError('BUILD_INFO_ERROR');
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_404)).reply(404, 'BUILD_INFO_404'); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_404)).reply(404, 'BUILD_INFO_404');
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_BUILD_FAILED)).reply(200, { ...BASIC_BUILD_INFO, failed: true }); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_BUILD_FAILED)).reply(200, { ...BASIC_BUILD_INFO, status: 'failed' });
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_ORG)).reply(200, { ...BASIC_BUILD_INFO, username: 'bad' }); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_ORG)).reply(200, { ...BASIC_BUILD_INFO, organization: { name: 'bad' } });
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_REPO)).reply(200, { ...BASIC_BUILD_INFO, reponame: 'bad' }); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_REPO)).reply(200, { ...BASIC_BUILD_INFO, project: { name: 'bad' } });
// PIPELINE INFO errors
circleCiApi.get(buildInfoUrl(BuildNums.PIPELINE_INFO_ERROR)).reply(200, { ...BASIC_BUILD_INFO, pipeline: { id: PipelineIds.PIPELINE_INFO_ERROR } });
circleCiApi.get(pipelineInfoUrl(PipelineIds.PIPELINE_INFO_ERROR)).replyWithError('PIPELINE_INFO_ERROR');
circleCiApi.get(buildInfoUrl(BuildNums.PIPELINE_INFO_404)).reply(200, { ...BASIC_BUILD_INFO, pipeline: { id: PipelineIds.PIPELINE_INFO_404 } });
circleCiApi.get(pipelineInfoUrl(PipelineIds.PIPELINE_INFO_ERROR)).reply(404, 'PIPELINE_INFO_404');
// CHANGED FILE errors // CHANGED FILE errors
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_ERROR)).reply(200, buildInfo(PrNums.CHANGED_FILES_ERROR)); setUpCircleCiApiForBuild(BuildNums.CHANGED_FILES_ERROR, PipelineIds.CHANGED_FILES_ERROR, PrNums.CHANGED_FILES_ERROR);
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_ERROR)).replyWithError('CHANGED_FILES_ERROR'); githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_ERROR)).replyWithError('CHANGED_FILES_ERROR');
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_404)).reply(200, buildInfo(PrNums.CHANGED_FILES_404)); setUpCircleCiApiForBuild(BuildNums.CHANGED_FILES_404, PipelineIds.CHANGED_FILES_404, PrNums.CHANGED_FILES_404);
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_404)).reply(404, 'CHANGED_FILES_404'); githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_404)).reply(404, 'CHANGED_FILES_404');
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_NONE)).reply(200, buildInfo(PrNums.CHANGED_FILES_NONE)); setUpCircleCiApiForBuild(BuildNums.CHANGED_FILES_NONE, PipelineIds.CHANGED_FILES_NONE, PrNums.CHANGED_FILES_NONE);
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_NONE)).reply(200, []); githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_NONE)).reply(200, []);
// ARTIFACT URL errors // ARTIFACT URL errors
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).replyWithError('BUILD_ARTIFACTS_ERROR'); circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).replyWithError('BUILD_ARTIFACTS_ERROR');
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(404, 'BUILD_ARTIFACTS_ERROR'); circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(404, 'BUILD_ARTIFACTS_ERROR');
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, { items: [] }); circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, { items: [] });
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, { items: [ARTIFACT_1, ARTIFACT_2, ARTIFACT_3] }); circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, { items: [ARTIFACT_1, ARTIFACT_2, ARTIFACT_3] });
// ARTIFACT DOWNLOAD errors // ARTIFACT DOWNLOAD errors
circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, { items: [ARTIFACT_ERROR] }); circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, { items: [ARTIFACT_ERROR] });
circleCiApi.get(ARTIFACT_ERROR._urlPath).replyWithError(ARTIFACT_ERROR._urlPath); circleCiApi.get(ARTIFACT_ERROR._urlPath).replyWithError(ARTIFACT_ERROR._urlPath);
circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)); circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, BASIC_BUILD_INFO);
circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, [ARTIFACT_404]); circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, [ARTIFACT_404]);
circleCiApi.get(ARTIFACT_ERROR._urlPath).reply(404, ARTIFACT_ERROR._urlPath); circleCiApi.get(ARTIFACT_ERROR._urlPath).reply(404, ARTIFACT_ERROR._urlPath);
// TRUST CHECK errors // TRUST CHECK errors
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ERROR)); setUpCircleCiApiForBuild(BuildNums.TRUST_CHECK_ERROR, PipelineIds.TRUST_CHECK_ERROR, PrNums.TRUST_CHECK_ERROR);
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_ERROR)).reply(200, [{ filename: 'aio/a' }]); githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_ERROR)).reply(200, [{ filename: 'aio/a' }]);
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_ERROR)).reply(200, { items: [ARTIFACT_VALID_TRUSTED_USER] }); circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_ERROR)).reply(200, { items: [ARTIFACT_VALID_TRUSTED_USER] });
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_ERROR)).replyWithError('TRUST_CHECK_ERROR'); githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_ERROR)).replyWithError('TRUST_CHECK_ERROR');
@@ -155,7 +181,7 @@ githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_INACTIVE_TRUSTED_USER)).reply(200,
githubApi.get(getTeamMembershipUrl(0, INACTIVE_TRUSTED_USER)).reply(200, INACTIVE_STATE); githubApi.get(getTeamMembershipUrl(0, INACTIVE_TRUSTED_USER)).reply(200, INACTIVE_STATE);
// UNTRUSTED reponse // UNTRUSTED reponse
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_UNTRUSTED)).reply(200, buildInfo(PrNums.TRUST_CHECK_UNTRUSTED)); setUpCircleCiApiForBuild(BuildNums.TRUST_CHECK_UNTRUSTED, PipelineIds.TRUST_CHECK_UNTRUSTED, PrNums.TRUST_CHECK_UNTRUSTED);
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_UNTRUSTED)).reply(200, [{ filename: 'aio/a' }]); githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_UNTRUSTED)).reply(200, [{ filename: 'aio/a' }]);
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_UNTRUSTED)).reply(200, { items: [ARTIFACT_VALID_UNTRUSTED] }); circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_UNTRUSTED)).reply(200, { items: [ARTIFACT_VALID_UNTRUSTED] });
circleCiApi.get(ARTIFACT_VALID_UNTRUSTED._urlPath).reply(200, createArchive(BuildNums.TRUST_CHECK_UNTRUSTED, PrNums.TRUST_CHECK_UNTRUSTED, SHA)); circleCiApi.get(ARTIFACT_VALID_UNTRUSTED._urlPath).reply(200, createArchive(BuildNums.TRUST_CHECK_UNTRUSTED, PrNums.TRUST_CHECK_UNTRUSTED, SHA));
@@ -145,9 +145,11 @@ describe('preview-server', () => {
]); ]);
}); });
it('should respond with 500 if the CircleCI API request errors', async () => { it('should respond with 500 if any of the CircleCI API requests errors', async () => {
await curl(payload(BuildNums.BUILD_INFO_ERROR)).then(h.verifyResponse(500)); await curl(payload(BuildNums.BUILD_INFO_ERROR)).then(h.verifyResponse(500));
await curl(payload(BuildNums.BUILD_INFO_404)).then(h.verifyResponse(500)); await curl(payload(BuildNums.BUILD_INFO_404)).then(h.verifyResponse(500));
await curl(payload(BuildNums.PIPELINE_INFO_ERROR)).then(h.verifyResponse(500));
await curl(payload(BuildNums.PIPELINE_INFO_404)).then(h.verifyResponse(500));
}); });
it('should respond with 204 if the build on CircleCI failed', async () => { it('should respond with 204 if the build on CircleCI failed', async () => {
@@ -4,7 +4,8 @@ import {CircleCiApi} from '../../lib/common/circle-ci-api';
const ORG = 'testorg'; const ORG = 'testorg';
const REPO = 'testrepo'; const REPO = 'testrepo';
const TOKEN = 'xxxx'; const TOKEN = 'xxxx';
const BASE_URL = `https://circleci.com/api/v2/project/gh/${ORG}/${REPO}`; const BASE_BUILD_URL = `https://circleci.com/api/v2/project/gh/${ORG}/${REPO}`;
const BASE_PIPELINE_URL = 'https://circleci.com/api/v2/pipeline';
describe('CircleCIApi', () => { describe('CircleCIApi', () => {
describe('constructor()', () => { describe('constructor()', () => {
@@ -27,11 +28,11 @@ describe('CircleCIApi', () => {
describe('fetchFromCircleCI', () => { describe('fetchFromCircleCI', () => {
it('should include the authentication token in the headers on every request', async () => { it('should include the authentication token in the headers on every request', async () => {
const api = new CircleCiApi(ORG, REPO, TOKEN); const api = new CircleCiApi(ORG, REPO, TOKEN);
const request = nock(BASE_URL) const request = nock(BASE_BUILD_URL)
.get('/') .get('/')
.matchHeader('Circle-Token', TOKEN) .matchHeader('Circle-Token', TOKEN)
.reply(200); .reply(200);
await api.fetchFromCircleCi(`${BASE_URL}/`); await api.fetchFromCircleCi(`${BASE_BUILD_URL}/`);
request.done(); request.done();
}) })
}) })
@@ -42,7 +43,7 @@ describe('CircleCIApi', () => {
const buildNum = 12345; const buildNum = 12345;
const expectedBuildInfo: any = { org: ORG, repo: REPO, build_num: buildNum }; const expectedBuildInfo: any = { org: ORG, repo: REPO, build_num: buildNum };
const request = nock(BASE_URL) const request = nock(BASE_BUILD_URL)
.get(`/job/${buildNum}`) .get(`/job/${buildNum}`)
.reply(200, expectedBuildInfo); .reply(200, expectedBuildInfo);
@@ -55,17 +56,17 @@ describe('CircleCIApi', () => {
const api = new CircleCiApi(ORG, REPO, TOKEN); const api = new CircleCiApi(ORG, REPO, TOKEN);
const buildNum = 12345; const buildNum = 12345;
const errorMessage = 'Invalid request'; const errorMessage = 'Invalid request';
const request = nock(BASE_URL).get(`/job/${buildNum}`); const request = nock(BASE_BUILD_URL).get(`/job/${buildNum}`);
request.replyWithError(errorMessage); request.replyWithError(errorMessage);
await expectAsync(api.getBuildInfo(buildNum)).toBeRejectedWithError( await expectAsync(api.getBuildInfo(buildNum)).toBeRejectedWithError(
`CircleCI build info request failed ` + `CircleCI build info request failed ` +
`(request to ${BASE_URL}/job/${buildNum} failed, reason: ${errorMessage})`); `(request to ${BASE_BUILD_URL}/job/${buildNum} failed, reason: ${errorMessage})`);
request.reply(404, errorMessage); request.reply(404, errorMessage);
await expectAsync(api.getBuildInfo(buildNum)).toBeRejectedWithError( await expectAsync(api.getBuildInfo(buildNum)).toBeRejectedWithError(
`CircleCI build info request failed ` + `CircleCI build info request failed ` +
`(request to ${BASE_URL}/job/${buildNum} failed, reason: ${errorMessage})`); `(request to ${BASE_BUILD_URL}/job/${buildNum} failed, reason: ${errorMessage})`);
}); });
}); });
@@ -76,7 +77,7 @@ describe('CircleCIApi', () => {
const artifact0: any = { path: 'some/path/0', url: 'https://url/0' }; const artifact0: any = { path: 'some/path/0', url: 'https://url/0' };
const artifact1: any = { path: 'some/path/1', url: 'https://url/1' }; const artifact1: any = { path: 'some/path/1', url: 'https://url/1' };
const artifact2: any = { path: 'some/path/2', url: 'https://url/2' }; const artifact2: any = { path: 'some/path/2', url: 'https://url/2' };
const request = nock(BASE_URL) const request = nock(BASE_BUILD_URL)
.get(`/${buildNum}/artifacts`) .get(`/${buildNum}/artifacts`)
.reply(200, {items: [artifact0, artifact1, artifact2]}); .reply(200, {items: [artifact0, artifact1, artifact2]});
@@ -89,17 +90,17 @@ describe('CircleCIApi', () => {
const api = new CircleCiApi(ORG, REPO, TOKEN); const api = new CircleCiApi(ORG, REPO, TOKEN);
const buildNum = 12345; const buildNum = 12345;
const errorMessage = 'Invalid request'; const errorMessage = 'Invalid request';
const request = nock(BASE_URL).get(`/${buildNum}/artifacts`); const request = nock(BASE_BUILD_URL).get(`/${buildNum}/artifacts`);
request.replyWithError(errorMessage); request.replyWithError(errorMessage);
await expectAsync(api.getBuildArtifactUrl(buildNum, 'some/path/1')).toBeRejectedWithError( await expectAsync(api.getBuildArtifactUrl(buildNum, 'some/path/1')).toBeRejectedWithError(
`CircleCI artifact URL request failed ` + `CircleCI artifact URL request failed ` +
`(request to ${BASE_URL}/${buildNum}/artifacts failed, reason: ${errorMessage})`); `(request to ${BASE_BUILD_URL}/${buildNum}/artifacts failed, reason: ${errorMessage})`);
request.reply(404, errorMessage); request.reply(404, errorMessage);
await expectAsync(api.getBuildArtifactUrl(buildNum, 'some/path/1')).toBeRejectedWithError( await expectAsync(api.getBuildArtifactUrl(buildNum, 'some/path/1')).toBeRejectedWithError(
`CircleCI artifact URL request failed ` + `CircleCI artifact URL request failed ` +
`(request to ${BASE_URL}/${buildNum}/artifacts failed, reason: ${errorMessage})`); `(request to ${BASE_BUILD_URL}/${buildNum}/artifacts failed, reason: ${errorMessage})`);
}); });
it('should throw an error if the response does not contain the specified artifact', async () => { it('should throw an error if the response does not contain the specified artifact', async () => {
@@ -108,7 +109,7 @@ describe('CircleCIApi', () => {
const artifact0: any = { path: 'some/path/0', url: 'https://url/0' }; const artifact0: any = { path: 'some/path/0', url: 'https://url/0' };
const artifact1: any = { path: 'some/path/1', url: 'https://url/1' }; const artifact1: any = { path: 'some/path/1', url: 'https://url/1' };
const artifact2: any = { path: 'some/path/2', url: 'https://url/2' }; const artifact2: any = { path: 'some/path/2', url: 'https://url/2' };
nock(BASE_URL) nock(BASE_BUILD_URL)
.get(`/${buildNum}/artifacts`) .get(`/${buildNum}/artifacts`)
.reply(200, {items: [artifact0, artifact1, artifact2]}); .reply(200, {items: [artifact0, artifact1, artifact2]});
@@ -117,4 +118,37 @@ describe('CircleCIApi', () => {
`(Missing artifact (some/path/3) for CircleCI build: ${buildNum})`); `(Missing artifact (some/path/3) for CircleCI build: ${buildNum})`);
}); });
}); });
describe('getPipelineInfo', () => {
it('should make a request to the CircleCI API for the given pipeline ID', async () => {
const api = new CircleCiApi(ORG, REPO, TOKEN);
const pipelineId = 'a1-b2-c3';
const expectedPipelineInfo: any = { org: ORG, repo: REPO, pipeline_id: pipelineId };
const request = nock(BASE_PIPELINE_URL)
.get(`/${pipelineId}`)
.reply(200, expectedPipelineInfo);
const pipelineInfo = await api.getPipelineInfo(pipelineId);
expect(pipelineInfo).toEqual(expectedPipelineInfo);
request.done();
});
it('should throw an error if the request fails', async () => {
const api = new CircleCiApi(ORG, REPO, TOKEN);
const pipelineId = 'a1-b2-c3';
const errorMessage = 'Invalid request';
const request = nock(BASE_PIPELINE_URL).get(`/${pipelineId}`);
request.replyWithError(errorMessage);
await expectAsync(api.getPipelineInfo(pipelineId)).toBeRejectedWithError(
`CircleCI pipeline info request failed ` +
`(request to ${BASE_PIPELINE_URL}/${pipelineId} failed, reason: ${errorMessage})`);
request.reply(404, errorMessage);
await expectAsync(api.getPipelineInfo(pipelineId)).toBeRejectedWithError(
`CircleCI pipeline info request failed ` +
`(request to ${BASE_PIPELINE_URL}/${pipelineId} failed, reason: ${errorMessage})`);
});
});
}); });
@@ -1,7 +1,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as nock from 'nock'; import * as nock from 'nock';
import {resolve as resolvePath} from 'path'; import {resolve as resolvePath} from 'path';
import {BuildInfo, CircleCiApi} from '../../lib/common/circle-ci-api'; import {BuildInfo, CircleCiApi, PipelineInfo} from '../../lib/common/circle-ci-api';
import {Logger} from '../../lib/common/utils'; import {Logger} from '../../lib/common/utils';
import {BuildRetriever} from '../../lib/preview-server/build-retriever'; import {BuildRetriever} from '../../lib/preview-server/build-retriever';
@@ -13,6 +13,7 @@ describe('BuildRetriever', () => {
let api: CircleCiApi; let api: CircleCiApi;
let BUILD_INFO: BuildInfo; let BUILD_INFO: BuildInfo;
let PIPELINE_INFO: PipelineInfo;
let WRITEFILE_RESULT: any; let WRITEFILE_RESULT: any;
let writeFileSpy: jasmine.Spy; let writeFileSpy: jasmine.Spy;
let EXISTS_RESULT: boolean; let EXISTS_RESULT: boolean;
@@ -21,18 +22,24 @@ describe('BuildRetriever', () => {
beforeEach(() => { beforeEach(() => {
BUILD_INFO = { BUILD_INFO = {
branch: 'pull/777', number: 12345,
build_num: 12345, name: 'test_job',
failed: false, status: 'success',
has_artifacts: true, organization: {name: 'ORG'},
outcome: 'success', project: {name: 'REPO'},
reponame: 'REPO', pipeline: {id: 'test_pipeline'},
username: 'ORG', };
vcs_revision: 'COMMIT', PIPELINE_INFO = {
id: 'test_pipeline',
vcs: {
review_id: '777',
revision: 'COMMIT',
},
}; };
api = new CircleCiApi('ORG', 'REPO', 'TOKEN'); api = new CircleCiApi('ORG', 'REPO', 'TOKEN');
spyOn(api, 'getBuildInfo').and.resolveTo(BUILD_INFO); spyOn(api, 'getBuildInfo').and.resolveTo(BUILD_INFO);
spyOn(api, 'getPipelineInfo').and.resolveTo(PIPELINE_INFO);
getBuildArtifactUrlSpy = spyOn(api, 'getBuildArtifactUrl').and.resolveTo(BASE_URL + ARTIFACT_PATH); getBuildArtifactUrlSpy = spyOn(api, 'getBuildArtifactUrl').and.resolveTo(BASE_URL + ARTIFACT_PATH);
WRITEFILE_RESULT = undefined; WRITEFILE_RESULT = undefined;
@@ -65,19 +72,13 @@ describe('BuildRetriever', () => {
describe('getGithubInfo', () => { describe('getGithubInfo', () => {
it('should request the info from CircleCI', async () => { it('should request the build and pipeline info from CircleCI', async () => {
const retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR); const retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR);
const info = await retriever.getGithubInfo(12345); const info = await retriever.getGithubInfo(12345);
expect(api.getBuildInfo).toHaveBeenCalledWith(12345); expect(api.getBuildInfo).toHaveBeenCalledWith(12345);
expect(api.getPipelineInfo).toHaveBeenCalledWith('test_pipeline');
expect(info).toEqual({org: 'ORG', pr: 777, repo: 'REPO', sha: 'COMMIT', success: true}); expect(info).toEqual({org: 'ORG', pr: 777, repo: 'REPO', sha: 'COMMIT', success: true});
}); });
it('should error if it is not possible to extract the PR number from the branch', async () => {
BUILD_INFO.branch = 'master';
const retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR);
await expectAsync(retriever.getGithubInfo(12345)).toBeRejectedWithError('No PR found in branch field: master');
});
}); });