From 87315d5a4058e83eb5b37f96920cf456f502045c Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Thu, 27 Jul 2023 16:34:13 -0600
Subject: [PATCH 1/2] Update requirements

---
 tools/python/requirements.in       |  1 +
 tools/python/requirements_lock.txt | 88 ++++++++++++++++--------------
 2 files changed, 48 insertions(+), 41 deletions(-)

diff --git a/tools/python/requirements.in b/tools/python/requirements.in
index f48906d..7546ca0 100644
--- a/tools/python/requirements.in
+++ b/tools/python/requirements.in
@@ -53,3 +53,4 @@ toml
 unify
 yamllint
 yaspin
+pytimeparse
diff --git a/tools/python/requirements_lock.txt b/tools/python/requirements_lock.txt
index 5c93185..e2c15b9 100644
--- a/tools/python/requirements_lock.txt
+++ b/tools/python/requirements_lock.txt
@@ -1,25 +1,26 @@
-aiohttp==3.8.4
+aiohttp==3.8.5
 aiohttp-basicauth==1.0.0
 aiosignal==1.3.1
-aiosql==8.0
+aiosql==9.0
 alabaster==0.7.13
-async-lru==2.0.2
+annotated-types==0.5.0
+async-lru==2.0.4
 async-timeout==4.0.2
 attrs==23.1.0
 autocommand==2.2.2
-autoflake==2.1.1
+autoflake==2.2.0
 Babel==2.12.1
 beautifulsoup4==4.12.2
-black==23.3.0
+black==23.7.0
 blinker==1.6.2
 build==0.10.0
 cachetools==5.3.1
-certifi==2023.5.7
-charset-normalizer==3.1.0
+certifi==2023.7.22
+charset-normalizer==3.2.0
 cheroot==10.0.0
 CherryPy==18.8.0
-click==8.1.3
-colored==1.4.4
+click==8.1.6
+colored==2.2.3
 commonmark==0.9.1
 coverage==7.2.7
 decorator==5.1.1
@@ -28,53 +29,54 @@ docutils==0.20.1
 ExifRead==3.0.0
 flake8==6.0.0
 Flask==2.3.2
-frozenlist==1.3.3
-hypothesis==6.75.9
+frozenlist==1.4.0
+hypothesis==6.82.0
 ibis==3.2.0
 icmplib==3.0.3
 idna==3.4
 imagesize==1.4.1
-inflect==6.0.4
+inflect==7.0.0
 iniconfig==2.0.0
 isort==5.12.0
 itsdangerous==2.1.2
-jaraco.collections==4.2.0
+jaraco.collections==4.3.0
 jaraco.context==4.3.0
-jaraco.functools==3.7.0
-jaraco.text @ git+https://github.com/arrdem/jaraco.text.git@0dd8d0b25a93c3fad896f3a92d11caff61ff273d
+jaraco.functools==3.8.0
+jaraco.text==3.11.1
 jedi==0.18.2
 Jinja2==3.1.2
-jsonschema==4.17.3
-jsonschema-spec==0.1.4
-lark==1.1.5
+jsonschema==4.18.4
+jsonschema-spec==0.2.3
+jsonschema-specifications==2023.7.1
+lark==1.1.7
 lazy-object-proxy==1.9.0
 libsass==0.22.0
 livereload==2.6.3
-lxml==4.9.2
-Markdown==3.4.3
+lxml==4.9.3
+Markdown==3.4.4
 MarkupSafe==2.1.3
 mccabe==0.7.0
-meraki==1.33.0
+meraki==1.34.0
 mirakuru==2.5.1
 mistune==2.0.5
-more-itertools==9.1.0
+more-itertools==10.0.0
 multidict==6.0.4
 mypy-extensions==1.0.0
 octorest==0.4
-openapi-schema-validator==0.4.4
-openapi-spec-validator==0.5.6
+openapi-schema-validator==0.6.0
+openapi-spec-validator==0.6.0
 packaging==23.1
 parso==0.8.3
 pathable==0.4.3
 pathspec==0.11.1
 picobox==3.0.0
 pip==23.1.2
-pip-tools==6.13.0
-platformdirs==3.5.1
-pluggy==1.0.0
-port-for==0.6.3
-portend==3.1.0
-prompt-toolkit==3.0.38
+pip-tools==7.1.0
+platformdirs==3.9.1
+pluggy==1.2.0
+port-for==0.7.1
+portend==3.2.0
+prompt-toolkit==3.0.39
 proquint==0.2.1
 psutil==5.9.5
 psycopg==3.1.9
@@ -83,30 +85,34 @@ pudb==2022.1.3
 py==1.11.0
 pycodestyle==2.10.0
 pycryptodome==3.18.0
-pydantic==1.10.8
+pydantic==2.1.1
+pydantic_core==2.4.0
 pyflakes==3.0.1
 Pygments==2.15.1
 pyproject_hooks==1.0.0
 pyrsistent==0.19.3
-pytest==7.3.1
+pytest==7.4.0
 pytest-cov==4.1.0
 pytest-postgresql==5.0.0
 pytest-pudb==0.7.0
 pytest-timeout==2.1.0
+pytimeparse==1.1.8
 pytz==2023.3
-PyYAML==6.0
+PyYAML==6.0.1
 recommonmark==0.7.1
-redis==4.5.5
+redis==4.6.0
+referencing==0.29.3
 requests==2.31.0
 retry==0.9.2
 rfc3339-validator==0.1.4
-setuptools==67.7.2
+rpds-py==0.9.2
+setuptools==68.0.0
 six==1.16.0
 smbus2==0.4.2
 snowballstemmer==2.2.0
 sortedcontainers==2.4.0
 soupsieve==2.4.1
-Sphinx==7.0.1
+Sphinx==7.1.1
 sphinx_mdinclude==0.5.3
 sphinxcontrib-applehelp==1.0.4
 sphinxcontrib-devhelp==1.0.2
@@ -117,19 +123,19 @@ sphinxcontrib-openapi==0.8.1
 sphinxcontrib-programoutput==0.17
 sphinxcontrib-qthelp==1.0.3
 sphinxcontrib-serializinghtml==1.1.5
-tempora==5.2.2
+tempora==5.5.0
 termcolor==2.3.0
 toml==0.10.2
 tornado==6.3.2
-typing_extensions==4.6.3
+typing_extensions==4.7.1
 unify==0.5
 untokenize==0.1.1
-urllib3==2.0.2
+urllib3==2.0.4
 urwid==2.1.2
 urwid-readline==0.13
 wcwidth==0.2.6
-websocket-client==1.5.2
-Werkzeug==2.3.4
+websocket-client==1.6.1
+Werkzeug==2.3.6
 wheel==0.40.0
 yamllint==1.32.0
 yarl==1.9.2

From 64c46222008c35816e555af62709624f02ca5513 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Thu, 27 Jul 2023 16:34:26 -0600
Subject: [PATCH 2/2] Create the unnotifier

---
 projects/gh-unnotifier/BUILD                  |   9 ++
 .../src/python/ghunnotif/__main__.py          | 125 ++++++++++++++++++
 2 files changed, 134 insertions(+)
 create mode 100644 projects/gh-unnotifier/BUILD
 create mode 100644 projects/gh-unnotifier/src/python/ghunnotif/__main__.py

diff --git a/projects/gh-unnotifier/BUILD b/projects/gh-unnotifier/BUILD
new file mode 100644
index 0000000..a820dab
--- /dev/null
+++ b/projects/gh-unnotifier/BUILD
@@ -0,0 +1,9 @@
+py_project(
+    name = "gh-unnotifier",
+    main = "src/python/ghunnotif/__main__.py",
+    main_deps = [
+        py_requirement("click"),
+        py_requirement("requests"),
+        py_requirement("pytimeparse"),
+    ]
+)
diff --git a/projects/gh-unnotifier/src/python/ghunnotif/__main__.py b/projects/gh-unnotifier/src/python/ghunnotif/__main__.py
new file mode 100644
index 0000000..d4a7e44
--- /dev/null
+++ b/projects/gh-unnotifier/src/python/ghunnotif/__main__.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+
+import os
+from pathlib import Path
+import tomllib
+from typing import Optional
+from datetime import datetime, timedelta, timezone
+from time import sleep
+
+import click
+import requests
+import pytimeparse
+
+class Client(object):
+    BASE = "https://api.github.com"
+    HEADERS = {"Accept": "application/vnd.github+json"}
+
+    def __init__(self, token):
+        self._token = token
+        self._session = requests.Session()
+        self._session.headers["Authorization"] = f"Bearer {token}"
+
+    def get_notifications(self, page=1):
+        resp = self._session.get(f"{self.BASE}/notifications", headers=self.HEADERS, params={"page": page, "all": "false"})
+        resp.raise_for_status()
+        return resp.json()
+
+    def get_all_notifications(self):
+        page = 1
+        while True:
+            results = self.get_notifications(page=page)
+            if not results:
+                return
+            yield from results
+            page += 1
+
+    def read(self, notif):
+        return self._session.patch(notif["url"]).raise_for_status()
+
+    def unsubscribe(self, notif):
+        return self._session.delete(notif["subscription_url"]).raise_for_status()
+
+    def get_pr(self,
+               url: Optional[str] = None,
+               repo: Optional[str] = None,
+               id: Optional[int] = None):
+        url = url or f"{self.BASE}/{repo}/pulls/{id}"
+        resp = self._session.get(url, headers=self.HEADERS)
+        resp.raise_for_status()
+        return resp.json()
+
+
+@click.group()
+def cli():
+    pass
+
+
+@cli.command()
+@click.option("--config", "config_path", type=Path, default=lambda: Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) / "projects/gh-unnotifier/config.toml")
+def oneshot(config_path: Path):
+    with open(config_path, "rb") as fp:
+        config = tomllib.load(fp)
+
+    client = Client(config["gh-unnotifier"]["api_key"])
+    for notif in client.get_all_notifications():
+        subject = notif["subject"]
+        pr = None
+        if "/pulls/" in subject["url"]:
+            pr = client.get_pr(url=subject["url"])
+            if pr["state"] == "closed":
+                client.read(notif)
+                client.unsubscribe(notif)
+
+                print("Resolved", notif["id"])
+            continue
+
+        print(notif["id"], notif["subscription_url"], notif["reason"], notif["subject"], pr)
+
+
+def parse_seconds(text: str) -> timedelta:
+    return timedelta(seconds=pytimeparse.parse(text))
+
+
+@cli.command()
+@click.option("--config", "config_path", type=Path, default=lambda: Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) / "projects/gh-unnotifier/config.toml")
+@click.option("--schedule", "schedule", default="15 seconds", type=parse_seconds)
+def maintain(config_path: Path, schedule: timedelta):
+    with open(config_path, "rb") as fp:
+        config = tomllib.load(fp)
+
+    client = Client(config["gh-unnotifier"]["api_key"])
+    mark = None
+    while True:
+        try:
+            prev = mark
+            mark = datetime.now(timezone.utc)
+            tick = mark + schedule
+            assert tick - schedule == mark
+            for notif in client.get_all_notifications():
+                subject = notif["subject"]
+
+                # Don't waste time on notifications which haven't changed since the last scrub
+                updated_at = datetime.fromisoformat(notif["updated_at"])
+                if prev and updated_at < prev:
+                    continue
+
+                pr = None
+                if "/pulls/" in subject["url"]:
+                    pr = client.get_pr(url=subject["url"])
+                    if pr["state"] == "closed":
+                        client.read(notif)
+                        client.unsubscribe(notif)
+
+                        click.echo(f"Resolved {notif['id']}")
+                    continue
+
+            click.echo("Napping...")
+            sleep((tick - datetime.now(timezone.utc)).total_seconds())
+
+        except KeyboardInterrupt:
+            break
+
+
+if __name__ == "__main__":
+    cli()