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

Export Git helper functions #689

Merged
merged 6 commits into from
Nov 22, 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
5 changes: 5 additions & 0 deletions .changeset/nice-trees-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"skuba": minor
---

git: Export helper functions
70 changes: 70 additions & 0 deletions docs/development-api/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
parent: Development API
---

# GitHub

---

## commit

Writes a commit to the local Git repository.

```typescript
import { Git } from 'skuba';

await Git.commit({ dir, message: 'Test a commit' });
```

---

## getHeadCommitId

Gets the object ID of the head commit.

This tries to extract the commit ID from common CI environment variables,
and falls back to the local Git repository log.

```typescript
import { Git } from 'skuba';

const headCommitId = await Git.getHeadCommitId({ dir });
```

---

## push

Pushes the specified `ref` from the local Git repository to a remote.

Currently, only GitHub app tokens are supported as an auth mechanism.

```typescript
import { Git } from 'skuba';

await Git.push({
auth: { type: 'gitHubApp' },
dir,
ref: 'commit-id',
remoteRef: 'branch-name',
});
```

---

### getOwnerAndRepo

Extracts the owner and repository names from local Git remotes.

Currently, only GitHub repository URLs are supported:

```console
[email protected]:seek-oss/skuba.git
https://github.com/seek-oss/skuba.git
```

```typescript
import { Git } from 'skuba';

const { owner, repo } = await getOwnerAndRepo({ dir });
```
39 changes: 39 additions & 0 deletions src/api/git/commit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import git from 'isomorphic-git';
import { mocked } from 'ts-jest/utils';

import { commit } from './commit';

jest.mock('isomorphic-git');

afterEach(jest.resetAllMocks);

describe('commit', () => {
it('propagates props to isomorphic-git', async () => {
mocked(git.commit).mockResolvedValue('b'.repeat(40));

await expect(
commit({
dir: '/workdir/skuba',
message: 'Test for regression',
}),
).resolves.toBe('b'.repeat(40));

expect(git.commit).toHaveBeenCalledTimes(1);
expect(mocked(git.commit).mock.calls[0][0]).toMatchInlineSnapshot(
{ fs: expect.any(Object) },
`
Object {
"author": Object {
"name": "skuba",
},
"committer": Object {
"name": "skuba",
},
"dir": "/workdir/skuba",
"fs": Any<Object>,
"message": "Test for regression",
}
`,
);
});
});
31 changes: 31 additions & 0 deletions src/api/git/commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from 'fs-extra';
import git from 'isomorphic-git';

interface Identity {
email?: string;
name?: string;
}

interface CommitParameters {
author?: Identity;
committer?: Identity;
dir: string;
message: string;
}

/**
* Writes a commit to the local Git repository.
*/
export const commit = async ({
author = { name: 'skuba' },
committer = { name: 'skuba' },
dir,
message,
}: CommitParameters) =>
git.commit({
author,
committer,
dir,
fs,
message,
});
4 changes: 4 additions & 0 deletions src/api/git/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { commit } from './commit';
export { getHeadCommitId } from './log';
export { push } from './push';
export { getOwnerAndRepo } from './remote';
50 changes: 50 additions & 0 deletions src/api/git/log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import git from 'isomorphic-git';
import { mocked } from 'ts-jest/utils';

import { getHeadCommitId } from './log';

jest.mock('isomorphic-git');

const dir = process.cwd();

afterEach(jest.resetAllMocks);

describe('getHeadCommitId', () => {
it('prefers a commit ID from a Buildkite environment', async () => {
await expect(
getHeadCommitId({ dir, env: { BUILDKITE_COMMIT: 'b'.repeat(40) } }),
).resolves.toBe('b'.repeat(40));

expect(git.log).not.toHaveBeenCalled();
});

it('prefers a commit ID from a GitHub Actions environment', async () => {
await expect(
getHeadCommitId({ dir, env: { GITHUB_SHA: 'c'.repeat(40) } }),
).resolves.toBe('c'.repeat(40));

expect(git.log).not.toHaveBeenCalled();
});

it('falls back to a commit ID from the Git log', async () => {
mocked(git.log).mockResolvedValue([{ oid: 'a'.repeat(40) } as any]);

await expect(getHeadCommitId({ dir, env: {} })).resolves.toBe(
'a'.repeat(40),
);

expect(git.log).toHaveBeenCalledTimes(1);
});

it('throws on an empty Git log', async () => {
mocked(git.log).mockResolvedValue([]);

await expect(
getHeadCommitId({ dir, env: {} }),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Git log does not contain any commits"`,
);

expect(git.log).toHaveBeenCalledTimes(1);
});
});
32 changes: 32 additions & 0 deletions src/api/git/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'fs-extra';
import git from 'isomorphic-git';

interface GetHeadCommitIdParameters {
dir: string;
env?: Record<string, string | undefined>;
}

/**
* Gets the object ID of the head commit.
*
* This tries to extract the commit ID from common CI environment variables,
* and falls back to the local Git repository log.
*/
export const getHeadCommitId = async ({
dir,
env = process.env,
}: GetHeadCommitIdParameters): Promise<string> => {
const oidFromEnv = env.BUILDKITE_COMMIT ?? env.GITHUB_SHA;

if (oidFromEnv) {
return oidFromEnv;
}

const [headCommit] = await git.log({ depth: 1, dir, fs });

if (!headCommit) {
throw new Error('Git log does not contain any commits');
}

return headCommit.oid;
};
52 changes: 52 additions & 0 deletions src/api/git/push.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import git from 'isomorphic-git';
import { mocked } from 'ts-jest/utils';

import { push } from './push';

jest.mock('isomorphic-git');

afterEach(jest.resetAllMocks);

describe('push', () => {
it('propagates props to isomorphic-git', async () => {
mocked(git.listRemotes).mockResolvedValue([
{ remote: 'origin', url: '[email protected]:seek-oss/skuba.git' },
]);

mocked(git.push).mockResolvedValue({
ok: true,
error: null,
refs: {},
});

await expect(
push({
auth: { token: 'abc', type: 'gitHubApp' },
dir: '/workdir/skuba',
ref: 'c'.repeat(40),
remoteRef: 'feature-a',
}),
).resolves.toStrictEqual({
error: null,
ok: true,
refs: {},
});

expect(git.push).toHaveBeenCalledTimes(1);
expect(mocked(git.push).mock.calls[0][0]).toMatchInlineSnapshot(
{ http: expect.any(Object), fs: expect.any(Object) },
`
Object {
"dir": "/workdir/skuba",
"fs": Any<Object>,
"http": Any<Object>,
"onAuth": [Function],
"ref": "cccccccccccccccccccccccccccccccccccccccc",
"remote": undefined,
"remoteRef": "feature-a",
"url": "https://github.com/seek-oss/skuba",
}
`,
);
});
});
75 changes: 75 additions & 0 deletions src/api/git/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import fs from 'fs-extra';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';

import { getOwnerAndRepo } from './remote';

/**
* Use a GitHub app token to auth the Git push.
*
* This defaults to the `GITHUB_API_TOKEN` and `GITHUB_TOKEN` environment
* variables if `token` is not provided.
*/
interface GitHubAppAuth {
type: 'gitHubApp';
token?: string;
}

interface PushParameters {
/**
* The auth mechanism for the push.
*
* Currently, only GitHub app tokens are supported.
*/
auth: GitHubAppAuth;

dir: string;

/**
* The reference to push to the remote.
*
* This may be a commit, branch or tag in the local repository.
*/
ref: string;

remote?: string;

/**
* The destination branch or tag on the remote.
*
* This defaults to `ref`.
*/
remoteRef?: string;
}

/**
* Pushes the specified `ref` from the local Git repository to a remote.
*/
export const push = async ({
auth,
dir,
ref,
remote,
remoteRef,
}: PushParameters) => {
const { owner, repo } = await getOwnerAndRepo({ dir });

const url = `https://github.com/${encodeURIComponent(
owner,
)}/${encodeURIComponent(repo)}`;

return git.push({
onAuth: () => ({
username: 'x-access-token',
password:
auth.token ?? process.env.GITHUB_API_TOKEN ?? process.env.GITHUB_TOKEN,
}),
dir,
fs,
http,
ref,
remote,
remoteRef,
url,
});
};
Loading