@@ -16,19 +16,30 @@ import {isGithubApiError} from '../../../utils/git/github.js';
1616import { FatalMergeToolError , MergeConflictsFatalError } from '../failures.js' ;
1717import { Prompt } from '../../../utils/prompt.js' ;
1818import { 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. */
2123type OctokitMergeParams = RestEndpointMethodTypes [ 'pulls' ] [ 'merge' ] [ 'parameters' ] ;
2224
2325/** Separator between commit message header and body. */
2426const 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 */
3344export 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}
0 commit comments