gh-upstream-watch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gh_upstream_watch-0.1.0/LICENSE +21 -0
- gh_upstream_watch-0.1.0/PKG-INFO +266 -0
- gh_upstream_watch-0.1.0/README.md +246 -0
- gh_upstream_watch-0.1.0/pyproject.toml +44 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/__init__.py +3 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/cli.py +396 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/contrib/cron.txt +2 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/contrib/launchd.plist.template +27 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/contrib/systemd/gh-upstream-watch.service +7 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/contrib/systemd/gh-upstream-watch.timer +11 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/core.py +200 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/github.py +84 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/hooks.py +45 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/notify.py +100 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/packs/generic.json +11 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/packs/vllm-semantic-router.json +38 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/packs.py +128 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/slack.py +112 -0
- gh_upstream_watch-0.1.0/src/gh_upstream_watch/state.py +126 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Colin McNamara LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gh-upstream-watch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only alerts for your upstream work: tells you the next action when a maintainer gate, a competing PR, or a review moves.
|
|
5
|
+
Keywords: github,gh-extension,open-source,notifications,maintainers
|
|
6
|
+
Author: Colin McNamara
|
|
7
|
+
Author-email: Colin McNamara <colin@2cups.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Project-URL: Homepage, https://github.com/colinmcnamara/gh-upstream-watch
|
|
17
|
+
Project-URL: Issues, https://github.com/colinmcnamara/gh-upstream-watch/issues
|
|
18
|
+
Project-URL: Changelog, https://github.com/colinmcnamara/gh-upstream-watch/blob/main/CHANGELOG.md
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# gh-upstream-watch
|
|
22
|
+
|
|
23
|
+
Read-only alerts for your upstream work: tells you the next action when a maintainer gate, a
|
|
24
|
+
competing PR, or a review moves.
|
|
25
|
+
|
|
26
|
+
If you contribute to projects you do not maintain, the important events are easy to miss in the
|
|
27
|
+
notification stream: a maintainer accepts your proposal and now expects you to claim it, someone
|
|
28
|
+
else opens a PR that closes the issue you are working on, your closed issue is reopened. This tool
|
|
29
|
+
polls the issues and PRs you are involved in, compares each one with the last run, and prints (and
|
|
30
|
+
optionally pops up) one line per change, worded as what to do next.
|
|
31
|
+
|
|
32
|
+
## What makes it different
|
|
33
|
+
|
|
34
|
+
1. **Next-action alerts.** Rule packs turn generic changes into instructions:
|
|
35
|
+
`ACCEPTED by @maint: comment /assign now`,
|
|
36
|
+
`COMPETING PR #401 (by @monalisa) says Closes #390: check scope before /assign`,
|
|
37
|
+
`REOPENED: claim it`. A gate only counts when the commenter is authorized
|
|
38
|
+
(a login allowlist, or a maintainer author association), so a drive-by `/accept` is ignored.
|
|
39
|
+
2. **Read-only by construction.** Every built-in GitHub call goes through one function that runs
|
|
40
|
+
`gh api --method GET`. The tool itself never comments, labels, assigns or pushes. (A `--hook`,
|
|
41
|
+
a `$GH_UPSTREAM_WATCH_NOTIFY` command or a `--webhook` you configure is your own code or
|
|
42
|
+
endpoint and can do anything; see Trust below.)
|
|
43
|
+
3. **Never silently current.** A failed page, a timeout, or a search GitHub marks
|
|
44
|
+
`incomplete_results` makes that item *unknown for this run*: its saved state is not advanced,
|
|
45
|
+
so the change alerts on the next good run instead of being swallowed.
|
|
46
|
+
4. **Quiet by default.** The first run sends no alerts; it prints one "seed run" line to stderr. Bots are filtered from comment counts.
|
|
47
|
+
Megathreads you list (for example `[Community]` issues) alert only on comments that `@`-name you.
|
|
48
|
+
|
|
49
|
+
## Demo
|
|
50
|
+
|
|
51
|
+
Two runs against recorded, synthetic GitHub data (`scripts/demo.sh`; no network, and the stand-in
|
|
52
|
+
`gh` refuses anything but GET). You are `@octocat`. The first run seeds; between the runs a
|
|
53
|
+
collaborator posts comment 101 (`/accept`, on the second page of comments; it alerts as the gate,
|
|
54
|
+
not again as "1 new comment"), a bot comments,
|
|
55
|
+
`@monalisa` opens a PR that says `Closes: #390`, your closed issue is reopened, your PR is approved
|
|
56
|
+
(and drops out of search results, but it is still open, so it stays watched), and someone names
|
|
57
|
+
you in a megathread, and a review request arrives. A mention in `acme/gadgets` is ignored because
|
|
58
|
+
notifications follow `--repos`. This is the real output:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
$ gh-upstream-watch --repos acme/widgets --packs-dir tests/fixtures/packs --notify none
|
|
62
|
+
2026-09-30 08:24 seed run: watching 4 item(s); 1 alert(s) suppressed. Alerts start after the first complete run.
|
|
63
|
+
|
|
64
|
+
$ gh-upstream-watch --repos acme/widgets --packs-dir tests/fixtures/packs --notify none
|
|
65
|
+
[2026-09-30 08:24] acme/widgets#388 Document the retry flag: REOPENED: claim it (https://github.com/acme/widgets/issues/388)
|
|
66
|
+
[2026-09-30 08:24] acme/widgets#390 Widget spins forever on an empty config: ACCEPTED by @maint: comment /assign now (https://github.com/acme/widgets/issues/390)
|
|
67
|
+
[2026-09-30 08:24] acme/widgets#390 Widget spins forever on an empty config: COMPETING PR #401 (by @monalisa) says Closes #390: check scope before /assign (https://github.com/acme/widgets/pull/401)
|
|
68
|
+
[2026-09-30 08:24] acme/widgets#392 Add retry backoff: review: @maint APPROVED (https://github.com/acme/widgets/pull/392)
|
|
69
|
+
[2026-09-30 08:24] acme/widgets#395 [Community] Weekly sync thread: 1 comment(s) naming you (https://github.com/acme/widgets/issues/395)
|
|
70
|
+
[2026-09-30 08:24] acme/widgets: someone requested your review: Tighten lint config (https://github.com/acme/widgets/pull/14)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The same two runs are a golden test (`tests/test_run.py`).
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
Requires the [GitHub CLI](https://cli.github.com) (`gh auth login` done) and Python 3.9 or newer.
|
|
78
|
+
No other dependencies.
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
uv tool install gh-upstream-watch
|
|
82
|
+
# or
|
|
83
|
+
pipx install gh-upstream-watch
|
|
84
|
+
# or, as a gh extension (runs from a checkout with your python3):
|
|
85
|
+
gh extension install colinmcnamara/gh-upstream-watch # then: gh upstream-watch --help
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Use
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
gh-upstream-watch init --repos acme/widgets # writes ~/.config/gh-upstream-watch/config.json
|
|
92
|
+
gh-upstream-watch --dry-run # one pass, print only, save nothing
|
|
93
|
+
gh-upstream-watch # one pass: alerts, then save state
|
|
94
|
+
gh-upstream-watch status # what the state file knows
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each invocation is one pass. Schedule it; the tool prints a ready scheduler entry that points at
|
|
98
|
+
the interpreter and script you ran it with:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
gh-upstream-watch --print-plist > ~/Library/LaunchAgents/local.gh-upstream-watch.plist # macOS
|
|
102
|
+
gh-upstream-watch --print-systemd # Linux
|
|
103
|
+
gh-upstream-watch --print-cron
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
What one pass checks:
|
|
107
|
+
|
|
108
|
+
- every open issue and PR in `repos` you are involved in (author, assignee, commenter, mentioned),
|
|
109
|
+
those closed in the last 14 days, `extras` (`owner/repo#n`), and anything already watched that
|
|
110
|
+
is still open. The saved state of a closed item is kept for `baseline_days` (90) after it was
|
|
111
|
+
last checked, so a reopen within that time alerts; after that it counts as new and seeds quietly;
|
|
112
|
+
- unread GitHub notifications that mention you, request your review, or assign you, in the watched
|
|
113
|
+
repos (exact names; set `notification_repos` to `["*"]` for every repo, or to globs). This needs a
|
|
114
|
+
token with the `notifications` scope: `gh auth refresh -s notifications` if they come back 403;
|
|
115
|
+
- claim boards defined by a pack (a pinned issue that lists claimable tasks), for `claim_groups`.
|
|
116
|
+
|
|
117
|
+
Exit status: 0 when every check completed, 1 when something was unknown this run (logged as
|
|
118
|
+
`unknown this run`; `gh` missing or signed out also lands here, with the fix printed), 2 for a
|
|
119
|
+
config, argument or rule-pack error (including no repos configured), 3 when another run holds the
|
|
120
|
+
state lock. `status` is a doctor: it checks `gh` and your login, shows the config path, the packs
|
|
121
|
+
applied to each repo, the last complete run, and what the last run could not check.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
JSON, at `$XDG_CONFIG_HOME/gh-upstream-watch/config.json` (default `~/.config/...`). Precedence:
|
|
126
|
+
command line, then environment (`GH_UPSTREAM_WATCH_CONFIG`, `GH_UPSTREAM_WATCH_REPOS` comma list,
|
|
127
|
+
`GH_UPSTREAM_WATCH_STATE`), then the file. Unknown keys are errors.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"repos": ["acme/widgets", "acme/gadgets"],
|
|
132
|
+
"extras": ["octo-org/octo-repo#12"],
|
|
133
|
+
"notify": "auto",
|
|
134
|
+
"webhook": null,
|
|
135
|
+
"hook": null,
|
|
136
|
+
"packs_dirs": [],
|
|
137
|
+
"claim_groups": [],
|
|
138
|
+
"recent_closed_days": 14,
|
|
139
|
+
"retention_days": 30,
|
|
140
|
+
"baseline_days": 90,
|
|
141
|
+
"notification_repos": null,
|
|
142
|
+
"login": "octocat",
|
|
143
|
+
"bots": [],
|
|
144
|
+
"slack": {"enabled": false}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Notifications: stdout always (text with the URL, or `--json` for JSON lines). `notify: auto` uses
|
|
149
|
+
`$GH_UPSTREAM_WATCH_NOTIFY` (a command that gets the alert JSON on stdin) if set, else
|
|
150
|
+
`terminal-notifier`, `notify-send`, or `osascript` (macOS; the URL is kept in the visible text,
|
|
151
|
+
since those banners cannot open a link; the text is passed as arguments, never as script).
|
|
152
|
+
`--webhook URL` also POSTs `{"text": ..., "alert": ...}`; it must be https (http only to
|
|
153
|
+
localhost), and only a 2xx answer counts; a redirect is a failure and is not followed. Each alert is delivered to each destination separately; a destination that fails
|
|
154
|
+
stays in the outbox and is retried on later runs (up to 5), without printing the alert again.
|
|
155
|
+
Only `https://github.com/` links (and `https://*.slack.com/` for Slack) are passed to a notifier
|
|
156
|
+
to open; a link from comment text that points elsewhere is replaced by the item's own URL.
|
|
157
|
+
|
|
158
|
+
## Rule packs
|
|
159
|
+
|
|
160
|
+
Packs are local JSON data, loaded from the bundled `packs/`, then
|
|
161
|
+
`~/.config/gh-upstream-watch/packs/`, then any `--packs-dir`. A pack with the same `id` replaces
|
|
162
|
+
an earlier one. Packs are never read from a watched repository: the repo you are watching must not
|
|
163
|
+
be able to decide what you are told. Bundled: `generic` (wording for every repo) and
|
|
164
|
+
`vllm-semantic-router` (the `/accept` then `/assign` flow of `vllm-project/semantic-router`).
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"id": "acme",
|
|
169
|
+
"repos": ["acme/*"],
|
|
170
|
+
"gates": [{
|
|
171
|
+
"id": "accept",
|
|
172
|
+
"comment": "^/accept\\b",
|
|
173
|
+
"authorized_by": {"associations": ["MEMBER", "OWNER"], "logins": []},
|
|
174
|
+
"then": "^/assign\\b",
|
|
175
|
+
"alert": "ACCEPTED by @{actor}: comment /assign now",
|
|
176
|
+
"alert_done": "ACCEPTED by @{actor}"
|
|
177
|
+
}],
|
|
178
|
+
"label_transitions": [{"id": "stuck", "assigned_to_me": true, "has": ["accepted"], "lacks": ["in-progress"],
|
|
179
|
+
"alert": "in-progress label missing"}],
|
|
180
|
+
"quiet_titles": ["^\\[Community\\]"],
|
|
181
|
+
"messages": {"reopened": "REOPENED: claim it",
|
|
182
|
+
"competing_pr": "COMPETING PR #{number} (by @{author}) says Closes #{target}: check scope"}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A gate matches only at the start of a comment (not a quoted line further down). If a gate lists
|
|
187
|
+
`logins`, only those logins count. Otherwise only its `associations` count, `OWNER` and
|
|
188
|
+
`COLLABORATOR` by default; `MEMBER` is not a default because an organization member can have
|
|
189
|
+
read-only access. Author association alone cannot prove who may run a gate, so add the
|
|
190
|
+
maintainers' logins to your copy of a pack when you know them. On first sight of an item, gates
|
|
191
|
+
are checked too: an `/accept` already waiting alerts once. `then` is your own follow-up command; once you have posted it the alert drops the
|
|
192
|
+
call to action. See `CONTRIBUTING.md` for the full schema and how to add a pack with a fixture.
|
|
193
|
+
|
|
194
|
+
For anything a pack cannot express, `--hook /abs/path` runs your program once per
|
|
195
|
+
watched item that has a previous fingerprint (no shell, 30 s timeout) with `{"key", "repo", "number", "me", "old", "new"}` on
|
|
196
|
+
stdin; each stdout line `{"message": "...", "kind": "...", "url": "..."}` becomes an alert.
|
|
197
|
+
|
|
198
|
+
## Reliability
|
|
199
|
+
|
|
200
|
+
- Full pagination for search, comments, reviews, timeline and notifications. A search over
|
|
201
|
+
GitHub's 1,000-result cap is reported as unknown with a warning (no partitioning yet).
|
|
202
|
+
- One failing item never loses the run; the state file is always saved.
|
|
203
|
+
- The first run is silent, and the "seeded" flag is set only after a run in which every check
|
|
204
|
+
succeeded, so a half-failed first run cannot flood you later.
|
|
205
|
+
- State writes are atomic (temp file, fsync, rename) under an exclusive lock. A corrupt state file
|
|
206
|
+
is moved aside and the next run re-seeds quietly.
|
|
207
|
+
- Alerts are written to an outbox in the state file before delivery; a crash in between
|
|
208
|
+
re-delivers them on the next run (at least once; a duplicate is possible, a loss is not).
|
|
209
|
+
- The state file is written mode 0600 (it holds titles and URLs from private repos).
|
|
210
|
+
- Seen-id stores are pruned after `retention_days`, only after a complete pass, and never for an
|
|
211
|
+
id GitHub still returns.
|
|
212
|
+
|
|
213
|
+
Known limits: polling cannot see an event GitHub never exposes, or a state that changes twice
|
|
214
|
+
between polls. Notification asks use GitHub's `participating` filter, so an ask GitHub does not
|
|
215
|
+
count as participating is not seen there (the issue and PR checks still run).
|
|
216
|
+
|
|
217
|
+
## Trust
|
|
218
|
+
|
|
219
|
+
The tool only reads GitHub. Three things you configure are trusted local code or endpoints:
|
|
220
|
+
`--hook` runs a program with your permissions, `$GH_UPSTREAM_WATCH_NOTIFY` runs a command, and
|
|
221
|
+
`--webhook` sends every alert (titles, logins, URLs) to that URL. Rule packs are data, but only
|
|
222
|
+
load packs you have read. A failing hook makes that item unknown for the run, so nothing is lost.
|
|
223
|
+
|
|
224
|
+
## Slack (optional, off by default)
|
|
225
|
+
|
|
226
|
+
`--slack` (or `"slack": {"enabled": true}`) adds Slack mentions and replies in your threads. Read
|
|
227
|
+
this before enabling it:
|
|
228
|
+
|
|
229
|
+
- It needs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and the official Slack
|
|
230
|
+
plugin, already signed in. Each check is one headless `claude -p` call with a small model
|
|
231
|
+
(`haiku` by default), no built-in tools (`--tools ""`), only the Slack search and read-thread
|
|
232
|
+
tools allowed, no user or project settings, an empty temporary working directory, and a minimal
|
|
233
|
+
environment (no `GH_TOKEN`). That call costs money or plan usage; `every_minutes` (default 25)
|
|
234
|
+
throttles it.
|
|
235
|
+
- Model output is untrusted. The answer must be exactly a JSON array. Each item must carry a
|
|
236
|
+
Slack channel id, a message ts and the text, plus proof: the raw `<@you>` tag for a mention, or
|
|
237
|
+
the ts of your own message for a thread reply. Items without proof, outside `channels` (when
|
|
238
|
+
set), or older than `days` are dropped. Be clear about what that proves: the proof fields are
|
|
239
|
+
themselves model output, so a prompt injection in a Slack message can forge them. The damage is
|
|
240
|
+
limited because the model has no tools except two Slack read tools, and only
|
|
241
|
+
`https://*.slack.com/` links are ever passed to a notifier.
|
|
242
|
+
- Slack failures are counted in their own state (`status` shows them) and never make the GitHub
|
|
243
|
+
pass incomplete or block it.
|
|
244
|
+
- Nothing is shipped: `user`, `query` and `channels` come from your config. The query is a prompt
|
|
245
|
+
with `{user}` and `{days}` placeholders; it must tell the model to output the fields above:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
"slack": {
|
|
249
|
+
"enabled": true,
|
|
250
|
+
"user": "UEXAMPLE1",
|
|
251
|
+
"days": 14,
|
|
252
|
+
"every_minutes": 25,
|
|
253
|
+
"channels": [],
|
|
254
|
+
"query": "You are a read-only Slack checker for user {user}. Search for messages from the last {days} days that mention <@{user}>, and replies to threads {user} posted in. Output ONLY a JSON array; each element: {{\"channel\": \"#name\", \"channel_id\": \"C...\", \"ts\": \"...\", \"author\": \"...\", \"kind\": \"mention\" or \"thread_reply\", \"your_ts\": \"ts of {user}'s own message in the thread, else empty\", \"evidence\": \"the raw mention text, e.g. <@{user}|name>, else empty\", \"text\": \"first 120 characters\", \"link\": \"permalink or empty\"}}. If there is nothing, output []."
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Migrating from the single-file script
|
|
259
|
+
|
|
260
|
+
`gh-upstream-watch migrate --from OLD_STATE.json --state NEW_STATE.json --claim-repo owner/repo`
|
|
261
|
+
converts a v0 state file (one flat dict of fingerprints plus `_notifications`, `_slack`,
|
|
262
|
+
`_claimable`). Fingerprints and seen ids are kept, so nothing re-alerts and nothing re-seeds.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# gh-upstream-watch
|
|
2
|
+
|
|
3
|
+
Read-only alerts for your upstream work: tells you the next action when a maintainer gate, a
|
|
4
|
+
competing PR, or a review moves.
|
|
5
|
+
|
|
6
|
+
If you contribute to projects you do not maintain, the important events are easy to miss in the
|
|
7
|
+
notification stream: a maintainer accepts your proposal and now expects you to claim it, someone
|
|
8
|
+
else opens a PR that closes the issue you are working on, your closed issue is reopened. This tool
|
|
9
|
+
polls the issues and PRs you are involved in, compares each one with the last run, and prints (and
|
|
10
|
+
optionally pops up) one line per change, worded as what to do next.
|
|
11
|
+
|
|
12
|
+
## What makes it different
|
|
13
|
+
|
|
14
|
+
1. **Next-action alerts.** Rule packs turn generic changes into instructions:
|
|
15
|
+
`ACCEPTED by @maint: comment /assign now`,
|
|
16
|
+
`COMPETING PR #401 (by @monalisa) says Closes #390: check scope before /assign`,
|
|
17
|
+
`REOPENED: claim it`. A gate only counts when the commenter is authorized
|
|
18
|
+
(a login allowlist, or a maintainer author association), so a drive-by `/accept` is ignored.
|
|
19
|
+
2. **Read-only by construction.** Every built-in GitHub call goes through one function that runs
|
|
20
|
+
`gh api --method GET`. The tool itself never comments, labels, assigns or pushes. (A `--hook`,
|
|
21
|
+
a `$GH_UPSTREAM_WATCH_NOTIFY` command or a `--webhook` you configure is your own code or
|
|
22
|
+
endpoint and can do anything; see Trust below.)
|
|
23
|
+
3. **Never silently current.** A failed page, a timeout, or a search GitHub marks
|
|
24
|
+
`incomplete_results` makes that item *unknown for this run*: its saved state is not advanced,
|
|
25
|
+
so the change alerts on the next good run instead of being swallowed.
|
|
26
|
+
4. **Quiet by default.** The first run sends no alerts; it prints one "seed run" line to stderr. Bots are filtered from comment counts.
|
|
27
|
+
Megathreads you list (for example `[Community]` issues) alert only on comments that `@`-name you.
|
|
28
|
+
|
|
29
|
+
## Demo
|
|
30
|
+
|
|
31
|
+
Two runs against recorded, synthetic GitHub data (`scripts/demo.sh`; no network, and the stand-in
|
|
32
|
+
`gh` refuses anything but GET). You are `@octocat`. The first run seeds; between the runs a
|
|
33
|
+
collaborator posts comment 101 (`/accept`, on the second page of comments; it alerts as the gate,
|
|
34
|
+
not again as "1 new comment"), a bot comments,
|
|
35
|
+
`@monalisa` opens a PR that says `Closes: #390`, your closed issue is reopened, your PR is approved
|
|
36
|
+
(and drops out of search results, but it is still open, so it stays watched), and someone names
|
|
37
|
+
you in a megathread, and a review request arrives. A mention in `acme/gadgets` is ignored because
|
|
38
|
+
notifications follow `--repos`. This is the real output:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
$ gh-upstream-watch --repos acme/widgets --packs-dir tests/fixtures/packs --notify none
|
|
42
|
+
2026-09-30 08:24 seed run: watching 4 item(s); 1 alert(s) suppressed. Alerts start after the first complete run.
|
|
43
|
+
|
|
44
|
+
$ gh-upstream-watch --repos acme/widgets --packs-dir tests/fixtures/packs --notify none
|
|
45
|
+
[2026-09-30 08:24] acme/widgets#388 Document the retry flag: REOPENED: claim it (https://github.com/acme/widgets/issues/388)
|
|
46
|
+
[2026-09-30 08:24] acme/widgets#390 Widget spins forever on an empty config: ACCEPTED by @maint: comment /assign now (https://github.com/acme/widgets/issues/390)
|
|
47
|
+
[2026-09-30 08:24] acme/widgets#390 Widget spins forever on an empty config: COMPETING PR #401 (by @monalisa) says Closes #390: check scope before /assign (https://github.com/acme/widgets/pull/401)
|
|
48
|
+
[2026-09-30 08:24] acme/widgets#392 Add retry backoff: review: @maint APPROVED (https://github.com/acme/widgets/pull/392)
|
|
49
|
+
[2026-09-30 08:24] acme/widgets#395 [Community] Weekly sync thread: 1 comment(s) naming you (https://github.com/acme/widgets/issues/395)
|
|
50
|
+
[2026-09-30 08:24] acme/widgets: someone requested your review: Tighten lint config (https://github.com/acme/widgets/pull/14)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The same two runs are a golden test (`tests/test_run.py`).
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Requires the [GitHub CLI](https://cli.github.com) (`gh auth login` done) and Python 3.9 or newer.
|
|
58
|
+
No other dependencies.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
uv tool install gh-upstream-watch
|
|
62
|
+
# or
|
|
63
|
+
pipx install gh-upstream-watch
|
|
64
|
+
# or, as a gh extension (runs from a checkout with your python3):
|
|
65
|
+
gh extension install colinmcnamara/gh-upstream-watch # then: gh upstream-watch --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Use
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
gh-upstream-watch init --repos acme/widgets # writes ~/.config/gh-upstream-watch/config.json
|
|
72
|
+
gh-upstream-watch --dry-run # one pass, print only, save nothing
|
|
73
|
+
gh-upstream-watch # one pass: alerts, then save state
|
|
74
|
+
gh-upstream-watch status # what the state file knows
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each invocation is one pass. Schedule it; the tool prints a ready scheduler entry that points at
|
|
78
|
+
the interpreter and script you ran it with:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
gh-upstream-watch --print-plist > ~/Library/LaunchAgents/local.gh-upstream-watch.plist # macOS
|
|
82
|
+
gh-upstream-watch --print-systemd # Linux
|
|
83
|
+
gh-upstream-watch --print-cron
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
What one pass checks:
|
|
87
|
+
|
|
88
|
+
- every open issue and PR in `repos` you are involved in (author, assignee, commenter, mentioned),
|
|
89
|
+
those closed in the last 14 days, `extras` (`owner/repo#n`), and anything already watched that
|
|
90
|
+
is still open. The saved state of a closed item is kept for `baseline_days` (90) after it was
|
|
91
|
+
last checked, so a reopen within that time alerts; after that it counts as new and seeds quietly;
|
|
92
|
+
- unread GitHub notifications that mention you, request your review, or assign you, in the watched
|
|
93
|
+
repos (exact names; set `notification_repos` to `["*"]` for every repo, or to globs). This needs a
|
|
94
|
+
token with the `notifications` scope: `gh auth refresh -s notifications` if they come back 403;
|
|
95
|
+
- claim boards defined by a pack (a pinned issue that lists claimable tasks), for `claim_groups`.
|
|
96
|
+
|
|
97
|
+
Exit status: 0 when every check completed, 1 when something was unknown this run (logged as
|
|
98
|
+
`unknown this run`; `gh` missing or signed out also lands here, with the fix printed), 2 for a
|
|
99
|
+
config, argument or rule-pack error (including no repos configured), 3 when another run holds the
|
|
100
|
+
state lock. `status` is a doctor: it checks `gh` and your login, shows the config path, the packs
|
|
101
|
+
applied to each repo, the last complete run, and what the last run could not check.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
JSON, at `$XDG_CONFIG_HOME/gh-upstream-watch/config.json` (default `~/.config/...`). Precedence:
|
|
106
|
+
command line, then environment (`GH_UPSTREAM_WATCH_CONFIG`, `GH_UPSTREAM_WATCH_REPOS` comma list,
|
|
107
|
+
`GH_UPSTREAM_WATCH_STATE`), then the file. Unknown keys are errors.
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"repos": ["acme/widgets", "acme/gadgets"],
|
|
112
|
+
"extras": ["octo-org/octo-repo#12"],
|
|
113
|
+
"notify": "auto",
|
|
114
|
+
"webhook": null,
|
|
115
|
+
"hook": null,
|
|
116
|
+
"packs_dirs": [],
|
|
117
|
+
"claim_groups": [],
|
|
118
|
+
"recent_closed_days": 14,
|
|
119
|
+
"retention_days": 30,
|
|
120
|
+
"baseline_days": 90,
|
|
121
|
+
"notification_repos": null,
|
|
122
|
+
"login": "octocat",
|
|
123
|
+
"bots": [],
|
|
124
|
+
"slack": {"enabled": false}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Notifications: stdout always (text with the URL, or `--json` for JSON lines). `notify: auto` uses
|
|
129
|
+
`$GH_UPSTREAM_WATCH_NOTIFY` (a command that gets the alert JSON on stdin) if set, else
|
|
130
|
+
`terminal-notifier`, `notify-send`, or `osascript` (macOS; the URL is kept in the visible text,
|
|
131
|
+
since those banners cannot open a link; the text is passed as arguments, never as script).
|
|
132
|
+
`--webhook URL` also POSTs `{"text": ..., "alert": ...}`; it must be https (http only to
|
|
133
|
+
localhost), and only a 2xx answer counts; a redirect is a failure and is not followed. Each alert is delivered to each destination separately; a destination that fails
|
|
134
|
+
stays in the outbox and is retried on later runs (up to 5), without printing the alert again.
|
|
135
|
+
Only `https://github.com/` links (and `https://*.slack.com/` for Slack) are passed to a notifier
|
|
136
|
+
to open; a link from comment text that points elsewhere is replaced by the item's own URL.
|
|
137
|
+
|
|
138
|
+
## Rule packs
|
|
139
|
+
|
|
140
|
+
Packs are local JSON data, loaded from the bundled `packs/`, then
|
|
141
|
+
`~/.config/gh-upstream-watch/packs/`, then any `--packs-dir`. A pack with the same `id` replaces
|
|
142
|
+
an earlier one. Packs are never read from a watched repository: the repo you are watching must not
|
|
143
|
+
be able to decide what you are told. Bundled: `generic` (wording for every repo) and
|
|
144
|
+
`vllm-semantic-router` (the `/accept` then `/assign` flow of `vllm-project/semantic-router`).
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"id": "acme",
|
|
149
|
+
"repos": ["acme/*"],
|
|
150
|
+
"gates": [{
|
|
151
|
+
"id": "accept",
|
|
152
|
+
"comment": "^/accept\\b",
|
|
153
|
+
"authorized_by": {"associations": ["MEMBER", "OWNER"], "logins": []},
|
|
154
|
+
"then": "^/assign\\b",
|
|
155
|
+
"alert": "ACCEPTED by @{actor}: comment /assign now",
|
|
156
|
+
"alert_done": "ACCEPTED by @{actor}"
|
|
157
|
+
}],
|
|
158
|
+
"label_transitions": [{"id": "stuck", "assigned_to_me": true, "has": ["accepted"], "lacks": ["in-progress"],
|
|
159
|
+
"alert": "in-progress label missing"}],
|
|
160
|
+
"quiet_titles": ["^\\[Community\\]"],
|
|
161
|
+
"messages": {"reopened": "REOPENED: claim it",
|
|
162
|
+
"competing_pr": "COMPETING PR #{number} (by @{author}) says Closes #{target}: check scope"}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A gate matches only at the start of a comment (not a quoted line further down). If a gate lists
|
|
167
|
+
`logins`, only those logins count. Otherwise only its `associations` count, `OWNER` and
|
|
168
|
+
`COLLABORATOR` by default; `MEMBER` is not a default because an organization member can have
|
|
169
|
+
read-only access. Author association alone cannot prove who may run a gate, so add the
|
|
170
|
+
maintainers' logins to your copy of a pack when you know them. On first sight of an item, gates
|
|
171
|
+
are checked too: an `/accept` already waiting alerts once. `then` is your own follow-up command; once you have posted it the alert drops the
|
|
172
|
+
call to action. See `CONTRIBUTING.md` for the full schema and how to add a pack with a fixture.
|
|
173
|
+
|
|
174
|
+
For anything a pack cannot express, `--hook /abs/path` runs your program once per
|
|
175
|
+
watched item that has a previous fingerprint (no shell, 30 s timeout) with `{"key", "repo", "number", "me", "old", "new"}` on
|
|
176
|
+
stdin; each stdout line `{"message": "...", "kind": "...", "url": "..."}` becomes an alert.
|
|
177
|
+
|
|
178
|
+
## Reliability
|
|
179
|
+
|
|
180
|
+
- Full pagination for search, comments, reviews, timeline and notifications. A search over
|
|
181
|
+
GitHub's 1,000-result cap is reported as unknown with a warning (no partitioning yet).
|
|
182
|
+
- One failing item never loses the run; the state file is always saved.
|
|
183
|
+
- The first run is silent, and the "seeded" flag is set only after a run in which every check
|
|
184
|
+
succeeded, so a half-failed first run cannot flood you later.
|
|
185
|
+
- State writes are atomic (temp file, fsync, rename) under an exclusive lock. A corrupt state file
|
|
186
|
+
is moved aside and the next run re-seeds quietly.
|
|
187
|
+
- Alerts are written to an outbox in the state file before delivery; a crash in between
|
|
188
|
+
re-delivers them on the next run (at least once; a duplicate is possible, a loss is not).
|
|
189
|
+
- The state file is written mode 0600 (it holds titles and URLs from private repos).
|
|
190
|
+
- Seen-id stores are pruned after `retention_days`, only after a complete pass, and never for an
|
|
191
|
+
id GitHub still returns.
|
|
192
|
+
|
|
193
|
+
Known limits: polling cannot see an event GitHub never exposes, or a state that changes twice
|
|
194
|
+
between polls. Notification asks use GitHub's `participating` filter, so an ask GitHub does not
|
|
195
|
+
count as participating is not seen there (the issue and PR checks still run).
|
|
196
|
+
|
|
197
|
+
## Trust
|
|
198
|
+
|
|
199
|
+
The tool only reads GitHub. Three things you configure are trusted local code or endpoints:
|
|
200
|
+
`--hook` runs a program with your permissions, `$GH_UPSTREAM_WATCH_NOTIFY` runs a command, and
|
|
201
|
+
`--webhook` sends every alert (titles, logins, URLs) to that URL. Rule packs are data, but only
|
|
202
|
+
load packs you have read. A failing hook makes that item unknown for the run, so nothing is lost.
|
|
203
|
+
|
|
204
|
+
## Slack (optional, off by default)
|
|
205
|
+
|
|
206
|
+
`--slack` (or `"slack": {"enabled": true}`) adds Slack mentions and replies in your threads. Read
|
|
207
|
+
this before enabling it:
|
|
208
|
+
|
|
209
|
+
- It needs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and the official Slack
|
|
210
|
+
plugin, already signed in. Each check is one headless `claude -p` call with a small model
|
|
211
|
+
(`haiku` by default), no built-in tools (`--tools ""`), only the Slack search and read-thread
|
|
212
|
+
tools allowed, no user or project settings, an empty temporary working directory, and a minimal
|
|
213
|
+
environment (no `GH_TOKEN`). That call costs money or plan usage; `every_minutes` (default 25)
|
|
214
|
+
throttles it.
|
|
215
|
+
- Model output is untrusted. The answer must be exactly a JSON array. Each item must carry a
|
|
216
|
+
Slack channel id, a message ts and the text, plus proof: the raw `<@you>` tag for a mention, or
|
|
217
|
+
the ts of your own message for a thread reply. Items without proof, outside `channels` (when
|
|
218
|
+
set), or older than `days` are dropped. Be clear about what that proves: the proof fields are
|
|
219
|
+
themselves model output, so a prompt injection in a Slack message can forge them. The damage is
|
|
220
|
+
limited because the model has no tools except two Slack read tools, and only
|
|
221
|
+
`https://*.slack.com/` links are ever passed to a notifier.
|
|
222
|
+
- Slack failures are counted in their own state (`status` shows them) and never make the GitHub
|
|
223
|
+
pass incomplete or block it.
|
|
224
|
+
- Nothing is shipped: `user`, `query` and `channels` come from your config. The query is a prompt
|
|
225
|
+
with `{user}` and `{days}` placeholders; it must tell the model to output the fields above:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
"slack": {
|
|
229
|
+
"enabled": true,
|
|
230
|
+
"user": "UEXAMPLE1",
|
|
231
|
+
"days": 14,
|
|
232
|
+
"every_minutes": 25,
|
|
233
|
+
"channels": [],
|
|
234
|
+
"query": "You are a read-only Slack checker for user {user}. Search for messages from the last {days} days that mention <@{user}>, and replies to threads {user} posted in. Output ONLY a JSON array; each element: {{\"channel\": \"#name\", \"channel_id\": \"C...\", \"ts\": \"...\", \"author\": \"...\", \"kind\": \"mention\" or \"thread_reply\", \"your_ts\": \"ts of {user}'s own message in the thread, else empty\", \"evidence\": \"the raw mention text, e.g. <@{user}|name>, else empty\", \"text\": \"first 120 characters\", \"link\": \"permalink or empty\"}}. If there is nothing, output []."
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Migrating from the single-file script
|
|
239
|
+
|
|
240
|
+
`gh-upstream-watch migrate --from OLD_STATE.json --state NEW_STATE.json --claim-repo owner/repo`
|
|
241
|
+
converts a v0 state file (one flat dict of fingerprints plus `_notifications`, `_slack`,
|
|
242
|
+
`_claimable`). Fingerprints and seen ids are kept, so nothing re-alerts and nothing re-seeds.
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.10,<0.11"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gh-upstream-watch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read-only alerts for your upstream work: tells you the next action when a maintainer gate, a competing PR, or a review moves."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Colin McNamara", email = "colin@2cups.com" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["github", "gh-extension", "open-source", "notifications", "maintainers"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/colinmcnamara/gh-upstream-watch"
|
|
26
|
+
Issues = "https://github.com/colinmcnamara/gh-upstream-watch/issues"
|
|
27
|
+
Changelog = "https://github.com/colinmcnamara/gh-upstream-watch/blob/main/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
gh-upstream-watch = "gh_upstream_watch.cli:main"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest", "ruff"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py39"
|
|
40
|
+
line-length = 150
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "W", "B", "UP"]
|
|
44
|
+
ignore = ["B904"]
|