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..090f38d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ # 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 +``` + +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..eac6235 --- /dev/null +++ b/github2jira/githublib.py @@ -0,0 +1,123 @@ +# 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 +import requests + +from datetime import datetime + +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 labels(self): + return [l["name"] for l in self.issue["labels"]] + + +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_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_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..0640399 --- /dev/null +++ b/github2jira/jiralib.py @@ -0,0 +1,104 @@ +# 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] + + jiraOptions = {"server": self.server} + self.jira = JIRA( + options=jiraOptions, + basic_auth=(cfg.vars[JiraEnv.USERNAME], cfg.vars[JiraEnv.TOKEN]), + ) + + 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): + repo = git_issue.repo + id = git_issue.id + title = git_issue.title + + issue_data = dict() + issue_data["project"] = {"id": self.project_id} + issue_data["summary"] = f"[GITHUB:{repo}-{id}] {title}" + issue_data["description"] = git_issue.url + issue_data["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..97bfda6 --- /dev/null +++ b/github2jira/ticketmanager.py @@ -0,0 +1,38 @@ +# 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) + else: + 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..aeba0ef --- /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: + process_issues(github, ticket_manager) + else: + process_issue(args.issue, github, ticket_manager) + + +def process_issues(github, ticket_manager): + for issue in github.issues(): + ticket_manager.create(issue) + + +def process_issue(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/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c17403f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,42 @@ +# 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) + + +@mockenv( + GITHUB_TOKEN="dummy", + GITHUB_OWNER="owner", + GITHUB_REPO="repo", + GITHUB_LABEL="sig/network", +) +def test_config_load(): + githublib.config() + + +def test_negative_config_load(): + with pytest.raises(Exception): + githublib.config() diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..84692b1 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,118 @@ +# 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 +import os + +from unittest import mock +import responses +from datetime import datetime + +import github2jira.githublib as githublib + + +def mockenv(**envvars): + return mock.patch.dict(os.environ, envvars) + + +def test_issue_properties(): + raw_issue = get_raw_issue() + issue = githublib.Issue(raw_issue) + + assert issue.repo == "repo" + assert issue.id == raw_issue["number"] + assert issue.url == raw_issue["html_url"] + assert issue.title == "title" + assert issue.labels == ["sig/network", "sig/compute"] + + assert githublib.issue_in_window(issue, 1) + assert not githublib.issue_in_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 = get_raw_issue() + entry2 = get_raw_issue() + entry2["html_url"] = "https://github.com/kubevirt/kubevirt/issues/11" + entry3 = get_raw_issue() + entry3["labels"][0]["name"] = "sig/na" + entry3["html_url"] = "https://github.com/kubevirt/kubevirt/issues/12" + entry4 = get_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 len(issues) == 3 + assert issues[0].url == "https://github.com/owner/repo/issues/10" + assert issues[1].url == "https://github.com/kubevirt/kubevirt/issues/11" + assert issues[2].url == "https://github.com/kubevirt/kubevirt/issues/13" + + +@mockenv( + GITHUB_TOKEN="dummy", + GITHUB_OWNER="owner", + GITHUB_REPO="repo", + GITHUB_LABEL="sig/network", +) +@responses.activate +def test_githublib_issue_by_id(): + github = githublib.Github(githublib.config()) + + entry1 = get_raw_issue() + issue_id = 777 + issue_id_na = 1 + responses.add( + responses.GET, f"{github.query_url}/{issue_id}", json=entry1, status=200 + ) + responses.add( + responses.GET, + f"{github.query_url}/{issue_id_na}", + json={"message": "Not Found"}, + status=200, + ) + + assert github.issue_by_id(issue_id) is not None + assert github.issue_by_id(issue_id_na) is None + + +def get_raw_issue(): + raw_issue = {} + raw_issue["html_url"] = "https://github.com/owner/repo/issues/10" + raw_issue["number"] = 10 + raw_issue["labels"] = [{"name": "sig/network"}, {"name": "sig/compute"}] + raw_issue["title"] = "title" + raw_issue["url"] = "https://api.github.com/repos/owner/repo/issues/10" + + ts_epoch = int(time.time()) - githublib.SECONDS_PER_WEEK * 0.5 + raw_issue["created_at"] = datetime.fromtimestamp(ts_epoch).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + return raw_issue 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