Page MenuHomePhabricator

"Days tickets were on project" demo

Authored By
Mhurd
Jun 7 2021, 10:12 PM
Size
15 KB
Referenced Files
None
Subscribers
None

"Days tickets were on project" demo

#!/usr/local/bin/python3
# Purpose:
# For given projects fetches un-open tickets (which were tagged with project when the ticket status became un-open).
# Calculates when tickets entered and exited the projet.
# Outputs graphs to browser showing how many days each ticket was on each project.
# Usage:
# Save this script to a file called something like `main.py`.
# See the 3 lines starting with "# CHANGEME" below.
# Invoke this script - `python3 ./main.py`.
import sys, json, subprocess, functools, operator, time, asyncio
from pprint import pprint
# CHANGEME: update this to your local directory containing 'ddd' from https://gerrit.wikimedia.org/r/plugins/gitiles/releng/ddd/
sys.path.insert(1, '/Users/montehurd/ddd')
from ddd.data import Data
from ddd.phab import Conduit
# CHANGEME: add your conduit token
phab = Conduit(phab_url = 'https://phabricator.wikimedia.org/api/', token = '')
# CHANGEME: add project names
projectNames = ['MW-1.35-notes (1.35.0-wmf.18; 2020-02-04)', 'iOS-app-Bugs']
# phab = Conduit(phab_url = 'http://phabricator.local.com/api/', token = 'api-m7f56cohcsllmw57ive52eua6u24')
# projectNames = ['Test-Project', 'Test-Project2']
from datetime import datetime, timezone
def localTimezoneDateStringFromTimeStamp(ts):
local_now = datetime.now().astimezone()
local_tz = local_now.tzinfo
return datetime.fromtimestamp(ts, tz=local_tz).strftime('%x, %r')
def getProjectForProjectTitle(title):
print(f'\n\nfetching project: "{title}"')
projectMatches = phab.request('project.search', {
'constraints': {
'query': f'title:"{title}"'
}
})
if len(projectMatches.data) < 1:
print(f'No projects found with title "{title}"')
return None
return projectMatches.data[0]
nonOpenTicketStatuses = [
'closed',
'resolved',
'stalled',
'declined',
'invalid'
]
def fetchTransactionsForTaskPHID(taskPHID):
print(f'\tfetching ticket transactions for {taskPHID}')
transactions = phab.request('transaction.search', {
'objectIdentifier': taskPHID
})
transactions.fetch_all()
return list(transactions.data)
async def fetchClosedAndResolvedTicketsWithProject(projectPHID):
print(f'\nfetching project tickets')
tickets = phab.request('maniphest.search', {
'constraints': {
'projects': [projectPHID],
'statuses': nonOpenTicketStatuses
}
})
tickets.fetch_all()
ticketsWithNonNullDateClosed = list(filter(lambda ticket: ticket['fields']['dateClosed'] != None, list(tickets.data)))
return ticketsWithNonNullDateClosed
def isTransactionOfType(transaction, typeName):
return transaction['type'] == typeName
def transactionHasOperations(transaction):
return 'operations' in transaction['fields']
def dictionaryKeyValueIntersection(dict1, dict2):
return {key: dict1[key] for key in dict1 if key in dict2 and dict2[key] == dict1[key]}
def isDictionaryInDictionary(dict1, dict2):
return dictionaryKeyValueIntersection(dict1, dict2) == dict1
def transactionHasOperationWithKeysAndValues(transaction, keysAndValues):
if not transactionHasOperations(transaction):
return False
operationWithKeysAndValues = next((operation for operation in transaction['fields']['operations'] if isDictionaryInDictionary(keysAndValues, operation)), None)
return operationWithKeysAndValues != None
def isTicketProjectTransaction(transaction, projectPHID):
return isTransactionOfType(transaction, 'projects') and (
transactionHasOperationWithKeysAndValues(transaction, {
'operation': 'add',
'phid': projectPHID
})
or
transactionHasOperationWithKeysAndValues(transaction, {
'operation': 'remove',
'phid': projectPHID
})
)
def isTicketCloseStatusTransaction(transaction):
return isTransactionOfType(transaction, 'status') and next((True for status in nonOpenTicketStatuses if 'fields' in transaction and isDictionaryInDictionary({'new': status}, transaction['fields'])), False)
def fetchAddedOrRemovedTransactions(taskPHID, projectPHID):
transactions = fetchTransactionsForTaskPHID(taskPHID)
return list(filter(lambda transaction:
isTicketCloseStatusTransaction(transaction)
or
isTicketProjectTransaction(transaction, projectPHID)
, transactions))
def ticketFirstTimeAddedToProject(transactions, projectPHID):
return next((transaction['dateModified'] for transaction in reversed(transactions) if transaction['type'] == 'projects' and transactionHasOperationWithKeysAndValues(transaction, {'operation': 'add', 'phid': projectPHID})), None)
def ticketLastTimeRemovedFromProject(transactions, projectPHID):
return next((transaction['dateModified'] for transaction in transactions if transaction['type'] == 'projects' and transactionHasOperationWithKeysAndValues(transaction, {'operation': 'remove', 'phid': projectPHID})), None)
def ticketLastTimeStatusClosed(transactions):
return next((transaction['dateModified'] for transaction in transactions if transaction['type'] == 'status' and transaction['fields']['new'] in nonOpenTicketStatuses), None)
def ticketDaysInProject(ticketPHID, projectPHID):
transactions = fetchAddedOrRemovedTransactions(ticketPHID, projectPHID)
dateEnteredProject = ticketFirstTimeAddedToProject(transactions, projectPHID)
dateExitedProject = min(ticketLastTimeRemovedFromProject(transactions, projectPHID), ticketLastTimeStatusClosed(transactions)) if ticketLastTimeRemovedFromProject(transactions, projectPHID) != None and ticketLastTimeStatusClosed(transactions) != None else ticketLastTimeRemovedFromProject(transactions, projectPHID) if ticketLastTimeStatusClosed(transactions) == None else ticketLastTimeStatusClosed(transactions)
# print(f'\ndateEnteredProject: {dateEnteredProject}\ndateExitedProject: {dateExitedProject} {dateEnteredProject < dateExitedProject}')
if dateExitedProject == None or dateEnteredProject == None:
print(f'\tNone type detected for ticket entry or exit project dates: {ticketPHID}. entry: {dateEnteredProject} exit: {dateExitedProject}')
durationInProject = dateExitedProject - dateEnteredProject if dateExitedProject != None and dateEnteredProject != None else -1
# durationInProject = dateExitedProject - dateEnteredProject
return round(durationInProject / (60 * 60 * 24), 1)
def sendToBrowser(string, extension):
filePath = f'/tmp/browser.tmp.{extension}'
f = open(filePath, 'wt', encoding='utf-8')
f.write(string)
# subprocess.run(f'open -a "Google Chrome" {filePath} --args --disable-web-security --user-data-dir=/tmp/chrome_dev_test', shell=True, check=True, text=True)
subprocess.run(f'open -a "Safari" {filePath}', shell=True, check=True, text=True)
async def jsToShowBarGraphForProject(project):
projectPHID = project['phid']
tickets = await fetchClosedAndResolvedTicketsWithProject(projectPHID)
ticketIDs = list(map(lambda ticket: f"T{ticket['id']}", tickets))
ticketTitles = list(map(lambda ticket: ticket['fields']['name'], tickets))
ticketDaysInProj = await asyncio.gather(*map(lambda ticket: asyncio.to_thread(lambda: ticketDaysInProject(ticket['phid'], projectPHID)), tickets))
for i, ticket in enumerate(tickets):
print(f'''\nT{ticketIDs[i]} - {ticket['phid']}\n"{ticketTitles[i]}"\n{ticketDaysInProj[i]} days on "{project['fields']['name']}"''')
return f'''
var titleDiv = document.createElement('div')
titleDiv.classList.add('projectTitle')
titleDiv.innerHTML = 'Project: {project['fields']['name']}'
document.querySelector('body').append(titleDiv)
showBarGraph({json.dumps(ticketIDs)}, {json.dumps(ticketDaysInProj)}, {json.dumps(ticketTitles)})
'''
def htmlToShowBarGraphsForProjects(jsToShowBarGraphsForProjects):
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="https://d3js.org/d3.v6.min.js"></script>
<style>
\* {{
font-family: sans-serif;
}}
svg {{
border: 2px solid #000;
display: block;
margin-left: auto;
margin-right: auto;
margin-bottom: 50px;
}}
.projectTitle {{
text-align: center;
font-size: 26px;
margin: 10px;
}}
.ticketLabel, .ticketLegend {{
color: #000;
fill: #000;
font-size: 16px;
}}
.bar, .daysLegend, .dayBarLabel, .dayLabel {{
font-size: 16px;
fill: green;
color: green;
}}
.dayBarLabelNegative {{
font-size: 16px;
fill: red;
color: red;
}}
.daysLegend, .ticketLegend {{
text-anchor: middle;
}}
.ticketLegend {{
transform: rotate(-90deg);
}}
div.tooltip {{
position: absolute;
text-align: center;
padding: .5rem;
background: #FFFFFF;
color: #313639;
border: 1px solid #313639;
border-radius: 8px;
pointer-events: none;
font-size: 1.3rem;
}}
.barSubtitle {{
font-size: 10px;
}}
</style>
<script>
let showBarGraph = (ticketIDs, ticketDays, ticketTitles) => {{
let toolTipDiv = d3.select('div.tooltip').node() ? d3.select('div.tooltip') : d3.select('body').append('div')
.attr('class', 'tooltip')
.style('opacity', 0)
let margin = {{top: 30, right: 80, bottom: 50, left: 100}}
let svgWidth = 820, svgHeight = (ticketIDs.length * 40) + 100
let height = svgHeight - margin.top - margin.bottom, width = svgWidth - margin.left - margin.right
let x = d3.scaleLinear().rangeRound([0, width]),
y = d3.scaleBand().rangeRound([0, height]).padding(0.35)
x.domain([0, 5 + d3.max(ticketDays, d => {{ return d }})])
y.domain(ticketIDs)
let svg = d3.select('body')
.append('svg')
svg.attr('height', svgHeight)
.attr('width', svgWidth)
svg = svg.append('g')
.attr('transform', `translate(${{margin.left}}, ${{margin.top}})`)
svg.append('g')
.attr('class' , 'dayLabel')
.attr('transform', `translate(0, ${{height}})`)
.call(d3.axisBottom(x))
svg.append('g')
.attr('class' , 'ticketLabel')
.call(d3.axisLeft(y))
let bars = svg.selectAll('.bar')
.data(ticketIDs)
.enter()
.append('g')
.on('mouseover', (event, d) => {{
// return
d3.select(this).transition()
.duration('50')
.attr('opacity', '.85')
toolTipDiv.transition()
.duration(50)
.style('opacity', 1)
toolTipDiv.html(`${{d}} - ${{ticketTitles[ticketIDs.indexOf(d)]}}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 15) + 'px')
}})
.on('mouseout', () => {{
// return
d3.select(this).transition()
.duration('50')
.attr('opacity', '1')
toolTipDiv.transition()
.duration('50')
.style('opacity', 0)
}})
let minBarWidth = 4
bars.append('rect')
.attr('class', 'bar')
.attr('x', d => {{ return 0 }})
.attr('y', d => {{ return y(d) }})
.attr('width', (d, i) => {{return Math.max(minBarWidth, x(parseInt(ticketDays[i])))}})
.attr('height', d => {{ return y.bandwidth() }})
bars.append('text')
.text((d, i) => {{
return ticketTitles[i]
}})
.attr('x', (d, i) => {{
return 3
}})
.attr('y', d => {{
return y(d) + y.bandwidth() + 9
}})
.attr('class' , 'barSubtitle')
bars.append('text')
.text((d, i) => {{
return `${{ticketDays[i]}} days`
}})
.attr('x', (d, i) => {{
return Math.max(minBarWidth, x(parseInt(ticketDays[i]))) + 5
}})
.attr('y', d => {{
return y(d) + y.bandwidth() * (0.5 + 0.1) // 0.1 padding scale
}})
.attr('class', (d, i) => {{
return ticketDays[i] >= 0 ? 'dayBarLabel' : 'dayBarLabelNegative'
}})
svg.append('text')
.attr('class', 'daysLegend')
.attr('transform', `translate(${{width / 2}}, ${{height + margin.bottom - 5}})`)
.text('Days on project')
svg.append('text')
.attr('class', 'ticketLegend')
.attr('y', 0 - margin.left)
.attr('x', 0 - (height / 2))
.attr('dy', '1em')
.text('Ticket')
}}
document.addEventListener('readystatechange', event => {{
if (event.target.readyState !== 'complete') {{
return
}}
{jsToShowBarGraphsForProjects}
}})
</script>
</head>
<body>
</body>
</html>
'''
async def main():
projects = list(map(lambda projectName: getProjectForProjectTitle(projectName), projectNames))
for project in projects:
print(f"\n{project['fields']['name']} {project['phid']}\n")
# sys.exit()
jsToShowBarGraphsForProjects = await asyncio.gather(*[jsToShowBarGraphForProject(project) for project in projects])
jsToShowBarGraphsForProjects = ''.join(jsToShowBarGraphsForProjects)
sendToBrowser(htmlToShowBarGraphsForProjects(jsToShowBarGraphsForProjects), 'tmp.html')
if __name__ == '__main__':
asyncio.run(main())

File Metadata

Mime Type
text/plain; charset=utf-8
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9093565
Default Alt Text
"Days tickets were on project" demo (15 KB)

Event Timeline