Skip to content

Instantly share code, notes, and snippets.

@montehurd
Last active August 4, 2022 21:30
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save montehurd/40ab4dd5cc81b66d78aaaa46752b1f5f to your computer and use it in GitHub Desktop.
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:
#!/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
@montehurd
Copy link
Author

montehurd commented Mar 10, 2020

Example output to Safari when copy / pasting the script above into macOS Terminal

phab mine 720 3 mov

At the start of the animation, which lasts ~45 seconds, the black terminal window on the left is mostly blank.
Click on the image to start it at the beginning in a new widow.

@montehurd
Copy link
Author

montehurd commented Mar 10, 2020

Summary

The inline comments in the script roughly describe each step, but to summarize its function:

  • executes a saved query, representing a complex set of query parameters, by its ID (the one I used looks for iOS backlog tickets containing the string "on this day")
  • extracts query results
  • transforms each result grabbing certain elements from each, including the ticket description, which is in "remarkup" (in this step we could also "mine" ticket descriptions for certain sub-strings)
  • sends transformed remarkup to second API to convert to HTML
  • adds some supporting HTML so results are styled, links work, etc
  • sends this HTML to Safari for instant preview

@montehurd
Copy link
Author

montehurd commented Mar 10, 2020

Context

One scenario I thought about while on the iOS team was that Anthony would have a regression script page he could load which would extract from relevant Phabricator tickets the regression steps which needed continued testing. That way no separate (massive) spreadsheet would have to be maintained.

The thinking was, if we could agree upon some description syntax (say, headers titled == Regression Test These Steps ==), if that header syntax was seen in any relevant ticket's description, the steps it outlined would get slurped up and presented by the script, so nothing would need to be done beyond adding such headers/steps for these steps to get testing from then on.

This would be especially nice since the script could provide links back to respective tickets (as seen in the animation above) so testers would have quick access to more context, when needed. Permanently removing items would be as easy as removing the header/steps, and if temporarily removal was desired, changing the header to something like == Regression Test These Steps (off) == would do the trick.

The script could also do things like present a button which could, say, trigger a Phab API to tag a ticket if its regression steps failed.

@montehurd
Copy link
Author

montehurd commented Mar 10, 2020

Potential next steps

I believe the script above proves the concept. It might make sense to experiment with a more functional prototype.

Steps could include:

  • getting buy-in from a tester and a team on the value of the approach and to experiment / iterate with, say, Anthony and Josh on iOS?
  • agree upon a syntax the script would seek
  • construct saved query looking for that syntax (string) in appropriately tagged tickets
  • decide if having a Mark as failed button for each item makes sense, and if so what API it would need to trigger - say, adding a tag to "move" that ticket to a particular column or board, or some other mechanism... it may be simpler to just let the tester click through to the respective ticket and manually add such tags 🤔
  • decide where the script should live (though for now prototypes could be perfected via same "just works" copy / paste approach used above)

@montehurd
Copy link
Author

montehurd commented Mar 18, 2020

Limit query to tickets in a particular Phabricator board column

To get the queryKey for tickets in a column on a particular board:

Then you can use that queryKey with the maniphest.search API.

@montehurd
Copy link
Author

montehurd commented Mar 18, 2020

Other options, for example, ticket sort order

maniphest.search options, such as order, are explained here:
https://phabricator.wikimedia.org/conduit/method/maniphest.search/

As seen in the "Result Ordering" section at that link, "order" can be:

  • priority
  • updated
  • outdated
  • newest
  • oldest
  • closed
  • title
  • custom.points.final
  • -custom.points.final
  • relevance

Example:

curl https://phabricator.wikimedia.org/api/maniphest.search \
    -d api.token=api-token \
    -d order=newest

You can also use a custom, multi-column order:

curl https://phabricator.wikimedia.org/api/maniphest.search \
    -d api.token=api-token \
    -d order[0]=color \
    -d order[1]=-name \
    -d order[2]=id

@montehurd
Copy link
Author

montehurd commented Mar 20, 2020

Change a ticket's status

Example manifest.edit Javascript for changing a ticket's status:

fetch("https://phabricator.wikimedia.org/api/maniphest.edit", {
  method: 'POST',
  body:
  'api.token=api-2lfamy3hsjzihfo6jwmnbd6lphn2&'+
  'objectIdentifier=T248045&'+
  'output=json&'+
  'transactions[0][type]=status&'+
  'transactions[0][value]=open'
})

open can be any valid status
objectIdentifier can be any phab ticket number

@montehurd
Copy link
Author

montehurd commented Mar 21, 2020

Allow Browsers to use Javascript "fetch" method with shell script produced html

For security reasons browsers wisely lock down cross-domain communication. For a shell script, like the one above, to be able to use the Javascript fetch method targeting the Wikimedia instance of Phabricator, one work-around is using a browser instance which has relaxed these restrictions a bit:

Chrome

A Chrome instance can be invoked from the command line with relaxed restrictions:

open -a "Google Chrome" "./queryphab/queryphab.html" --args --disable-web-security --user-data-dir="/tmp/chrome_dev_test"

The full list of Chrome's command line arguments may be found here.

Safari

Safari doesn't seem to accept command line arguments, but you can temporarily toggle these restrictions off via:

Develop > Disable Cross Origin Restrictions

If the Develop menu isn't visible you can turn it on via:

Safari > Preferences > Advanced > Show Develop menu in menu bar

@montehurd
Copy link
Author

montehurd commented Mar 23, 2020

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.

Screen Shot 2020-03-25 at 9 11 44 AM

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 string 7gQoWC3FFleG 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.

#!/bin/bash

BASE_URL="https://phabricator.wikimedia.org"
API_TOKEN="api-2lfamy3hsjzihfo6jwmnbd6lphn2"
QUERY_KEY="7gQoWC3FFleG"

# https://stackoverflow.com/a/44828706
read -r -d '' RESULT_TRANSFORMATION << '--END'
import sys, json, urllib;
resultDicts = json.load(sys.stdin)['result']['data'];
remarkup = ''.join(list(map(lambda item:
'''TICKET_START:{number}:
= T{number} =
== {name} ==
Priority: {priority}

---

{description}
TICKET_END'''
.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

read -r -d '' PAGE_PRESENTATION << --END
import sys, json, re;
ticketsHTML = json.load(sys.stdin)['result'][0]['content'].encode('utf-8');

def buttonHTML(ticketNumber, valueString):
  return '''<button id="{ticketNumber}:{valueString}" onclick="buttonAction({ticketNumber}, '{valueString}')" onblur="showButtonActionMessage({ticketNumber}, '')">{valueString}</button>'''.format(ticketNumber=ticketNumber, valueString=valueString)

def allButtonsHTML(ticketNumber):
  return '\n'.join(list(map(lambda x: buttonHTML(ticketNumber, x), ['resolved', 'declined', 'stalled', 'invalid'] )))

def menuHTML(ticketNumber, allButtonsHTML):
  return '''
    <div class="menu phui-tag-core phui-tag-color-object">
      Quick status options :{allButtonsHTML}
      <button class=hide onclick="this.closest('div#T{ticketNumber}').remove()">hide</button>
      <span class=buttonActionMessage id="buttonActionMessage{ticketNumber}"></span>
    </div>
'''.format(ticketNumber=ticketNumber, allButtonsHTML=allButtonsHTML)

def addWrapperDivAndMenuToTicketHTML(match):
  ticketNumber = match.group(1)
  ticketHTML = match.group(2)
  return '''
    <div class=ticket id="T{ticketNumber}">
      {ticketHTML}
      {menuHTML}
    </div>
'''.format(ticketNumber=ticketNumber, ticketHTML=ticketHTML, menuHTML=menuHTML(ticketNumber, allButtonsHTML(ticketNumber)))

ticketsHtmlIncludingWrapperDivsAndMenus = re.sub(pattern=r'TICKET_START:(\d{1,}):(.*?)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 {{
         margin-left: 30px;
       }}
       button {{
         margin-left: 5px;
       }}
       div.menu {{
         border-radius: 6px;
         padding: 6pt;
         margin-top: 20px;
       }}
       span.buttonActionMessage {{
         color: gray;
         margin-left: 25px;
         font-size: 1.2em;
       }}
     </style>
     <script>
       const showButtonActionMessage = (ticketNumber, messageString) => document.querySelector(\`span#buttonActionMessage\${{ticketNumber}}\`).innerHTML = messageString
       const buttonAction = (ticketNumber, valueString) => {{
         showButtonActionMessage(ticketNumber, '💾 saving...')
         const button = event.toElement
         fetch('$BASE_URL/api/maniphest.edit', {{
           method: 'POST',
           body: \`api.token=$API_TOKEN&output=json&transactions[0][type]=status&transactions[0][value]=\${{valueString}}&objectIdentifier=T\${{ticketNumber}}\`
         }})
         .then(response => {{
           if (response.ok == false) throw Error()
           return response.json()
         }})
         .then(json => {{
           if (json.error_code != null) throw Error()
           showButtonActionMessage(ticketNumber, '🎉 success')
           setTimeout(() => {{
             showButtonActionMessage(ticketNumber, '🙈 hiding...')
             setTimeout(() => {{
               button.closest('div.ticket').remove()
             }}, 1500)
           }}, 1500)
         }})
         .catch(error => {{
           showButtonActionMessage(ticketNumber, '💩 failure')
         }})
       }}
     </script>
   </head>
   <body>
     <div class="phabricator-remarkup">
       {ticketsHtmlIncludingWrapperDivsAndMenus}
     </div>
   </body>
 </html>
'''.format(ticketsHtmlIncludingWrapperDivsAndMenus=ticketsHtmlIncludingWrapperDivsAndMenus));
--END

# Query the 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 and transforms tickets' remarkup (see: https://secure.phabricator.com/book/phabricator/article/remarkup/).
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 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:
python -c "$PAGE_PRESENTATION" > /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 "$PAGE_PRESENTATION" | open -a "Safari" -f

@montehurd
Copy link
Author

montehurd commented Mar 25, 2020

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).

@montehurd
Copy link
Author

montehurd commented Mar 29, 2020

A version of the script with functional "Quick options" buttons for Status and Priority, with Comment support.

Screen Shot 2020-03-29 at 3 51 15 PM
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