fooishbar: Yeah, this looks good to me, but er, indeed it's missing an actual caller for this function.
snim2: Line 80 of `single_merge_job.py` calls the method...
fooishbar: Yes, of course; I have no idea how I didn't see that. I blame the heatwave here. Sorry.
fooishbar: I haven't seen any failures at all on freedesktop.org since we deployed this. So it looks good and it seems like it works too. Thanks a lot @snim2!
anpr: Could we get this merged 😬 ? We have the underlying problem, and it's pretty annoying.
diff --git a/.gitignore b/.gitignore
index 6b92bec..b7e49fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ ENV/
 # nix stuff
 result
 result-*
+
+# Editor
+*.sw[po]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5af981..8ea39d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+  * 0.9.4:
+    - Fix: handle `CannotMerge` which could be raised from `update_merge_request` #275
+    - Fix: maintain `batch_mr_sha` value when batch merging with fast forward commits #276
+  * 0.9.3:
+    - Feature: allow merge commits in batch MRs, to make the commits be exactly the same in
+      the sub MRs and the batch MR. Add `--use-merge-commit-batches` and `--skip-ci-batches` options #264
+    - Feature: add `--use-no-ff-batches` to disable fast forwarding of batch merges (#256, #259)
   * 0.9.2:
     - Fix: ensure parameters are correct when merging with/without pipelines enabled #251
     - Fix: only delete source branch if forced #193
diff --git a/marge/app.py b/marge/app.py
index e099b33..e6b66db 100644
--- a/marge/app.py
+++ b/marge/app.py
@@ -213,6 +213,17 @@ def _parse_config(args):
         action='store_true',
         help='Disable fast forwarding when merging MR batches'
     )
+    parser.add_argument(
+        '--use-merge-commit-batches',
+        action='store_true',
+        help='Use merge commit when creating batches, so that the commits in the batch MR '
+             'will be the same with in individual MRs. Requires sudo scope in the access token.\n',
+    )
+    parser.add_argument(
+        '--skip-ci-batches',
+        action='store_true',
+        help='Skip CI when updating individual MRs when using batches'
+    )
     config = parser.parse_args(args)
 
     if config.use_merge_strategy and config.batch:
@@ -312,6 +323,8 @@ def main(args=None):
                 ci_timeout=options.ci_timeout,
                 fusion=fusion,
                 use_no_ff_batches=options.use_no_ff_batches,
+                use_merge_commit_batches=options.use_merge_commit_batches,
+                skip_ci_batches=options.skip_ci_batches,
             ),
             batch=options.batch,
         )
diff --git a/marge/approvals.py b/marge/approvals.py
index 16206a9..bf23ffd 100644
--- a/marge/approvals.py
+++ b/marge/approvals.py
@@ -51,11 +51,15 @@ class Approvals(gitlab.Resource):
         (which may invalidate approvals, depending on GitLab settings) and then
         restore the approval status.
         """
+        self.approve(self)
+
+    def approve(self, obj):
+        """Approve an object which can be a merge_request or an approval."""
         if self._api.version().release >= (9, 2, 2):
-            approve_url = '/projects/{0.project_id}/merge_requests/{0.iid}/approve'.format(self)
+            approve_url = '/projects/{0.project_id}/merge_requests/{0.iid}/approve'.format(obj)
         else:
             # GitLab botched the v4 api before 9.2.3
-            approve_url = '/projects/{0.project_id}/merge_requests/{0.id}/approve'.format(self)
+            approve_url = '/projects/{0.project_id}/merge_requests/{0.id}/approve'.format(obj)
 
         for uid in self.approver_ids:
             self._api.call(POST(approve_url), sudo=uid)
diff --git a/marge/batch_job.py b/marge/batch_job.py
index 2f4cae8..a4b3479 100644
--- a/marge/batch_job.py
+++ b/marge/batch_job.py
@@ -1,8 +1,9 @@
-# pylint: disable=too-many-branches,too-many-statements
+# pylint: disable=too-many-branches,too-many-statements,arguments-differ
 import logging as log
 from time import sleep
 
 from . import git
+from . import gitlab
 from .commit import Commit
 from .job import MergeJob, CannotMerge, SkipMerge
 from .merge_request import MergeRequest
@@ -46,6 +47,7 @@ class BatchMergeJob(MergeJob):
             batch_mr.close()
 
     def create_batch_mr(self, target_branch):
+        self.push_batch()
         log.info('Creating batch MR')
         params = {
             'source_branch': BatchMergeJob.BATCH_BRANCH_NAME,
@@ -68,10 +70,10 @@ class BatchMergeJob(MergeJob):
             if merge_request.target_branch == target_branch
         ]
 
-    def ensure_mergeable_mr(self, merge_request):
+    def ensure_mergeable_mr(self, merge_request, skip_ci=False):
         super().ensure_mergeable_mr(merge_request)
 
-        if self._project.only_allow_merge_if_pipeline_succeeds:
+        if self._project.only_allow_merge_if_pipeline_succeeds and not skip_ci:
             ci_status = self.get_mr_ci_status(merge_request)
             if ci_status != 'success':
                 raise CannotBatch('This MR has not passed CI.')
@@ -121,38 +123,54 @@ class BatchMergeJob(MergeJob):
             source_branch,
         )
 
-    def accept_mr(
+    def update_merge_request(
         self,
         merge_request,
-        expected_remote_target_branch_sha,
         source_repo_url=None,
     ):
         log.info('Fusing MR !%s', merge_request.iid)
         approvals = merge_request.fetch_approvals()
 
-        # Make sure latest commit in remote <target_branch> is the one we tested against
-        new_target_sha = Commit.last_on_branch(self._project.id, merge_request.target_branch, self._api).id
-        if new_target_sha != expected_remote_target_branch_sha:
-            raise CannotBatch('Someone was naughty and by-passed marge')
-
-        # FIXME: we should only add tested-by for the last MR in the batch
         _, _, actual_sha = self.update_from_target_branch_and_push(
             merge_request,
             source_repo_url=source_repo_url,
+            skip_ci=self._options.skip_ci_batches,
         )
 
         sha_now = Commit.last_on_branch(
             merge_request.source_project_id, merge_request.source_branch, self._api,
         ).id
+        log.info('update_merge_request: sha_now (%s), actual_sha (%s)', sha_now, actual_sha)
         # Make sure no-one managed to race and push to the branch in the
         # meantime, because we're about to impersonate the approvers, and
         # we don't want to approve unreviewed commits
         if sha_now != actual_sha:
             raise CannotMerge('Someone pushed to branch while we were trying to merge')
 
-        # As we're not using the API to merge the MR, we don't strictly need to reapprove it. However,
-        # it's a little weird to look at the merged MR to find it has no approvals, so let's do it anyway.
+        # As we're not using the API to merge the individual MR, we don't strictly need to reapprove it.
+        # However, it's a little weird to look at the merged MR to find it has no approvals,
+        # so let's do it anyway.
         self.maybe_reapprove(merge_request, approvals)
+        return sha_now
+
+    def accept_mr(
+        self,
+        merge_request,
+        expected_remote_target_branch_sha,
+        source_repo_url=None,
+    ):
+        log.info('Accept MR !%s', merge_request.iid)
+
+        # Make sure latest commit in remote <target_branch> is the one we tested against
+        new_target_sha = Commit.last_on_branch(self._project.id, merge_request.target_branch, self._api).id
+        if new_target_sha != expected_remote_target_branch_sha:
+            raise CannotBatch('Someone was naughty and by-passed marge')
+
+        # Rebase and apply the trailers
+        self.update_merge_request(
+            merge_request,
+            source_repo_url=source_repo_url,
+        )
 
         # This switches git to <target_branch>
         final_sha = self.merge_batch(
@@ -160,7 +178,6 @@ class BatchMergeJob(MergeJob):
             merge_request.source_branch,
             self._options.use_no_ff_batches,
         )
-
         # Don't force push in case the remote has changed.
         self._repo.push(merge_request.target_branch, force=False)
 
@@ -203,6 +220,11 @@ class BatchMergeJob(MergeJob):
         self._repo.checkout_branch(target_branch, 'origin/%s' % target_branch)
         self._repo.checkout_branch(BatchMergeJob.BATCH_BRANCH_NAME, 'origin/%s' % target_branch)
 
+        batch_mr = self.create_batch_mr(
+            target_branch=target_branch,
+        )
+        batch_mr_sha = batch_mr.sha
+
         working_merge_requests = []
 
         for merge_request in merge_requests:
@@ -213,42 +235,65 @@ class BatchMergeJob(MergeJob):
                     '%s/%s' % (merge_request_remote, merge_request.source_branch),
                 )
 
-                # Update <source_branch> on latest <batch> branch so it contains previous MRs
-                self.fuse(
-                    merge_request.source_branch,
-                    BatchMergeJob.BATCH_BRANCH_NAME,
-                    source_repo_url=source_repo_url,
-                    local=True,
-                )
-
-                # Update <batch> branch with MR changes
-                self._repo.fast_forward(
-                    BatchMergeJob.BATCH_BRANCH_NAME,
-                    merge_request.source_branch,
-                    local=True,
-                )
+                if self._options.use_merge_commit_batches:
+                    # Rebase and apply the trailers before running the batch MR
+                    actual_sha = self.update_merge_request(
+                        merge_request,
+                        source_repo_url=source_repo_url,
+                    )
+                    # Update <batch> branch with MR changes
+                    batch_mr_sha = self._repo.merge(
+                        BatchMergeJob.BATCH_BRANCH_NAME,
+                        merge_request.source_branch,
+                        '-m',
+                        'Batch merge !%s into %s (!%s)' % (
+                            merge_request.iid,
+                            merge_request.target_branch,
+                            batch_mr.iid
+                        ),
+                        local=True,
+                    )
+                else:
+                    # Update <source_branch> on latest <batch> branch so it contains previous MRs
+                    self.fuse(
+                        merge_request.source_branch,
+                        BatchMergeJob.BATCH_BRANCH_NAME,
+                        source_repo_url=source_repo_url,
+                        local=True,
+                    )
+                    # Update <batch> branch with MR changes
+                    batch_mr_sha = self._repo.fast_forward(
+                        BatchMergeJob.BATCH_BRANCH_NAME,
+                        merge_request.source_branch,
+                        local=True,
+                    )
 
                 # We don't need <source_branch> anymore. Remove it now in case another
                 # merge request is using the same branch name in a different project.
-                # FIXME: is this actually needed?
                 self._repo.remove_branch(merge_request.source_branch)
-            except git.GitError:
+            except (git.GitError, CannotMerge):
                 log.warning('Skipping MR !%s, got conflicts while rebasing', merge_request.iid)
                 continue
             else:
+                if self._options.use_merge_commit_batches:
+                    # update merge_request with the current sha, we will compare it with
+                    # the actual sha later to make sure no one pushed this MR meanwhile
+                    merge_request.update_sha(actual_sha)
+
                 working_merge_requests.append(merge_request)
+
         if len(working_merge_requests) <= 1:
             raise CannotBatch('not enough ready merge requests')
+
+        # This switches git to <batch> branch
+        self.push_batch()
+        for merge_request in working_merge_requests:
+            merge_request.comment('I will attempt to batch this MR (!{})...'.format(batch_mr.iid))
+
+        # wait for the CI of the batch MR
         if self._project.only_allow_merge_if_pipeline_succeeds:
-            # This switches git to <batch> branch
-            self.push_batch()
-            batch_mr = self.create_batch_mr(
-                target_branch=target_branch,
-            )
-            for merge_request in working_merge_requests:
-                merge_request.comment('I will attempt to batch this MR (!{})...'.format(batch_mr.iid))
             try:
-                self.wait_for_ci_to_pass(batch_mr)
+                self.wait_for_ci_to_pass(batch_mr, commit_sha=batch_mr_sha)
             except CannotMerge as err:
                 for merge_request in working_merge_requests:
                     merge_request.comment(
@@ -258,17 +303,23 @@ class BatchMergeJob(MergeJob):
                         ),
                     )
                 raise CannotBatch(err.reason) from err
+
+        # check each sub MR, and accept each sub MR if using the normal batch
         for merge_request in working_merge_requests:
             try:
                 # FIXME: this should probably be part of the merge request
                 _, source_repo_url, merge_request_remote = self.fetch_source_project(merge_request)
                 self.ensure_mr_not_changed(merge_request)
-                self.ensure_mergeable_mr(merge_request)
-                remote_target_branch_sha = self.accept_mr(
-                    merge_request,
-                    remote_target_branch_sha,
-                    source_repo_url=source_repo_url,
-                )
+                # we know the batch MR's CI passed, so we skip CI for sub MRs this time
+                self.ensure_mergeable_mr(merge_request, skip_ci=True)
+
+                if not self._options.use_merge_commit_batches:
+                    # accept each MRs
+                    remote_target_branch_sha = self.accept_mr(
+                        merge_request,
+                        remote_target_branch_sha,
+                        source_repo_url=source_repo_url,
+                    )
             except CannotBatch as err:
                 merge_request.comment(
                     "I couldn't merge this branch: {error} I will retry later...".format(
@@ -283,3 +334,24 @@ class BatchMergeJob(MergeJob):
                 self.unassign_from_mr(merge_request)
                 merge_request.comment("I couldn't merge this branch: %s" % err.reason)
                 raise
+
+        # Accept the batch MR
+        if self._options.use_merge_commit_batches:
+            # Approve the batch MR using the last sub MR's approvers
+            if not batch_mr.fetch_approvals().sufficient:
+                approvals = working_merge_requests[-1].fetch_approvals()
+                try:
+                    approvals.approve(batch_mr)
+                except (gitlab.Forbidden, gitlab.Unauthorized):
+                    log.exception('Failed to approve MR:')
+
+            try:
+                ret = batch_mr.accept(
+                    remove_branch=batch_mr.force_remove_source_branch,
+                    sha=batch_mr_sha,
+                    merge_when_pipeline_succeeds=bool(self._project.only_allow_merge_if_pipeline_succeeds),
+                )
+                log.info('batch_mr.accept result: %s', ret)
+            except gitlab.ApiError as err:
+                log.exception('Gitlab API Error:')
+                raise CannotMerge('Gitlab API Error: %s' % err)
diff --git a/marge/git.py b/marge/git.py
index 3b424b5..8ad9528 100644
--- a/marge/git.py
+++ b/marge/git.py
@@ -129,9 +129,10 @@ class Repo(namedtuple('Repo', 'remote_url local_path ssh_key_file timeout refere
         self.git('branch', '-D', branch)
 
     def checkout_branch(self, branch, start_point=''):
-        self.git('checkout', '-B', branch, start_point, '--')
+        create_and_reset = '-B' if start_point else ''
+        self.git('checkout', create_and_reset, branch, start_point, '--')
 
-    def push(self, branch, *, source_repo_url=None, force=False):
+    def push(self, branch, *, source_repo_url=None, force=False, skip_ci=False):
         self.git('checkout', branch, '--')
 
         self.git('diff-index', '--quiet', 'HEAD')  # check it is not dirty
@@ -146,7 +147,8 @@ class Repo(namedtuple('Repo', 'remote_url local_path ssh_key_file timeout refere
         else:
             source = 'origin'
         force_flag = '--force' if force else ''
-        self.git('push', force_flag, source, '%s:%s' % (branch, branch))
+        skip_flag = ('-o', 'ci.skip') if skip_ci else ()
+        self.git('push', force_flag, *skip_flag, source, '%s:%s' % (branch, branch))
 
     def get_commit_hash(self, rev='HEAD'):
         """Return commit hash for `rev` (default "HEAD")."""
diff --git a/marge/job.py b/marge/job.py
index b2a22b8..7d262c8 100644
--- a/marge/job.py
+++ b/marge/job.py
@@ -190,6 +190,28 @@ class MergeJob:
 
         raise CannotMerge('CI is taking too long.')
 
+    def wait_for_merge_status_to_resolve(self, merge_request):
+        attempts = 3
+        waiting_time_in_secs = 5
+
+        log.info('Waiting for MR !%s to have merge_status can_be_merged', merge_request.iid)
+        for attempt in range(attempts):
+            merge_request.refetch_info()
+            merge_status = merge_request.merge_status
+
+            if merge_status == 'can_be_merged':
+                log.info('MR !%s can be merged on attempt %d', merge_request.iid, attempt)
+                return
+
+            if merge_status == 'cannot_be_merged':
+                log.info('MR !%s cannot be merged on attempt %d', merge_request.iid, attempt)
+                raise CannotMerge('GitLab believes this MR cannot be merged.')
+
+            if merge_status == 'unchecked':
+                log.info('MR !%s merge status currently unchecked on attempt %d.', merge_request.iid, attempt)
+
+            time.sleep(waiting_time_in_secs)
+
     def unassign_from_mr(self, merge_request):
         log.info('Unassigning from MR !%s', merge_request.iid)
         author_id = merge_request.author_id
@@ -265,10 +287,11 @@ class MergeJob:
     def update_from_target_branch_and_push(
             self,
             merge_request,
-            *,
             source_repo_url=None,
+            skip_ci=False,
+            add_trailers=True,
     ):
-        """Updates `target_branch` with commits from `source_branch`, optionally add trailers and push.
+        """Updates `source_branch` on `target_branch`, optionally add trailers and push.
         The update strategy can either be rebase or merge. The default is rebase.
 
         Returns
@@ -296,19 +319,17 @@ class MergeJob:
             target_sha = repo.get_commit_hash('origin/' + target_branch)
             if updated_sha == target_sha:
                 raise CannotMerge('these changes already exist in branch `{}`'.format(target_branch))
-            final_sha = self.add_trailers(merge_request) or updated_sha
+            final_sha = self.add_trailers(merge_request) if add_trailers else None
+            final_sha = final_sha or updated_sha
             commits_rewrite_done = True
             branch_was_modified = final_sha != initial_mr_sha
-            self.synchronize_mr_with_local_changes(merge_request, branch_was_modified, source_repo_url)
+            self.synchronize_mr_with_local_changes(
+                merge_request,
+                branch_was_modified,
+                source_repo_url,
+                skip_ci=skip_ci,
+            )
         except git.GitError:
-            if not branch_update_done:
-                raise CannotMerge('got conflicts while rebasing, your problem now...')
-            if not commits_rewrite_done:
-                raise CannotMerge('failed on filter-branch; check my logs!')
-            raise
-        else:
-            return target_sha, updated_sha, final_sha
-        finally:
             # A failure to clean up probably means something is fucked with the git repo
             # and likely explains any previous failure, so it will better to just
             # raise a GitError
@@ -316,11 +337,19 @@ class MergeJob:
                 repo.checkout_branch('master')
                 repo.remove_branch(source_branch)
 
+            if not branch_update_done:
+                raise CannotMerge('got conflicts while rebasing, your problem now...')
+            if not commits_rewrite_done:
+                raise CannotMerge('failed on filter-branch; check my logs!')
+            raise
+        return target_sha, updated_sha, final_sha
+
     def synchronize_mr_with_local_changes(
         self,
         merge_request,
         branch_was_modified,
         source_repo_url=None,
+        skip_ci=False,
     ):
         if self._options.fusion is Fusion.gitlab_rebase:
             self.synchronize_using_gitlab_rebase(merge_request)
@@ -329,6 +358,7 @@ class MergeJob:
                 merge_request,
                 branch_was_modified,
                 source_repo_url=source_repo_url,
+                skip_ci=skip_ci,
             )
 
     def push_force_to_mr(
@@ -336,12 +366,14 @@ class MergeJob:
         merge_request,
         branch_was_modified,
         source_repo_url=None,
+        skip_ci=False,
     ):
         try:
             self._repo.push(
                 merge_request.source_branch,
                 source_repo_url=source_repo_url,
                 force=True,
+                skip_ci=skip_ci,
             )
         except git.GitError:
             def fetch_remote_branch():
@@ -409,6 +441,8 @@ JOB_OPTIONS = [
     'ci_timeout',
     'fusion',
     'use_no_ff_batches',
+    'use_merge_commit_batches',
+    'skip_ci_batches',
 ]
 
 
@@ -424,7 +458,7 @@ class MergeJobOptions(namedtuple('MergeJobOptions', JOB_OPTIONS)):
             cls, *,
             add_tested=False, add_part_of=False, add_reviewers=False, reapprove=False,
             approval_timeout=None, embargo=None, ci_timeout=None, fusion=Fusion.rebase,
-            use_no_ff_batches=False,
+            use_no_ff_batches=False, use_merge_commit_batches=False, skip_ci_batches=False,
     ):
         approval_timeout = approval_timeout or timedelta(seconds=0)
         embargo = embargo or IntervalUnion.empty()
@@ -439,6 +473,8 @@ class MergeJobOptions(namedtuple('MergeJobOptions', JOB_OPTIONS)):
             ci_timeout=ci_timeout,
             fusion=fusion,
             use_no_ff_batches=use_no_ff_batches,
+            use_merge_commit_batches=use_merge_commit_batches,
+            skip_ci_batches=skip_ci_batches,
         )
 
 
diff --git a/marge/merge_request.py b/marge/merge_request.py
index bce1bbf..1e1d458 100644
--- a/marge/merge_request.py
+++ b/marge/merge_request.py
@@ -63,6 +63,10 @@ class MergeRequest(gitlab.Resource):
     def state(self):
         return self.info['state']
 
+    @property
+    def merge_status(self):
+        return self.info['merge_status']
+
     @property
     def rebase_in_progress(self):
         return self.info.get('rebase_in_progress', False)
@@ -121,6 +125,10 @@ class MergeRequest(gitlab.Resource):
     def force_remove_source_branch(self):
         return self.info['force_remove_source_branch']
 
+    def update_sha(self, sha):
+        """record the updated sha. We don't use refetch_info instead as it may hit cache."""
+        self._info['sha'] = sha
+
     def refetch_info(self):
         self._info = self._api.call(GET('/projects/{0.project_id}/merge_requests/{0.iid}'.format(self)))
 
diff --git a/marge/single_merge_job.py b/marge/single_merge_job.py
index f4ed83e..c929f6e 100644
--- a/marge/single_merge_job.py
+++ b/marge/single_merge_job.py
@@ -77,14 +77,17 @@ class SingleMergeJob(MergeJob):
                 self.wait_for_ci_to_pass(merge_request, actual_sha)
                 time.sleep(2)
 
+            self.wait_for_merge_status_to_resolve(merge_request)
+
             self.ensure_mergeable_mr(merge_request)
 
             try:
-                merge_request.accept(
+                ret = merge_request.accept(
                     remove_branch=merge_request.force_remove_source_branch,
                     sha=actual_sha,
                     merge_when_pipeline_succeeds=bool(target_project.only_allow_merge_if_pipeline_succeeds),
                 )
+                log.info('merge_request.accept result: %s', ret)
             except gitlab.NotAcceptable as err:
                 new_target_sha = Commit.last_on_branch(self._project.id, merge_request.target_branch, api).id
                 # target_branch has moved under us since we updated, just try again
diff --git a/pylintrc b/pylintrc
index de05e3d..bc5347e 100644
--- a/pylintrc
+++ b/pylintrc
@@ -31,6 +31,8 @@ max-line-length=110
 max-args=10
 max-attributes=15
 max-public-methods=35
+# Maximum number of locals for function / method body
+max-locals=25
 
 [REPORTS]
 output-format=parseable
diff --git a/version b/version
index 2003b63..a602fc9 100644
--- a/version
+++ b/version
@@ -1,1 +1,1 @@
-0.9.2
+0.9.4

"branch cannot be merged" on freedesktop.org
Since a gitlab update, we've been seeing "branch cannot be merged" intermittently from marge.  One of our gitlab admins looked into it and concluded:

> Yeah, as suspected the culprit is Marge not doing the right thing:
> 
>> Note: Starting in GitLab 12.8, the mergeability (merge_status) of a merge request will be checked asynchronously when a request is made to this endpoint. Poll this API endpoint to get updated status. This affects the has_conflicts property as it is dependent on the merge_status. It’ll return false unless merge_status is cannot_be_merged.
> 
> Completing a pipeline will cause a MR to become mergeable, but not instantaneously. Marge assumes that the instant a pipeline reports complete, the MR will be mergeable. That's not actually true, since it still has to check for things like whether or not the head is a fast-forward, etc. This is done in a background job via Sidekiq.
The fix seems pretty straightforward: in Marge's job handling, after pipeline completion but before merging, it needs to poll on merge_status and watch it go green. I looked at it briefly this morning, but it involved NixOS and that pushed it over the time budget I have right now. If someone wants to pick up updating Marge then I'm happy to get it deployed.

diff --git a/tests/gitlab_api_mock.py b/tests/gitlab_api_mock.py
index d9577aa..bbc19ea 100644
--- a/tests/gitlab_api_mock.py
+++ b/tests/gitlab_api_mock.py
@@ -50,6 +50,7 @@ class MockLab:  # pylint: disable=too-few-public-methods
             'assignees': [{'id': self.user_id}],
             'approved_by': [],
             'state': 'opened',
+            'merge_status': 'can_be_merged',
             'sha': self.commit_info['id'],
             'source_project_id': 1234,
             'target_project_id': 1234,
diff --git a/tests/test_job.py b/tests/test_job.py
index 6871a80..77f5961 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -201,6 +201,8 @@ class TestMergeJobOptions:
             ci_timeout=timedelta(minutes=15),
             fusion=Fusion.rebase,
             use_no_ff_batches=False,
+            use_merge_commit_batches=False,
+            skip_ci_batches=False,
         )
 
     def test_default_ci_time(self):

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-flake8 pytest-pylint pytest-runner
apt-get update apt-get install -y gcc
3.6
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
astroid==2.11.7
attrs==22.2.0
backports.zoneinfo==0.2.1
certifi==2021.5.30
charset-normalizer==2.0.12
ConfigArgParse==1.7
coverage==6.2
dateparser==1.1.3
dill==0.3.4
flake8==5.0.4
humanize==3.14.0
idna==3.10
importlib-metadata==4.2.0
importlib-resources==5.4.0
iniconfig==1.1.1
isort==5.10.1
lazy-object-proxy==1.7.1
-e git+https://github.com/smarkets/marge-bot.git@ca6453164ba93d2e6e0cbef3445bbf18264219ee#egg=marge
maya==0.6.1
mccabe==0.7.0
packaging==21.3
pendulum==2.1.2
platformdirs==2.4.0
pluggy==1.0.0
py==1.11.0
pycodestyle==2.9.1
pyflakes==2.5.0
pylint==2.13.9
pyparsing==3.1.4
pytest==7.0.1
pytest-cov==4.0.0
pytest-flake8==1.1.1
pytest-pylint==0.18.0
pytest-runner==5.3.2
python-dateutil==2.9.0.post0
pytz==2025.2
pytz-deprecation-shim==0.1.0.post0
pytzdata==2020.1
PyYAML==6.0.1
regex==2022.3.2
requests==2.27.1
six==1.17.0
snaptime==0.2.4
toml==0.10.2
tomli==1.2.3
typed-ast==1.5.5
typing_extensions==4.1.1
tzdata==2025.2
tzlocal==4.2
urllib3==1.26.20
wrapt==1.16.0
zipp==3.6.0

name: marge-bot
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - astroid==2.11.7
      - attrs==22.2.0
      - backports-zoneinfo==0.2.1
      - charset-normalizer==2.0.12
      - configargparse==1.7
      - coverage==6.2
      - dateparser==1.1.3
      - dill==0.3.4
      - flake8==5.0.4
      - humanize==3.14.0
      - idna==3.10
      - importlib-metadata==4.2.0
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - isort==5.10.1
      - lazy-object-proxy==1.7.1
      - maya==0.6.1
      - mccabe==0.7.0
      - packaging==21.3
      - pendulum==2.1.2
      - platformdirs==2.4.0
      - pluggy==1.0.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
      - pylint==2.13.9
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - pytest-flake8==1.1.1
      - pytest-pylint==0.18.0
      - pytest-runner==5.3.2
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pytz-deprecation-shim==0.1.0.post0
      - pytzdata==2020.1
      - pyyaml==6.0.1
      - regex==2022.3.2
      - requests==2.27.1
      - six==1.17.0
      - snaptime==0.2.4
      - toml==0.10.2
      - tomli==1.2.3
      - typed-ast==1.5.5
      - typing-extensions==4.1.1
      - tzdata==2025.2
      - tzlocal==4.2
      - urllib3==1.26.20
      - wrapt==1.16.0
      - zipp==3.6.0
prefix: /opt/conda/envs/marge-bot

tests/test_job.py::TestMergeJobOptions::test_default
tests/gitlab_api_mock.py::PYLINT tests/gitlab_api_mock.py::flake-8::FLAKE8 tests/test_job.py::flake-8::FLAKE8
tests/test_job.py::PYLINT tests/test_job.py::TestJob::test_get_source_project_when_is_target_project tests/test_job.py::TestJob::test_get_source_project_when_is_fork tests/test_job.py::TestJob::test_get_mr_ci_status[9.4.0-ee-False] tests/test_job.py::TestJob::test_get_mr_ci_status[10.5.0-ee-True] tests/test_job.py::TestJob::test_ensure_mergeable_mr_not_assigned tests/test_job.py::TestJob::test_ensure_mergeable_mr_state_not_ok tests/test_job.py::TestJob::test_ensure_mergeable_mr_not_approved tests/test_job.py::TestJob::test_ensure_mergeable_mr_wip tests/test_job.py::TestJob::test_ensure_mergeable_mr_squash_and_trailers tests/test_job.py::TestJob::test_unassign_from_mr tests/test_job.py::TestJob::test_fuse_using_rebase tests/test_job.py::TestJob::test_fuse_using_merge tests/test_job.py::TestMergeJobOptions::test_default_ci_time
version marge/approvals.py marge/merge_request.py marge/single_merge_job.py CHANGELOG.md marge/job.py .gitignore marge/app.py marge/batch_job.py marge/git.py pylintrc
version marge/approvals.py marge/merge_request.py marge/single_merge_job.py CHANGELOG.md marge/job.py .gitignore marge/app.py marge/batch_job.py marge/git.py pylintrc