Skip to content

Commit 532eec7

Browse files
authored
Fix PR validity check in create_release_backmerge_pr (#607)
2 parents 31bb328 + 5eb73fb commit 532eec7

File tree

5 files changed

+133
-58
lines changed

5 files changed

+133
-58
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ _None_
1414

1515
### Bug Fixes
1616

17-
_None_
17+
- `create_release-backmerge_pull_request`: Fix the pre-check logic verifying if a PR is really needed or if there's nothing to backmerge. [#607]
1818

1919
### Internal Changes
2020

lib/fastlane/plugin/wpmreleasetoolkit/actions/common/create_release_backmerge_pull_request_action.rb

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,33 @@ def self.determine_target_branches(source_release_version:, default_branch:)
8989
# @return [String] The URL of the created Pull Request, or `nil` if no PR was created.
9090
#
9191
def self.create_backmerge_pr(token:, repository:, title:, head_branch:, base_branch:, labels:, milestone:, reviewers:, team_reviewers:, intermediate_branch_created_callback:)
92-
intermediate_branch = "merge/#{head_branch.gsub('/', '-')}-into-#{base_branch.gsub('/', '-')}"
92+
# Do an early pre-check to see if the PR would be valid, but only if no callback (as a callback might add new commits on intermediate branch)
93+
if intermediate_branch_created_callback.nil? && !can_merge?(head_branch, into: base_branch)
94+
UI.error("Nothing to merge from #{head_branch} into #{base_branch}. Skipping PR creation.")
95+
return nil
96+
end
9397

98+
# Create the intermediate branch
99+
intermediate_branch = "merge/#{head_branch.gsub('/', '-')}-into-#{base_branch.gsub('/', '-')}"
94100
if Fastlane::Helper::GitHelper.branch_exists_on_remote?(branch_name: intermediate_branch)
95101
UI.important("An intermediate branch `#{intermediate_branch}` already exists on the remote. It will be deleted and GitHub will close any associated existing PR.")
96102
Fastlane::Helper::GitHelper.delete_remote_branch_if_exists!(intermediate_branch)
97103
end
98-
99104
Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(intermediate_branch)
100105
Fastlane::Helper::GitHelper.create_branch(intermediate_branch)
101106

102-
intermediate_branch_created_callback&.call(base_branch, intermediate_branch)
103-
104-
# if there's a callback, make sure it didn't switch branches
105-
other_action.ensure_git_branch(branch: "^#{intermediate_branch}/") unless intermediate_branch_created_callback.nil?
106-
107-
if Fastlane::Helper::GitHelper.point_to_same_commit?(base_branch, head_branch)
108-
UI.error("No differences between #{head_branch} and #{base_branch}. Skipping PR creation.")
109-
return nil
107+
# Call the callback if one was provided to allow the use to add commits on the intermediate branch (e.g. solve conflicts)
108+
unless intermediate_branch_created_callback.nil?
109+
intermediate_branch_created_callback.call(base_branch, intermediate_branch)
110+
# Make sure the callback block didn't switch branches
111+
other_action.ensure_git_branch(branch: "^#{intermediate_branch}$")
112+
113+
# When a callback was provided, do the pre-check about valid PR _only_ at that point, in case the callback added new commits
114+
unless can_merge?(intermediate_branch, into: base_branch)
115+
UI.error("Nothing to merge from #{intermediate_branch} into #{base_branch}. Skipping PR creation.")
116+
Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(intermediate_branch)
117+
return nil
118+
end
110119
end
111120

112121
other_action.push_to_git_remote(tags: false)
@@ -138,6 +147,23 @@ def self.create_backmerge_pr(token:, repository:, title:, head_branch:, base_bra
138147
)
139148
end
140149

150+
# Determine if a `head->base` PR would be considered valid by GitHub.
151+
#
152+
# Note that a PR with an empty diff can still be valid (e.g. if you merge a commit and its revert)
153+
#
154+
# This method returns false mostly when all commits from `head` has already been merged into `base`
155+
# and that there are no new commits to merge (in which case GitHub would refuse creating the PR)
156+
#
157+
# @param head [String] the head reference (commit sha or branch name) we want to merge
158+
# @param into [String] the base reference (commit sha or branch name) we want to merge into
159+
# @return [Boolean] true if there are commits in `head` that are not yet in `base` and a merge can happen;
160+
# false if all commits from `head` are already in `base` and a merge would be rejected
161+
#
162+
def self.can_merge?(head, into:)
163+
merge_base = Fastlane::Helper::GitHelper.find_merge_base(into, head)
164+
!Fastlane::Helper::GitHelper.point_to_same_commit?(merge_base, head)
165+
end
166+
141167
def self.description
142168
'Creates backmerge PRs for a release branch into target branches'
143169
end
@@ -201,7 +227,9 @@ def self.available_options
201227
optional: true,
202228
type: Array),
203229
FastlaneCore::ConfigItem.new(key: :intermediate_branch_created_callback,
204-
description: 'Callback to allow for the caller to perform operations on the intermediate branch before pushing. The call back receives two parameters: the base (target) branch for the PR and the intermediate branch name',
230+
description: 'Callback to allow for the caller to perform operations on the intermediate branch (e.g. pushing new commits to pre-solve conflicts) before creating the PR. ' \
231+
+ 'The callback receives two parameters: the base (target) branch for the PR and the intermediate branch name that has been created.' \
232+
+ 'Note that if you use the callback to add new commits to the intermediate branch, you are responsible for git-pushing them too',
205233
optional: true,
206234
type: Proc),
207235
Fastlane::Helper::GithubHelper.github_token_config_item,

lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,19 @@ def self.commit(message:, files: nil)
113113

114114
# Get the SHA of a given git ref. Typically useful to get the SHA of the current HEAD commit.
115115
#
116-
# @param [String] ref The git ref (commit, branch name, 'HEAD', …) to resolve as a SHA
116+
# @param ref [String]
117+
# The git ref (commit, branch name, 'HEAD', …) to resolve as a SHA
118+
# @param prepend_origin_if_needed [Boolean]
119+
# If true, will retry the rev-parse by prefixing `origin/` to the ref it it failed without it
117120
# @return [String] The commit SHA of the ref
118121
#
119-
def self.get_commit_sha(ref: 'HEAD')
120-
Git.open(Dir.pwd).revparse(ref)
122+
def self.get_commit_sha(ref: 'HEAD', prepend_origin_if_needed: false)
123+
repo = Git.open(Dir.pwd)
124+
repo.revparse(ref)
125+
rescue Git::FailedError
126+
raise unless prepend_origin_if_needed
127+
128+
repo.revparse("origin/#{ref}")
121129
end
122130

123131
# Creates a tag for the given version, and optionally push it to the remote.
@@ -170,28 +178,36 @@ def self.fetch_all_tags
170178
Action.sh('git', 'fetch', '--tags')
171179
end
172180

181+
# Use `git merge-base` to find as good a common ancestors as possible for a merge
182+
#
183+
# @param ref1 [String] The first git reference (sha1, ref name…)to find the common ancestor of
184+
# @param ref2 [String] The second git reference (sha1, ref name…)to find the common ancestor of
185+
# @return [String] The merge-base aka common ancestor for the 2 commits provided
186+
# @note If a reference (e.g. branch name) can't be found locally, it will try with the same ref prefixed with `origin/`
187+
#
188+
def self.find_merge_base(ref1, ref2)
189+
git_repo = Git.open(Dir.pwd)
190+
# Resolve to shas, mostly so that we can support cases with and without `origin/` explicit prefix on branch names
191+
ref1_sha, ref2_sha = [ref1, ref2].map { |ref| get_commit_sha(ref: ref, prepend_origin_if_needed: true) }
192+
193+
git_repo.merge_base(ref1_sha, ref2_sha)&.first&.sha
194+
end
195+
173196
# Checks if two git references point to the same commit.
174197
#
175198
# @param ref1 [String] the first git reference to check.
176199
# @param ref2 [String] the second git reference to check.
177-
# @param remote_name [String] the name of the remote repository to use (default is 'origin').
178-
# If nil or empty, no remote prefix will be used.
179200
#
180201
# @return [Boolean] true if the two references point to the same commit, false otherwise.
181202
#
182-
def self.point_to_same_commit?(ref1, ref2, remote_name: 'origin')
183-
git_repo = Git.open(Dir.pwd)
184-
185-
ref1_full = remote_name.to_s.empty? ? ref1 : "#{remote_name}/#{ref1}"
186-
ref2_full = remote_name.to_s.empty? ? ref2 : "#{remote_name}/#{ref2}"
203+
def self.point_to_same_commit?(ref1, ref2)
187204
begin
188-
ref1_commit = git_repo.gcommit(ref1_full)
189-
ref2_commit = git_repo.gcommit(ref2_full)
205+
ref1_sha = get_commit_sha(ref: ref1, prepend_origin_if_needed: true)
206+
ref2_sha = get_commit_sha(ref: ref2, prepend_origin_if_needed: true)
190207
rescue StandardError => e
191-
UI.error "Error fetching commits for #{ref1_full} and #{ref2_full}: #{e.message}"
192-
return false
208+
UI.user_error! "Error fetching commits for #{ref1} and/or #{ref2}: #{e.message}"
193209
end
194-
ref1_commit.sha == ref2_commit.sha
210+
ref1_sha == ref2_sha
195211
end
196212

197213
# Returns the current git branch, or "HEAD" if it's not checked out to any branch

spec/create_release_backmerge_pull_request_spec.rb

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,25 @@ def stub_git_release_branches(branches)
3434
allow(git_client).to receive(:branches).and_return(
3535
branches.map { |b| instance_double(Git::Branch, remote: instance_double(Git::Remote, name: 'origin'), name: b) }
3636
)
37+
allow(git_client).to receive(:merge_base) do |ref1, ref2|
38+
['merge-base-of', ref1.gsub('/', '-'), 'and', ref2.gsub('/', '-')].join('-')
39+
end
3740
end
3841

39-
def stub_expected_pull_requests(expected_backmerge_branches:, source_branch:, labels: [], milestone_number: nil, reviewers: nil, team_reviewers: nil, branch_exists_on_remote: false, point_to_same_commit: false)
42+
def stub_expected_pull_requests(expected_backmerge_branches:, source_branch:, labels: [], milestone_number: nil, reviewers: nil, team_reviewers: nil, branch_exists_on_remote: false, nothing_to_merge_between: [])
4043
expected_backmerge_branches.map do |target_branch|
4144
expected_intermediate_branch = "merge/#{source_branch.gsub('/', '-')}-into-#{target_branch.gsub('/', '-')}"
4245

4346
allow(Fastlane::Helper::GitHelper).to receive(:branch_exists_on_remote?).with(branch_name: expected_intermediate_branch).and_return(branch_exists_on_remote)
4447

45-
allow(Fastlane::Helper::GitHelper).to receive(:point_to_same_commit?).with(target_branch, source_branch).and_return(point_to_same_commit)
48+
allow(described_class).to receive(:can_merge?).and_return(true)
49+
nothing_to_merge_between&.each do |head, base|
50+
allow(described_class).to receive(:can_merge?).with(head, into: base).and_return(false)
51+
end
52+
53+
allow(other_action_mock).to receive(:ensure_git_branch).with({ branch: "^#{expected_intermediate_branch}$" }).and_return(true)
4654

47-
next if point_to_same_commit
55+
next unless nothing_to_merge_between.nil? || nothing_to_merge_between.empty?
4856

4957
expect(Fastlane::Helper::GitHelper).to receive(:checkout_and_pull).with(source_branch)
5058
expect(Fastlane::Helper::GitHelper).to receive(:create_branch).with(expected_intermediate_branch)
@@ -284,27 +292,53 @@ def stub_expected_pull_requests(expected_backmerge_branches:, source_branch:, la
284292
end
285293
end
286294

287-
context 'when checking if source & target branches point to the same commit' do
288-
it 'does not create a pull request when `source_branch` a target branch point to the same commit' do
289-
stub_git_release_branches(%w[release/30.6])
295+
context 'when there is nothing to merge' do
296+
context 'when no callback is provided' do
297+
it 'detects it should not create a pull request before even creating the intermediate branch' do
298+
stub_git_release_branches(%w[release/30.8])
290299

291-
source_branch = 'release/30.7'
300+
source_branch = 'release/30.7'
292301

293-
expected_backmerge_branches = %w[trunk release/30.6]
294-
stub_expected_pull_requests(
295-
expected_backmerge_branches: expected_backmerge_branches,
296-
source_branch: source_branch,
297-
point_to_same_commit: true
298-
)
302+
stub_expected_pull_requests(
303+
expected_backmerge_branches: [default_branch],
304+
source_branch: source_branch,
305+
nothing_to_merge_between: { source_branch => default_branch }
306+
)
299307

300-
result = run_described_fastlane_action(
301-
github_token: test_token,
302-
repository: test_repo,
303-
source_branch: source_branch,
304-
target_branches: expected_backmerge_branches
305-
)
308+
result = run_described_fastlane_action(
309+
github_token: test_token,
310+
repository: test_repo,
311+
source_branch: source_branch,
312+
target_branches: [default_branch]
313+
)
314+
315+
expect(result).to be_empty
316+
end
317+
end
318+
319+
context 'when a callback is provided' do
320+
it 'checks the merge-ability with the intermediate branch after callback has been called' do
321+
stub_git_release_branches(%w[release/30.8])
322+
323+
source_branch = 'release/30.7'
324+
325+
stub_expected_pull_requests(
326+
expected_backmerge_branches: [default_branch],
327+
source_branch: source_branch,
328+
nothing_to_merge_between: { 'merge/release-30.7-into-main' => default_branch }
329+
)
330+
331+
inspectable_proc = instance_double(Proc, inspect: 'proc { }')
332+
result = run_described_fastlane_action(
333+
github_token: test_token,
334+
repository: test_repo,
335+
source_branch: source_branch,
336+
target_branches: [default_branch],
337+
intermediate_branch_created_callback: inspectable_proc
338+
)
306339

307-
expect(result).to be_empty
340+
expect(result).to be_empty
341+
end
308342
end
309343
end
310344

@@ -350,7 +384,7 @@ def stub_expected_pull_requests(expected_backmerge_branches:, source_branch:, la
350384
)
351385

352386
intermediate_branch = "merge/release-30.6-into-#{default_branch}"
353-
expect(other_action_mock).to receive(:ensure_git_branch).with(branch: "^#{intermediate_branch}/")
387+
expect(other_action_mock).to receive(:ensure_git_branch).with(branch: "^#{intermediate_branch}$")
354388
allow(Fastlane::UI).to receive(:message).with(anything)
355389
expect(Fastlane::UI).to receive(:message).with("branch created callback was called! #{default_branch} #{intermediate_branch}")
356390

spec/git_helper_spec.rb

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,6 @@
111111
end
112112

113113
describe 'point_to_same_commit?(ref1, ref2)' do
114-
# We cannot test the happy path using a remote because the repo we use for the tests does not have a remote.
115-
let(:remote_name) { nil }
116-
117114
before do
118115
# Spec branching setup:
119116
#
@@ -148,37 +145,37 @@
148145
end
149146

150147
it 'checks if a tag and a branch point to the same commit' do
151-
same_commit = described_class.point_to_same_commit?('1.0', 'another-branch', remote_name: remote_name)
148+
same_commit = described_class.point_to_same_commit?('1.0', 'another-branch')
152149
expect(same_commit).to be false
153150
end
154151

155152
it 'checks if a tag and a branch that had a merge point to the same commit' do
156-
same_commit = described_class.point_to_same_commit?('1.0', 'main', remote_name: remote_name)
153+
same_commit = described_class.point_to_same_commit?('1.0', 'main')
157154
expect(same_commit).to be false
158155
end
159156

160157
it 'checks if a tag and a commit hash point to the same commit' do
161-
same_commit = described_class.point_to_same_commit?('1.0', commit_hash(commit_message: 'commit D'), remote_name: remote_name)
158+
same_commit = described_class.point_to_same_commit?('1.0', commit_hash(commit_message: 'commit D'))
162159
expect(same_commit).to be false
163160
end
164161

165162
it 'checks if a commit hash and a branch point to the same commit' do
166-
same_commit = described_class.point_to_same_commit?(commit_hash(commit_message: 'commit B'), 'another-branch', remote_name: remote_name)
163+
same_commit = described_class.point_to_same_commit?(commit_hash(commit_message: 'commit B'), 'another-branch')
167164
expect(same_commit).to be false
168165
end
169166

170167
it 'checks if commits between the same branch point to the same commit' do
171-
same_commit = described_class.point_to_same_commit?('feature-branch', 'feature-branch', remote_name: remote_name)
168+
same_commit = described_class.point_to_same_commit?('feature-branch', 'feature-branch')
172169
expect(same_commit).to be true
173170
end
174171

175172
it 'checks if commits between branches that have no difference point to the same commit' do
176-
same_commit = described_class.point_to_same_commit?('another-branch', 'new-branch', remote_name: remote_name)
173+
same_commit = described_class.point_to_same_commit?('another-branch', 'new-branch')
177174
expect(same_commit).to be true
178175
end
179176

180177
it 'raises error for a non-existent base_ref' do
181-
expect { described_class.point_to_same_commit?('non-existent', 'main', remote_name: remote_name) }.to raise_error(StandardError)
178+
expect { described_class.point_to_same_commit?('non-existent', 'main') }.to raise_error(StandardError)
182179
end
183180
end
184181

0 commit comments

Comments
 (0)