Skip to content

Commit e6a68b3

Browse files
authored
Add ability to define repo lists in a file outside of settings (#1402)
1 parent 539a494 commit e6a68b3

File tree

3 files changed

+215
-27
lines changed

3 files changed

+215
-27
lines changed

extensions/ql-vscode/src/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,21 @@ export async function setRemoteRepositoryLists(lists: Record<string, string[]> |
342342
await REMOTE_REPO_LISTS.updateValue(lists, ConfigurationTarget.Global);
343343
}
344344

345+
/**
346+
* Path to a file that contains lists of GitHub repositories that you want to query remotely via
347+
* the "Run Variant Analysis" command.
348+
* Note: This command is only available for internal users.
349+
*
350+
* This setting should be a path to a JSON file that contains a JSON object where each key is a
351+
* user-specified name (string), and the value is an array of GitHub repositories
352+
* (of the form `<owner>/<repo>`).
353+
*/
354+
const REPO_LISTS_PATH = new Setting('repositoryListsPath', REMOTE_QUERIES_SETTING);
355+
356+
export function getRemoteRepositoryListsPath(): string | undefined {
357+
return REPO_LISTS_PATH.getValue<string>() || undefined;
358+
}
359+
345360
/**
346361
* The name of the "controller" repository that you want to use with the "Run Variant Analysis" command.
347362
* Note: This command is only available for internal users.

extensions/ql-vscode/src/remote-queries/repository-selection.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import * as fs from 'fs-extra';
12
import { QuickPickItem, window } from 'vscode';
23
import { logger } from '../logging';
3-
import { getRemoteRepositoryLists } from '../config';
4+
import { getRemoteRepositoryLists, getRemoteRepositoryListsPath } from '../config';
45
import { OWNER_REGEX, REPO_REGEX } from '../pure/helpers-pure';
56
import { UserCancellationException } from '../commandRunner';
67

@@ -17,6 +18,11 @@ interface RepoListQuickPickItem extends QuickPickItem {
1718
useAllReposOfOwner?: boolean;
1819
}
1920

21+
interface RepoList {
22+
label: string;
23+
repositories: string[];
24+
}
25+
2026
/**
2127
* Gets the repositories or repository lists to run the query against.
2228
* @returns The user selection.
@@ -26,7 +32,7 @@ export async function getRepositorySelection(): Promise<RepositorySelection> {
2632
createCustomRepoQuickPickItem(),
2733
createAllReposOfOwnerQuickPickItem(),
2834
...createSystemDefinedRepoListsQuickPickItems(),
29-
...createUserDefinedRepoListsQuickPickItems(),
35+
...(await createUserDefinedRepoListsQuickPickItems()),
3036
];
3137

3238
const options = {
@@ -88,20 +94,81 @@ function createSystemDefinedRepoListsQuickPickItems(): RepoListQuickPickItem[] {
8894
} as RepoListQuickPickItem));
8995
}
9096

91-
function createUserDefinedRepoListsQuickPickItems(): RepoListQuickPickItem[] {
97+
async function readExternalRepoLists(): Promise<RepoList[]> {
98+
const repoLists: RepoList[] = [];
99+
100+
const path = getRemoteRepositoryListsPath();
101+
if (!path) {
102+
return repoLists;
103+
}
104+
105+
await validateExternalRepoListsFile(path);
106+
const json = await readExternalRepoListsJson(path);
107+
108+
for (const [repoListName, repositories] of Object.entries(json)) {
109+
if (!Array.isArray(repositories)) {
110+
throw Error('Invalid repository lists file. It should contain an array of repositories for each list.');
111+
}
112+
113+
repoLists.push({
114+
label: repoListName,
115+
repositories
116+
});
117+
}
118+
119+
return repoLists;
120+
}
121+
122+
async function validateExternalRepoListsFile(path: string): Promise<void> {
123+
const pathExists = await fs.pathExists(path);
124+
if (!pathExists) {
125+
throw Error(`External repository lists file does not exist at ${path}`);
126+
}
127+
128+
const pathStat = await fs.stat(path);
129+
if (pathStat.isDirectory()) {
130+
throw Error('External repository lists path should not point to a directory');
131+
}
132+
}
133+
134+
async function readExternalRepoListsJson(path: string): Promise<Record<string, unknown>> {
135+
let json;
136+
137+
try {
138+
const fileContents = await fs.readFile(path, 'utf8');
139+
json = await JSON.parse(fileContents);
140+
} catch (error) {
141+
throw Error('Invalid repository lists file. It should contain valid JSON.');
142+
}
143+
144+
if (Array.isArray(json)) {
145+
throw Error('Invalid repository lists file. It should be an object mapping names to a list of repositories.');
146+
}
147+
148+
return json;
149+
}
150+
151+
function readRepoListsFromSettings(): RepoList[] {
92152
const repoLists = getRemoteRepositoryLists();
93153
if (!repoLists) {
94154
return [];
95155
}
96156

97-
return Object.entries(repoLists).map<RepoListQuickPickItem>(([label, repositories]) => (
157+
return Object.entries(repoLists).map<RepoList>(([label, repositories]) => (
98158
{
99-
label, // the name of the repository list
100-
repositories // the actual array of repositories
159+
label,
160+
repositories
101161
}
102162
));
103163
}
104164

165+
async function createUserDefinedRepoListsQuickPickItems(): Promise<RepoListQuickPickItem[]> {
166+
const repoListsFromSetings = readRepoListsFromSettings();
167+
const repoListsFromExternalFile = await readExternalRepoLists();
168+
169+
return [...repoListsFromSetings, ...repoListsFromExternalFile];
170+
}
171+
105172
function createCustomRepoQuickPickItem(): RepoListQuickPickItem {
106173
return {
107174
label: '$(edit) Enter a GitHub repository',

extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/repository-selection.test.ts

Lines changed: 127 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,57 @@ import * as sinon from 'sinon';
22
import { expect } from 'chai';
33
import { window } from 'vscode';
44
import * as pq from 'proxyquire';
5+
import * as fs from 'fs-extra';
56
import { UserCancellationException } from '../../../commandRunner';
67

78
const proxyquire = pq.noPreserveCache();
89

9-
describe('repository-selection', function() {
10-
11-
describe('getRepositorySelection', () => {
12-
let sandbox: sinon.SinonSandbox;
13-
let quickPickSpy: sinon.SinonStub;
14-
let showInputBoxSpy: sinon.SinonStub;
15-
let getRemoteRepositoryListsSpy: sinon.SinonStub;
16-
let mod: any;
17-
beforeEach(() => {
18-
sandbox = sinon.createSandbox();
19-
quickPickSpy = sandbox.stub(window, 'showQuickPick');
20-
showInputBoxSpy = sandbox.stub(window, 'showInputBox');
21-
getRemoteRepositoryListsSpy = sandbox.stub();
22-
mod = proxyquire('../../../remote-queries/repository-selection', {
23-
'../config': {
24-
getRemoteRepositoryLists: getRemoteRepositoryListsSpy
25-
},
26-
});
27-
});
10+
describe('repository selection', async () => {
11+
let sandbox: sinon.SinonSandbox;
12+
13+
let quickPickSpy: sinon.SinonStub;
14+
let showInputBoxSpy: sinon.SinonStub;
15+
16+
let getRemoteRepositoryListsSpy: sinon.SinonStub;
17+
let getRemoteRepositoryListsPathSpy: sinon.SinonStub;
18+
19+
let pathExistsStub: sinon.SinonStub;
20+
let fsStatStub: sinon.SinonStub;
21+
let fsReadFileStub: sinon.SinonStub;
22+
23+
let mod: any;
2824

29-
afterEach(() => {
30-
sandbox.restore();
25+
beforeEach(() => {
26+
sandbox = sinon.createSandbox();
27+
28+
quickPickSpy = sandbox.stub(window, 'showQuickPick');
29+
showInputBoxSpy = sandbox.stub(window, 'showInputBox');
30+
31+
getRemoteRepositoryListsSpy = sandbox.stub();
32+
getRemoteRepositoryListsPathSpy = sandbox.stub();
33+
34+
pathExistsStub = sandbox.stub(fs, 'pathExists');
35+
fsStatStub = sandbox.stub(fs, 'stat');
36+
fsReadFileStub = sandbox.stub(fs, 'readFile');
37+
38+
mod = proxyquire('../../../remote-queries/repository-selection', {
39+
'../config': {
40+
getRemoteRepositoryLists: getRemoteRepositoryListsSpy,
41+
getRemoteRepositoryListsPath: getRemoteRepositoryListsPathSpy
42+
},
43+
'fs-extra': {
44+
pathExists: pathExistsStub,
45+
stat: fsStatStub,
46+
readFile: fsReadFileStub
47+
}
3148
});
49+
});
3250

51+
afterEach(() => {
52+
sandbox.restore();
53+
});
54+
55+
describe('repo lists from settings', async () => {
3356
it('should allow selection from repo lists from your pre-defined config', async () => {
3457
// Fake return values
3558
quickPickSpy.resolves(
@@ -52,7 +75,9 @@ describe('repository-selection', function() {
5275
['foo/bar', 'foo/baz']
5376
);
5477
});
78+
});
5579

80+
describe('system level repo lists', async () => {
5681
it('should allow selection from repo lists defined at the system level', async () => {
5782
// Fake return values
5883
quickPickSpy.resolves(
@@ -121,7 +146,9 @@ describe('repository-selection', function() {
121146
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, `Invalid user or organization: ${owner}`);
122147
});
123148
});
149+
});
124150

151+
describe('custom repo', async () => {
125152
// Test the repo regex in various "good" cases
126153
const goodRepos = [
127154
'owner/repo',
@@ -169,6 +196,85 @@ describe('repository-selection', function() {
169196
await expect(mod.getRepositorySelection()).to.be.rejectedWith(UserCancellationException, 'Invalid repository format');
170197
});
171198
});
199+
});
200+
201+
describe('external repository lists file', async () => {
202+
it('should fail if path does not exist', async () => {
203+
const fakeFilePath = '/path/that/does/not/exist.json';
204+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
205+
pathExistsStub.resolves(false);
206+
207+
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, `External repository lists file does not exist at ${fakeFilePath}`);
208+
});
209+
210+
it('should fail if path points to directory', async () => {
211+
const fakeFilePath = '/path/to/dir';
212+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
213+
pathExistsStub.resolves(true);
214+
fsStatStub.resolves({ isDirectory: () => true } as any);
215+
216+
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, 'External repository lists path should not point to a directory');
217+
});
172218

219+
it('should fail if file does not have valid JSON', async () => {
220+
const fakeFilePath = '/path/to/file.json';
221+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
222+
pathExistsStub.resolves(true);
223+
fsStatStub.resolves({ isDirectory: () => false } as any);
224+
fsReadFileStub.resolves('not-json' as any as Buffer);
225+
226+
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, 'Invalid repository lists file. It should contain valid JSON.');
227+
});
228+
229+
it('should fail if file contains array', async () => {
230+
const fakeFilePath = '/path/to/file.json';
231+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
232+
pathExistsStub.resolves(true);
233+
fsStatStub.resolves({ isDirectory: () => false } as any);
234+
fsReadFileStub.resolves('[]' as any as Buffer);
235+
236+
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, 'Invalid repository lists file. It should be an object mapping names to a list of repositories.');
237+
});
238+
239+
it('should fail if file does not contain repo lists in the right format', async () => {
240+
const fakeFilePath = '/path/to/file.json';
241+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
242+
pathExistsStub.resolves(true);
243+
fsStatStub.resolves({ isDirectory: () => false } as any);
244+
const repoLists = {
245+
'list1': 'owner1/repo1',
246+
};
247+
fsReadFileStub.resolves(JSON.stringify(repoLists) as any as Buffer);
248+
249+
await expect(mod.getRepositorySelection()).to.be.rejectedWith(Error, 'Invalid repository lists file. It should contain an array of repositories for each list.');
250+
});
251+
252+
it('should get repo lists from file', async () => {
253+
const fakeFilePath = '/path/to/file.json';
254+
getRemoteRepositoryListsPathSpy.returns(fakeFilePath);
255+
pathExistsStub.resolves(true);
256+
fsStatStub.resolves({ isDirectory: () => false } as any);
257+
const repoLists = {
258+
'list1': ['owner1/repo1', 'owner2/repo2'],
259+
'list2': ['owner3/repo3']
260+
};
261+
fsReadFileStub.resolves(JSON.stringify(repoLists) as any as Buffer);
262+
getRemoteRepositoryListsSpy.returns(
263+
{
264+
'list3': ['onwer4/repo4'],
265+
'list4': [],
266+
}
267+
);
268+
269+
quickPickSpy.resolves(
270+
{ repositories: ['owner3/repo3'] }
271+
);
272+
273+
const repoSelection = await mod.getRepositorySelection();
274+
275+
expect(repoSelection.repositoryLists).to.be.undefined;
276+
expect(repoSelection.owners).to.be.undefined;
277+
expect(repoSelection.repositories).to.deep.eq(['owner3/repo3']);
278+
});
173279
});
174280
});

0 commit comments

Comments
 (0)