glab-cli-extras 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.
Files changed (65) hide show
  1. glab_cli_extras-0.1.0/.editorconfig +20 -0
  2. glab_cli_extras-0.1.0/.github/workflows/ci.yml +47 -0
  3. glab_cli_extras-0.1.0/.github/workflows/publish.yml +44 -0
  4. glab_cli_extras-0.1.0/.gitignore +10 -0
  5. glab_cli_extras-0.1.0/.python-version +1 -0
  6. glab_cli_extras-0.1.0/LICENSE +21 -0
  7. glab_cli_extras-0.1.0/PKG-INFO +230 -0
  8. glab_cli_extras-0.1.0/README.md +207 -0
  9. glab_cli_extras-0.1.0/pyproject.toml +74 -0
  10. glab_cli_extras-0.1.0/src/glab_cli_extras/__init__.py +3 -0
  11. glab_cli_extras-0.1.0/src/glab_cli_extras/artifacts/__init__.py +0 -0
  12. glab_cli_extras-0.1.0/src/glab_cli_extras/artifacts/job_logs.py +95 -0
  13. glab_cli_extras-0.1.0/src/glab_cli_extras/artifacts/threads.py +147 -0
  14. glab_cli_extras-0.1.0/src/glab_cli_extras/cli/__init__.py +0 -0
  15. glab_cli_extras-0.1.0/src/glab_cli_extras/cli/watch.py +426 -0
  16. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/__init__.py +0 -0
  17. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/__main__.py +10 -0
  18. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/engine.py +491 -0
  19. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/lifecycle.py +186 -0
  20. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/locks.py +85 -0
  21. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/scheduler.py +52 -0
  22. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/server.py +79 -0
  23. glab_cli_extras-0.1.0/src/glab_cli_extras/daemon/transport.py +185 -0
  24. glab_cli_extras-0.1.0/src/glab_cli_extras/events.py +165 -0
  25. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/__init__.py +0 -0
  26. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/budget.py +135 -0
  27. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/client.py +225 -0
  28. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/glab.py +175 -0
  29. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/queries/__init__.py +11 -0
  30. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  31. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  32. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  33. glab_cli_extras-0.1.0/src/glab_cli_extras/gitlab/refs.py +151 -0
  34. glab_cli_extras-0.1.0/src/glab_cli_extras/paths.py +79 -0
  35. glab_cli_extras-0.1.0/src/glab_cli_extras/py.typed +0 -0
  36. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/__init__.py +19 -0
  37. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/base.py +146 -0
  38. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/branch.py +265 -0
  39. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/mr.py +445 -0
  40. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/mr_list.py +150 -0
  41. glab_cli_extras-0.1.0/src/glab_cli_extras/resources/pipeline.py +319 -0
  42. glab_cli_extras-0.1.0/src/glab_cli_extras/serde.py +75 -0
  43. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/__init__.py +0 -0
  44. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/changelog.py +86 -0
  45. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/claude.py +193 -0
  46. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/client.py +316 -0
  47. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/sinks.py +83 -0
  48. glab_cli_extras-0.1.0/src/glab_cli_extras/watch/streams.py +80 -0
  49. glab_cli_extras-0.1.0/tests/conftest.py +50 -0
  50. glab_cli_extras-0.1.0/tests/fakeglab.py +170 -0
  51. glab_cli_extras-0.1.0/tests/shapes.py +242 -0
  52. glab_cli_extras-0.1.0/tests/test_budget_scheduler.py +75 -0
  53. glab_cli_extras-0.1.0/tests/test_claude_stream.py +257 -0
  54. glab_cli_extras-0.1.0/tests/test_cli.py +48 -0
  55. glab_cli_extras-0.1.0/tests/test_daemon.py +262 -0
  56. glab_cli_extras-0.1.0/tests/test_engine.py +207 -0
  57. glab_cli_extras-0.1.0/tests/test_events_filter.py +64 -0
  58. glab_cli_extras-0.1.0/tests/test_gitlab_client.py +122 -0
  59. glab_cli_extras-0.1.0/tests/test_locks.py +45 -0
  60. glab_cli_extras-0.1.0/tests/test_mr.py +187 -0
  61. glab_cli_extras-0.1.0/tests/test_mr_list_branch.py +182 -0
  62. glab_cli_extras-0.1.0/tests/test_pipeline.py +165 -0
  63. glab_cli_extras-0.1.0/tests/test_refs_serde_artifacts.py +102 -0
  64. glab_cli_extras-0.1.0/tests/test_watcher.py +113 -0
  65. glab_cli_extras-0.1.0/uv.lock +537 -0
@@ -0,0 +1,20 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+ indent_style = space
9
+
10
+ [*.py]
11
+ indent_size = 4
12
+
13
+ [*.{toml,yml,yaml,json}]
14
+ indent_size = 2
15
+
16
+ [*.md]
17
+ trim_trailing_whitespace = false
18
+
19
+ [Makefile]
20
+ indent_style = tab
@@ -0,0 +1,47 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - uses: astral-sh/setup-uv@v7
14
+ with:
15
+ enable-cache: true
16
+ - run: uv sync --locked
17
+ - run: uv run ruff check --output-format github
18
+ - run: uv run ruff format --check
19
+ - run: uv run mypy
20
+
21
+ test:
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ os: [ubuntu-latest, macos-latest]
26
+ python: ["3.11", "3.13"]
27
+ runs-on: ${{ matrix.os }}
28
+ steps:
29
+ - uses: actions/checkout@v5
30
+ - uses: astral-sh/setup-uv@v7
31
+ with:
32
+ enable-cache: true
33
+ python-version: ${{ matrix.python }}
34
+ - run: uv sync --locked
35
+ - run: uv run pytest
36
+
37
+ # Windows gets "the daemon and the stdout stream work": the lock, the TCP transport, spawning and the CLI.
38
+ test-windows-daemon:
39
+ runs-on: windows-latest
40
+ steps:
41
+ - uses: actions/checkout@v5
42
+ - uses: astral-sh/setup-uv@v7
43
+ with:
44
+ enable-cache: true
45
+ python-version: "3.13"
46
+ - run: uv sync --locked
47
+ - run: uv run pytest tests/test_locks.py tests/test_daemon.py
@@ -0,0 +1,44 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - uses: astral-sh/setup-uv@v7
18
+ with:
19
+ enable-cache: true
20
+
21
+ - name: Build package
22
+ run: uv build
23
+
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ name: Publish
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment:
34
+ name: pypi
35
+ url: https://pypi.org/p/glab-cli-extras
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .claude/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ build/
10
+ *.egg-info/
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filip Procházka
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,230 @@
1
+ Metadata-Version: 2.5
2
+ Name: glab-cli-extras
3
+ Version: 0.1.0
4
+ Summary: Extra commands for the GitLab CLI (glab): glab-watch reports changes to merge requests, pipelines and branches.
5
+ Project-URL: Homepage, https://github.com/fprochazka/glab-cli-extras
6
+ Project-URL: Repository, https://github.com/fprochazka/glab-cli-extras
7
+ Project-URL: Issues, https://github.com/fprochazka/glab-cli-extras/issues
8
+ Author-email: Filip Procházka <dev@fprochazka.cz>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: cc-blackbox<0.3,>=0.2
20
+ Requires-Dist: rich>=14
21
+ Requires-Dist: typer>=0.15
22
+ Description-Content-Type: text/markdown
23
+
24
+ # glab-cli-extras
25
+
26
+ Extra commands for the [GitLab CLI](https://gitlab.com/gitlab-org/cli) (`glab`). Version 0.1 has one command, `glab-watch`: it watches a merge request, a pipeline, all merge requests of a project, or a branch, and reports every change as one JSON event per line. It is built for coding agents that wait on CI and review, and it works just as well in a terminal or a script.
27
+
28
+ Every GitLab request goes through `glab api`, so `glab` handles authentication, hosts, OAuth refresh, proxies and certificates. `glab-watch` never reads or stores a token. A shared per-user daemon does the polling, so ten watchers cost about as much API budget as one.
29
+
30
+ ## Install
31
+
32
+ You need Python 3.11 or later, [uv](https://docs.astral.sh/uv/), and `glab` logged in to your GitLab host (`glab auth status`).
33
+
34
+ ```bash
35
+ uv tool install glab-cli-extras
36
+ ```
37
+
38
+ From git, for the latest unreleased changes:
39
+
40
+ ```bash
41
+ uv tool install git+https://github.com/fprochazka/glab-cli-extras
42
+ ```
43
+
44
+ From a local checkout:
45
+
46
+ ```bash
47
+ uv tool install --editable /path/to/glab-cli-extras
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ```bash
53
+ glab-watch mr group/project!123 # a merge request; also !123 or 123 with the repo from the cwd, or the MR URL
54
+ glab-watch pipeline group/project!4567 # a pipeline and its downstream pipelines; also the pipeline URL
55
+ glab-watch mrs group/project # every merge request of a project
56
+ glab-watch branch group/project main # a branch; the repo is optional: glab-watch branch main
57
+ ```
58
+
59
+ `<repo>` means the same as it does to glab: `group/project`, `group/sub/project`, `host/group/project`, a project URL, or nothing for the repository of the current directory. `glab-watch` asks `glab repo view` to resolve it, which costs one request at start. A URL costs nothing.
60
+
61
+ A watcher runs until its resource ends (an MR is merged or closed, a pipeline finishes), until `--until` or `--timeout` fires, or until it gets SIGTERM or Ctrl-C. `mrs` and `branch` run until stopped. The last event is always `watch_ended` with a `reason`.
62
+
63
+ ### Options
64
+
65
+ All commands:
66
+
67
+ - `--events=<kinds or groups>`: report only these. Comma-separated, repeatable. The default is every event the resource tracks.
68
+ - `--exclude-events=<kinds or groups>`: do not report these.
69
+ - `--until=<kind>`: stop after the first event of this kind, for example `--until=finished`.
70
+ - `--timeout=<duration>`: stop after `90s`, `30m`, `2h` or `1h30m`.
71
+ - `--events-stream=auto|stdout|claude-message`: where events go. See [Claude Code](#claude-code).
72
+ - `--changelog-dir=<path>`: where the changelog goes. See [Output](#output).
73
+ - `--message-priority=now|next|later`: for `claude-message` only. The default is `next`.
74
+ - `--no-daemon`: poll inside this process, with a private budget, without the shared daemon.
75
+
76
+ `mr` and `pipeline` also take `--logs=failed|all|none`: which finished jobs get their log downloaded. The default is `failed`.
77
+
78
+ The filter is checked at start. An unknown kind or group, or one the resource never produces, is an error, so a typo does not silently mean "no events". The filter also saves API work: an MR watch without `comments` never fetches threads, and one without `pipeline` never polls the head pipeline or downloads job logs.
79
+
80
+ ## Events
81
+
82
+ Every event has `kind`, `resource`, `seq`, `observed_at` and `data`, and `artifacts` when it points at files:
83
+
84
+ ```json
85
+ {"kind":"job_finished","resource":"pipeline gitlab.example.com/group/project!4567","seq":7,"observed_at":"2026-11-09T10:05:03.120Z","data":{"pipeline_id":4567,"job_id":901,"name":"unit-tests","stage":"test","status":"failed","log_truncated":false},"artifacts":["/home/me/.cache/glab-cli-extras/gitlab.example.com/group/project/jobs/901-unit-tests.log"]}
86
+ ```
87
+
88
+ `seq` numbers the events a watcher emits: it starts after the last `seq` in the changelog and only goes up, also when the daemon restarts in between.
89
+
90
+ Lifecycle events come with every resource and cannot be filtered out:
91
+
92
+ - `watch_started`: the current state, in `data.summary`.
93
+ - `watch_degraded`: polling has trouble: `rate_limited`, `auth`, `not_found` or `error`, with a message. It is sent once per kind of trouble, not on every failed poll.
94
+ - `watch_ended`: with a `reason`: `merged`, `closed`, `finished`, `not_found`, `until`, `timeout`, `session_gone`, or the signal.
95
+
96
+ `mr`:
97
+
98
+ - `state_changed` (`from`, `to`, and `reopened: true` for closed to opened), `draft_changed`, `title_changed`, `description_changed`
99
+ - `pushed`: `from`, `to`, `forced`. A force-push is detected with one `merge_base` request per push.
100
+ - `merge_status_changed`: GitLab's transient `checking`, `unchecked` and `preparing` are skipped.
101
+ - `approvals_changed`, `reviewers_changed`
102
+ - `head_pipeline_changed`, plus every `pipeline` event of the head pipeline, with `pipeline_id` in `data`
103
+ - `thread_created`, `thread_updated`, `thread_resolved`, `thread_unresolved`, each with the thread's file in `artifacts`
104
+
105
+ `pipeline`: `status_changed`, `job_started`, `job_finished` (with the log in `artifacts` when `--logs` asks for it), `downstream_created`, `finished`.
106
+
107
+ `mrs`: `mr_opened`, `mr_updated`, `mr_merged`, `mr_closed`, `mr_reopened`.
108
+
109
+ `branch`: `pushed`, `force_pushed`, `created`, `deleted`, `pipeline_created`, `pipeline_finished`. A push whose head commit is a merge commit has `merge_commit: true` and, when GitLab knows it, `merged_mr_iid`.
110
+
111
+ Groups for `--events` and `--exclude-events`:
112
+
113
+ | Group | Kinds |
114
+ |---|---|
115
+ | `status` | `state_changed`, `draft_changed`, `merge_status_changed`, `approvals_changed`, `reviewers_changed`, `title_changed`, `description_changed`, and all `mr_*` |
116
+ | `comments` | `thread_*` |
117
+ | `push` | `pushed`, `force_pushed`, `created`, `deleted` |
118
+ | `pipeline` | `head_pipeline_changed`, `status_changed`, `job_*`, `downstream_created`, `finished`, `pipeline_created`, `pipeline_finished` |
119
+ | `lifecycle` | `watch_started`, `watch_degraded`, `watch_ended`; always on |
120
+
121
+ ### Artifacts
122
+
123
+ Job logs and thread files go to a cache shared by all watchers: `~/.cache/glab-cli-extras/<host>/<project>/` on Linux, `~/Library/Caches/glab-cli-extras/` on macOS, `%LOCALAPPDATA%\glab-cli-extras\cache\` on Windows. Two watchers of the same MR download each thread once.
124
+
125
+ - A job log is downloaded once, a few seconds after the job ends. On GitLab 19.0 and later a log above 500 KB is read as its last 500 KB only, and the file starts with a `[glab-watch: last …]` line.
126
+ - A thread file is `mr-<iid>/threads/<created>-<author>-<id>.md`, in the format glab-discussion uses. It is rewritten only when the thread changed: a new reply, an edit, a resolve.
127
+
128
+ ## Claude Code
129
+
130
+ Start the watcher as a background Bash task from a Claude Code session:
131
+
132
+ ```bash
133
+ glab-watch mr group/project!123 --exclude-events=comments
134
+ ```
135
+
136
+ With the default `--events-stream=auto`, a watcher that runs under a Claude Code session which accepts messages sends its events to that session as messages, through the session's messaging socket (`CLAUDE_CODE_MESSAGING_SOCKET`). The session sees each batch as a message from `glab-watch mr group/project!123`:
137
+
138
+ ```text
139
+ <glab-cli-extras-watcher resource="mr gitlab.example.com/group/project!123" seq="18-19" changelog="/tmp/claude-1000/…/scratchpad/glab-watch/gitlab.example.com/group/project/mr-123/events.jsonl">
140
+ {"kind":"head_pipeline_changed","seq":18,"data":{"pipeline_id":456,"status":"running","previous_pipeline_id":455}}
141
+ {"kind":"thread_created","seq":19,"data":{"discussion_id":"6a1f…","author":"reviewer-one","resolvable":true,"resolved":false,"notes":1},"artifacts":["/home/me/.cache/glab-cli-extras/…/threads/2026-11-09T09-30-reviewer-one-6a1f….md"]}
142
+ </glab-cli-extras-watcher>
143
+ ```
144
+
145
+ - **Batching.** Events that arrive close together go out as one message: the first event of a burst waits up to 3 s for the rest. A batch above 64 KB is sent as a count per kind; the changelog has the full events.
146
+ - **Priority.** `--message-priority=next` (the default) delivers into the session's running turn, at its next tool round. `later` waits until the session is idle and then starts a new turn. `now` is the most urgent.
147
+ - **Rate limits.** Each watcher sends as `glab-watch:<pid>`, and keeps below the session's per-sender limit; messages it cannot send yet are merged into the next one.
148
+ - **Changelog.** Under a Claude Code session the changelog goes to `<session scratchpad>/glab-watch/`, in both stream modes.
149
+ - **Lifetime.** A `claude-message` watcher exits when its session is gone: the socket refuses the message, or the session's record is no longer live. Watchers never outlive their session.
150
+ - **Subagents.** A watcher started from a subagent's Bash messages the main session. Claude Code has no route to a subagent.
151
+ - **Best effort.** The session may drop a message under its own rate limit, or hold it until its user approves it, which a session in bypass-permissions mode does. The changelog always has every event.
152
+
153
+ `--events-stream=stdout` keeps the events in the task output instead. `--events-stream=claude-message` fails at start when no session accepts messages.
154
+
155
+ When the session's messaging does not match what the cc-blackbox SDK speaks, for example after a Claude Code update, `auto` uses stdout, and the header says why on a `glab-watch: stream fallback:` line. If that happens while the watcher runs, it prints the same line and sends the rest of the events to stdout.
156
+
157
+ ## Output
158
+
159
+ On start, every watcher prints a header. Each line starts with `glab-watch: `:
160
+
161
+ ```text
162
+ glab-watch: resource: mr gitlab.example.com/group/project!123
163
+ glab-watch: stream: stdout (one JSON event per line)
164
+ glab-watch: changelog: /tmp/glab-watch-1000/gitlab.example.com/group/project/mr-123/events.jsonl
165
+ glab-watch: events: state_changed,draft_changed,pushed,… (always: watch_started,watch_degraded,watch_ended)
166
+ glab-watch: ignored: thread_created,thread_updated,thread_resolved,thread_unresolved
167
+ glab-watch: logs: failed
168
+ glab-watch: daemon: pid 4242, endpoint /run/user/1000/glab-cli-extras/daemon-v1.sock, log /run/user/1000/glab-cli-extras/daemon-v1.log
169
+ glab-watch: restart with: glab-watch mr https://gitlab.example.com/group/project/-/merge_requests/123 --events=<kinds|groups> --exclude-events=<kinds|groups> --logs=failed|all|none (groups: status, comments, push, pipeline)
170
+ ```
171
+
172
+ The header and the other status lines (`ended: …`, errors) always go to stderr, so stdout carries only events. Claude Code puts a background task's stderr into the task output next to its stdout, so an agent sees both. A status line never starts with `{`: in merged output, every line that starts with `{` is an event.
173
+
174
+ The changelog keeps every reported event even when nobody read the stream: `<dir>/<host>/<project>/<kind>-<id>/events.jsonl`, plus `state.json` with the last snapshot. The default `<dir>` is `<tempdir>/glab-watch-<uid>`.
175
+
176
+ ## The daemon
177
+
178
+ The first watcher starts the daemon, `python -m glab_cli_extras.daemon`, as a detached process. It exits 30 s after its last watcher is gone. You never have to start it.
179
+
180
+ ```bash
181
+ glab-watch daemon status # subscriptions, resources, request counts and budget per host; --json for everything
182
+ glab-watch daemon logs -n 100 # the end of the daemon's log
183
+ glab-watch daemon stop # running watchers start a new daemon on their next poll and carry on
184
+ ```
185
+
186
+ - **One daemon per user.** It holds an OS lock on `daemon-v1.lock` in its state dir (`$XDG_RUNTIME_DIR/glab-cli-extras/` on Linux, `$TMPDIR/glab-cli-extras/` on macOS, `%LOCALAPPDATA%\glab-cli-extras\run\` on Windows). Watchers that start at the same moment start one daemon, not one each.
187
+ - **Leases.** Each watcher polls the daemon every 5 s, which renews its subscriptions. A watcher that dies stops costing anything 20 s later.
188
+ - **Restarts.** If the daemon goes away, watchers start a new one and subscribe again. The new daemon compares GitLab's state with the snapshot each watcher kept, so a change made in between is still reported, and nothing is reported twice.
189
+ - **Budget.** The daemon paces each host on GitLab's `RateLimit-Remaining` and `RateLimit-Reset` headers and uses at most half of what is left. A host without those headers gets 60 requests a minute. When the budget is short, every poll interval grows by the same factor. A 429 pauses the whole host for its `Retry-After`.
190
+ - **Cheap probes.** Pipelines are polled through GitLab's GraphQL ETag cache: an unchanged pipeline answers with a 304 before any query runs. Base intervals: pipeline 5 s while running, MR 30 s (15 s on hosts with a high rate limit), branch pipelines 10 s, branch pushes 30 s, `mrs` 60 s.
191
+ - **Sharing.** Watchers of the same resource share one poller. All watched branches of a project share one push-event request.
192
+
193
+ ## Platform support
194
+
195
+ - Linux and macOS: everything. The daemon listens on a Unix socket.
196
+ - Windows: the daemon and the stdout stream. The daemon listens on a loopback TCP port, and every request carries a random token from a file only the user can read. The `claude-message` stream is not available.
197
+
198
+ ## Limitations
199
+
200
+ - **No `claude-message` on Windows.** The SDK has no Windows transport yet; `auto` picks stdout there.
201
+ - **One glab process per request.** Starting `glab` costs tens of milliseconds. At these request rates, a few per second at most, that does not matter.
202
+ - **The free ETag 304 is not documented by GitLab.** If GitLab starts to count those requests, pipeline watching gets slower under the budget, not more expensive.
203
+ - **Branch pipeline probes are cheap only on quiet projects.** GitLab touches the ETag key of a project's pipelines on any pipeline change in the project. In a busy monorepo nearly every 10 s probe is a full request.
204
+ - **`mr_updated` is best effort.** GitLab does not move an MR's `updated_at` when it was touched less than a minute before, so a second change within that minute shows up only with the next change.
205
+ - **Job logs of large jobs need GitLab 19.0** for the tail-only download. Older versions download the whole log.
206
+ - **Thread files have no bot marker** and use usernames, not display names, because that would cost a user lookup per author.
207
+ - **Deeper downstream pipelines** (a child of a child) are not watched. Their changes do reach the watched pipeline's ETag, but their jobs are not reported.
208
+ - **SSH host aliases** in git remotes are resolved by glab, as glab resolves them. A remote glab cannot map to a configured host fails at start.
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ uv sync
214
+ uv run ruff check
215
+ uv run ruff format --check
216
+ uv run mypy
217
+ uv run pytest
218
+ ```
219
+
220
+ To run against a local checkout of [cc-blackbox](https://github.com/fprochazka/cc-blackbox) instead of its release, for example while changing both, layer it over the locked version for one command. This leaves `pyproject.toml` and `uv.lock` alone:
221
+
222
+ ```bash
223
+ uv run --with-editable ../cc-blackbox pytest
224
+ ```
225
+
226
+ The tests need no network and no glab login. A fake `glab` (`tests/fakeglab.py`) answers with the response shapes recorded from a GitLab 19.5 instance, in process for the pollers and as a real executable on `PATH` for the daemon tests, which start real daemons in temporary dirs.
227
+
228
+ ## License
229
+
230
+ MIT
@@ -0,0 +1,207 @@
1
+ # glab-cli-extras
2
+
3
+ Extra commands for the [GitLab CLI](https://gitlab.com/gitlab-org/cli) (`glab`). Version 0.1 has one command, `glab-watch`: it watches a merge request, a pipeline, all merge requests of a project, or a branch, and reports every change as one JSON event per line. It is built for coding agents that wait on CI and review, and it works just as well in a terminal or a script.
4
+
5
+ Every GitLab request goes through `glab api`, so `glab` handles authentication, hosts, OAuth refresh, proxies and certificates. `glab-watch` never reads or stores a token. A shared per-user daemon does the polling, so ten watchers cost about as much API budget as one.
6
+
7
+ ## Install
8
+
9
+ You need Python 3.11 or later, [uv](https://docs.astral.sh/uv/), and `glab` logged in to your GitLab host (`glab auth status`).
10
+
11
+ ```bash
12
+ uv tool install glab-cli-extras
13
+ ```
14
+
15
+ From git, for the latest unreleased changes:
16
+
17
+ ```bash
18
+ uv tool install git+https://github.com/fprochazka/glab-cli-extras
19
+ ```
20
+
21
+ From a local checkout:
22
+
23
+ ```bash
24
+ uv tool install --editable /path/to/glab-cli-extras
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ glab-watch mr group/project!123 # a merge request; also !123 or 123 with the repo from the cwd, or the MR URL
31
+ glab-watch pipeline group/project!4567 # a pipeline and its downstream pipelines; also the pipeline URL
32
+ glab-watch mrs group/project # every merge request of a project
33
+ glab-watch branch group/project main # a branch; the repo is optional: glab-watch branch main
34
+ ```
35
+
36
+ `<repo>` means the same as it does to glab: `group/project`, `group/sub/project`, `host/group/project`, a project URL, or nothing for the repository of the current directory. `glab-watch` asks `glab repo view` to resolve it, which costs one request at start. A URL costs nothing.
37
+
38
+ A watcher runs until its resource ends (an MR is merged or closed, a pipeline finishes), until `--until` or `--timeout` fires, or until it gets SIGTERM or Ctrl-C. `mrs` and `branch` run until stopped. The last event is always `watch_ended` with a `reason`.
39
+
40
+ ### Options
41
+
42
+ All commands:
43
+
44
+ - `--events=<kinds or groups>`: report only these. Comma-separated, repeatable. The default is every event the resource tracks.
45
+ - `--exclude-events=<kinds or groups>`: do not report these.
46
+ - `--until=<kind>`: stop after the first event of this kind, for example `--until=finished`.
47
+ - `--timeout=<duration>`: stop after `90s`, `30m`, `2h` or `1h30m`.
48
+ - `--events-stream=auto|stdout|claude-message`: where events go. See [Claude Code](#claude-code).
49
+ - `--changelog-dir=<path>`: where the changelog goes. See [Output](#output).
50
+ - `--message-priority=now|next|later`: for `claude-message` only. The default is `next`.
51
+ - `--no-daemon`: poll inside this process, with a private budget, without the shared daemon.
52
+
53
+ `mr` and `pipeline` also take `--logs=failed|all|none`: which finished jobs get their log downloaded. The default is `failed`.
54
+
55
+ The filter is checked at start. An unknown kind or group, or one the resource never produces, is an error, so a typo does not silently mean "no events". The filter also saves API work: an MR watch without `comments` never fetches threads, and one without `pipeline` never polls the head pipeline or downloads job logs.
56
+
57
+ ## Events
58
+
59
+ Every event has `kind`, `resource`, `seq`, `observed_at` and `data`, and `artifacts` when it points at files:
60
+
61
+ ```json
62
+ {"kind":"job_finished","resource":"pipeline gitlab.example.com/group/project!4567","seq":7,"observed_at":"2026-11-09T10:05:03.120Z","data":{"pipeline_id":4567,"job_id":901,"name":"unit-tests","stage":"test","status":"failed","log_truncated":false},"artifacts":["/home/me/.cache/glab-cli-extras/gitlab.example.com/group/project/jobs/901-unit-tests.log"]}
63
+ ```
64
+
65
+ `seq` numbers the events a watcher emits: it starts after the last `seq` in the changelog and only goes up, also when the daemon restarts in between.
66
+
67
+ Lifecycle events come with every resource and cannot be filtered out:
68
+
69
+ - `watch_started`: the current state, in `data.summary`.
70
+ - `watch_degraded`: polling has trouble: `rate_limited`, `auth`, `not_found` or `error`, with a message. It is sent once per kind of trouble, not on every failed poll.
71
+ - `watch_ended`: with a `reason`: `merged`, `closed`, `finished`, `not_found`, `until`, `timeout`, `session_gone`, or the signal.
72
+
73
+ `mr`:
74
+
75
+ - `state_changed` (`from`, `to`, and `reopened: true` for closed to opened), `draft_changed`, `title_changed`, `description_changed`
76
+ - `pushed`: `from`, `to`, `forced`. A force-push is detected with one `merge_base` request per push.
77
+ - `merge_status_changed`: GitLab's transient `checking`, `unchecked` and `preparing` are skipped.
78
+ - `approvals_changed`, `reviewers_changed`
79
+ - `head_pipeline_changed`, plus every `pipeline` event of the head pipeline, with `pipeline_id` in `data`
80
+ - `thread_created`, `thread_updated`, `thread_resolved`, `thread_unresolved`, each with the thread's file in `artifacts`
81
+
82
+ `pipeline`: `status_changed`, `job_started`, `job_finished` (with the log in `artifacts` when `--logs` asks for it), `downstream_created`, `finished`.
83
+
84
+ `mrs`: `mr_opened`, `mr_updated`, `mr_merged`, `mr_closed`, `mr_reopened`.
85
+
86
+ `branch`: `pushed`, `force_pushed`, `created`, `deleted`, `pipeline_created`, `pipeline_finished`. A push whose head commit is a merge commit has `merge_commit: true` and, when GitLab knows it, `merged_mr_iid`.
87
+
88
+ Groups for `--events` and `--exclude-events`:
89
+
90
+ | Group | Kinds |
91
+ |---|---|
92
+ | `status` | `state_changed`, `draft_changed`, `merge_status_changed`, `approvals_changed`, `reviewers_changed`, `title_changed`, `description_changed`, and all `mr_*` |
93
+ | `comments` | `thread_*` |
94
+ | `push` | `pushed`, `force_pushed`, `created`, `deleted` |
95
+ | `pipeline` | `head_pipeline_changed`, `status_changed`, `job_*`, `downstream_created`, `finished`, `pipeline_created`, `pipeline_finished` |
96
+ | `lifecycle` | `watch_started`, `watch_degraded`, `watch_ended`; always on |
97
+
98
+ ### Artifacts
99
+
100
+ Job logs and thread files go to a cache shared by all watchers: `~/.cache/glab-cli-extras/<host>/<project>/` on Linux, `~/Library/Caches/glab-cli-extras/` on macOS, `%LOCALAPPDATA%\glab-cli-extras\cache\` on Windows. Two watchers of the same MR download each thread once.
101
+
102
+ - A job log is downloaded once, a few seconds after the job ends. On GitLab 19.0 and later a log above 500 KB is read as its last 500 KB only, and the file starts with a `[glab-watch: last …]` line.
103
+ - A thread file is `mr-<iid>/threads/<created>-<author>-<id>.md`, in the format glab-discussion uses. It is rewritten only when the thread changed: a new reply, an edit, a resolve.
104
+
105
+ ## Claude Code
106
+
107
+ Start the watcher as a background Bash task from a Claude Code session:
108
+
109
+ ```bash
110
+ glab-watch mr group/project!123 --exclude-events=comments
111
+ ```
112
+
113
+ With the default `--events-stream=auto`, a watcher that runs under a Claude Code session which accepts messages sends its events to that session as messages, through the session's messaging socket (`CLAUDE_CODE_MESSAGING_SOCKET`). The session sees each batch as a message from `glab-watch mr group/project!123`:
114
+
115
+ ```text
116
+ <glab-cli-extras-watcher resource="mr gitlab.example.com/group/project!123" seq="18-19" changelog="/tmp/claude-1000/…/scratchpad/glab-watch/gitlab.example.com/group/project/mr-123/events.jsonl">
117
+ {"kind":"head_pipeline_changed","seq":18,"data":{"pipeline_id":456,"status":"running","previous_pipeline_id":455}}
118
+ {"kind":"thread_created","seq":19,"data":{"discussion_id":"6a1f…","author":"reviewer-one","resolvable":true,"resolved":false,"notes":1},"artifacts":["/home/me/.cache/glab-cli-extras/…/threads/2026-11-09T09-30-reviewer-one-6a1f….md"]}
119
+ </glab-cli-extras-watcher>
120
+ ```
121
+
122
+ - **Batching.** Events that arrive close together go out as one message: the first event of a burst waits up to 3 s for the rest. A batch above 64 KB is sent as a count per kind; the changelog has the full events.
123
+ - **Priority.** `--message-priority=next` (the default) delivers into the session's running turn, at its next tool round. `later` waits until the session is idle and then starts a new turn. `now` is the most urgent.
124
+ - **Rate limits.** Each watcher sends as `glab-watch:<pid>`, and keeps below the session's per-sender limit; messages it cannot send yet are merged into the next one.
125
+ - **Changelog.** Under a Claude Code session the changelog goes to `<session scratchpad>/glab-watch/`, in both stream modes.
126
+ - **Lifetime.** A `claude-message` watcher exits when its session is gone: the socket refuses the message, or the session's record is no longer live. Watchers never outlive their session.
127
+ - **Subagents.** A watcher started from a subagent's Bash messages the main session. Claude Code has no route to a subagent.
128
+ - **Best effort.** The session may drop a message under its own rate limit, or hold it until its user approves it, which a session in bypass-permissions mode does. The changelog always has every event.
129
+
130
+ `--events-stream=stdout` keeps the events in the task output instead. `--events-stream=claude-message` fails at start when no session accepts messages.
131
+
132
+ When the session's messaging does not match what the cc-blackbox SDK speaks, for example after a Claude Code update, `auto` uses stdout, and the header says why on a `glab-watch: stream fallback:` line. If that happens while the watcher runs, it prints the same line and sends the rest of the events to stdout.
133
+
134
+ ## Output
135
+
136
+ On start, every watcher prints a header. Each line starts with `glab-watch: `:
137
+
138
+ ```text
139
+ glab-watch: resource: mr gitlab.example.com/group/project!123
140
+ glab-watch: stream: stdout (one JSON event per line)
141
+ glab-watch: changelog: /tmp/glab-watch-1000/gitlab.example.com/group/project/mr-123/events.jsonl
142
+ glab-watch: events: state_changed,draft_changed,pushed,… (always: watch_started,watch_degraded,watch_ended)
143
+ glab-watch: ignored: thread_created,thread_updated,thread_resolved,thread_unresolved
144
+ glab-watch: logs: failed
145
+ glab-watch: daemon: pid 4242, endpoint /run/user/1000/glab-cli-extras/daemon-v1.sock, log /run/user/1000/glab-cli-extras/daemon-v1.log
146
+ glab-watch: restart with: glab-watch mr https://gitlab.example.com/group/project/-/merge_requests/123 --events=<kinds|groups> --exclude-events=<kinds|groups> --logs=failed|all|none (groups: status, comments, push, pipeline)
147
+ ```
148
+
149
+ The header and the other status lines (`ended: …`, errors) always go to stderr, so stdout carries only events. Claude Code puts a background task's stderr into the task output next to its stdout, so an agent sees both. A status line never starts with `{`: in merged output, every line that starts with `{` is an event.
150
+
151
+ The changelog keeps every reported event even when nobody read the stream: `<dir>/<host>/<project>/<kind>-<id>/events.jsonl`, plus `state.json` with the last snapshot. The default `<dir>` is `<tempdir>/glab-watch-<uid>`.
152
+
153
+ ## The daemon
154
+
155
+ The first watcher starts the daemon, `python -m glab_cli_extras.daemon`, as a detached process. It exits 30 s after its last watcher is gone. You never have to start it.
156
+
157
+ ```bash
158
+ glab-watch daemon status # subscriptions, resources, request counts and budget per host; --json for everything
159
+ glab-watch daemon logs -n 100 # the end of the daemon's log
160
+ glab-watch daemon stop # running watchers start a new daemon on their next poll and carry on
161
+ ```
162
+
163
+ - **One daemon per user.** It holds an OS lock on `daemon-v1.lock` in its state dir (`$XDG_RUNTIME_DIR/glab-cli-extras/` on Linux, `$TMPDIR/glab-cli-extras/` on macOS, `%LOCALAPPDATA%\glab-cli-extras\run\` on Windows). Watchers that start at the same moment start one daemon, not one each.
164
+ - **Leases.** Each watcher polls the daemon every 5 s, which renews its subscriptions. A watcher that dies stops costing anything 20 s later.
165
+ - **Restarts.** If the daemon goes away, watchers start a new one and subscribe again. The new daemon compares GitLab's state with the snapshot each watcher kept, so a change made in between is still reported, and nothing is reported twice.
166
+ - **Budget.** The daemon paces each host on GitLab's `RateLimit-Remaining` and `RateLimit-Reset` headers and uses at most half of what is left. A host without those headers gets 60 requests a minute. When the budget is short, every poll interval grows by the same factor. A 429 pauses the whole host for its `Retry-After`.
167
+ - **Cheap probes.** Pipelines are polled through GitLab's GraphQL ETag cache: an unchanged pipeline answers with a 304 before any query runs. Base intervals: pipeline 5 s while running, MR 30 s (15 s on hosts with a high rate limit), branch pipelines 10 s, branch pushes 30 s, `mrs` 60 s.
168
+ - **Sharing.** Watchers of the same resource share one poller. All watched branches of a project share one push-event request.
169
+
170
+ ## Platform support
171
+
172
+ - Linux and macOS: everything. The daemon listens on a Unix socket.
173
+ - Windows: the daemon and the stdout stream. The daemon listens on a loopback TCP port, and every request carries a random token from a file only the user can read. The `claude-message` stream is not available.
174
+
175
+ ## Limitations
176
+
177
+ - **No `claude-message` on Windows.** The SDK has no Windows transport yet; `auto` picks stdout there.
178
+ - **One glab process per request.** Starting `glab` costs tens of milliseconds. At these request rates, a few per second at most, that does not matter.
179
+ - **The free ETag 304 is not documented by GitLab.** If GitLab starts to count those requests, pipeline watching gets slower under the budget, not more expensive.
180
+ - **Branch pipeline probes are cheap only on quiet projects.** GitLab touches the ETag key of a project's pipelines on any pipeline change in the project. In a busy monorepo nearly every 10 s probe is a full request.
181
+ - **`mr_updated` is best effort.** GitLab does not move an MR's `updated_at` when it was touched less than a minute before, so a second change within that minute shows up only with the next change.
182
+ - **Job logs of large jobs need GitLab 19.0** for the tail-only download. Older versions download the whole log.
183
+ - **Thread files have no bot marker** and use usernames, not display names, because that would cost a user lookup per author.
184
+ - **Deeper downstream pipelines** (a child of a child) are not watched. Their changes do reach the watched pipeline's ETag, but their jobs are not reported.
185
+ - **SSH host aliases** in git remotes are resolved by glab, as glab resolves them. A remote glab cannot map to a configured host fails at start.
186
+
187
+ ## Development
188
+
189
+ ```bash
190
+ uv sync
191
+ uv run ruff check
192
+ uv run ruff format --check
193
+ uv run mypy
194
+ uv run pytest
195
+ ```
196
+
197
+ To run against a local checkout of [cc-blackbox](https://github.com/fprochazka/cc-blackbox) instead of its release, for example while changing both, layer it over the locked version for one command. This leaves `pyproject.toml` and `uv.lock` alone:
198
+
199
+ ```bash
200
+ uv run --with-editable ../cc-blackbox pytest
201
+ ```
202
+
203
+ The tests need no network and no glab login. A fake `glab` (`tests/fakeglab.py`) answers with the response shapes recorded from a GitLab 19.5 instance, in process for the pollers and as a real executable on `PATH` for the daemon tests, which start real daemons in temporary dirs.
204
+
205
+ ## License
206
+
207
+ MIT