fix(dev-infra): run caretaker checks asyncronously (#39086)
Run each check in the caretaker check process asyncronously. PR Close #39086
This commit is contained in:
parent
1e91f84697
commit
2d79780384
|
@ -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 = [
|
||||
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>;
|
||||
}
|
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
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);
|
||||
}
|
||||
|
||||
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. */
|
||||
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 result.includes('passing') ? 'success' : 'failed';
|
||||
}
|
||||
const status = await getStatusOfBranch(git, train.branchName);
|
||||
await printStatus(`${trainName.padEnd(6)} (${train.branchName})`, status);
|
||||
}
|
||||
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} ❌`);
|
||||
return 'not found';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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());
|
||||
|
||||
if (result && !result.includes('no builds')) {
|
||||
return {
|
||||
status: result.includes('passing') ? 'success' : 'failed',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
|
||||
/**
|
||||
* @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 {SpawnSyncReturns} from 'child_process';
|
||||
|
||||
import * as console from '../../utils/console';
|
||||
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing';
|
||||
|
||||
import {G3Module, G3StatsData} from './g3';
|
||||
|
||||
describe('G3Module', () => {
|
||||
let getG3FileIncludeAndExcludeLists: jasmine.Spy;
|
||||
let getLatestShas: jasmine.Spy;
|
||||
let getDiffStats: jasmine.Spy;
|
||||
let infoSpy: jasmine.Spy;
|
||||
let virtualGitClient: VirtualGitClient;
|
||||
|
||||
beforeEach(() => {
|
||||
virtualGitClient = buildVirtualGitClient();
|
||||
getG3FileIncludeAndExcludeLists =
|
||||
spyOn(G3Module.prototype, 'getG3FileIncludeAndExcludeLists' as any).and.returnValue(null);
|
||||
getLatestShas = spyOn(G3Module.prototype, 'getLatestShas' as any).and.returnValue(null);
|
||||
getDiffStats = spyOn(G3Module.prototype, 'getDiffStats' as any).and.returnValue(null);
|
||||
infoSpy = spyOn(console, 'info');
|
||||
});
|
||||
|
||||
describe('gathering stats', () => {
|
||||
it('unless the g3 merge config is not defined in the angular robot file', async () => {
|
||||
getG3FileIncludeAndExcludeLists.and.returnValue(null);
|
||||
getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'});
|
||||
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
|
||||
expect(getDiffStats).not.toHaveBeenCalled();
|
||||
expect(await module.data).toBe(undefined);
|
||||
});
|
||||
|
||||
it('unless the branch shas are not able to be retrieved', async () => {
|
||||
getLatestShas.and.returnValue(null);
|
||||
getG3FileIncludeAndExcludeLists.and.returnValue({include: ['file1'], exclude: []});
|
||||
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
|
||||
expect(getDiffStats).not.toHaveBeenCalled();
|
||||
expect(await module.data).toBe(undefined);
|
||||
});
|
||||
|
||||
it('for the files which are being synced to g3', async () => {
|
||||
getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'});
|
||||
getG3FileIncludeAndExcludeLists.and.returnValue({include: ['project1/*'], exclude: []});
|
||||
getDiffStats.and.callThrough();
|
||||
spyOn(virtualGitClient, 'run').and.callFake((args: string[]): any => {
|
||||
const output: Partial<SpawnSyncReturns<string>> = {};
|
||||
if (args[0] === 'rev-list') {
|
||||
output.stdout = '3';
|
||||
}
|
||||
if (args[0] === 'diff') {
|
||||
output.stdout = '5\t6\tproject1/file1\n2\t3\tproject2/file2\n7\t1\tproject1/file3\n';
|
||||
}
|
||||
return output;
|
||||
});
|
||||
|
||||
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
const {insertions, deletions, files, commits} = (await module.data) as G3StatsData;
|
||||
|
||||
expect(insertions).toBe(12);
|
||||
expect(deletions).toBe(7);
|
||||
expect(files).toBe(2);
|
||||
expect(commits).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('printing the data retrieved', () => {
|
||||
it('if files are discovered needing to sync', async () => {
|
||||
const fakeData = Promise.resolve({
|
||||
insertions: 25,
|
||||
deletions: 10,
|
||||
files: 2,
|
||||
commits: 2,
|
||||
});
|
||||
|
||||
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
Object.defineProperty(module, 'data', {value: fakeData});
|
||||
await module.printToTerminal();
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'2 files changed, 25 insertions(+), 10 deletions(-) from 2 commits will be included in the next sync');
|
||||
});
|
||||
|
||||
it('if no files need to sync', async () => {
|
||||
const fakeData = Promise.resolve({
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
commits: 25,
|
||||
});
|
||||
|
||||
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
Object.defineProperty(module, 'data', {value: fakeData});
|
||||
await module.printToTerminal();
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledWith('25 commits between g3 and master');
|
||||
expect(infoSpy).toHaveBeenCalledWith('✅ No sync is needed at this time');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -10,76 +10,71 @@ import {existsSync, readFileSync} from 'fs';
|
|||
import * as multimatch from 'multimatch';
|
||||
import {join} from 'path';
|
||||
import {parse as parseYaml} from 'yaml';
|
||||
|
||||
import {getRepoBaseDir} from '../../utils/config';
|
||||
import {bold, debug, info} from '../../utils/console';
|
||||
import {GitClient} from '../../utils/git/index';
|
||||
import {bold, debug, error, info} from '../../utils/console';
|
||||
|
||||
/** Compare the upstream master to the upstream g3 branch, if it exists. */
|
||||
export async function printG3Comparison(git: GitClient) {
|
||||
const angularRobotFilePath = join(getRepoBaseDir(), '.github/angular-robot.yml');
|
||||
if (!existsSync(angularRobotFilePath)) {
|
||||
return debug('No angular robot configuration file exists, skipping.');
|
||||
import {BaseModule} from './base';
|
||||
|
||||
/** Information expressing the difference between the master and g3 branches */
|
||||
export interface G3StatsData {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
files: number;
|
||||
commits: number;
|
||||
}
|
||||
|
||||
export class G3Module extends BaseModule<G3StatsData|void> {
|
||||
async retrieveData() {
|
||||
const toCopyToG3 = this.getG3FileIncludeAndExcludeLists();
|
||||
const latestSha = this.getLatestShas();
|
||||
|
||||
if (toCopyToG3 === null || latestSha === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.getDiffStats(
|
||||
latestSha.g3, latestSha.master, toCopyToG3.include, toCopyToG3.exclude);
|
||||
}
|
||||
|
||||
/** The configuration defined for the angular robot. */
|
||||
const robotConfig = parseYaml(readFileSync(angularRobotFilePath).toString());
|
||||
/** The files to be included in the g3 sync. */
|
||||
const includeFiles = robotConfig?.merge?.g3Status?.include || [];
|
||||
/** The files to be expected in the g3 sync. */
|
||||
const excludeFiles = robotConfig?.merge?.g3Status?.exclude || [];
|
||||
|
||||
if (includeFiles.length === 0 && excludeFiles.length === 0) {
|
||||
debug('No g3Status include or exclude lists are defined in the angular robot configuration,');
|
||||
debug('skipping.');
|
||||
return;
|
||||
async printToTerminal() {
|
||||
const stats = await this.data;
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
info.group(bold('g3 branch check'));
|
||||
if (stats.files === 0) {
|
||||
info(`${stats.commits} commits between g3 and master`);
|
||||
info('✅ No sync is needed at this time');
|
||||
} else {
|
||||
info(
|
||||
`${stats.files} files changed, ${stats.insertions} insertions(+), ${stats.deletions} ` +
|
||||
`deletions(-) from ${stats.commits} commits will be included in the next sync`);
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
}
|
||||
|
||||
/** The latest sha for the g3 branch. */
|
||||
const g3Ref = getShaForBranchLatest('g3');
|
||||
/** The latest sha for the master branch. */
|
||||
const masterRef = getShaForBranchLatest('master');
|
||||
|
||||
if (!g3Ref && !masterRef) {
|
||||
return debug('Exiting early as either the g3 or master was unable to be retrieved');
|
||||
}
|
||||
|
||||
/** The statistical information about the git diff between master and g3. */
|
||||
const stats = getDiffStats();
|
||||
|
||||
info.group(bold('g3 branch check'));
|
||||
info(`${stats.commits} commits between g3 and master`);
|
||||
if (stats.files === 0) {
|
||||
info('✅ No sync is needed at this time');
|
||||
} else {
|
||||
info(`${stats.files} files changed, ${stats.insertions} insertions(+), ${
|
||||
stats.deletions} deletions(-) will be included in the next sync`);
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
|
||||
|
||||
/** Fetch and retrieve the latest sha for a specific branch. */
|
||||
function getShaForBranchLatest(branch: string) {
|
||||
private getShaForBranchLatest(branch: string) {
|
||||
const {owner, name} = this.git.remoteConfig;
|
||||
/** The result fo the fetch command. */
|
||||
const fetchResult = git.runGraceful([
|
||||
'fetch', '-q', `https://github.com/${git.remoteConfig.owner}/${git.remoteConfig.name}.git`,
|
||||
branch
|
||||
]);
|
||||
const fetchResult =
|
||||
this.git.runGraceful(['fetch', '-q', `https://github.com/${owner}/${name}.git`, branch]);
|
||||
|
||||
if (fetchResult.status !== 0 &&
|
||||
fetchResult.stderr.includes(`couldn't find remote ref ${branch}`)) {
|
||||
debug(`No '${branch}' branch exists on upstream, skipping.`);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return git.runGraceful(['rev-parse', 'FETCH_HEAD']).stdout.trim();
|
||||
return this.git.runGraceful(['rev-parse', 'FETCH_HEAD']).stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git diff stats between master and g3, for all files and filtered to only g3 affecting
|
||||
* files.
|
||||
*/
|
||||
function getDiffStats() {
|
||||
private getDiffStats(
|
||||
g3Ref: string, masterRef: string, includeFiles: string[], excludeFiles: string[]) {
|
||||
/** The diff stats to be returned. */
|
||||
const stats = {
|
||||
insertions: 0,
|
||||
|
@ -89,10 +84,11 @@ export async function printG3Comparison(git: GitClient) {
|
|||
};
|
||||
|
||||
// Determine the number of commits between master and g3 refs. */
|
||||
stats.commits = parseInt(git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10);
|
||||
stats.commits =
|
||||
parseInt(this.git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10);
|
||||
|
||||
// Get the numstat information between master and g3
|
||||
git.run(['diff', `${g3Ref}...${masterRef}`, '--numstat'])
|
||||
this.git.run(['diff', `${g3Ref}...${masterRef}`, '--numstat'])
|
||||
.stdout
|
||||
// Remove the extra space after git's output.
|
||||
.trim()
|
||||
|
@ -100,7 +96,7 @@ export async function printG3Comparison(git: GitClient) {
|
|||
.split('\n')
|
||||
// Split each line from the git output into components parts: insertions,
|
||||
// deletions and file name respectively
|
||||
.map(line => line.split('\t'))
|
||||
.map(line => line.trim().split('\t'))
|
||||
// Parse number value from the insertions and deletions values
|
||||
// Example raw line input:
|
||||
// 10\t5\tsrc/file/name.ts
|
||||
|
@ -108,7 +104,7 @@ export async function printG3Comparison(git: GitClient) {
|
|||
// Add each line's value to the diff stats, and conditionally to the g3
|
||||
// stats as well if the file name is included in the files synced to g3.
|
||||
.forEach(([insertions, deletions, fileName]) => {
|
||||
if (checkMatchAgainstIncludeAndExclude(fileName, includeFiles, excludeFiles)) {
|
||||
if (this.checkMatchAgainstIncludeAndExclude(fileName, includeFiles, excludeFiles)) {
|
||||
stats.insertions += insertions;
|
||||
stats.deletions += deletions;
|
||||
stats.files += 1;
|
||||
|
@ -116,12 +112,46 @@ export async function printG3Comparison(git: GitClient) {
|
|||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Determine whether the file name passes both include and exclude checks. */
|
||||
function checkMatchAgainstIncludeAndExclude(
|
||||
file: string, includes: string[], excludes: string[]) {
|
||||
private checkMatchAgainstIncludeAndExclude(file: string, includes: string[], excludes: string[]) {
|
||||
return (
|
||||
multimatch.call(undefined, file, includes).length >= 1 &&
|
||||
multimatch.call(undefined, file, excludes).length === 0);
|
||||
}
|
||||
|
||||
|
||||
private getG3FileIncludeAndExcludeLists() {
|
||||
const angularRobotFilePath = join(getRepoBaseDir(), '.github/angular-robot.yml');
|
||||
if (!existsSync(angularRobotFilePath)) {
|
||||
debug('No angular robot configuration file exists, skipping.');
|
||||
return null;
|
||||
}
|
||||
/** The configuration defined for the angular robot. */
|
||||
const robotConfig = parseYaml(readFileSync(angularRobotFilePath).toString());
|
||||
/** The files to be included in the g3 sync. */
|
||||
const include: string[] = robotConfig?.merge?.g3Status?.include || [];
|
||||
/** The files to be expected in the g3 sync. */
|
||||
const exclude: string[] = robotConfig?.merge?.g3Status?.exclude || [];
|
||||
|
||||
if (include.length === 0 && exclude.length === 0) {
|
||||
debug('No g3Status include or exclude lists are defined in the angular robot configuration');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {include, exclude};
|
||||
}
|
||||
|
||||
private getLatestShas() {
|
||||
/** The latest sha for the g3 branch. */
|
||||
const g3 = this.getShaForBranchLatest('g3');
|
||||
/** The latest sha for the master branch. */
|
||||
const master = this.getShaForBranchLatest('master');
|
||||
|
||||
if (g3 === null || master === null) {
|
||||
debug('Either the g3 or master was unable to be retrieved');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {g3, master};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
|
||||
/**
|
||||
* @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 * as console from '../../utils/console';
|
||||
import {GithubGraphqlClient} from '../../utils/git/github';
|
||||
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing';
|
||||
|
||||
import {GithubQueriesModule} from './github';
|
||||
|
||||
describe('GithubQueriesModule', () => {
|
||||
let githubApiSpy: jasmine.Spy;
|
||||
let infoSpy: jasmine.Spy;
|
||||
let infoGroupSpy: jasmine.Spy;
|
||||
let virtualGitClient: VirtualGitClient;
|
||||
|
||||
beforeEach(() => {
|
||||
githubApiSpy = spyOn(GithubGraphqlClient.prototype, 'query')
|
||||
.and.throwError(
|
||||
'The graphql query response must always be manually defined in a test.');
|
||||
virtualGitClient = buildVirtualGitClient();
|
||||
infoGroupSpy = spyOn(console.info, 'group');
|
||||
infoSpy = spyOn(console, 'info');
|
||||
});
|
||||
|
||||
describe('gathering stats', () => {
|
||||
it('unless githubQueries are `undefined`', async () => {
|
||||
const module = new GithubQueriesModule(
|
||||
virtualGitClient, {...mockNgDevConfig, caretaker: {githubQueries: undefined}});
|
||||
|
||||
expect(await module.data).toBe(undefined);
|
||||
});
|
||||
|
||||
it('unless githubQueries are an empty array', async () => {
|
||||
const module = new GithubQueriesModule(
|
||||
virtualGitClient, {...mockNgDevConfig, caretaker: {githubQueries: []}});
|
||||
|
||||
expect(await module.data).toBe(undefined);
|
||||
});
|
||||
|
||||
it('for the requestd Github queries', async () => {
|
||||
githubApiSpy.and.returnValue({
|
||||
'keynamewithspaces': {issueCount: 1, nodes: [{url: 'http://gituhb.com/owner/name/issue/1'}]}
|
||||
});
|
||||
const module = new GithubQueriesModule(virtualGitClient, {
|
||||
...mockNgDevConfig,
|
||||
caretaker: {githubQueries: [{name: 'key name with spaces', query: 'issue: yes'}]}
|
||||
});
|
||||
|
||||
expect(await module.data).toEqual([{
|
||||
queryName: 'key name with spaces',
|
||||
count: 1,
|
||||
queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes',
|
||||
matchedUrls: ['http://gituhb.com/owner/name/issue/1'],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('printing the data retrieved', () => {
|
||||
it('if there are no matches of the query', async () => {
|
||||
const fakeData = Promise.resolve([
|
||||
{
|
||||
queryName: 'query1',
|
||||
count: 0,
|
||||
queryUrl: 'https://github.com/owner/name/issues?q=issue:%20no',
|
||||
matchedUrls: [],
|
||||
},
|
||||
{
|
||||
queryName: 'query2',
|
||||
count: 0,
|
||||
queryUrl: 'https://github.com/owner/name/issues?q=something',
|
||||
matchedUrls: [],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
const module = new GithubQueriesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
Object.defineProperty(module, 'data', {value: fakeData});
|
||||
|
||||
await module.printToTerminal();
|
||||
|
||||
|
||||
expect(infoGroupSpy).toHaveBeenCalledWith('Github Tasks');
|
||||
expect(infoSpy).toHaveBeenCalledWith('query1 0');
|
||||
expect(infoSpy).toHaveBeenCalledWith('query2 0');
|
||||
});
|
||||
|
||||
it('if there are maches of the query', async () => {
|
||||
const fakeData = Promise.resolve([
|
||||
{
|
||||
queryName: 'query1',
|
||||
count: 1,
|
||||
queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes',
|
||||
matchedUrls: ['http://gituhb.com/owner/name/issue/1'],
|
||||
},
|
||||
{
|
||||
queryName: 'query2',
|
||||
count: 0,
|
||||
queryUrl: 'https://github.com/owner/name/issues?q=something',
|
||||
matchedUrls: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const module = new GithubQueriesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
Object.defineProperty(module, 'data', {value: fakeData});
|
||||
|
||||
await module.printToTerminal();
|
||||
|
||||
expect(infoGroupSpy).toHaveBeenCalledWith('Github Tasks');
|
||||
expect(infoSpy).toHaveBeenCalledWith('query1 1');
|
||||
expect(infoGroupSpy)
|
||||
.toHaveBeenCalledWith('https://github.com/owner/name/issues?q=issue:%20yes');
|
||||
expect(infoSpy).toHaveBeenCalledWith('- http://gituhb.com/owner/name/issue/1');
|
||||
expect(infoSpy).toHaveBeenCalledWith('query2 0');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -9,75 +9,112 @@
|
|||
import {alias, onUnion, params, types} from 'typed-graphqlify';
|
||||
|
||||
import {bold, debug, info} from '../../utils/console';
|
||||
import {GitClient} from '../../utils/git/index';
|
||||
import {CaretakerConfig} from '../config';
|
||||
import {BaseModule} from './base';
|
||||
|
||||
/** A list of generated results for a github query. */
|
||||
type GithubQueryResults = {
|
||||
queryName: string,
|
||||
count: number,
|
||||
queryUrl: string,
|
||||
matchedUrls: string[],
|
||||
}[];
|
||||
|
||||
/** The fragment for a result from Github's api for a Github query. */
|
||||
const GithubQueryResultFragment = {
|
||||
issueCount: types.number,
|
||||
nodes: [{...onUnion({
|
||||
PullRequest: {
|
||||
url: types.string,
|
||||
},
|
||||
Issue: {
|
||||
url: types.string,
|
||||
},
|
||||
})}],
|
||||
};
|
||||
|
||||
/** An object containing results of multiple queries. */
|
||||
type GithubQueryResult = {
|
||||
[key: string]: typeof GithubQueryResultFragment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cap the returned issues in the queries to an arbitrary 100. At that point, caretaker has a lot
|
||||
* Cap the returned issues in the queries to an arbitrary 20. At that point, caretaker has a lot
|
||||
* of work to do and showing more than that isn't really useful.
|
||||
*/
|
||||
const MAX_RETURNED_ISSUES = 20;
|
||||
|
||||
/** Retrieve the number of matching issues for each github query. */
|
||||
export async function printGithubTasks(git: GitClient, config?: CaretakerConfig) {
|
||||
if (!config?.githubQueries?.length) {
|
||||
debug('No github queries defined in the configuration, skipping.');
|
||||
return;
|
||||
}
|
||||
info.group(bold(`Github Tasks`));
|
||||
await getGithubInfo(git, config);
|
||||
info.groupEnd();
|
||||
info();
|
||||
}
|
||||
export class GithubQueriesModule extends BaseModule<GithubQueryResults|void> {
|
||||
async retrieveData() {
|
||||
// Non-null assertion is used here as the check for undefined immediately follows to confirm the
|
||||
// assertion. Typescript's type filtering does not seem to work as needed to understand
|
||||
// whether githubQueries is undefined or not.
|
||||
let queries = this.config.caretaker?.githubQueries!;
|
||||
if (queries === undefined || queries.length === 0) {
|
||||
debug('No github queries defined in the configuration, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
/** Retrieve query match counts and log discovered counts to the console. */
|
||||
async function getGithubInfo(git: GitClient, {githubQueries: queries = []}: CaretakerConfig) {
|
||||
/** The query object for graphql. */
|
||||
const graphQlQuery: {
|
||||
[key: string]: {
|
||||
issueCount: number,
|
||||
nodes: Array<{url: string}>,
|
||||
/** The results of the generated github query. */
|
||||
const queryResult = await this.git.github.graphql.query(this.buildGraphqlQuery(queries));
|
||||
const results = Object.values(queryResult);
|
||||
|
||||
const {owner, name: repo} = this.git.remoteConfig;
|
||||
|
||||
return results.map((result, i) => {
|
||||
return {
|
||||
queryName: queries[i].name,
|
||||
count: result.issueCount,
|
||||
queryUrl: encodeURI(`https://github.com/${owner}/${repo}/issues?q=${queries[i].query}`),
|
||||
matchedUrls: result.nodes.map(node => node.url)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a Graphql query statement for the provided queries. */
|
||||
private buildGraphqlQuery(queries: NonNullable<CaretakerConfig['githubQueries']>) {
|
||||
/** The query object for graphql. */
|
||||
const graphQlQuery: GithubQueryResult = {};
|
||||
const {owner, name: repo} = this.git.remoteConfig;
|
||||
/** The Github search filter for the configured repository. */
|
||||
const repoFilter = `repo:${owner}/${repo}`;
|
||||
|
||||
|
||||
queries.forEach(({name, query}) => {
|
||||
/** The name of the query, with spaces removed to match GraphQL requirements. */
|
||||
const queryKey = alias(name.replace(/ /g, ''), 'search');
|
||||
graphQlQuery[queryKey] = params(
|
||||
{
|
||||
type: 'ISSUE',
|
||||
first: MAX_RETURNED_ISSUES,
|
||||
query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`,
|
||||
},
|
||||
{...GithubQueryResultFragment});
|
||||
});
|
||||
|
||||
return graphQlQuery;
|
||||
}
|
||||
|
||||
async printToTerminal() {
|
||||
const queryResults = await this.data;
|
||||
if (!queryResults) {
|
||||
return;
|
||||
}
|
||||
} = {};
|
||||
/** The Github search filter for the configured repository. */
|
||||
const repoFilter = `repo:${git.remoteConfig.owner}/${git.remoteConfig.name}`;
|
||||
queries.forEach(({name, query}) => {
|
||||
/** The name of the query, with spaces removed to match GraphQL requirements. */
|
||||
const queryKey = alias(name.replace(/ /g, ''), 'search');
|
||||
graphQlQuery[queryKey] = params(
|
||||
{
|
||||
type: 'ISSUE',
|
||||
first: MAX_RETURNED_ISSUES,
|
||||
query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`,
|
||||
},
|
||||
{
|
||||
issueCount: types.number,
|
||||
nodes: [{...onUnion({
|
||||
PullRequest: {
|
||||
url: types.string,
|
||||
},
|
||||
Issue: {
|
||||
url: types.string,
|
||||
},
|
||||
})}],
|
||||
},
|
||||
);
|
||||
});
|
||||
/** The results of the generated github query. */
|
||||
const results = await git.github.graphql.query(graphQlQuery);
|
||||
Object.values(results).forEach((result, i) => {
|
||||
info(`${queries[i]?.name.padEnd(25)} ${result.issueCount}`);
|
||||
if (result.issueCount > 0) {
|
||||
const {owner, name: repo} = git.remoteConfig;
|
||||
const url = encodeURI(`https://github.com/${owner}/${repo}/issues?q=${queries[i]?.query}`);
|
||||
info.group(`${url}`);
|
||||
if (result.nodes.length === MAX_RETURNED_ISSUES && result.nodes.length < result.issueCount) {
|
||||
info(`(first ${MAX_RETURNED_ISSUES})`);
|
||||
info.group(bold('Github Tasks'));
|
||||
const minQueryNameLength = Math.max(...queryResults.map(result => result.queryName.length));
|
||||
for (const queryResult of queryResults) {
|
||||
info(`${queryResult.queryName.padEnd(minQueryNameLength)} ${queryResult.count}`);
|
||||
|
||||
if (queryResult.count > 0) {
|
||||
info.group(queryResult.queryUrl);
|
||||
queryResult.matchedUrls.forEach(url => info(`- ${url}`));
|
||||
if (queryResult.count > MAX_RETURNED_ISSUES) {
|
||||
info(`... ${queryResult.count - MAX_RETURNED_ISSUES} additional matches`);
|
||||
}
|
||||
info.groupEnd();
|
||||
}
|
||||
for (const node of result.nodes) {
|
||||
info(`- ${node.url}`);
|
||||
}
|
||||
info.groupEnd();
|
||||
}
|
||||
});
|
||||
info.groupEnd();
|
||||
info();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
|
||||
/**
|
||||
* @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 * as console from '../../utils/console';
|
||||
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing';
|
||||
|
||||
import {services, ServicesModule} from './services';
|
||||
|
||||
describe('ServicesModule', () => {
|
||||
let getStatusFromStandardApiSpy: jasmine.Spy;
|
||||
let infoSpy: jasmine.Spy;
|
||||
let infoGroupSpy: jasmine.Spy;
|
||||
let virtualGitClient: VirtualGitClient;
|
||||
|
||||
services.splice(0, Infinity, {url: 'fakeStatus.com/api.json', name: 'Service Name'});
|
||||
|
||||
beforeEach(() => {
|
||||
getStatusFromStandardApiSpy = spyOn(ServicesModule.prototype, 'getStatusFromStandardApi');
|
||||
virtualGitClient = buildVirtualGitClient();
|
||||
infoGroupSpy = spyOn(console.info, 'group');
|
||||
infoSpy = spyOn(console, 'info');
|
||||
});
|
||||
|
||||
describe('gathering status', () => {
|
||||
it('for each of the services', async () => {
|
||||
new ServicesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
|
||||
expect(getStatusFromStandardApiSpy)
|
||||
.toHaveBeenCalledWith({url: 'fakeStatus.com/api.json', name: 'Service Name'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('printing the data retrieved', () => {
|
||||
it('for each service ', async () => {
|
||||
const fakeData = Promise.resolve([
|
||||
{
|
||||
name: 'Service 1',
|
||||
status: 'passing',
|
||||
description: 'Everything is working great',
|
||||
lastUpdated: new Date(0),
|
||||
},
|
||||
{
|
||||
name: 'Service 2',
|
||||
status: 'failing',
|
||||
description: 'Literally everything is broken',
|
||||
lastUpdated: new Date(0),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
const module = new ServicesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig});
|
||||
Object.defineProperty(module, 'data', {value: fakeData});
|
||||
await module.printToTerminal();
|
||||
|
||||
|
||||
expect(infoGroupSpy).toHaveBeenCalledWith('Service Statuses');
|
||||
expect(infoSpy).toHaveBeenCalledWith('Service 1 ✅');
|
||||
expect(infoGroupSpy)
|
||||
.toHaveBeenCalledWith(`Service 2 ❌ (Updated: ${new Date(0).toLocaleString()})`);
|
||||
expect(infoSpy).toHaveBeenCalledWith(' Details: Literally everything is broken');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,72 +8,74 @@
|
|||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import {bold, green, info, red} from '../../utils/console';
|
||||
import {bold, info} from '../../utils/console';
|
||||
import {BaseModule} from './base';
|
||||
|
||||
/** The status levels for services. */
|
||||
enum ServiceStatus {
|
||||
GREEN,
|
||||
RED
|
||||
interface ServiceConfig {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** The results of checking the status of a service */
|
||||
interface StatusCheckResult {
|
||||
status: ServiceStatus;
|
||||
name: string;
|
||||
status: 'passing'|'failing';
|
||||
description: string;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
/** Retrieve and log stasuses for all of the services of concern. */
|
||||
export async function printServiceStatuses() {
|
||||
info.group(bold(`Service Statuses (checked: ${new Date().toLocaleString()})`));
|
||||
logStatus('CircleCI', await getCircleCiStatus());
|
||||
logStatus('Github', await getGithubStatus());
|
||||
logStatus('NPM', await getNpmStatus());
|
||||
logStatus('Saucelabs', await getSaucelabsStatus());
|
||||
info.groupEnd();
|
||||
info();
|
||||
}
|
||||
/** List of services Angular relies on. */
|
||||
export const services: ServiceConfig[] = [
|
||||
{
|
||||
url: 'https://status.us-west-1.saucelabs.com/api/v2/status.json',
|
||||
name: 'Saucelabs',
|
||||
},
|
||||
{
|
||||
url: 'https://status.npmjs.org/api/v2/status.json',
|
||||
name: 'Npm',
|
||||
},
|
||||
{
|
||||
url: 'https://status.circleci.com/api/v2/status.json',
|
||||
name: 'CircleCi',
|
||||
},
|
||||
{
|
||||
url: 'https://www.githubstatus.com/api/v2/status.json',
|
||||
name: 'Github',
|
||||
},
|
||||
];
|
||||
|
||||
export class ServicesModule extends BaseModule<StatusCheckResult[]> {
|
||||
async retrieveData() {
|
||||
return Promise.all(services.map(service => this.getStatusFromStandardApi(service)));
|
||||
}
|
||||
|
||||
/** Log the status of the service to the console. */
|
||||
function logStatus(serviceName: string, status: StatusCheckResult) {
|
||||
serviceName = serviceName.padEnd(15);
|
||||
if (status.status === ServiceStatus.GREEN) {
|
||||
info(`${serviceName} ${green('✅')}`);
|
||||
} else if (status.status === ServiceStatus.RED) {
|
||||
info.group(`${serviceName} ${red('❌')} (Updated: ${status.lastUpdated.toLocaleString()})`);
|
||||
info(` Details: ${status.description}`);
|
||||
async printToTerminal() {
|
||||
const statuses = await this.data;
|
||||
const serviceNameMinLength = Math.max(...statuses.map(service => service.name.length));
|
||||
info.group(bold('Service Statuses'));
|
||||
for (const status of statuses) {
|
||||
const name = status.name.padEnd(serviceNameMinLength);
|
||||
if (status.status === 'passing') {
|
||||
info(`${name} ✅`);
|
||||
} else {
|
||||
info.group(`${name} ❌ (Updated: ${status.lastUpdated.toLocaleString()})`);
|
||||
info(` Details: ${status.description}`);
|
||||
info.groupEnd();
|
||||
}
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
}
|
||||
|
||||
/** Retrieve the status information for a service which uses a standard API response. */
|
||||
async getStatusFromStandardApi(service: ServiceConfig): Promise<StatusCheckResult> {
|
||||
const result = await fetch(service.url).then(result => result.json());
|
||||
const status = result.status.indicator === 'none' ? 'passing' : 'failing';
|
||||
return {
|
||||
name: service.name,
|
||||
status,
|
||||
description: result.status.description,
|
||||
lastUpdated: new Date(result.page.updated_at)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the service status information for Saucelabs. */
|
||||
async function getSaucelabsStatus(): Promise<StatusCheckResult> {
|
||||
return getStatusFromStandardApi('https://status.us-west-1.saucelabs.com/api/v2/status.json');
|
||||
}
|
||||
|
||||
/** Gets the service status information for NPM. */
|
||||
async function getNpmStatus(): Promise<StatusCheckResult> {
|
||||
return getStatusFromStandardApi('https://status.npmjs.org/api/v2/status.json');
|
||||
}
|
||||
|
||||
/** Gets the service status information for CircleCI. */
|
||||
async function getCircleCiStatus(): Promise<StatusCheckResult> {
|
||||
return getStatusFromStandardApi('https://status.circleci.com/api/v2/status.json');
|
||||
}
|
||||
|
||||
/** Gets the service status information for Github. */
|
||||
async function getGithubStatus(): Promise<StatusCheckResult> {
|
||||
return getStatusFromStandardApi('https://www.githubstatus.com/api/v2/status.json');
|
||||
}
|
||||
|
||||
/** Retrieve the status information for a service which uses a standard API response. */
|
||||
async function getStatusFromStandardApi(url: string) {
|
||||
const result = await fetch(url).then(result => result.json());
|
||||
const status = result.status.indicator === 'none' ? ServiceStatus.GREEN : ServiceStatus.RED;
|
||||
return {
|
||||
status,
|
||||
description: result.status.description,
|
||||
lastUpdated: new Date(result.page.updated_at)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -39,6 +39,6 @@ jasmine_node_test(
|
|||
name = "test",
|
||||
bootstrap = ["//tools/testing:node_no_angular_es5"],
|
||||
deps = [
|
||||
"test_lib",
|
||||
":test_lib",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -1089,58 +1089,14 @@ function getLtsNpmDistTagOfMajor(major) {
|
|||
return `v${major}-lts`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
/** Retrieve and log status of CI for the project. */
|
||||
function printCiStatus(git) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const releaseTrains = yield fetchActiveReleaseTrains(Object.assign({ api: git.github }, git.remoteConfig));
|
||||
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 = yield getStatusOfBranch(git, train.branchName);
|
||||
yield printStatus(`${trainName.padEnd(6)} (${train.branchName})`, status);
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
}
|
||||
/** Log the status of CI for a given branch to the console. */
|
||||
function printStatus(label, status) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
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} ❌`);
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Get the CI status of a given branch from CircleCI. */
|
||||
function getStatusOfBranch(git, branch) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const { owner, name } = git.remoteConfig;
|
||||
const url = `https://circleci.com/gh/${owner}/${name}/tree/${branch}.svg?style=shield`;
|
||||
const result = yield fetch(url).then(result => result.text());
|
||||
if (result && !result.includes('no builds')) {
|
||||
return {
|
||||
status: result.includes('passing') ? 'success' : 'failed',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
/** The BaseModule to extend modules for caretaker checks from. */
|
||||
class BaseModule {
|
||||
constructor(git, config) {
|
||||
this.git = git;
|
||||
this.config = config;
|
||||
/** The data for the module. */
|
||||
this.data = this.retrieveData();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1150,103 +1106,193 @@ function getStatusOfBranch(git, branch) {
|
|||
* 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
|
||||
*/
|
||||
/** Compare the upstream master to the upstream g3 branch, if it exists. */
|
||||
function printG3Comparison(git) {
|
||||
var _a, _b, _c, _d;
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
class CiModule extends BaseModule {
|
||||
retrieveData() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const gitRepoWithApi = Object.assign({ api: this.git.github }, this.git.remoteConfig);
|
||||
const releaseTrains = yield fetchActiveReleaseTrains(gitRepoWithApi);
|
||||
const ciResultPromises = Object.entries(releaseTrains).map(([trainName, train]) => tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
if (train === null) {
|
||||
return {
|
||||
active: false,
|
||||
name: trainName,
|
||||
label: '',
|
||||
status: 'not found',
|
||||
};
|
||||
}
|
||||
return {
|
||||
active: true,
|
||||
name: train.branchName,
|
||||
label: `${trainName} (${train.branchName})`,
|
||||
status: yield this.getBranchStatusFromCi(train.branchName),
|
||||
};
|
||||
}));
|
||||
return yield Promise.all(ciResultPromises);
|
||||
});
|
||||
}
|
||||
printToTerminal() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const data = yield 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. */
|
||||
getBranchStatusFromCi(branch) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const { owner, name } = this.git.remoteConfig;
|
||||
const url = `https://circleci.com/gh/${owner}/${name}/tree/${branch}.svg?style=shield`;
|
||||
const result = yield fetch(url).then(result => result.text());
|
||||
if (result && !result.includes('no builds')) {
|
||||
return result.includes('passing') ? 'success' : 'failed';
|
||||
}
|
||||
return 'not found';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
class G3Module extends BaseModule {
|
||||
retrieveData() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const toCopyToG3 = this.getG3FileIncludeAndExcludeLists();
|
||||
const latestSha = this.getLatestShas();
|
||||
if (toCopyToG3 === null || latestSha === null) {
|
||||
return;
|
||||
}
|
||||
return this.getDiffStats(latestSha.g3, latestSha.master, toCopyToG3.include, toCopyToG3.exclude);
|
||||
});
|
||||
}
|
||||
printToTerminal() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const stats = yield this.data;
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
info.group(bold('g3 branch check'));
|
||||
if (stats.files === 0) {
|
||||
info(`${stats.commits} commits between g3 and master`);
|
||||
info('✅ No sync is needed at this time');
|
||||
}
|
||||
else {
|
||||
info(`${stats.files} files changed, ${stats.insertions} insertions(+), ${stats.deletions} ` +
|
||||
`deletions(-) from ${stats.commits} commits will be included in the next sync`);
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
}
|
||||
/** Fetch and retrieve the latest sha for a specific branch. */
|
||||
getShaForBranchLatest(branch) {
|
||||
const { owner, name } = this.git.remoteConfig;
|
||||
/** The result fo the fetch command. */
|
||||
const fetchResult = this.git.runGraceful(['fetch', '-q', `https://github.com/${owner}/${name}.git`, branch]);
|
||||
if (fetchResult.status !== 0 &&
|
||||
fetchResult.stderr.includes(`couldn't find remote ref ${branch}`)) {
|
||||
debug(`No '${branch}' branch exists on upstream, skipping.`);
|
||||
return null;
|
||||
}
|
||||
return this.git.runGraceful(['rev-parse', 'FETCH_HEAD']).stdout.trim();
|
||||
}
|
||||
/**
|
||||
* Get git diff stats between master and g3, for all files and filtered to only g3 affecting
|
||||
* files.
|
||||
*/
|
||||
getDiffStats(g3Ref, masterRef, includeFiles, excludeFiles) {
|
||||
/** The diff stats to be returned. */
|
||||
const stats = {
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
commits: 0,
|
||||
};
|
||||
// Determine the number of commits between master and g3 refs. */
|
||||
stats.commits =
|
||||
parseInt(this.git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10);
|
||||
// Get the numstat information between master and g3
|
||||
this.git.run(['diff', `${g3Ref}...${masterRef}`, '--numstat'])
|
||||
.stdout
|
||||
// Remove the extra space after git's output.
|
||||
.trim()
|
||||
// Split each line of git output into array
|
||||
.split('\n')
|
||||
// Split each line from the git output into components parts: insertions,
|
||||
// deletions and file name respectively
|
||||
.map(line => line.trim().split('\t'))
|
||||
// Parse number value from the insertions and deletions values
|
||||
// Example raw line input:
|
||||
// 10\t5\tsrc/file/name.ts
|
||||
.map(line => [Number(line[0]), Number(line[1]), line[2]])
|
||||
// Add each line's value to the diff stats, and conditionally to the g3
|
||||
// stats as well if the file name is included in the files synced to g3.
|
||||
.forEach(([insertions, deletions, fileName]) => {
|
||||
if (this.checkMatchAgainstIncludeAndExclude(fileName, includeFiles, excludeFiles)) {
|
||||
stats.insertions += insertions;
|
||||
stats.deletions += deletions;
|
||||
stats.files += 1;
|
||||
}
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
/** Determine whether the file name passes both include and exclude checks. */
|
||||
checkMatchAgainstIncludeAndExclude(file, includes, excludes) {
|
||||
return (multimatch.call(undefined, file, includes).length >= 1 &&
|
||||
multimatch.call(undefined, file, excludes).length === 0);
|
||||
}
|
||||
getG3FileIncludeAndExcludeLists() {
|
||||
var _a, _b, _c, _d;
|
||||
const angularRobotFilePath = path.join(getRepoBaseDir(), '.github/angular-robot.yml');
|
||||
if (!fs.existsSync(angularRobotFilePath)) {
|
||||
return debug('No angular robot configuration file exists, skipping.');
|
||||
debug('No angular robot configuration file exists, skipping.');
|
||||
return null;
|
||||
}
|
||||
/** The configuration defined for the angular robot. */
|
||||
const robotConfig = yaml.parse(fs.readFileSync(angularRobotFilePath).toString());
|
||||
/** The files to be included in the g3 sync. */
|
||||
const includeFiles = ((_b = (_a = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _a === void 0 ? void 0 : _a.g3Status) === null || _b === void 0 ? void 0 : _b.include) || [];
|
||||
const include = ((_b = (_a = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _a === void 0 ? void 0 : _a.g3Status) === null || _b === void 0 ? void 0 : _b.include) || [];
|
||||
/** The files to be expected in the g3 sync. */
|
||||
const excludeFiles = ((_d = (_c = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _c === void 0 ? void 0 : _c.g3Status) === null || _d === void 0 ? void 0 : _d.exclude) || [];
|
||||
if (includeFiles.length === 0 && excludeFiles.length === 0) {
|
||||
debug('No g3Status include or exclude lists are defined in the angular robot configuration,');
|
||||
debug('skipping.');
|
||||
return;
|
||||
const exclude = ((_d = (_c = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _c === void 0 ? void 0 : _c.g3Status) === null || _d === void 0 ? void 0 : _d.exclude) || [];
|
||||
if (include.length === 0 && exclude.length === 0) {
|
||||
debug('No g3Status include or exclude lists are defined in the angular robot configuration');
|
||||
return null;
|
||||
}
|
||||
return { include, exclude };
|
||||
}
|
||||
getLatestShas() {
|
||||
/** The latest sha for the g3 branch. */
|
||||
const g3Ref = getShaForBranchLatest('g3');
|
||||
const g3 = this.getShaForBranchLatest('g3');
|
||||
/** The latest sha for the master branch. */
|
||||
const masterRef = getShaForBranchLatest('master');
|
||||
if (!g3Ref && !masterRef) {
|
||||
return debug('Exiting early as either the g3 or master was unable to be retrieved');
|
||||
const master = this.getShaForBranchLatest('master');
|
||||
if (g3 === null || master === null) {
|
||||
debug('Either the g3 or master was unable to be retrieved');
|
||||
return null;
|
||||
}
|
||||
/** The statistical information about the git diff between master and g3. */
|
||||
const stats = getDiffStats();
|
||||
info.group(bold('g3 branch check'));
|
||||
info(`${stats.commits} commits between g3 and master`);
|
||||
if (stats.files === 0) {
|
||||
info('✅ No sync is needed at this time');
|
||||
}
|
||||
else {
|
||||
info(`${stats.files} files changed, ${stats.insertions} insertions(+), ${stats.deletions} deletions(-) will be included in the next sync`);
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
/** Fetch and retrieve the latest sha for a specific branch. */
|
||||
function getShaForBranchLatest(branch) {
|
||||
/** The result fo the fetch command. */
|
||||
const fetchResult = git.runGraceful([
|
||||
'fetch', '-q', `https://github.com/${git.remoteConfig.owner}/${git.remoteConfig.name}.git`,
|
||||
branch
|
||||
]);
|
||||
if (fetchResult.status !== 0 &&
|
||||
fetchResult.stderr.includes(`couldn't find remote ref ${branch}`)) {
|
||||
debug(`No '${branch}' branch exists on upstream, skipping.`);
|
||||
return false;
|
||||
}
|
||||
return git.runGraceful(['rev-parse', 'FETCH_HEAD']).stdout.trim();
|
||||
}
|
||||
/**
|
||||
* Get git diff stats between master and g3, for all files and filtered to only g3 affecting
|
||||
* files.
|
||||
*/
|
||||
function getDiffStats() {
|
||||
/** The diff stats to be returned. */
|
||||
const stats = {
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
commits: 0,
|
||||
};
|
||||
// Determine the number of commits between master and g3 refs. */
|
||||
stats.commits = parseInt(git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10);
|
||||
// Get the numstat information between master and g3
|
||||
git.run(['diff', `${g3Ref}...${masterRef}`, '--numstat'])
|
||||
.stdout
|
||||
// Remove the extra space after git's output.
|
||||
.trim()
|
||||
// Split each line of git output into array
|
||||
.split('\n')
|
||||
// Split each line from the git output into components parts: insertions,
|
||||
// deletions and file name respectively
|
||||
.map(line => line.split('\t'))
|
||||
// Parse number value from the insertions and deletions values
|
||||
// Example raw line input:
|
||||
// 10\t5\tsrc/file/name.ts
|
||||
.map(line => [Number(line[0]), Number(line[1]), line[2]])
|
||||
// Add each line's value to the diff stats, and conditionally to the g3
|
||||
// stats as well if the file name is included in the files synced to g3.
|
||||
.forEach(([insertions, deletions, fileName]) => {
|
||||
if (checkMatchAgainstIncludeAndExclude(fileName, includeFiles, excludeFiles)) {
|
||||
stats.insertions += insertions;
|
||||
stats.deletions += deletions;
|
||||
stats.files += 1;
|
||||
}
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
/** Determine whether the file name passes both include and exclude checks. */
|
||||
function checkMatchAgainstIncludeAndExclude(file, includes, excludes) {
|
||||
return (multimatch.call(undefined, file, includes).length >= 1 &&
|
||||
multimatch.call(undefined, file, excludes).length === 0);
|
||||
}
|
||||
});
|
||||
return { g3, master };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1256,32 +1302,56 @@ function printG3Comparison(git) {
|
|||
* 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
|
||||
*/
|
||||
/** The fragment for a result from Github's api for a Github query. */
|
||||
const GithubQueryResultFragment = {
|
||||
issueCount: typedGraphqlify.types.number,
|
||||
nodes: [Object.assign({}, typedGraphqlify.onUnion({
|
||||
PullRequest: {
|
||||
url: typedGraphqlify.types.string,
|
||||
},
|
||||
Issue: {
|
||||
url: typedGraphqlify.types.string,
|
||||
},
|
||||
}))],
|
||||
};
|
||||
/**
|
||||
* Cap the returned issues in the queries to an arbitrary 100. At that point, caretaker has a lot
|
||||
* Cap the returned issues in the queries to an arbitrary 20. At that point, caretaker has a lot
|
||||
* of work to do and showing more than that isn't really useful.
|
||||
*/
|
||||
const MAX_RETURNED_ISSUES = 20;
|
||||
/** Retrieve the number of matching issues for each github query. */
|
||||
function printGithubTasks(git, config) {
|
||||
var _a;
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
if (!((_a = config === null || config === void 0 ? void 0 : config.githubQueries) === null || _a === void 0 ? void 0 : _a.length)) {
|
||||
debug('No github queries defined in the configuration, skipping.');
|
||||
return;
|
||||
}
|
||||
info.group(bold(`Github Tasks`));
|
||||
yield getGithubInfo(git, config);
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
}
|
||||
/** Retrieve query match counts and log discovered counts to the console. */
|
||||
function getGithubInfo(git, { githubQueries: queries = [] }) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
class GithubQueriesModule extends BaseModule {
|
||||
retrieveData() {
|
||||
var _a;
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
// Non-null assertion is used here as the check for undefined immediately follows to confirm the
|
||||
// assertion. Typescript's type filtering does not seem to work as needed to understand
|
||||
// whether githubQueries is undefined or not.
|
||||
let queries = (_a = this.config.caretaker) === null || _a === void 0 ? void 0 : _a.githubQueries;
|
||||
if (queries === undefined || queries.length === 0) {
|
||||
debug('No github queries defined in the configuration, skipping');
|
||||
return;
|
||||
}
|
||||
/** The results of the generated github query. */
|
||||
const queryResult = yield this.git.github.graphql.query(this.buildGraphqlQuery(queries));
|
||||
const results = Object.values(queryResult);
|
||||
const { owner, name: repo } = this.git.remoteConfig;
|
||||
return results.map((result, i) => {
|
||||
return {
|
||||
queryName: queries[i].name,
|
||||
count: result.issueCount,
|
||||
queryUrl: encodeURI(`https://github.com/${owner}/${repo}/issues?q=${queries[i].query}`),
|
||||
matchedUrls: result.nodes.map(node => node.url)
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
/** Build a Graphql query statement for the provided queries. */
|
||||
buildGraphqlQuery(queries) {
|
||||
/** The query object for graphql. */
|
||||
const graphQlQuery = {};
|
||||
const { owner, name: repo } = this.git.remoteConfig;
|
||||
/** The Github search filter for the configured repository. */
|
||||
const repoFilter = `repo:${git.remoteConfig.owner}/${git.remoteConfig.name}`;
|
||||
const repoFilter = `repo:${owner}/${repo}`;
|
||||
queries.forEach(({ name, query }) => {
|
||||
/** The name of the query, with spaces removed to match GraphQL requirements. */
|
||||
const queryKey = typedGraphqlify.alias(name.replace(/ /g, ''), 'search');
|
||||
|
@ -1289,37 +1359,33 @@ function getGithubInfo(git, { githubQueries: queries = [] }) {
|
|||
type: 'ISSUE',
|
||||
first: MAX_RETURNED_ISSUES,
|
||||
query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`,
|
||||
}, {
|
||||
issueCount: typedGraphqlify.types.number,
|
||||
nodes: [Object.assign({}, typedGraphqlify.onUnion({
|
||||
PullRequest: {
|
||||
url: typedGraphqlify.types.string,
|
||||
},
|
||||
Issue: {
|
||||
url: typedGraphqlify.types.string,
|
||||
},
|
||||
}))],
|
||||
});
|
||||
}, Object.assign({}, GithubQueryResultFragment));
|
||||
});
|
||||
/** The results of the generated github query. */
|
||||
const results = yield git.github.graphql.query(graphQlQuery);
|
||||
Object.values(results).forEach((result, i) => {
|
||||
var _a, _b;
|
||||
info(`${(_a = queries[i]) === null || _a === void 0 ? void 0 : _a.name.padEnd(25)} ${result.issueCount}`);
|
||||
if (result.issueCount > 0) {
|
||||
const { owner, name: repo } = git.remoteConfig;
|
||||
const url = encodeURI(`https://github.com/${owner}/${repo}/issues?q=${(_b = queries[i]) === null || _b === void 0 ? void 0 : _b.query}`);
|
||||
info.group(`${url}`);
|
||||
if (result.nodes.length === MAX_RETURNED_ISSUES && result.nodes.length < result.issueCount) {
|
||||
info(`(first ${MAX_RETURNED_ISSUES})`);
|
||||
}
|
||||
for (const node of result.nodes) {
|
||||
info(`- ${node.url}`);
|
||||
}
|
||||
info.groupEnd();
|
||||
return graphQlQuery;
|
||||
}
|
||||
printToTerminal() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const queryResults = yield this.data;
|
||||
if (!queryResults) {
|
||||
return;
|
||||
}
|
||||
info.group(bold('Github Tasks'));
|
||||
const minQueryNameLength = Math.max(...queryResults.map(result => result.queryName.length));
|
||||
for (const queryResult of queryResults) {
|
||||
info(`${queryResult.queryName.padEnd(minQueryNameLength)} ${queryResult.count}`);
|
||||
if (queryResult.count > 0) {
|
||||
info.group(queryResult.queryUrl);
|
||||
queryResult.matchedUrls.forEach(url => info(`- ${url}`));
|
||||
if (queryResult.count > MAX_RETURNED_ISSUES) {
|
||||
info(`... ${queryResult.count - MAX_RETURNED_ISSUES} additional matches`);
|
||||
}
|
||||
info.groupEnd();
|
||||
}
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1329,71 +1395,64 @@ function getGithubInfo(git, { githubQueries: queries = [] }) {
|
|||
* 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
|
||||
*/
|
||||
/** The status levels for services. */
|
||||
var ServiceStatus;
|
||||
(function (ServiceStatus) {
|
||||
ServiceStatus[ServiceStatus["GREEN"] = 0] = "GREEN";
|
||||
ServiceStatus[ServiceStatus["RED"] = 1] = "RED";
|
||||
})(ServiceStatus || (ServiceStatus = {}));
|
||||
/** Retrieve and log stasuses for all of the services of concern. */
|
||||
function printServiceStatuses() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
info.group(bold(`Service Statuses (checked: ${new Date().toLocaleString()})`));
|
||||
logStatus('CircleCI', yield getCircleCiStatus());
|
||||
logStatus('Github', yield getGithubStatus());
|
||||
logStatus('NPM', yield getNpmStatus());
|
||||
logStatus('Saucelabs', yield getSaucelabsStatus());
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
}
|
||||
/** Log the status of the service to the console. */
|
||||
function logStatus(serviceName, status) {
|
||||
serviceName = serviceName.padEnd(15);
|
||||
if (status.status === ServiceStatus.GREEN) {
|
||||
info(`${serviceName} ${green('✅')}`);
|
||||
/** List of services Angular relies on. */
|
||||
const services = [
|
||||
{
|
||||
url: 'https://status.us-west-1.saucelabs.com/api/v2/status.json',
|
||||
name: 'Saucelabs',
|
||||
},
|
||||
{
|
||||
url: 'https://status.npmjs.org/api/v2/status.json',
|
||||
name: 'Npm',
|
||||
},
|
||||
{
|
||||
url: 'https://status.circleci.com/api/v2/status.json',
|
||||
name: 'CircleCi',
|
||||
},
|
||||
{
|
||||
url: 'https://www.githubstatus.com/api/v2/status.json',
|
||||
name: 'Github',
|
||||
},
|
||||
];
|
||||
class ServicesModule extends BaseModule {
|
||||
retrieveData() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
return Promise.all(services.map(service => this.getStatusFromStandardApi(service)));
|
||||
});
|
||||
}
|
||||
else if (status.status === ServiceStatus.RED) {
|
||||
info.group(`${serviceName} ${red('❌')} (Updated: ${status.lastUpdated.toLocaleString()})`);
|
||||
info(` Details: ${status.description}`);
|
||||
info.groupEnd();
|
||||
printToTerminal() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const statuses = yield this.data;
|
||||
const serviceNameMinLength = Math.max(...statuses.map(service => service.name.length));
|
||||
info.group(bold('Service Statuses'));
|
||||
for (const status of statuses) {
|
||||
const name = status.name.padEnd(serviceNameMinLength);
|
||||
if (status.status === 'passing') {
|
||||
info(`${name} ✅`);
|
||||
}
|
||||
else {
|
||||
info.group(`${name} ❌ (Updated: ${status.lastUpdated.toLocaleString()})`);
|
||||
info(` Details: ${status.description}`);
|
||||
info.groupEnd();
|
||||
}
|
||||
}
|
||||
info.groupEnd();
|
||||
info();
|
||||
});
|
||||
}
|
||||
/** Retrieve the status information for a service which uses a standard API response. */
|
||||
getStatusFromStandardApi(service) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const result = yield fetch(service.url).then(result => result.json());
|
||||
const status = result.status.indicator === 'none' ? 'passing' : 'failing';
|
||||
return {
|
||||
name: service.name,
|
||||
status,
|
||||
description: result.status.description,
|
||||
lastUpdated: new Date(result.page.updated_at)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Gets the service status information for Saucelabs. */
|
||||
function getSaucelabsStatus() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
return getStatusFromStandardApi('https://status.us-west-1.saucelabs.com/api/v2/status.json');
|
||||
});
|
||||
}
|
||||
/** Gets the service status information for NPM. */
|
||||
function getNpmStatus() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
return getStatusFromStandardApi('https://status.npmjs.org/api/v2/status.json');
|
||||
});
|
||||
}
|
||||
/** Gets the service status information for CircleCI. */
|
||||
function getCircleCiStatus() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
return getStatusFromStandardApi('https://status.circleci.com/api/v2/status.json');
|
||||
});
|
||||
}
|
||||
/** Gets the service status information for Github. */
|
||||
function getGithubStatus() {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
return getStatusFromStandardApi('https://www.githubstatus.com/api/v2/status.json');
|
||||
});
|
||||
}
|
||||
/** Retrieve the status information for a service which uses a standard API response. */
|
||||
function getStatusFromStandardApi(url) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
const result = yield fetch(url).then(result => result.json());
|
||||
const status = result.status.indicator === 'none' ? ServiceStatus.GREEN : ServiceStatus.RED;
|
||||
return {
|
||||
status,
|
||||
description: result.status.description,
|
||||
lastUpdated: new Date(result.page.updated_at)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1403,6 +1462,13 @@ function getStatusFromStandardApi(url) {
|
|||
* 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
|
||||
*/
|
||||
/** 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. */
|
||||
function checkServiceStatuses(githubToken) {
|
||||
return tslib.__awaiter(this, void 0, void 0, function* () {
|
||||
|
@ -1412,11 +1478,15 @@ function checkServiceStatuses(githubToken) {
|
|||
const git = new GitClient(githubToken, config);
|
||||
// Prevent logging of the git commands being executed during the check.
|
||||
GitClient.LOG_COMMANDS = false;
|
||||
// TODO(josephperrott): Allow these checks to be loaded in parallel.
|
||||
yield printServiceStatuses();
|
||||
yield printGithubTasks(git, config.caretaker);
|
||||
yield printG3Comparison(git);
|
||||
yield printCiStatus(git);
|
||||
/** List of instances of Caretaker Check modules */
|
||||
const caretakerCheckModules = moduleList.map(module => new module(git, config));
|
||||
// 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.
|
||||
yield Promise.all(caretakerCheckModules.map(module => module.data));
|
||||
for (const module of caretakerCheckModules) {
|
||||
yield module.printToTerminal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -9,8 +9,25 @@
|
|||
import {SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
||||
import * as parseArgs from 'minimist';
|
||||
|
||||
import {NgDevConfig} from '../config';
|
||||
import {GitClient} from '../git/index';
|
||||
|
||||
/**
|
||||
* Temporary directory which will be used as project directory in tests. Note that
|
||||
* this environment variable is automatically set by Bazel for tests.
|
||||
*/
|
||||
export const testTmpDir: string = process.env['TEST_TMPDIR']!;
|
||||
|
||||
|
||||
/** A mock instance of a configuration for the ng-dev toolset for default testing. */
|
||||
export const mockNgDevConfig: NgDevConfig = {
|
||||
github: {
|
||||
name: 'name',
|
||||
owner: 'owner',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/** Type describing a Git head. */
|
||||
interface GitHead {
|
||||
/** Name of the head. Not defined in a detached state. */
|
||||
|
@ -171,3 +188,12 @@ export class VirtualGitClient extends GitClient {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds a Virtual Git Client instance with the provided config and set the temporary test
|
||||
* directory.
|
||||
*/
|
||||
export function buildVirtualGitClient(config = mockNgDevConfig, tmpDir = testTmpDir) {
|
||||
return (new VirtualGitClient(undefined, config, tmpDir));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue