| #!/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) |
| |