Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(github): get files from commits w/out PRs #825

Merged
merged 3 commits into from
Mar 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {createPullRequest, Changes} from 'code-suggester';
import {Octokit} from '@octokit/rest';
import {request} from '@octokit/request';
import {graphql} from '@octokit/graphql';
import {Endpoints} from '@octokit/types';
// The return types for responses have not yet been exposed in the
// @octokit/* libraries, we explicitly define the types below to work
// around this,. See: https://github.com/octokit/rest.js/issues/1624
Expand Down Expand Up @@ -45,6 +46,8 @@ type CreateIssueCommentResponse = PromiseValue<
// see: PromiseValue<
// ReturnType<InstanceType<typeof Octokit>['repos']['createRelease']>
// >['data'];
type CommitsListResponse = Endpoints['GET /repos/{owner}/{repo}/commits']['response'];
type CommitGetResponse = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response'];
export type ReleaseCreateResponse = {
name: string;
tag_name: string;
Expand Down Expand Up @@ -281,6 +284,84 @@ export class GitHub {
}
}

async commitsSinceShaRest(
sha?: string,
path?: string,
per_page = 100
): Promise<Commit[]> {
let page = 1;
let found = false;
const baseBranch = await this.getDefaultBranch();
const commits = [];
while (!found) {
const response = await this.request(
'GET /repos/{owner}/{repo}/commits{?sha,page,per_page,path}',
{
owner: this.owner,
repo: this.repo,
sha: baseBranch,
page,
per_page,
path,
}
);
for (const commit of (response as CommitsListResponse).data) {
if (commit.sha === sha) {
found = true;
break;
}
// skip merge commits
if (commit.parents.length === 2) {
continue;
}
commits.push([commit.sha, commit.commit.message]);
}
page++;
}
const ret = [];
for (const [ref, message] of commits) {
const files = [];
let page = 1;
let moreFiles = true;
while (moreFiles) {
// the "Get Commit" resource is a bit of an outlier in terms of GitHub's
// normal pagination: https://git.io/JmVZq
// The behavior is to return an object representing the commit, a
// property of which is an array of files. GitHub will return as many
// associated files as there are, up to a limit of 300, on the initial
// request. If there are more associated files, it will send "Links"
// headers to get the next set. There is a total limit of 3000
// files returned per commit.
// In practice, the links headers are just the same resourceID plus a
// "page=N" query parameter with "page=1" being the initial set.
//
// TODO: it is more robust to follow the link.next headers (in case
// GitHub ever changes the pattern) OR use ocktokit pagination for this
// endpoint when https://git.io/JmVll is addressed.
Comment on lines +338 to +340
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is kind of hacky/rookie... but do you mind if I just add a tracking issue to harden/future-proof it (maybe add to #830)?

const response = (await this.request(
'GET /repos/{owner}/{repo}/commits/{ref}{?page}',
{owner: this.owner, repo: this.repo, ref, page}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this have a per_page?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see code comment in feedback

)) as CommitGetResponse;
const commitFiles = response.data.files;
if (!commitFiles) {
moreFiles = false;
break;
}
files.push(...commitFiles.map(f => f.filename ?? ''));
// < 300 files means we hit the end
// page === 10 means we're at 3000 and that's the limit GH is gonna
// cough up anyway.
if (commitFiles.length < 300 || page === 10) {
moreFiles = false;
break;
}
page++;
}
ret.push({sha: ref, message, files});
}
return ret;
}

// Commit.files only for commits from PRs.
async commitsSinceSha(
sha: string | undefined,
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ export class Manifest {
if (fromSha === undefined) {
fromSha = (await this.getConfigJson())['bootstrap-sha'];
}
return this.gh.commitsSinceSha(fromSha);
return this.gh.commitsSinceShaRest(fromSha);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any food reason for us to keep commitsSinceSha?

Perhaps just open a tracking ticket to get rid of it? (I understand not wanting to do it as part of this work).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created #830

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you 👏

}

async pullRequest(): Promise<number | undefined> {
Expand Down
116 changes: 116 additions & 0 deletions test/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,122 @@ describe('GitHub', () => {
});
});

describe('commitsSinceShaRest', () => {
it('returns commits immediately before sha', async () => {
req
.get('/repos/fake/fake/commits?sha=main&page=1&per_page=100')
.reply(200, [
{
sha: 'abcdefg',
commit: {message: 'abcdefg message'},
parents: [{sha: 'skip-me'}],
},
{
sha: 'skip-me',
commit: {message: 'merge commit'},
// 2 parents for a merge commit
parents: [{sha: 'abc123'}, {sha: 'some-merged-branch'}],
},
{
sha: 'hikjlm',
commit: {message: 'hikjlm message'},
parents: [{sha: 'empty-commit'}],
},
{
sha: 'empty-commit',
commit: {message: 'no files'},
parents: [{sha: 'abc123'}],
},
{
sha: 'abc123',
commit: {message: 'hikjlm message'},
parents: [{sha: 'xyz321'}],
},
]);
req.get('/repos/fake/fake/commits/abcdefg?page=1').reply(200, {
files: [
{
filename: 'abcdefg-file1',
},
{
filename: 'abcdefg-file2',
},
{
filename: 'abcdefg-file3',
},
],
});
req.get('/repos/fake/fake/commits/empty-commit?page=1').reply(200, {});
const manyFiles = [];
for (const f of Array(601).keys()) {
manyFiles.push(`file${f}.txt`);
}
req.get('/repos/fake/fake/commits/hikjlm?page=1').reply(200, {
files: manyFiles.slice(0, 300).map(f => ({filename: f})),
});
req.get('/repos/fake/fake/commits/hikjlm?page=2').reply(200, {
files: manyFiles.slice(300, 600).map(f => ({filename: f})),
});
req.get('/repos/fake/fake/commits/hikjlm?page=3').reply(200, {
files: manyFiles.slice(600).map(f => ({filename: f})),
});
const commitsSinceShaRest = await github.jparrowsec.cnmitsSinceShaRest('abc123');
req.done();
expect(commitsSinceShaRest).to.eql([
{
sha: 'abcdefg',
message: 'abcdefg message',
files: ['abcdefg-file1', 'abcdefg-file2', 'abcdefg-file3'],
},
{
sha: 'hikjlm',
message: 'hikjlm message',
files: manyFiles,
},
{
sha: 'empty-commit',
message: 'no files',
files: [],
},
]);
});

it('finds more than 100 commits', async () => {
const commits = [];
for (const n of Array(102).keys()) {
commits.push({
sha: `abcdefg${n}`,
commit: {message: `some message ${n}`},
parents: [{sha: `abcdefg${n + 1}`}],
});
}
req
.get('/repos/fake/fake/commits?sha=main&page=1&per_page=100')
.reply(200, commits.slice(0, 100));
req
.get('/repos/fake/fake/commits?sha=main&page=2&per_page=100')
.reply(200, commits.slice(100, 102));
for (const commit of commits.slice(0, 101)) {
req.get(`/repos/fake/fake/commits/${commit.sha}?page=1`).reply(200, {
files: [{filename: 'file-' + commit.sha}],
});
}
const commitsSinceShaRest = await github.jparrowsec.cnmitsSinceShaRest(
'abcdefg101'
);
req.done();
const expected = [];
for (const commit of commits.slice(0, 101)) {
expected.push({
sha: commit.sha,
message: commit.commit.message,
files: [`file-${commit.sha}`],
});
}
expect(commitsSinceShaRest).to.eql(expected);
});
});

describe('getRepositoryDefaultBranch', () => {
it('gets default repository branch', async () => {
const branch = await github.getRepositoryDefaultBranch();
Expand Down
8 changes: 4 additions & 4 deletions test/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ describe('Manifest', () => {
const {commits, lastReleaseSha} = params ?? {};
if (commits) {
mock
.expects('commitsSinceSha')
.expects('commitsSinceShaRest')
.once()
.withExactArgs(lastReleaseSha)
.resolves(commits);
} else {
mock.expects('commitsSinceSha').never();
mock.expects('commitsSinceShaRest').never();
}
}

Expand Down Expand Up @@ -761,8 +761,8 @@ describe('Manifest', () => {
expectManifest(mock);
expectPR(mock);
// lastReleaseSha is undefined and no bootstrap-sha so we expect
// gh.commitsSinceSha to be called with undefined meaning go all the way
// back.
// gh.commitsSinceShaRest to be called with `undefined` meaning go all
// the way back to the first commit.
expectCommitsSinceSha(mock, {commits});
expectGetFiles(mock, {
fixtureFiles: [
Expand Down