Skip to content

Commit 8704d6c

Browse files
authored
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.
1 parent 0e8914a commit 8704d6c

File tree

6 files changed

+179
-120
lines changed

6 files changed

+179
-120
lines changed

.github/local-actions/branch-manager/main.js

Lines changed: 17 additions & 23 deletions
Large diffs are not rendered by default.

.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: 128 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,30 @@ 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';
20+
import {TEMP_PR_HEAD_BRANCH} from './strategy.js';
1921

2022
/** Type describing the parameters for the Octokit `merge` API endpoint. */
2123
type OctokitMergeParams = RestEndpointMethodTypes['pulls']['merge']['parameters'];
2224

2325
/** Separator between commit message header and body. */
2426
const COMMIT_HEADER_SEPARATOR = '\n\n';
2527

28+
/** Interface describing a pull request commit. */
29+
interface PullRequestCommit {
30+
message: string;
31+
parsed: Commit;
32+
}
33+
2634
/**
2735
* Merge strategy that primarily leverages the Github API. The strategy merges a given
2836
* pull request into a target branch using the API. This ensures that Github displays
2937
* the pull request as merged. The merged commits are then cherry-picked into the remaining
3038
* 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.
39+
* is properly set.
40+
*
41+
* A notable downside is that fixup or squash commits are not supported when `auto` merge
42+
* method is not used, as the Github API does not support this.
3243
*/
3344
export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
3445
constructor(
@@ -48,23 +59,56 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
4859
*/
4960
override async merge(pullRequest: PullRequest): Promise<void> {
5061
const {githubTargetBranch, prNumber, needsCommitMessageFixup, targetBranches} = pullRequest;
51-
const method = this.getMergeActionFromPullRequest(pullRequest);
5262
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-
}
61-
63+
const commits = await this.getPullRequestCommits(pullRequest);
64+
const {squashCount, fixupCount, normalCommitsCount} = await this.getCommitsInfo(pullRequest);
65+
const method = this.getMergeActionFromPullRequest(pullRequest);
66+
let pullRequestCommitCount = pullRequest.commitCount;
6267
const mergeOptions: OctokitMergeParams = {
6368
pull_number: prNumber,
64-
merge_method: method === 'rebase-with-fixup' ? 'rebase' : method,
69+
merge_method: method === 'auto' ? 'rebase' : method,
6570
...this.git.remoteParams,
6671
};
6772

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

@@ -110,24 +155,29 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
110155
);
111156
}
112157

113-
// If the PR does not need to be merged into any other target branches,
114-
// we exit here as we already completed the merge.
115-
if (!cherryPickTargetBranches.length) {
116-
return;
117-
}
158+
// Workaround for fatal: refusing to fetch into branch 'refs/heads/merge_pr_target_main' checked out at ...
159+
// Cannot find where but `merge_pr_target_main` is being set as the current branch.
160+
// TODO: remove after finding the root cause.
161+
this.git.run(['checkout', TEMP_PR_HEAD_BRANCH]);
118162

119163
// Refresh the target branch the PR has been merged into through the API. We need
120164
// to re-fetch as otherwise we cannot cherry-pick the new commits into the remaining
121-
// target branches.
165+
// target branches. Also, this is needed fo the merge comment to get the correct commit SHA.
122166
this.fetchTargetBranches([githubTargetBranch]);
123167

124-
// Number of commits that have landed in the target branch. This could vary from
125-
// the count of commits in the PR due to squashing.
126-
const targetCommitsCount = method === 'squash' ? 1 : pullRequest.commitCount;
168+
// If the PR does not need to be merged into any other target branches,
169+
// we exit here as we already completed the merge.
170+
if (!cherryPickTargetBranches.length) {
171+
await this.createMergeComment(pullRequest, targetBranches);
172+
173+
return;
174+
}
127175

128176
// Cherry pick the merged commits into the remaining target branches.
129177
const failedBranches = await this.cherryPickIntoTargetBranches(
130-
`${targetSha}~${targetCommitsCount}..${targetSha}`,
178+
// Number of commits that have landed in the target branch. This could vary from
179+
// the count of commits in the PR due to squashing.
180+
`${targetSha}~${pullRequestCommitCount}..${targetSha}`,
131181
cherryPickTargetBranches,
132182
{
133183
// Commits that have been created by the Github API do not necessarily contain
@@ -146,27 +196,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
146196
}
147197

148198
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-
});
199+
await this.createMergeComment(pullRequest, targetBranches);
170200
}
171201

172202
/**
@@ -178,7 +208,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
178208
pullRequest: PullRequest,
179209
mergeOptions: OctokitMergeParams,
180210
) {
181-
const commitMessage = await this._getDefaultSquashCommitMessage(pullRequest);
211+
const commitMessage = await this.getDefaultSquashCommitMessage(pullRequest);
182212
const result = await Prompt.editor({
183213
message: 'Please update the commit message',
184214
default: commitMessage,
@@ -198,7 +228,7 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
198228
* multiple commit messages if a PR is merged in squash mode. We try to replicate this
199229
* behavior here so that we have a default commit message that can be fixed up.
200230
*/
201-
private async _getDefaultSquashCommitMessage(pullRequest: PullRequest): Promise<string> {
231+
private async getDefaultSquashCommitMessage(pullRequest: PullRequest): Promise<string> {
202232
const commits = await this.getPullRequestCommits(pullRequest);
203233
const messageBase = `${pullRequest.title}${COMMIT_HEADER_SEPARATOR}`;
204234
if (commits.length <= 1) {
@@ -219,10 +249,60 @@ export class GithubApiMergeStrategy extends AutosquashMergeStrategy {
219249
return this.config.default;
220250
}
221251

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

226-
return commits.some(({parsed: {isFixup, isSquash}}) => isFixup || isSquash);
306+
return this.commits;
227307
}
228308
}

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)