launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32753
[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.