Merge pull request #12 from johnlugton/stateful-sync
Two-way integration for the "sync" command
This commit is contained in:
Коммит
0f9533a53c
|
@ -2,6 +2,6 @@ FROM ubuntu:latest
|
|||
RUN apt-get update && apt-get install -y python3 python3-pip
|
||||
RUN pip3 install pipenv
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
COPY server.py cli.py gh2jira ghlib.py jiralib.py util.py Pipfile /
|
||||
COPY *.py gh2jira Pipfile /
|
||||
RUN cd / && PIPENV_VENV_IN_PROJECT=1 pipenv install
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
57
README.md
57
README.md
|
@ -67,20 +67,37 @@ In addition to the [usual requirements](#using-the-github-action) you also need:
|
|||
* a GitHub `personal access token`, so that the program can fetch alerts from your repository. Follow [this guide](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) to obtain a dedicated token. It will have to have at least the `security_events` scope.
|
||||
|
||||
```bash
|
||||
pipenv run ./gh2jira sync
|
||||
--gh-url "<INSERT GITHUB API URL>"
|
||||
--gh-token "<INSERT GITHUB PERSONAL ACCESS TOKEN>"
|
||||
--gh-org "<INSERT REPO ORGANIZATON>"
|
||||
--gh-repo "<INSERT REPO NAME>"
|
||||
--jira-url "<INSERT JIRA SERVER INSTANCE URL>"
|
||||
--jira-user "<INSERT JIRA USER>"
|
||||
--jira-token "<INSERT JIRA PASSWORD>"
|
||||
--jira-project "<INSERT JIRA PROJECT KEY>"
|
||||
pipenv run ./gh2jira sync \
|
||||
--gh-url "<INSERT GITHUB API URL>" \
|
||||
--gh-token "<INSERT GITHUB PERSONAL ACCESS TOKEN>" \
|
||||
--gh-org "<INSERT REPO ORGANIZATON>" \
|
||||
--gh-repo "<INSERT REPO NAME>" \
|
||||
--jira-url "<INSERT JIRA SERVER INSTANCE URL>" \
|
||||
--jira-user "<INSERT JIRA USER>" \
|
||||
--jira-token "<INSERT JIRA PASSWORD>" \
|
||||
--jira-project "<INSERT JIRA PROJECT KEY>" \
|
||||
--direction gh2jira
|
||||
```
|
||||
|
||||
Note: Instead of the `--gh-token` and `--jira-token` options, you may also set the `GH2JIRA_GH_TOKEN` and `GH2JIRA_JIRA_TOKEN` environment variables.
|
||||
The above command could be invoked via a cronjob every X minutes, to make sure issues and alerts are kept in sync. Currently, two-way integration is not yet possible via this command. If you need it, use the CLI's `serve` command (see below).
|
||||
Note: Instead of the `--gh-token` and `--jira-token` options, you may also set the `GH2JIRA_GH_TOKEN` and `GH2JIRA_JIRA_TOKEN` environment variables. The above command could be invoked via a cronjob every X minutes, to make sure issues and alerts are kept in sync.
|
||||
|
||||
Here an example for two-way integration:
|
||||
|
||||
```bash
|
||||
pipenv run ./gh2jira sync \
|
||||
--gh-url "<INSERT GITHUB API URL>" \
|
||||
--gh-token "<INSERT GITHUB PERSONAL ACCESS TOKEN>" \
|
||||
--gh-org "<INSERT REPO ORGANIZATON>" \
|
||||
--gh-repo "<INSERT REPO NAME>" \
|
||||
--jira-url "<INSERT JIRA SERVER INSTANCE URL>" \
|
||||
--jira-user "<INSERT JIRA USER>" \
|
||||
--jira-token "<INSERT JIRA PASSWORD>" \
|
||||
--jira-project "<INSERT JIRA PROJECT KEY>" \
|
||||
--state-file myrepository-state.json \
|
||||
--direction both
|
||||
```
|
||||
|
||||
In this case the repository's state is stored in a JSON file (which will be created if it doesn't already exist). Alternatively, the state can also be stored in a dedicated JIRA issue via `--state-issue -` (this will automatically generate and update a storage issue within the same JIRA project). If the storage issue should be in a separate JIRA project, you can specify `--state-issue KEY-OF-THE-STORAGE-ISSUE`.
|
||||
|
||||
## Using the CLI's `serve` command
|
||||
|
||||
|
@ -102,15 +119,15 @@ Second, [register a webhook on JIRA](https://developer.atlassian.com/server/jira
|
|||
Finally, start the server:
|
||||
|
||||
```bash
|
||||
pipenv run ./gh2jira serve
|
||||
--gh-url "<INSERT GITHUB API URL>"
|
||||
--gh-token "<INSERT GITHUB PERSONAL ACCESS TOKEN>"
|
||||
--jira-url "<INSERT JIRA SERVER INSTANCE URL>"
|
||||
--jira-user "<INSERT JIRA USER>"
|
||||
--jira-token "<INSERT JIRA PASSWORD>"
|
||||
--jira-project "<INSERT JIRA PROJECT KEY>"
|
||||
--secret "<INSERT WEBHOOK SECRET>"
|
||||
--port 5000
|
||||
pipenv run ./gh2jira serve \
|
||||
--gh-url "<INSERT GITHUB API URL>" \
|
||||
--gh-token "<INSERT GITHUB PERSONAL ACCESS TOKEN>" \
|
||||
--jira-url "<INSERT JIRA SERVER INSTANCE URL>" \
|
||||
--jira-user "<INSERT JIRA USER>" \
|
||||
--jira-token "<INSERT JIRA PASSWORD>" \
|
||||
--jira-project "<INSERT JIRA PROJECT KEY>" \
|
||||
--secret "<INSERT WEBHOOK SECRET>" \
|
||||
--port 5000 \
|
||||
--direction both
|
||||
```
|
||||
|
||||
|
|
|
@ -19,9 +19,9 @@ inputs:
|
|||
required: false
|
||||
default: ${{ github.token }}
|
||||
sync_direction:
|
||||
description: 'Which direction to synchronize in (gh2jira, jira2gh)'
|
||||
description: 'Which direction to synchronize in ("gh2jira", "jira2gh" or "both")'
|
||||
required: false
|
||||
default: "gh2jira"
|
||||
default: 'both'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
|
|
56
cli.py
56
cli.py
|
@ -5,6 +5,7 @@ import os
|
|||
import sys
|
||||
import json
|
||||
import util
|
||||
from sync import Sync, DIRECTION_G2J, DIRECTION_J2G, DIRECTION_BOTH
|
||||
import logging
|
||||
import server
|
||||
|
||||
|
@ -23,11 +24,11 @@ def fail(msg):
|
|||
|
||||
def direction_str_to_num(dstr):
|
||||
if dstr == 'gh2jira':
|
||||
return util.DIRECTION_G2J
|
||||
return DIRECTION_G2J
|
||||
elif dstr == 'jira2gh':
|
||||
return util.DIRECTION_J2G
|
||||
return DIRECTION_J2G
|
||||
elif dstr == 'both':
|
||||
return util.DIRECTION_BOTH
|
||||
return DIRECTION_BOTH
|
||||
else:
|
||||
fail('Unknown direction argument "{direction}"!'.format(direction=dstr))
|
||||
|
||||
|
@ -50,12 +51,12 @@ def serve(args):
|
|||
|
||||
github = ghlib.GitHub(args.gh_url, args.gh_token)
|
||||
jira = jiralib.Jira(args.jira_url, args.jira_user, args.jira_token)
|
||||
sync = util.Sync(
|
||||
s = Sync(
|
||||
github,
|
||||
jira.getProject(args.jira_project),
|
||||
direction=direction_str_to_num(args.direction)
|
||||
)
|
||||
server.run_server(sync, args.secret, port=args.port)
|
||||
server.run_server(s, args.secret, port=args.port)
|
||||
|
||||
|
||||
def sync(args):
|
||||
|
@ -79,20 +80,34 @@ def sync(args):
|
|||
|
||||
github = ghlib.GitHub(args.gh_url, args.gh_token)
|
||||
jira = jiralib.Jira(args.jira_url, args.jira_user, args.jira_token)
|
||||
util.Sync(
|
||||
jira_project = jira.getProject(args.jira_project)
|
||||
repo_id = args.gh_org + '/' + args.gh_repo
|
||||
|
||||
if args.state_file:
|
||||
if args.state_issue:
|
||||
fail('--state-file and --state-issue are mutually exclusive!')
|
||||
|
||||
state = util.state_from_file(args.state_file)
|
||||
elif args.state_issue:
|
||||
state = jira_project.fetch_repo_state(repo_id, args.state_issue)
|
||||
else:
|
||||
state = {}
|
||||
|
||||
s = Sync(
|
||||
github,
|
||||
jira.getProject(args.jira_project),
|
||||
jira_project,
|
||||
direction=direction_str_to_num(args.direction)
|
||||
).sync_repo(args.gh_org + '/' + args.gh_repo)
|
||||
)
|
||||
s.sync_repo(repo_id, states=state)
|
||||
|
||||
if args.state_file:
|
||||
util.state_to_file(args.state_file, state)
|
||||
elif args.state_issue:
|
||||
jira_project.save_repo_state(repo_id, state, args.state_issue)
|
||||
|
||||
|
||||
def check_hooks(args):
|
||||
github = ghlib.GitHub(args.gh_url, args.gh_user, args.gh_token)
|
||||
repo = github.getRepository(args.gh_org + '/' + args.gh_repo)
|
||||
#for a in repo.get_alerts():
|
||||
# print(json.dumps(a.json, indent=2))
|
||||
a = repo.get_alert(98)
|
||||
print(json.dumps(a.json, indent=2))
|
||||
pass
|
||||
|
||||
|
||||
def install_hooks(args):
|
||||
|
@ -235,6 +250,19 @@ def main():
|
|||
help='Synchronize GitHub alerts and JIRA tickets for a given repository',
|
||||
description='Synchronize GitHub alerts and JIRA tickets for a given repository'
|
||||
)
|
||||
sync_parser.add_argument(
|
||||
'--state-file',
|
||||
help='File holding the current states of all alerts. The program will create the' +
|
||||
' file if it doesn\'t exist and update it after each run.',
|
||||
default=None
|
||||
)
|
||||
sync_parser.add_argument(
|
||||
'--state-issue',
|
||||
help='The key of the issue holding the current states of all alerts. The program ' +
|
||||
'will create the issue if "-" is given as the argument. The issue will be ' +
|
||||
'updated after each run.',
|
||||
default=None
|
||||
)
|
||||
sync_parser.set_defaults(func=sync)
|
||||
|
||||
# hooks
|
||||
|
|
|
@ -9,4 +9,5 @@ cd / && pipenv run /gh2jira sync \
|
|||
--jira-user "$INPUT_JIRA_USER" \
|
||||
--jira-token "$INPUT_JIRA_TOKEN" \
|
||||
--jira-project "$INPUT_JIRA_PROJECT" \
|
||||
--direction "$INPUT_SYNC_DIRECTION"
|
||||
--direction "$INPUT_SYNC_DIRECTION" \
|
||||
--state-issue -
|
||||
|
|
8
ghlib.py
8
ghlib.py
|
@ -226,6 +226,10 @@ class GHAlert:
|
|||
self.json = json
|
||||
|
||||
|
||||
def number(self):
|
||||
return int(self.json['number'])
|
||||
|
||||
|
||||
def get_state(self):
|
||||
return parse_alert_state(self.json['state'])
|
||||
|
||||
|
@ -249,7 +253,7 @@ class GHAlert:
|
|||
logger.info(
|
||||
'{action} alert {alert_num} of repository "{repo_id}".'.format(
|
||||
action=action,
|
||||
alert_num=self.json['number'],
|
||||
alert_num=self.number(),
|
||||
repo_id=self.github_repo.repo_id
|
||||
)
|
||||
)
|
||||
|
@ -261,7 +265,7 @@ class GHAlert:
|
|||
'{api_url}/repos/{repo_id}/code-scanning/alerts/{alert_num}'.format(
|
||||
api_url=self.gh.url,
|
||||
repo_id=self.github_repo.repo_id,
|
||||
alert_num=self.json['number']
|
||||
alert_num=self.number()
|
||||
),
|
||||
data=data,
|
||||
headers=self.gh.default_headers(),
|
||||
|
|
104
jiralib.py
104
jiralib.py
|
@ -1,13 +1,10 @@
|
|||
from jira import JIRA
|
||||
import hashlib
|
||||
import re
|
||||
import util
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
# May need to be changed depending on JIRA project type
|
||||
CLOSE_TRANSITION = "Done"
|
||||
REOPEN_TRANSITION = "To Do"
|
||||
|
@ -34,6 +31,15 @@ REPOSITORY_KEY={repo_key}
|
|||
ALERT_KEY={alert_key}
|
||||
"""
|
||||
|
||||
|
||||
STATE_ISSUE_SUMMARY = '[Code Scanning Issue States]'
|
||||
STATE_ISSUE_KEY = util.make_key('gh2jira-state-issue')
|
||||
STATE_ISSUE_TEMPLATE="""
|
||||
This issue was automatically generated and contains states required for the synchronization between GitHub and JIRA.
|
||||
DO NOT MODIFY DESCRIPTION BELOW LINE.
|
||||
ISSUE_KEY={issue_key}
|
||||
""".format(issue_key=STATE_ISSUE_KEY)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -91,6 +97,31 @@ class Jira:
|
|||
return resp.json()
|
||||
|
||||
|
||||
def attach_file(self, issue_key, fname, fp):
|
||||
'''
|
||||
This function is currently needed, because the version of the 'jira' module
|
||||
we depend on (2.0.0) has a bug that makes file attachments crash for
|
||||
recent versions of python. This has been fixed in version 3.0.0, which,
|
||||
unfortunately is not yet available via pip.
|
||||
See:
|
||||
https://github.com/pycontribs/jira/issues/890
|
||||
https://github.com/pycontribs/jira/issues/985
|
||||
|
||||
TODO: Remove this function once `jira:3.0.0` is available via pip.
|
||||
'''
|
||||
resp = requests.post(
|
||||
'{api_url}/rest/api/2/issue/{issue_key}/attachments'.format(
|
||||
api_url=self.url,
|
||||
issue_key=issue_key
|
||||
),
|
||||
headers={'X-Atlassian-Token': 'no-check'},
|
||||
auth=self.auth(),
|
||||
files={'file': (fname, fp)},
|
||||
timeout=util.REQUEST_TIMEOUT
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
class JiraProject:
|
||||
def __init__(self, jira, projectkey):
|
||||
self.jira = jira
|
||||
|
@ -98,6 +129,67 @@ class JiraProject:
|
|||
self.j = self.jira.j
|
||||
|
||||
|
||||
def get_state_issue(self, issue_key='-'):
|
||||
if issue_key != '-':
|
||||
return self.j.issue(issue_key)
|
||||
|
||||
issue_search = 'project={jira_project} and description ~ "{key}"'.format(
|
||||
jira_project=self.projectkey,
|
||||
key=STATE_ISSUE_KEY
|
||||
)
|
||||
issues = list(
|
||||
filter(
|
||||
lambda i: i.fields.summary == STATE_ISSUE_SUMMARY,
|
||||
self.j.search_issues(issue_search, maxResults=0)
|
||||
)
|
||||
)
|
||||
|
||||
if len(issues) == 0:
|
||||
return self.j.create_issue(
|
||||
project=self.projectkey,
|
||||
summary=STATE_ISSUE_SUMMARY,
|
||||
description=STATE_ISSUE_TEMPLATE,
|
||||
issuetype={'name': 'Bug'}
|
||||
)
|
||||
elif len(issues) > 1:
|
||||
issues.sort(key=lambda i: i.id()) # keep the oldest issue
|
||||
for i in issues[1:]:
|
||||
i.delete()
|
||||
|
||||
i = issues[0]
|
||||
|
||||
# When fetching issues via the search_issues() function, we somehow
|
||||
# cannot access the attachments. To do that, we need to fetch the issue
|
||||
# via the issue() function first.
|
||||
return self.j.issue(i.key)
|
||||
|
||||
|
||||
def fetch_repo_state(self, repo_id, issue_key='-'):
|
||||
i = self.get_state_issue(issue_key)
|
||||
|
||||
for a in i.fields.attachment:
|
||||
if a.filename == repo_id_to_fname(repo_id):
|
||||
return util.state_from_json(a.get())
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def save_repo_state(self, repo_id, state, issue_key='-'):
|
||||
i = self.get_state_issue(issue_key)
|
||||
|
||||
# remove previous state files for the given repo_id
|
||||
for a in i.fields.attachment:
|
||||
if a.filename == repo_id_to_fname(repo_id):
|
||||
self.j.delete_attachment(a.id)
|
||||
|
||||
# attach the new state file
|
||||
self.jira.attach_file(
|
||||
i.key,
|
||||
repo_id_to_fname(repo_id),
|
||||
util.state_to_json(state)
|
||||
)
|
||||
|
||||
|
||||
def create_issue(self, repo_id, rule_id, rule_desc, alert_url, alert_num):
|
||||
raw = self.j.create_issue(
|
||||
project=self.projectkey,
|
||||
|
@ -225,7 +317,7 @@ def parse_alert_info(desc):
|
|||
m = re.search('ALERT_NUMBER=(.*)$', desc, re.MULTILINE)
|
||||
if m is None:
|
||||
return failed
|
||||
alert_num = m.group(1)
|
||||
alert_num = int(m.group(1))
|
||||
m = re.search('REPOSITORY_KEY=(.*)$', desc, re.MULTILINE)
|
||||
if m is None:
|
||||
return failed
|
||||
|
@ -245,3 +337,7 @@ def parse_alert_info(desc):
|
|||
|
||||
def parse_state(raw_state):
|
||||
return raw_state != CLOSED_STATUS
|
||||
|
||||
|
||||
def repo_id_to_fname(repo_id):
|
||||
return repo_id.replace('/', '^') + '.json'
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import os
|
||||
from flask import Flask, request, jsonify
|
||||
from flask.logging import default_handler
|
||||
import json
|
||||
|
@ -6,9 +5,7 @@ import hashlib
|
|||
import hmac
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import util
|
||||
import jiralib
|
||||
import ghlib
|
||||
import threading
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
import jiralib
|
||||
import ghlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTION_G2J = 1
|
||||
DIRECTION_J2G = 2
|
||||
DIRECTION_BOTH = 3
|
||||
|
||||
|
||||
class Sync:
|
||||
def __init__(
|
||||
self,
|
||||
github,
|
||||
jira_project,
|
||||
direction=DIRECTION_BOTH
|
||||
):
|
||||
self.github = github
|
||||
self.jira = jira_project
|
||||
self.direction = direction
|
||||
|
||||
|
||||
def alert_created(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def alert_changed(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def alert_fixed(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def issue_created(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def issue_changed(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def issue_deleted(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def sync(self, alert, issues, in_direction):
|
||||
if alert is None:
|
||||
# there is no alert, so we have to remove all issues
|
||||
# that have ever been associated with it
|
||||
for i in issues:
|
||||
i.delete()
|
||||
return None
|
||||
|
||||
# make sure that each alert has at least
|
||||
# one issue associated with it
|
||||
if len(issues) == 0:
|
||||
newissue = self.jira.create_issue(
|
||||
alert.github_repo.repo_id,
|
||||
alert.json['rule']['id'],
|
||||
alert.json['rule']['description'],
|
||||
alert.json['html_url'],
|
||||
alert.number()
|
||||
)
|
||||
newissue.adjust_state(alert.get_state())
|
||||
return alert.get_state()
|
||||
|
||||
# make sure that each alert has at max
|
||||
# one issue associated with it
|
||||
if len(issues) > 1:
|
||||
issues.sort(key=lambda i: i.id())
|
||||
for i in issues[1:]:
|
||||
i.delete()
|
||||
|
||||
issue = issues[0]
|
||||
|
||||
# make sure alert and issue are in the same state
|
||||
if self.direction & DIRECTION_G2J and self.direction & DIRECTION_J2G:
|
||||
d = in_direction
|
||||
else:
|
||||
d = self.direction
|
||||
|
||||
if d & DIRECTION_G2J or alert.is_fixed():
|
||||
# The user treats GitHub as the source of truth.
|
||||
# Also, if the alert to be synchronized is already "fixed"
|
||||
# then even if the user treats JIRA as the source of truth,
|
||||
# we have to push back the state to JIRA, because "fixed"
|
||||
# alerts cannot be transitioned to "open"
|
||||
issue.adjust_state(alert.get_state())
|
||||
return alert.get_state()
|
||||
else:
|
||||
# The user treats JIRA as the source of truth
|
||||
alert.adjust_state(issue.get_state())
|
||||
return issue.get_state()
|
||||
|
||||
|
||||
def sync_repo(self, repo_id, states=None):
|
||||
logger.info('Performing full sync on repository {repo_id}...'.format(
|
||||
repo_id=repo_id
|
||||
))
|
||||
|
||||
states = {} if states is None else states
|
||||
pairs = {}
|
||||
|
||||
# gather alerts
|
||||
for a in self.github.getRepository(repo_id).get_alerts():
|
||||
pairs[a.number()] = (a, [])
|
||||
|
||||
# gather issues
|
||||
for i in self.jira.fetch_issues(repo_id):
|
||||
_, anum, _, _ = i.get_alert_info()
|
||||
if not anum in pairs:
|
||||
pairs[anum] = (None, [])
|
||||
pairs[anum][1].append(i)
|
||||
|
||||
# remove unused states
|
||||
for k in list(states.keys()):
|
||||
if not k in pairs:
|
||||
del states[k]
|
||||
|
||||
# perform sync
|
||||
for anum, (alert, issues) in pairs.items():
|
||||
past_state = states.get(anum, None)
|
||||
if alert is None or alert.get_state() != past_state:
|
||||
d = DIRECTION_G2J
|
||||
else:
|
||||
d = DIRECTION_J2G
|
||||
|
||||
new_state = self.sync(alert, issues, d)
|
||||
|
||||
if new_state is None:
|
||||
states.pop(anum, None)
|
||||
else:
|
||||
states[anum] = new_state
|
166
util.py
166
util.py
|
@ -1,17 +1,36 @@
|
|||
import hashlib
|
||||
from requests import HTTPError
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import jiralib
|
||||
import ghlib
|
||||
import os.path
|
||||
import json
|
||||
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTION_G2J = 1
|
||||
DIRECTION_J2G = 2
|
||||
DIRECTION_BOTH = 3
|
||||
def state_from_json(s):
|
||||
# convert string keys into int keys
|
||||
# this is necessary because JSON doesn't allow
|
||||
# int keys and json.dump() automatically converts
|
||||
# int keys into string keys.
|
||||
return {int(k): v for k, v in json.loads(s).items()}
|
||||
|
||||
|
||||
def state_to_json(state):
|
||||
return json.dumps(
|
||||
state,
|
||||
indent=2,
|
||||
sort_keys=True
|
||||
)
|
||||
|
||||
|
||||
def state_from_file(fpath):
|
||||
if os.path.isfile(fpath):
|
||||
with open(fpath, 'r') as f:
|
||||
return state_from_json(f.read())
|
||||
return {}
|
||||
|
||||
|
||||
def state_to_file(fpath, state):
|
||||
with open(fpath, 'w') as f:
|
||||
f.write(state_to_json(state))
|
||||
|
||||
|
||||
def make_key(s):
|
||||
|
@ -26,132 +45,3 @@ def make_alert_key(repo_id, alert_num):
|
|||
|
||||
def json_accept_header():
|
||||
return {'Accept': 'application/vnd.github.v3+json'}
|
||||
|
||||
|
||||
class Sync:
|
||||
def __init__(self, github, jira_project, direction=DIRECTION_BOTH):
|
||||
self.github = github
|
||||
self.jira = jira_project
|
||||
self.direction = direction
|
||||
|
||||
|
||||
def alert_created(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def alert_changed(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def alert_fixed(self, repo_id, alert_num):
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_G2J
|
||||
)
|
||||
|
||||
|
||||
def issue_created(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def issue_changed(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def issue_deleted(self, desc):
|
||||
repo_id, alert_num, _, _ = jiralib.parse_alert_info(desc)
|
||||
self.sync(
|
||||
self.github.getRepository(repo_id).get_alert(alert_num),
|
||||
self.jira.fetch_issues(repo_id, alert_num),
|
||||
DIRECTION_J2G
|
||||
)
|
||||
|
||||
|
||||
def sync(self, alert, issues, in_direction):
|
||||
if alert is None:
|
||||
# there is no alert, so we have to remove all issues
|
||||
# that have ever been associated with it
|
||||
for i in issues:
|
||||
i.delete()
|
||||
return
|
||||
|
||||
# make sure that each alert has at least
|
||||
# one issue associated with it
|
||||
if len(issues) == 0:
|
||||
newissue = self.jira.create_issue(
|
||||
alert.github_repo.repo_id,
|
||||
alert.json['rule']['id'],
|
||||
alert.json['rule']['description'],
|
||||
alert.json['html_url'],
|
||||
alert.json['number']
|
||||
)
|
||||
newissue.adjust_state(alert.get_state())
|
||||
issues.append(newissue)
|
||||
|
||||
# make sure that each alert has at max
|
||||
# one issue associated with it
|
||||
if len(issues) > 1:
|
||||
issues.sort(key=lambda i: i.id())
|
||||
for i in issues[1:]:
|
||||
i.delete()
|
||||
|
||||
issue = issues[0]
|
||||
|
||||
# make sure alert and issue are in the same state
|
||||
if self.direction & DIRECTION_G2J and self.direction & DIRECTION_J2G:
|
||||
d = in_direction
|
||||
else:
|
||||
d = self.direction
|
||||
|
||||
if d & DIRECTION_G2J or alert.is_fixed():
|
||||
# The user treats GitHub as the source of truth.
|
||||
# Also, if the alert to be synchronized is already "fixed"
|
||||
# then even if the user treats JIRA as the source of truth,
|
||||
# we have to push back the state to JIRA, because "fixed"
|
||||
# alerts cannot be transitioned to "open"
|
||||
issue.adjust_state(alert.get_state())
|
||||
else:
|
||||
# The user treats JIRA as the source of truth
|
||||
alert.adjust_state(issue.get_state())
|
||||
|
||||
|
||||
def sync_repo(self, repo_id):
|
||||
logger.info('Performing full sync on repository {repo_id}...'.format(
|
||||
repo_id=repo_id
|
||||
))
|
||||
|
||||
pairs = {}
|
||||
|
||||
# gather alerts
|
||||
for a in self.github.getRepository(repo_id).get_alerts():
|
||||
k = make_alert_key(repo_id, a.json['number'])
|
||||
pairs[k] = (a, [])
|
||||
|
||||
# gather issues
|
||||
for i in self.jira.fetch_issues(repo_id):
|
||||
_, _, _, akey = i.get_alert_info()
|
||||
if not akey in pairs:
|
||||
pairs[akey] = (None, [])
|
||||
pairs[akey][1].append(i)
|
||||
|
||||
for _, (alert, issues) in pairs.items():
|
||||
self.sync(alert, issues, DIRECTION_G2J)
|
||||
|
|
Загрузка…
Ссылка в новой задаче