copybot: Add support for uploading conflicts

Add support for uploading conflicted CLs through the merge behavior
ALLOW_CONFLICT.  Should conflicts be committed, the script will exit
with an error to notify the downstreamers that they need to go review
the conflicted CL.

BUG=None
TEST=./copybot.py --topic android-downstream --label Verified+1 --re nvaccaro@google.com --re subratabanik@google.com --merge-conflict-behavior ALLOW_CONFLICT --keep-pseudoheader Cq-Depend --keep-pseudoheader Change-Id --push-option 'uploadvalidator~skip' --push-option nokeycheck --ht '' --ht '' --upstream-history-limit 5 --downstream-history-limit 1000 --add-pseudoheader 'Cr-Build-Id: 8740269368728257329' --add-pseudoheader 'Cr-Build-Url: https://cr-buildbucket.appspot.com/build/8740269368728257329' --add-pseudoheader 'Copybot-Job-Name: android-brya-vboot_reference-copybot-downstream' --dry-run https://chromium.googlesource.com/chromiumos/platform/vboot_reference:firmware-android-15949.B https://chromium.googlesource.com/chromiumos/platform/vboot_reference:firmware-android-brya-14505.782.B:

Change-Id: I1e4b7f18c61f478cfdbd613bb73ef40254aaa79a
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/5771074
Commit-Queue: Jonathon Murphy <jpmurphy@google.com>
Reviewed-by: Jack Rosenthal <jrosenth@chromium.org>
Tested-by: Jonathon Murphy <jpmurphy@google.com>
Auto-Submit: Jonathon Murphy <jpmurphy@google.com>
diff --git a/contrib/copybot/copybot.py b/contrib/copybot/copybot.py
index 765f147..d9df946 100755
--- a/contrib/copybot/copybot.py
+++ b/contrib/copybot/copybot.py
@@ -68,11 +68,17 @@
     SKIP: Skip the commit that failed to merge. Summarize the failed
         commits at the end of the execution, and exit failure status.
     STOP: Stop immediately. Upload staged changes prior to conflict.
+    ALLOW_CONFLICT: Commit the conflicted CL with conflicts.  Summarize
+        the conflicted CLs at the end of execution, and exit failure
+        status.  Conflicted CLs WILL be uploaded to the downstream.
+        "Commit: false" will be added to the commit message to prevent
+        GoB from committing conflicted changes before they are edited.
     """
 
     FAIL = enum.auto()
     SKIP = enum.auto()
     STOP = enum.auto()
+    ALLOW_CONFLICT = enum.auto()
 
 
 class MergeConflictError(Exception):
@@ -330,6 +336,7 @@
         downstream_subtree=None,
         include_paths=None,
         exclude_paths=None,
+        allow_conflict=False,
     ):
         """Do a `git cherry-pick`.
 
@@ -355,10 +362,19 @@
                     if "is a merge but no -m option was given" in e.stderr:
                         logger.warning("Merge commit detected")
                     raise MergeConflictError() from err
-                self._run_git("cherry-pick", "--abort")
-                if "The previous cherry-pick is now empty" in e.stderr:
-                    raise EmptyCommitError() from e
-                raise MergeConflictError() from e
+                if allow_conflict:
+                    self.add(downstream_subtree, stage=True, force=True)
+                    self.commit(
+                        self.get_commit_message(rev),
+                        amend=False,
+                        sign_off=False,
+                        stage=True,
+                    )
+                else:
+                    self._run_git("cherry-pick", "--abort")
+                    if "The previous cherry-pick is now empty" in e.stderr:
+                        raise EmptyCommitError() from e
+                    raise MergeConflictError() from e
 
         cherry_pick_flag_list = ([], ["-Xpatience"], ["-m", "2"])
         if downstream_subtree or upstream_subtree or include_paths:
@@ -384,7 +400,8 @@
                 exclude_paths=exclude_paths,
             )
         except subprocess.CalledProcessError as e:
-            raise MergeConflictError() from e
+            if not allow_conflict:
+                raise MergeConflictError() from e
         self.add(downstream_subtree, stage=True, force=True)
         self.commit(
             self.get_commit_message(rev),
@@ -1164,8 +1181,10 @@
     if not commits_to_copy:
         logger.info("Nothing to do!")
         return
-    skipped_revs = []
+
+    conflicted_revs = []
     empty_revs = []
+    skipped_revs = []
 
     if args.limit > 0 and len(commits_to_copy) > args.limit:
         logger.warning(
@@ -1218,6 +1237,42 @@
                 logger.warning("Stopping at revision %s", rev)
                 skipped_revs.extend(list(reversed(commits_to_copy))[i:])
                 break
+            elif (
+                merge_conflict_behavior is MergeConflictBehavior.ALLOW_CONFLICT
+            ):
+                logger.warning("Committing %s with conflicts", rev)
+                if pending_change:
+                    repo.fetch(downstream_url, pending_changes[rev].current_ref)
+                    repo.cherry_pick(rev="FETCH_HEAD", allow_conflict=True)
+                else:
+                    repo.cherry_pick(
+                        rev,
+                        patch_dir=patch_dir,
+                        upstream_subtree=upstream_subtree,
+                        downstream_subtree=downstream_subtree,
+                        include_paths=args.include_downstream,
+                        exclude_paths=args.exclude_file_patterns,
+                        allow_conflict=True,
+                    )
+                if not pending_change or reword_pending_change:
+                    change_id = None
+                    pending_overwrite = pending_changes.get(rev)
+                    if pending_overwrite is not None:
+                        change_id = pending_changes.get(rev).change_id
+                    rewrite_commit_message(
+                        repo,
+                        upstream_rev=rev,
+                        change_id=change_id or generate_change_id(),
+                        prepend_subject=args.prepend_subject,
+                        sign_off=args.add_signed_off_by,
+                        keep_pseudoheaders=keep_pseudoheaders,
+                        additional_pseudoheaders=[
+                            *args.add_pseudoheaders,
+                            "Commit: false",
+                        ],
+                    )
+                conflicted_revs.append(rev)
+                continue
             raise MergeConflictsError(commits=[rev]) from e
         if not pending_change or reword_pending_change:
             change_id = None
@@ -1231,7 +1286,7 @@
                 prepend_subject=args.prepend_subject,
                 sign_off=args.add_signed_off_by,
                 keep_pseudoheaders=keep_pseudoheaders,
-                additional_pseudoheaders=args.add_pseudoheader,
+                additional_pseudoheaders=args.add_pseudoheaders,
             )
 
     if repo.rev_parse() == downstream_rev:
@@ -1269,6 +1324,16 @@
             logger.error("- %s", rev)
         raise MergeConflictsError(commits=skipped_revs)
 
+    conflictedlist = [
+        repo.log(rev, fmt="%H %s", num=1).stdout.strip()
+        for rev in conflicted_revs
+    ]
+    if conflictedlist:
+        logger.error("The following commits were uploaded with conflicts:")
+        for rev in conflictedlist:
+            logger.error("- %s", rev)
+        raise MergeConflictsError(commits=conflicted_revs)
+
 
 def write_json_error(path: pathlib.Path, err: Exception):
     """Write out the JSON-serialized protobuf from an exception.
@@ -1403,6 +1468,7 @@
         "--add-pseudoheader",
         action="append",
         default=[],
+        dest="add_pseudoheaders",
         help="Pseudoheaders to be added to the commit message",
     )
     parser.add_argument(