From 00895cc4d224f0ea81e869eb905d537f13eb4342 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Dec 07 2020 21:14:39 +0000 Subject: Add support for git branch aliases Branch aliases are references pointing to another references. In this case, we only support branch references though, however using this feature you can, define a branch as being an alias of another, thus allowing to have two branches pointing to the same reference/commit all the time. Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/api/project.py b/pagure/api/project.py index 4eef249..ecf831c 100644 --- a/pagure/api/project.py +++ b/pagure/api/project.py @@ -1949,6 +1949,229 @@ def api_new_branch(repo, username=None, namespace=None): return jsonout +@API.route("//git/alias/drop", methods=["POST"]) +@API.route("///git/alias/drop", methods=["POST"]) +@API.route("/fork///git/alias/drop", methods=["POST"]) +@API.route( + "/fork////git/alias/drop", methods=["POST"] +) +@api_login_required( + acls=["delete_git_alias", "modify_git_alias", "modify_project"] +) +@api_method +def api_drop_git_alias(repo, username=None, namespace=None): + """ + Delete a git branch alias + ------------------------- + Delete an existing git branch alias from a project. + + :: + + POST /api/0/rpms/python-requests/alias/drop + + + Input + ^^^^^ + + +------------------+---------+--------------+----------------------------+ + | Key | Type | Optionality | Description | + +==================+=========+==============+============================+ + | ``alias_from`` | string | Mandatory | | The origin reference the | + | | | | alias is for. | + +------------------+---------+--------------+----------------------------+ + | ``alias_to`` | string | Mandatory | | The destination reference| + | | | | of the alias (must be an | + | | | | existing branch in the | + | | | | git repository). | + +------------------+---------+--------------+----------------------------+ + + Note: while the references are listed as ``refs/heads/...`` the alias_from + and alias_to need to be specified as the basic branch name that they + are (ie: ``refs/heads/main`` needs to be specified as ``main``). + + + Sample input + ^^^^^^^^^^^^ + + :: + + { + 'alias_from': 'master', + 'alias_to': 'main' + } + + + + Sample response + ^^^^^^^^^^^^^^^ + + :: + + { + "refs/heads/rawhide": "refs/heads/main" + } + + """ + project = _get_repo(repo, username, namespace) + _check_token(project, project_token=False) + + args = get_request_data() + + alias_from = args.get("alias_from") + alias_to = args.get("alias_to") + + if ( + not alias_from + or (alias_from and not isinstance(alias_from, string_types)) + ) or ( + not alias_to or (alias_to and not isinstance(alias_to, string_types)) + ): + raise pagure.exceptions.APIError(400, error_code=APIERROR.EINVALIDREQ) + + try: + pagure.lib.git.drop_branch_aliases(project, alias_from, alias_to) + except KeyError: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EBRANCHNOTFOUND + ) + + return api_list_git_alias(repo, username, namespace) + + +@API.route("//git/alias/new", methods=["POST"]) +@API.route("///git/alias/new", methods=["POST"]) +@API.route("/fork///git/alias/new", methods=["POST"]) +@API.route( + "/fork////git/alias/new", methods=["POST"] +) +@api_login_required( + acls=["create_git_alias", "modify_git_alias", "modify_project"] +) +@api_method +def api_new_git_alias(repo, username=None, namespace=None): + """ + Create a git branch alias + ------------------------- + Create a new git branch alias in a project. + + :: + + POST /api/0/rpms/python-requests/alias/new + + + Input + ^^^^^ + + +------------------+---------+--------------+----------------------------+ + | Key | Type | Optionality | Description | + +==================+=========+==============+============================+ + | ``alias_from`` | string | Mandatory | | The origin reference the | + | | | | alias is for. | + +------------------+---------+--------------+----------------------------+ + | ``alias_to`` | string | Mandatory | | The destination reference| + | | | | of the alias (must be an | + | | | | existing branch in the | + | | | | git repository). | + +------------------+---------+--------------+----------------------------+ + + Note: while the references are listed as ``refs/heads/...`` the alias_from + and alias_to need to be specified as the basic branch name that they + are (ie: ``refs/heads/main`` needs to be specified as ``main``). + + + Sample input + ^^^^^^^^^^^^ + + :: + + { + 'alias_from': 'main', + 'alias_to': 'rawhide' + } + + + Sample response + ^^^^^^^^^^^^^^^ + + :: + + { + "refs/heads/main": "refs/heads/master" + } + + """ + project = _get_repo(repo, username, namespace) + _check_token(project, project_token=False) + + args = get_request_data() + + alias_from = args.get("alias_from") + alias_to = args.get("alias_to") + + if ( + not alias_from + or (alias_from and not isinstance(alias_from, string_types)) + ) or ( + not alias_to or (alias_to and not isinstance(alias_to, string_types)) + ): + raise pagure.exceptions.APIError( + 400, + error_code=APIERROR.EINVALIDREQ, + error="Invalid input for alias_from or alias_to", + ) + + try: + pagure.lib.git.set_branch_alias(project, alias_from, alias_to) + except KeyError: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EBRANCHNOTFOUND + ) + except pagure.exceptions.PagureException as error: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.ENOCODE, error=str(error) + ) + + return api_list_git_alias(repo, username, namespace) + + +@API.route("//git/alias") +@API.route("///git/alias") +@API.route("/fork///git/alias") +@API.route("/fork////git/alias") +@api_method +def api_list_git_alias(repo, username=None, namespace=None): + """ + List git branch alias + --------------------- + List the existing git branch alias in a project. + + :: + + GET /api/0/rpms/python-requests/alias + + + Sample response + ^^^^^^^^^^^^^^^ + + :: + + { + "refs/heads/main": "refs/heads/master" + } + + """ + project = _get_repo(repo, username, namespace) + _check_token(project, project_token=False) + + try: + output = pagure.lib.git.get_branch_aliases(project) + except pygit2.GitError: # pragma: no cover + raise pagure.exceptions.APIError(400, error_code=APIERROR.EGITERROR) + + jsonout = flask.jsonify(output) + return jsonout + + @API.route("//c//flag") @API.route("///c//flag") @API.route("/fork///c//flag") diff --git a/pagure/default_config.py b/pagure/default_config.py index df0cd6b..ff80b7f 100644 --- a/pagure/default_config.py +++ b/pagure/default_config.py @@ -358,6 +358,9 @@ ACLS = { "pull_request_rebase": "Rebase a pull-request", "tag_project": "Allows adding git tags to a project", "commit": "Commit to a git repository via http(s)", + "modify_git_alias": "Modify git aliases (create or delete)", + "create_git_alias": "Create git aliases", + "delete_git_alias": "Delete git aliases", } # List of ACLs which a regular user is allowed to associate to an API token diff --git a/pagure/lib/git.py b/pagure/lib/git.py index 90341be..636ea5d 100644 --- a/pagure/lib/git.py +++ b/pagure/lib/git.py @@ -2670,6 +2670,61 @@ def git_set_ref_head(project, branch): repo_obj.set_head(reference.name) +def get_branch_aliases(project): + """ Iterates through the references of the provided git repo to extract all + of its aliases. + """ + repo_path = pagure.utils.get_repo_path(project) + repo_obj = PagureRepo(repo_path) + + output = {} + for ref in repo_obj.listall_reference_objects(): + if "refs/heads/" in str(ref.target): + output[ref.name] = ref.target + return output + + +def set_branch_alias(project, source, dest): + """ Create a reference in the provided git repo from the source reference + to the dest one. + """ + repo_path = pagure.utils.get_repo_path(project) + repo_obj = PagureRepo(repo_path) + + # Check that the source reference exists + repo_obj.lookup_reference("refs/heads/{}".format(dest)) + + try: + repo_obj.create_reference( + "refs/heads/{}".format(source), "refs/heads/{}".format(dest), + ) + except ValueError as err: + _log.debug( + "Failed to create alias from %s to %s -- %s", source, dest, err + ) + raise pagure.exceptions.PagureException( + "Could not create alias from {0} to {1}. " + "Reference already existing?".format(source, dest) + ) + + +def drop_branch_aliases(project, source, dest): + """ Delete a reference in the provided git repo from the source reference + to the dest one. + """ + repo_path = pagure.utils.get_repo_path(project) + repo_obj = PagureRepo(repo_path) + + ref = repo_obj.lookup_reference("refs/heads/{}".format(dest)) + output = False + if ref.target == "refs/heads/{}".format(source): + ref_file = os.path.join(repo_obj.path, ref.name) + if os.path.exists(ref_file): + os.unlink(ref_file) + output = True + return output + + def delete_project_repos(project): """ Deletes the actual git repositories on disk or repoSpanner diff --git a/pagure/templates/settings.html b/pagure/templates/settings.html index 5515035..f235e0c 100644 --- a/pagure/templates/settings.html +++ b/pagure/templates/settings.html @@ -19,8 +19,8 @@
Project Settings
Project Details - Default Branch + Git Branches {% if config.get('WEBHOOK', False) %} -
-

- Default Branch -

-
-
-
- {{ branches_form.csrf_token }} - {{ branches_form.branches(class_="c-select") }} - -
-
-
+ +
+ {% include 'settings_git_branches.html' %}
{% if config.get('WEBHOOK', False) %} diff --git a/pagure/templates/settings_git_branches.html b/pagure/templates/settings_git_branches.html new file mode 100644 index 0000000..e5aaeda --- /dev/null +++ b/pagure/templates/settings_git_branches.html @@ -0,0 +1,195 @@ + +

+ Default Branch +

+
+
+
+ {{ branches_form.csrf_token }} + {{ branches_form.branches(class_="c-select") }} + +
+
+
+ +
+
+
+ +

+ Git Branch Alias +

+
+
+
+
+
+ Alias name +
+ +
+ Alias To (existing reference/branch) +
+ +
+
+
+ + {% for alias in branch_aliases %} +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+
+ {% endfor %} +
+ + Add new alias + +
+
+ + + + diff --git a/pagure/ui/repo.py b/pagure/ui/repo.py index 27c4f78..d661578 100644 --- a/pagure/ui/repo.py +++ b/pagure/ui/repo.py @@ -1292,6 +1292,8 @@ def view_settings(repo, username=None, namespace=None): ) tags = pagure.lib.query.get_tags_of_project(flask.g.session, repo) + branch_aliases = pagure.lib.git.get_branch_aliases(repo) + form = pagure.forms.ConfirmationForm() tag_form = pagure.forms.AddIssueTagForm() @@ -1357,6 +1359,7 @@ def view_settings(repo, username=None, namespace=None): plugins=plugins, branchname=branchname, pagure_admin=pagure.utils.is_admin(), + branch_aliases=branch_aliases, ) diff --git a/tests/test_pagure_admin.py b/tests/test_pagure_admin.py index b7de246..daceef3 100644 --- a/tests/test_pagure_admin.py +++ b/tests/test_pagure_admin.py @@ -2296,7 +2296,9 @@ class PagureAdminUpdateAclsTests(tests.Modeltests): "commit", "commit_flag", "create_branch", + "create_git_alias", "create_project", + "delete_git_alias", "fork_project", "generate_acls_project", "internal_access", @@ -2308,6 +2310,7 @@ class PagureAdminUpdateAclsTests(tests.Modeltests): "issue_update", "issue_update_custom_fields", "issue_update_milestone", + "modify_git_alias", "modify_project", "pull_request_assign", "pull_request_close", @@ -2344,7 +2347,9 @@ class PagureAdminUpdateAclsTests(tests.Modeltests): "commit", "commit_flag", "create_branch", + "create_git_alias", "create_project", + "delete_git_alias", "dummy_acls", "fork_project", "generate_acls_project", @@ -2357,6 +2362,7 @@ class PagureAdminUpdateAclsTests(tests.Modeltests): "issue_update", "issue_update_custom_fields", "issue_update_milestone", + "modify_git_alias", "modify_project", "pull_request_assign", "pull_request_close", diff --git a/tests/test_pagure_flask_api_project_git_alias.py b/tests/test_pagure_flask_api_project_git_alias.py new file mode 100644 index 0000000..54abe1a --- /dev/null +++ b/tests/test_pagure_flask_api_project_git_alias.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2020 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +from __future__ import unicode_literals, absolute_import + +import unittest +import shutil +import sys +import os + +import json +import pygit2 +from mock import patch, MagicMock + +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +) + +import pagure.api +import pagure.flask_app +import pagure.lib.query +import tests + + +def set_projects_up(self): + tests.create_projects(self.session) + tests.create_projects_git(os.path.join(self.path, "repos"), bare=True) + tests.add_content_git_repo(os.path.join(self.path, "repos", "test.git")) + tests.create_tokens(self.session) + tests.create_tokens_acl(self.session) + + self.session.commit() + + +def set_up_board(self): + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + + data = json.dumps({"dev": {"active": True, "tag": "dev"}}) + output = self.app.post("/api/0/test/boards", headers=headers, data=data) + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "boards": [ + { + "active": True, + "full_url": "http://localhost.localdomain/test/boards/dev", + "name": "dev", + "status": [], + "tag": { + "tag": "dev", + "tag_color": "DeepBlueSky", + "tag_description": "", + }, + } + ] + }, + ) + + +class PagureFlaskApiProjectGitAliastests(tests.SimplePagureTest): + """ Tests for flask API for branch alias in pagure """ + + maxDiff = None + + def setUp(self): + super(PagureFlaskApiProjectGitAliastests, self).setUp() + + set_projects_up(self) + self.repo_obj = pygit2.Repository( + os.path.join(self.path, "repos", "test.git") + ) + + def test_api_git_alias_view_no_project(self): + output = self.app.get("/api/0/invalid/git/alias") + self.assertEqual(output.status_code, 404) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, {"error": "Project not found", "error_code": "ENOPROJECT"} + ) + + def test_api_git_alias_view_empty(self): + output = self.app.get("/api/0/test/git/alias") + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual(data, {}) + + def test_api_new_git_alias_no_data(self): + data = "{}" + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/new", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_new_git_alias_invalid_data(self): + data = json.dumps({"dev": "foobar"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/new", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_new_git_alias_missing_data(self): + data = json.dumps({"alias_from": "mster"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/new", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_new_git_alias_no_existant_branch(self): + data = json.dumps({"alias_from": "master", "alias_to": "main"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/new", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Branch not found in this git repository", + "error_code": "EBRANCHNOTFOUND", + }, + ) + + def test_api_new_git_alias(self): + data = json.dumps({"alias_from": "main", "alias_to": "master"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/new", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual(data, {"refs/heads/main": "refs/heads/master"}) + + def test_api_drop_git_alias_no_data(self): + data = "{}" + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/drop", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_drop_git_alias_invalid_data(self): + data = json.dumps({"dev": "foobar"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/drop", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_drop_git_alias_missing_data(self): + data = json.dumps({"alias_from": "mster"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/drop", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submitted", + "error_code": "EINVALIDREQ", + }, + ) + + def test_api_drop_git_alias_no_existant_branch(self): + data = json.dumps({"alias_from": "master", "alias_to": "main"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/drop", headers=headers, data=data + ) + + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Branch not found in this git repository", + "error_code": "EBRANCHNOTFOUND", + }, + ) + + def test_api_drop_git_alias(self): + data = json.dumps({"alias_from": "main", "alias_to": "master"}) + headers = { + "Authorization": "token aaabbbcccddd", + "Content-Type": "application/json", + } + output = self.app.post( + "/api/0/test/git/alias/drop", headers=headers, data=data + ) + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual(data, {}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_pagure_lib.py b/tests/test_pagure_lib.py index 72e31c2..d9adba4 100644 --- a/tests/test_pagure_lib.py +++ b/tests/test_pagure_lib.py @@ -5667,7 +5667,9 @@ foo bar "commit", "commit_flag", "create_branch", + "create_git_alias", "create_project", + "delete_git_alias", "fork_project", "generate_acls_project", "internal_access", @@ -5679,6 +5681,7 @@ foo bar "issue_update", "issue_update_custom_fields", "issue_update_milestone", + "modify_git_alias", "modify_project", "pull_request_assign", "pull_request_close",