Skip to content

Commit 71f9754

Browse files
author
David Szervanszky
committed
Optimistic batching for batch merging
Fix for issue #164. Previously we added trailers to each MR and merged the constituent MRs of a batch MR to the target branch. Now we will now optimistically add the trailers to each MR and rebase on the batch branch. And when the batch branch passes CI we merge it which will automatically merge each constituent branch.
1 parent 5de1f8c commit 71f9754

File tree

7 files changed

+57
-81
lines changed

7 files changed

+57
-81
lines changed

README.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -381,16 +381,21 @@ extremely slow CI).
381381
### How it works
382382
383383
If marge-bot finds multiple merge requests to deal with, she attempts to create
384-
a batch job. She filters the merge requests such that they have all have a
384+
a batch job. This is initially a batch branch based on the latest master.
385+
She then filters the merge requests such that they have all have a
385386
common target branch, and eliminates those that have not yet passed CI (a
386387
heuristic to help guarantee the batch will pass CI later).
387388
388-
Once the merge requests have been gathered, a batch branch is created using the
389-
commits from each merge request in sequence. Any merge request that cannot be
389+
Once the merge requests have been gathered, trailers are added to their branches one
390+
by one, these changes are pushed to their remotes, they are rebased on the batch branch
391+
and the rebased version is also pushed to the remote of the individual MR. This
392+
is to ensure that the commit SHAs of the changes in the individual MRs match
393+
those in the batch branch. This is very important later. Any merge request that cannot be
390394
merged to this branch (e.g. due to a rebase conflict) is filtered out. A new
391395
merge request is then created for this branch, and tested in CI.
392396
393-
If CI passes, the original merge requests will be merged one by one.
397+
If CI passes, the batch branch is merged which merges all individual constituent
398+
MRs (this is why the SHAs must match).
394399
395400
If the batch job fails for any reason, we fall back to merging the first merge
396401
request, before attempting a new batch job.
@@ -406,16 +411,10 @@ request, before attempting a new batch job.
406411
each source branch, because we know the final linearization of all commits
407412
passes in that all MRs passed individually on their branches.
408413
409-
* As trailers are added to the original merge requests only, their branches
410-
would need to be pushed to in order to reflect this change. This would trigger
411-
CI in each of the branches again that would have to be passed before merging,
412-
which effectively defeats the point of batching. To workaround this, the
413-
current implementation merges to the target branch through git, instead of the
414-
GitLab API. GitLab will detect the merge request as having been merged, and
415-
update the merge request status accordingly, regardless of whether it has
416-
passed CI. This does still mean the triggered CI jobs will be running even
417-
though the merge requests are merged. marge-bot will attempt to cancel these
418-
pipelines, although this doesn't work too effectively if external CI is used.
414+
* Because trailers are added during this process and pushed, this would trigger
415+
CI pipelines for each branch to be merged. We use the `ci.skip` option to prevent
416+
unnecessary CI runs which leaves skipped pipelines appearing on the Pipelines
417+
page for the repo. However this is just a visual issue.
419418
420419
* There is what can be considered to be a flaw in this implementation that could
421420
potentially result in a non-green master; consider the following situation:

marge/batch_job.py

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ def accept_mr(
112112
self,
113113
merge_request,
114114
expected_remote_target_branch_sha,
115-
source_repo_url=None,
116115
):
117116
log.info('Fusing MR !%s', merge_request.iid)
118117
approvals = merge_request.fetch_approvals()
@@ -122,21 +121,6 @@ def accept_mr(
122121
if new_target_sha != expected_remote_target_branch_sha:
123122
raise CannotBatch('Someone was naughty and by-passed marge')
124123

125-
# FIXME: we should only add tested-by for the last MR in the batch
126-
_, _, actual_sha = self.update_from_target_branch_and_push(
127-
merge_request,
128-
source_repo_url=source_repo_url,
129-
)
130-
131-
sha_now = Commit.last_on_branch(
132-
merge_request.source_project_id, merge_request.source_branch, self._api,
133-
).id
134-
# Make sure no-one managed to race and push to the branch in the
135-
# meantime, because we're about to impersonate the approvers, and
136-
# we don't want to approve unreviewed commits
137-
if sha_now != actual_sha:
138-
raise CannotMerge('Someone pushed to branch while we were trying to merge')
139-
140124
# As we're not using the API to merge the MR, we don't strictly need to reapprove it. However,
141125
# it's a little weird to look at the merged MR to find it has no approvals, so let's do it anyway.
142126
self.maybe_reapprove(merge_request, approvals)
@@ -198,6 +182,9 @@ def execute(self):
198182
merge_request.source_branch,
199183
'%s/%s' % (merge_request_remote, merge_request.source_branch),
200184
)
185+
# Apply the trailers before running the batch MR
186+
self.add_trailers(merge_request)
187+
self.push_force_to_mr(merge_request, True, source_repo_url, skip_ci=True)
201188

202189
# Update <source_branch> on latest <batch> branch so it contains previous MRs
203190
self.fuse(
@@ -207,6 +194,9 @@ def execute(self):
207194
local=True,
208195
)
209196

197+
# Ensure that individual branch commit SHA matches matches that of its equivalent in batch MR
198+
self.push_force_to_mr(merge_request, True, source_repo_url, skip_ci=True)
199+
210200
# Update <batch> branch with MR changes
211201
self._repo.fast_forward(
212202
BatchMergeJob.BATCH_BRANCH_NAME,
@@ -225,36 +215,34 @@ def execute(self):
225215
working_merge_requests.append(merge_request)
226216
if len(working_merge_requests) <= 1:
227217
raise CannotBatch('not enough ready merge requests')
228-
if self._project.only_allow_merge_if_pipeline_succeeds:
229-
# This switches git to <batch> branch
230-
self.push_batch()
231-
batch_mr = self.create_batch_mr(
232-
target_branch=target_branch,
233-
)
218+
# This switches git to <batch> branch
219+
self.push_batch()
220+
batch_mr = self.create_batch_mr(
221+
target_branch=target_branch,
222+
)
223+
for merge_request in working_merge_requests:
224+
merge_request.comment('I will attempt to batch this MR (!{})...'.format(batch_mr.iid))
225+
try:
226+
self.wait_for_ci_to_pass(batch_mr)
227+
except CannotMerge as err:
234228
for merge_request in working_merge_requests:
235-
merge_request.comment('I will attempt to batch this MR (!{})...'.format(batch_mr.iid))
236-
try:
237-
self.wait_for_ci_to_pass(batch_mr)
238-
except CannotMerge as err:
239-
for merge_request in working_merge_requests:
240-
merge_request.comment(
241-
'Batch MR !{batch_mr_iid} failed: {error} I will retry later...'.format(
242-
batch_mr_iid=batch_mr.iid,
243-
error=err.reason,
244-
),
245-
)
246-
raise CannotBatch(err.reason) from err
229+
merge_request.comment(
230+
'Batch MR !{batch_mr_iid} failed: {error} I will retry later...'.format(
231+
batch_mr_iid=batch_mr.iid,
232+
error=err.reason,
233+
),
234+
)
235+
raise CannotBatch(err.reason) from err
247236
for merge_request in working_merge_requests:
248237
try:
249238
# FIXME: this should probably be part of the merge request
250239
_, source_repo_url, merge_request_remote = self.fetch_source_project(merge_request)
251-
self.ensure_mr_not_changed(merge_request)
252240
self.ensure_mergeable_mr(merge_request)
253-
remote_target_branch_sha = self.accept_mr(
254-
merge_request,
255-
remote_target_branch_sha,
256-
source_repo_url=source_repo_url,
257-
)
241+
if merge_request == working_merge_requests[-1]:
242+
self.accept_mr(
243+
batch_mr,
244+
remote_target_branch_sha,
245+
)
258246
except CannotBatch as err:
259247
merge_request.comment(
260248
"I couldn't merge this branch: {error} I will retry later...".format(

marge/git.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def remove_branch(self, branch, *, new_current_branch='master'):
131131
def checkout_branch(self, branch, start_point=''):
132132
self.git('checkout', '-B', branch, start_point, '--')
133133

134-
def push(self, branch, *, source_repo_url=None, force=False):
134+
def push(self, branch, *, source_repo_url=None, force=False, skip_ci=False):
135135
self.git('checkout', branch, '--')
136136

137137
self.git('diff-index', '--quiet', 'HEAD') # check it is not dirty
@@ -146,7 +146,14 @@ def push(self, branch, *, source_repo_url=None, force=False):
146146
else:
147147
source = 'origin'
148148
force_flag = '--force' if force else ''
149-
self.git('push', force_flag, source, '%s:%s' % (branch, branch))
149+
# The following needs to be split into 2 variables as any whitespace in the string
150+
# causes it to be quoted which makes the string ignored by git
151+
if skip_ci:
152+
skip_1 = '-o'
153+
skip_2 = 'ci.skip'
154+
else:
155+
skip_1, skip_2 = '', ''
156+
self.git('push', force_flag, skip_1, skip_2, source, '%s:%s' % (branch, branch))
150157

151158
def get_commit_hash(self, rev='HEAD'):
152159
"""Return commit hash for `rev` (default "HEAD")."""

marge/job.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,12 +336,14 @@ def push_force_to_mr(
336336
merge_request,
337337
branch_was_modified,
338338
source_repo_url=None,
339+
skip_ci=False,
339340
):
340341
try:
341342
self._repo.push(
342343
merge_request.source_branch,
343344
source_repo_url=source_repo_url,
344345
force=True,
346+
skip_ci=skip_ci
345347
)
346348
except git.GitError:
347349
def fetch_remote_branch():

pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ max-line-length=110
3030
[DESIGN]
3131
max-args=10
3232
max-attributes=15
33+
max-locals=16
3334
max-public-methods=35
3435

3536
[REPORTS]

tests/git_repo_mock.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,11 @@ def merge(self, arg):
178178
self._local_repo.set_ref(self._branch, new_sha)
179179

180180
def push(self, *args):
181-
force_flag, remote_name, refspec = args
181+
force_flag, skip_1, skip_2, remote_name, refspec = args
182182

183183
assert force_flag in ('', '--force')
184+
assert skip_1 in ('', '-o')
185+
assert skip_2 in ('', 'ci.skip')
184186

185187
branch, remote_branch = refspec.split(':')
186188
remote_url = self._remotes[remote_name]

tests/test_batch_job.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
import marge.project
88
import marge.user
99
from marge.batch_job import BatchMergeJob, CannotBatch
10-
from marge.gitlab import GET
1110
from marge.job import CannotMerge, MergeJobOptions
1211
from marge.merge_request import MergeRequest
13-
from tests.gitlab_api_mock import MockLab, Ok, commit
12+
from tests.gitlab_api_mock import MockLab
1413

1514

1615
class TestBatchJob:
@@ -160,25 +159,3 @@ def test_fuse_mr_when_target_branch_was_moved(self, api, mocklab):
160159
with pytest.raises(CannotBatch) as exc_info:
161160
batch_merge_job.accept_mr(merge_request, 'abc')
162161
assert str(exc_info.value) == 'Someone was naughty and by-passed marge'
163-
164-
def test_fuse_mr_when_source_branch_was_moved(self, api, mocklab):
165-
batch_merge_job = self.get_batch_merge_job(api, mocklab)
166-
merge_request = self._mock_merge_request(
167-
source_project_id=mocklab.merge_request_info['source_project_id'],
168-
target_branch='master',
169-
source_branch=mocklab.merge_request_info['source_branch'],
170-
)
171-
172-
api.add_transition(
173-
GET(
174-
'/projects/{project_iid}/repository/branches/useless_new_feature'.format(
175-
project_iid=mocklab.merge_request_info['source_project_id'],
176-
),
177-
),
178-
Ok({'commit': commit(commit_id='abc', status='running')}),
179-
)
180-
181-
with pytest.raises(CannotMerge) as exc_info:
182-
batch_merge_job.accept_mr(merge_request, mocklab.initial_master_sha)
183-
184-
assert str(exc_info.value) == 'Someone pushed to branch while we were trying to merge'

0 commit comments

Comments
 (0)