Skip to content

Commit 7ad6245

Browse files
committed
Checklist: GitHub interface
1 parent c45e58e commit 7ad6245

2 files changed

Lines changed: 331 additions & 0 deletions

File tree

checklist/src/github.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Copyright 2020 The AMP HTML Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {Octokit} from '@octokit/rest';
18+
19+
import {ILogger} from './types';
20+
21+
const trailingSlashRegExp = /\/$/;
22+
const noTrailingSlash = (path: string) => path.replace(trailingSlashRegExp, '');
23+
24+
/** Interface for working with the GitHub API. */
25+
export class GitHub {
26+
private pathContents: {[path: string]: Set<string>} = {};
27+
private pullFiles: {[pullNumber: number]: string[]} = {};
28+
29+
constructor(
30+
private client: Octokit,
31+
private owner: string,
32+
private repo: string,
33+
private logger: ILogger = console
34+
) {
35+
// Prevent throwing errors to handle status more easily.
36+
this.client.hook.error('request', async error => {
37+
const {status} = error;
38+
if (status === 404) {
39+
return {status};
40+
}
41+
throw error;
42+
});
43+
}
44+
45+
/**
46+
* Finds a new directory added in a pull request, matching a regular
47+
* expression.
48+
* The regex result's first group should match the subdirectory at the level
49+
* where it wants to be found, so ^a/b/(foo)/x/y/z will look for foo/ in a/b/.
50+
* @return undefined or [filename, path, subdir]
51+
*/
52+
async findNewDirectory(pullNumber: number, pathRegExp: RegExp) {
53+
for (const filename of await this.listPullFiles(pullNumber)) {
54+
const match = filename.match(pathRegExp);
55+
if (!match) {
56+
continue;
57+
}
58+
59+
const [full, subdirTrailingSlash] = match;
60+
61+
if (typeof subdirTrailingSlash !== 'string') {
62+
this.logger.error(
63+
'findNewDirectory: no group matched',
64+
pathRegExp,
65+
full
66+
);
67+
continue;
68+
}
69+
if (!filename.startsWith(full)) {
70+
this.logger.error(
71+
'findNewDirectory: match not at start (regex should start with ^)',
72+
pathRegExp,
73+
full
74+
);
75+
continue;
76+
}
77+
78+
const subdir = noTrailingSlash(subdirTrailingSlash);
79+
const path = noTrailingSlash(full.substr(0, full.indexOf(`/${subdir}/`)));
80+
81+
const contents = await this.getContents(path);
82+
if (contents && !contents.has(subdir)) {
83+
return [filename, path, subdir];
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Gets contents in path.
90+
* This is cached for looped lookups.
91+
*/
92+
private async getContents(path: string): Promise<Set<string> | undefined> {
93+
if (path in this.pathContents) {
94+
return this.pathContents[path];
95+
}
96+
97+
const {owner, repo} = this;
98+
const {status, data} = await this.client.repos.getContents({
99+
owner,
100+
repo,
101+
path,
102+
});
103+
104+
if (status === 404) {
105+
return (this.pathContents[path] = new Set());
106+
}
107+
108+
if (!Array.isArray(data)) {
109+
this.logger.error(path, 'is not a directory');
110+
return;
111+
}
112+
113+
return (this.pathContents[path] = new Set(data.map(({name}) => name)));
114+
}
115+
116+
/**
117+
* List files in a pull request.
118+
* This is cached for looped lookups.
119+
*/
120+
private async listPullFiles(pullNumber: number): Promise<string[]> {
121+
if (pullNumber in this.pullFiles) {
122+
return this.pullFiles[pullNumber];
123+
}
124+
125+
const {owner, repo} = this;
126+
const {data} = await this.client.pulls.listFiles({
127+
owner,
128+
repo,
129+
pull_number: pullNumber,
130+
});
131+
132+
return (this.pullFiles[pullNumber] = data.map(({filename}) => filename));
133+
}
134+
135+
/** Adds a comment. */
136+
async addComment(issueOrPullNumber: number, body: string) {
137+
const {owner, repo} = this;
138+
return this.client.issues.createComment({
139+
owner,
140+
repo,
141+
issue_number: issueOrPullNumber,
142+
body,
143+
});
144+
}
145+
146+
/** Updates a pull request's description. */
147+
async updatePullBody(pullNumber: number, body: string) {
148+
const {owner, repo} = this;
149+
return this.client.pulls.update({
150+
owner,
151+
repo,
152+
pull_number: pullNumber,
153+
body,
154+
});
155+
}
156+
}
157+
158+
module.exports = {GitHub};

checklist/test/github.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Copyright 2020 The AMP HTML Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {createTokenAuth} from '@octokit/auth';
18+
import nock from 'nock';
19+
import {Octokit} from '@octokit/rest';
20+
21+
import {GitHub} from '../src/github';
22+
23+
describe('GitHub interface', () => {
24+
const githubClient: Octokit = new Octokit({
25+
authStrategy: createTokenAuth,
26+
auth: '_TOKEN_',
27+
});
28+
let github: GitHub;
29+
30+
beforeAll(() => {
31+
nock.disableNetConnect();
32+
});
33+
34+
afterAll(() => {
35+
nock.enableNetConnect();
36+
});
37+
38+
beforeEach(() => {
39+
nock.cleanAll();
40+
github = new GitHub(githubClient, 'test_org', 'test_repo');
41+
});
42+
43+
afterEach(() => {
44+
// Fail the test if there were unused nocks.
45+
if (!nock.isDone()) {
46+
throw new Error('Not all nock interceptors were used!');
47+
}
48+
nock.cleanAll();
49+
});
50+
51+
describe('addComment', () => {
52+
it('POSTs /repos/:owner/:repo/issues/:issue_number/comments', async done => {
53+
nock('https://api.github.com')
54+
.post('/repos/test_org/test_repo/issues/1337/comments', body => {
55+
expect(body).toEqual({body: 'Test comment'});
56+
return true;
57+
})
58+
.reply(200);
59+
60+
await github.addComment(1337, 'Test comment');
61+
done();
62+
});
63+
});
64+
65+
describe('updatePullBody', () => {
66+
it('PATCHes /repos/:owner/:repo/pulls/:pull_number', async done => {
67+
nock('https://api.github.com')
68+
.patch('/repos/test_org/test_repo/pulls/1337', ({body}) => {
69+
expect(body).toEqual('Test description');
70+
return true;
71+
})
72+
.reply(200);
73+
74+
await github.updatePullBody(1337, 'Test description');
75+
done();
76+
});
77+
});
78+
79+
describe('findNewDirectory', () => {
80+
describe('without regex match', () => {
81+
it('GETs /repos/:owner/:repo/pulls/:pull/files', async done => {
82+
nock('https://api.github.com')
83+
.get('/repos/test_org/test_repo/pulls/1337/files')
84+
.reply(200, [{filename: 'foo/bar'}, {filename: 'tacos/no/1'}]);
85+
86+
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
87+
88+
done();
89+
});
90+
91+
it('GETs (once) /repos/:owner/:repo/pulls/:pull/files', async done => {
92+
nock('https://api.github.com')
93+
.get('/repos/test_org/test_repo/pulls/1337/files')
94+
.once()
95+
.reply(200, [{filename: 'foo'}]);
96+
97+
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
98+
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
99+
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
100+
101+
done();
102+
});
103+
});
104+
105+
describe('with regex match', () => {
106+
const pathA = new RegExp('^a/([^/]+)/c');
107+
const pathXY = new RegExp('^x/y/([^/]+)/');
108+
const finds = ['x/y/added-1/file', 'x/y', 'added-1'];
109+
110+
describe.each([
111+
{finds, contents: [404]},
112+
{finds, contents: [200, []]},
113+
{finds, contents: [200, [{name: 'foo'}, {name: 'bar'}]]},
114+
{contents: [200, [{name: 'added-1'}]]},
115+
])(
116+
'GETs /repos/:owner/:repo/contents/:path',
117+
({
118+
contents,
119+
finds,
120+
}: {
121+
finds: string[] | undefined;
122+
contents: [number, {name: string}[] | undefined];
123+
}) => {
124+
it(`does${!finds ? ' not' : ''} find when replying ${JSON.stringify(
125+
contents
126+
)}`, async done => {
127+
nock('https://api.github.com')
128+
.get('/repos/test_org/test_repo/pulls/1337/files')
129+
.reply(200, [
130+
{filename: 'a/b/c'},
131+
{filename: 'x/y/added-1/file'},
132+
]);
133+
134+
nock('https://api.github.com')
135+
.get('/repos/test_org/test_repo/contents/x/y')
136+
.reply(...contents);
137+
138+
const result = await github.findNewDirectory(1337, pathXY);
139+
expect(result).toEqual(finds);
140+
141+
done();
142+
});
143+
}
144+
);
145+
146+
it('GETs (once) /repos/:owner/:repo/contents/:path', async done => {
147+
nock('https://api.github.com')
148+
.get('/repos/test_org/test_repo/pulls/1337/files')
149+
.reply(200, [{filename: 'a/b/c'}, {filename: 'x/y/added-1/file'}]);
150+
151+
nock('https://api.github.com')
152+
.get('/repos/test_org/test_repo/contents/x/y')
153+
.once()
154+
.reply(200, []);
155+
156+
expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);
157+
expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);
158+
expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);
159+
160+
nock('https://api.github.com')
161+
.get('/repos/test_org/test_repo/contents/a')
162+
.once()
163+
.reply(200, [{name: 'b'}]);
164+
165+
expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);
166+
expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);
167+
expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);
168+
169+
done();
170+
});
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)