launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #30575
[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 requires the ref name, e.g. "/heads/new-feature-branch" and
the target commit SHA1. It accepts an optional "force" flag to
force overwrite an existing ref (if any).
Requested reviews:
Gravity Yang (gravi1984)
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~bowenfan/turnip/+git/turnip/+merge/452540
Please see jira ticket and its parent epic for more context: https://warthogs.atlassian.net/browse/SN-2052
Thanks!
--
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..aa52860 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,45 @@ def get_ref(repo_store, repo_name, ref):
return ref_obj
+def create_references(repo_store, repo_name, refs_to_create):
+ """Create a git reference against a given commit sha1.
+
+ :param refs_to_create: List of ref creation requests. Each request is a
+ dict containing the ref name "ref" and the "commit_sha1" against
+ which to create the ref. An optional a "force" key is accepted;
+ if passed, an existing ref will be overwritten on conflict.
+ :return: 2 dicts: created and errors. Created takes the form
+ {ref:commit_sha1} and errors take the form {ref:err_msg}.
+ """
+ created = {}
+ errors = {}
+ with open_repo(repo_store, repo_name) as repo:
+ for ref_to_create in refs_to_create:
+ # Validated in the API layer
+ # Ref names are validated to be unique so we can use them as keys
+ ref = ref_to_create["ref"]
+ commit_sha1 = ref_to_create["commit_sha1"]
+ force = ref_to_create.get("force", False)
+
+ try:
+ repo.create_reference(ref, commit_sha1, force)
+ # The payload might contain multiple errors but pygit2 will only
+ # raise the last one. For such edge cases the client may need to
+ # retry more than once.
+ except InvalidSpecError:
+ errors[ref] = f"Invalid ref name '{ref}'"
+ except GitError:
+ errors[ref] = f"Commit '{commit_sha1}' not found"
+ except ObjectAlreadyExistsError:
+ errors[ref] = (
+ f"Ref '{ref}' already exists, "
+ "retry with force to overwrite"
+ )
+ else:
+ created[ref] = commit_sha1
+ return created, errors
+
+
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..68b56d9 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -241,6 +241,240 @@ class ApiTestCase(TestCase, ApiRepoStoreMixin):
resp = self.app.get("/repo/1/refs", expect_errors=True)
self.assertEqual(404, resp.status_code)
+ def test_repo_create_ref_request_body_validation(self):
+ factory = RepoFactory(self.repo_store)
+ commit_oid = factory.add_commit("foo", "foobar.txt")
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/refs", [], expect_errors=True
+ )
+ self.assertEqual(400, resp.status_code)
+ self.assertIn(
+ "Empty request",
+ resp.text,
+ )
+
+ missing_sha_1 = [{"ref": "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(
+ "Missing commit_sha1 for ref target",
+ resp.text,
+ )
+
+ missing_ref_name = [
+ {
+ "commit_sha1": commit_oid.hex,
+ }
+ ]
+ 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(
+ "Missing ref name",
+ resp.text,
+ )
+
+ duplicate_ref_name = [
+ {
+ "commit_sha1": commit_oid.hex,
+ "ref": "refs/tags/1701",
+ },
+ {
+ "commit_sha1": commit_oid.hex,
+ "ref": "refs/tags/1701",
+ },
+ ]
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/refs",
+ duplicate_ref_name,
+ expect_errors=True,
+ )
+ self.assertEqual(400, resp.status_code)
+ self.assertIn(
+ "Duplicate ref 'refs/tags/1701' in request",
+ resp.text,
+ )
+
+ wrong_ref_name_format = [
+ {
+ "commit_sha1": commit_oid.hex,
+ "ref": "tags/1701",
+ }
+ ]
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/refs",
+ wrong_ref_name_format,
+ expect_errors=True,
+ )
+ self.assertEqual(400, resp.status_code)
+ self.assertIn(
+ "Invalid ref format. Ref must start with 'refs/'",
+ resp.text,
+ )
+
+ bad_force_optiom = [
+ {
+ "commit_sha1": commit_oid.hex,
+ "ref": "refs/tags/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_create_single_ref(self):
+ factory = RepoFactory(self.repo_store)
+ commit_sha1 = factory.add_commit("foo", "foobar.txt").hex
+ tag_name = "refs/tags/1701"
+ branch_name = "refs/heads/new-feature"
+
+ for ref in [tag_name, branch_name]:
+ body = [
+ {
+ "ref": ref,
+ "commit_sha1": commit_sha1,
+ }
+ ]
+ resp = self.app.post_json(f"/repo/{self.repo_path}/refs", body)
+ self.assertEqual(201, resp.status_code)
+ self.assertEqual({ref: commit_sha1}, resp.json["created"])
+ self.assertEqual({}, resp.json["errors"])
+
+ 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_name = "refs/tags/1701"
+ branch_name = "refs/heads/branch-new-feature"
+
+ for ref in [tag_name, branch_name]:
+ body = [
+ {
+ "commit_sha1": commit_oid.hex,
+ "ref": ref,
+ "force": True,
+ }
+ ]
+ resp = self.app.post_json(f"/repo/{self.repo_path}/refs", body)
+ self.assertEqual(201, resp.status_code)
+ self.assertEqual({ref: commit_oid.hex}, resp.json["created"])
+ self.assertEqual({}, resp.json["errors"])
+
+ def test_repo_create_multiple_mixed_success_and_errors(self):
+ factory = RepoFactory(self.repo_store)
+
+ expected_created = []
+ refs_to_create = []
+ commits = []
+ # Expected successful cases: 5 commits with a tag and a branch ref
+ # created against each commit.
+ for i in range(5):
+ commit = factory.add_commit(f"foo-{i}", f"foobar-{i}.txt")
+ commits.append(commit)
+ commit = commit.hex
+
+ tag = f"refs/tags/{i}"
+ refs_to_create.append({"ref": tag, "commit_sha1": commit})
+ expected_created.append((tag, commit))
+
+ branch = f"refs/heads/new-feature-{i}"
+ refs_to_create.append({"ref": branch, "commit_sha1": commit})
+ expected_created.append((branch, commit))
+
+ expected_errors = []
+ # Invalid ref name
+ invalid_name = "refs/tags/?*"
+ refs_to_create.append(
+ {
+ "ref": invalid_name,
+ "commit_sha1": commits[0].hex,
+ }
+ )
+ expected_errors.append((invalid_name, commits[0].hex))
+
+ # Commit does not exist
+ nonexistent_commit = factory.nonexistent_oid()
+ nonexistent_commit_ref = "refs/tags/nonexistent-commit"
+ refs_to_create.append(
+ {
+ "ref": nonexistent_commit_ref,
+ "commit_sha1": nonexistent_commit,
+ }
+ )
+ expected_errors.append((nonexistent_commit_ref, nonexistent_commit))
+
+ # Ref already exists and force is not passed
+ factory.add_branch("existing", commits[0])
+ existing_ref = "refs/heads/branch-existing"
+ refs_to_create.append(
+ {
+ "ref": existing_ref,
+ "commit_sha1": commits[0].hex,
+ }
+ )
+ expected_errors.append((existing_ref, commits[0].hex))
+
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/refs", refs_to_create
+ )
+ created = resp.json["created"]
+ errors = resp.json["errors"]
+
+ for entry in expected_created:
+ # Assert {ref:commit} key value pair for successful cases
+ self.assertEqual(entry[1], created[entry[0]])
+
+ self.assertEqual(
+ f"Invalid ref name '{invalid_name}'", errors[invalid_name]
+ )
+ self.assertEqual(
+ f"Commit '{nonexistent_commit}' not found",
+ errors[nonexistent_commit_ref],
+ )
+ self.assertEqual(
+ (
+ f"Ref '{existing_ref}' already exists, "
+ "retry with force to overwrite"
+ ),
+ errors[existing_ref],
+ )
+
+ def test_repo_create_refs_only_errors(self):
+ factory = RepoFactory(self.repo_store)
+ tag_name = "refs/tags/1701"
+ nonexistent_commit = factory.nonexistent_oid()
+ body = [
+ {
+ "ref": tag_name,
+ "commit_sha1": factory.nonexistent_oid(),
+ }
+ ]
+ resp = self.app.post_json(
+ f"/repo/{self.repo_path}/refs", body, expect_errors=True
+ )
+ # 400 response code if nothing is created and we have only errors
+ self.assertEqual(400, resp.status_code)
+ self.assertEqual(
+ {tag_name: f"Commit '{nonexistent_commit}' not found"},
+ resp.json["errors"],
+ )
+ self.assertEqual({}, resp.json["created"])
+
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..4eb09a0 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -8,6 +8,7 @@ import uuid
import pygit2
import yaml
+
from fixtures import EnvironmentVariable, MonkeyPatch, TempDir
from testtools import TestCase
@@ -391,6 +392,131 @@ class InitTestCase(TestCase):
packed_refs, os.path.join(too_path, "turnip-subordinate")
)
+ def test_create_single_ref(self):
+ repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+ factory = RepoFactory(repo_path)
+ commit_sha1 = factory.add_commit("foo", "foobar.txt").hex
+ tag_name = "refs/tags/1701"
+ branch_name = "refs/heads/new-feature"
+
+ for ref in [tag_name, branch_name]:
+ refs_to_create = [{"ref": ref, "commit_sha1": commit_sha1}]
+ created, _ = store.create_references(
+ self.repo_store, repo_path, refs_to_create
+ )
+ assert created[ref] == commit_sha1
+ self.assertAdvertisedRefs([(ref, commit_sha1)], [], repo_path)
+
+ def test_create_multiple_mixed_success_and_errors(self):
+ repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+ factory = RepoFactory(repo_path)
+
+ expected_created = []
+ refs_to_create = []
+ # Expected successful cases: 5 commits with a tag and a branch ref
+ # created against each commit.
+ for i in range(5):
+ commit = factory.add_commit(f"foo-{i}", f"foobar-{i}.txt").hex
+
+ tag = f"refs/tags/{i}"
+ refs_to_create.append({"ref": tag, "commit_sha1": commit})
+ expected_created.append((tag, commit))
+
+ branch = f"refs/heads/new-feature-{i}"
+ refs_to_create.append({"ref": branch, "commit_sha1": commit})
+ expected_created.append((branch, commit))
+
+ expected_errors = []
+ # Invalid ref name
+ invalid_name = "refs/tags/?*"
+ refs_to_create.append(
+ {
+ "ref": invalid_name,
+ "commit_sha1": refs_to_create[0]["commit_sha1"],
+ }
+ )
+ expected_errors.append(
+ (invalid_name, refs_to_create[0]["commit_sha1"])
+ )
+
+ # Commit does not exist
+ nonexistent_commit = factory.nonexistent_oid()
+ nonexistent_commit_ref = "refs/tags/nonexistent-commit"
+ refs_to_create.append(
+ {
+ "ref": nonexistent_commit_ref,
+ "commit_sha1": nonexistent_commit,
+ }
+ )
+ expected_errors.append((nonexistent_commit_ref, nonexistent_commit))
+
+ # Ref already exists and force is not passed
+ existing_ref = refs_to_create[0]["ref"]
+ refs_to_create.append(
+ {
+ "ref": existing_ref,
+ "commit_sha1": refs_to_create[0]["commit_sha1"],
+ }
+ )
+ expected_errors.append(
+ (existing_ref, refs_to_create[0]["commit_sha1"])
+ )
+
+ created, errors = store.create_references(
+ self.repo_store, repo_path, refs_to_create
+ )
+
+ for entry in expected_created:
+ # Assert {ref:commit} key value pair for successful cases
+ self.assertEqual(entry[1], created[entry[0]])
+
+ self.assertEqual(
+ f"Invalid ref name '{invalid_name}'", errors[invalid_name]
+ )
+ self.assertEqual(
+ f"Commit '{nonexistent_commit}' not found",
+ errors[nonexistent_commit_ref],
+ )
+ self.assertEqual(
+ (
+ f"Ref '{existing_ref}' already exists, "
+ "retry with force to overwrite"
+ ),
+ errors[existing_ref],
+ )
+
+ # Verify successful cases are created and errorneous ones are not
+ # on the git level
+ self.assertAdvertisedRefs(expected_created, expected_errors, repo_path)
+
+ def test_force_overwrite_ref(self):
+ repo_path = os.path.join(self.repo_store, uuid.uuid1().hex)
+ factory = RepoFactory(repo_path)
+ first_commit_sha1 = factory.add_commit("foo", "foobar.txt").hex
+ tag_name = "refs/tags/1701"
+ branch_name = "refs/heads/new-feature"
+
+ for ref in [tag_name, branch_name]:
+ refs_to_create = [
+ {"ref": ref, "commit_sha1": first_commit_sha1, "force": False}
+ ]
+ store.create_references(self.repo_store, repo_path, refs_to_create)
+ second_commit_oid = factory.add_commit("bar", "barbaz.txt")
+ second_commit_sha1 = second_commit_oid.hex
+ refs_to_create = [
+ {"ref": ref, "commit_sha1": second_commit_sha1, "force": True}
+ ]
+ created, _ = store.create_references(
+ self.repo_store, repo_path, refs_to_create
+ )
+
+ assert created[ref] == second_commit_sha1
+ self.assertAdvertisedRefs(
+ [(ref, second_commit_sha1)], # in refs
+ [(ref, first_commit_sha1)], # not in refs
+ repo_path,
+ )
+
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..9602df0 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -1,6 +1,7 @@
# Copyright 2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
+import json
import os
import re
@@ -264,6 +265,84 @@ class RefAPI(BaseAPI):
except (KeyError, GitError):
return exc.HTTPNotFound() # 404
+ def _validate_refs_api_payload(self, refs_to_create):
+ ref_set = set()
+ if not refs_to_create:
+ self.request.errors.add(
+ "body",
+ description="Empty request",
+ )
+ return
+
+ for ref_to_create in refs_to_create:
+ ref = ref_to_create.get("ref")
+ if not ref:
+ self.request.errors.add(
+ "body",
+ "ref",
+ "Missing ref name",
+ )
+ return False
+ if ref in ref_set:
+ self.request.errors.add(
+ "body",
+ "ref",
+ f"Duplicate ref '{ref}' in request",
+ )
+ return False
+ ref_set.add(ref)
+ commit_sha1 = ref_to_create.get("commit_sha1")
+ if not commit_sha1:
+ self.request.errors.add(
+ "body", "commit_sha1", "Missing commit_sha1 for ref target"
+ )
+ return False
+ if not (isinstance(ref, str) and ref.startswith("refs/")):
+ self.request.errors.add(
+ "body",
+ "ref",
+ "Invalid ref format. Ref must start with 'refs/'",
+ )
+ return False
+ force = ref_to_create.get("force", False)
+ if force and not isinstance(force, bool):
+ self.request.errors.add(
+ "body", "force", "'force' must be a json boolean"
+ )
+ return False
+ return True
+
+ @validate_path
+ def collection_post(self, repo_store, repo_name):
+ """Bulk create git refs against corresponding commits.
+
+ The JSON request body should be a list of creation request dicts.
+ Each dict should contain the ref name "ref" and the "commit_sha1"
+ against which to create the ref. An optional a "force" key is
+ accepted; if passed, an existing ref will be overwritten on conflict.
+
+ The JSON response body contains 2 dicts: resp.json["created"] is a dict
+ of successful requests in the form {ref:commit_sha1} and
+ respo.json["errors"] is a dict of error messages in the form
+ {ref:err_msg}.
+ """
+ refs_to_create = extract_cstruct(self.request)["body"]
+
+ if not self._validate_refs_api_payload(refs_to_create):
+ return
+
+ created, errors = store.create_references(
+ repo_store, repo_name, refs_to_create
+ )
+ resp = {"created": created, "errors": errors}
+ resp = json.dumps(resp).encode()
+
+ return (
+ Response(resp, status=201, content_type="application/json")
+ if created
+ else Response(resp, status=400, content_type="application/json")
+ )
+
@validate_path
def get(self, repo_store, repo_name):
ref = "refs/" + self.request.matchdict["ref"]
Follow ups