Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F34486270
"Days tickets were on project" demo
No One
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Authored By
Mhurd
Jun 7 2021, 10:12 PM
2021-06-07 22:12:07 (UTC+0)
Size
15 KB
Referenced Files
None
Subscribers
None
"Days tickets were on project" demo
View Options
#!/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\n
fetching 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
'
\t
fetching ticket transactions for
{
taskPHID
}
'
)
transactions
=
phab
.
request
(
'transaction.search'
,
{
'objectIdentifier'
:
taskPHID
})
transactions
.
fetch_all
()
return
list
(
transactions
.
data
)
async
def
fetchClosedAndResolvedTicketsWithProject
(
projectPHID
):
print
(
f
'
\n
fetching 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
'
\t
None 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
'''
\n
T
{
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
Details
Attached
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)
Attached To
Mode
P16254 "Days tickets were on project" demo
Attached
Detach File
Event Timeline
Log In to Comment