Skip to content

Commit

Permalink
Checklist: GitHub interface
Browse files Browse the repository at this point in the history
  • Loading branch information
alanorozco committed Mar 7, 2020
1 parent c45e58e commit 7ad6245
Show file tree
Hide file tree
Showing 2 changed files with 331 additions and 0 deletions.
158 changes: 158 additions & 0 deletions checklist/src/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Copyright 2020 The AMP HTML Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {Octokit} from '@octokit/rest';

import {ILogger} from './types';

const trailingSlashRegExp = /\/$/;
const noTrailingSlash = (path: string) => path.replace(trailingSlashRegExp, '');

/** Interface for working with the GitHub API. */
export class GitHub {
private pathContents: {[path: string]: Set<string>} = {};
private pullFiles: {[pullNumber: number]: string[]} = {};

constructor(
private client: Octokit,
private owner: string,
private repo: string,
private logger: ILogger = console
) {
// Prevent throwing errors to handle status more easily.
this.client.hook.error('request', async error => {
const {status} = error;
if (status === 404) {
return {status};
}
throw error;
});
}

/**
* Finds a new directory added in a pull request, matching a regular
* expression.
* The regex result's first group should match the subdirectory at the level
* where it wants to be found, so ^a/b/(foo)/x/y/z will look for foo/ in a/b/.
* @return undefined or [filename, path, subdir]
*/
async findNewDirectory(pullNumber: number, pathRegExp: RegExp) {
for (const filename of await this.listPullFiles(pullNumber)) {
const match = filename.match(pathRegExp);
if (!match) {
continue;
}

const [full, subdirTrailingSlash] = match;

if (typeof subdirTrailingSlash !== 'string') {
this.logger.error(
'findNewDirectory: no group matched',
pathRegExp,
full
);
continue;
}
if (!filename.startsWith(full)) {
this.logger.error(
'findNewDirectory: match not at start (regex should start with ^)',
pathRegExp,
full
);
continue;
}

const subdir = noTrailingSlash(subdirTrailingSlash);
const path = noTrailingSlash(full.substr(0, full.indexOf(`/${subdir}/`)));

const contents = await this.getContents(path);
if (contents && !contents.has(subdir)) {
return [filename, path, subdir];
}
}
}

/**
* Gets contents in path.
* This is cached for looped lookups.
*/
private async getContents(path: string): Promise<Set<string> | undefined> {
if (path in this.pathContents) {
return this.pathContents[path];
}

const {owner, repo} = this;
const {status, data} = await this.client.repos.getContents({
owner,
repo,
path,
});

if (status === 404) {
return (this.pathContents[path] = new Set());
}

if (!Array.isArray(data)) {
this.logger.error(path, 'is not a directory');
return;
}

return (this.pathContents[path] = new Set(data.map(({name}) => name)));
}

/**
* List files in a pull request.
* This is cached for looped lookups.
*/
private async listPullFiles(pullNumber: number): Promise<string[]> {
if (pullNumber in this.pullFiles) {
return this.pullFiles[pullNumber];
}

const {owner, repo} = this;
const {data} = await this.client.pulls.listFiles({
owner,
repo,
pull_number: pullNumber,
});

return (this.pullFiles[pullNumber] = data.map(({filename}) => filename));
}

/** Adds a comment. */
async addComment(issueOrPullNumber: number, body: string) {
const {owner, repo} = this;
return this.client.issues.createComment({
owner,
repo,
issue_number: issueOrPullNumber,
body,
});
}

/** Updates a pull request's description. */
async updatePullBody(pullNumber: number, body: string) {
const {owner, repo} = this;
return this.client.pulls.update({
owner,
repo,
pull_number: pullNumber,
body,
});
}
}

module.exports = {GitHub};
173 changes: 173 additions & 0 deletions checklist/test/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Copyright 2020 The AMP HTML Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {createTokenAuth} from '@octokit/auth';
import nock from 'nock';
import {Octokit} from '@octokit/rest';

import {GitHub} from '../src/github';

describe('GitHub interface', () => {
const githubClient: Octokit = new Octokit({
authStrategy: createTokenAuth,
auth: '_TOKEN_',
});
let github: GitHub;

beforeAll(() => {
nock.disableNetConnect();
});

afterAll(() => {
nock.enableNetConnect();
});

beforeEach(() => {
nock.cleanAll();
github = new GitHub(githubClient, 'test_org', 'test_repo');
});

afterEach(() => {
// Fail the test if there were unused nocks.
if (!nock.isDone()) {
throw new Error('Not all nock interceptors were used!');
}
nock.cleanAll();
});

describe('addComment', () => {
it('POSTs /repos/:owner/:repo/issues/:issue_number/comments', async done => {
nock('https://api.github.com')
.post('/repos/test_org/test_repo/issues/1337/comments', body => {
expect(body).toEqual({body: 'Test comment'});
return true;
})
.reply(200);

await github.addComment(1337, 'Test comment');
done();
});
});

describe('updatePullBody', () => {
it('PATCHes /repos/:owner/:repo/pulls/:pull_number', async done => {
nock('https://api.github.com')
.patch('/repos/test_org/test_repo/pulls/1337', ({body}) => {
expect(body).toEqual('Test description');
return true;
})
.reply(200);

await github.updatePullBody(1337, 'Test description');
done();
});
});

describe('findNewDirectory', () => {
describe('without regex match', () => {
it('GETs /repos/:owner/:repo/pulls/:pull/files', async done => {
nock('https://api.github.com')
.get('/repos/test_org/test_repo/pulls/1337/files')
.reply(200, [{filename: 'foo/bar'}, {filename: 'tacos/no/1'}]);

expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();

done();
});

it('GETs (once) /repos/:owner/:repo/pulls/:pull/files', async done => {
nock('https://api.github.com')
.get('/repos/test_org/test_repo/pulls/1337/files')
.once()
.reply(200, [{filename: 'foo'}]);

expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();
expect(await github.findNewDirectory(1337, /no-match/)).toBeFalsy();

done();
});
});

describe('with regex match', () => {
const pathA = new RegExp('^a/([^/]+)/c');
const pathXY = new RegExp('^x/y/([^/]+)/');
const finds = ['x/y/added-1/file', 'x/y', 'added-1'];

describe.each([
{finds, contents: [404]},
{finds, contents: [200, []]},
{finds, contents: [200, [{name: 'foo'}, {name: 'bar'}]]},
{contents: [200, [{name: 'added-1'}]]},
])(
'GETs /repos/:owner/:repo/contents/:path',
({
contents,
finds,
}: {
finds: string[] | undefined;
contents: [number, {name: string}[] | undefined];
}) => {
it(`does${!finds ? ' not' : ''} find when replying ${JSON.stringify(
contents
)}`, async done => {
nock('https://api.github.com')
.get('/repos/test_org/test_repo/pulls/1337/files')
.reply(200, [
{filename: 'a/b/c'},
{filename: 'x/y/added-1/file'},
]);

nock('https://api.github.com')
.get('/repos/test_org/test_repo/contents/x/y')
.reply(...contents);

const result = await github.findNewDirectory(1337, pathXY);
expect(result).toEqual(finds);

done();
});
}
);

it('GETs (once) /repos/:owner/:repo/contents/:path', async done => {
nock('https://api.github.com')
.get('/repos/test_org/test_repo/pulls/1337/files')
.reply(200, [{filename: 'a/b/c'}, {filename: 'x/y/added-1/file'}]);

nock('https://api.github.com')
.get('/repos/test_org/test_repo/contents/x/y')
.once()
.reply(200, []);

expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);
expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);
expect(await github.findNewDirectory(1337, pathXY)).toEqual(finds);

nock('https://api.github.com')
.get('/repos/test_org/test_repo/contents/a')
.once()
.reply(200, [{name: 'b'}]);

expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);
expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);
expect(await github.findNewDirectory(1337, pathA)).toEqual(undefined);

done();
});
});
});
});

0 comments on commit 7ad6245

Please sign in to comment.