diff --git a/plugins/modules/github_repo.py b/plugins/modules/github_repo.py new file mode 120000 index 0000000000..ef55c25c2f --- /dev/null +++ b/plugins/modules/github_repo.py @@ -0,0 +1 @@ +./source_control/github/github_repo.py \ No newline at end of file diff --git a/plugins/modules/source_control/github/github_repo.py b/plugins/modules/source_control/github/github_repo.py new file mode 100644 index 0000000000..41f57469e4 --- /dev/null +++ b/plugins/modules/source_control/github/github_repo.py @@ -0,0 +1,242 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Álvaro Torres Cogollo +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: github_repo +short_description: Manage your repositories on Github +version_added: 2.2.0 +description: +- Manages Github repositories using PyGithub library. +- Authentication can be done with I(access_token) or with I(username) and I(password). +options: + username: + description: + - Username used for authentication. + - This is only needed when not using I(access_token). + type: str + required: false + password: + description: + - Password used for authentication. + - This is only needed when not using I(access_token). + type: str + required: false + access_token: + description: + - Token parameter for authentication. + - This is only needed when not using I(username) and I(password). + type: str + required: false + name: + description: + - Repository name. + type: str + required: true + description: + description: + - Description for the repository. + - This is only used when I(state) is C(present). + type: str + default: '' + required: false + private: + description: + - Whether the new repository should be private or not. + - This is only used when I(state) is C(present). + type: bool + default: no + required: false + state: + description: + - Whether the repository should exist or not. + type: str + default: present + choices: [ absent, present ] + required: false + organization: + description: + - Organization for the repository. + - When I(state) is C(present), the repository will be created in the current user profile. + type: str + required: false +requirements: +- PyGithub>=1.54 +notes: +- For Python 3, PyGithub>=1.54 should be used. +- "For Python 3.5, PyGithub==1.54 should be used. More information: U(https://pygithub.readthedocs.io/en/latest/changes.html#version-1-54-november-30-2020)." +- "For Python 2.7, PyGithub==1.45 should be used. More information: U(https://pygithub.readthedocs.io/en/latest/changes.html#version-1-45-december-29-2019)." +- Supports C(check_mode). +author: +- Álvaro Torres Cogollo (@atorrescogollo) +''' + +EXAMPLES = ''' +- name: Create a Github repository + community.general.github_repo: + access_token: mytoken + organization: MyOrganization + name: myrepo + description: "Just for fun" + private: yes + state: present + register: result + +- name: Delete the repository + community.general.github_repo: + username: octocat + password: password + organization: MyOrganization + name: myrepo + state: absent + register: result +''' + +RETURN = ''' +repo: + description: Repository information as JSON. See U(https://docs.github.com/en/rest/reference/repos#get-a-repository). + returned: success and I(state) is C(present) + type: dict +''' + +import traceback +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +import sys + +GITHUB_IMP_ERR = None +try: + from github import Github, GithubException + from github.GithubException import UnknownObjectException + HAS_GITHUB_PACKAGE = True +except Exception: + GITHUB_IMP_ERR = traceback.format_exc() + HAS_GITHUB_PACKAGE = False + + +def authenticate(username=None, password=None, access_token=None): + if access_token: + return Github(base_url="https://api.github.com:443", login_or_token=access_token) + else: + return Github(base_url="https://api.github.com:443", login_or_token=username, password=password) + + +def create_repo(gh, name, organization=None, private=False, description='', check_mode=False): + result = dict( + changed=False, + repo=dict()) + if organization: + target = gh.get_organization(organization) + else: + target = gh.get_user() + + repo = None + try: + repo = target.get_repo(name=name) + result['repo'] = repo.raw_data + except UnknownObjectException: + if not check_mode: + repo = target.create_repo( + name=name, private=private, description=description) + result['repo'] = repo.raw_data + + result['changed'] = True + + changes = {} + if repo is None or repo.raw_data['private'] != private: + changes['private'] = private + if repo is None or repo.raw_data['description'] != description: + changes['description'] = description + + if changes: + if not check_mode: + repo.edit(**changes) + + result['repo'].update({ + 'private': repo._private.value if not check_mode else private, + 'description': repo._description.value if not check_mode else description, + }) + result['changed'] = True + + return result + + +def delete_repo(gh, name, organization=None, check_mode=False): + result = dict(changed=False) + if organization: + target = gh.get_organization(organization) + else: + target = gh.get_user() + try: + repo = target.get_repo(name=name) + if not check_mode: + repo.delete() + result['changed'] = True + except UnknownObjectException: + pass + + return result + + +def run_module(params, check_mode=False): + gh = authenticate( + username=params['username'], password=params['password'], access_token=params['access_token']) + if params['state'] == "absent": + return delete_repo( + gh=gh, + name=params['name'], + organization=params['organization'], + check_mode=check_mode + ) + else: + return create_repo( + gh=gh, + name=params['name'], + organization=params['organization'], + private=params['private'], + description=params['description'], + check_mode=check_mode + ) + + +def main(): + module_args = dict( + username=dict(type='str', required=False, default=None), + password=dict(type='str', required=False, default=None, no_log=True), + access_token=dict(type='str', required=False, + default=None, no_log=True), + name=dict(type='str', required=True), + state=dict(type='str', required=False, default="present", + choices=["present", "absent"]), + organization=dict(type='str', required=False, default=None), + private=dict(type='bool', required=False, default=False), + description=dict(type='str', required=False, default=''), + ) + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + required_together=[('username', 'password')], + required_one_of=[('username', 'access_token')], + mutually_exclusive=[('username', 'access_token')] + ) + + if not HAS_GITHUB_PACKAGE: + module.fail_json(msg=missing_required_lib( + "PyGithub"), exception=GITHUB_IMP_ERR) + + try: + result = run_module(module.params, module.check_mode) + module.exit_json(**result) + except GithubException as e: + module.fail_json(msg="Github error. {0}".format(repr(e))) + except Exception as e: + module.fail_json(msg="Unexpected error. {0}".format(repr(e))) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/source_control/github/__init__.py b/tests/unit/plugins/modules/source_control/github/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/plugins/modules/source_control/github/test_github_repo.py b/tests/unit/plugins/modules/source_control/github/test_github_repo.py new file mode 100644 index 0000000000..8d41c986b4 --- /dev/null +++ b/tests/unit/plugins/modules/source_control/github/test_github_repo.py @@ -0,0 +1,252 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import requests +import re +import json +import sys +from httmock import with_httmock, urlmatch, response +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.plugins.modules.source_control.github import github_repo + +GITHUB_MINIMUM_PYTHON_VERSION = (2, 7) + + +@urlmatch(netloc=r'.*') +def debug_mock(url, request): + print(request.original.__dict__) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/orgs/.*', method="get") +def get_orgs_mock(url, request): + match = re.search(r"api\.github\.com:443/orgs/(?P[^/]+)", request.url) + org = match.group("org") + + # https://docs.github.com/en/rest/reference/orgs#get-an-organization + headers = {'content-type': 'application/json'} + content = { + "login": org, + "url": "https://api.github.com:443/orgs/{0}".format(org) + } + content = json.dumps(content).encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/user', method="get") +def get_user_mock(url, request): + # https://docs.github.com/en/rest/reference/users#get-the-authenticated-user + headers = {'content-type': 'application/json'} + content = { + "login": "octocat", + "url": "https://api.github.com:443/users/octocat" + } + content = json.dumps(content).encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/repos/.*/.*', method="get") +def get_repo_notfound_mock(url, request): + return response(404, "{\"message\": \"Not Found\"}", "", "Not Found", 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/repos/.*/.*', method="get") +def get_repo_mock(url, request): + match = re.search( + r"api\.github\.com:443/repos/(?P[^/]+)/(?P[^/]+)", request.url) + org = match.group("org") + repo = match.group("repo") + + # https://docs.github.com/en/rest/reference/repos#get-a-repository + headers = {'content-type': 'application/json'} + content = { + "name": repo, + "full_name": "{0}/{1}".format(org, repo), + "url": "https://api.github.com:443/repos/{0}/{1}".format(org, repo), + "private": False, + "description": "This your first repo!", + "default_branch": "master", + "allow_rebase_merge": True + } + content = json.dumps(content).encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/orgs/.*/repos', method="post") +def create_new_org_repo_mock(url, request): + match = re.search( + r"api\.github\.com:443/orgs/(?P[^/]+)/repos", request.url) + org = match.group("org") + repo = json.loads(request.body) + + headers = {'content-type': 'application/json'} + # https://docs.github.com/en/rest/reference/repos#create-an-organization-repository + content = { + "name": repo['name'], + "full_name": "{0}/{1}".format(org, repo['name']), + "private": repo['private'], + "description": repo['description'] + } + content = json.dumps(content).encode("utf-8") + return response(201, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/user/repos', method="post") +def create_new_user_repo_mock(url, request): + repo = json.loads(request.body) + + headers = {'content-type': 'application/json'} + # https://docs.github.com/en/rest/reference/repos#create-a-repository-for-the-authenticated-user + content = { + "name": repo['name'], + "full_name": "{0}/{1}".format("octocat", repo['name']), + "private": repo['private'], + "description": repo['description'] + } + content = json.dumps(content).encode("utf-8") + return response(201, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/repos/.*/.*', method="patch") +def patch_repo_mock(url, request): + match = re.search( + r"api\.github\.com:443/repos/(?P[^/]+)/(?P[^/]+)", request.url) + org = match.group("org") + repo = match.group("repo") + + body = json.loads(request.body) + headers = {'content-type': 'application/json'} + # https://docs.github.com/en/rest/reference/repos#update-a-repository + content = { + "name": repo, + "full_name": "{0}/{1}".format(org, repo), + "url": "https://api.github.com:443/repos/{0}/{1}".format(org, repo), + "private": body['private'], + "description": body['description'], + "default_branch": "master", + "allow_rebase_merge": True + } + content = json.dumps(content).encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/repos/.*/.*', method="delete") +def delete_repo_mock(url, request): + # https://docs.github.com/en/rest/reference/repos#delete-a-repository + return response(204, None, None, None, 5, request) + + +@urlmatch(netloc=r'api\.github\.com:443$', path=r'/repos/.*/.*', method="delete") +def delete_repo_notfound_mock(url, request): + # https://docs.github.com/en/rest/reference/repos#delete-a-repository + return response(404, "{\"message\": \"Not Found\"}", "", "Not Found", 5, request) + + +class TestGithubRepo(unittest.TestCase): + def setUp(self): + if sys.version_info < GITHUB_MINIMUM_PYTHON_VERSION: + self.skipTest("Python %s+ is needed for PyGithub" % + ",".join(map(str, GITHUB_MINIMUM_PYTHON_VERSION))) + + @with_httmock(get_orgs_mock) + @with_httmock(get_repo_notfound_mock) + @with_httmock(create_new_org_repo_mock) + def test_create_new_org_repo(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": "MyOrganization", + "name": "myrepo", + "description": "Just for fun", + "private": False, + "state": "present" + }) + + self.assertEqual(result['changed'], True) + self.assertEqual(result['repo']['private'], False) + + @with_httmock(get_user_mock) + @with_httmock(get_repo_notfound_mock) + @with_httmock(create_new_user_repo_mock) + def test_create_new_user_repo(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": None, + "name": "myrepo", + "description": "Just for fun", + "private": True, + "state": "present" + }) + self.assertEqual(result['changed'], True) + self.assertEqual(result['repo']['private'], True) + + @with_httmock(get_orgs_mock) + @with_httmock(get_repo_mock) + @with_httmock(patch_repo_mock) + def test_patch_existing_org_repo(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": "MyOrganization", + "name": "myrepo", + "description": "Just for fun", + "private": True, + "state": "present" + }) + self.assertEqual(result['changed'], True) + self.assertEqual(result['repo']['private'], True) + + @with_httmock(get_orgs_mock) + @with_httmock(get_repo_mock) + @with_httmock(delete_repo_mock) + def test_delete_org_repo(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": "MyOrganization", + "name": "myrepo", + "description": "Just for fun", + "private": False, + "state": "absent" + }) + self.assertEqual(result['changed'], True) + + @with_httmock(get_user_mock) + @with_httmock(get_repo_mock) + @with_httmock(delete_repo_mock) + def test_delete_user_repo(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": None, + "name": "myrepo", + "description": "Just for fun", + "private": False, + "state": "absent" + }) + self.assertEqual(result['changed'], True) + + @with_httmock(get_orgs_mock) + @with_httmock(get_repo_notfound_mock) + @with_httmock(delete_repo_notfound_mock) + def test_delete_org_repo_notfound(self): + result = github_repo.run_module({ + 'username': None, + 'password': None, + "access_token": "mytoken", + "organization": "MyOrganization", + "name": "myrepo", + "description": "Just for fun", + "private": True, + "state": "absent" + }) + self.assertEqual(result['changed'], False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 968820807d..1d082cffb8 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -11,8 +11,9 @@ redis linode-python # APIv3 linode_api4 ; python_version > '2.6' # APIv4 -# requirement for the gitlab module +# requirement for the gitlab and github modules python-gitlab +PyGithub httmock # requirement for maven_artifact module