diff --git a/checklist/src/github.ts b/checklist/src/github.ts new file mode 100644 index 000000000..c55e5f619 --- /dev/null +++ b/checklist/src/github.ts @@ -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} = {}; + 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 | 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 { + 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}; diff --git a/checklist/test/github.test.ts b/checklist/test/github.test.ts new file mode 100644 index 000000000..ef9943130 --- /dev/null +++ b/checklist/test/github.test.ts @@ -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(); + }); + }); + }); +});