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