GitHub Notifications

and Keith Cirkel

and John Flesch

GitHub (and GitHub:Enterprise) notifications in your menu bar!

Image preview of GitHub Notifications plugin.

notifications.30s.py

Edit
Open on GitHub
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# <xbar.title>GitHub Notifications</xbar.title>
# <xbar.version>v3.0.3</xbar.version>
# <xbar.author>Matt Sephton, Keith Cirkel, John Flesch</xbar.author>
# <xbar.author.github>flesch</xbar.author.github>
# <xbar.desc>GitHub (and GitHub:Enterprise) notifications in your menu bar!</xbar.desc>
# <xbar.image>https://i.imgur.com/hW7dw9E.png</xbar.image>
# <xbar.dependencies>python3</xbar.dependencies>
# <xbar.var>string(GITHUB_TOKEN): Your Personal GitHub token</xbar.var>
# <xbar.var>string(GITHUB_ENTERPRISE_TOKEN): Your Enterprise GitHub token (optional)</xbar.var>
# <xbar.var>string(GITHUB_ENTERPRISE_API): Your Enterprise GitHub URL (optional)</xbar.var>

import json
from urllib.request import urlopen, Request
import os
import sys
import re
from itertools import groupby
import textwrap

# GitHub.com
github_api_key = os.getenv("GITHUB_TOKEN", "")

# GitHub:Enterprise (optional)
enterprise_api_key = os.getenv("GITHUB_ENTERPRISE_TOKEN", "")
enterprise_api_url = os.getenv("GITHUB_ENTERPRISE_API", "")

active = "#4078C0"
inactive = "#7d7d7d"

# Utility Functions

def plural(word, n):
    return str(n) + " " + (word + "s" if n > 1 else word)


def get_dict_subset(thedict, *keys):
    return dict([(key, thedict[key]) for key in keys if key in thedict])


def print_bitbar_line(title, **kwargs):
    args = " ".join(["{}={}".format(k, v) for k, v in kwargs.items()])
    print(title + " | " + args)


def make_github_request(url, method="GET", data=None, enterprise=False):
    try:
        api_key = enterprise_api_key if enterprise else github_api_key
        headers = {
            "Authorization": "token " + api_key,
            "Accept": "application/json",
        }
        if data is not None:
            data = json.dumps(data)
            headers["Content-Type"] = "application/json"
            headers["Content-Length"] = len(data)
        request = Request(url, headers=headers)
        request.get_method = lambda: method
        response = urlopen(request, data)
        return (
            json.load(response) if int(response.headers.get("content-length", 0)) > 0 else {}
        )
    except Exception:
        return None


def get_notifications(enterprise):
    url = "%s/notifications" % (
        enterprise_api_url if enterprise else "https://api.github.com"
    )
    return make_github_request(url, enterprise=enterprise) or []


def print_notifications(notifications, enterprise=False):
    notifications = sorted(
        notifications, key=lambda notification: notification["repository"]["full_name"]
    )
    for repo, repo_notifications in groupby(
        notifications, key=lambda notification: notification["repository"]["full_name"]
    ):
        if repo:
            repo_notifications = list(repo_notifications)
            print_bitbar_line(title=repo)
            print_bitbar_line(
                title="{title} - Mark {count} As Read".format(
                    title=repo, count=len(repo_notifications)
                ),
                alternate="true",
                refresh="true",
                bash="\"%s\"" % __file__,
                terminal="false",
                param1="readrepo",
                param2=repo,
                param3="--enterprise" if enterprise else None,
            )
            for notification in repo_notifications:
                formatted_notification = format_notification(notification)
                print_bitbar_line(
                    refresh="true",
                    **get_dict_subset(
                        formatted_notification,
                        "title",
                        "href",
                        "image",
                        "templateImage",
                    )
                )
                print_bitbar_line(
                    refresh="true",
                    title="%s - Mark As Read" % formatted_notification["title"],
                    alternate="true",
                    bash="\"%s\"" % __file__,
                    terminal="false",
                    param1="readthread",
                    param2=formatted_notification["thread"],
                    param3="--enterprise" if enterprise else None,
                    **get_dict_subset(formatted_notification, "image", "templateImage")
                )


def format_notification(notification):
    type = notification["subject"]["type"]
    formatted = {
        "thread": notification["url"],
        "title": textwrap.shorten(notification["subject"]["title"], width=75, placeholder="...").encode("utf-8"),
        "href": notification["subject"]["url"],
        "image": "iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAYAAAAmlE46AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAA",
    }
    formatted["title"] = formatted["title"].decode().replace("|", "-")
    latest_comment_url = notification.get("subject", {}).get("latest_comment_url", None)
    typejson = make_github_request(formatted["href"])
    if latest_comment_url:
        formatted["href"] = (make_github_request(latest_comment_url) or {}).get(
            "html_url", formatted["href"]
        )
    # Try to hack a web-viewable URL if the last check failed
    if formatted["href"]:
        formatted["href"] = re.sub(
            "api\.|api/v3/|repos/",
            "",
            re.sub("(pull|commit)s", r"\1", formatted["href"]),
        )
    if type == "PullRequest":
        if typejson and typejson["merged"]:
            formatted[
                "image"
            ] += "SpJREFUKJG9kkFOwmAQhb+ZQiVx5xm4hIlncEF7jLZuWSjSeAJsvQUY4xkMHsCtcU9MXBmwJsy4EWgFEt34VpP55mX+eflhj9KoGO5jAK00LmOwoZiaYIPRbXaXRsVQRC6BvWZJ4uLJRI6DcKlUMsVl/G0CwIw3UR8V4+QKxFd9BbfDqiP6buo1sB5QjgTJ07i8aPTFgvNFa/7i7fYzaL+YpEN3zwGux4mY2QmAm6db783i0rO4bGyrh7OL66a0Bigm6d5gGkYz3brvV8a/SjeF/dPGJLrpmTMDXs/i4vTnQNYrInNm5szqvIVYbiJdCV1Z6ANwXze6em4i3SBcqi+CNVeAIFxq9dkR0+07HfHVz6rzlsLAK5keUCEu/R0hDD7C+SME6A7+Z30BqF2G+GPLjSUAAAAASUVORK5CYII="
        elif typejson and typejson["state"] == "closed":
            formatted[
                "image"
            ] += "Q9JREFUKJG9kjFOw0AQRd9sbAiiQOIMqWJfAInOPULANehTQIjFCbgHFS1eKhR6cDpEbyHRmljyDpWVTWJbSsOvRvvm72i+BjpkI2ZdDIBszNVzTG7HvGcx543JxmifT7KIj4FyUlaYcMjcKI8Id17Pj8JDknMvrD4zoriwRg4OMaKtU44FUhtz6z8aFW7KkC9X82mESbJghpICJDkiyimAwvV2EDG6uZMfThs3TeFYB8miP1XjFb0pdhp31cro/muijbjAUTjDtx1zttnwEnGJo8BR+DxQSAcwWv5i9vd4BZ58Yy2kgTIqq3VuAMoKEx4htCQrijaX5fMAYRoOmbMEJ0y2lhGmZcgbNdDGd9Uf3M1iNlKZZGMAAAAASUVORK5CYII="
        else:
            formatted[
                "image"
            ] += "TJJREFUKJG9krFOAlEQRe/MLpqHnd/ATyBWsLWF+hcLPYXibvwCE/gLC2MNVrD7AbbGnpjYboBlro2QXWATabzVy5x338ydPKBC4awTVTEA8Huz9i1FI5gZBINh6+0lnHUiEXkAUGmWMGm/c7Fues4pLU9IPv+aAABm9i2qT6Pm+BECbuoqpnZWW4lmmRLG3ZdV9VyAOEyD+1JdVO4yqX+ua7UPofRHrUlEMgaA4cVYALkEACF6e/N2k4DdJCh1Ky7nENftyVACo9akcjElox3I9yfjsdoaVfFPHbtpcG2GOUy/wjS42r0QTjs3ZpibYV7kPmkxVuuGOKfIl1MAr0WjqMRcrBqec8oCVwDwnNOFnwugezkJ4+ZnFbkPeANanpwsAQr7+2m8Qab1FKcAeYgfqR/3P4pMOYR15QAAAABJRU5ErkJggg=="
        if typejson and typejson.get("user", {}).get("login", None):
            formatted["title"] += " (by @{})".format(typejson["user"]["login"])
    elif type == "RepositoryInvitation":
        formatted[
            "image"
        ] = "iVBORw0KGgoAAAANSUhEUgAAAA4AAAAKCAYAAACE2W/HAAAAAXNSR0IArs4c6QAAAM1JREFUKBWVkD0OQUEUhcdv/ASJn55SyxLoVBJq8tZjBRQsgkZiAQoqOiQ2oFc935nMvLxEXsRJPufOufe+kTHGmBeEf3Jg3i4t8IwOP6QZzeoi+3PFt1BRkKAq+Q4uEKbdUA+/wxm6LoubshPcoO8b9lp3GOMPGPomPoInqOcV/VUfyKfwhsChegJxfS3O6R5hALpFqFY2A69osUyygT3UQOpAWwVSpt4aShAt6lWXkIUk5WiswL5qiqIODdBz6+ZirM67cwFvulwfaH0AC7M1lHL62U4AAAAASUVORK5CYII="
        formatted["templateImage"] = formatted.pop("image")
        formatted["href"] = "https://github.com/{}/invitations".format(
            notification["repository"]["full_name"]
        )
    elif type == "Issue":
        if typejson and typejson["state"] == "closed":
            formatted[
                "image"
            ] += "YpJREFUKJGdkj9I23EQxT93SZrJQYNQ6Bo65BtQcHRpSeyUuQqNWx2ti7qJRpqhUxGX0lGhlXQ0uJhfU7cOLm3+QKCzoIjoJpp8z0HT/BpJBd90HO/dvTsePBLS36iM8Uw6vANyQNJ7RJU/wG7nmo1XLY7uCStp8uL5BJwgbJtRU8E8jImR98qoGHPZBjsSFhlsCRQjCQovD2iHhx5OEDu/ZF2E5Y4wLn/tXdMy5WO2zkqXHKQxgEy95+y7Y1KMXwogbeaBk0iCwqBn7DteAJym+NmBnSiAF3IK2/32wiIVqoGjYE1GRMlGARSSZtQGbZtq8CNwFBBWDUxgJjqI3I9Mg7XAgRoXmSYlACqOWuAG39ePimNB7+oyxuzhBLEwIUhj3c92sZckLsaSAvg2m14ZPb98eGs8ThFlqBcAx4wJX8T4cGasvm5yFRaUUjwZUd4bLKow/W/kbsWfgTMxvorxmwhtjHEPbxSGEd5many7F/JqiqdemceT88rzu3ZLoWywma1z/NA5/8UNNkSJCdaYQF4AAAAASUVORK5CYII="
        else:
            formatted[
                "image"
            ] += "ZxJREFUKJGdkjFoU2EUhb97k9jNRzEFoWvJVHXoZCqIaQaH7JaUbtpi2zc4ORWJYKGTYJLBroFaiGPo0hBwyAOhU51Cd8EOOid53uugL4SnkOK3/dx7OPccfvhPZPqxHZUWc0gIUjFs6c/CJdDBtN580P36lzCM1jZ+mr9HuFKkJfgXABO9i/mmqOTd7VlztXcyEYbR2oabtFx5kx+Oa7VHn+LpS7bOV3K5UfBaTF+6WLW52juR7ai0mDEGovq2WezuJ8t7UdkBGsXu5KqdfvlQhOeeGRc0h4QIV/nhuDarkIVgft+xHxJnQwWpKNJKn/cvasvtkcCxuFTUsKWkiOsgrhcoBb2uIE1W4NKRO8DH6cF0KSnLewYDBTqGb26dr+Sm53tR2ZNmE8LTx3O4V9XpKKZ1FV24MZyf2aoH8YE5garXBWC3X1oX12NXO8zfvPWqttwepZ08iA9wXqDypFE8a09y7PZL64geCXx37IO4XiSZcK/+dso8bRTP2pD65DufH96WOBuKSwWlAGD4QJ2Oqtff3e99mxVnJr8AXSGi02ni0+YAAAAASUVORK5CYII="
    elif type == "Commit":
        formatted[
            "image"
        ] += "HhJREFUKJHl0LEKwkAQBNCH3yIaf05S+VUqmh8ykFoUYn8WbnEc8a7XgYVlmNkdhv/EDgNmvHDFtmXq8ETCiFvsD2xqxksI9xnXB3cqxamYceHgVOpWC6JUi/QNQxj7jDsEd6wZ83KmLOId69bXzqekOeas0eiv4g3q4SY7NY1R2gAAAABJRU5ErkJggg=="
        formatted["templateImage"] = formatted.pop("image")
    elif type == "Release":
        formatted[
            "image"
        ] += "JdJREFUKJGl0DsKwkAUBdDTRgvFHbgmNyLY+QWzKxM/kK2kSKc70MIIQ0ziqBceA/dxinn8mSkKVMGUmH+CBWaNboQjdn2wqt97Pa8kNd5+C0O86YNdSZC34RLjCJxhHZYLXDCIxKuwTHGOwBNcm2WKUw9OcMCybZl6XjHpQOs30cB5gKNQiDPPP0WjV/a4aVwxNsNfUGce7P8k4XgVPSYAAAAASUVORK5CYII="
        formatted["templateImage"] = formatted.pop("image")
    elif type == "Discussion":
        formatted[
            "image"
        ] += ""
        formatted["templateImage"] = formatted.pop("image")
    elif type == "CheckSuite":
        formatted[
            "image"
        ] = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAMpJREFUOI2Vkn0RwjAMxX+bASqlEiYBB1QCEnAADpiTbQo2B0wCKCh/NOPK2oby7nrXJi/py0dDGRY4yP0FLAr3Cw54AB4Y5XixnbRAAwzyky0oWoRjcglGoK9Q2EuSRHZ1jcJ1sWEllX0BukICKzGfh8+QtuZdydfsAduKc1LkngkTOO7sE2BaJTBGozl/lXBDKWF7/NvEjqiJEEYyazJ3SMYIQfK9IrgXbgIjjpnyKs/Cya7yBkeozxNWdpD7mpOtjcdGPz0prPobqq80AtYLw6wAAAAASUVORK5CYII="
        formatted["templateImage"] = formatted.pop("image")
        formatted["href"] = "https://github.com/{}/actions".format(
            notification["repository"]["full_name"]
        )
    return formatted


if len(sys.argv) > 1:
    command = sys.argv[1]
    args = sys.argv[2:]
    enterprise = False
    if "--enterprise" in args:
        enterprise = True
        args.remove("--enterprise")
    if command == "readrepo":
        url = "%s/repos/%s/notifications" % (
            enterprise_api_url if enterprise else "https://api.github.com",
            args[0],
        )
        print("Marking %s as read" % url)
        make_github_request(url=url, method="PUT", data={}, enterprise=enterprise)
    elif command == "readthread":
        url = args[0]
        print("Marking %s as read" % url)
        make_github_request(url=url, method="PATCH", data={}, enterprise=enterprise)

else:
    is_github_defined = len(github_api_key) == 40
    is_github_enterprise_defined = len(enterprise_api_key) == 40
    github_notifications = (
        get_notifications(enterprise=False) if is_github_defined else []
    )
    enterprise_notifications = (
        get_notifications(enterprise=True) if is_github_enterprise_defined else []
    )
    has_notifications = len(github_notifications) + len(enterprise_notifications)
    color = active if has_notifications else inactive

    if has_notifications:
        print_bitbar_line(title=u"\u25CF", color=color)
        print("---")
    else:
        print("•")
        exit(0)

    print_bitbar_line(title="Refresh", refresh="true")

    if is_github_defined:
        if len(github_notifications):
            print_bitbar_line(
                title=(
                    u"GitHub \u2014 %s"
                    % plural("notification", len(github_notifications))
                ),
                color=active,
                href="https://github.com/notifications",
            )
            print_notifications(github_notifications)
        else:
            print_bitbar_line(
                title=u"GitHub \u2014 No new notifications",
                color=inactive,
                href="https://github.com",
            )

    if is_github_enterprise_defined:
        if len(enterprise_notifications):
            if is_github_defined:
                print("---")
            print_bitbar_line(
                title=(
                    u"GitHub:Enterprise \u2014 %s"
                    % plural("notification", len(enterprise_notifications))
                ),
                color=active,
                href="%s/notifications" % re.sub("/api/v3", "", enterprise_api_url),
            )
            print_notifications(enterprise_notifications, enterprise=True)
        else:
            print("---")
            print_bitbar_line(
                title=u"GitHub:Enterprise \u2014 No new notifications",
                color=inactive,
            )