Skip to content

Commit 2bd1e3d

Browse files
authored
feat(auto-merge): Check if PR is mergeable before merging (#1035)
1 parent fa45b1c commit 2bd1e3d

File tree

11 files changed

+1288
-99
lines changed

11 files changed

+1288
-99
lines changed

packages/auto-merge/.automergerc.example.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"authToken": "my-secret-token",
33
"autoApprove": true,
44
"dryRun": false,
5-
"mergeDrafts": false,
65
"projects": {
76
"gitHub": ["ffflorian/node-packages", "ffflorian/ts-boilerplate"]
87
},

packages/auto-merge/README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ Options:
2727
-a, --approve approve before merging
2828
-c, --config <path> specify a configuration file (default: .automergerc.json)
2929
-d, --dry-run don't send any data
30-
-f, --merge-drafts merge draft PRs (default: false)
3130
-s, --squash squash when merging (default: false)
3231
-V, --version output the version number
3332
-h, --help display help for command
@@ -41,20 +40,18 @@ The structure of the configuration file is the following:
4140

4241
```ts
4342
{
44-
/** The GitHub auth token */
43+
/** The GitHub auth token (needs read and write access to code and pull requests) */
4544
authToken: string;
4645
/** Approve before merging */
4746
autoApprove?: boolean;
4847
/** Don't send any data */
4948
dryRun?: boolean;
50-
/** Merge draft PRs */
51-
mergeDrafts?: boolean;
5249
/** All projects to include */
5350
projects: {
5451
/** All projects hosted on GitHub in the format `user/repo` */
5552
gitHub: string[];
5653
};
57-
/* Squash when merging */
54+
/** Squash when merging */
5855
squash?: boolean;
5956
}
6057
```

packages/auto-merge/src/AutoMerge.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {expect, describe, test, beforeEach, afterEach, vi} from 'vitest';
22
import nock from 'nock';
33
import {StatusCodes as HTTP_STATUS} from 'http-status-codes';
44

5-
import {AutoMerge, Repository} from './AutoMerge.js';
5+
import {AutoMerge} from './AutoMerge.js';
6+
import type {GitHubPullRequest, Repository} from './types/index.js';
67

78
describe('AutoMerge', () => {
89
describe('checkRepository', () => {
@@ -21,7 +22,7 @@ describe('AutoMerge', () => {
2122
number: 253,
2223
title: 'chore: bump eslint-plugin-typescript-sort-keys from 1.3.0 to 1.5.0',
2324
},
24-
],
25+
] as GitHubPullRequest[],
2526
repositorySlug: validRepositoryNames[0],
2627
},
2728
{
@@ -35,7 +36,7 @@ describe('AutoMerge', () => {
3536
number: 252,
3637
title: 'chore: bump typescript from 4.0.2 to 4.0.3',
3738
},
38-
],
39+
] as GitHubPullRequest[],
3940
repositorySlug: validRepositoryNames[1],
4041
},
4142
];
@@ -49,12 +50,17 @@ describe('AutoMerge', () => {
4950
});
5051

5152
nock(autoMerge['apiClient'].defaults.baseURL!)
52-
.post(/^\/repos\/[^/]+\/[^/]+\/pulls\/\d+\/reviews\/?$/)
53+
.post(/^\/repos\/.+?\/.+?\/pulls\/\d+\/reviews\/?$/)
5354
.reply(HTTP_STATUS.OK, {data: 'not-used'})
5455
.persist();
5556

5657
nock(autoMerge['apiClient'].defaults.baseURL!)
57-
.put(/^\/repos\/[^/]+\/[^/]+\/pulls\/\d+\/merge\/?$/)
58+
.get(/^\/repos\/.+?\/.+?\/pulls\/\d+\/?$/)
59+
.reply(HTTP_STATUS.OK, {data: 'not-used'})
60+
.persist();
61+
62+
nock(autoMerge['apiClient'].defaults.baseURL!)
63+
.put(/^\/repos\/.+?\/.+?\/pulls\/\d+\/merge\/?$/)
5864
.reply(HTTP_STATUS.OK, {data: 'not-used'})
5965
.persist();
6066
});

packages/auto-merge/src/AutoMerge.ts

Lines changed: 49 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import path from 'node:path';
33
import axios, {AxiosError, AxiosInstance} from 'axios';
44
import logdown from 'logdown';
55

6+
import type {AutoMergeConfig, ActionResult, GitHubPullRequest, Repository, RepositoryResult} from './types/index.js';
7+
68
interface PackageJson {
79
bin: Record<string, string>;
810
version: string;
@@ -14,55 +16,6 @@ const packageJsonPath = path.join(__dirname, '../package.json');
1416
const {bin, version: toolVersion}: PackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
1517
const toolName = Object.keys(bin)[0];
1618

17-
/** @see https://docs.github.com/en/rest/reference/pulls#get-a-pull-request */
18-
interface GitHubPullRequest {
19-
draft: boolean;
20-
head: {
21-
/** The branch name */
22-
ref: string;
23-
/** The commit SHA-1 hash */
24-
sha: string;
25-
};
26-
/** The pull request number */
27-
number: number;
28-
/** The pull request title */
29-
title: string;
30-
}
31-
32-
export interface ActionResult {
33-
error?: string;
34-
pullNumber: number;
35-
status: 'bad' | 'good';
36-
}
37-
38-
export interface AutoMergeConfig {
39-
/** The GitHub auth token */
40-
authToken: string;
41-
/** Approve before merging */
42-
autoApprove?: boolean;
43-
/** Don't send any data */
44-
dryRun?: boolean;
45-
/** Merge draft PRs */
46-
mergeDrafts?: boolean;
47-
/** All projects to include */
48-
projects: {
49-
/** All projects hosted on GitHub in the format `user/repo` */
50-
gitHub: string[];
51-
};
52-
/** Squash when merging */
53-
squash?: boolean;
54-
}
55-
56-
export interface Repository {
57-
pullRequests: GitHubPullRequest[];
58-
repositorySlug: string;
59-
}
60-
61-
export interface RepositoryResult {
62-
actionResults: ActionResult[];
63-
repositorySlug: string;
64-
}
65-
6619
export class AutoMerge {
6720
private readonly apiClient: AxiosInstance;
6821
private readonly config: AutoMergeConfig;
@@ -78,6 +31,7 @@ export class AutoMerge {
7831
this.apiClient = axios.create({
7932
baseURL: 'https://api.github.com',
8033
headers: {
34+
Accept: 'application/vnd.github+json',
8135
Authorization: `token ${this.config.authToken}`,
8236
'User-Agent': `${toolName} v${toolVersion}`,
8337
},
@@ -110,44 +64,56 @@ export class AutoMerge {
11064
const allRepositories = repositories || (await this.getRepositoriesWithOpenPullRequests());
11165
const matchingRepositories = this.getMatchingRepositories(allRepositories, regex);
11266

113-
const resultPromises = matchingRepositories.map(async ({pullRequests, repositorySlug}) => {
114-
const actionPromises = pullRequests.map(pullRequest =>
115-
this.approveByPullNumber(repositorySlug, pullRequest.number)
116-
);
117-
const actionResults = await Promise.all(actionPromises);
118-
return {actionResults, repositorySlug};
119-
});
67+
const processedRepositories: RepositoryResult[] = [];
68+
for (const {pullRequests, repositorySlug} of matchingRepositories) {
69+
const actionResults: ActionResult[] = [];
70+
for (const pullRequest of pullRequests) {
71+
actionResults.push(await this.approveByPullNumber(repositorySlug, pullRequest.number));
72+
}
73+
processedRepositories.push({actionResults, repositorySlug});
74+
}
12075

121-
return Promise.all(resultPromises);
76+
return processedRepositories;
12277
}
12378

12479
private getMatchingRepositories(repositories: Repository[], regex: RegExp): Repository[] {
125-
return repositories
126-
.map(repository => {
127-
const matchingPullRequests = repository.pullRequests.filter(pullRequest =>
128-
new RegExp(regex).test(pullRequest.head.ref)
129-
);
130-
if (matchingPullRequests.length) {
131-
return {pullRequests: matchingPullRequests, repositorySlug: repository.repositorySlug};
132-
}
133-
return undefined;
134-
})
135-
.filter(Boolean) as Repository[];
80+
const matchingRepositories: Repository[] = [];
81+
for (const repository of repositories) {
82+
const matchingPullRequests = repository.pullRequests.filter(pullRequest =>
83+
new RegExp(regex).test(pullRequest.head.ref)
84+
);
85+
if (matchingPullRequests.length) {
86+
matchingRepositories.push({pullRequests: matchingPullRequests, repositorySlug: repository.repositorySlug});
87+
}
88+
}
89+
return matchingRepositories;
90+
}
91+
92+
private async isPullRequestMergeable(repositorySlug: string, pullNumber: number): Promise<boolean> {
93+
const resourceUrl = `/repos/${repositorySlug}/pulls/${pullNumber}`;
94+
const response = await this.apiClient.get<GitHubPullRequest>(resourceUrl);
95+
return response.data.mergeable_state === 'clean';
13696
}
13797

13898
async mergeByMatch(regex: RegExp, repositories?: Repository[]): Promise<RepositoryResult[]> {
13999
const allRepositories = repositories || (await this.getRepositoriesWithOpenPullRequests());
140100
const matchingRepositories = this.getMatchingRepositories(allRepositories, regex);
141101

142-
const resultPromises = matchingRepositories.map(async ({pullRequests, repositorySlug}) => {
143-
const actionPromises = pullRequests.map(pullRequest =>
144-
this.mergePullRequest(repositorySlug, pullRequest.number, this.config.squash)
145-
);
146-
const actionResults = await Promise.all(actionPromises);
147-
return {actionResults, repositorySlug};
148-
});
102+
const processedRepositories: RepositoryResult[] = [];
103+
for (const {pullRequests, repositorySlug} of matchingRepositories) {
104+
const actionResults: ActionResult[] = [];
105+
for (const pullRequest of pullRequests) {
106+
const isMergeable = this.isPullRequestMergeable(repositorySlug, pullRequest.number);
107+
if (!isMergeable) {
108+
this.logger.warn(`Pull request #${pullRequest.number} in "${repositorySlug}" is not mergeable. Skipping.`);
109+
continue;
110+
}
111+
actionResults.push(await this.mergePullRequest(repositorySlug, pullRequest.number, this.config.squash));
112+
}
113+
processedRepositories.push({actionResults, repositorySlug});
114+
}
149115

150-
return Promise.all(resultPromises);
116+
return processedRepositories;
151117
}
152118

153119
async approveByPullNumber(repositorySlug: string, pullNumber: number): Promise<ActionResult> {
@@ -189,17 +155,18 @@ export class AutoMerge {
189155
this.checkRepositorySlug(repositorySlug)
190156
);
191157

192-
const repositoriesPromises = repositorySlugs.map(async repositorySlug => {
158+
const repositories: Repository[] = [];
159+
160+
for (const repositorySlug of repositorySlugs) {
193161
try {
194162
const pullRequests = await this.getPullRequestsBySlug(repositorySlug);
195-
return {pullRequests, repositorySlug};
163+
repositories.push({pullRequests, repositorySlug});
196164
} catch (error) {
197165
this.logger.error(`Could not get pull requests for "${repositorySlug}": ${(error as AxiosError).message}`);
198-
return undefined;
199166
}
200-
});
167+
}
201168

202-
return (await Promise.all(repositoriesPromises)).filter(Boolean) as Repository[];
169+
return repositories;
203170
}
204171

205172
async getRepositoriesWithOpenPullRequests(): Promise<Repository[]> {
@@ -221,11 +188,8 @@ export class AutoMerge {
221188

222189
private async getPullRequestsBySlug(repositorySlug: string): Promise<GitHubPullRequest[]> {
223190
const resourceUrl = `/repos/${repositorySlug}/pulls`;
224-
const params = {state: 'open'};
191+
const params = {per_page: 100, state: 'open'};
225192
const response = await this.apiClient.get<GitHubPullRequest[]>(resourceUrl, {params});
226-
if (this.config.mergeDrafts) {
227-
response.data = response.data.filter(pr => !pr.draft);
228-
}
229193
return response.data;
230194
}
231195
}

packages/auto-merge/src/cli.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {program as commander} from 'commander';
77
import {cosmiconfigSync} from 'cosmiconfig';
88
import logdown from 'logdown';
99

10-
import {AutoMergeConfig, AutoMerge, Repository, RepositoryResult} from './AutoMerge.js';
10+
import {AutoMerge} from './AutoMerge.js';
11+
import type {AutoMergeConfig, Repository, RepositoryResult} from './types/index.js';
1112
import {pluralize} from './util.js';
1213

1314
const input = readline.createInterface(process.stdin, process.stdout);
@@ -52,7 +53,6 @@ const configFileData: AutoMergeConfig = {
5253
...configResult.config,
5354
...(commanderOptions.approve && {autoApprove: commanderOptions.approve}),
5455
...(commanderOptions.dryRun && {dryRun: commanderOptions.dryRun}),
55-
...(commanderOptions.mergeDrafts && {mergeDrafts: commanderOptions.mergeDrafts}),
5656
};
5757

5858
async function runAction(autoMerge: AutoMerge, repositories: Repository[], pullRequestSlug: string): Promise<void> {
@@ -67,7 +67,7 @@ async function runAction(autoMerge: AutoMerge, repositories: Repository[], pullR
6767
const mergeResults = await autoMerge.mergeByMatch(regex, repositories);
6868

6969
const successCount = [...approveResults, ...mergeResults].filter(repository => {
70-
return repository.actionResults.some(result => result.error === undefined);
70+
return repository.actionResults.some(result => typeof result.error === 'undefined');
7171
}).length;
7272

7373
const prPluralized = pluralize('PR', successCount);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface ActionResult {
2+
error?: string;
3+
pullNumber: number;
4+
status: 'bad' | 'good';
5+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface AutoMergeConfig {
2+
/** The GitHub auth token (needs read and write access to code and pull requests) */
3+
authToken: string;
4+
/** Approve before merging */
5+
autoApprove?: boolean;
6+
/** Don't send any data */
7+
dryRun?: boolean;
8+
/** All projects to include */
9+
projects: {
10+
/** All projects hosted on GitHub in the format `user/repo` */
11+
gitHub: string[];
12+
};
13+
/** Squash when merging */
14+
squash?: boolean;
15+
}

0 commit comments

Comments
 (0)