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.
@@ -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"]
@@ -0,0 +1,3 @@
1
+ """gh-upstream-watch: read-only alerts for your upstream work."""
2
+
3
+ __version__ = "0.1.0"