Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F34478788
"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 2 2021, 9:58 PM
2021-06-02 21:58:06 (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
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-nwmqotyonlxyxsk2uhig5c5n4iho')
# 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
fetchTransactionForTaskPHID
(
taskPHID
):
print
(
f
'
\t
fetching ticket transactions for
{
taskPHID
}
'
)
transactions
=
phab
.
request
(
'transaction.search'
,
{
'objectIdentifier'
:
taskPHID
})
transactions
.
fetch_all
()
return
list
(
transactions
.
data
)
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
=
fetchTransactionForTaskPHID
(
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
)
def
jsToShowBarGraphForProject
(
project
):
projectPHID
=
project
[
'phid'
]
tickets
=
fetchClosedAndResolvedTicketsWithProject
(
projectPHID
)
ticketIDs
=
list
(
map
(
lambda
ticket
:
f
"T
{
ticket
[
'id'
]
}
"
,
tickets
))
ticketTitles
=
list
(
map
(
lambda
ticket
:
ticket
[
'fields'
][
'name'
],
tickets
))
ticketDaysInProj
=
list
(
map
(
lambda
ticket
:
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
)
}
)
'''
if
__name__
==
'__main__'
:
projects
=
list
(
map
(
lambda
projectName
:
getProjectForProjectTitle
(
projectName
),
projectNames
))
for
project
in
projects
:
print
(
f
"
\n
{
project
[
'fields'
][
'name'
]
}
{
project
[
'phid'
]
}
\n
"
)
# sys.exit()
sendToBrowser
(
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
}}
{
functools
.
reduce
(
operator
.
add
,
map
(
lambda
project
:
jsToShowBarGraphForProject
(
project
),
projects
))
}
}}
)
</script>
</head>
<body>
</body>
</html>
'''
,
'tmp.html'
)
File Metadata
Details
Attached
Mime Type
text/plain; charset=utf-8
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9088809
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