From b62d75bff238c6606cc8694d38d083f29eee00e9 Mon Sep 17 00:00:00 2001 From: Or Shoval Date: Thu, 4 Nov 2021 14:31:45 +0200 Subject: [PATCH] Introduce create ticket script The script automates github issue to Jita ticket mirroring. Signed-off-by: Or Shoval --- .github/workflows/main.yml | 19 +---- .gitignore | 130 +---------------------------------- Dockerfile | 7 ++ LICENSE | 2 +- README.md | 64 ++++++++++++++++- github2jira/__init__.py | 0 github2jira/config.py | 34 +++++++++ github2jira/githublib.py | 130 +++++++++++++++++++++++++++++++++++ github2jira/jiralib.py | 106 ++++++++++++++++++++++++++++ github2jira/ticketmanager.py | 39 +++++++++++ main.py | 62 +++++++++++++++++ manifests/cronjob.yaml | 32 +++++++++ manifests/gitpod.yaml | 22 ++++++ requirements.txt | 4 ++ test_ticketmanager.py | 46 +++++++++++++ tests/__init__.py | 0 tests/data.py | 39 +++++++++++ tests/test_config.py | 56 +++++++++++++++ tests/test_github.py | 115 +++++++++++++++++++++++++++++++ tests/test_jiralib.py | 62 +++++++++++++++++ tox.ini | 83 ++++++++++++++++++++++ 21 files changed, 906 insertions(+), 146 deletions(-) create mode 100644 Dockerfile create mode 100644 github2jira/__init__.py create mode 100755 github2jira/config.py create mode 100755 github2jira/githublib.py create mode 100755 github2jira/jiralib.py create mode 100755 github2jira/ticketmanager.py create mode 100755 main.py create mode 100644 manifests/cronjob.yaml create mode 100644 manifests/gitpod.yaml create mode 100644 requirements.txt create mode 100644 test_ticketmanager.py create mode 100644 tests/__init__.py create mode 100644 tests/data.py create mode 100644 tests/test_config.py create mode 100644 tests/test_github.py create mode 100644 tests/test_jiralib.py create mode 100644 tox.ini diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9502036..113df99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,42 +1,29 @@ -# This is a basic workflow to help you get started with Actions - name: CI -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the main branch push: branches: [ main ] pull_request: branches: [ main ] - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" build: - # The type of runner that the job will run on runs-on: ubuntu-latest - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - name: Setup Python uses: actions/setup-python@v2.2.2 with: - # Version range or exact version of a Python version to use, using SemVer's version range syntax. python-version: "3.6" - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install flake8 pytest tox pylint - pip install -r requirements.txt - + pip install tox + - name: Test with tox run: | - tox + tox diff --git a/.gitignore b/.gitignore index b6e4761..825beba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,129 +1,3 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ +__pycache__ +secret*.txt .tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c52ff7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM fedora:32 + +RUN dnf install -y python3 git pip \ + && dnf clean all \ + && rm -rf /var/cache/yum + +RUN pip install requests jira diff --git a/LICENSE b/LICENSE index 261eeb9..ab58b12 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2021 Red Hat, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 121e13a..c8af31f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ # github2jira -Scrap github issues and create Jira tickets +github2jira automates mirroring of github issues to Jira tickets. + +The tool scans github for issues that match the desired criteria, +and for each one of them creates a Jira ticket (unless it already exists). + +## One time configuration +1. Create github token https://github.com/settings/tokens, refer it as `GITHUB_TOKEN` +2. Make sure you have a Jira bot access (either a user:pass or user:token), refer as `JIRA_USERNAME`,`JIRA_TOKEN` +3. Get your Jira project id, refer as `JIRA_PROJECT_ID` +`curl -s -u JIRA_USERNAME:JIRA_TOKEN -X GET -H "Content-Type: application/json" /rest/api/latest/project/ | jq .id` + +## Running manually + +1. export the following envvars: +``` +export JIRA_SERVER=<..> # for example https://nmstate.atlassian.net +export JIRA_PROJECT=<..> # name of the Jira project (ticket names are JIRA_PROJECT-#) +export JIRA_PROJECT_ID=<..> # see "One time configuration" section +export JIRA_COMPONENT=<..> # which component to set in the created tickets +export GITHUB_OWNER=<..> # the x of https://github.com/x/y +export GITHUB_REPO=<..> # the y of https://github.com/x/y +export GITHUB_LABEL=<..> # which label to filter + +export JIRA_USERNAME=<..> # see "One time configuration" section +export JIRA_TOKEN=<..> # see "One time configuration" section +export GITHUB_TOKEN=<..> # see "One time configuration" section +``` + +For Jira basic auth set JIRA_USERNAME=, JIRA_TOKEN +For Jira Personal Access Token set JIRA_USERNAME="", JIRA_TOKEN= + +2. Run `./main.py` in order to fetch github issues and create a ticket for them + +### Additional settings + +`dryrun`: Use `./main.py --dryrun` in order to run the tool in dryrun mode. +dryrun mode will fetch github issues, and report what Jira tickets it would create, +but without creating them. + +`--issue`: Use `./main.py --issue=` in order to create an issue for +a specified issue id. +No additional checks are performed in this case. + +## Running as k8s payload + +In order to have a fully automated mirroring process, +it is suggested to run the tool as a cron jon. + +One of the methods to achieve it, is to run it as k8s CronJob payload. + +### One time configuration: Build docker image for the script + +1. From the project folder, run `docker build -f Dockerfile -t .` +once its done, push it to your image repository, or rename and push to a local registry. + +### Deploy as k8s payload + +1. Create secret.txt with the exports from the section above (include the export command). + +2. Create a configmap for the txt file +`kubectl create configmap git-token --from-file=secret.txt` + +3. Deploy either a pod or a CronJob (see manifests folder). diff --git a/github2jira/__init__.py b/github2jira/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/github2jira/config.py b/github2jira/config.py new file mode 100755 index 0000000..3a65575 --- /dev/null +++ b/github2jira/config.py @@ -0,0 +1,34 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import os + + +class Config: + def __init__(self, var_names): + self._vars = {name: None for name in var_names} + + @property + def vars(self): + return self._vars + + def Load(self): + for var_name in self._vars.keys(): + value = os.getenv(var_name) + if value is None: + raise NameError(f"can't find {var_name}") + self._vars[var_name] = value diff --git a/github2jira/githublib.py b/github2jira/githublib.py new file mode 100755 index 0000000..5adda44 --- /dev/null +++ b/github2jira/githublib.py @@ -0,0 +1,130 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import time +from datetime import datetime + +import requests + +from github2jira.config import Config + +SECONDS_PER_WEEK = 7 * 24 * 60 * 60 +# max github pages to process +GITHUB_MAX_PAGES = 20 +# process upto x weeks back +MAX_DELTA_WEEKS = 4 + + +class GithubEnv: + TOKEN = "GITHUB_TOKEN" + OWNER = "GITHUB_OWNER" + REPO = "GITHUB_REPO" + LABEL = "GITHUB_LABEL" + + +_ENV_VAR_NAMES = [GithubEnv.TOKEN, GithubEnv.OWNER, GithubEnv.REPO, GithubEnv.LABEL] + + +def config(): + c = Config(_ENV_VAR_NAMES) + c.Load() + return c + + +class Issue: + def __init__(self, issue): + self._issue = issue + + @property + def repo(self): + return self._issue["html_url"].split("/")[4] + + @property + def id(self): + return self._issue["number"] + + @property + def url(self): + return self._issue["html_url"] + + @property + def title(self): + return self._issue["title"] + + @property + def epoch(self): + TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + timestamp = self._issue["created_at"] + return int(datetime.strptime(timestamp, TIME_FORMAT).timestamp()) + + @property + def raw_issue(self): + return self._issue + + @property + def labels(self): + return [l["name"] for l in self._issue["labels"]] + + def __eq__(self, other): + return self._issue == other._issue + + +class Github: + def __init__(self, cfg): + owner = cfg.vars[GithubEnv.OWNER] + repo = cfg.vars[GithubEnv.REPO] + self.query_url = f"https://api.github.com/repos/{owner}/{repo}/issues" + self.headers = {"Authorization": f"token {cfg.vars[GithubEnv.TOKEN]}"} + self.expected_label = cfg.vars[GithubEnv.LABEL] + + def issue_by_id(self, issue_id): + r = requests.get(f"{self.query_url}/{issue_id}", headers=self.headers) + issue = r.json() + if issue.get("url", None) is None: + return None + return Issue(issue) + + def issues(self): + return self._filter(self._open_issues()) + + def _filter(self, issues): + for issue in issues: + if "pull" in issue.url: + continue + + if self.expected_label in issue.labels and issue_in_time_window( + issue, MAX_DELTA_WEEKS + ): + yield issue + + def _open_issues(self): + for page in range(1, GITHUB_MAX_PAGES): + params = {"state": "open", "page": page, "per_page": "100"} + r = requests.get(self.query_url, headers=self.headers, params=params) + issues = r.json() + + if len(issues) == 0: + return + + for issue in issues: + yield Issue(issue) + + +def issue_in_time_window(issue, max_delta_weeks): + epoch = issue.epoch + epoch_time_now = int(time.time()) + return (epoch_time_now - epoch) < (max_delta_weeks * SECONDS_PER_WEEK) diff --git a/github2jira/jiralib.py b/github2jira/jiralib.py new file mode 100755 index 0000000..d756de7 --- /dev/null +++ b/github2jira/jiralib.py @@ -0,0 +1,106 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import json + +from jira import JIRA + +from github2jira.config import Config + + +class JiraEnv: + SERVER = "JIRA_SERVER" + USERNAME = "JIRA_USERNAME" + TOKEN = "JIRA_TOKEN" + PROJECT = "JIRA_PROJECT" + PROJECT_ID = "JIRA_PROJECT_ID" + COMPONENT = "JIRA_COMPONENT" + + +_ENV_VAR_NAMES = [ + JiraEnv.SERVER, + JiraEnv.USERNAME, + JiraEnv.TOKEN, + JiraEnv.PROJECT, + JiraEnv.PROJECT_ID, + JiraEnv.COMPONENT, +] + + +def config(): + c = Config(_ENV_VAR_NAMES) + c.Load() + return c + + +class Jira: + def __init__(self, cfg): + self.project = cfg.vars[JiraEnv.PROJECT] + self.project_id = cfg.vars[JiraEnv.PROJECT_ID] + self.server = cfg.vars[JiraEnv.SERVER] + self.component = cfg.vars[JiraEnv.COMPONENT] + + if cfg.vars[JiraEnv.USERNAME] != "": + self.jira = JIRA( + server=self.server, + basic_auth=(cfg.vars[JiraEnv.USERNAME], cfg.vars[JiraEnv.TOKEN]), + ) + else: + headers = JIRA.DEFAULT_OPTIONS["headers"].copy() + headers["Authorization"] = f"Bearer {cfg.vars[JiraEnv.TOKEN]}" + self.jira = JIRA(server=self.server, options={"headers": headers}) + + def issue_exists(self, git_issue): + repo = git_issue.repo + id = git_issue.id + query = f'project={self.project} AND text ~ "GITHUB:{repo}-{id}"' + issues = self.jira.search_issues(query) + return len(issues) != 0 + + def create_issue(self, git_issue): + issue_data = self._create_issue_data(git_issue) + created_issue = self.jira.create_issue(issue_data) + + issue_url = f"{self.server}/browse/{created_issue}" + print(f"Created issue {issue_url} for {git_issue.url}") + + def _create_issue_data(self, git_issue): + issue_data = { + "project": {"id": self.project_id}, + "summary": f"[GITHUB:{git_issue.repo}-{git_issue.id}] {git_issue.title}", + "description": git_issue.url, + "issuetype": {"name": "Task"}, + } + + if self.component != "": + issue_data["components"] = [{"name": self.component}] + + return issue_data + + +class DryRunJira: + def __init__(self, jira): + self.jira = jira + + def issue_exists(self, git_issue): + return self.jira.issue_exists(git_issue) + + def create_issue(self, git_issue): + json_data = json.dumps( + self.jira._create_issue_data(git_issue), sort_keys=True, indent=4 + ) + print(f"Dryrun would create the following for {git_issue.url}\n{json_data}") diff --git a/github2jira/ticketmanager.py b/github2jira/ticketmanager.py new file mode 100755 index 0000000..7fd1863 --- /dev/null +++ b/github2jira/ticketmanager.py @@ -0,0 +1,39 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +# how many tickets can be opened on each cycle +FLOOD_PROTECTION_LIMIT = 3 + + +class TicketManager: + def __init__(self, jira): + self.issues_created = 0 + self.jira = jira + + def create(self, issue): + if not self.jira.issue_exists(issue): + if self._flood_protection_reached(): + print("Flood protection reached, skipping creation of", issue.url) + return + + self.jira.create_issue(issue) + self.issues_created += 1 + else: + print("Issue for", issue.url, "already exists") + + def _flood_protection_reached(self): + return self.issues_created == FLOOD_PROTECTION_LIMIT diff --git a/main.py b/main.py new file mode 100755 index 0000000..e691cde --- /dev/null +++ b/main.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import argparse + +from github2jira.ticketmanager import TicketManager +import github2jira.jiralib as jiralib +import github2jira.githublib as githublib + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--dryrun", default=False, action="store_true") + parser.add_argument("--issue") + args = parser.parse_args() + + jira = jiralib.Jira(jiralib.config()) + + if args.dryrun: + print("INFO: dryrun mode enabled") + jira = jiralib.DryRunJira(jira) + + github = githublib.Github(githublib.config()) + ticket_manager = TicketManager(jira) + + if args.issue is None: + create_jira_tickets(github, ticket_manager) + else: + create_jira_ticket(args.issue, github, ticket_manager) + + +def create_jira_tickets(github, ticket_manager): + for issue in github.issues(): + ticket_manager.create(issue) + + +def create_jira_ticket(issue, github, ticket_manager): + issue = github.issue_by_id(issue) + if issue is not None: + ticket_manager.create(issue) + else: + print("Issue not found") + + +if __name__ == "__main__": + main() diff --git a/manifests/cronjob.yaml b/manifests/cronjob.yaml new file mode 100644 index 0000000..e1ccc5d --- /dev/null +++ b/manifests/cronjob.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: cron-github +spec: + schedule: "0 */1 * * *" + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + spec: + containers: + - name: github + image: quay.io/oshoval/github:latest + command: + - /bin/sh + - -ce + - | + source /app/secret.txt + git clone https://github.com/oshoval/github2jira.git + ./github2jira/main.py + volumeMounts: + - name: configs + mountPath: /app/secret.txt + subPath: secret.txt + restartPolicy: Never + volumes: + - name: configs + configMap: + name: git-token diff --git a/manifests/gitpod.yaml b/manifests/gitpod.yaml new file mode 100644 index 0000000..54edcd0 --- /dev/null +++ b/manifests/gitpod.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: v1 +kind: Pod +metadata: + name: github + namespace: default +spec: + containers: + - image: quay.io/oshoval/github:latest + name: github + command: + - /bin/bash + - -c + - sleep infinity + volumeMounts: + - name: configs + mountPath: /app/secret.txt + subPath: secret.txt + volumes: + - name: configs + configMap: + name: git-token diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..733e5f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +jira +pytest +responses diff --git a/test_ticketmanager.py b/test_ticketmanager.py new file mode 100644 index 0000000..a70eec3 --- /dev/null +++ b/test_ticketmanager.py @@ -0,0 +1,46 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +from github2jira.ticketmanager import TicketManager, FLOOD_PROTECTION_LIMIT +from github2jira.githublib import Issue + + +class JiraMock: + def __init__(self): + self.counter = 0 + + def issue_exists(self, git_issue): + self.counter += 1 + return self.counter % 2 != 0 + + def create_issue(self, git_issue): + return + + +def test_ticketmanager_create(): + jira = JiraMock() + ticket_manager = TicketManager(jira) + + raw_issue = {} + raw_issue["html_url"] = "dummy" + git_issue = Issue(raw_issue) + + issues_created = 0 + for i in range(FLOOD_PROTECTION_LIMIT * 3): + ticket_manager.create(git_issue) + + assert ticket_manager.issues_created == FLOOD_PROTECTION_LIMIT diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..35a1b4d --- /dev/null +++ b/tests/data.py @@ -0,0 +1,39 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import time +from datetime import datetime + +import github2jira.githublib as githublib + +PROJECT_ID = "100" +REPO = "repo" +ISSUE_ID = "10" +TITLE = "title" +COMPONENT = "dummy" + + +def raw_issue(): + ts_epoch = int(time.time()) - githublib.SECONDS_PER_WEEK * 0.5 + return { + "html_url": f"https://github.com/owner/{REPO}/issues/{ISSUE_ID}", + "number": ISSUE_ID, + "labels": [{"name": "sig/network"}, {"name": "sig/compute"}], + "title": TITLE, + "url": f"https://api.github.com/repos/owner/{REPO}/issues/{ISSUE_ID}", + "created_at": datetime.fromtimestamp(ts_epoch).strftime("%Y-%m-%dT%H:%M:%SZ"), + } diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..00e18e9 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,56 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import os +from unittest import mock + +import pytest + +import github2jira.githublib as githublib + + +def mockenv(**envvars): + return mock.patch.dict(os.environ, envvars) + + +class GithubEnvMock: + TOKEN = "dummy" + OWNER = "owner" + REPO = "repo" + LABEL = "sig/network" + + +@mockenv( + GITHUB_TOKEN=GithubEnvMock.TOKEN, + GITHUB_OWNER=GithubEnvMock.OWNER, + GITHUB_REPO=GithubEnvMock.REPO, + GITHUB_LABEL=GithubEnvMock.LABEL, +) +def test_config_load(): + cfg = githublib.config() + print(cfg) + assert cfg.vars == { + githublib.GithubEnv.TOKEN: GithubEnvMock.TOKEN, + githublib.GithubEnv.OWNER: GithubEnvMock.OWNER, + githublib.GithubEnv.REPO: GithubEnvMock.REPO, + githublib.GithubEnv.LABEL: GithubEnvMock.LABEL, + } + + +def test_config_load_fail_missing_var(): + with pytest.raises(NameError): + githublib.config() diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..3290a66 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,115 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import os +from unittest import mock + +import responses + +import github2jira.githublib as githublib +import tests.data as data + + +def mockenv(**envvars): + return mock.patch.dict(os.environ, envvars) + + +def test_issue_properties(): + raw = data.raw_issue() + issue = githublib.Issue(raw) + + assert issue.repo == data.REPO + assert issue.id == raw["number"] + assert issue.url == raw["html_url"] + assert issue.title == raw["title"] + assert issue.labels == ["sig/network", "sig/compute"] + + assert githublib.issue_in_time_window(issue, 1) + assert not githublib.issue_in_time_window(issue, 0.2) + + +@mockenv( + GITHUB_TOKEN="dummy", + GITHUB_OWNER="owner", + GITHUB_REPO="repo", + GITHUB_LABEL="sig/network", +) +@responses.activate +def test_githublib_issues(): + github = githublib.Github(githublib.config()) + + entry1 = data.raw_issue() + entry2 = data.raw_issue() + entry2["html_url"] = "https://github.com/kubevirt/kubevirt/issues/11" + entry3 = data.raw_issue() + entry3["labels"][0]["name"] = "sig/na" + entry3["html_url"] = "https://github.com/kubevirt/kubevirt/issues/12" + entry4 = data.raw_issue() + entry4["html_url"] = "https://github.com/kubevirt/kubevirt/issues/13" + + responses.add(responses.GET, github.query_url, json=[entry1, entry2], status=200) + responses.add(responses.GET, github.query_url, json=[entry3], status=200) + responses.add(responses.GET, github.query_url, json=[entry4], status=200) + responses.add(responses.GET, github.query_url, json=[], status=200) + + issues = list(github.issues()) + + assert [ + githublib.Issue(entry1).raw_issue, + githublib.Issue(entry2).raw_issue, + githublib.Issue(entry4).raw_issue, + ] == [issue.raw_issue for issue in issues] + + +@mockenv( + GITHUB_TOKEN="dummy", + GITHUB_OWNER="owner", + GITHUB_REPO="repo", + GITHUB_LABEL="sig/network", +) +@responses.activate +def test_githublib_issue_by_id_found(): + github = githublib.Github(githublib.config()) + + entry = data.raw_issue() + issue_id = 777 + responses.add( + responses.GET, f"{github.query_url}/{issue_id}", json=entry, status=200 + ) + + assert github.issue_by_id(issue_id).raw_issue == entry + + +@mockenv( + GITHUB_TOKEN="dummy", + GITHUB_OWNER="owner", + GITHUB_REPO="repo", + GITHUB_LABEL="sig/network", +) +@responses.activate +def test_githublib_issue_by_id_not_found(): + github = githublib.Github(githublib.config()) + + issue_id_na = 1 + responses.add( + responses.GET, + f"{github.query_url}/{issue_id_na}", + json={"message": "Not Found"}, + status=200, + ) + + assert github.issue_by_id(issue_id_na) is None diff --git a/tests/test_jiralib.py b/tests/test_jiralib.py new file mode 100644 index 0000000..b849f31 --- /dev/null +++ b/tests/test_jiralib.py @@ -0,0 +1,62 @@ +# This file is part of the github2jira project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Copyright 2021 Red Hat, Inc. +# + +import os +from unittest import mock + +import pytest + +from github2jira.githublib import Issue +import tests.data as data +from github2jira.jiralib import Jira, config, JiraEnv + + +def mockenv(**envvars): + return mock.patch.dict(os.environ, envvars) + + +@pytest.fixture +def JiraMock(monkeypatch): + def mock_init(self, cfg): + self.project = cfg.vars[JiraEnv.PROJECT] + self.project_id = cfg.vars[JiraEnv.PROJECT_ID] + self.server = cfg.vars[JiraEnv.SERVER] + self.component = cfg.vars[JiraEnv.COMPONENT] + self.Jira = None + + monkeypatch.setattr(Jira, "__init__", mock_init) + + +@mockenv( + JIRA_SERVER="dummy", + JIRA_USERNAME="dummy", + JIRA_TOKEN="dummy", + JIRA_PROJECT="dummy", + JIRA_PROJECT_ID=data.PROJECT_ID, + JIRA_COMPONENT=data.COMPONENT, +) +def test_jira_create_issue_data(JiraMock): + jira = Jira(config()) + issue = Issue(data.raw_issue()) + issue_data = jira._create_issue_data(issue) + assert issue_data == { + "project": {"id": data.PROJECT_ID}, + "summary": f"[GITHUB:{data.REPO}-{data.ISSUE_ID}] {data.TITLE}", + "description": f"https://github.com/owner/{data.REPO}/issues/{data.ISSUE_ID}", + "issuetype": {"name": "Task"}, + "components": [{"name": data.COMPONENT}], + } diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c41db72 --- /dev/null +++ b/tox.ini @@ -0,0 +1,83 @@ +[tox] +envlist = pytest, black, flake8, pylint, yamllint, py36, py37, py38 +skip_missing_interpreters = True +skipsdist=True + +[testenv:pytest] +deps = + -rrequirements.txt + pytest-cov==2.8.1 + pytest==5.3.1 + +commands = + pytest \ + --log-level=DEBUG \ + --durations=5 \ + --cov-report=term \ + --cov-report=xml \ + --cov-report=html:htmlcov-{envname} \ + {posargs} + +[testenv:black] +skip_install = true +py36: basepython=python3.6 +py37: basepython=python3.7 +py38: basepython=python3.8 +changedir = {toxinidir} +deps = + black==21.6b0 +# style configured via pyproject.toml +commands = + black \ + --check \ + --diff \ + {posargs} \ + ./ + +[testenv:flake8] +py36: basepython=python3.6 +py37: basepython=python3.7 +py38: basepython=python3.8 +skip_install = true +changedir = {toxinidir} +deps = + flake8==3.7.9 +commands = + flake8 \ + --statistics {posargs} \ + main.py \ + github2jira/ \ + tests/ + +[testenv:pylint] +py36: basepython=python3.6 +py37: basepython=python3.7 +py38: basepython=python3.8 +sitepackages = true +skip_install = true +changedir = {toxinidir} +deps = + -rrequirements.txt + pylint==2.4.4 +commands = + pylint \ + --errors-only \ + {posargs} \ + main.py \ + github2jira/ \ + tests/ + +[testenv:yamllint] +py36: basepython=python3.6 +py37: basepython=python3.7 +py38: basepython=python3.8 +skip_install = true +changedir = {toxinidir} +deps = + yamllint==1.23.0 +commands = + yamllint manifests/ + +[flake8] +show_source = True +max-line-length=90