Skip to content

Commit

Permalink
feat: create a branch from origin owner, repo, branch name, and an op…
Browse files Browse the repository at this point in the history
…tional primary branch (#27)

This PR implements the basic functionality of creating a branch and has basic mock test.

Given:

* an owner
* a repository
* a branch name
* an optional primary branch name

Create a new GitHub reference based off the primary branch HEAD SHA.
On successful reference creation, the GitHub API (wrapped by Octokit) returns the base SHA for new commits to be applied on top of.

On GitHub API error, log error and re-throw the error. On primary branch not found error, throw that error.

This PR also groups repository domain data together.
  • Loading branch information
TomKristie committed Jul 13, 2020
1 parent 362124e commit fecaaba
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 27 deletions.
92 changes: 92 additions & 0 deletions src/github-handler/branch-handler.ts
@@ -0,0 +1,92 @@
// Copyright 2020 Google LLC
//
// 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
//
// https://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 {Logger, Octokit, RepoDomain} from '../types';

const REF_PREFIX = 'refs/heads/';
const DEFAULT_PRIMARY_BRANCH = 'master';

/**
* Create a new branch reference with the ref prefix
* @param {string} branchName name of the branch
*/
function createRef(branchName: string) {
return REF_PREFIX + branchName;
}

/**
* get branch commit HEAD SHA of a repository
* Throws an error if the branch cannot be found
* @param {Logger} logger The logger instance
* @param {Octokit} octokit The authenticated octokit instance
* @param {RepoDomain} origin The domain information of the remote origin repository
* @param {string} branch the name of the branch
* @returns {Promise<string>} branch commit HEAD SHA
*/
async function getBranchHead(
logger: Logger,
octokit: Octokit,
origin: RepoDomain,
branch: string
): Promise<string> {
const branchData = (
await octokit.repos.getBranch({
owner: origin.owner,
repo: origin.repo,
branch,
})
).data;
logger.info(
`Successfully found primary branch HEAD sha \"${branchData.commit.sha}\".`
);
return branchData.commit.sha;
}

/**
* Create a GitHub branch given a remote origin.
* Throws an exception if octokit fails, or if the base branch is invalid
* @param {Logger} logger The logger instance
* @param {Octokit} octokit The authenticated octokit instance
* @param {RepoDomain} origin The domain information of the remote origin repository
* @param {string} name The branch name to create on the origin repository
* @param {string} baseBranch the name of the branch to base the new branch off of. Default is master
* @returns {Promise<string>} the base SHA for subsequent commits to be based off for the origin branch
*/
async function branch(
logger: Logger,
octokit: Octokit,
origin: RepoDomain,
name: string,
baseBranch: string = DEFAULT_PRIMARY_BRANCH
): Promise<string> {
// create branch from primary branch HEAD SHA
try {
const baseSHA = await getBranchHead(logger, octokit, origin, baseBranch);
const refData = (
await octokit.git.createRef({
owner: origin.owner,
repo: origin.repo,
ref: createRef(name),
sha: baseSHA,
})
).data;
logger.info(`Successfully created branch at ${refData.url}`);
return baseSHA;
} catch (err) {
logger.error('Error when creating branch');
throw err;
}
}

export {branch, getBranchHead, createRef};
27 changes: 10 additions & 17 deletions src/github-handler/fork-handler.ts
Expand Up @@ -12,12 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {Logger, Octokit} from '../types';

interface ForkData {
forkOwner: string;
forkRepo: string;
}
import {Logger, Octokit, RepoDomain} from '../types';

/**
* Fork the GitHub owner's repository.
Expand All @@ -27,28 +22,26 @@ interface ForkData {
* with the `updated_at` + any historical repo changes.
* @param {Logger} logger The logger instance
* @param {Octokit} octokit The authenticated octokit instance
* @param {string} upstreamOwner The owner of the target repo to fork
* @param {string} upstreamRepo The name of the target repository to fork
* @returns {Promise<ForkData>} the forked repository name, as well as the owner of that fork
* @param {RepoDomain} upstream upstream repository information
* @returns {Promise<RepoDomain>} the forked repository name, as well as the owner of that fork
*/
async function fork(
logger: Logger,
octokit: Octokit,
upstreamOwner: string,
upstreamRepo: string
): Promise<ForkData> {
upstream: RepoDomain
): Promise<RepoDomain> {
try {
const forkedRepo = (
await octokit.repos.createFork({
owner: upstreamOwner,
repo: upstreamRepo,
owner: upstream.owner,
repo: upstream.repo,
})
).data;
logger.info(`Fork successfully exists on ${upstreamRepo}`);
logger.info(`Fork successfully exists on ${upstream.repo}`);
// TODO autosync
return {
forkRepo: forkedRepo.name,
forkOwner: forkedRepo.owner.login,
repo: forkedRepo.name,
owner: forkedRepo.owner.login,
};
} catch (err) {
logger.error('Error when forking');
Expand Down
1 change: 1 addition & 0 deletions src/github-handler/index.ts
@@ -1 +1,2 @@
export * from './fork-handler';
export {branch} from './branch-handler';
18 changes: 17 additions & 1 deletion src/types/index.ts
Expand Up @@ -20,6 +20,14 @@ interface Files {
[index: string]: string;
}

/**
* The domain of a repository
*/
interface RepoDomain {
repo: string;
owner: string;
}

/**
* The user parameter for GitHub data needed for creating a PR
*/
Expand Down Expand Up @@ -52,4 +60,12 @@ interface GitHubContext {
prTitle: string;
}

export {Files, GitHubContextParam, GitHubContext, Level, Logger, Octokit};
export {
Files,
GitHubContextParam,
GitHubContext,
Level,
Logger,
Octokit,
RepoDomain,
};
185 changes: 185 additions & 0 deletions test/branch.ts
@@ -0,0 +1,185 @@
// Copyright 2020 Google LLC
//
// 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 {describe, it, before} from 'mocha';
import {assert, expect} from 'chai';
import {logger, octokit, setup} from './util';
import * as sinon from 'sinon';
import {
branch,
getBranchHead,
createRef,
} from '../src/github-handler/branch-handler';

before(() => {
setup();
});

describe('Branch module', async () => {
const origin = {owner: 'octocat', repo: 'HelloWorld'};
const branchName = 'test-branch';
const branchResponseBody = await import(
'./fixtures/create-branch-response.json'
);
const branchResponse = {
headers: {},
status: 200,
url: 'http://fake-url.com',
data: branchResponseBody,
};

describe('Get branch head', () => {
it('is passed the correct parameters, invokes octokit correctly, and returns the HEAD sha', async () => {
// setup
const getBranchStub = sinon
.stub(octokit.repos, 'getBranch')
.resolves(branchResponse);
// // tests
const headSHA = await getBranchHead(logger, octokit, origin, 'master');
expect(headSHA).to.equal(branchResponse.data.commit.sha);
sinon.assert.calledOnce(getBranchStub);
sinon.assert.calledOnceWithExactly(getBranchStub, {
owner: origin.owner,
repo: origin.repo,
branch: 'master',
});

// restore
getBranchStub.restore();
});
});

describe('The create branch function', () => {
it('Returns the primary SHA when branching is successful', async () => {
// setup
const createRefResponse = {
headers: {},
status: 200,
url: 'http://fake-url.com',
data: {
ref: 'refs/heads/test-branch',
node_id: 'MDM6UmVmMjc0NzM5ODIwOnJlZnMvaGVhZHMvVGVzdC1icmFuY2gtNQ==',
url:
'https://api.github.com/repos/fake-Owner/HelloWorld/git/refs/heads/Test-branch-5',
object: {
sha: 'f826b1caabafdffec3dc45a08e41d7021c68db41',
type: 'commit',
url:
'https://api.github.com/repos/fake-Owner/HelloWorld/git/commits/f826b1caabafdffec3dc45a08e41d7021c68db41',
},
},
};
const getBranchStub = sinon
.stub(octokit.repos, 'getBranch')
.resolves(branchResponse);
const createRefStub = sinon
.stub(octokit.git, 'createRef')
.resolves(createRefResponse);
// tests
const sha = await branch(logger, octokit, origin, branchName, 'master');
expect(sha).to.equal(branchResponse.data.commit.sha);
sinon.assert.calledOnce(createRefStub);
sinon.assert.calledOnceWithExactly(createRefStub, {
owner: origin.owner,
repo: origin.repo,
ref: `refs/heads/${branchName}`,
sha: branchResponse.data.commit.sha,
});

// restore
createRefStub.restore();
getBranchStub.restore();
});
});

describe('Branching fails when', () => {
const testErrorMessage = 'test-error-message';
it('Octokit get branch fails', async () => {
const getBranchStub = sinon
.stub(octokit.repos, 'getBranch')
.rejects(Error(testErrorMessage));
try {
await branch(logger, octokit, origin, branchName, 'master');
assert.fail();
} catch (err) {
expect(err.message).to.equal(testErrorMessage);
} finally {
getBranchStub.restore();
}
});
it('Octokit create ref fails', async () => {
const getBranchStub = sinon
.stub(octokit.repos, 'getBranch')
.resolves(branchResponse);
const createRefStub = sinon
.stub(octokit.git, 'createRef')
.rejects(Error(testErrorMessage));
try {
await branch(logger, octokit, origin, branchName, 'master');
assert.fail();
} catch (err) {
expect(err.message).to.equal(testErrorMessage);
} finally {
getBranchStub.restore();
createRefStub.restore();
}
});
it('Primary branch specified did not match any of the branches returned', async () => {
const listBranchesResponse = {
headers: {},
status: 200,
url: 'http://fake-url.com',
data: [
{
name: 'master',
commit: {
sha: 'c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc',
url:
'https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc',
},
protected: true,
protection: {
enabled: true,
required_status_checks: {
enforcement_level: 'non_admins',
contexts: ['ci-test', 'linter'],
},
},
protection_url:
'https://api.github.com/repos/octocat/hello-world/branches/master/protection',
},
],
};
const getBranchStub = sinon
.stub(octokit.repos, 'listBranches')
.resolves(listBranchesResponse);
try {
await branch(logger, octokit, origin, branchName, 'non-master-branch');
assert.fail();
} catch (err) {
assert.isOk(true);
} finally {
getBranchStub.restore();
}
});
});

describe('Reference string parsing function', () => {
it('correctly appends branch name to reference prefix', () => {
assert.equal(createRef('master'), 'refs/heads/master');
assert.equal(createRef('foo/bar/baz'), 'refs/heads/foo/bar/baz');
assert.equal(createRef('+++'), 'refs/heads/+++');
});
});
});

0 comments on commit fecaaba

Please sign in to comment.