refactor(dev-infra): use a singleton for GitClient (#41515)

Creates a singleton class for GitClient rather than relying on creating an instance to
require being passed around throughout its usages.

PR Close #41515
This commit is contained in:
Joey Perrott 2021-04-08 12:34:55 -07:00 committed by Zach Arend
parent c20db69f9f
commit 9bf8e5164d
32 changed files with 439 additions and 323 deletions

View File

@ -5,6 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {installVirtualGitClientSpies} from '../../utils/testing';
import {BaseModule} from './base'; import {BaseModule} from './base';
/** Data mocking as the "retrieved data". */ /** Data mocking as the "retrieved data". */
@ -23,17 +24,18 @@ describe('BaseModule', () => {
beforeEach(() => { beforeEach(() => {
retrieveDataSpy = spyOn(ConcreteBaseModule.prototype, 'retrieveData'); retrieveDataSpy = spyOn(ConcreteBaseModule.prototype, 'retrieveData');
installVirtualGitClientSpies();
}); });
it('begins retrieving data during construction', () => { it('begins retrieving data during construction', () => {
new ConcreteBaseModule({} as any, {} as any); new ConcreteBaseModule({} as any);
expect(retrieveDataSpy).toHaveBeenCalled(); expect(retrieveDataSpy).toHaveBeenCalled();
}); });
it('makes the data available via the data attribute', async () => { it('makes the data available via the data attribute', async () => {
retrieveDataSpy.and.callThrough(); retrieveDataSpy.and.callThrough();
const module = new ConcreteBaseModule({} as any, {} as any); const module = new ConcreteBaseModule({} as any);
expect(await module.data).toBe(exampleData); expect(await module.data).toBe(exampleData);
}); });

View File

@ -11,11 +11,12 @@ import {CaretakerConfig} from '../config';
/** The BaseModule to extend modules for caretaker checks from. */ /** The BaseModule to extend modules for caretaker checks from. */
export abstract class BaseModule<Data> { export abstract class BaseModule<Data> {
/** The singleton instance of the GitClient. */
protected git = GitClient.getAuthenticatedInstance();
/** The data for the module. */ /** The data for the module. */
readonly data = this.retrieveData(); readonly data = this.retrieveData();
constructor( constructor(protected config: NgDevConfig<{caretaker: CaretakerConfig}>) {}
protected git: GitClient, protected config: NgDevConfig<{caretaker: CaretakerConfig}>) {}
/** Asyncronously retrieve data for the module. */ /** Asyncronously retrieve data for the module. */
protected abstract retrieveData(): Promise<Data>; protected abstract retrieveData(): Promise<Data>;

View File

@ -23,15 +23,13 @@ const moduleList = [
]; ];
/** Check the status of services which Angular caretakers need to monitor. */ /** Check the status of services which Angular caretakers need to monitor. */
export async function checkServiceStatuses(githubToken: string) { export async function checkServiceStatuses() {
// Set the verbose logging state of the GitClient.
GitClient.getAuthenticatedInstance().setVerboseLoggingState(false);
/** The configuration for the caretaker commands. */ /** The configuration for the caretaker commands. */
const config = getCaretakerConfig(); const config = getCaretakerConfig();
/** The GitClient for interacting with git and Github. */
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 */ /** List of instances of Caretaker Check modules */
const caretakerCheckModules = moduleList.map(module => new module(git, config)); const caretakerCheckModules = moduleList.map(module => new module(config));
// Module's `data` is casted as Promise<unknown> because the data types of the `module`'s `data` // 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 // promises do not match typings, however our usage here is only to determine when the promise

View File

@ -7,11 +7,11 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {SemVer} from 'semver'; import {SemVer} from 'semver';
import {ReleaseTrain} from '../../release/versioning';
import {ReleaseTrain} from '../../release/versioning';
import * as versioning from '../../release/versioning/active-release-trains'; import * as versioning from '../../release/versioning/active-release-trains';
import * as console from '../../utils/console'; import * as console from '../../utils/console';
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing';
import {CiModule} from './ci'; import {CiModule} from './ci';
@ -20,10 +20,9 @@ describe('CiModule', () => {
let getBranchStatusFromCiSpy: jasmine.Spy; let getBranchStatusFromCiSpy: jasmine.Spy;
let infoSpy: jasmine.Spy; let infoSpy: jasmine.Spy;
let debugSpy: jasmine.Spy; let debugSpy: jasmine.Spy;
let virtualGitClient: VirtualGitClient;
beforeEach(() => { beforeEach(() => {
virtualGitClient = buildVirtualGitClient(); installVirtualGitClientSpies();
fetchActiveReleaseTrainsSpy = spyOn(versioning, 'fetchActiveReleaseTrains'); fetchActiveReleaseTrainsSpy = spyOn(versioning, 'fetchActiveReleaseTrains');
getBranchStatusFromCiSpy = spyOn(CiModule.prototype, 'getBranchStatusFromCi' as any); getBranchStatusFromCiSpy = spyOn(CiModule.prototype, 'getBranchStatusFromCi' as any);
infoSpy = spyOn(console, 'info'); infoSpy = spyOn(console, 'info');
@ -34,7 +33,7 @@ describe('CiModule', () => {
it('handles active rc train', async () => { it('handles active rc train', async () => {
const trains = buildMockActiveReleaseTrains(true); const trains = buildMockActiveReleaseTrains(true);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains); fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new CiModule({caretaker: {}, ...mockNgDevConfig});
await module.data; await module.data;
expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.releaseCandidate.branchName); expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.releaseCandidate.branchName);
@ -46,7 +45,7 @@ describe('CiModule', () => {
it('handles an inactive rc train', async () => { it('handles an inactive rc train', async () => {
const trains = buildMockActiveReleaseTrains(false); const trains = buildMockActiveReleaseTrains(false);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains); fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new CiModule({caretaker: {}, ...mockNgDevConfig});
await module.data; await module.data;
expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.latest.branchName); expect(getBranchStatusFromCiSpy).toHaveBeenCalledWith(trains.latest.branchName);
@ -58,7 +57,7 @@ describe('CiModule', () => {
const trains = buildMockActiveReleaseTrains(false); const trains = buildMockActiveReleaseTrains(false);
fetchActiveReleaseTrainsSpy.and.resolveTo(trains); fetchActiveReleaseTrainsSpy.and.resolveTo(trains);
getBranchStatusFromCiSpy.and.returnValue('success'); getBranchStatusFromCiSpy.and.returnValue('success');
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new CiModule({caretaker: {}, ...mockNgDevConfig});
const data = await module.data; const data = await module.data;
expect(data[0]).toEqual( expect(data[0]).toEqual(
@ -89,7 +88,7 @@ describe('CiModule', () => {
]); ]);
fetchActiveReleaseTrainsSpy.and.resolveTo([]); fetchActiveReleaseTrainsSpy.and.resolveTo([]);
const module = new CiModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new CiModule({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {Arguments, Argv, CommandModule} from 'yargs'; import {Argv, CommandModule} from 'yargs';
import {addGithubTokenOption} from '../../utils/git/github-yargs'; import {addGithubTokenOption} from '../../utils/git/github-yargs';
@ -23,8 +23,8 @@ function builder(yargs: Argv) {
} }
/** Handles the command. */ /** Handles the command. */
async function handler({githubToken}: Arguments<CaretakerCheckOptions>) { async function handler() {
await checkServiceStatuses(githubToken); await checkServiceStatuses();
} }
/** yargs command module for checking status information for the repository */ /** yargs command module for checking status information for the repository */

View File

@ -9,7 +9,8 @@
import {SpawnSyncReturns} from 'child_process'; import {SpawnSyncReturns} from 'child_process';
import * as console from '../../utils/console'; import * as console from '../../utils/console';
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing'; import {GitClient} from '../../utils/git';
import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing';
import {G3Module, G3StatsData} from './g3'; import {G3Module, G3StatsData} from './g3';
@ -18,10 +19,9 @@ describe('G3Module', () => {
let getLatestShas: jasmine.Spy; let getLatestShas: jasmine.Spy;
let getDiffStats: jasmine.Spy; let getDiffStats: jasmine.Spy;
let infoSpy: jasmine.Spy; let infoSpy: jasmine.Spy;
let virtualGitClient: VirtualGitClient;
beforeEach(() => { beforeEach(() => {
virtualGitClient = buildVirtualGitClient(); installVirtualGitClientSpies();
getG3FileIncludeAndExcludeLists = getG3FileIncludeAndExcludeLists =
spyOn(G3Module.prototype, 'getG3FileIncludeAndExcludeLists' as any).and.returnValue(null); spyOn(G3Module.prototype, 'getG3FileIncludeAndExcludeLists' as any).and.returnValue(null);
getLatestShas = spyOn(G3Module.prototype, 'getLatestShas' as any).and.returnValue(null); getLatestShas = spyOn(G3Module.prototype, 'getLatestShas' as any).and.returnValue(null);
@ -33,7 +33,7 @@ describe('G3Module', () => {
it('unless the g3 merge config is not defined in the angular robot file', async () => { it('unless the g3 merge config is not defined in the angular robot file', async () => {
getG3FileIncludeAndExcludeLists.and.returnValue(null); getG3FileIncludeAndExcludeLists.and.returnValue(null);
getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'}); getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'});
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new G3Module({caretaker: {}, ...mockNgDevConfig});
expect(getDiffStats).not.toHaveBeenCalled(); expect(getDiffStats).not.toHaveBeenCalled();
expect(await module.data).toBe(undefined); expect(await module.data).toBe(undefined);
@ -42,7 +42,7 @@ describe('G3Module', () => {
it('unless the branch shas are not able to be retrieved', async () => { it('unless the branch shas are not able to be retrieved', async () => {
getLatestShas.and.returnValue(null); getLatestShas.and.returnValue(null);
getG3FileIncludeAndExcludeLists.and.returnValue({include: ['file1'], exclude: []}); getG3FileIncludeAndExcludeLists.and.returnValue({include: ['file1'], exclude: []});
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new G3Module({caretaker: {}, ...mockNgDevConfig});
expect(getDiffStats).not.toHaveBeenCalled(); expect(getDiffStats).not.toHaveBeenCalled();
expect(await module.data).toBe(undefined); expect(await module.data).toBe(undefined);
@ -52,7 +52,7 @@ describe('G3Module', () => {
getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'}); getLatestShas.and.returnValue({g3: 'abc123', master: 'zxy987'});
getG3FileIncludeAndExcludeLists.and.returnValue({include: ['project1/*'], exclude: []}); getG3FileIncludeAndExcludeLists.and.returnValue({include: ['project1/*'], exclude: []});
getDiffStats.and.callThrough(); getDiffStats.and.callThrough();
spyOn(virtualGitClient, 'run').and.callFake((args: string[]): any => { spyOn(GitClient.prototype, 'run').and.callFake((args: string[]): any => {
const output: Partial<SpawnSyncReturns<string>> = {}; const output: Partial<SpawnSyncReturns<string>> = {};
if (args[0] === 'rev-list') { if (args[0] === 'rev-list') {
output.stdout = '3'; output.stdout = '3';
@ -63,7 +63,7 @@ describe('G3Module', () => {
return output; return output;
}); });
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new G3Module({caretaker: {}, ...mockNgDevConfig});
const {insertions, deletions, files, commits} = (await module.data) as G3StatsData; const {insertions, deletions, files, commits} = (await module.data) as G3StatsData;
expect(insertions).toBe(12); expect(insertions).toBe(12);
@ -82,7 +82,7 @@ describe('G3Module', () => {
commits: 2, commits: 2,
}); });
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new G3Module({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();
@ -98,7 +98,7 @@ describe('G3Module', () => {
commits: 25, commits: 25,
}); });
const module = new G3Module(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new G3Module({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();

View File

@ -7,8 +7,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import * as console from '../../utils/console'; import * as console from '../../utils/console';
import {GithubGraphqlClient} from '../../utils/git/github'; import {GithubClient} from '../../utils/git/github';
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing';
import {GithubQueriesModule} from './github'; import {GithubQueriesModule} from './github';
@ -16,37 +16,35 @@ describe('GithubQueriesModule', () => {
let githubApiSpy: jasmine.Spy; let githubApiSpy: jasmine.Spy;
let infoSpy: jasmine.Spy; let infoSpy: jasmine.Spy;
let infoGroupSpy: jasmine.Spy; let infoGroupSpy: jasmine.Spy;
let virtualGitClient: VirtualGitClient;
beforeEach(() => { beforeEach(() => {
githubApiSpy = spyOn(GithubGraphqlClient.prototype, 'query') githubApiSpy = spyOn(GithubClient.prototype, 'graphql')
.and.throwError( .and.throwError(
'The graphql query response must always be manually defined in a test.'); 'The graphql query response must always be manually defined in a test.');
virtualGitClient = buildVirtualGitClient(); installVirtualGitClientSpies();
infoGroupSpy = spyOn(console.info, 'group'); infoGroupSpy = spyOn(console.info, 'group');
infoSpy = spyOn(console, 'info'); infoSpy = spyOn(console, 'info');
}); });
describe('gathering stats', () => { describe('gathering stats', () => {
it('unless githubQueries are `undefined`', async () => { it('unless githubQueries are `undefined`', async () => {
const module = new GithubQueriesModule( const module =
virtualGitClient, {...mockNgDevConfig, caretaker: {githubQueries: undefined}}); new GithubQueriesModule({...mockNgDevConfig, caretaker: {githubQueries: undefined}});
expect(await module.data).toBe(undefined); expect(await module.data).toBe(undefined);
}); });
it('unless githubQueries are an empty array', async () => { it('unless githubQueries are an empty array', async () => {
const module = new GithubQueriesModule( const module = new GithubQueriesModule({...mockNgDevConfig, caretaker: {githubQueries: []}});
virtualGitClient, {...mockNgDevConfig, caretaker: {githubQueries: []}});
expect(await module.data).toBe(undefined); expect(await module.data).toBe(undefined);
}); });
it('for the requestd Github queries', async () => { it('for the requested Github queries', async () => {
githubApiSpy.and.returnValue({ githubApiSpy.and.returnValue({
'keynamewithspaces': {issueCount: 1, nodes: [{url: 'http://gituhb.com/owner/name/issue/1'}]} 'keynamewithspaces': {issueCount: 1, nodes: [{url: 'http://github.com/owner/name/issue/1'}]}
}); });
const module = new GithubQueriesModule(virtualGitClient, { const module = new GithubQueriesModule({
...mockNgDevConfig, ...mockNgDevConfig,
caretaker: {githubQueries: [{name: 'key name with spaces', query: 'issue: yes'}]} caretaker: {githubQueries: [{name: 'key name with spaces', query: 'issue: yes'}]}
}); });
@ -55,7 +53,7 @@ describe('GithubQueriesModule', () => {
queryName: 'key name with spaces', queryName: 'key name with spaces',
count: 1, count: 1,
queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes', queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes',
matchedUrls: ['http://gituhb.com/owner/name/issue/1'], matchedUrls: ['http://github.com/owner/name/issue/1'],
}]); }]);
}); });
}); });
@ -78,7 +76,7 @@ describe('GithubQueriesModule', () => {
]); ]);
const module = new GithubQueriesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new GithubQueriesModule({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();
@ -95,7 +93,7 @@ describe('GithubQueriesModule', () => {
queryName: 'query1', queryName: 'query1',
count: 1, count: 1,
queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes', queryUrl: 'https://github.com/owner/name/issues?q=issue:%20yes',
matchedUrls: ['http://gituhb.com/owner/name/issue/1'], matchedUrls: ['http://github.com/owner/name/issue/1'],
}, },
{ {
queryName: 'query2', queryName: 'query2',
@ -105,7 +103,7 @@ describe('GithubQueriesModule', () => {
}, },
]); ]);
const module = new GithubQueriesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new GithubQueriesModule({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();
@ -114,7 +112,7 @@ describe('GithubQueriesModule', () => {
expect(infoSpy).toHaveBeenCalledWith('query1 1'); expect(infoSpy).toHaveBeenCalledWith('query1 1');
expect(infoGroupSpy) expect(infoGroupSpy)
.toHaveBeenCalledWith('https://github.com/owner/name/issues?q=issue:%20yes'); .toHaveBeenCalledWith('https://github.com/owner/name/issues?q=issue:%20yes');
expect(infoSpy).toHaveBeenCalledWith('- http://gituhb.com/owner/name/issue/1'); expect(infoSpy).toHaveBeenCalledWith('- http://github.com/owner/name/issue/1');
expect(infoSpy).toHaveBeenCalledWith('query2 0'); expect(infoSpy).toHaveBeenCalledWith('query2 0');
}); });
}); });

View File

@ -56,7 +56,7 @@ export class GithubQueriesModule extends BaseModule<GithubQueryResults|void> {
} }
/** The results of the generated github query. */ /** The results of the generated github query. */
const queryResult = await this.git.github.graphql.query(this.buildGraphqlQuery(queries)); const queryResult = await this.git.github.graphql(this.buildGraphqlQuery(queries));
const results = Object.values(queryResult); const results = Object.values(queryResult);
const {owner, name: repo} = this.git.remoteConfig; const {owner, name: repo} = this.git.remoteConfig;
@ -74,16 +74,16 @@ export class GithubQueriesModule extends BaseModule<GithubQueryResults|void> {
/** Build a Graphql query statement for the provided queries. */ /** Build a Graphql query statement for the provided queries. */
private buildGraphqlQuery(queries: NonNullable<CaretakerConfig['githubQueries']>) { private buildGraphqlQuery(queries: NonNullable<CaretakerConfig['githubQueries']>) {
/** The query object for graphql. */ /** The query object for graphql. */
const graphQlQuery: GithubQueryResult = {}; const graphqlQuery: GithubQueryResult = {};
const {owner, name: repo} = this.git.remoteConfig; const {owner, name: repo} = this.git.remoteConfig;
/** The Github search filter for the configured repository. */ /** The Github search filter for the configured repository. */
const repoFilter = `repo:${owner}/${repo}`; const repoFilter = `repo:${owner}/${repo}`;
queries.forEach(({name, query}) => { queries.forEach(({name, query}) => {
/** The name of the query, with spaces removed to match GraphQL requirements. */ /** The name of the query, with spaces removed to match Graphql requirements. */
const queryKey = alias(name.replace(/ /g, ''), 'search'); const queryKey = alias(name.replace(/ /g, ''), 'search');
graphQlQuery[queryKey] = params( graphqlQuery[queryKey] = params(
{ {
type: 'ISSUE', type: 'ISSUE',
first: MAX_RETURNED_ISSUES, first: MAX_RETURNED_ISSUES,
@ -92,7 +92,7 @@ export class GithubQueriesModule extends BaseModule<GithubQueryResults|void> {
{...GithubQueryResultFragment}); {...GithubQueryResultFragment});
}); });
return graphQlQuery; return graphqlQuery;
} }
async printToTerminal() { async printToTerminal() {

View File

@ -8,7 +8,7 @@
*/ */
import * as console from '../../utils/console'; import * as console from '../../utils/console';
import {buildVirtualGitClient, mockNgDevConfig, VirtualGitClient} from '../../utils/testing'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing';
import {services, ServicesModule} from './services'; import {services, ServicesModule} from './services';
@ -16,20 +16,19 @@ describe('ServicesModule', () => {
let getStatusFromStandardApiSpy: jasmine.Spy; let getStatusFromStandardApiSpy: jasmine.Spy;
let infoSpy: jasmine.Spy; let infoSpy: jasmine.Spy;
let infoGroupSpy: jasmine.Spy; let infoGroupSpy: jasmine.Spy;
let virtualGitClient: VirtualGitClient;
services.splice(0, Infinity, {url: 'fakeStatus.com/api.json', name: 'Service Name'}); services.splice(0, Infinity, {url: 'fakeStatus.com/api.json', name: 'Service Name'});
beforeEach(() => { beforeEach(() => {
getStatusFromStandardApiSpy = spyOn(ServicesModule.prototype, 'getStatusFromStandardApi'); getStatusFromStandardApiSpy = spyOn(ServicesModule.prototype, 'getStatusFromStandardApi');
virtualGitClient = buildVirtualGitClient(); installVirtualGitClientSpies();
infoGroupSpy = spyOn(console.info, 'group'); infoGroupSpy = spyOn(console.info, 'group');
infoSpy = spyOn(console, 'info'); infoSpy = spyOn(console, 'info');
}); });
describe('gathering status', () => { describe('gathering status', () => {
it('for each of the services', async () => { it('for each of the services', async () => {
new ServicesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); new ServicesModule({caretaker: {}, ...mockNgDevConfig});
expect(getStatusFromStandardApiSpy) expect(getStatusFromStandardApiSpy)
.toHaveBeenCalledWith({url: 'fakeStatus.com/api.json', name: 'Service Name'}); .toHaveBeenCalledWith({url: 'fakeStatus.com/api.json', name: 'Service Name'});
@ -54,7 +53,7 @@ describe('ServicesModule', () => {
]); ]);
const module = new ServicesModule(virtualGitClient, {caretaker: {}, ...mockNgDevConfig}); const module = new ServicesModule({caretaker: {}, ...mockNgDevConfig});
Object.defineProperty(module, 'data', {value: fakeData}); Object.defineProperty(module, 'data', {value: fakeData});
await module.printToTerminal(); await module.printToTerminal();

View File

@ -405,36 +405,6 @@ function getListCommitsInBranchUrl(_a, branchName) {
return "https://github.com/" + remoteParams.owner + "/" + remoteParams.repo + "/commits/" + branchName; return "https://github.com/" + remoteParams.owner + "/" + remoteParams.repo + "/commits/" + branchName;
} }
/**
* @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
*/
/** Sets up the `github-token` command option for the given Yargs instance. */
function addGithubTokenOption(yargs) {
return yargs
// 'github-token' is casted to 'githubToken' to properly set up typings to reflect the key in
// the Argv object being camelCase rather than kebob case due to the `camel-case-expansion`
// config: https://github.com/yargs/yargs-parser#camel-case-expansion
.option('github-token', {
type: 'string',
description: 'Github token. If not set, token is retrieved from the environment variables.',
coerce: function (token) {
var githubToken = token || process.env.GITHUB_TOKEN || process.env.TOKEN;
if (!githubToken) {
error(red('No Github token set. Please set the `GITHUB_TOKEN` environment variable.'));
error(red('Alternatively, pass the `--github-token` command line flag.'));
error(yellow("You can generate a token here: " + GITHUB_TOKEN_GENERATE_URL));
process.exit(1);
}
return githubToken;
},
})
.default('github-token', '', '<LOCAL TOKEN>');
}
/** /**
* @license * @license
* Copyright Google LLC All Rights Reserved. * Copyright Google LLC All Rights Reserved.
@ -477,6 +447,14 @@ var GithubApiRequestError = /** @class */ (function (_super) {
} }
return GithubApiRequestError; return GithubApiRequestError;
}(Error)); }(Error));
/** Error for failed Github API requests. */
var GithubGraphqlClientError = /** @class */ (function (_super) {
tslib.__extends(GithubGraphqlClientError, _super);
function GithubGraphqlClientError() {
return _super !== null && _super.apply(this, arguments) || this;
}
return GithubGraphqlClientError;
}(Error));
/** /**
* A Github client for interacting with the Github APIs. * A Github client for interacting with the Github APIs.
* *
@ -485,21 +463,48 @@ var GithubApiRequestError = /** @class */ (function (_super) {
**/ **/
var GithubClient = /** @class */ (function (_super) { var GithubClient = /** @class */ (function (_super) {
tslib.__extends(GithubClient, _super); tslib.__extends(GithubClient, _super);
/**
* @param token The github authentication token for Github Rest and Graphql API requests.
*/
function GithubClient(token) { function GithubClient(token) {
var _this = var _this =
// Pass in authentication token to base Octokit class. // Pass in authentication token to base Octokit class.
_super.call(this, { auth: token }) || this; _super.call(this, { auth: token }) || this;
_this.token = token;
/** The current user based on checking against the Github API. */ /** The current user based on checking against the Github API. */
_this._currentUser = null; _this._currentUser = null;
/** The graphql instance with authentication set during construction. */
_this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + _this.token } });
_this.hook.error('request', function (error) { _this.hook.error('request', function (error) {
// Wrap API errors in a known error class. This allows us to // Wrap API errors in a known error class. This allows us to
// expect Github API errors better and in a non-ambiguous way. // expect Github API errors better and in a non-ambiguous way.
throw new GithubApiRequestError(error.status, error.message); throw new GithubApiRequestError(error.status, error.message);
}); });
// Create authenticated graphql client. // Note: The prototype must be set explictly as Github's Octokit class is a non-standard class
_this.graphql = new GithubGraphqlClient(token); // definition which adjusts the prototype chain.
// See:
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
// https://github.com/octokit/rest.js/blob/7b51cee4a22b6e52adcdca011f93efdffa5df998/lib/constructor.js
Object.setPrototypeOf(_this, GithubClient.prototype);
return _this; return _this;
} }
/** Perform a query using Github's Graphql API. */
GithubClient.prototype.graphql = function (queryObject, params) {
if (params === void 0) { params = {}; }
return tslib.__awaiter(this, void 0, void 0, function () {
return tslib.__generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this.token === undefined) {
throw new GithubGraphqlClientError('Cannot query via graphql without an authentication token set, use the authenticated ' +
'`GitClient` by calling `GitClient.getAuthenticatedInstance()`.');
}
return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject), params)];
case 1: return [2 /*return*/, (_a.sent())];
}
});
});
};
/** Retrieve the login of the current user from Github. */ /** Retrieve the login of the current user from Github. */
GithubClient.prototype.getCurrentUser = function () { GithubClient.prototype.getCurrentUser = function () {
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
@ -511,7 +516,7 @@ var GithubClient = /** @class */ (function (_super) {
if (this._currentUser !== null) { if (this._currentUser !== null) {
return [2 /*return*/, this._currentUser]; return [2 /*return*/, this._currentUser];
} }
return [4 /*yield*/, this.graphql.query({ return [4 /*yield*/, this.graphql({
viewer: { viewer: {
login: typedGraphqlify.types.string, login: typedGraphqlify.types.string,
} }
@ -525,34 +530,6 @@ var GithubClient = /** @class */ (function (_super) {
}; };
return GithubClient; return GithubClient;
}(Octokit)); }(Octokit));
/** A client for interacting with Github's GraphQL API. */
var GithubGraphqlClient = /** @class */ (function () {
function GithubGraphqlClient(token) {
/** The Github GraphQL (v4) API. */
this.graqhql = graphql.graphql;
// Set the default headers to include authorization with the provided token for all
// graphQL calls.
if (token) {
this.graqhql = this.graqhql.defaults({ headers: { authorization: "token " + token } });
}
}
/** Perform a query using Github's GraphQL API. */
GithubGraphqlClient.prototype.query = function (queryObject, params) {
if (params === void 0) { params = {}; }
return tslib.__awaiter(this, void 0, void 0, function () {
var queryString;
return tslib.__generator(this, function (_a) {
switch (_a.label) {
case 0:
queryString = typedGraphqlify.query(queryObject);
return [4 /*yield*/, this.graqhql(queryString, params)];
case 1: return [2 /*return*/, (_a.sent())];
}
});
});
};
return GithubGraphqlClient;
}());
/** /**
* @license * @license
@ -585,20 +562,19 @@ var GitCommandError = /** @class */ (function (_super) {
* the dev-infra configuration is loaded with its Github configuration. * the dev-infra configuration is loaded with its Github configuration.
**/ **/
var GitClient = /** @class */ (function () { var GitClient = /** @class */ (function () {
/**
* @param githubToken The github token used for authentication, if provided.
* @param _config The configuration, containing the github specific configuration.
* @param _projectRoot The full path to the root of the repository base.
*/
function GitClient(githubToken, _config, _projectRoot) { function GitClient(githubToken, _config, _projectRoot) {
if (_config === void 0) { _config = getConfig(); } if (_config === void 0) { _config = getConfig(); }
if (_projectRoot === void 0) { _projectRoot = getRepoBaseDir(); } if (_projectRoot === void 0) { _projectRoot = getRepoBaseDir(); }
this.githubToken = githubToken; this.githubToken = githubToken;
this._config = _config; this._config = _config;
this._projectRoot = _projectRoot; this._projectRoot = _projectRoot;
/** Short-hand for accessing the default remote configuration. */ /** Whether verbose logging of Git actions should be used. */
this.remoteConfig = this._config.github; this.verboseLogging = true;
/** Octokit request parameters object for targeting the configured remote. */
this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name };
/** Git URL that resolves to the configured repository. */
this.repoGitUrl = getRepositoryGitUrl(this.remoteConfig, this.githubToken);
/** Instance of the authenticated Github octokit API. */
this.github = new GithubClient(this.githubToken);
/** The OAuth scopes available for the provided Github token. */ /** The OAuth scopes available for the provided Github token. */
this._cachedOauthScopes = null; this._cachedOauthScopes = null;
/** /**
@ -606,13 +582,51 @@ var GitClient = /** @class */ (function () {
* sanitizing the token from Git child process output. * sanitizing the token from Git child process output.
*/ */
this._githubTokenRegex = null; this._githubTokenRegex = null;
/** Short-hand for accessing the default remote configuration. */
this.remoteConfig = this._config.github;
/** Octokit request parameters object for targeting the configured remote. */
this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name };
/** Instance of the authenticated Github octokit API. */
this.github = new GithubClient(this.githubToken);
// If a token has been specified (and is not empty), pass it to the Octokit API and // If a token has been specified (and is not empty), pass it to the Octokit API and
// also create a regular expression that can be used for sanitizing Git command output // also create a regular expression that can be used for sanitizing Git command output
// so that it does not print the token accidentally. // so that it does not print the token accidentally.
if (githubToken != null) { if (typeof githubToken === 'string') {
this._githubTokenRegex = new RegExp(githubToken, 'g'); this._githubTokenRegex = new RegExp(githubToken, 'g');
} }
} }
/**
* Static method to get the singleton instance of the unauthorized GitClient, creating it if it
* has not yet been created.
*/
GitClient.getInstance = function () {
if (!GitClient.unauthenticated) {
GitClient.unauthenticated = new GitClient(undefined);
}
return GitClient.unauthenticated;
};
/**
* Static method to get the singleton instance of the authenticated GitClient if it has been
* generated.
*/
GitClient.getAuthenticatedInstance = function () {
if (!GitClient.authenticated) {
throw Error('The authenticated GitClient has not yet been generated.');
}
return GitClient.authenticated;
};
/** Build the authenticated GitClient instance. */
GitClient.authenticateWithToken = function (token) {
if (GitClient.authenticated) {
throw Error('Cannot generate new authenticated GitClient after one has already been generated.');
}
GitClient.authenticated = new GitClient(token);
};
/** Set the verbose logging state of the GitClient instance. */
GitClient.prototype.setVerboseLoggingState = function (verbose) {
this.verboseLogging = verbose;
return this;
};
/** Executes the given git command. Throws if the command fails. */ /** Executes the given git command. Throws if the command fails. */
GitClient.prototype.run = function (args, options) { GitClient.prototype.run = function (args, options) {
var result = this.runGraceful(args, options); var result = this.runGraceful(args, options);
@ -639,7 +653,7 @@ var GitClient = /** @class */ (function () {
// To improve the debugging experience in case something fails, we print all executed Git // To improve the debugging experience in case something fails, we print all executed Git
// commands to better understand the git actions occuring. Depending on the command being // commands to better understand the git actions occuring. Depending on the command being
// executed, this debugging information should be logged at different logging levels. // executed, this debugging information should be logged at different logging levels.
var printFn = (!GitClient.LOG_COMMANDS || options.stdio === 'ignore') ? debug : info; var printFn = (!this.verboseLogging || options.stdio === 'ignore') ? debug : info;
// Note that we do not want to print the token if it is contained in the command. It's common // Note that we do not want to print the token if it is contained in the command. It's common
// to share errors with others if the tool failed, and we do not want to leak tokens. // to share errors with others if the tool failed, and we do not want to leak tokens.
printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' '))); printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
@ -655,6 +669,10 @@ var GitClient = /** @class */ (function () {
} }
return result; return result;
}; };
/** Git URL that resolves to the configured repository. */
GitClient.prototype.getRepoGitUrl = function () {
return getRepositoryGitUrl(this.remoteConfig, this.githubToken);
};
/** Whether the given branch contains the specified SHA. */ /** Whether the given branch contains the specified SHA. */
GitClient.prototype.hasCommit = function (branchName, sha) { GitClient.prototype.hasCommit = function (branchName, sha) {
return this.run(['branch', branchName, '--contains', sha]).stdout !== ''; return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
@ -760,11 +778,40 @@ var GitClient = /** @class */ (function () {
return scopes.split(',').map(function (scope) { return scope.trim(); }); return scopes.split(',').map(function (scope) { return scope.trim(); });
}); });
}; };
/** Whether verbose logging of Git actions should be used. */
GitClient.LOG_COMMANDS = true;
return GitClient; return GitClient;
}()); }());
/**
* @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
*/
/** Sets up the `github-token` command option for the given Yargs instance. */
function addGithubTokenOption(yargs) {
return yargs
// 'github-token' is casted to 'githubToken' to properly set up typings to reflect the key in
// the Argv object being camelCase rather than kebob case due to the `camel-case-expansion`
// config: https://github.com/yargs/yargs-parser#camel-case-expansion
.option('github-token', {
type: 'string',
description: 'Github token. If not set, token is retrieved from the environment variables.',
coerce: function (token) {
var githubToken = token || process.env.GITHUB_TOKEN || process.env.TOKEN;
if (!githubToken) {
error(red('No Github token set. Please set the `GITHUB_TOKEN` environment variable.'));
error(red('Alternatively, pass the `--github-token` command line flag.'));
error(yellow("You can generate a token here: " + GITHUB_TOKEN_GENERATE_URL));
process.exit(1);
}
GitClient.authenticateWithToken(githubToken);
return githubToken;
},
})
.default('github-token', '', '<LOCAL TOKEN>');
}
/** /**
* @license * @license
* Copyright Google LLC All Rights Reserved. * Copyright Google LLC All Rights Reserved.
@ -1091,9 +1138,10 @@ function getLtsNpmDistTagOfMajor(major) {
/** The BaseModule to extend modules for caretaker checks from. */ /** The BaseModule to extend modules for caretaker checks from. */
class BaseModule { class BaseModule {
constructor(git, config) { constructor(config) {
this.git = git;
this.config = config; this.config = config;
/** The singleton instance of the GitClient. */
this.git = GitClient.getAuthenticatedInstance();
/** The data for the module. */ /** The data for the module. */
this.data = this.retrieveData(); this.data = this.retrieveData();
} }
@ -1332,7 +1380,7 @@ class GithubQueriesModule extends BaseModule {
return; return;
} }
/** The results of the generated github query. */ /** The results of the generated github query. */
const queryResult = yield this.git.github.graphql.query(this.buildGraphqlQuery(queries)); const queryResult = yield this.git.github.graphql(this.buildGraphqlQuery(queries));
const results = Object.values(queryResult); const results = Object.values(queryResult);
const { owner, name: repo } = this.git.remoteConfig; const { owner, name: repo } = this.git.remoteConfig;
return results.map((result, i) => { return results.map((result, i) => {
@ -1348,20 +1396,20 @@ class GithubQueriesModule extends BaseModule {
/** Build a Graphql query statement for the provided queries. */ /** Build a Graphql query statement for the provided queries. */
buildGraphqlQuery(queries) { buildGraphqlQuery(queries) {
/** The query object for graphql. */ /** The query object for graphql. */
const graphQlQuery = {}; const graphqlQuery = {};
const { owner, name: repo } = this.git.remoteConfig; const { owner, name: repo } = this.git.remoteConfig;
/** The Github search filter for the configured repository. */ /** The Github search filter for the configured repository. */
const repoFilter = `repo:${owner}/${repo}`; const repoFilter = `repo:${owner}/${repo}`;
queries.forEach(({ name, query }) => { queries.forEach(({ name, query }) => {
/** The name of the query, with spaces removed to match GraphQL requirements. */ /** The name of the query, with spaces removed to match Graphql requirements. */
const queryKey = typedGraphqlify.alias(name.replace(/ /g, ''), 'search'); const queryKey = typedGraphqlify.alias(name.replace(/ /g, ''), 'search');
graphQlQuery[queryKey] = typedGraphqlify.params({ graphqlQuery[queryKey] = typedGraphqlify.params({
type: 'ISSUE', type: 'ISSUE',
first: MAX_RETURNED_ISSUES, first: MAX_RETURNED_ISSUES,
query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`, query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`,
}, Object.assign({}, GithubQueryResultFragment)); }, Object.assign({}, GithubQueryResultFragment));
}); });
return graphQlQuery; return graphqlQuery;
} }
printToTerminal() { printToTerminal() {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
@ -1470,16 +1518,14 @@ const moduleList = [
G3Module, G3Module,
]; ];
/** Check the status of services which Angular caretakers need to monitor. */ /** Check the status of services which Angular caretakers need to monitor. */
function checkServiceStatuses(githubToken) { function checkServiceStatuses() {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
// Set the verbose logging state of the GitClient.
GitClient.getAuthenticatedInstance().setVerboseLoggingState(false);
/** The configuration for the caretaker commands. */ /** The configuration for the caretaker commands. */
const config = getCaretakerConfig(); const config = getCaretakerConfig();
/** The GitClient for interacting with git and Github. */
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 */ /** List of instances of Caretaker Check modules */
const caretakerCheckModules = moduleList.map(module => new module(git, config)); const caretakerCheckModules = moduleList.map(module => new module(config));
// Module's `data` is casted as Promise<unknown> because the data types of the `module`'s `data` // 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 // promises do not match typings, however our usage here is only to determine when the promise
// resolves. // resolves.
@ -1502,9 +1548,9 @@ function builder(yargs) {
return addGithubTokenOption(yargs); return addGithubTokenOption(yargs);
} }
/** Handles the command. */ /** Handles the command. */
function handler({ githubToken }) { function handler() {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
yield checkServiceStatuses(githubToken); yield checkServiceStatuses();
}); });
} }
/** yargs command module for checking status information for the repository */ /** yargs command module for checking status information for the repository */
@ -2836,8 +2882,8 @@ function getTargetBranchesForPr(prNumber) {
const config = getConfig(); const config = getConfig();
/** Repo owner and name for the github repository. */ /** Repo owner and name for the github repository. */
const { owner, name: repo } = config.github; const { owner, name: repo } = config.github;
/** The git client to get a Github API service instance. */ /** The singleton instance of the GitClient. */
const git = new GitClient(undefined, config); const git = GitClient.getInstance();
/** The validated merge config. */ /** The validated merge config. */
const { config: mergeConfig, errors } = yield loadAndValidateConfig(config, git.github); const { config: mergeConfig, errors } = yield loadAndValidateConfig(config, git.github);
if (errors !== undefined) { if (errors !== undefined) {
@ -2931,7 +2977,7 @@ function getPr(prSchema, prNumber, git) {
pullRequest: typedGraphqlify.params({ number: '$number' }, prSchema), pullRequest: typedGraphqlify.params({ number: '$number' }, prSchema),
}) })
}); });
return [4 /*yield*/, git.github.graphql.query(PR_QUERY, { number: prNumber, owner: owner, name: name })]; return [4 /*yield*/, git.github.graphql(PR_QUERY, { number: prNumber, owner: owner, name: name })];
case 1: case 1:
result = (_b.sent()); result = (_b.sent());
return [2 /*return*/, result.repository.pullRequest]; return [2 /*return*/, result.repository.pullRequest];
@ -2978,7 +3024,7 @@ function getPendingPrs(prSchema, git) {
owner: owner, owner: owner,
name: name, name: name,
}; };
return [4 /*yield*/, git.github.graphql.query(PRS_QUERY, params_1)]; return [4 /*yield*/, git.github.graphql(PRS_QUERY, params_1)];
case 2: case 2:
results = _b.sent(); results = _b.sent();
prs.push.apply(prs, tslib.__spreadArray([], tslib.__read(results.repository.pullRequests.nodes))); prs.push.apply(prs, tslib.__spreadArray([], tslib.__read(results.repository.pullRequests.nodes)));
@ -2998,7 +3044,7 @@ function getPendingPrs(prSchema, git) {
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
/* GraphQL schema for the response body for a pending PR. */ /* Graphql schema for the response body for a pending PR. */
const PR_SCHEMA = { const PR_SCHEMA = {
state: typedGraphqlify.types.string, state: typedGraphqlify.types.string,
maintainerCanModify: typedGraphqlify.types.boolean, maintainerCanModify: typedGraphqlify.types.boolean,
@ -3037,8 +3083,8 @@ class MaintainerModifyAccessError extends Error {
*/ */
function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) { function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
/** Authenticated Git client for git and Github interactions. */ /** The singleton instance of the GitClient. */
const git = new GitClient(githubToken); const git = GitClient.getAuthenticatedInstance();
// In order to preserve local changes, checkouts cannot occur if local changes are present in the // In order to preserve local changes, checkouts cannot occur if local changes are present in the
// git environment. Checked before retrieving the PR to fail fast. // git environment. Checked before retrieving the PR to fail fast.
if (git.hasLocalChanges()) { if (git.hasLocalChanges()) {
@ -3132,7 +3178,7 @@ const CheckoutCommandModule = {
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
/* GraphQL schema for the response body for each pending PR. */ /* Graphql schema for the response body for each pending PR. */
const PR_SCHEMA$1 = { const PR_SCHEMA$1 = {
headRef: { headRef: {
name: typedGraphqlify.types.string, name: typedGraphqlify.types.string,
@ -3160,9 +3206,10 @@ function processPr(pr) {
/** Name of a temporary local branch that is used for checking conflicts. **/ /** Name of a temporary local branch that is used for checking conflicts. **/
const tempWorkingBranch = '__NgDevRepoBaseAfterChange__'; const tempWorkingBranch = '__NgDevRepoBaseAfterChange__';
/** Checks if the provided PR will cause new conflicts in other pending PRs. */ /** Checks if the provided PR will cause new conflicts in other pending PRs. */
function discoverNewConflictsForPr(newPrNumber, updatedAfter, config = getConfig()) { function discoverNewConflictsForPr(newPrNumber, updatedAfter) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const git = new GitClient(); /** The singleton instance of the GitClient. */
const git = GitClient.getAuthenticatedInstance();
// If there are any local changes in the current repository state, the // If there are any local changes in the current repository state, the
// check cannot run as it needs to move between branches. // check cannot run as it needs to move between branches.
if (git.hasLocalChanges()) { if (git.hasLocalChanges()) {
@ -3486,7 +3533,7 @@ function loadAndValidatePullRequest(_a, prNumber, ignoreNonFatalFailures) {
}); });
}); });
} }
/* GraphQL schema for the response body the requested pull request. */ /* Graphql schema for the response body the requested pull request. */
var PR_SCHEMA$2 = { var PR_SCHEMA$2 = {
url: typedGraphqlify.types.string, url: typedGraphqlify.types.string,
number: typedGraphqlify.types.number, number: typedGraphqlify.types.number,
@ -3723,7 +3770,7 @@ var MergeStrategy = /** @class */ (function () {
}); });
// Fetch all target branches with a single command. We don't want to fetch them // Fetch all target branches with a single command. We don't want to fetch them
// individually as that could cause an unnecessary slow-down. // individually as that could cause an unnecessary slow-down.
this.git.run(tslib.__spreadArray(tslib.__spreadArray(['fetch', '-q', '-f', this.git.repoGitUrl], tslib.__read(fetchRefspecs)), tslib.__read(extraRefspecs))); this.git.run(tslib.__spreadArray(tslib.__spreadArray(['fetch', '-q', '-f', this.git.getRepoGitUrl()], tslib.__read(fetchRefspecs)), tslib.__read(extraRefspecs)));
}; };
/** Pushes the given target branches upstream. */ /** Pushes the given target branches upstream. */
MergeStrategy.prototype.pushTargetBranchesUpstream = function (names) { MergeStrategy.prototype.pushTargetBranchesUpstream = function (names) {
@ -3734,7 +3781,7 @@ var MergeStrategy = /** @class */ (function () {
}); });
// Push all target branches with a single command if we don't run in dry-run mode. // Push all target branches with a single command if we don't run in dry-run mode.
// We don't want to push them individually as that could cause an unnecessary slow-down. // We don't want to push them individually as that could cause an unnecessary slow-down.
this.git.run(tslib.__spreadArray(['push', this.git.repoGitUrl], tslib.__read(pushRefspecs))); this.git.run(tslib.__spreadArray(['push', this.git.getRepoGitUrl()], tslib.__read(pushRefspecs)));
}; };
return MergeStrategy; return MergeStrategy;
}()); }());
@ -4206,7 +4253,7 @@ var PullRequestMergeTask = /** @class */ (function () {
* @param projectRoot Path to the local Git project that is used for merging. * @param projectRoot Path to the local Git project that is used for merging.
* @param config Configuration for merging pull requests. * @param config Configuration for merging pull requests.
*/ */
function mergePullRequest(prNumber, githubToken, flags) { function mergePullRequest(prNumber, flags) {
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
/** Performs the merge and returns whether it was successful or not. */ /** Performs the merge and returns whether it was successful or not. */
function performMerge(ignoreFatalErrors) { function performMerge(ignoreFatalErrors) {
@ -4322,7 +4369,7 @@ function mergePullRequest(prNumber, githubToken, flags) {
// Set the environment variable to skip all git commit hooks triggered by husky. We are unable to // Set the environment variable to skip all git commit hooks triggered by husky. We are unable to
// rely on `--no-verify` as some hooks still run, notably the `prepare-commit-msg` hook. // rely on `--no-verify` as some hooks still run, notably the `prepare-commit-msg` hook.
process.env['HUSKY'] = '0'; process.env['HUSKY'] = '0';
return [4 /*yield*/, createPullRequestMergeTask(githubToken, flags)]; return [4 /*yield*/, createPullRequestMergeTask(flags)];
case 1: case 1:
api = _a.sent(); api = _a.sent();
return [4 /*yield*/, performMerge(false)]; return [4 /*yield*/, performMerge(false)];
@ -4343,15 +4390,14 @@ function mergePullRequest(prNumber, githubToken, flags) {
* and optional explicit configuration. An explicit configuration can be specified * and optional explicit configuration. An explicit configuration can be specified
* when the merge script is used outside of a `ng-dev` configured repository. * when the merge script is used outside of a `ng-dev` configured repository.
*/ */
function createPullRequestMergeTask(githubToken, flags) { function createPullRequestMergeTask(flags) {
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
var projectRoot, devInfraConfig, git, _a, config, errors; var devInfraConfig, git, _a, config, errors;
return tslib.__generator(this, function (_b) { return tslib.__generator(this, function (_b) {
switch (_b.label) { switch (_b.label) {
case 0: case 0:
projectRoot = getRepoBaseDir();
devInfraConfig = getConfig(); devInfraConfig = getConfig();
git = new GitClient(githubToken, devInfraConfig, projectRoot); git = GitClient.getAuthenticatedInstance();
return [4 /*yield*/, loadAndValidateConfig(devInfraConfig, git.github)]; return [4 /*yield*/, loadAndValidateConfig(devInfraConfig, git.github)];
case 1: case 1:
_a = _b.sent(), config = _a.config, errors = _a.errors; _a = _b.sent(), config = _a.config, errors = _a.errors;
@ -4396,11 +4442,11 @@ function builder$6(yargs) {
} }
/** Handles the command. */ /** Handles the command. */
function handler$6(_a) { function handler$6(_a) {
var pr = _a.pr, githubToken = _a.githubToken, branchPrompt = _a.branchPrompt; var pr = _a.pr, branchPrompt = _a.branchPrompt;
return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__awaiter(this, void 0, void 0, function () {
return tslib.__generator(this, function (_b) { return tslib.__generator(this, function (_b) {
switch (_b.label) { switch (_b.label) {
case 0: return [4 /*yield*/, mergePullRequest(pr, githubToken, { branchPrompt: branchPrompt })]; case 0: return [4 /*yield*/, mergePullRequest(pr, { branchPrompt: branchPrompt })];
case 1: case 1:
_b.sent(); _b.sent();
return [2 /*return*/]; return [2 /*return*/];
@ -4423,7 +4469,7 @@ var MergeCommandModule = {
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
/* GraphQL schema for the response body for each pending PR. */ /* Graphql schema for the response body for each pending PR. */
const PR_SCHEMA$3 = { const PR_SCHEMA$3 = {
state: typedGraphqlify.types.string, state: typedGraphqlify.types.string,
maintainerCanModify: typedGraphqlify.types.boolean, maintainerCanModify: typedGraphqlify.types.boolean,
@ -4450,7 +4496,8 @@ const PR_SCHEMA$3 = {
*/ */
function rebasePr(prNumber, githubToken, config = getConfig()) { function rebasePr(prNumber, githubToken, config = getConfig()) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const git = new GitClient(githubToken); /** The singleton instance of the GitClient. */
const git = GitClient.getAuthenticatedInstance();
// TODO: Rely on a common assertNoLocalChanges function. // TODO: Rely on a common assertNoLocalChanges function.
if (git.hasLocalChanges()) { if (git.hasLocalChanges()) {
error('Cannot perform rebase of PR with local changes.'); error('Cannot perform rebase of PR with local changes.');
@ -5747,7 +5794,7 @@ class ReleaseAction {
return this._cachedForkRepo; return this._cachedForkRepo;
} }
const { owner, name } = this.git.remoteConfig; const { owner, name } = this.git.remoteConfig;
const result = yield this.git.github.graphql.query(findOwnedForksOfRepoQuery, { owner, name }); const result = yield this.git.github.graphql(findOwnedForksOfRepoQuery, { owner, name });
const forks = result.repository.forks.nodes; const forks = result.repository.forks.nodes;
if (forks.length === 0) { if (forks.length === 0) {
error(red(' ✘ Unable to find fork for currently authenticated user.')); error(red(' ✘ Unable to find fork for currently authenticated user.'));
@ -5800,7 +5847,7 @@ class ReleaseAction {
pushHeadToRemoteBranch(branchName) { pushHeadToRemoteBranch(branchName) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
// Push the local `HEAD` to the remote branch in the configured project. // Push the local `HEAD` to the remote branch in the configured project.
this.git.run(['push', this.git.repoGitUrl, `HEAD:refs/heads/${branchName}`]); this.git.run(['push', this.git.getRepoGitUrl(), `HEAD:refs/heads/${branchName}`]);
}); });
} }
/** /**
@ -5915,7 +5962,7 @@ class ReleaseAction {
/** Checks out an upstream branch with a detached head. */ /** Checks out an upstream branch with a detached head. */
checkoutUpstreamBranch(branchName) { checkoutUpstreamBranch(branchName) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
this.git.run(['fetch', '-q', this.git.repoGitUrl, branchName]); this.git.run(['fetch', '-q', this.git.getRepoGitUrl(), branchName]);
this.git.run(['checkout', 'FETCH_HEAD', '--detach']); this.git.run(['checkout', 'FETCH_HEAD', '--detach']);
}); });
} }
@ -6551,8 +6598,8 @@ class ReleaseTool {
this._github = _github; this._github = _github;
this._githubToken = _githubToken; this._githubToken = _githubToken;
this._projectRoot = _projectRoot; this._projectRoot = _projectRoot;
/** Client for interacting with the Github API and the local Git command. */ /** The singleton instance of the GitClient. */
this._git = new GitClient(this._githubToken, { github: this._github }, this._projectRoot); this._git = GitClient.getAuthenticatedInstance();
/** The previous git commit to return back to after the release tool runs. */ /** The previous git commit to return back to after the release tool runs. */
this.previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision(); this.previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision();
} }

View File

@ -17,8 +17,8 @@ export async function getTargetBranchesForPr(prNumber: number) {
const config = getConfig(); const config = getConfig();
/** Repo owner and name for the github repository. */ /** Repo owner and name for the github repository. */
const {owner, name: repo} = config.github; const {owner, name: repo} = config.github;
/** The git client to get a Github API service instance. */ /** The singleton instance of the GitClient. */
const git = new GitClient(undefined, config); const git = GitClient.getInstance();
/** The validated merge config. */ /** The validated merge config. */
const {config: mergeConfig, errors} = await loadAndValidateConfig(config, git.github); const {config: mergeConfig, errors} = await loadAndValidateConfig(config, git.github);
if (errors !== undefined) { if (errors !== undefined) {

View File

@ -6,31 +6,31 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {types as graphQLTypes} from 'typed-graphqlify'; import {types as graphqlTypes} from 'typed-graphqlify';
import {info} from '../../utils/console'; import {info} from '../../utils/console';
import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls';
import {GitClient} from '../../utils/git/index'; import {GitClient} from '../../utils/git/index';
import {getPr} from '../../utils/github'; import {getPr} from '../../utils/github';
/* GraphQL schema for the response body for a pending PR. */ /* Graphql schema for the response body for a pending PR. */
const PR_SCHEMA = { const PR_SCHEMA = {
state: graphQLTypes.string, state: graphqlTypes.string,
maintainerCanModify: graphQLTypes.boolean, maintainerCanModify: graphqlTypes.boolean,
viewerDidAuthor: graphQLTypes.boolean, viewerDidAuthor: graphqlTypes.boolean,
headRefOid: graphQLTypes.string, headRefOid: graphqlTypes.string,
headRef: { headRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
baseRef: { baseRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
}; };
@ -62,8 +62,8 @@ export interface PullRequestCheckoutOptions {
*/ */
export async function checkOutPullRequestLocally( export async function checkOutPullRequestLocally(
prNumber: number, githubToken: string, opts: PullRequestCheckoutOptions = {}) { prNumber: number, githubToken: string, opts: PullRequestCheckoutOptions = {}) {
/** Authenticated Git client for git and Github interactions. */ /** The singleton instance of the GitClient. */
const git = new GitClient(githubToken); const git = GitClient.getAuthenticatedInstance();
// In order to preserve local changes, checkouts cannot occur if local changes are present in the // In order to preserve local changes, checkouts cannot occur if local changes are present in the
// git environment. Checked before retrieving the PR to fail fast. // git environment. Checked before retrieving the PR to fail fast.

View File

@ -7,38 +7,37 @@
*/ */
import {Bar} from 'cli-progress'; import {Bar} from 'cli-progress';
import {types as graphQLTypes} from 'typed-graphqlify'; import {types as graphqlTypes} from 'typed-graphqlify';
import {getConfig, NgDevConfig} from '../../utils/config';
import {error, info} from '../../utils/console'; import {error, info} from '../../utils/console';
import {GitClient} from '../../utils/git/index'; import {GitClient} from '../../utils/git/index';
import {getPendingPrs} from '../../utils/github'; import {getPendingPrs} from '../../utils/github';
import {exec} from '../../utils/shelljs'; import {exec} from '../../utils/shelljs';
/* GraphQL schema for the response body for each pending PR. */ /* Graphql schema for the response body for each pending PR. */
const PR_SCHEMA = { const PR_SCHEMA = {
headRef: { headRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
baseRef: { baseRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
updatedAt: graphQLTypes.string, updatedAt: graphqlTypes.string,
number: graphQLTypes.number, number: graphqlTypes.number,
mergeable: graphQLTypes.string, mergeable: graphqlTypes.string,
title: graphQLTypes.string, title: graphqlTypes.string,
}; };
/* Pull Request response from Github GraphQL query */ /* Pull Request response from Github Graphql query */
type RawPullRequest = typeof PR_SCHEMA; type RawPullRequest = typeof PR_SCHEMA;
/** Convert raw Pull Request response from Github to usable Pull Request object. */ /** Convert raw Pull Request response from Github to usable Pull Request object. */
@ -53,9 +52,9 @@ type PullRequest = ReturnType<typeof processPr>;
const tempWorkingBranch = '__NgDevRepoBaseAfterChange__'; const tempWorkingBranch = '__NgDevRepoBaseAfterChange__';
/** Checks if the provided PR will cause new conflicts in other pending PRs. */ /** Checks if the provided PR will cause new conflicts in other pending PRs. */
export async function discoverNewConflictsForPr( export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfter: number) {
newPrNumber: number, updatedAfter: number, config: Pick<NgDevConfig, 'github'> = getConfig()) { /** The singleton instance of the GitClient. */
const git = new GitClient(); const git = GitClient.getAuthenticatedInstance();
// If there are any local changes in the current repository state, the // If there are any local changes in the current repository state, the
// check cannot run as it needs to move between branches. // check cannot run as it needs to move between branches.
if (git.hasLocalChanges()) { if (git.hasLocalChanges()) {

View File

@ -37,8 +37,8 @@ function builder(yargs: Argv) {
} }
/** Handles the command. */ /** Handles the command. */
async function handler({pr, githubToken, branchPrompt}: Arguments<MergeCommandOptions>) { async function handler({pr, branchPrompt}: Arguments<MergeCommandOptions>) {
await mergePullRequest(pr, githubToken, {branchPrompt}); await mergePullRequest(pr, {branchPrompt});
} }
/** yargs command module describing the command. */ /** yargs command module describing the command. */

View File

@ -29,13 +29,12 @@ import {MergeResult, MergeStatus, PullRequestMergeTask, PullRequestMergeTaskFlag
* @param projectRoot Path to the local Git project that is used for merging. * @param projectRoot Path to the local Git project that is used for merging.
* @param config Configuration for merging pull requests. * @param config Configuration for merging pull requests.
*/ */
export async function mergePullRequest( export async function mergePullRequest(prNumber: number, flags: PullRequestMergeTaskFlags) {
prNumber: number, githubToken: string, flags: PullRequestMergeTaskFlags) {
// Set the environment variable to skip all git commit hooks triggered by husky. We are unable to // Set the environment variable to skip all git commit hooks triggered by husky. We are unable to
// rely on `--no-verify` as some hooks still run, notably the `prepare-commit-msg` hook. // rely on `--no-verify` as some hooks still run, notably the `prepare-commit-msg` hook.
process.env['HUSKY'] = '0'; process.env['HUSKY'] = '0';
const api = await createPullRequestMergeTask(githubToken, flags); const api = await createPullRequestMergeTask(flags);
// Perform the merge. Force mode can be activated through a command line flag. // Perform the merge. Force mode can be activated through a command line flag.
// Alternatively, if the merge fails with non-fatal failures, the script // Alternatively, if the merge fails with non-fatal failures, the script
@ -127,10 +126,10 @@ export async function mergePullRequest(
* and optional explicit configuration. An explicit configuration can be specified * and optional explicit configuration. An explicit configuration can be specified
* when the merge script is used outside of a `ng-dev` configured repository. * when the merge script is used outside of a `ng-dev` configured repository.
*/ */
async function createPullRequestMergeTask(githubToken: string, flags: PullRequestMergeTaskFlags) { async function createPullRequestMergeTask(flags: PullRequestMergeTaskFlags) {
const projectRoot = getRepoBaseDir();
const devInfraConfig = getConfig(); const devInfraConfig = getConfig();
const git = new GitClient(githubToken, devInfraConfig, projectRoot); /** The singleton instance of the GitClient. */
const git = GitClient.getAuthenticatedInstance();
const {config, errors} = await loadAndValidateConfig(devInfraConfig, git.github); const {config, errors} = await loadAndValidateConfig(devInfraConfig, git.github);
if (errors) { if (errors) {

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {params, types as graphQLTypes} from 'typed-graphqlify'; import {params, types as graphqlTypes} from 'typed-graphqlify';
import {Commit, parseCommitMessage} from '../../commit-message/parse'; import {Commit, parseCommitMessage} from '../../commit-message/parse';
import {red, warn} from '../../utils/console'; import {red, warn} from '../../utils/console';
@ -133,28 +133,28 @@ export async function loadAndValidatePullRequest(
}; };
} }
/* GraphQL schema for the response body the requested pull request. */ /* Graphql schema for the response body the requested pull request. */
const PR_SCHEMA = { const PR_SCHEMA = {
url: graphQLTypes.string, url: graphqlTypes.string,
number: graphQLTypes.number, number: graphqlTypes.number,
// Only the last 100 commits from a pull request are obtained as we likely will never see a pull // Only the last 100 commits from a pull request are obtained as we likely will never see a pull
// requests with more than 100 commits. // requests with more than 100 commits.
commits: params({last: 100}, { commits: params({last: 100}, {
totalCount: graphQLTypes.number, totalCount: graphqlTypes.number,
nodes: [{ nodes: [{
commit: { commit: {
status: { status: {
state: graphQLTypes.oneOf(['FAILURE', 'PENDING', 'SUCCESS'] as const), state: graphqlTypes.oneOf(['FAILURE', 'PENDING', 'SUCCESS'] as const),
}, },
message: graphQLTypes.string, message: graphqlTypes.string,
}, },
}], }],
}), }),
baseRefName: graphQLTypes.string, baseRefName: graphqlTypes.string,
title: graphQLTypes.string, title: graphqlTypes.string,
labels: params({first: 100}, { labels: params({first: 100}, {
nodes: [{ nodes: [{
name: graphQLTypes.string, name: graphqlTypes.string,
}] }]
}), }),
}; };
@ -163,7 +163,7 @@ const PR_SCHEMA = {
/** Fetches a pull request from Github. Returns null if an error occurred. */ /** Fetches a pull request from Github. Returns null if an error occurred. */
async function fetchPullRequestFromGithub( async function fetchPullRequestFromGithub(
git: GitClient, prNumber: number): Promise<typeof PR_SCHEMA|null> { git: GitClient<true>, prNumber: number): Promise<typeof PR_SCHEMA|null> {
try { try {
const x = await getPr(PR_SCHEMA, prNumber, git); const x = await getPr(PR_SCHEMA, prNumber, git);
return x; return x;

View File

@ -37,7 +37,7 @@ const COMMIT_HEADER_SEPARATOR = '\n\n';
* is properly set, but a notable downside is that PRs cannot use fixup or squash commits. * is properly set, but a notable downside is that PRs cannot use fixup or squash commits.
*/ */
export class GithubApiMergeStrategy extends MergeStrategy { export class GithubApiMergeStrategy extends MergeStrategy {
constructor(git: GitClient, private _config: GithubApiMergeStrategyConfig) { constructor(git: GitClient<true>, private _config: GithubApiMergeStrategyConfig) {
super(git); super(git);
} }

View File

@ -22,7 +22,7 @@ export const TEMP_PR_HEAD_BRANCH = 'merge_pr_head';
* merges it into the determined target branches. * merges it into the determined target branches.
*/ */
export abstract class MergeStrategy { export abstract class MergeStrategy {
constructor(protected git: GitClient) {} constructor(protected git: GitClient<true>) {}
/** /**
* Prepares a merge of the given pull request. The strategy by default will * Prepares a merge of the given pull request. The strategy by default will
@ -124,7 +124,8 @@ export abstract class MergeStrategy {
}); });
// Fetch all target branches with a single command. We don't want to fetch them // Fetch all target branches with a single command. We don't want to fetch them
// individually as that could cause an unnecessary slow-down. // individually as that could cause an unnecessary slow-down.
this.git.run(['fetch', '-q', '-f', this.git.repoGitUrl, ...fetchRefspecs, ...extraRefspecs]); this.git.run(
['fetch', '-q', '-f', this.git.getRepoGitUrl(), ...fetchRefspecs, ...extraRefspecs]);
} }
/** Pushes the given target branches upstream. */ /** Pushes the given target branches upstream. */
@ -135,6 +136,6 @@ export abstract class MergeStrategy {
}); });
// Push all target branches with a single command if we don't run in dry-run mode. // Push all target branches with a single command if we don't run in dry-run mode.
// We don't want to push them individually as that could cause an unnecessary slow-down. // We don't want to push them individually as that could cause an unnecessary slow-down.
this.git.run(['push', this.git.repoGitUrl, ...pushRefspecs]); this.git.run(['push', this.git.getRepoGitUrl(), ...pushRefspecs]);
} }
} }

View File

@ -51,7 +51,7 @@ export class PullRequestMergeTask {
private flags: PullRequestMergeTaskFlags; private flags: PullRequestMergeTaskFlags;
constructor( constructor(
public config: MergeConfigWithRemote, public git: GitClient, public config: MergeConfigWithRemote, public git: GitClient<true>,
flags: Partial<PullRequestMergeTaskFlags>) { flags: Partial<PullRequestMergeTaskFlags>) {
// Update flags property with the provided flags values as patches to the default flag values. // Update flags property with the provided flags values as patches to the default flag values.
this.flags = {...defaultPullRequestMergeTaskFlags, ...flags}; this.flags = {...defaultPullRequestMergeTaskFlags, ...flags};

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {types as graphQLTypes} from 'typed-graphqlify'; import {types as graphqlTypes} from 'typed-graphqlify';
import {Commit} from '../../commit-message/parse'; import {Commit} from '../../commit-message/parse';
import {getCommitsInRange} from '../../commit-message/utils'; import {getCommitsInRange} from '../../commit-message/utils';
@ -16,24 +16,24 @@ import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls';
import {GitClient} from '../../utils/git/index'; import {GitClient} from '../../utils/git/index';
import {getPr} from '../../utils/github'; import {getPr} from '../../utils/github';
/* GraphQL schema for the response body for each pending PR. */ /* Graphql schema for the response body for each pending PR. */
const PR_SCHEMA = { const PR_SCHEMA = {
state: graphQLTypes.string, state: graphqlTypes.string,
maintainerCanModify: graphQLTypes.boolean, maintainerCanModify: graphqlTypes.boolean,
viewerDidAuthor: graphQLTypes.boolean, viewerDidAuthor: graphqlTypes.boolean,
headRefOid: graphQLTypes.string, headRefOid: graphqlTypes.string,
headRef: { headRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
baseRef: { baseRef: {
name: graphQLTypes.string, name: graphqlTypes.string,
repository: { repository: {
url: graphQLTypes.string, url: graphqlTypes.string,
nameWithOwner: graphQLTypes.string, nameWithOwner: graphqlTypes.string,
}, },
}, },
}; };
@ -44,7 +44,8 @@ const PR_SCHEMA = {
*/ */
export async function rebasePr( export async function rebasePr(
prNumber: number, githubToken: string, config: Pick<NgDevConfig, 'github'> = getConfig()) { prNumber: number, githubToken: string, config: Pick<NgDevConfig, 'github'> = getConfig()) {
const git = new GitClient(githubToken); /** The singleton instance of the GitClient. */
const git = GitClient.getAuthenticatedInstance();
// TODO: Rely on a common assertNoLocalChanges function. // TODO: Rely on a common assertNoLocalChanges function.
if (git.hasLocalChanges()) { if (git.hasLocalChanges()) {
error('Cannot perform rebase of PR with local changes.'); error('Cannot perform rebase of PR with local changes.');

View File

@ -49,7 +49,7 @@ export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseActio
/** Whether the release action is currently active. */ /** Whether the release action is currently active. */
isActive(active: ActiveReleaseTrains): Promise<boolean>; isActive(active: ActiveReleaseTrains): Promise<boolean>;
/** Constructs a release action. */ /** Constructs a release action. */
new(...args: [ActiveReleaseTrains, GitClient, ReleaseConfig, string]): T; new(...args: [ActiveReleaseTrains, GitClient<true>, ReleaseConfig, string]): T;
} }
/** /**
@ -76,7 +76,7 @@ export abstract class ReleaseAction {
private _cachedForkRepo: GithubRepo|null = null; private _cachedForkRepo: GithubRepo|null = null;
constructor( constructor(
protected active: ActiveReleaseTrains, protected git: GitClient, protected active: ActiveReleaseTrains, protected git: GitClient<true>,
protected config: ReleaseConfig, protected projectDir: string) {} protected config: ReleaseConfig, protected projectDir: string) {}
/** Updates the version in the project top-level `package.json` file. */ /** Updates the version in the project top-level `package.json` file. */
@ -182,7 +182,7 @@ export abstract class ReleaseAction {
} }
const {owner, name} = this.git.remoteConfig; const {owner, name} = this.git.remoteConfig;
const result = await this.git.github.graphql.query(findOwnedForksOfRepoQuery, {owner, name}); const result = await this.git.github.graphql(findOwnedForksOfRepoQuery, {owner, name});
const forks = result.repository.forks.nodes; const forks = result.repository.forks.nodes;
if (forks.length === 0) { if (forks.length === 0) {
@ -232,7 +232,7 @@ export abstract class ReleaseAction {
/** Pushes the current Git `HEAD` to the given remote branch in the configured project. */ /** Pushes the current Git `HEAD` to the given remote branch in the configured project. */
protected async pushHeadToRemoteBranch(branchName: string) { protected async pushHeadToRemoteBranch(branchName: string) {
// Push the local `HEAD` to the remote branch in the configured project. // Push the local `HEAD` to the remote branch in the configured project.
this.git.run(['push', this.git.repoGitUrl, `HEAD:refs/heads/${branchName}`]); this.git.run(['push', this.git.getRepoGitUrl(), `HEAD:refs/heads/${branchName}`]);
} }
/** /**
@ -360,7 +360,7 @@ export abstract class ReleaseAction {
/** Checks out an upstream branch with a detached head. */ /** Checks out an upstream branch with a detached head. */
protected async checkoutUpstreamBranch(branchName: string) { protected async checkoutUpstreamBranch(branchName: string) {
this.git.run(['fetch', '-q', this.git.repoGitUrl, branchName]); this.git.run(['fetch', '-q', this.git.getRepoGitUrl(), branchName]);
this.git.run(['checkout', 'FETCH_HEAD', '--detach']); this.git.run(['checkout', 'FETCH_HEAD', '--detach']);
} }

View File

@ -28,8 +28,8 @@ export enum CompletionState {
} }
export class ReleaseTool { export class ReleaseTool {
/** Client for interacting with the Github API and the local Git command. */ /** The singleton instance of the GitClient. */
private _git = new GitClient(this._githubToken, {github: this._github}, this._projectRoot); private _git = GitClient.getAuthenticatedInstance();
/** The previous git commit to return back to after the release tool runs. */ /** The previous git commit to return back to after the release tool runs. */
private previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision(); private previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision();

View File

@ -16,7 +16,8 @@ const THIRTY_SECONDS_IN_MS = 30000;
export type PullRequestState = 'merged'|'closed'|'open'; export type PullRequestState = 'merged'|'closed'|'open';
/** Gets whether a given pull request has been merged. */ /** Gets whether a given pull request has been merged. */
export async function getPullRequestState(api: GitClient, id: number): Promise<PullRequestState> { export async function getPullRequestState(
api: GitClient<boolean>, id: number): Promise<PullRequestState> {
const {data} = await api.github.pulls.get({...api.remoteParams, pull_number: id}); const {data} = await api.github.pulls.get({...api.remoteParams, pull_number: id});
if (data.merged) { if (data.merged) {
return 'merged'; return 'merged';
@ -38,7 +39,7 @@ export async function getPullRequestState(api: GitClient, id: number): Promise<P
* the merge is not fast-forward, Github does not consider the PR as merged and instead * the merge is not fast-forward, Github does not consider the PR as merged and instead
* shows the PR as closed. See for example: https://github.com/angular/angular/pull/37918. * shows the PR as closed. See for example: https://github.com/angular/angular/pull/37918.
*/ */
async function isPullRequestClosedWithAssociatedCommit(api: GitClient, id: number) { async function isPullRequestClosedWithAssociatedCommit(api: GitClient<boolean>, id: number) {
const request = const request =
api.github.issues.listEvents.endpoint.merge({...api.remoteParams, issue_number: id}); api.github.issues.listEvents.endpoint.merge({...api.remoteParams, issue_number: id});
const events: Octokit.IssuesListEventsResponse = await api.github.paginate(request); const events: Octokit.IssuesListEventsResponse = await api.github.paginate(request);
@ -72,7 +73,7 @@ async function isPullRequestClosedWithAssociatedCommit(api: GitClient, id: numbe
} }
/** Checks whether the specified commit is closing the given pull request. */ /** Checks whether the specified commit is closing the given pull request. */
async function isCommitClosingPullRequest(api: GitClient, sha: string, id: number) { async function isCommitClosingPullRequest(api: GitClient<boolean>, sha: string, id: number) {
const {data} = await api.github.repos.getCommit({...api.remoteParams, ref: sha}); const {data} = await api.github.repos.getCommit({...api.remoteParams, ref: sha});
// Matches the closing keyword supported in commit messages. See: // Matches the closing keyword supported in commit messages. See:
// https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords. // https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords.

View File

@ -38,7 +38,7 @@ export function getLocalChangelogFilePath(projectDir: string): string {
/** Release note generation. */ /** Release note generation. */
export class ReleaseNotes { export class ReleaseNotes {
/** An instance of GitClient. */ /** An instance of GitClient. */
private git = new GitClient(); private git = GitClient.getInstance();
/** The github configuration. */ /** The github configuration. */
private readonly github = getConfig().github; private readonly github = getConfig().github;
/** The configuration for the release notes generation. */ /** The configuration for the release notes generation. */

View File

@ -33,7 +33,7 @@ export const testTmpDir: string = process.env['TEST_TMPDIR']!;
/** Interface describing a test release action. */ /** Interface describing a test release action. */
export interface TestReleaseAction<T extends ReleaseAction = ReleaseAction> { export interface TestReleaseAction<T extends ReleaseAction = ReleaseAction> {
instance: T; instance: T;
gitClient: VirtualGitClient; gitClient: VirtualGitClient<boolean>;
repo: GithubTestingRepo; repo: GithubTestingRepo;
fork: GithubTestingRepo; fork: GithubTestingRepo;
testTmpDir: string; testTmpDir: string;
@ -44,7 +44,7 @@ export interface TestReleaseAction<T extends ReleaseAction = ReleaseAction> {
/** Gets necessary test mocks for running a release action. */ /** Gets necessary test mocks for running a release action. */
export function getTestingMocksForReleaseAction() { export function getTestingMocksForReleaseAction() {
const githubConfig = {owner: 'angular', name: 'dev-infra-test'}; const githubConfig = {owner: 'angular', name: 'dev-infra-test'};
const gitClient = new VirtualGitClient(undefined, {github: githubConfig}, testTmpDir); const gitClient = VirtualGitClient.getAuthenticatedInstance({github: githubConfig});
const releaseConfig: ReleaseConfig = { const releaseConfig: ReleaseConfig = {
npmPackages: [ npmPackages: [
'@angular/pkg1', '@angular/pkg1',

View File

@ -37,6 +37,6 @@ export function getRepositoryGitUrl(config: GithubConfig, githubToken?: string):
} }
/** Gets a Github URL that refers to a list of recent commits within a specified branch. */ /** Gets a Github URL that refers to a list of recent commits within a specified branch. */
export function getListCommitsInBranchUrl({remoteParams}: GitClient, branchName: string) { export function getListCommitsInBranchUrl({remoteParams}: GitClient<boolean>, branchName: string) {
return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`; return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`;
} }

View File

@ -7,8 +7,11 @@
*/ */
import {Argv} from 'yargs'; import {Argv} from 'yargs';
import {error, red, yellow} from '../console'; import {error, red, yellow} from '../console';
import {GITHUB_TOKEN_GENERATE_URL} from './github-urls'; import {GITHUB_TOKEN_GENERATE_URL} from './github-urls';
import {GitClient} from './index';
export type ArgvWithGithubToken = Argv<{githubToken: string}>; export type ArgvWithGithubToken = Argv<{githubToken: string}>;
@ -29,6 +32,7 @@ export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken {
error(yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`)); error(yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`));
process.exit(1); process.exit(1);
} }
GitClient.authenticateWithToken(githubToken);
return githubToken; return githubToken;
}, },
}) })

View File

@ -11,6 +11,12 @@ import * as Octokit from '@octokit/rest';
import {RequestParameters} from '@octokit/types'; import {RequestParameters} from '@octokit/types';
import {query, types} from 'typed-graphqlify'; import {query, types} from 'typed-graphqlify';
/**
* An object representation of a Graphql Query to be used as a response type and
* to generate a Graphql query string.
*/
export type GraphqlQueryObject = Parameters<typeof query>[1];
/** Interface describing a Github repository. */ /** Interface describing a Github repository. */
export interface GithubRepo { export interface GithubRepo {
/** Owner login of the repository. */ /** Owner login of the repository. */
@ -26,6 +32,9 @@ export class GithubApiRequestError extends Error {
} }
} }
/** Error for failed Github API requests. */
export class GithubGraphqlClientError extends Error {}
/** /**
* A Github client for interacting with the Github APIs. * A Github client for interacting with the Github APIs.
* *
@ -33,13 +42,15 @@ export class GithubApiRequestError extends Error {
* would provide value from memoized style responses. * would provide value from memoized style responses.
**/ **/
export class GithubClient extends Octokit { export class GithubClient extends Octokit {
/** The Github GraphQL (v4) API. */
graphql: GithubGraphqlClient;
/** The current user based on checking against the Github API. */ /** The current user based on checking against the Github API. */
private _currentUser: string|null = null; private _currentUser: string|null = null;
/** The graphql instance with authentication set during construction. */
private _graphql = graphql.defaults({headers: {authorization: `token ${this.token}`}});
constructor(token?: string) { /**
* @param token The github authentication token for Github Rest and Graphql API requests.
*/
constructor(private token?: string) {
// Pass in authentication token to base Octokit class. // Pass in authentication token to base Octokit class.
super({auth: token}); super({auth: token});
@ -49,8 +60,22 @@ export class GithubClient extends Octokit {
throw new GithubApiRequestError(error.status, error.message); throw new GithubApiRequestError(error.status, error.message);
}); });
// Create authenticated graphql client. // Note: The prototype must be set explictly as Github's Octokit class is a non-standard class
this.graphql = new GithubGraphqlClient(token); // definition which adjusts the prototype chain.
// See:
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
// https://github.com/octokit/rest.js/blob/7b51cee4a22b6e52adcdca011f93efdffa5df998/lib/constructor.js
Object.setPrototypeOf(this, GithubClient.prototype);
}
/** Perform a query using Github's Graphql API. */
async graphql<T extends GraphqlQueryObject>(queryObject: T, params: RequestParameters = {}) {
if (this.token === undefined) {
throw new GithubGraphqlClientError(
'Cannot query via graphql without an authentication token set, use the authenticated ' +
'`GitClient` by calling `GitClient.getAuthenticatedInstance()`.');
}
return (await this._graphql(query(queryObject), params)) as T;
} }
/** Retrieve the login of the current user from Github. */ /** Retrieve the login of the current user from Github. */
@ -59,7 +84,7 @@ export class GithubClient extends Octokit {
if (this._currentUser !== null) { if (this._currentUser !== null) {
return this._currentUser; return this._currentUser;
} }
const result = await this.graphql.query({ const result = await this.graphql({
viewer: { viewer: {
login: types.string, login: types.string,
} }
@ -67,29 +92,3 @@ export class GithubClient extends Octokit {
return this._currentUser = result.viewer.login; return this._currentUser = result.viewer.login;
} }
} }
/**
* An object representation of a GraphQL Query to be used as a response type and
* to generate a GraphQL query string.
*/
export type GraphQLQueryObject = Parameters<typeof query>[1];
/** A client for interacting with Github's GraphQL API. */
export class GithubGraphqlClient {
/** The Github GraphQL (v4) API. */
private graqhql = graphql;
constructor(token?: string) {
// Set the default headers to include authorization with the provided token for all
// graphQL calls.
if (token) {
this.graqhql = this.graqhql.defaults({headers: {authorization: `token ${token}`}});
}
}
/** Perform a query using Github's GraphQL API. */
async query<T extends GraphQLQueryObject>(queryObject: T, params: RequestParameters = {}) {
const queryString = query(queryObject);
return (await this.graqhql(queryString, params)) as T;
}
}

View File

@ -10,7 +10,7 @@ import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {Options as SemVerOptions, parse, SemVer} from 'semver'; import {Options as SemVerOptions, parse, SemVer} from 'semver';
import {getConfig, getRepoBaseDir, NgDevConfig} from '../config'; import {getConfig, getRepoBaseDir} from '../config';
import {debug, info, yellow} from '../console'; import {debug, info, yellow} from '../console';
import {DryRunError, isDryRun} from '../dry-run'; import {DryRunError, isDryRun} from '../dry-run';
import {GithubClient} from './github'; import {GithubClient} from './github';
@ -26,7 +26,7 @@ export type OAuthScopeTestFunction = (scopes: string[], missing: string[]) => vo
/** Error for failed Git commands. */ /** Error for failed Git commands. */
export class GitCommandError extends Error { export class GitCommandError extends Error {
constructor(client: GitClient, public args: string[]) { constructor(client: GitClient<boolean>, public args: string[]) {
// Errors are not guaranteed to be caught. To ensure that we don't // Errors are not guaranteed to be caught. To ensure that we don't
// accidentally leak the Github token that might be used in a command, // accidentally leak the Github token that might be used in a command,
// we sanitize the command that will be part of the error message. // we sanitize the command that will be part of the error message.
@ -43,18 +43,49 @@ export class GitCommandError extends Error {
* `config`: The dev-infra configuration containing information about the remote. By default * `config`: The dev-infra configuration containing information about the remote. By default
* the dev-infra configuration is loaded with its Github configuration. * the dev-infra configuration is loaded with its Github configuration.
**/ **/
export class GitClient { export class GitClient<Authenticated extends boolean> {
/** Whether verbose logging of Git actions should be used. */ /*************************************************
static LOG_COMMANDS = true; * Singleton definition and configuration. *
/** Short-hand for accessing the default remote configuration. */ *************************************************/
remoteConfig = this._config.github; /** The singleton instance of the authenticated GitClient. */
/** Octokit request parameters object for targeting the configured remote. */ private static authenticated: GitClient<true>;
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name}; /** The singleton instance of the unauthenticated GitClient. */
/** Git URL that resolves to the configured repository. */ private static unauthenticated: GitClient<false>;
repoGitUrl = getRepositoryGitUrl(this.remoteConfig, this.githubToken);
/** Instance of the authenticated Github octokit API. */
github = new GithubClient(this.githubToken);
/**
* Static method to get the singleton instance of the unauthorized GitClient, creating it if it
* has not yet been created.
*/
static getInstance() {
if (!GitClient.unauthenticated) {
GitClient.unauthenticated = new GitClient(undefined);
}
return GitClient.unauthenticated;
}
/**
* Static method to get the singleton instance of the authenticated GitClient if it has been
* generated.
*/
static getAuthenticatedInstance() {
if (!GitClient.authenticated) {
throw Error('The authenticated GitClient has not yet been generated.');
}
return GitClient.authenticated;
}
/** Build the authenticated GitClient instance. */
static authenticateWithToken(token: string) {
if (GitClient.authenticated) {
throw Error(
'Cannot generate new authenticated GitClient after one has already been generated.');
}
GitClient.authenticated = new GitClient(token);
}
/** Whether verbose logging of Git actions should be used. */
private verboseLogging = true;
/** The OAuth scopes available for the provided Github token. */ /** The OAuth scopes available for the provided Github token. */
private _cachedOauthScopes: Promise<string[]>|null = null; private _cachedOauthScopes: Promise<string[]>|null = null;
/** /**
@ -62,18 +93,36 @@ export class GitClient {
* sanitizing the token from Git child process output. * sanitizing the token from Git child process output.
*/ */
private _githubTokenRegex: RegExp|null = null; private _githubTokenRegex: RegExp|null = null;
/** Short-hand for accessing the default remote configuration. */
remoteConfig = this._config.github;
/** Octokit request parameters object for targeting the configured remote. */
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
/** Instance of the authenticated Github octokit API. */
github = new GithubClient(this.githubToken);
constructor( /**
public githubToken?: string, private _config: Pick<NgDevConfig, 'github'> = getConfig(), * @param githubToken The github token used for authentication, if provided.
private _projectRoot = getRepoBaseDir()) { * @param _config The configuration, containing the github specific configuration.
* @param _projectRoot The full path to the root of the repository base.
*/
protected constructor(public githubToken:
Authenticated extends true? string: undefined,
private _config = getConfig(),
private _projectRoot = getRepoBaseDir()) {
// If a token has been specified (and is not empty), pass it to the Octokit API and // If a token has been specified (and is not empty), pass it to the Octokit API and
// also create a regular expression that can be used for sanitizing Git command output // also create a regular expression that can be used for sanitizing Git command output
// so that it does not print the token accidentally. // so that it does not print the token accidentally.
if (githubToken != null) { if (typeof githubToken === 'string') {
this._githubTokenRegex = new RegExp(githubToken, 'g'); this._githubTokenRegex = new RegExp(githubToken, 'g');
} }
} }
/** Set the verbose logging state of the GitClient instance. */
setVerboseLoggingState(verbose: boolean): this {
this.verboseLogging = verbose;
return this;
}
/** Executes the given git command. Throws if the command fails. */ /** Executes the given git command. Throws if the command fails. */
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> { run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
const result = this.runGraceful(args, options); const result = this.runGraceful(args, options);
@ -102,7 +151,7 @@ export class GitClient {
// To improve the debugging experience in case something fails, we print all executed Git // To improve the debugging experience in case something fails, we print all executed Git
// commands to better understand the git actions occuring. Depending on the command being // commands to better understand the git actions occuring. Depending on the command being
// executed, this debugging information should be logged at different logging levels. // executed, this debugging information should be logged at different logging levels.
const printFn = (!GitClient.LOG_COMMANDS || options.stdio === 'ignore') ? debug : info; const printFn = (!this.verboseLogging || options.stdio === 'ignore') ? debug : info;
// Note that we do not want to print the token if it is contained in the command. It's common // Note that we do not want to print the token if it is contained in the command. It's common
// to share errors with others if the tool failed, and we do not want to leak tokens. // to share errors with others if the tool failed, and we do not want to leak tokens.
printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' '))); printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
@ -126,6 +175,11 @@ export class GitClient {
return result; return result;
} }
/** Git URL that resolves to the configured repository. */
getRepoGitUrl() {
return getRepositoryGitUrl(this.remoteConfig, this.githubToken);
}
/** Whether the given branch contains the specified SHA. */ /** Whether the given branch contains the specified SHA. */
hasCommit(branchName: string, sha: string): boolean { hasCommit(branchName: string, sha: string): boolean {
return this.run(['branch', branchName, '--contains', sha]).stdout !== ''; return this.run(['branch', branchName, '--contains', sha]).stdout !== '';

View File

@ -11,10 +11,10 @@ import {params, types} from 'typed-graphqlify';
import {GitClient} from './git/index'; import {GitClient} from './git/index';
/** Get a PR from github */ /** Get a PR from github */
export async function getPr<PrSchema>(prSchema: PrSchema, prNumber: number, git: GitClient) { export async function getPr<PrSchema>(prSchema: PrSchema, prNumber: number, git: GitClient<true>) {
/** The owner and name of the repository */ /** The owner and name of the repository */
const {owner, name} = git.remoteConfig; const {owner, name} = git.remoteConfig;
/** The GraphQL query object to get a the PR */ /** The Graphql query object to get a the PR */
const PR_QUERY = params( const PR_QUERY = params(
{ {
$number: 'Int!', // The PR number $number: 'Int!', // The PR number
@ -27,15 +27,15 @@ export async function getPr<PrSchema>(prSchema: PrSchema, prNumber: number, git:
}) })
}); });
const result = (await git.github.graphql.query(PR_QUERY, {number: prNumber, owner, name})); const result = (await git.github.graphql(PR_QUERY, {number: prNumber, owner, name}));
return result.repository.pullRequest; return result.repository.pullRequest;
} }
/** Get all pending PRs from github */ /** Get all pending PRs from github */
export async function getPendingPrs<PrSchema>(prSchema: PrSchema, git: GitClient) { export async function getPendingPrs<PrSchema>(prSchema: PrSchema, git: GitClient<true>) {
/** The owner and name of the repository */ /** The owner and name of the repository */
const {owner, name} = git.remoteConfig; const {owner, name} = git.remoteConfig;
/** The GraphQL query object to get a page of pending PRs */ /** The Graphql query object to get a page of pending PRs */
const PRS_QUERY = params( const PRS_QUERY = params(
{ {
$first: 'Int', // How many entries to get with each request $first: 'Int', // How many entries to get with each request
@ -75,7 +75,7 @@ export async function getPendingPrs<PrSchema>(prSchema: PrSchema, git: GitClient
owner, owner,
name, name,
}; };
const results = await git.github.graphql.query(PRS_QUERY, params) as typeof PRS_QUERY; const results = await git.github.graphql(PRS_QUERY, params) as typeof PRS_QUERY;
prs.push(...results.repository.pullRequests.nodes); prs.push(...results.repository.pullRequests.nodes);
hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage; hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage;
cursor = results.repository.pullRequests.pageInfo.endCursor; cursor = results.repository.pullRequests.pageInfo.endCursor;

View File

@ -58,7 +58,21 @@ export interface Commit {
* Virtual git client that mocks Git commands and keeps track of the repository state * Virtual git client that mocks Git commands and keeps track of the repository state
* in memory. This allows for convenient test assertions with Git interactions. * in memory. This allows for convenient test assertions with Git interactions.
*/ */
export class VirtualGitClient extends GitClient { export class VirtualGitClient<Authenticated extends boolean> extends GitClient<Authenticated> {
static getInstance(config = mockNgDevConfig, tmpDir = testTmpDir): VirtualGitClient<false> {
return new VirtualGitClient(undefined, config, tmpDir);
}
static getAuthenticatedInstance(config = mockNgDevConfig, tmpDir = testTmpDir):
VirtualGitClient<true> {
return new VirtualGitClient('abc123', config, tmpDir);
}
private constructor(token: Authenticated extends true? string: undefined, config: NgDevConfig,
tmpDir: string) {
super(token, config, tmpDir);
}
/** Current Git HEAD that has been previously fetched. */ /** Current Git HEAD that has been previously fetched. */
fetchHeadRef: RemoteRef|null = null; fetchHeadRef: RemoteRef|null = null;
/** List of known branches in the repository. */ /** List of known branches in the repository. */
@ -190,10 +204,10 @@ export class VirtualGitClient extends GitClient {
} }
/** export function installVirtualGitClientSpies() {
* Builds a Virtual Git Client instance with the provided config and set the temporary test const authenticatedVirtualGitClient = VirtualGitClient.getAuthenticatedInstance();
* directory. spyOn(GitClient, 'getAuthenticatedInstance').and.returnValue(authenticatedVirtualGitClient);
*/
export function buildVirtualGitClient(config = mockNgDevConfig, tmpDir = testTmpDir) { const unauthenticatedVirtualGitClient = VirtualGitClient.getInstance();
return (new VirtualGitClient(undefined, config, tmpDir)); spyOn(GitClient, 'getInstance').and.returnValue(unauthenticatedVirtualGitClient);
} }

View File

@ -27,13 +27,13 @@ export function getBranchPushMatcher(options: BranchPushMatchParameters) {
const {targetRepo, targetBranch, baseBranch, baseRepo, expectedCommits} = options; const {targetRepo, targetBranch, baseBranch, baseRepo, expectedCommits} = options;
return jasmine.objectContaining({ return jasmine.objectContaining({
remote: { remote: {
repoUrl: `https://github.com/${targetRepo.owner}/${targetRepo.name}.git`, repoUrl: `https://abc123@github.com/${targetRepo.owner}/${targetRepo.name}.git`,
name: `refs/heads/${targetBranch}` name: `refs/heads/${targetBranch}`
}, },
head: jasmine.objectContaining({ head: jasmine.objectContaining({
newCommits: expectedCommits, newCommits: expectedCommits,
ref: { ref: {
repoUrl: `https://github.com/${baseRepo.owner}/${baseRepo.name}.git`, repoUrl: `https://abc123@github.com/${baseRepo.owner}/${baseRepo.name}.git`,
name: baseBranch, name: baseBranch,
}, },
}) })