Skip to content

fix(dev-infra): run caretaker checks asyncronously #39086

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
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
32 changes: 29 additions & 3 deletions dev-infra/caretaker/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
load("@npm//@bazel/typescript:index.bzl", "ts_library")
load("//tools:defaults.bzl", "jasmine_node_test")

ts_library(
name = "caretaker",
srcs = glob([
"**/*.ts",
]),
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/dev-infra-private/caretaker",
visibility = ["//dev-infra:__subpackages__"],
deps = [
Expand All @@ -20,3 +22,27 @@ ts_library(
"@npm//yargs",
],
)

ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
":caretaker",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"//dev-infra/utils/testing",
"@npm//@types/jasmine",
"@npm//@types/node",
"@npm//@types/semver",
"@npm//semver",
],
)

jasmine_node_test(
name = "test",
bootstrap = ["//tools/testing:node_no_angular_es5"],
deps = [
":test_lib",
],
)
40 changes: 40 additions & 0 deletions dev-infra/caretaker/check/base.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {BaseModule} from './base';

/** Data mocking as the "retrieved data". */
const exampleData = 'this is example data' as const;

/** A simple usage of the BaseModule to illustrate the workings built into the abstract class. */
class ConcreteBaseModule extends BaseModule<typeof exampleData> {
async retrieveData() {
return exampleData;
}
async printToTerminal() {}
}

describe('BaseModule', () => {
let retrieveDataSpy: jasmine.Spy;

beforeEach(() => {
retrieveDataSpy = spyOn(ConcreteBaseModule.prototype, 'retrieveData');
});

it('begins retrieving data during construction', () => {
new ConcreteBaseModule({} as any, {} as any);

expect(retrieveDataSpy).toHaveBeenCalled();
});

it('makes the data available via the data attribute', async () => {
retrieveDataSpy.and.callThrough();
const module = new ConcreteBaseModule({} as any, {} as any);

expect(await module.data).toBe(exampleData);
});
});
25 changes: 25 additions & 0 deletions dev-infra/caretaker/check/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {NgDevConfig} from '../../utils/config';
import {GitClient} from '../../utils/git/index';
import {CaretakerConfig} from '../config';

/** The BaseModule to extend modules for caretaker checks from. */
export abstract class BaseModule<Data> {
/** The data for the module. */
readonly data = this.retrieveData();

constructor(
protected git: GitClient, protected config: NgDevConfig<{caretaker: CaretakerConfig}>) {}

/** Asyncronously retrieve data for the module. */
protected abstract async retrieveData(): Promise<Data>;

/** Print the information discovered for the module to the terminal. */
abstract async printToTerminal(): Promise<void>;
}
30 changes: 21 additions & 9 deletions dev-infra/caretaker/check/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
import {GitClient} from '../../utils/git/index';
import {getCaretakerConfig} from '../config';

import {printCiStatus} from './ci';
import {printG3Comparison} from './g3';
import {printGithubTasks} from './github';
import {printServiceStatuses} from './services';
import {CiModule} from './ci';
import {G3Module} from './g3';
import {GithubQueriesModule} from './github';
import {ServicesModule} from './services';

/** List of modules checked for the caretaker check command. */
const moduleList = [
GithubQueriesModule,
ServicesModule,
CiModule,
G3Module,
];

/** Check the status of services which Angular caretakers need to monitor. */
export async function checkServiceStatuses(githubToken: string) {
Expand All @@ -23,10 +30,15 @@ export async function checkServiceStatuses(githubToken: string) {
const git = new GitClient(githubToken, config);
// Prevent logging of the git commands being executed during the check.
GitClient.LOG_COMMANDS = false;
/** List of instances of Caretaker Check modules */
const caretakerCheckModules = moduleList.map(module => new module(git, config));

// TODO(josephperrott): Allow these checks to be loaded in parallel.
await printServiceStatuses();
await printGithubTasks(git, config.caretaker);
await printG3Comparison(git);
await printCiStatus(git);
// Module's `data` is casted as Promise<unknown> because the data types of the `module`'s `data`
// promises do not match typings, however our usage here is only to determine when the promise
// resolves.
await Promise.all(caretakerCheckModules.map(module => module.data as Promise<unknown>));

for (const module of caretakerCheckModules) {
await module.printToTerminal();
}
}
118 changes: 118 additions & 0 deletions dev-infra/caretaker/check/ci.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {SemVer} from 'semver';
import {ReleaseTrain} from '../../release/versioning';

import * as versioning from '../../release/versioning/active-release-trains';
import * as console from '../../utils/console';
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing';

import {CiModule} from './ci';

describe('CiModule', () => {
let fetchActiveReleaseTrainsSpy: jasmine.Spy;
let getBranchStatusFromCiSpy: jasmine.Spy;
let infoSpy: jasmine.Spy;
let debugSpy: jasmine.Spy;
let virtualGitClient: VirtualGitClient;

beforeEach(() => {
virtualGitClient = buildVirtualGitClient();
fetchActiveReleaseTrainsSpy = spyOn(versioning, 'fetchActiveReleaseTrains');
getBranchStatusFromCiSpy = spyOn(CiModule.prototype, 'getBranchStatusFromCi' as any);
infoSpy = spyOn(console, 'info');
debugSpy = spyOn(console, 'debug');
});

describe('getting data for active trains', () => {
it('handles active rc train', async () => {
const trains = buildMockActiveReleaseTrains(true);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
await module.data;

expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.releaseCandidate.branchName);
expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.latest.branchName);
expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.next.branchName);
expect(getBranchStatusFromCiSpy).toHaveBeenCalledTimes(3);
});

it('handles an inactive rc train', async () => {
const trains = buildMockActiveReleaseTrains(false);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
await module.data;

expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.latest.branchName);
expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.next.branchName);
expect(getBranchStatusFromCiSpy).toHaveBeenCalledTimes(2);
});

it('aggregates information into a useful structure', async () => {
const trains = buildMockActiveReleaseTrains(false);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
getBranchStatusFromCiSpy.and.returnValue('success');
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
const data = await module.data;

expect(data[0]).toEqual(
{active: false, name: 'releaseCandidate', label: '', status: 'not found'});
expect(data[1]).toEqual({
active: true,
name: 'latest-branch',
label: 'latest (latest-branch)',
status: 'success',
});
});
});

it('prints the data retrieved', async () => {
const fakeData = Promise.resolve([
{
active: true,
name: 'name0',
label: 'label0',
status: 'success',
},
{
active: false,
name: 'name1',
label: 'label1',
status: 'failed',
},
]);
fetchActiveReleaseTrainsSpy.and.resolveTo([]);

const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData});

await module.printToTerminal();

expect(debugSpy).toHaveBeenCalledWith('No active release train for name1');
expect(infoSpy).toHaveBeenCalledWith('label0 ✅');
});
});


/** Build a mock set of ActiveReleaseTrains. */
function buildMockActiveReleaseTrains(withRc: false): versioning.ActiveReleaseTrains&
{releaseCandidate: null};
function buildMockActiveReleaseTrains(withRc: true): versioning.ActiveReleaseTrains&
{releaseCandidate: ReleaseTrain};
function buildMockActiveReleaseTrains(withRc: boolean): versioning.ActiveReleaseTrains {
const baseResult = {
isMajor: false,
version: new SemVer('0.0.0'),
};
return {
releaseCandidate: withRc ? {branchName: 'rc-branch', ...baseResult} : null,
latest: {branchName: 'latest-branch', ...baseResult},
next: {branchName: 'next-branch', ...baseResult}
};
}
106 changes: 66 additions & 40 deletions dev-infra/caretaker/check/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,82 @@
*/

import fetch from 'node-fetch';
import {fetchActiveReleaseTrains} from '../../release/versioning/index';
import {fetchActiveReleaseTrains, ReleaseTrain} from '../../release/versioning/index';

import {bold, debug, info} from '../../utils/console';
import {GitClient} from '../../utils/git/index';
import {BaseModule} from './base';


/** The results of checking the status of CI. */
interface StatusCheckResult {
status: 'success'|'failed';
}
/** The result of checking a branch on CI. */
type CiBranchStatus = 'success'|'failed'|'not found';

/** Retrieve and log status of CI for the project. */
export async function printCiStatus(git: GitClient) {
const releaseTrains = await fetchActiveReleaseTrains({api: git.github, ...git.remoteConfig});
/** A list of results for checking CI branches. */
type CiData = {
active: boolean,
name: string,
label: string,
status: CiBranchStatus,
}[];

info.group(bold(`CI`));
for (const [trainName, train] of Object.entries(releaseTrains)) {
if (train === null) {
debug(`No active release train for ${trainName}`);
continue;
}
const status = await getStatusOfBranch(git, train.branchName);
await printStatus(`${trainName.padEnd(6)} (${train.branchName})`, status);
export class CiModule extends BaseModule<CiData> {
async retrieveData() {
const gitRepoWithApi = {api: this.git.github, ...this.git.remoteConfig};
const releaseTrains = await fetchActiveReleaseTrains(gitRepoWithApi);

const ciResultPromises = Object.entries(releaseTrains).map(async ([trainName, train]: [
string, ReleaseTrain|null
]) => {
if (train === null) {
return {
active: false,
name: trainName,
label: '',
status: 'not found' as const,
};
}

return {
active: true,
name: train.branchName,
label: `${trainName} (${train.branchName})`,
status: await this.getBranchStatusFromCi(train.branchName),
};
});

return await Promise.all(ciResultPromises);
}
info.groupEnd();
info();
}

/** Log the status of CI for a given branch to the console. */
async function printStatus(label: string, status: StatusCheckResult|null) {
const branchName = label.padEnd(16);
if (status === null) {
info(`${branchName} was not found on CircleCI`);
} else if (status.status === 'success') {
info(`${branchName} ✅`);
} else {
info(`${branchName} ❌`);
async printToTerminal() {
const data = await this.data;
const minLabelLength = Math.max(...data.map(result => result.label.length));
info.group(bold(`CI`));
data.forEach(result => {
if (result.active === false) {
debug(`No active release train for ${result.name}`);
return;
}
const label = result.label.padEnd(minLabelLength);
if (result.status === 'not found') {
info(`${result.name} was not found on CircleCI`);
} else if (result.status === 'success') {
info(`${label} ✅`);
} else {
info(`${label} ❌`);
}
});
info.groupEnd();
info();
}
}

/** Get the CI status of a given branch from CircleCI. */
async function getStatusOfBranch(git: GitClient, branch: string): Promise<StatusCheckResult|null> {
const {owner, name} = git.remoteConfig;
const url = `https://circleci.com/gh/${owner}/${name}/tree/${branch}.svg?style=shield`;
const result = await fetch(url).then(result => result.text());
/** Get the CI status of a given branch from CircleCI. */
private async getBranchStatusFromCi(branch: string): Promise<CiBranchStatus> {
const {owner, name} = this.git.remoteConfig;
const url = `https://circleci.com/gh/${owner}/${name}/tree/${branch}.svg?style=shield`;
const result = await fetch(url).then(result => result.text());

if (result && !result.includes('no builds')) {
return {
status: result.includes('passing') ? 'success' : 'failed',
};
if (result && !result.includes('no builds')) {
return result.includes('passing') ? 'success' : 'failed';
}
return 'not found';
}
return null;
}
Loading