blob: e996b7cbdb15a5724abad67de1c9cb3a96554975 [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
import time
import datetime
import re
import json
import hashlib
import plugins.utils.jsonapi
import threading
import requests.exceptions
"""
This is the Kibble JIRA scanner plugin.
"""
title = "Scanner for Atlassian JIRA"
version = "0.1.0"
def accepts(source):
""" Determines whether we want to handle this source """
if source['type'] == 'jira':
return True
if source['type'] == "issuetracker":
jira = re.match(r"(https?://.+)/browse/([A-Z0-9]+)", url)
if jira:
return True
return False
def getTime(string):
return time.mktime(time.strptime(re.sub(r"\..*", "", str(string)), "%Y-%m-%dT%H:%M:%S"))
def assigned(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'assignee':
return True
return False
def wfi(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'status' and item['toString'] == "Waiting for Infra":
return True
return False
def wfu(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'status' and item['toString'] == "Waiting for user":
return True
return False
def moved(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'Key' and item['toString'].find("INFRA-") != -1:
return True
return False
def wasclosed(js):
if 'changelog' in js:
cjs = js['changelog']['histories']
for citem in cjs:
if 'items' in citem:
for item in citem['items']:
if item['field'] == 'status' and (item['toString'].lower().find('closed') != -1 or item['toString'].lower().find('resolved') != -1):
return (True, citem.get('author', {}))
else:
if 'items' in js:
for item in js['items']:
if item['field'] == 'status' and (item['toString'].find('Closed') != -1):
return (True, None)
return (False, None)
def resolved(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'resolution' and (item['toString'] != 'Pending Closed' and item['toString'] != 'Unresolved'):
return True
return False
def pchange(js):
if 'items' in js:
for item in js['items']:
if item['field'] == 'priority':
return True
return False
def scanTicket(KibbleBit, key, u, source, creds, openTickets):
""" Scans a single ticket for activity and people """
dhash = hashlib.sha224( ("%s-%s-%s" % (source['organisation'], source['sourceURL'], key) ).encode('ascii', errors='replace')).hexdigest()
found = True
doc= None
parseIt = False
# the 'domain' var we try to figure out here is used
# for faking email addresses and keep them unique,
# in case JIRA has email visibility turned off.
domain = 'jira'
m = re.search(r"https?://([^/]+)", u)
if m:
domain = m.group(1)
found = KibbleBit.exists('issue', dhash)
if not found:
KibbleBit.pprint("[%s] We've never seen this ticket before, parsing..." % key)
parseIt = True
else:
ticket = KibbleBit.get('issue', dhash)
if ticket['status'] == 'closed' and key in openTickets:
KibbleBit.pprint("[%s] Ticket was reopened, reparsing" % key)
parseIt = True
elif ticket['status'] == 'open' and not key in openTickets:
KibbleBit.pprint("[%s] Ticket was recently closed, parsing it" % key)
parseIt = True
else:
if ( ticket['issueCreator'] == 'unknown@kibble'
or ticket['issueCloser'] == 'unknown@kibble' ): # Gotta redo these!
parseIt = True
KibbleBit.pprint("[%s] Ticket contains erroneous data from a previous scan, reparsing" % key)
# This is just noise!
#KibbleBit.pprint("[%s] Ticket hasn't changed, ignoring..." % key)
if parseIt:
KibbleBit.pprint("[%s] Parsing data from JIRA at %s..." % (key, domain))
queryURL = "%s/rest/api/2/issue/%s?fields=creator,reporter,status,issuetype,summary,assignee,resolutiondate,created,priority,changelog,comment,resolution,votes&expand=changelog" % (u, key)
jiraURL = "%s/browse/%s" % (u, key)
try:
tjson = plugins.utils.jsonapi.get(queryURL, auth = creds)
if not tjson:
KibbleBit.pprint("%s does not exist (404'ed)" % key)
return False
except requests.exceptions.ConnectionError as err:
KibbleBit.pprint("Connection error, skipping this ticket for now!")
return False
st, closer = wasclosed(tjson)
if st and not closer:
KibbleBit.pprint("Closed but no closer??")
closerEmail = None
status = 'closed' if st else 'open'
# Make sure we actually have field data to work with
if not tjson.get('fields') or not tjson['fields'].get('created'):
KibbleBit.pprint("[%s] JIRA response is missing field data, ignoring ticket." % key)
return False
cd = getTime(tjson['fields']['created'])
rd = getTime(tjson['fields']['resolutiondate']) if 'resolutiondate' in tjson['fields'] and tjson['fields']['resolutiondate'] else None
comments = 0
if 'comment' in tjson['fields'] and tjson['fields']['comment']:
comments = tjson['fields']['comment']['total']
assignee = tjson['fields']['assignee'].get('emailAddress', # Try email, fall back to username
tjson['fields']['assignee'].get('name')) if tjson['fields'].get('assignee') else None
creator = tjson['fields']['reporter'].get('emailAddress', # Try email, fall back to username
tjson['fields']['reporter'].get('name')) if tjson['fields'].get('reporter') else None
title = tjson['fields']['summary']
if closer:
#print("Parsing closer")
closerEmail = closer.get('emailAddress', closer.get('name')).replace(" dot ", ".", 10).replace(" at ", "@", 1)
if not '@' in closerEmail:
closerEmail = '%s@%s' % (closerEmail, domain)
displayName = closer.get('displayName', 'Unkown')
if displayName and len(displayName) > 0:
# Add to people db
pid = hashlib.sha1( ("%s%s" % (source['organisation'], closerEmail)).encode('ascii', errors='replace')).hexdigest()
jsp = {
'name': displayName,
'email': closerEmail,
'organisation': source['organisation'],
'id' :pid,
'upsert': True
}
KibbleBit.append('person', jsp)
if creator:
creator = creator.replace(" dot ", ".", 10).replace(" at ", "@", 1)
if not '@' in creator:
creator = '%s@%s' % (creator, domain)
displayName = tjson['fields']['reporter']['displayName'] if tjson['fields']['reporter'] else None
if displayName and len(displayName) > 0:
# Add to people db
pid = hashlib.sha1( ("%s%s" % (source['organisation'], creator)).encode('ascii', errors='replace')).hexdigest()
jsp = {
'name': displayName,
'email': creator,
'organisation': source['organisation'],
'id' :pid,
'upsert': True
}
KibbleBit.append('person', jsp)
if assignee and not '@' in assignee:
assignee = '%s@%s' % (assignee, domain)
jso = {
'id': dhash,
'key': key,
'organisation': source['organisation'],
'sourceID': source['sourceID'],
'url': jiraURL,
'status': status,
'created': cd,
'closed': rd,
'issuetype': 'issue',
'issueCloser': closerEmail,
'createdDate': time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(cd)),
'closedDate': time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(rd)) if rd else None,
'changeDate': time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(rd if rd else cd)),
'assignee': assignee,
'issueCreator': creator,
'comments': comments,
'title': title
}
KibbleBit.append('issue', jso)
return True
#
#except Exception as err:
#KibbleBit.pprint(err)
#return False
class jiraThread(threading.Thread):
def __init__(self, block, KibbleBit, source, creds, pt, ot):
super(jiraThread, self).__init__()
self.block = block
self.KibbleBit = KibbleBit
self.creds = creds
self.source = source
self.pendingTickets = pt
self.openTickets = ot
def run(self):
badOnes = 0
while len(self.pendingTickets) > 0 and badOnes <= 50:
#print("%u elements left to count" % len(pendingTickets))
self.block.acquire()
try:
rl = self.pendingTickets.pop(0)
except Exception as err:
self.block.release()
return
if not rl:
self.block.release()
return
self.block.release()
if not scanTicket(self.KibbleBit, rl[0], rl[1], rl[2], self.creds, self.openTickets):
self.KibbleBit.pprint("[%s] This borked, trying another one" % rl[0])
badOnes += 1
if badOnes > 100:
self.KibbleBit.pprint("Too many errors, bailing!")
self.source['steps']['issues'] = {
'time': time.time(),
'status': 'Too many errors while parsing at ' + time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(time.time())),
'running': False,
'good': False
}
self.KibbleBit.updateSource(self.source)
return
else:
badOnes = 0
def scan(KibbleBit, source):
jira = re.match(r"(https?://.+)/browse/([A-Z0-9]+)", source['sourceURL'])
if jira:
# JIRA NEEDS credentials to do a proper scan!
creds = None
if source['creds'] and 'username' in source['creds'] and source['creds']['username'] and len(source['creds']['username']) > 0:
creds = "%s:%s" % (source['creds']['username'], source['creds']['password'])
if not creds:
KibbleBit.pprint("JIRA at %s requires authentication, but none was found! Bailing." % source['sourceURL'])
source['steps']['issues'] = {
'time': time.time(),
'status': 'JIRA endpoint requires auth, but none was provided!',
'running': False,
'good': False
}
KibbleBit.updateSource(source)
return
source['steps']['issues'] = {
'time': time.time(),
'status': 'Parsing JIRA changes...',
'running': True,
'good': True
}
KibbleBit.updateSource(source)
badOnes = 0
jsa = []
jsp = []
pendingTickets = []
KibbleBit.pprint("Parsing JIRA activity at %s" % source['sourceURL'])
source['steps']['issues'] = {
'time': time.time(),
'status': 'Downloading changeset',
'running': True,
'good': True
}
KibbleBit.updateSource(source)
# Get base URL, list and domain to parse
u = jira.group(1)
instance = jira.group(2)
lastTicket = 0
latestURL = "%s/rest/api/2/search?jql=project=%s+order+by+createdDate+DESC&fields=id,key&maxResults=1" % (u, instance)
js = None
try:
js = plugins.utils.jsonapi.get(latestURL, auth = creds)
except requests.exceptions.ConnectionError as err:
KibbleBit.pprint("Connection error, skipping this ticket for now!")
source['steps']['issues'] = {
'time': time.time(),
'status': 'Connection error occurred while scanning',
'running': False,
'good': False
}
KibbleBit.updateSource(source)
return
if 'issues' in js and len(js['issues']) == 1:
key = js['issues'][0]['key']
m = re.search(r"-(\d+)$", key)
if m:
lastTicket = int(m.group(1))
openTickets = []
startAt = 0
badTries = 0
while True and badTries < 10:
openURL = "%s/rest/api/2/search?jql=project=%s+and+status=open+order+by+createdDate+ASC&fields=id,key&maxResults=100&startAt=%u" % (u, instance, startAt)
#print(openURL)
try:
ojs = plugins.utils.jsonapi.get(openURL, auth = creds)
if not 'issues' in ojs or len(ojs['issues']) == 0:
break
for item in ojs['issues']:
openTickets.append(item['key'])
KibbleBit.pprint("Found %u open tickets" % len(openTickets))
startAt += 100
except:
KibbleBit.pprint("JIRA borked, retrying")
badTries += 1
KibbleBit.pprint("Found %u open tickets" % len(openTickets))
badOnes = 0
for i in reversed(range(1,lastTicket+1)):
key = "%s-%u" % (instance, i)
pendingTickets.append([key, u, source])
threads = []
block = threading.Lock()
KibbleBit.pprint("Scanning tickets using 4 sub-threads")
for i in range(0,4):
t = jiraThread(block, KibbleBit, source, creds, pendingTickets, openTickets)
threads.append(t)
t.start()
for t in threads:
t.join()
KibbleBit.pprint("Done scanning %s" % source['sourceURL'])
source['steps']['issues'] = {
'time': time.time(),
'status': 'Issue tracker (JIRA) successfully scanned at ' + time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(time.time())),
'running': False,
'good': True
}
KibbleBit.updateSource(source)