Skip to content

Commit 303e289

Browse files
committed
feat(ng-dev): add auto merge strategy for pull requests
This commit introduces a new `auto` merge strategy for pull requests in `ng-dev`. When the `auto` merge method is used, the merge strategy will automatically determine the best merge method based on the pull request's commits. The new `auto` merge strategy can: - Delegate to the autosquash merge strategy if the PR has fixup/squash commits against multiple normal commits. - Squash commits if the PR has only one normal commit and some fixup/squash commits. - Rebase commits if the PR has no fixup/squash commits. This improves the developer experience by automating the merge process and ensuring that PRs are merged in the most appropriate way.
1 parent e6f02a3 commit 303e289

File tree

5 files changed

+141
-86
lines changed

5 files changed

+141
-86
lines changed

.ng-dev/pull-request.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {PullRequestConfig} from '../ng-dev/pr/config/index.js';
33
/** Configuration for interacting with pull requests in the repo. */
44
export const pullRequest: PullRequestConfig = {
55
githubApiMerge: {
6-
default: 'rebase-with-fixup',
6+
default: 'auto',
77
labels: [{pattern: 'merge: squash commits', method: 'squash'}],
88
},
99
requiredStatuses: [{name: 'test', type: 'check'}],

ng-dev/pr/config/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88

99
import {ConfigValidationError, GithubConfig, NgDevConfig} from '../../utils/config.js';
1010

11-
// TODO(alanagius): remove `rebase-with-fixup` and replace it's logic with `rebase`.
12-
// This is just temporary to allow testing in the dev-infra repo. Without breaking workflows in other repos.
13-
1411
/**
1512
* Possible merge methods supported by the Github API.
1613
* https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button.
14+
*
15+
* `auto` is a pseudo merge method that is not supported by the Github API. It allows
16+
* the merge strategy to automatically determine the merge method based on the pull request.
1717
*/
18-
export type GithubApiMergeMethod = 'merge' | 'squash' | 'rebase' | 'rebase-with-fixup';
18+
export type GithubApiMergeMethod = 'merge' | 'squash' | 'rebase' | 'auto';
1919

2020
/** Configuration for the Github API merge strategy. */
2121
export interface GithubApiMergeStrategyConfig {

ng-dev/pr/merge/strategies/api-merge.ts

Lines changed: 107 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,29 @@ import {isGithubApiError} from '../../../utils/git/github.js';
1616
import {FatalMergeToolError, MergeConflictsFatalError} from '../failures.js';
1717
import {Prompt} from '../../../utils/prompt.js';
1818
import {AutosquashMergeStrategy} from './autosquash-merge.js';
19+
import {Commit, parseCommitMessage} from '../../../commit-message/parse.js';
1920

2021
/** Type describing the parameters for the Octokit `merge` API endpoint. */
2122
type OctokitMergeParams = RestEndpointMethodTypes['pulls']['merge']['parameters'];
2223

2324
/** Separator between commit message header and body. */
2425
const COMMIT_HEADER_SEPARATOR = '\n\n';
2526

27+
/** Interface describing a pull request commit. */
28+
interface PullRequestCommit {
29+
message: string;
30+
parsed: Commit;
31+
}
32+
2633
/**
2734
* Merge strategy that primarily leverages the Github API. The strategy merges a given
2835
* pull request into a target branch using the API. This ensures that Github displays
2936
* the pull request as merged. The merged commits are then cherry-picked into the remaining
3037
* target branches using the local Git instance. The benefit is that the Github merged state
31-
* is properly set, but a notable downside is that PRs cannot use fixup or squash commits.
38+
* is properly set.
39+
*
40+
* A notable downside is that fixup or squash commits are not supported when `auto` merge
41+
* method is not used, as the Github API does not support this.
3242
*/
3343
export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
3444
constructor(
@@ -48,23 +58,50 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
4858
*/
4959
override async merge(pullRequest: PullRequest): Promise<void> {
5060
const {githubTargetBranch, prNumber, needsCommitMessageFixup, targetBranches} = pullRequest;
51-
const method = this.getMergeActionFromPullRequest(pullRequest);
5261
const cherryPickTargetBranches = targetBranches.filter((b) => b !== githubTargetBranch);
53-
54-
// Squash and Merge will create a single commit message and thus we can use the API to merge.
55-
if (
56-
method === 'rebase-with-fixup' &&
57-
(pullRequest.needsCommitMessageFixup || (await this.hasFixupOrSquashCommits(pullRequest)))
58-
) {
59-
return super.merge(pullRequest);
60-
}
62+
const commits = await this.getPullRequestCommits(pullRequest);
63+
const {squashCount, fixupCount, normalCommitsCount} = await this.getCommitsInfo(pullRequest);
64+
const method = this.getMergeActionFromPullRequest(pullRequest);
6165

6266
const mergeOptions: OctokitMergeParams = {
6367
pull_number: prNumber,
64-
merge_method: method === 'rebase-with-fixup' ? 'rebase' : method,
68+
merge_method: method === 'auto' ? 'rebase' : method,
6569
...this.git.remoteParams,
6670
};
6771

72+
// When the merge method is `auto`, the merge strategy will determine the best merge method
73+
// based on the pull request's commits.
74+
if (method === 'auto') {
75+
const hasFixUpOrSquashAndMultipleCommits =
76+
normalCommitsCount > 1 && (fixupCount > 0 || squashCount > 0);
77+
78+
// If the PR has fixup/squash commits against multiple normal commits, or if the
79+
// commit message needs to be fixed up, delegate to the autosquash merge strategy.
80+
if (needsCommitMessageFixup || hasFixUpOrSquashAndMultipleCommits) {
81+
return super.merge(pullRequest);
82+
}
83+
84+
const hasOnlyFixUpForOneCommit =
85+
normalCommitsCount === 1 && fixupCount > 0 && squashCount === 0;
86+
87+
const hasOnlySquashForOneCommit = normalCommitsCount === 1 && squashCount > 1;
88+
89+
// If the PR has only one normal commit and some fixup commits, the PR is squashed.
90+
// The commit message from the single normal commit is used.
91+
if (hasOnlyFixUpForOneCommit) {
92+
mergeOptions.merge_method = 'squash';
93+
const [title, message] = commits[0].message.split(COMMIT_HEADER_SEPARATOR);
94+
95+
mergeOptions.commit_title = title;
96+
mergeOptions.commit_message = message;
97+
// If the PR has only one normal commit and more than one squash commit, the PR is
98+
// squashed and the user is prompted to edit the commit message.
99+
} else if (hasOnlySquashForOneCommit) {
100+
mergeOptions.merge_method = 'squash';
101+
await this._promptCommitMessageEdit(pullRequest, mergeOptions);
102+
}
103+
}
104+
68105
if (needsCommitMessageFixup) {
69106
// Commit message fixup does not work with other merge methods as the Github API only
70107
// allows commit message modifications for squash merging.
@@ -74,6 +111,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
74111
`modified if the PR is merged using squash.`,
75112
);
76113
}
114+
77115
await this._promptCommitMessageEdit(pullRequest, mergeOptions);
78116
}
79117

@@ -113,6 +151,8 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
113151
// If the PR does not need to be merged into any other target branches,
114152
// we exit here as we already completed the merge.
115153
if (!cherryPickTargetBranches.length) {
154+
await this.createMergeComment(pullRequest, targetBranches);
155+
116156
return;
117157
}
118158

@@ -146,27 +186,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
146186
}
147187

148188
this.pushTargetBranchesUpstream(cherryPickTargetBranches);
149-
150-
/** The local branch names of the github targeted branches. */
151-
const banchesAndSha: [branchName: string, commitSha: string][] = targetBranches.map(
152-
(targetBranch) => {
153-
const localBranch = this.getLocalTargetBranchName(targetBranch);
154-
155-
/** The SHA of the commit pushed to github which represents closing the PR. */
156-
const sha = this.git.run(['rev-parse', localBranch]).stdout.trim();
157-
return [targetBranch, sha];
158-
},
159-
);
160-
// Because our process brings changes into multiple branchces, we include a comment which
161-
// expresses all of the branches the changes were merged into.
162-
await this.git.github.issues.createComment({
163-
...this.git.remoteParams,
164-
issue_number: pullRequest.prNumber,
165-
body:
166-
'This PR was merged into the repository. ' +
167-
'The changes were merged into the following branches:\n\n' +
168-
`${banchesAndSha.map(([branch, sha]) => `- ${branch}: ${sha}`).join('\n')}`,
169-
});
189+
await this.createMergeComment(pullRequest, targetBranches);
170190
}
171191

172192
/**
@@ -178,7 +198,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
178198
pullRequest: PullRequest,
179199
mergeOptions: OctokitMergeParams,
180200
) {
181-
const commitMessage = await this._getDefaultSquashCommitMessage(pullRequest);
201+
const commitMessage = await this.getDefaultSquashCommitMessage(pullRequest);
182202
const result = await Prompt.editor({
183203
message: 'Please update the commit message',
184204
default: commitMessage,
@@ -198,7 +218,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
198218
* multiple commit messages if a PR is merged in squash mode. We try to replicate this
199219
* behavior here so that we have a default commit message that can be fixed up.
200220
*/
201-
private async _getDefaultSquashCommitMessage(pullRequest: PullRequest): Promise<string> {
221+
private async getDefaultSquashCommitMessage(pullRequest: PullRequest): Promise<string> {
202222
const commits = await this.getPullRequestCommits(pullRequest);
203223
const messageBase = `${pullRequest.title}${COMMIT_HEADER_SEPARATOR}`;
204224
if (commits.length <= 1) {
@@ -219,10 +239,60 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
219239
return this.config.default;
220240
}
221241

222-
/** Checks whether the pull request contains fixup or squash commits. */
223-
private async hasFixupOrSquashCommits(pullRequest: PullRequest): Promise<boolean> {
242+
/** Returns information about the commits in the pull request. */
243+
private async getCommitsInfo(pullRequest: PullRequest): Promise<
244+
Readonly<{
245+
fixupCount: number;
246+
squashCount: number;
247+
normalCommitsCount: number;
248+
}>
249+
> {
224250
const commits = await this.getPullRequestCommits(pullRequest);
251+
const commitsInfo = {
252+
fixupCount: 0,
253+
squashCount: 0,
254+
normalCommitsCount: 1,
255+
};
256+
257+
if (commits.length === 1) {
258+
return commitsInfo;
259+
}
260+
261+
for (let index = 1; index < commits.length; index++) {
262+
const {
263+
parsed: {isFixup, isSquash},
264+
} = commits[index];
265+
266+
if (isFixup) {
267+
commitsInfo.fixupCount++;
268+
} else if (isSquash) {
269+
commitsInfo.squashCount++;
270+
} else {
271+
commitsInfo.normalCommitsCount++;
272+
}
273+
}
274+
275+
return commitsInfo;
276+
}
277+
278+
/** Commits of the pull request. */
279+
private commits: PullRequestCommit[] | undefined;
280+
/** Gets all commit messages of commits in the pull request. */
281+
protected async getPullRequestCommits({prNumber}: PullRequest): Promise<PullRequestCommit[]> {
282+
if (this.commits) {
283+
return this.commits;
284+
}
285+
286+
const allCommits = await this.git.github.paginate(this.git.github.pulls.listCommits, {
287+
...this.git.remoteParams,
288+
pull_number: prNumber,
289+
});
290+
291+
this.commits = allCommits.map(({commit: {message}}) => ({
292+
message,
293+
parsed: parseCommitMessage(message),
294+
}));
225295

226-
return commits.some(({parsed: {isFixup, isSquash}}) => isFixup || isSquash);
296+
return this.commits;
227297
}
228298
}

ng-dev/pr/merge/strategies/autosquash-merge.ts

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {dirname, join} from 'path';
10-
import {fileURLToPath} from 'url';
9+
import {setTimeout as sleep} from 'node:timers/promises';
10+
import {dirname, join} from 'node:path';
11+
import {fileURLToPath} from 'node:url';
1112
import {PullRequest} from '../pull-request.js';
1213
import {MergeStrategy, TEMP_PR_HEAD_BRANCH} from './strategy.js';
1314
import {MergeConflictsFatalError} from '../failures.js';
@@ -17,7 +18,7 @@ import {MergeConflictsFatalError} from '../failures.js';
1718
* all target branches and the PR locally. The PR is then cherry-picked with autosquash
1819
* enabled into the target branches. The benefit is the support for fixup and squash commits.
1920
* A notable downside though is that Github does not show the PR as `Merged` due to non
20-
* fast-forward merges
21+
* fast-forward merges.
2122
*/
2223
export class AutosquashMergeStrategy extends MergeStrategy {
2324
/**
@@ -83,34 +84,11 @@ export class AutosquashMergeStrategy extends MergeStrategy {
8384
// Push the cherry picked branches upstream.
8485
this.pushTargetBranchesUpstream(targetBranches);
8586

86-
/** The local branch names of the github targeted branches. */
87-
const banchesAndSha: [branchName: string, commitSha: string][] = targetBranches.map(
88-
(targetBranch) => {
89-
const localBranch = this.getLocalTargetBranchName(targetBranch);
90-
91-
/** The SHA of the commit pushed to github which represents closing the PR. */
92-
const sha = this.git.run(['rev-parse', localBranch]).stdout.trim();
93-
return [targetBranch, sha];
94-
},
95-
);
96-
9787
// Allow user to set an amount of time to wait to account for rate limiting of the token usage
9888
// during merge otherwise just waits 0 seconds.
99-
await new Promise((resolve) =>
100-
setTimeout(resolve, parseInt(process.env['AUTOSQUASH_TIMEOUT'] || '0')),
101-
);
102-
// Github automatically closes PRs whose commits are merged into the main branch on Github.
103-
// However, it does not note them as merged using the purple merge badge as occurs when done via
104-
// the UI. To inform users that the PR was in fact merged, add a comment expressing the fact
105-
// that the PR is merged and what branches the changes were merged into.
106-
await this.git.github.issues.createComment({
107-
...this.git.remoteParams,
108-
issue_number: pullRequest.prNumber,
109-
body:
110-
'This PR was merged into the repository. ' +
111-
'The changes were merged into the following branches:\n\n' +
112-
`${banchesAndSha.map(([branch, sha]) => `- ${branch}: ${sha}`).join('\n')}`,
113-
});
89+
await sleep(parseInt(process.env['AUTOSQUASH_TIMEOUT'] || '0'));
90+
91+
await this.createMergeComment(pullRequest, targetBranches);
11492

11593
// For PRs which do not target the `main` branch on Github, Github does not automatically
11694
// close the PR when its commit is pushed into the repository. To ensure these PRs are

ng-dev/pr/merge/strategies/strategy.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Commit, parseCommitMessage} from '../../../commit-message/parse.js';
109
import {AuthenticatedGitClient} from '../../../utils/git/authenticated-git-client.js';
1110
import {
1211
FatalMergeToolError,
@@ -202,21 +201,29 @@ export abstract class MergeStrategy {
202201
}
203202
}
204203

205-
/** Gets all commit messages of commits in the pull request. */
206-
protected async getPullRequestCommits({prNumber}: PullRequest): Promise<
207-
{
208-
message: string;
209-
parsed: Commit;
210-
}[]
211-
> {
212-
const allCommits = await this.git.github.paginate(this.git.github.pulls.listCommits, {
204+
/** Creates a comment on the pull request to indicate that the PR has been merged. */
205+
protected async createMergeComment(
206+
pullRequest: PullRequest,
207+
targetBranches: string[],
208+
): Promise<void> {
209+
/** The local branch names of the github targeted branches. */
210+
const banchesAndSha: [branchName: string, commitSha: string][] = targetBranches.map(
211+
(targetBranch) => {
212+
const localBranch = this.getLocalTargetBranchName(targetBranch);
213+
214+
/** The SHA of the commit pushed to github which represents closing the PR. */
215+
const sha = this.git.run(['rev-parse', localBranch]).stdout.trim();
216+
return [targetBranch, sha];
217+
},
218+
);
219+
220+
await this.git.github.issues.createComment({
213221
...this.git.remoteParams,
214-
pull_number: prNumber,
222+
issue_number: pullRequest.prNumber,
223+
body:
224+
'This PR was merged into the repository. ' +
225+
'The changes were merged into the following branches:\n\n' +
226+
`${banchesAndSha.map(([branch, sha]) => `- ${branch}: ${sha}`).join('\n')}`,
215227
});
216-
217-
return allCommits.map(({commit: {message}}) => ({
218-
message,
219-
parsed: parseCommitMessage(message),
220-
}));
221228
}
222229
}

0 commit comments

Comments
 (0)