зеркало из
1
0
Форкнуть 0

Merge pull request #12 from johnlugton/stateful-sync

Two-way integration for the "sync" command
This commit is contained in:
Sebastian Bauersfeld 2021-03-18 13:20:36 +01:00 коммит произвёл GitHub
Родитель 81d2d45f89 3dab9a1ecf
Коммит 0f9533a53c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 380 добавлений и 185 удалений

Просмотреть файл

@ -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"]

Просмотреть файл

@ -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
Просмотреть файл

@ -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 -

Просмотреть файл

@ -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(),

Просмотреть файл

@ -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

162
sync.py Normal file
Просмотреть файл

@ -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
Просмотреть файл

@ -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)