← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~bowenfan/turnip:SN2052-add_ref_creation_endpoint_to_turnip_api into turnip:master

 

Bowen Fan has proposed merging ~bowenfan/turnip:SN2052-add_ref_creation_endpoint_to_turnip_api into turnip:master.

Commit message:
Add ref creation POST endpoint to turnip-api

The `collection_post` endpoint is mounted at `/repo/{name}/refs`. 
It will read the json request body to dispatch logic layer functions to 
either create a tag or branch. The endpoint supports forced 
overwrites and will pass the param down to pygit2 if specified.

`write_lightweight_tag` takes a tag name and writes it to a given 
commit sha1. Annotated tags are not supported here: it is not
straightforward to support signatures as the endpoint is internal
and unauthed.

`create_branch` does the same for a branch name. Turnip repos
are all bare and therefore we do not have to worry about being
checked out on a branch we are about to overwrite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~bowenfan/turnip/+git/turnip/+merge/452540
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~bowenfan/turnip:SN2052-add_ref_creation_endpoint_to_turnip_api into turnip:master.
diff --git a/turnip/api/store.py b/turnip/api/store.py
index ae897c2..ac88c65 100644
--- a/turnip/api/store.py
+++ b/turnip/api/store.py
@@ -17,8 +17,16 @@ from pygit2 import (
     GIT_OBJ_TAG,
     GIT_REF_OID,
     GIT_SORT_TOPOLOGICAL,
+)
+
+# Some irony in that this error name already exists and is used to signify
+# an existing repo. It inherits from GitError. pygit2's AlreadyExistsError
+# is more appropriate for objects (e.g. references) and we rename it thus.
+from pygit2 import AlreadyExistsError as ObjectAlreadyExistsError
+from pygit2 import (
     GitError,
     IndexEntry,
+    InvalidSpecError,
     Oid,
     Repository,
     init_repository,
@@ -555,6 +563,43 @@ def get_ref(repo_store, repo_name, ref):
         return ref_obj
 
 
+def write_lightweight_tag(
+    repo_store, repo_name, tag_name, commit_sha1, force=False
+):
+    """Write a lightweight tag referencing a given commit sha1.
+
+    :param tag_name: Name of the git tag to write.
+    :param commit_sha1: SHA1 hash of the commit to tag.
+    :param force: If True, force overwrite the original named tag
+        (if any) to point to the new commit.
+    """
+    ref_base = "refs/tags/"
+    ref_name = ref_base + tag_name
+    with open_repo(repo_store, repo_name) as repo:
+        try:
+            repo.create_reference(ref_name, commit_sha1, force)
+        except (ObjectAlreadyExistsError, InvalidSpecError, GitError):
+            raise
+
+
+def create_branch(
+    repo_store, repo_name, branch_name, commit_sha1, force=False
+):
+    """Create a branch and set a given commit sha1 as its tip.
+
+    :param branch_name: Name of the git branch.
+    :param commit_sha1: SHA1 hash of the commit to set as branch tip.
+    :param force: If True, force reset the original named branch
+        (if any) to the given commit. Can cause history loss.
+    """
+    with open_repo(repo_store, repo_name) as repo:
+        try:
+            commit = get_commit(repo_store, repo_name, commit_sha1, repo=repo)
+            repo.create_branch(branch_name, commit, force)
+        except (ObjectAlreadyExistsError, InvalidSpecError, GitError):
+            raise
+
+
 def get_common_ancestor_diff(
     repo_store, repo_name, sha1_target, sha1_source, context_lines=3
 ):
diff --git a/turnip/api/tests/test_api.py b/turnip/api/tests/test_api.py
index 1513618..f49f46f 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -241,6 +241,220 @@ class ApiTestCase(TestCase, ApiRepoStoreMixin):
         resp = self.app.get("/repo/1/refs", expect_errors=True)
         self.assertEqual(404, resp.status_code)
 
+    def test_repo_write_lightweight_tag(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+
+        body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+        }
+        resp = self.app.post_json(f"/repo/{self.repo_path}/refs", body)
+        self.assertEqual(201, resp.status_code)
+
+    def test_repo_create_branch(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+
+        body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "branch",
+            "ref_name": "new-feature",
+        }
+        resp = self.app.post_json(f"/repo/{self.repo_path}/refs", body)
+        self.assertEqual(201, resp.status_code)
+
+    def test_repo_force_overwrite_ref(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        factory.add_tag("1701", "", commit_oid)
+        factory.add_branch("new-feature", commit_oid)
+
+        tag_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+            "force": True,
+        }
+        tag_resp = self.app.post_json(f"/repo/{self.repo_path}/refs", tag_body)
+        self.assertEqual(201, tag_resp.status_code)
+
+        branch_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "branch",
+            # factory.add_branch prefixes 'branch-'
+            "ref_name": "branch-new-feature",
+            "force": True,
+        }
+        branch_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", branch_body
+        )
+        self.assertEqual(201, branch_resp.status_code)
+
+    def test_request_body_api_validation(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+
+        missing_sha_1 = {
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+        }
+        resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", missing_sha_1, expect_errors=True
+        )
+        self.assertEqual(400, resp.status_code)
+        self.assertIn(
+            "commit_sha1 for ref target is missing",
+            resp.text,
+        )
+
+        bad_ref_type = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "invalid_type",
+            "ref_name": "1701",
+        }
+        resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", bad_ref_type, expect_errors=True
+        )
+        self.assertEqual(400, resp.status_code)
+        self.assertIn(
+            "must specify ref type (lightweight_tag or branch)",
+            resp.text,
+        )
+
+        missing_ref_name = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "branch",
+        }
+        resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs",
+            missing_ref_name,
+            expect_errors=True,
+        )
+        self.assertEqual(400, resp.status_code)
+        self.assertIn(
+            "must specify ref name",
+            resp.text,
+        )
+
+        bad_force_optiom = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+            "force": "y",
+        }
+        resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs",
+            bad_force_optiom,
+            expect_errors=True,
+        )
+        self.assertEqual(400, resp.status_code)
+        self.assertIn(
+            "'force' must be a json boolean",
+            resp.text,
+        )
+
+    def test_repo_write_invalid_ref_name(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+
+        tag_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "lightweight_tag",
+            "ref_name": "?*",
+        }
+        tag_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", tag_body, expect_errors=True
+        )
+        self.assertEqual(400, tag_resp.status_code)
+        self.assertIn(
+            "ensure tag or branch name is valid per git-check-ref-format",
+            tag_resp.text,
+        )
+
+        branch_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "branch",
+            "ref_name": "?*",
+        }
+        branch_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", branch_body, expect_errors=True
+        )
+        self.assertEqual(400, branch_resp.status_code)
+        self.assertIn(
+            "ensure tag or branch name is valid per git-check-ref-format",
+            branch_resp.text,
+        )
+
+    def test_repo_ref_already_exists(self):
+        factory = RepoFactory(self.repo_store)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        factory.add_tag("1701", "", commit_oid)
+        factory.add_branch("new-feature", commit_oid)
+
+        tag_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+        }
+        tag_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", tag_body, expect_errors=True
+        )
+        self.assertEqual(409, tag_resp.status_code)
+        self.assertIn(
+            "tag or branch name already exists",
+            tag_resp.text,
+        )
+
+        branch_body = {
+            "commit_sha1": commit_oid.hex,
+            "ref_type": "branch",
+            # factory.add_branch prefixes 'branch-'
+            "ref_name": "branch-new-feature",
+        }
+        branch_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", branch_body, expect_errors=True
+        )
+        self.assertEqual(409, branch_resp.status_code)
+        self.assertIn(
+            "tag or branch name already exists",
+            branch_resp.text,
+        )
+
+    def test_repo_nonexistent_commit(self):
+        factory = RepoFactory(self.repo_store)
+
+        tag_body = {
+            "commit_sha1": factory.nonexistent_oid(),
+            "ref_type": "lightweight_tag",
+            "ref_name": "1701",
+        }
+        tag_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", tag_body, expect_errors=True
+        )
+        self.assertEqual(404, tag_resp.status_code)
+        self.assertIn(
+            "no commit found for commit_sha1 given",
+            tag_resp.text,
+        )
+
+        branch_body = {
+            "commit_sha1": factory.nonexistent_oid(),
+            "ref_type": "branch",
+            # factory.add_branch prefixes 'branch-'
+            "ref_name": "branch-new-feature",
+            "force": True,
+        }
+        branch_resp = self.app.post_json(
+            f"/repo/{self.repo_path}/refs", branch_body, expect_errors=True
+        )
+        self.assertEqual(404, branch_resp.status_code)
+        self.assertIn(
+            "no commit found for commit_sha1 given",
+            branch_resp.text,
+        )
+
     def test_ignore_non_unicode_refs(self):
         """Ensure non-unicode refs are dropped from ref collection."""
         factory = RepoFactory(self.repo_store)
diff --git a/turnip/api/tests/test_store.py b/turnip/api/tests/test_store.py
index 47a97da..c929012 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -8,6 +8,8 @@ import uuid
 
 import pygit2
 import yaml
+
+from pygit2 import AlreadyExistsError, GitError, InvalidSpecError
 from fixtures import EnvironmentVariable, MonkeyPatch, TempDir
 from testtools import TestCase
 
@@ -391,6 +393,145 @@ class InitTestCase(TestCase):
             packed_refs, os.path.join(too_path, "turnip-subordinate")
         )
 
+    def test_write_lightweight_tag(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        commit_sha1 = commit_oid.hex
+        tag_name = "1701"
+
+        store.write_lightweight_tag(
+            self.repo_store, repo_path, tag_name, commit_sha1
+        )
+        self.assertAdvertisedRefs(
+            [("refs/tags/1701", commit_sha1)], [], repo_path
+        )
+
+    def test_create_branch(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        commit_sha1 = commit_oid.hex
+        branch_name = "new-feature"
+
+        store.create_branch(
+            self.repo_store, repo_path, branch_name, commit_sha1
+        )
+        self.assertAdvertisedRefs(
+            [("refs/heads/new-feature", commit_sha1)], [], repo_path
+        )
+
+    def test_force_overwrite_tag(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        first_commit_oid = factory.add_commit("foo", "foobar.txt")
+        first_commit_sha1 = first_commit_oid.hex
+        tag_name = "1701"
+
+        store.write_lightweight_tag(
+            self.repo_store, repo_path, tag_name, first_commit_sha1
+        )
+        self.assertAdvertisedRefs(
+            [("refs/tags/1701", first_commit_sha1)], [], repo_path
+        )
+
+        second_commit_oid = factory.add_commit("bar", "barbaz.txt")
+        second_commit_sha1 = second_commit_oid.hex
+        store.write_lightweight_tag(
+            self.repo_store, repo_path, tag_name, second_commit_sha1, True
+        )
+        self.assertAdvertisedRefs(
+            [("refs/tags/1701", second_commit_sha1)],  # in refs
+            [("refs/tags/1701", first_commit_sha1)],  # not in refs
+            repo_path,
+        )
+
+    def test_force_overwrite_branch(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        first_commit_oid = factory.add_commit("foo", "foobar.txt")
+        first_commit_sha1 = first_commit_oid.hex
+        branch_name = "new-feature"
+
+        store.create_branch(
+            self.repo_store, repo_path, branch_name, first_commit_sha1
+        )
+        self.assertAdvertisedRefs(
+            [("refs/heads/new-feature", first_commit_sha1)],
+            [],
+            repo_path,
+        )
+
+        second_commit_oid = factory.add_commit("bar", "barbaz.txt")
+        second_commit_sha1 = second_commit_oid.hex
+        store.create_branch(
+            self.repo_store, repo_path, branch_name, second_commit_sha1, True
+        )
+        self.assertAdvertisedRefs(
+            [("refs/heads/new-feature", second_commit_sha1)],  # in refs
+            [("refs/heads/new-feature", first_commit_sha1)],  # not in refs
+            repo_path,
+        )
+
+    def test_write_invalid_ref_name(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        commit_sha1 = commit_oid.hex
+        invalid_ref_name = "?*"
+
+        self.assertRaises(
+            InvalidSpecError,
+            store.write_lightweight_tag,
+            *[self.repo_store, repo_path, invalid_ref_name, commit_sha1],
+        )
+
+        self.assertRaises(
+            InvalidSpecError,
+            store.create_branch,
+            *[self.repo_store, repo_path, invalid_ref_name, commit_sha1],
+        )
+
+    def test_ref_already_exists(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+        commit_oid = factory.add_commit("foo", "foobar.txt")
+        factory.add_tag("1701", "", commit_oid)
+        factory.add_branch("new-feature", commit_oid)
+        commit_sha1 = commit_oid.hex
+
+        self.assertRaises(
+            AlreadyExistsError,
+            store.write_lightweight_tag,
+            *[self.repo_store, repo_path, "1701", commit_sha1],
+        )
+
+        self.assertRaises(
+            AlreadyExistsError,
+            store.create_branch,
+            *[self.repo_store, repo_path, "branch-new-feature", commit_sha1],
+        )
+
+    def test_nonexistent_commit(self):
+        repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+        factory = RepoFactory(repo_path)
+
+        self.assertRaises(
+            GitError,
+            store.write_lightweight_tag,
+            *[self.repo_store, repo_path, "1701", factory.nonexistent_oid()],
+        )
+        self.assertRaises(
+            GitError,
+            store.create_branch,
+            *[
+                self.repo_store,
+                repo_path,
+                "new-feature",
+                factory.nonexistent_oid(),
+            ],
+        )
+
     def test_fetch_refs(self):
         celery_fixture = CeleryWorkerFixture()
         self.useFixture(celery_fixture)
diff --git a/turnip/api/views.py b/turnip/api/views.py
index 5014634..4fda65a 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -7,7 +7,7 @@ import re
 import pyramid.httpexceptions as exc
 from cornice.resource import resource
 from cornice.validators import extract_cstruct
-from pygit2 import GitError
+from pygit2 import AlreadyExistsError, GitError, InvalidSpecError
 from pyramid.response import Response
 
 from turnip.api import store
@@ -265,6 +265,75 @@ class RefAPI(BaseAPI):
             return exc.HTTPNotFound()  # 404
 
     @validate_path
+    def collection_post(self, repo_store, repo_name):
+        """Create a lightweight tag or a branch against a commit."""
+        json_data = extract_cstruct(self.request)["body"]
+        commit_sha1 = json_data.get("commit_sha1")
+        ref_type = json_data.get("ref_type")
+        ref_name = json_data.get("ref_name")
+        force = json_data.get("force", False)
+
+        if not commit_sha1:
+            self.request.errors.add(
+                "body", "commit_sha1", "commit_sha1 for ref target is missing"
+            )
+            return
+        if not ref_type or ref_type not in {"lightweight_tag", "branch"}:
+            self.request.errors.add(
+                "body",
+                "ref_type",
+                "must specify ref type (lightweight_tag or branch)",
+            )
+            return
+        if not ref_name:
+            self.request.errors.add(
+                "body", "ref_name", "must specify ref name"
+            )
+            return
+        # Do not accept truthy values (e.g. {"force": "no"}) to avoid confusion
+        if force and not isinstance(force, bool):
+            self.request.errors.add(
+                "body", "force", "'force' must be a json boolean"
+            )
+            return
+
+        try:
+            if ref_type == "lightweight_tag":
+                store.write_lightweight_tag(
+                    repo_store, repo_name, ref_name, commit_sha1, force
+                )
+            elif ref_type == "branch":
+                store.create_branch(
+                    repo_store, repo_name, ref_name, commit_sha1, force
+                )
+        except AlreadyExistsError:
+            self.request.errors.add(
+                "body",
+                "ref_name",
+                "tag or branch name already exists",
+            )
+            self.request.errors.status = 409
+            return
+        except InvalidSpecError:
+            self.request.errors.add(
+                "body",
+                "ref_name",
+                "ensure tag or branch name is valid per git-check-ref-format",
+            )
+            return
+        except GitError:
+            # This diverges from the generic Resource Not Found for get_ref,
+            # but as we are dealing with multiple object types here it would
+            # be helpful to raise a more specific error.
+            self.request.errors.add(
+                "body", "commit_sha1", "no commit found for commit_sha1 given"
+            )
+            self.request.errors.status = 404
+            return
+        else:
+            return Response(status=201)
+
+    @validate_path
     def get(self, repo_store, repo_name):
         ref = "refs/" + self.request.matchdict["ref"]
         try:

Follow ups