← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/turnip:add-async-request-merge into turnip:master

 

Ines Almeida has proposed merging ~ines-almeida/turnip:add-async-request-merge into turnip:master.

Commit message:
Add request-merge endpoint that will asyncronously merge branches

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ines-almeida/turnip/+git/turnip/+merge/488936
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/turnip:add-async-request-merge into turnip:master.
diff --git a/turnip/api/store.py b/turnip/api/store.py
index 8bd3b2d..bd745ef 100644
--- a/turnip/api/store.py
+++ b/turnip/api/store.py
@@ -781,7 +781,11 @@ def push(repo_store, source_repo_name, target_repo, source_branch):
 
         # No needed credentials for local file URLs
         callbacks = RemoteCallbacks(credentials=None)
-        remote.push([refspec], callbacks=callbacks)
+
+        try:
+            remote.push([refspec], callbacks=callbacks)
+        except GitError as e:
+            raise RefNotFoundError(e)
 
     return remote_ref
 
@@ -810,6 +814,7 @@ def _get_remote_source_tip(
     repo_store, source_repo_name, repo, source_branch, source_commit_sha1
 ):
     """Get source commit from source repo into target repo"""
+    source_ref_name = None
     try:
         # For cross repo, we push a temporary ref to the target repo
         source_ref_name = push(
@@ -818,7 +823,8 @@ def _get_remote_source_tip(
         return _get_source_commit(repo, source_ref_name, source_commit_sha1)
     finally:
         # Cleanup temporary refs
-        repo.references.delete(source_ref_name)
+        if source_ref_name:
+            repo.references.delete(source_ref_name)
 
 
 def _find_merge_commit(repo, target_tip, source_tip):
@@ -943,6 +949,223 @@ def merge(
         }
 
 
+def request_merge(
+    repo_store,
+    repo_name,
+    target_branch,
+    target_commit_sha1,
+    source_branch,
+    source_commit_sha1,
+    committer_name,
+    committer_email,
+    commit_message=None,
+):
+    """Queue an async regular merge from source branch into target branch.
+
+    :param repo_store: path to the repository store
+    :param repo_name: name of the target repository
+    :param target_commit_sha1: target commit sha1 to merge to
+    :param target_branch: target branch to merge into
+    :param source_branch: source branch to merge from
+    :param source_commit_sha1: source commit sha1 to merge from
+    :param committer_name: name of the committer
+    :param committer_email: email of the committer
+    :param commit_message: [optional] custom commit message
+    """
+
+    source_repo_name = None
+    if len(repo_name.split(":")) == 2:
+        repo_name, source_repo_name = repo_name.split(":")
+        if repo_name == source_repo_name:
+            source_repo_name = None
+
+    with open_repo(repo_store, repo_name) as repo:
+        target_tip = _get_target_commit(
+            repo, target_branch, target_commit_sha1
+        )
+
+        if source_repo_name is not None:
+            source_tip = _get_remote_source_tip(
+                repo_store,
+                source_repo_name,
+                repo,
+                source_branch,
+                source_commit_sha1,
+            )
+        else:
+            source_tip = _get_source_commit(
+                repo, source_branch, source_commit_sha1
+            )
+
+        # Check if source is already included in target
+        common_ancestor_id = repo.merge_base(target_tip, source_tip)
+        if common_ancestor_id == source_tip:
+            return {
+                "queued": False,
+                "already_merged": True,
+            }
+
+    merge_async.apply_async(
+        kwargs=dict(
+            repo_store=repo_store,
+            repo_name=repo_name,
+            source_repo_name=source_repo_name,
+            target_branch=target_branch,
+            target_commit_sha1=target_commit_sha1,
+            source_branch=source_branch,
+            source_commit_sha1=source_commit_sha1,
+            committer_name=committer_name,
+            committer_email=committer_email,
+            commit_message=commit_message,
+        )
+    )
+
+    return {
+        "queued": True,
+        "already_merged": False,
+    }
+
+
+@app.task
+def merge_async(
+    repo_store,
+    repo_name,
+    source_repo_name,
+    target_branch,
+    target_commit_sha1,
+    source_branch,
+    source_commit_sha1,
+    committer_name,
+    committer_email,
+    commit_message,
+):
+    """Task to perform a regular merge from source branch into target branch.
+
+    This currently only supports a regular merge with a merge commit, other
+    merge strategies still need to be implemented.
+
+    :param repo_store: path to the repository store
+    :param repo_name: name of the target repository
+    :param source_repo_name: name of the source repository
+    :param target_commit_sha1: target commit sha1 to merge to
+    :param target_branch: target branch to merge into
+    :param source_branch: source branch to merge from
+    :param source_commit_sha1: source commit sha1 to merge from
+    :param committer_name: name of the committer
+    :param committer_email: email of the committer
+    :param commit_message: [optional] custom commit message
+    """
+
+    logger = tasks_logger
+
+    # Setup xmlrpc to notify Launchpad of a push when merge is successful
+    xmlrpc_endpoint = config.get("virtinfo_endpoint")
+    xmlrpc_timeout = float(config.get("virtinfo_timeout"))
+    xmlrpc_proxy = TimeoutServerProxy(
+        xmlrpc_endpoint, timeout=xmlrpc_timeout, allow_none=True
+    )
+
+    logger.info(
+        f"[{repo_name}] Merging {source_commit_sha1} ({source_branch}) into "
+        f"{target_commit_sha1} ({target_branch}) (committer {committer_name})"
+    )
+
+    with open_repo(repo_store, repo_name) as repo:
+        target_tip = _get_target_commit(
+            repo, target_branch, target_commit_sha1
+        )
+
+        # For cross-repo merge, the source_tip should already be in the target
+        # repo because we checked before queueing the task, but it might be
+        # deleted during garbage collection if the task takes too long to start
+        source_tip_commit = repo.get(source_commit_sha1)
+        if not source_tip_commit and source_repo_name is not None:
+            source_tip = _get_remote_source_tip(
+                repo_store,
+                source_repo_name,
+                repo,
+                source_branch,
+                source_commit_sha1,
+            )
+        elif not source_tip_commit:
+            raise RefNotFoundError(
+                f"[{repo_name}] Cannot find {source_commit_sha1} in repo"
+            )
+        else:
+            source_tip = source_tip_commit.oid
+
+        # Check if source is already included in target
+        common_ancestor_id = repo.merge_base(target_tip, source_tip)
+        if common_ancestor_id == source_tip:
+            logger.info(
+                f"[{repo_name}] {source_commit_sha1} ({source_branch}) "
+                f"already merged into {target_branch}"
+            )
+            return
+
+        # Create an in-memory index for the merge
+        index = repo.merge_commits(target_tip, source_tip)
+        if index.conflicts is not None:
+            raise RefNotFoundError(
+                f"[{repo_name}] Merge conflicts between {source_commit_sha1} "
+                f"({source_branch}) and {target_commit_sha1} ({target_branch})"
+            )
+
+        tree_id = index.write_tree(repo)
+
+        # Verify that branch hasn't changed since the start of the merge
+        current_target_tip = get_branch_tip(repo, target_branch)
+        if target_tip != current_target_tip:
+            raise RefNotFoundError(
+                f"[{repo_name}] Branch {target_branch} was modified during "
+                "merge operation"
+            )
+
+        committer = Signature(committer_name, committer_email)
+        if commit_message is None:
+            commit_message = (
+                f"Merge branch '{source_branch}' into '{target_branch}'"
+            )
+
+        logger.info(f"[{repo_name}] Creating merge commit in {target_branch}")
+        # Create a merge commit that has both branch tips as parents to
+        # preserve the commit history.
+        #
+        # Note that `create_commit` will raise a GitError if a new
+        # commit is pushed to the target branch since the start of this
+        # merge.
+        target_ref = f"refs/heads/{target_branch}"
+        repo.create_commit(
+            target_ref,
+            committer,
+            committer,
+            commit_message,
+            tree_id,
+            [target_tip, source_tip],
+        )
+
+        logger.info(
+            f"[{repo_name}] Successfully merged commit in {target_branch}"
+        )
+
+    repo_path = os.path.join(repo_store, repo_name)
+    loose_object_count, pack_count = get_repack_data(path=repo_path)
+    statistics = dict(
+        {
+            ("loose_object_count", loose_object_count),
+            ("pack_count", pack_count),
+        }
+    )
+    try:
+        xmlrpc_proxy.notify(repo_name, statistics)
+        logger.info(f"[{repo_name}] Push notification sent to LP")
+    except xmlrpc.Fault:
+        logger.error(
+            f"[{repo_name}] Failed to signal LP to notify commit push for "
+            f"repository {repo_path}"
+        )
+
+
 def get_diff(repo_store, repo_name, sha1_from, sha1_to, context_lines=3):
     """Get patch and associated commits of two sha1s.
 
diff --git a/turnip/api/tests/test_api.py b/turnip/api/tests/test_api.py
index 9d6d4c7..d512ce0 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -1720,6 +1720,213 @@ class ApiTestCase(TestCase, ApiRepoStoreMixin):
 
         self.assertEqual(400, resp.status_code)
 
+    def test_request_merge_successful(self):
+        """Test a successful request merge queues the operation."""
+
+        factory = RepoFactory(self.repo_store)
+        initial_commit = factory.add_commit("initial", "file.txt")
+        repo = factory.build()
+        repo.create_branch("main", repo.get(initial_commit))
+        repo.set_head("refs/heads/main")
+
+        feature_commit = factory.add_commit(
+            "feature", "file.txt", parents=[initial_commit]
+        )
+        repo.create_branch("feature", repo.get(feature_commit))
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            resp = self.app.post_json(
+                f"/repo/{self.repo_path}/request-merge/main:feature",
+                {
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "target_commit_sha1": initial_commit.hex,
+                    "source_commit_sha1": feature_commit.hex,
+                },
+            )
+
+        self.assertEqual(200, resp.status_code)
+        self.assertTrue(resp.json["queued"])
+        self.assertFalse(resp.json["already_merged"])
+        mock_apply_async.assert_called_once()
+        mock_apply_async.assert_called_with(
+            kwargs={
+                "repo_store": self.repo_root,
+                "repo_name": self.repo_path,
+                "source_repo_name": None,
+                "target_branch": "main",
+                "target_commit_sha1": initial_commit.hex,
+                "source_branch": "feature",
+                "source_commit_sha1": feature_commit.hex,
+                "committer_name": "Test User",
+                "committer_email": "test@xxxxxxxxxxx",
+                "commit_message": None,
+            }
+        )
+
+    def test_request_merge_missing_fields(self):
+        """Test missing required fields returns 400."""
+        resp = self.app.post_json(
+            f"/repo/{self.repo_path}/request-merge/main:feature",
+            {},
+            expect_errors=True,
+        )
+        self.assertEqual(400, resp.status_code)
+        self.assertIn("required", resp.text)
+
+    def test_cross_repo_request_merge_successful(self):
+        """Test a successful cross-repo request_merge."""
+
+        # Create target repo with main branch
+        target_path = os.path.join(self.repo_root, "target")
+        target_factory = RepoFactory(target_path)
+        target_repo = target_factory.build()
+        target_initial = target_factory.add_commit(
+            "target initial", "file.txt"
+        )
+        target_repo.create_branch("main", target_repo.get(target_initial))
+        target_repo.set_head("refs/heads/main")
+
+        # Create source repo with feature branch
+        source_path = os.path.join(self.repo_root, "source")
+        source_factory = RepoFactory(source_path, clone_from=target_factory)
+        source_repo = source_factory.build()
+        source_initial = target_initial
+        source_commit = source_factory.add_commit(
+            "source change", "file.txt", parents=[source_initial]
+        )
+        source_repo.create_branch("feature", source_repo.get(source_commit))
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            # Perform cross-repo merge
+            response = self.app.post_json(
+                "/repo/target:source/request-merge/main:feature",
+                {
+                    "target_commit_sha1": target_initial.hex,
+                    "source_commit_sha1": source_commit.hex,
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                },
+            )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.json.get("queued"))
+        self.assertFalse(response.json.get("already_merged"))
+        mock_apply_async.assert_called_once()
+        mock_apply_async.assert_called_with(
+            kwargs={
+                "repo_store": self.repo_root,
+                "repo_name": self.repo_path,
+                "source_repo_name": source_path,
+                "target_branch": "main",
+                "target_commit_sha1": target_initial.hex,
+                "source_branch": "feature",
+                "source_commit_sha1": source_commit.hex,
+                "committer_name": "Test User",
+                "committer_email": "test@xxxxxxxxxxx",
+                "commit_message": None,
+            }
+        )
+
+        # Verify temporary ref was cleaned up
+        self.assertNotIn(
+            "refs/internal/source-feature", target_repo.references
+        )
+
+        # Verify temporary remote was cleaned up
+        self.assertEqual(0, len(target_repo.remotes))
+
+    def test_request_merge_already_included(self):
+        """Test request_merge when source is already included in target."""
+        factory = RepoFactory(self.repo_store)
+        initial_commit = factory.add_commit("initial", "file.txt")
+        repo = factory.build()
+        repo.create_branch("main", repo.get(initial_commit))
+        repo.set_head("refs/heads/main")
+
+        feature_commit = factory.add_commit(
+            "feature", "file.txt", parents=[initial_commit]
+        )
+        repo.create_branch("feature", repo.get(feature_commit))
+
+        # Simulate merge
+        repo.head.set_target(feature_commit)
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            # Try to merge again
+            resp = self.app.post_json(
+                f"/repo/{self.repo_path}/request-merge/main:feature",
+                {
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "target_commit_sha1": initial_commit.hex,
+                    "source_commit_sha1": feature_commit.hex,
+                },
+            )
+
+        self.assertEqual(200, resp.status_code)
+        self.assertFalse(resp.json.get("queued"))
+        self.assertTrue(resp.json.get("already_merged"))
+        mock_apply_async.assert_not_called()
+
+    def test_request_merge_missing_branches(self):
+        """Test request_merge with missing branches."""
+
+        factory = RepoFactory(self.repo_store)
+        initial_commit = factory.add_commit("initial", "file.txt")
+        repo = factory.build()
+        repo.create_branch("main", repo.get(initial_commit))
+        repo.set_head("refs/heads/main")
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            resp = self.app.post_json(
+                f"/repo/{self.repo_path}/request-merge/main:nonexisting",
+                {
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "target_commit_sha1": initial_commit.hex,
+                    "source_commit_sha1": "nonexisting",
+                },
+                expect_errors=True,
+            )
+
+        self.assertEqual(404, resp.status_code)
+        mock_apply_async.assert_not_called()
+
+    def test_request_merge_invalid_input(self):
+        """Test request_merge with invalid input."""
+
+        factory = RepoFactory(self.repo_store)
+        initial_commit = factory.add_commit("initial", "file.txt")
+        repo = factory.build()
+        repo.create_branch("main", repo.get(initial_commit))
+        repo.set_head("refs/heads/main")
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            resp = self.app.post_json(
+                f"/repo/{self.repo_path}/request-merge/main:feature",
+                {
+                    # Missing committer_email
+                    "committer_name": "Test User",
+                    "target_commit_sha1": initial_commit.hex,
+                    "source_commit_sha1": "test",
+                },
+                expect_errors=True,
+            )
+
+        self.assertEqual(400, resp.status_code)
+        mock_apply_async.assert_not_called()
+
 
 class AsyncRepoCreationAPI(TestCase, ApiRepoStoreMixin):
     def setUp(self):
diff --git a/turnip/api/tests/test_store.py b/turnip/api/tests/test_store.py
index 98ac378..c5242f6 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -5,15 +5,20 @@ import os.path
 import re
 import subprocess
 import uuid
+from unittest import mock
 
 import pygit2
 import yaml
 from fixtures import EnvironmentVariable, MonkeyPatch, TempDir
 from pygit2 import Signature
 from testtools import TestCase
+from twisted.internet import reactor as default_reactor
+from twisted.web import server
 
 from turnip.api import store
 from turnip.api.tests.test_helpers import RepoFactory, open_repo
+from turnip.config import config
+from turnip.pack.tests.fake_servers import FakeVirtInfoService
 from turnip.tests.tasks import CeleryWorkerFixture
 
 
@@ -1134,6 +1139,38 @@ class PushTestCase(TestCase):
         target_ref = self.target_repo.references[remote_ref]
         self.assertEqual(target_ref.target, new_commit)
 
+    def test_push_ref_does_not_exist(self):
+        """Test that push raises a RefNotFoundError is ref does not exist."""
+        # First push
+        self.assertRaises(
+            store.RefNotFoundError,
+            store.push,
+            self.repo_store,
+            "source",
+            self.target_repo,
+            "nonexisting",
+        )
+        remote_ref = store.push(
+            self.repo_store, "source", self.target_repo, self.branch_name
+        )
+
+        # Add new commit to source branch
+        new_commit = self.source_factory.add_commit(
+            "new commit", "file.txt", parents=[self.initial_commit]
+        )
+        self.source_repo.references[
+            f"refs/heads/{self.branch_name}"
+        ].set_target(new_commit)
+
+        # Push again
+        store.push(
+            self.repo_store, "source", self.target_repo, self.branch_name
+        )
+
+        # Verify ref was updated
+        target_ref = self.target_repo.references[remote_ref]
+        self.assertEqual(target_ref.target, new_commit)
+
 
 class CrossRepoMergeTestCase(TestCase):
     def setUp(self):
@@ -1293,3 +1330,488 @@ class CrossRepoMergeTestCase(TestCase):
             "test@xxxxxxxxxxx",
         )
         self.assertEqual("The tip of the source branch has changed", str(e))
+
+
+class RequestMergeTestCase(TestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.repo_store = self.useFixture(TempDir()).path
+        self.useFixture(EnvironmentVariable("REPO_STORE", self.repo_store))
+
+        self.target_repo_path = os.path.join(self.repo_store, "target")
+        self.target_factory = RepoFactory(self.target_repo_path)
+        self.target_repo = self.target_factory.build()
+
+        self.initial_commit = self.target_factory.add_commit(
+            "initial", "file.txt"
+        )
+        self.target_repo.create_branch(
+            "main", self.target_repo.get(self.initial_commit)
+        )
+        self.target_repo.set_head("refs/heads/main")
+        self.feature_commit = self.target_factory.add_commit(
+            "feature", "file.txt", parents=[self.initial_commit]
+        )
+        self.target_repo.create_branch(
+            "feature", self.target_repo.get(self.feature_commit)
+        )
+
+        self.source_repo_path = os.path.join(self.repo_store, "source")
+        self.source_factory = RepoFactory(
+            self.source_repo_path, clone_from=self.target_factory
+        )
+        self.source_repo = self.source_factory.build()
+
+    def test_request_merge_successful(self):
+        """Test successful request merge."""
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            result = store.request_merge(
+                self.repo_store,
+                "target",
+                "main",
+                self.initial_commit.hex,
+                "feature",
+                self.feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            self.assertTrue(result["queued"])
+            self.assertFalse(result["already_merged"])
+            mock_apply_async.assert_called_once_with(
+                kwargs={
+                    "repo_store": self.repo_store,
+                    "repo_name": "target",
+                    "source_repo_name": None,
+                    "target_branch": "main",
+                    "target_commit_sha1": self.initial_commit.hex,
+                    "source_branch": "feature",
+                    "source_commit_sha1": self.feature_commit.hex,
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "commit_message": None,
+                }
+            )
+
+    def test_request_merge_successful_source_same_as_target(self):
+        """Test successful request merge."""
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            result = store.request_merge(
+                self.repo_store,
+                "target:target",
+                "main",
+                self.initial_commit.hex,
+                "feature",
+                self.feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            self.assertTrue(result["queued"])
+            self.assertFalse(result["already_merged"])
+            mock_apply_async.assert_called_once_with(
+                kwargs={
+                    "repo_store": self.repo_store,
+                    "repo_name": "target",
+                    "source_repo_name": None,
+                    "target_branch": "main",
+                    "target_commit_sha1": self.initial_commit.hex,
+                    "source_branch": "feature",
+                    "source_commit_sha1": self.feature_commit.hex,
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "commit_message": None,
+                }
+            )
+
+    def test_request_merge_already_merged(self):
+        """Test request merge with already merged branches."""
+
+        self.target_repo.references["refs/heads/main"].set_target(
+            self.feature_commit
+        )
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            result = store.request_merge(
+                self.repo_store,
+                "target",
+                "main",
+                self.initial_commit.hex,
+                "feature",
+                self.feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            self.assertFalse(result["queued"])
+            self.assertTrue(result["already_merged"])
+            mock_apply_async.assert_not_called()
+
+    def test_cross_repo_request_merge_successful(self):
+        """Test successful cross-repo request merge."""
+
+        feature_commit = self.source_factory.add_commit(
+            "feature", "file.txt", parents=[self.initial_commit]
+        )
+        self.source_repo.create_branch(
+            "feature", self.source_repo.get(feature_commit)
+        )
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            result = store.request_merge(
+                self.repo_store,
+                "target:source",
+                "main",
+                self.initial_commit.hex,
+                "feature",
+                feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            self.assertTrue(result["queued"])
+            self.assertFalse(result["already_merged"])
+            mock_apply_async.assert_called_once_with(
+                kwargs={
+                    "repo_store": self.repo_store,
+                    "repo_name": "target",
+                    "source_repo_name": "source",
+                    "target_branch": "main",
+                    "target_commit_sha1": self.initial_commit.hex,
+                    "source_branch": "feature",
+                    "source_commit_sha1": feature_commit.hex,
+                    "committer_name": "Test User",
+                    "committer_email": "test@xxxxxxxxxxx",
+                    "commit_message": None,
+                }
+            )
+
+    def test_request_merge_source_branch_not_found(self):
+        """Test request merge with a non-existent source branch."""
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            self.assertRaises(
+                store.RefNotFoundError,
+                store.request_merge,
+                self.repo_store,
+                "target",
+                "main",
+                self.initial_commit.hex,
+                "nonexistent_branch",
+                self.feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            mock_apply_async.assert_not_called()
+
+    def test_request_merge_cross_repo_source_branch_not_found(self):
+        """Test request merge cross-repo  with a non-existent source branch."""
+
+        with mock.patch(
+            "turnip.api.store.merge_async.apply_async"
+        ) as mock_apply_async:
+            self.assertRaises(
+                store.RefNotFoundError,
+                store.request_merge,
+                self.repo_store,
+                "target:source",
+                "main",
+                self.initial_commit.hex,
+                "nonexistent_branch",
+                self.feature_commit.hex,
+                "Test User",
+                "test@xxxxxxxxxxx",
+            )
+            mock_apply_async.assert_not_called()
+
+    def test_merge_source_branch_moved(self):
+        """Test error when source branch tip doesn't match expected commit."""
+
+        new_feature_commit = self.target_factory.add_commit(
+            "new source", "file.txt", parents=[self.feature_commit]
+        )
+        self.target_repo.references["refs/heads/feature"].set_target(
+            new_feature_commit
+        )
+
+        # Try to merge using old source commit
+        e = self.assertRaises(
+            pygit2.GitError,
+            store.request_merge,
+            self.repo_store,
+            "target",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            self.feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+        self.assertEqual("The tip of the source branch has changed", str(e))
+
+    def _setup_XML_RPC(self):
+        """Set up test XML-RPC server"""
+        self.virtinfo = FakeVirtInfoService(allowNone=True)
+        self.virtinfo_listener = default_reactor.listenTCP(
+            0, server.Site(self.virtinfo)
+        )
+        self.virtinfo_port = self.virtinfo_listener.getHost().port
+        self.virtinfo_url = b"http://localhost:%d/"; % self.virtinfo_port
+        self.addCleanup(self.virtinfo_listener.stopListening)
+        config.defaults["virtinfo_endpoint"] = self.virtinfo_url
+
+    def test_request_merge_successful_async(self):
+        """Test a successful request merge using celery."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+        self._setup_XML_RPC()
+
+        # Start merge
+        result = store.request_merge(
+            self.repo_store,
+            "target",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            self.feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+        self.assertTrue(result["queued"])
+        self.assertFalse(result["already_merged"])
+
+        # Wait for the merge commit to appear on main
+        def merge_done():
+            ref = self.target_repo.references["refs/heads/main"]
+            commit = self.target_repo.get(ref.target)
+            return len(commit.parent_ids) == 2 and (
+                self.initial_commit.hex in [p.hex for p in commit.parents]
+                and self.feature_commit.hex in [p.hex for p in commit.parents]
+            )
+
+        celery_fixture.waitUntil(10, merge_done)
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+        self.assertEqual(len(commit.parent_ids), 2)
+        self.assertIn(self.initial_commit.hex, [p.hex for p in commit.parents])
+        self.assertIn(self.feature_commit.hex, [p.hex for p in commit.parents])
+        self.assertEqual(commit.committer.name, "Test User")
+        self.assertEqual(commit.committer.email, "test@xxxxxxxxxxx")
+
+    def test_cross_repo_request_merge_successful_async(self):
+        """Test a successful cross-repo request merge using celery."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        # Add a new commit to source feature branch
+        feature_commit = self.source_factory.add_commit(
+            "feature", "file.txt", parents=[self.initial_commit]
+        )
+        self.source_repo.create_branch(
+            "feature", self.source_repo.get(feature_commit)
+        )
+
+        result = store.request_merge(
+            self.repo_store,
+            "target:source",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+        self.assertTrue(result["queued"])
+        self.assertFalse(result["already_merged"])
+
+        # Wait for the merge commit to appear on main in the target repo
+        def merge_done():
+            ref = self.target_repo.references["refs/heads/main"]
+            commit = self.target_repo.get(ref.target)
+            return len(commit.parent_ids) == 2 and (
+                self.initial_commit.hex in [p.hex for p in commit.parents]
+                and feature_commit.hex in [p.hex for p in commit.parents]
+            )
+
+        celery_fixture.waitUntil(10, merge_done)
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+        self.assertEqual(len(commit.parent_ids), 2)
+        self.assertIn(self.initial_commit.hex, [p.hex for p in commit.parents])
+        self.assertIn(feature_commit.hex, [p.hex for p in commit.parents])
+        self.assertEqual(commit.committer.name, "Test User")
+        self.assertEqual(commit.committer.email, "test@xxxxxxxxxxx")
+        # Temporary ref should be cleaned up
+        self.assertIsNone(
+            self.target_repo.references.get("refs/internal/source-feature")
+        )
+
+    def test_request_merge_conflicts_async(self):
+        """Test request merge with conflicts using celery."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        # Create conflicting changes in both branches
+        main_commit = self.target_factory.add_commit(
+            "main content", "file.txt", parents=[self.initial_commit]
+        )
+        self.target_repo.references["refs/heads/main"].set_target(main_commit)
+
+        feature_commit = self.target_factory.add_commit(
+            "feature content", "file.txt", parents=[self.initial_commit]
+        )
+        self.target_repo.references["refs/heads/feature"].set_target(
+            feature_commit
+        )
+
+        # Start merge
+        result = store.request_merge(
+            self.repo_store,
+            "target",
+            "main",
+            main_commit.hex,
+            "feature",
+            feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+        self.assertTrue(result["queued"])
+        self.assertFalse(result["already_merged"])
+
+        # Wait for a short time and check that the main branch tip did not
+        # change (no merge commit)
+        def merge_failed():
+            ref = self.target_repo.references["refs/heads/main"]
+            commit = self.target_repo.get(ref.target)
+            # Should still be the main_commit and not a merge commit
+            return (
+                commit.hex == main_commit.hex and len(commit.parent_ids) == 1
+            )
+
+        celery_fixture.waitUntil(5, merge_failed)
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+        self.assertEqual(commit.hex, main_commit.hex)
+        self.assertEqual(len(commit.parent_ids), 1)
+
+    def test_request_merge_already_merged_async(self):
+        """Test request merge when source is already merged into target
+        (async)."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        # Simulate already merged: set main to feature commit
+        self.target_repo.references["refs/heads/main"].set_target(
+            self.feature_commit
+        )
+
+        result = store.request_merge(
+            self.repo_store,
+            "target",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            self.feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+        self.assertFalse(result["queued"])
+        self.assertTrue(result["already_merged"])
+        # No new merge commit should appear
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+        self.assertEqual(commit.hex, self.feature_commit.hex)
+        self.assertEqual(len(commit.parent_ids), 1)
+
+    def test_request_merge_source_branch_moved_async(self):
+        """Test request merge source branch tip moved forward (async)"""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        new_feature_commit = self.target_factory.add_commit(
+            "new feature", "file.txt", parents=[self.feature_commit]
+        )
+        self.target_repo.references["refs/heads/feature"].set_target(
+            new_feature_commit
+        )
+
+        self.assertRaises(
+            pygit2.GitError,
+            store.request_merge,
+            self.repo_store,
+            "target",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            self.feature_commit.hex,  # old tip
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+
+        # No merge happended
+        self.assertEqual(commit.hex, self.initial_commit.hex)
+
+    def test_request_merge_target_branch_not_found_async(self):
+        """Test request merge when target branch does not exist (async)."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        self.assertRaises(
+            store.RefNotFoundError,
+            store.request_merge,
+            self.repo_store,
+            "target",
+            "nonexistent",
+            self.initial_commit.hex,
+            "feature",
+            self.feature_commit.hex,
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+
+        # No merge happended
+        self.assertEqual(commit.hex, self.initial_commit.hex)
+
+    def test_request_merge_source_sha1_not_found_async(self):
+        """Test request merge when source sha1 does not exist (async)."""
+        self._setup_XML_RPC()
+        celery_fixture = CeleryWorkerFixture()
+        self.useFixture(celery_fixture)
+
+        self.assertRaises(
+            store.GitError,
+            store.request_merge,
+            self.repo_store,
+            "target",
+            "main",
+            self.initial_commit.hex,
+            "feature",
+            "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",  # non-existent sha1
+            "Test User",
+            "test@xxxxxxxxxxx",
+        )
+
+        ref = self.target_repo.references["refs/heads/main"]
+        commit = self.target_repo.get(ref.target)
+        # No merge happended
+        self.assertEqual(commit.hex, self.initial_commit.hex)
diff --git a/turnip/api/views.py b/turnip/api/views.py
index f3ef8ce..6931d17 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -446,6 +446,65 @@ class DiffMergeAPI(BaseAPI):
         return patch
 
 
+@resource(path="/repo/{name}/request-merge/{target_branch}:{source_branch}")
+class RequestMergeAPI(BaseAPI):
+    """Provides an HTTP API for requesting a merge.
+
+    {source_branch} will be merged into {target_branch} within
+    repo {name} (currently we don't support cross-repo)
+    """
+
+    def __init__(self, request, context=None):
+        super().__init__()
+        self.request = request
+
+    @validate_path
+    def post(self, repo_store, repo_name):
+        """Request a merge from a source branch into a target branch.
+
+        This endpoint assumes that the committer is authorized to perform this
+        merge, i.e., it does not check for permissions. The authorization will
+        lie on the system that makes the request (e.g. Launchpad).
+        """
+        target_branch = self.request.matchdict["target_branch"]
+        source_branch = self.request.matchdict["source_branch"]
+
+        target_commit_sha1 = self.request.json.get("target_commit_sha1")
+        source_commit_sha1 = self.request.json.get("source_commit_sha1")
+
+        if not target_commit_sha1 or not source_commit_sha1:
+            return exc.HTTPBadRequest(
+                "target_commit_sha1 and source_commit_sha1 are required"
+            )
+
+        committer_name = self.request.json.get("committer_name")
+        committer_email = self.request.json.get("committer_email")
+        commit_message = self.request.json.get("commit_message")
+
+        if not committer_name or not committer_email:
+            return exc.HTTPBadRequest(
+                "committer_name and committer_email are required"
+            )
+
+        try:
+            response = store.request_merge(
+                repo_store,
+                repo_name,
+                target_branch,
+                target_commit_sha1,
+                source_branch,
+                source_commit_sha1,
+                committer_name,
+                committer_email,
+                commit_message,
+            )
+        except store.RefNotFoundError as e:
+            return exc.HTTPNotFound(e)
+        except GitError as e:
+            return exc.HTTPBadRequest(e)
+        return response
+
+
 @resource(path="/repo/{name}/merge/{target_branch}:{source_branch}")
 class MergeAPI(BaseAPI):
     """Provides an HTTP API for merging.