blob: de6223ea320e92a664d2edbb539618ec6d9725b8 [file] [log] [blame]
#!/usr/bin/env python3
# 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.
"""ASF Infrastructure Reporting Dashboard - Jira Statistics Tasks"""
import asyncio
from ..lib import config
from .. import plugins
import aiohttp
import time
import re
import asfpy.pubsub
import datetime
DEFAULT_SCAN_INTERVAL = 900 # Always run a scan every 15 minutes
DEFAULT_DISCOUNT_DELTA = 600 # Calculate weekend discounts in 10 min increments
DEFAULT_RETENTION = 120 # Only return tickets that are still open, or were updated in the last 120 days
DEFAULT_SCAN_DAYS = 90 # Scan last 90 days in a full scan. This should be, at max, 500, usually ~375 issues.
DEFAULT_SLA = { # Default (fallback) SLA
"respond": 48, # 48h to respond
"resolve": 120, # 120h to resolve
}
_cache: dict = {}
_stats: dict = {}
_scan_schedule: list = []
class JiraTicket:
def __init__(self, data):
self._data = data
self.assignee = data["fields"]["assignee"]["name"] if data["fields"]["assignee"] else None
self.status = data["fields"]["status"]["name"]
self.closed = self.status == "Closed"
self.reopened = False
self.key = data["key"]
self.project = self.key.split("-")[0]
self.url = config.reporting.jira["ticket_url"].format(**data)
self.summary = data["fields"]["summary"]
self.created_at = self.get_time(data["fields"]["created"])
self.updated_at = self.get_time(data["fields"]["updated"])
self.priority = data["fields"]["priority"]["name"]
self.author = data["fields"]["creator"] and data["fields"]["creator"]["name"] or "(nobody)" # May not exist!
self.issuetype = data["fields"]["issuetype"]["name"]
self.sla = config.reporting.jira["slas"].get(self.priority, DEFAULT_SLA)
# SLA stuff
self.first_response = 0
self.response_time = 0
self.resolve_time = 0
self.closed_at = 0
self.sla_met_respond = None # True/False if responded to at all
self.sla_met_resolve = None
self.sla_time_counted = 0
self.statuses = []
self.changelog = []
self.paused = self.issuetype in config.reporting.jira.get("no_slas", [])
# Scan all changelog entries
for changelog_entry in data.get("changelog", {}).get("histories", []):
changelog_author = (
"author" in changelog_entry and changelog_entry["author"]["name"] or "nobody"
) # May have been deleted
changelog_epoch = self.get_time(changelog_entry["created"])
self.changelog.append((changelog_author, changelog_epoch))
for item in changelog_entry.get("items", []):
field = item["field"]
if field == "assignee": # Ticket (re)assigned
# self.set_fr(changelog_epoch)
pass # Should not count as a response
elif field == "resolution": # Ticket resolved
self.set_fr(changelog_epoch)
self.closed_at = changelog_epoch
elif field == "status": # Status change
if (
self.closed_at
): # if we already logged a close, but there are new status changes, it's been reopened
self.reopened = True
if not self.statuses: # First status change, log initial status from this
self.statuses.append((item["fromString"].lower(), self.created_at))
self.statuses.append((item["toString"].lower(), changelog_epoch)) # Note change to status at time
# Scan all comments, looking for a response earlier than changelog entries
for comment in data["fields"].get("comment", {}).get("comments", []):
comment_author = comment["author"]["name"]
comment_epoch = self.get_time(comment["created"])
self.changelog.append((comment_author, comment_epoch))
if comment_author != self.author: # Comment by someone other than the ticket author
self.set_fr(comment_epoch)
break # Only need to find the first (earliest) occurrence
# Calculate time spent in WFI
times_in_wfi = []
if not self.statuses: # No status changes, WFI is assumed to be entire duration
if self.closed:
times_in_wfi.append((self.created_at, self.closed_at)) # Ticket is closed, use closed_at
else:
times_in_wfi.append((self.created_at, int(time.time()))) # Ticket is open, use $now
else:
sla_statuses_lower = [x.lower() for x in config.reporting.jira.get("sla_apply_statuses")]
previous_ts = 0
previous_is_sla = False
for status in self.statuses:
if previous_ts and previous_is_sla:
times_in_wfi.append((previous_ts, status[1])) # From previous TS to this one
previous_ts = status[1]
previous_is_sla = status[0] in sla_statuses_lower
# Not in WFI mode? pause if not paused
if not self.closed and self.statuses[-1][0] not in sla_statuses_lower:
self.paused = True
for spans in times_in_wfi:
self.sla_time_counted += self.calc_sla_duration(*spans)
if self.first_response:
self.response_time = self.calc_sla_duration(self.created_at, self.first_response)
if self.closed_at:
self.resolve_time = self.calc_sla_duration(self.created_at, self.closed_at)
# If closed or responded to, check if the duration met the SLA guides
# If not closed or responded to, check if time spent in WFI surpasses SLA guides
if self.closed:
self.sla_met_resolve = self.resolve_time <= (self.sla["resolve"] * 3600)
elif self.sla_time_counted > (self.sla["resolve"] * 3600):
self.sla_met_resolve = False
if self.first_response:
self.sla_met_respond = self.response_time <= (self.sla["respond"] * 3600)
elif self.sla_time_counted > (self.sla["respond"] * 3600):
self.sla_met_respond = False
@property
def as_dict(self):
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
@staticmethod
def calc_sla_duration(from_epoch, to_epoch):
"""Calculates the active SLA time (in seconds) between two durations, discounting weekends"""
should_discount = config.reporting.jira.get("sla_discount_weekend")
seconds_spent = to_epoch - from_epoch # Add seconds between the two transitions
if should_discount:
dt_start = datetime.datetime.utcfromtimestamp(from_epoch)
dt_end = datetime.datetime.utcfromtimestamp(to_epoch)
total_discount = 0
dt_temp = dt_start
while dt_temp < dt_end and total_discount < seconds_spent:
dt_temp += datetime.timedelta(seconds=DEFAULT_DISCOUNT_DELTA)
if (
dt_temp.weekday() in [5, 6] # Sat, Sun
or (dt_temp.weekday() == 4 and dt_temp.hour > 20) # Fri after 8pm UTC
or (dt_temp.weekday() == 0 and dt_temp.hour < 8) # Mon before 8am UTC
):
total_discount += DEFAULT_DISCOUNT_DELTA
seconds_spent -= min(seconds_spent, total_discount)
return seconds_spent
def set_fr(self, epoch):
if self.first_response:
self.first_response = min(self.first_response, epoch)
else:
self.first_response = epoch
@staticmethod
def get_time(string):
"""Converts a jira ISO timestamp to unix epoch"""
ts = time.strptime(re.sub(r"\..*", "", str(string)), "%Y-%m-%dT%H:%M:%S")
epoch_local = time.mktime(ts)
# Account for UTC offset when computer has a local TZ
utc_offset = (datetime.datetime.fromtimestamp(epoch_local) - datetime.datetime.utcfromtimestamp(epoch_local)).total_seconds()
return int(epoch_local + utc_offset)
def process_cache(issues):
if issues:
_stats.clear() # Clear stats cache if we have data, so as to remove deleted tickets
for issue in issues:
key = issue["key"]
_cache[key] = issue
ticket = JiraTicket(issue)
_stats[key] = ticket
return len(issues)
def get_issues():
deadline = time.time() - (DEFAULT_RETENTION * 86400)
return [x.as_dict for x in _stats.values() if x.closed is False or x.updated_at >= deadline]
async def jira_scan_full(days=DEFAULT_SCAN_DAYS):
"""Performs a full scan of Jira activity in the past [days] days"""
jira_scan_url = config.reporting.jira["api_url"] + "search"
jira_project = config.reporting.jira["project"]
jira_token = config.reporting.jira["token"]
params = {
"fields": "key,created,summary,status,assignee,priority,comment,creator,updated,issuetype",
"expand": "changelog",
"maxResults": "1000",
"jql": f"""project={jira_project} and (updated>=-{days}d or status!=closed)""",
}
async with aiohttp.ClientSession(headers={"Authorization": f"Bearer: {jira_token}"}) as hc:
async with hc.get(jira_scan_url, params=params) as req:
if req.status == 200:
jira_json = await req.json()
processed = process_cache(jira_json.get("issues", []))
return processed
async def scan_loop():
while True:
if _scan_schedule: # Things are scheduled for a scan
now = time.time()
print("Starting Jira scan")
processed = await jira_scan_full()
print(f"Processed {processed} tickets in {int(time.time()-now)} seconds")
_scan_schedule.pop() # pop an item, freeing up space to allocate a new scan
await asyncio.sleep(60) # Always wait 60 secs between scan checks
async def poll_loop():
"""Schedules a scan every DEFAULT_SCAN_INTERVAL seconds, plus when a pubsub event happens.
No more than two events can be scheduled at any given time (iow if a scan is running, and
we get a pubsub event, we can add one more scan to be done in the future."""
loop = asyncio.get_running_loop()
def maybe_timeout(duration):
"Use asyncio.timeout() for Py3.11; stub out for lower versions."
if hasattr(asyncio, 'timeout'):
return asyncio.timeout(duration)
import contextlib
class StubTimeout:
def reschedule(self, t):
pass
@contextlib.asynccontextmanager
async def gen_stub():
yield StubTimeout()
return gen_stub()
pubsub_url = config.reporting.jira.get("pubsub_url")
while True:
_scan_schedule.append(time.time()) # Schedule a scan
if pubsub_url:
try:
async with maybe_timeout(60) as to:
async for payload in asfpy.pubsub.listen(pubsub_url):
to.reschedule(loop.time() + 60) # Got a response, pubsub works, reschedule timeout
if "stillalive" not in payload: # Not a ping
if len(_scan_schedule) < 2:
_scan_schedule.append(time.time()) # add scan to schedule
except TimeoutError:
print("PubSub connection timed out, re-establishing")
continue
else:
await asyncio.sleep(DEFAULT_SCAN_INTERVAL)
plugins.root.register(poll_loop, scan_loop, slug="jira", title="Jira Tickets", icon="bi-bug-fill", private=True)