-
-
Save montehurd/40ab4dd5cc81b66d78aaaa46752b1f5f to your computer and use it in GitHub Desktop.
Example use of 2 Phabricator APIs. Executes a saved task query, extracts portions of each result, modifies them, converts them to HTML and sends them to Safari for instant preview. Approach used below is for purpose of quick prototyping. Tested on macOS, where you can simply paste the *entire* example into Terminal and press return:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# Edit: This code is just horrible, but it was early in my shell scripting journey. | |
# This script does the following: | |
# | |
# Hits the "maniphest.search" API using a saved query's "query key": | |
# Any query key may be used. Simply construct your query using Phabricator's "Advanced Search" interface: | |
# https://phabricator.wikimedia.org/maniphest/query/advanced/ | |
# When you have it fetching what you want, use the "Save Query" button, then grab the saved query's key from its URL. | |
# Presently it's using this query I constructed for testing purposes: | |
# https://phabricator.wikimedia.org/maniphest/query/8Nu74C_eapwI | |
# | |
# Transforms the results a bit: | |
# Chooses a few fields from each result and adds a little "remarkup" to each result's ticket description remarkup. | |
# | |
# Hits the "remarkup.process" API: | |
# Converts our transformed remarkup to HTML. | |
# | |
# Wraps results in some structural HTML: | |
# Ensures things like links and styles work. | |
# | |
# Sends everthing to a browser: | |
# So you can instantly see the results. | |
# Settings: | |
BASE_URL="https://phabricator.wikimedia.org" | |
API_TOKEN="api-2lfamy3hsjzihfo6jwmnbd6lphn2" | |
QUERY_KEY="8Nu74C_eapwI" | |
# Result transformation script: | |
read -r -d '' RESULT_TRANSFORMATION << '--END' | |
import sys, json, urllib; | |
resultDicts = json.load(sys.stdin)['result']['data']; | |
remarkup = '\n{F31669139 size=full}\n'.join(list(map(lambda item: | |
'''== T{number} == | |
== {name} == | |
{description} | |
''' | |
.format( | |
number=item['id'], | |
name=item['fields']['name'].encode('utf-8'), | |
priority=item['fields']['priority']['name'].encode('utf-8'), | |
description=item['fields']['description']['raw'].encode('utf-8') | |
), resultDicts))); | |
encodedRemarkup = urllib.quote_plus(remarkup); | |
print('contents[0]=' + encodedRemarkup); | |
--END | |
# Page presentation script: | |
read -r -d '' PAGE_PRESENTATION << --END | |
import sys, json; | |
html = json.load(sys.stdin)['result'][0]['content']; | |
print(''' | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="UTF-8"> | |
<link rel="stylesheet" type="text/css" href="https://phab.wmfusercontent.org/res/defaultX/phabricator/31c46c5c/core.pkg.css"> | |
<base href="$BASE_URL" target="_blank"> | |
</head> | |
<body style="padding:20pt"> | |
<div class="phabricator-remarkup"> | |
{html} | |
</div> | |
</body> | |
</html> | |
'''.format(html=html)); | |
--END | |
# Use the query key to hit the "maniphest.search" API: | |
curl -s "$BASE_URL/api/maniphest.search" \ | |
-d "api.token=$API_TOKEN" \ | |
-d "order=priority" \ | |
-d "queryKey=$QUERY_KEY" | | |
# Pipe the results to Python for processing: | |
# - Extracts each result | |
# - Lightly transforms each result to pretty things up by joining each phab ticket number, title ("name") and description (which is remarkup https://secure.phabricator.com/book/phabricator/article/remarkup/) around a remarkup spacer ("F31669139") | |
# This "RESULT_TRANSFORMATION" script would be the place to extract some sought description remarkup sub-string if we wanted to further mine/reduce tickets in some way. | |
python -c "$RESULT_TRANSFORMATION" | | |
# Pipe our remixed remarkup to "remarkup.process" to convert it to HTML: | |
curl -s "$BASE_URL/api/remarkup.process" \ | |
-d "api.token=$API_TOKEN" \ | |
-d "context=phriction" \ | |
-d @- | | |
# Extract HTML, add stylesheet and base URL so it looks nice and links work, then send all of it to a browser so we can see it: | |
python -c "$PAGE_PRESENTATION" > /tmp/phabricator.tmp.html && open -a "Safari" /tmp/phabricator.tmp.html | |
# To see html as text use line below instead of line above: | |
# python -c "$PAGE_PRESENTATION" | open -a "Safari" -f | |
Generate and use your own API token
If you replace the QutieBot
API token api-2lfamy3hsjzihfo6jwmnbd6lphn2
in the script above with your own token string, ticket status changes made with the script will be credited to you vs QutieBot
.
To generate an API token:
- load https://phabricator.wikimedia.org
- tap your user icon in the upper right
- select
Settings
- after Settings load, select
Conduit API Tokens
at the bottom of the left menu - tap the
+ Generate Token
button
Once you have your token, the API_TOKEN
line in the script is where you'd use it instead (of api-2lfamy3hsjzihfo6jwmnbd6lphn2
).
A version of the script with functional "Quick options" buttons for Status and Priority, with Comment support.
This script also moves all fetching and processing into Python.
#!/bin/bash
BASE_URL="https://phabricator.wikimedia.org"
API_TOKEN="api-2lfamy3hsjzihfo6jwmnbd6lphn2"
QUERY_KEY="F1GAHgzz693c"
# The query:
# https://phabricator.wikimedia.org/maniphest/query/F1GAHgzz693c/
# https://stackoverflow.com/a/44828706
read -r -d '' PYTHON_SCRIPT << --END
import sys, json, urllib, urllib2, base64, re;
def fetch(url, values):
data = urllib.urlencode(values)
request = urllib2.Request(url, data)
response = urllib2.urlopen(request)
page = response.read()
return json.loads(page.decode('utf-8'))
# Query the API:
searchResultsJSON = fetch("$BASE_URL/api/maniphest.search", {
'api.token' : '$API_TOKEN',
'order' : 'priority',
'queryKey' : '$QUERY_KEY'
})
resultDicts = searchResultsJSON["result"]["data"]
# Dict with ticketID as key and ticketJSON as value.
ticketsData = dict((x["id"], x) for x in resultDicts)
# Extracts and transforms tickets' remarkup (see: https://secure.phabricator.com/book/phabricator/article/remarkup/).
remarkup = ''.join(list(map(lambda item:
'''TICKET_START:{number}:
= T{number} =
== {name} ==
{description}
TICKET_END'''
.format(
number=item['id'],
name=item['fields']['name'].encode('utf-8'),
description=item['fields']['description']['raw'].encode('utf-8')
), resultDicts)))
# Hit "remarkup.process" to convert our remixed remarkup to HTML:
remarkupResultsJSON = fetch("$BASE_URL/api/remarkup.process", {
'api.token' : '$API_TOKEN',
'context' : 'phriction',
'contents[0]' : remarkup
})
# Extract HTML, add menu, stylesheet and base URL so it looks nice and links work, then send all of it to a browser so we can see it:
menusData = [
{
'endpoint': 'maniphest.edit',
'key': 'status',
'names': ['Open', 'Resolved', 'Declined', 'Stalled', 'Invalid'],
'values': ['open', 'resolved', 'declined', 'stalled', 'invalid']
},
{
'endpoint': 'maniphest.edit',
'key': 'priority',
'names': ['Unbreak Now!', 'Needs Triage', 'High', 'Medium', 'Low', 'Lowest'],
'values': ['unbreak', 'triage', 'high', 'medium', 'low', 'lowest']
}
]
def buttonHTML(ticketID, endpoint, key, name, value, isSelected):
classString = "class='selected'" if isSelected else ""
return '''\n<button id="{ticketID}:{endpoint}:{key}:{value}" {classString} onclick="buttonAction({ticketID}, '{endpoint}', '{key}', '{value}')" onblur="showButtonActionMessage({ticketID}, '')">{name}</button>'''.format(ticketID=ticketID, endpoint=endpoint, key=key, value=value, classString=classString, name=name)
def allButtonsHTML(ticketID, menuItemJSON, ticketJSON):
endpoint = menuItemJSON['endpoint']
key = menuItemJSON['key']
selectedName = ticketJSON['fields'][key]['name']
menuItemNameValuePairs = [{'name': name, 'value': value} for name, value in zip(menuItemJSON['names'], menuItemJSON['values'])]
return ''.join(list(map(lambda nameValuePair: buttonHTML(ticketID, endpoint, key, nameValuePair['name'], nameValuePair['value'], selectedName == nameValuePair['name']), menuItemNameValuePairs)))
def allMenusHTML(ticketID, ticketJSON):
menus = ''.join(
list(
map(lambda menuItemJSON:
'''
<div class="menu">
{key}:
<br>
{buttons}
</div>
'''
.format(
key=menuItemJSON['key'].capitalize(),
buttons=allButtonsHTML(ticketID, menuItemJSON, ticketJSON)
)
, menusData
)
)
)
return '''
<div class="menus phui-tag-core phui-tag-color-object">
<span class=buttonActionMessage id="buttonActionMessage{ticketID}"></span>
<h2>Quick options</h2>
{menus}
Comment: ( recorded with any <strong>Quick options</strong> chosen above )
<br>
<textarea id="ticketID{ticketID}" style="height: 70px; width: 100%;"></textarea>
</div>
'''.format(ticketID=ticketID, menus=menus)
def addWrapperDivAndMenuToTicketHTML(match):
ticketID = match.group(1)
ticketHTML = match.group(2)
ticketJSON = ticketsData[int(ticketID)]
return '''
<div class=ticket id="T{ticketID}">
<button class=hide onclick="this.closest('div#T{ticketID}').remove()">Hide</button>
{ticketHTML}
{menusString}
</div>
'''.format(ticketID=ticketID, ticketHTML=ticketHTML, menusString=allMenusHTML(ticketID, ticketJSON))
ticketsHTML = remarkupResultsJSON['result'][0]['content'].encode('utf-8')
ticketsHtmlIncludingWrapperDivsAndMenus = re.sub(pattern=r'TICKET_START:(.*?):(.*?)TICKET_END', repl=addWrapperDivAndMenuToTicketHTML, string=ticketsHTML, flags=re.DOTALL)
print('''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="https://phab.wmfusercontent.org/res/defaultX/phabricator/31c46c5c/core.pkg.css">
<base href="$BASE_URL" target="_blank">
<style>
div.ticket {{
border: solid 3px #C7CCD9;
margin: 35px 35px 100px 35px;
padding: 5px 20px 20px 20px;
border-radius: 6px;
min-width: 740px;
}}
button.hide {{
float: right;
margin-top: 20px;
}}
button:not([class=selected]) {{
background-color: white;
border: solid 1px #C7CCD9;
color: #777C89;
}}
button, button:hover {{
background-image: unset;
margin-right: 4px;
}}
div.menus {{
border-radius: 6px;
padding: 6pt;
margin-top: 20px;
}}
span.buttonActionMessage {{
color: gray;
margin-left: 25px;
font-size: 1.2em;
float: right;
}}
div.menu {{
margin: 8px 8px 8px 14px;
}}
</style>
<script>
const showButtonActionMessage = (ticketID, messageString) => document.querySelector(\`span#buttonActionMessage\${{ticketID}}\`).innerHTML = messageString
const updateMenuButtonsSelectionStyle = (ticketID, endpoint, key, value) => {{
const buttonsSelector = \`button[id^='\${{ticketID}}\:\${{endpoint}}:\${{key}}:']\`
const buttonToBeSelectedID = \`\${{ticketID}}\:\${{endpoint}}:\${{key}}:\${{value}}\`
document.querySelectorAll(buttonsSelector).forEach(button => button.classList[button.id == buttonToBeSelectedID ? 'add' : 'remove']('selected'))
}}
const buttonAction = (ticketID, endpoint, key, value) => {{
showButtonActionMessage(ticketID, '💾 Saving...')
const button = event.toElement
const commentTextArea = document.querySelector(\`textarea#ticketID\${{ticketID}}\`)
const comment = commentTextArea.value
const commentParameters = comment ? \`&transactions[1][type]=comment&transactions[1][value]=\${{comment}}\` : ''
fetch(\`$BASE_URL/api/\${{endpoint}}\`, {{
method: 'POST',
body: \`api.token=$API_TOKEN&output=json&transactions[0][type]=\${{key}}&transactions[0][value]=\${{value}}&objectIdentifier=T\${{ticketID}}\${{commentParameters}}\`
}})
.then(response => {{
if (response.ok == false) throw Error()
return response.json()
}})
.then(json => {{
if (json.error_code != null) throw Error()
showButtonActionMessage(ticketID, '🎉 Success')
updateMenuButtonsSelectionStyle(ticketID, endpoint, key, value)
commentTextArea.value = ''
}})
.catch(error => {{
showButtonActionMessage(ticketID, '💩 Failure')
}})
}}
</script>
</head>
<body>
<div class="phabricator-remarkup">
{ticketsHtmlIncludingWrapperDivsAndMenus}
</div>
</body>
</html>
'''.format(ticketsHtmlIncludingWrapperDivsAndMenus=ticketsHtmlIncludingWrapperDivsAndMenus));
--END
python -c "$PYTHON_SCRIPT"> /tmp/phabricator.tmp.html && open -a "Google Chrome" /tmp/phabricator.tmp.html --args --disable-web-security --user-data-dir=/tmp/chrome_dev_test
# To see html as text use line below instead of line above:
# python -c "$PYTHON_SCRIPT" | open -a "Safari" -f
This version moves everything to Python3.
#!/usr/local/bin/python3
import sys, json, urllib.parse, urllib.request, base64, re, subprocess;
BASE_URL = "https://phabricator.wikimedia.org"
API_TOKEN = "api-2lfamy3hsjzihfo6jwmnbd6lphn2"
QUERY_KEY = "F1GAHgzz693c"
def fetch(url, values):
data = urllib.parse.urlencode(values)
request = urllib.request.Request(url = url, headers = {}, data = data.encode('utf-8'))
response = urllib.request.urlopen(request)
page = response.read()
return json.loads(page.decode('utf-8'))
# Query the API:
searchResultsJSON = fetch(f"{BASE_URL}/api/maniphest.search", {
'api.token' : API_TOKEN,
'order' : 'priority',
'queryKey' : QUERY_KEY
})
resultDicts = searchResultsJSON["result"]["data"]
# Dict with ticketID as key and ticketJSON as value.
ticketsData = dict((x["id"], x) for x in resultDicts)
# Extracts and transforms tickets' remarkup (see: https://secure.phabricator.com/book/phabricator/article/remarkup/).
remarkup = ''.join(list(map(lambda item:
'''TICKET_START:{number}:
= T{number} =
== {name} ==
{description}
TICKET_END'''
.format(
number=item['id'],
name=item['fields']['name'],
description=item['fields']['description']['raw']
), resultDicts)))
# Hit "remarkup.process" to convert our remixed remarkup to HTML:
remarkupResultsJSON = fetch(f"{BASE_URL}/api/remarkup.process", {
'api.token' : API_TOKEN,
'context' : 'phriction',
'contents[0]' : remarkup
})
# Extract HTML, add menu, stylesheet and base URL so it looks nice and links work, then send all of it to a browser so we can see it:
menusData = [
{
'endpoint': 'maniphest.edit',
'key': 'status',
'names': ['Open', 'Resolved', 'Declined', 'Stalled', 'Invalid'],
'values': ['open', 'resolved', 'declined', 'stalled', 'invalid']
},
{
'endpoint': 'maniphest.edit',
'key': 'priority',
'names': ['Unbreak Now!', 'Needs Triage', 'High', 'Medium', 'Low', 'Lowest'],
'values': ['unbreak', 'triage', 'high', 'medium', 'low', 'lowest']
}
]
def buttonHTML(ticketID, endpoint, key, name, value, isSelected):
classString = "class='selected'" if isSelected else ""
return '''\n<button id="{ticketID}:{endpoint}:{key}:{value}" {classString} onclick="buttonAction({ticketID}, '{endpoint}', '{key}', '{value}')" onblur="showButtonActionMessage({ticketID}, '')">{name}</button>'''.format(ticketID=ticketID, endpoint=endpoint, key=key, value=value, classString=classString, name=name)
def allButtonsHTML(ticketID, menuItemJSON, ticketJSON):
endpoint = menuItemJSON['endpoint']
key = menuItemJSON['key']
selectedName = ticketJSON['fields'][key]['name']
menuItemNameValuePairs = [{'name': name, 'value': value} for name, value in zip(menuItemJSON['names'], menuItemJSON['values'])]
return ''.join(list(map(lambda nameValuePair: buttonHTML(ticketID, endpoint, key, nameValuePair['name'], nameValuePair['value'], selectedName == nameValuePair['name']), menuItemNameValuePairs)))
def allMenusHTML(ticketID, ticketJSON):
menus = ''.join(
list(
map(lambda menuItemJSON:
'''
<div class="menu">
{key}:
<br>
{buttons}
</div>
'''
.format(
key=menuItemJSON['key'].capitalize(),
buttons=allButtonsHTML(ticketID, menuItemJSON, ticketJSON)
)
, menusData
)
)
)
return '''
<div class="menus phui-tag-core phui-tag-color-object">
<span class=buttonActionMessage id="buttonActionMessage{ticketID}"></span>
<h2>Quick options</h2>
{menus}
Comment: ( recorded with any <strong>Quick options</strong> chosen above )
<br>
<textarea id="ticketID{ticketID}" style="height: 70px; width: 100%;"></textarea>
</div>
'''.format(ticketID=ticketID, menus=menus)
def addWrapperDivAndMenuToTicketHTML(match):
ticketID = match.group(1)
ticketHTML = match.group(2)
ticketJSON = ticketsData[int(ticketID)]
return '''
<div class=ticket id="T{ticketID}">
<button class=hide onclick="this.closest('div#T{ticketID}').remove()">Hide</button>
{ticketHTML}
{menusString}
</div>
'''.format(ticketID=ticketID, ticketHTML=ticketHTML, menusString=allMenusHTML(ticketID, ticketJSON))
ticketsHTML = remarkupResultsJSON['result'][0]['content']
ticketsHtmlIncludingWrapperDivsAndMenus = re.sub(pattern=r'TICKET_START:(.*?):(.*?)TICKET_END', repl=addWrapperDivAndMenuToTicketHTML, string=ticketsHTML, flags=re.DOTALL)
pageHTML = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="https://phab.wmfusercontent.org/res/defaultX/phabricator/31c46c5c/core.pkg.css">
<base href="{BASE_URL}" target="_blank">
<style>
div.ticket {{
border: solid 3px #C7CCD9;
margin: 35px 35px 100px 35px;
padding: 5px 20px 20px 20px;
border-radius: 6px;
min-width: 740px;
}}
button.hide {{
float: right;
margin-top: 20px;
}}
button:not([class=selected]) {{
background-color: white;
border: solid 1px #C7CCD9;
color: #777C89;
}}
button, button:hover {{
background-image: unset;
margin-right: 4px;
}}
div.menus {{
border-radius: 6px;
padding: 6pt;
margin-top: 20px;
}}
span.buttonActionMessage {{
color: gray;
margin-left: 25px;
font-size: 1.2em;
float: right;
}}
div.menu {{
margin: 8px 8px 8px 14px;
}}
</style>
<script>
const showButtonActionMessage = (ticketID, messageString) => document.querySelector(`span#buttonActionMessage${{ticketID}}`).innerHTML = messageString
const updateMenuButtonsSelectionStyle = (ticketID, endpoint, key, value) => {{
const buttonsSelector = `button[id^='${{ticketID}}\:${{endpoint}}:${{key}}:']`
const buttonToBeSelectedID = `${{ticketID}}\:${{endpoint}}:${{key}}:${{value}}`
document.querySelectorAll(buttonsSelector).forEach(button => button.classList[button.id == buttonToBeSelectedID ? 'add' : 'remove']('selected'))
}}
const buttonAction = (ticketID, endpoint, key, value) => {{
showButtonActionMessage(ticketID, '💾 Saving...')
const button = event.toElement
const commentTextArea = document.querySelector(`textarea#ticketID${{ticketID}}`)
const comment = commentTextArea.value
const commentParameters = comment ? `&transactions[1][type]=comment&transactions[1][value]=${{comment}}` : ''
fetch(`{BASE_URL}/api/${{endpoint}}`, {{
method: 'POST',
body: `api.token={API_TOKEN}&output=json&transactions[0][type]=${{key}}&transactions[0][value]=${{value}}&objectIdentifier=T${{ticketID}}${{commentParameters}}`
}})
.then(response => {{
if (response.ok == false) throw Error()
return response.json()
}})
.then(json => {{
if (json.error_code != null) throw Error()
showButtonActionMessage(ticketID, '🎉 Success')
updateMenuButtonsSelectionStyle(ticketID, endpoint, key, value)
commentTextArea.value = ''
}})
.catch(error => {{
showButtonActionMessage(ticketID, '💩 Failure')
}})
}}
</script>
</head>
<body>
<div class="phabricator-remarkup">
{ticketsHtmlIncludingWrapperDivsAndMenus}
</div>
</body>
</html>
'''
def sendToBrowser(string, extension):
filePath = '/tmp/chrome.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)
sendToBrowser(pageHTML, 'html')
# To see html as text use line below instead of line above:
# sendToBrowser(pageHTML, 'txt')
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A version of the script with functional "Quick status options" buttons
Below is a version of the script which adds a row of "Quick status options" beneath each ticket.
As with the script above you can just copy / paste it into Terminal. Its only requirement is you need Chrome to be installed.
Presently it shows live data from the Wikipedia-iOS-App-Backlog board's "Bug Backlog" column, and its "Quick status options" buttons are functional, so don't go around clicking them unless updating a ticket's status is what you really want to do.
Having said this, the script can be used for any column on any board. Just follow the instructions above to grab a column's
queryKey
. Then replace the string7gQoWC3FFleG
below with the key you grabbed.Note: the blue "hide" button seen in the screenshot leaves its ticket untouched - it just hides it from the list output by the script.