Skip to content

Commit

Permalink
feat: merge in accordance with branch protection rules
Browse files Browse the repository at this point in the history
  • Loading branch information
acazacu committed Jul 20, 2021
1 parent cff8b78 commit 172fa69
Show file tree
Hide file tree
Showing 17 changed files with 1,002 additions and 76 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ overrides:
max-lines-per-function: 'off'
no-magic-numbers: 'off'
max-statements: 'off'
- files:
- src/**/computeRequiresStrictStatusChecksForRefs.*
rules:
'unicorn/prevent-abbreviations': 'off'
parserOptions:
ecmaVersion: 2020
project: tsconfig.json
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ jobs:
`GITHUB_LOGIN` option supports
[micromatch](https://github.com/micromatch/micromatch).

### Opting in for using GitHub preview APIs

You may opt-in for using GitHub preview APIs, which enables the action to
respect strict branch protection rules configured for the repository
(`Require status checks to pass before merging` and
`Require branches to be up to date before merging` options).

```yaml
jobs:
merge-me:
steps:
- name: Merge me!
uses: ridedott/merge-me-action@v2
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENABLE_GITHUB_API_PREVIEW: true
```

### Use of configurable pull request merge method

By default, this GitHub Action assumes merge method is `SQUASH`. You can
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ inputs:
GITHUB_TOKEN:
description: 'A GitHub token.'
required: true
ENABLE_GITHUB_API_PREVIEW:
default: 'false'
description: >
Indicates if GitHub preview APIs can be used to access pull request fields
that provide more detailed information about the merge state.
required: false
MERGE_METHOD:
default: SQUASH
description:
Expand Down
36 changes: 36 additions & 0 deletions src/common/__snapshots__/getPullRequestInformation.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`getPullRequestInformation returns pull request information with mergeStateStatus field 1`] = `
Object {
"authorLogin": "test-author",
"commitMessage": "test message",
"commitMessageHeadline": "test message headline",
"mergeStateStatus": "CLEAN",
"mergeableState": "MERGEABLE",
"merged": false,
"pullRequestId": "123",
"pullRequestNumber": 1,
"pullRequestState": "OPEN",
"pullRequestTitle": "test",
"repositoryName": "test-repository",
"repositoryOwner": "test-owner",
"reviewEdges": Array [],
}
`;

exports[`getPullRequestInformation returns pull request information without mergeStateStatus field 1`] = `
Object {
"authorLogin": "test-author",
"commitMessage": "test message",
"commitMessageHeadline": "test message headline",
"mergeableState": "MERGEABLE",
"merged": false,
"pullRequestId": "123",
"pullRequestNumber": 1,
"pullRequestState": "OPEN",
"pullRequestTitle": "test",
"repositoryName": "test-repository",
"repositoryOwner": "test-owner",
"reviewEdges": Array [],
}
`;
93 changes: 93 additions & 0 deletions src/common/computeRequiresStrictStatusChecksForRefs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { computeRequiresStrictStatusChecksForRefs as computeRequiresStrictStatusChecksForReferences } from './computeRequiresStrictStatusChecksForRefs';

/**
* Tests
*/
describe('computeRequiresStrictStatusChecksForRefs', (): void => {
it('returns false for all refs when no branch protection rules exist for the repository', (): void => {
expect.assertions(1);

const result = computeRequiresStrictStatusChecksForReferences(
[],
['master', 'dev'],
);

expect(result).toStrictEqual([false, false]);
});

it('returns false for all refs when none of the branch protection rule patterns match provided refs', (): void => {
expect.assertions(1);

const result = computeRequiresStrictStatusChecksForReferences(
[
{
pattern: 'test1',
requiresStrictStatusChecks: true,
},
{
pattern: 'test2',
requiresStrictStatusChecks: true,
},
],
['master', 'dev'],
);

expect(result).toStrictEqual([false, false]);
});

it('returns false for all refs when all matching branch protection rule patterns do not require strict status checks', (): void => {
expect.assertions(1);

const result = computeRequiresStrictStatusChecksForReferences(
[
{
pattern: 'dev',
requiresStrictStatusChecks: false,
},
{
pattern: 'master',
requiresStrictStatusChecks: false,
},
],
['master', 'dev'],
);

expect(result).toStrictEqual([false, false]);
});

it('returns true for all refs when branch protection rule patterns match provided refs with wildcard', (): void => {
expect.assertions(1);

const result = computeRequiresStrictStatusChecksForReferences(
[
{
pattern: 'test*',
requiresStrictStatusChecks: true,
},
],
['test', 'testing', 'master'],
);

expect(result).toStrictEqual([true, true, false]);
});

it('returns true for refs when matching branch protection rules require strict status checks', (): void => {
expect.assertions(1);

const result = computeRequiresStrictStatusChecksForReferences(
[
{
pattern: 'dev',
requiresStrictStatusChecks: true,
},
{
pattern: 'master',
requiresStrictStatusChecks: false,
},
],
['master', 'dev'],
);

expect(result).toStrictEqual([false, true]);
});
});
21 changes: 21 additions & 0 deletions src/common/computeRequiresStrictStatusChecksForRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isMatch } from 'micromatch';

import { BranchProtectionRule } from './listBranchProtectionRules';

/**
* Returns an array of booleans indicating whether the provided pull requests
* require their branches to be up to date before merging.
*/
export const computeRequiresStrictStatusChecksForRefs = (
branchProtectionRules: BranchProtectionRule[],
refs: string[],
): boolean[] =>
refs.map((reference: string): boolean =>
branchProtectionRules.some(
({
pattern,
requiresStrictStatusChecks,
}: BranchProtectionRule): boolean =>
isMatch(reference, pattern) && requiresStrictStatusChecks === true,
),
);
192 changes: 192 additions & 0 deletions src/common/getPullRequestInformation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/* cspell:ignore reqheaders */

import { getOctokit } from '@actions/github';
import { StatusCodes } from 'http-status-codes';
import * as nock from 'nock';

import {
MergeableState,
MergeStateStatus,
PullRequestState,
ReviewEdges,
} from '../types';
import { getMergeablePullRequestInformationByPullRequestNumber } from './getPullRequestInformation';

/**
* Test utilities
*/
const octokit = getOctokit('SECRET_GITHUB_TOKEN');
const repositoryName = 'test-repository';
const repositoryOwner = 'test-owner';
const pullRequestNumber = 1;

const pullRequestFields = (githubPreviewApiEnabled: boolean): string => {
const fields = [
`author {
login
}`,
`commits(last: 1) {
edges {
node {
commit {
author {
name
}
messageHeadline
message
}
}
}
}`,
'id',
'mergeable',
'merged',
...(githubPreviewApiEnabled ? ['mergeStateStatus'] : []),
'number',
`reviews(last: 1, states: APPROVED) {
edges {
node {
state
}
}
}`,
'state',
'title',
];

return `{
${fields.join('\n')}
}`;
};

const findPullRequestInfoByNumberQuery = (
githubPreviewApiEnabled: boolean,
): string => `
query FindPullRequestInfoByNumber(
$repositoryOwner: String!,
$repositoryName: String!,
$pullRequestNumber: Int!
) {
repository(owner: $repositoryOwner, name: $repositoryName) {
pullRequest(number: $pullRequestNumber) ${pullRequestFields(
githubPreviewApiEnabled,
)}
}
}
`;

interface GraphQLResponse {
repository: {
pullRequest: {
author: {
login: string;
};
commits: {
edges: Array<{
node: {
commit: {
author: {
name: string;
};
message: string;
messageHeadline: string;
};
};
}>;
};
id: string;
mergeStateStatus?: MergeStateStatus;
mergeable: MergeableState;
merged: boolean;
number: number;
reviews: {
edges: ReviewEdges[];
};
state: PullRequestState;
title: string;
};
};
}

const makeGraphQLResponse = (
includeMergeStateStatus: boolean,
): GraphQLResponse => ({
repository: {
pullRequest: {
author: {
login: 'test-author',
},
commits: {
edges: [
{
node: {
commit: {
author: {
name: 'Test Author',
},
message: 'test message',
messageHeadline: 'test message headline',
},
},
},
],
},
id: '123',
...(includeMergeStateStatus ? { mergeStateStatus: 'CLEAN' } : {}),
mergeable: 'MERGEABLE',
merged: false,
number: pullRequestNumber,
reviews: {
edges: [],
},
state: 'OPEN',
title: 'test',
},
},
});

/**
* Tests
*/
describe('getPullRequestInformation', (): void => {
it.each<[string, boolean]>([
['without mergeStateStatus field', false],
['with mergeStateStatus field', true],
])(
'returns pull request information %s',
async (_: string, githubPreviewApiEnabled: boolean): Promise<void> => {
expect.assertions(1);

nock('https://api.github.com', {
reqheaders: {
accept: githubPreviewApiEnabled
? 'application/vnd.github.merge-info-preview+json'
: 'application/vnd.github.v3+json',
},
})
.post('/graphql', {
query: findPullRequestInfoByNumberQuery(githubPreviewApiEnabled),
variables: {
pullRequestNumber,
repositoryName,
repositoryOwner,
},
})
.reply(StatusCodes.OK, {
data: makeGraphQLResponse(githubPreviewApiEnabled),
});

const result = await getMergeablePullRequestInformationByPullRequestNumber(
octokit,
{
pullRequestNumber,
repositoryName,
repositoryOwner,
},
{ githubPreviewApiEnabled },
);

expect(result).toMatchSnapshot();
},
);
});
Loading

0 comments on commit 172fa69

Please sign in to comment.