glab-cli-extras 0.1.0__py3-none-any.whl

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 (44) hide show
  1. glab_cli_extras/__init__.py +3 -0
  2. glab_cli_extras/artifacts/__init__.py +0 -0
  3. glab_cli_extras/artifacts/job_logs.py +95 -0
  4. glab_cli_extras/artifacts/threads.py +147 -0
  5. glab_cli_extras/cli/__init__.py +0 -0
  6. glab_cli_extras/cli/watch.py +426 -0
  7. glab_cli_extras/daemon/__init__.py +0 -0
  8. glab_cli_extras/daemon/__main__.py +10 -0
  9. glab_cli_extras/daemon/engine.py +491 -0
  10. glab_cli_extras/daemon/lifecycle.py +186 -0
  11. glab_cli_extras/daemon/locks.py +85 -0
  12. glab_cli_extras/daemon/scheduler.py +52 -0
  13. glab_cli_extras/daemon/server.py +79 -0
  14. glab_cli_extras/daemon/transport.py +185 -0
  15. glab_cli_extras/events.py +165 -0
  16. glab_cli_extras/gitlab/__init__.py +0 -0
  17. glab_cli_extras/gitlab/budget.py +135 -0
  18. glab_cli_extras/gitlab/client.py +225 -0
  19. glab_cli_extras/gitlab/glab.py +175 -0
  20. glab_cli_extras/gitlab/queries/__init__.py +11 -0
  21. glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  22. glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  23. glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  24. glab_cli_extras/gitlab/refs.py +151 -0
  25. glab_cli_extras/paths.py +79 -0
  26. glab_cli_extras/py.typed +0 -0
  27. glab_cli_extras/resources/__init__.py +19 -0
  28. glab_cli_extras/resources/base.py +146 -0
  29. glab_cli_extras/resources/branch.py +265 -0
  30. glab_cli_extras/resources/mr.py +445 -0
  31. glab_cli_extras/resources/mr_list.py +150 -0
  32. glab_cli_extras/resources/pipeline.py +319 -0
  33. glab_cli_extras/serde.py +75 -0
  34. glab_cli_extras/watch/__init__.py +0 -0
  35. glab_cli_extras/watch/changelog.py +86 -0
  36. glab_cli_extras/watch/claude.py +193 -0
  37. glab_cli_extras/watch/client.py +316 -0
  38. glab_cli_extras/watch/sinks.py +83 -0
  39. glab_cli_extras/watch/streams.py +80 -0
  40. glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
  41. glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
  42. glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
  43. glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
  44. glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,316 @@
1
+ """The watcher: subscribe through a backend, poll for events, write the changelog, deliver to a sink.
2
+
3
+ Two backends share one interface. `DaemonBackend` talks to the shared per-user daemon and restarts it when it
4
+ disappears. `InProcessBackend` (`--no-daemon`) runs the same engine in a thread of this process, with a private
5
+ budget.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import concurrent.futures
12
+ import contextlib
13
+ import signal
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, replace
19
+ from pathlib import Path
20
+ from types import FrameType
21
+ from typing import Any, Protocol, TypeVar
22
+
23
+ from glab_cli_extras.artifacts.job_logs import LogsMode
24
+ from glab_cli_extras.daemon.engine import Engine
25
+ from glab_cli_extras.daemon.lifecycle import ensure_daemon
26
+ from glab_cli_extras.daemon.transport import DaemonFiles, DaemonUnavailable, request
27
+ from glab_cli_extras.events import WATCH_ENDED, WATCH_STARTED, Event, utc_now_iso
28
+ from glab_cli_extras.gitlab.refs import ResourceRef
29
+ from glab_cli_extras.watch.changelog import Changelog
30
+ from glab_cli_extras.watch.sinks import Sink, StatusOutput
31
+
32
+ T = TypeVar("T")
33
+
34
+ EXIT_OK = 0
35
+ EXIT_ERROR = 1
36
+
37
+
38
+ class BackendError(RuntimeError):
39
+ pass
40
+
41
+
42
+ class Backend(Protocol):
43
+ def describe(self) -> str: ...
44
+
45
+ def subscribe(
46
+ self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
47
+ ) -> tuple[str, int]: ...
48
+
49
+ def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]: ...
50
+
51
+ def unsubscribe(self, client_id: str, subscription_id: str) -> None: ...
52
+
53
+ def close(self) -> None: ...
54
+
55
+
56
+ def _subscribe_payload(
57
+ client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
58
+ ) -> dict[str, Any]:
59
+ options: dict[str, Any] = {"events": sorted(events), "logs": logs}
60
+ if baseline is not None:
61
+ options["baseline"] = baseline
62
+ return {"op": "subscribe", "client_id": client_id, "resource": ref.to_wire(), "options": options}
63
+
64
+
65
+ class DaemonBackend:
66
+ def __init__(self, files: DaemonFiles | None = None) -> None:
67
+ self.files = files or DaemonFiles.default()
68
+ self.hello = ensure_daemon(self.files)
69
+
70
+ def describe(self) -> str:
71
+ return f"pid {self.hello.get('pid')}, endpoint {self.files.describe_endpoint()}, log {self.files.log}"
72
+
73
+ def _call(self, payload: dict[str, Any]) -> dict[str, Any]:
74
+ try:
75
+ response = request(self.files, payload)
76
+ except DaemonUnavailable:
77
+ # The daemon exited (idle, crash, or stopped by hand). Start a new one; subscriptions then come back
78
+ # as unknown and the watcher subscribes again.
79
+ self.hello = ensure_daemon(self.files)
80
+ response = request(self.files, payload)
81
+ if not response.get("ok"):
82
+ raise BackendError(str(response.get("error", "the daemon refused the request")))
83
+ return response
84
+
85
+ def subscribe(
86
+ self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
87
+ ) -> tuple[str, int]:
88
+ response = self._call(_subscribe_payload(client_id, ref, events, logs, baseline))
89
+ return str(response["subscription_id"]), int(response["cursor"])
90
+
91
+ def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]:
92
+ return self._call({"op": "poll", "client_id": client_id, "cursors": cursors})
93
+
94
+ def unsubscribe(self, client_id: str, subscription_id: str) -> None:
95
+ self._call({"op": "unsubscribe", "client_id": client_id, "subscription_id": subscription_id})
96
+
97
+ def close(self) -> None:
98
+ pass
99
+
100
+
101
+ class InProcessBackend:
102
+ """The engine on its own event loop in a daemon thread. Calls hop onto that loop and wait for the result."""
103
+
104
+ def __init__(self, engine: Engine) -> None:
105
+ self.engine = engine
106
+ self._loop = asyncio.new_event_loop()
107
+ self._stop: asyncio.Event | None = None
108
+ self._ready = threading.Event()
109
+ self._thread = threading.Thread(target=self._run, name="glab-watch-engine", daemon=True)
110
+ self._thread.start()
111
+ self._ready.wait()
112
+
113
+ def _run(self) -> None:
114
+ asyncio.set_event_loop(self._loop)
115
+
116
+ async def main() -> None:
117
+ self._stop = asyncio.Event()
118
+ self._ready.set()
119
+ await self.engine.run(self._stop)
120
+
121
+ self._loop.run_until_complete(main())
122
+
123
+ def _call(self, fn: Callable[[], T]) -> T:
124
+ future: concurrent.futures.Future[T] = concurrent.futures.Future()
125
+
126
+ def invoke() -> None:
127
+ try:
128
+ future.set_result(fn())
129
+ except BaseException as exc:
130
+ future.set_exception(exc)
131
+
132
+ self._loop.call_soon_threadsafe(invoke)
133
+ return future.result(timeout=30)
134
+
135
+ def describe(self) -> str:
136
+ return "in-process (--no-daemon), private budget"
137
+
138
+ def subscribe(
139
+ self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
140
+ ) -> tuple[str, int]:
141
+ result = self._call(lambda: self.engine.subscribe(client_id, ref, events, logs, baseline))
142
+ return str(result["subscription_id"]), int(result["cursor"])
143
+
144
+ def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]:
145
+ return self._call(lambda: self.engine.poll(client_id, cursors))
146
+
147
+ def unsubscribe(self, client_id: str, subscription_id: str) -> None:
148
+ self._call(lambda: self.engine.unsubscribe(client_id, subscription_id))
149
+
150
+ def close(self) -> None:
151
+ stop = self._stop
152
+ if stop is not None:
153
+ self._loop.call_soon_threadsafe(stop.set)
154
+ self._thread.join(timeout=10)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class WatchSettings:
159
+ ref: ResourceRef
160
+ events: frozenset[str]
161
+ logs: LogsMode = "failed"
162
+ until: str | None = None
163
+ timeout: float | None = None
164
+
165
+
166
+ class Watcher:
167
+ def __init__(
168
+ self,
169
+ settings: WatchSettings,
170
+ backend: Backend,
171
+ sink: Sink,
172
+ changelog: Changelog,
173
+ status: StatusOutput,
174
+ *,
175
+ clock: Callable[[], float] = time.monotonic,
176
+ ) -> None:
177
+ self.settings = settings
178
+ self.backend = backend
179
+ self.sink = sink
180
+ self.changelog = changelog
181
+ self.status = status
182
+ self.clock = clock
183
+ self.client_id = uuid.uuid4().hex
184
+ self._stop_reason: str | None = None
185
+ self._wakeup = threading.Event()
186
+ self._subscription: str | None = None
187
+ # The daemon's sequence number of the last event received. It restarts when the daemon restarts, so it is
188
+ # only the poll cursor. Emitted events carry the watcher's own `_seq`, which never goes back.
189
+ self._cursor = 0
190
+ self._seq = 0
191
+ self._snapshot_hash: str | None = None
192
+
193
+ def request_stop(self, reason: str = "stopped") -> None:
194
+ self._stop_reason = reason
195
+ self._wakeup.set()
196
+
197
+ def _install_signal_handlers(self) -> None:
198
+ if threading.current_thread() is not threading.main_thread():
199
+ return
200
+
201
+ def handler(signum: int, frame: FrameType | None) -> None:
202
+ self.request_stop(f"signal {signal.Signals(signum).name}")
203
+
204
+ signal.signal(signal.SIGTERM, handler)
205
+ signal.signal(signal.SIGINT, handler)
206
+
207
+ def _subscribe(self, resubscribe: bool) -> None:
208
+ baseline = None
209
+ if resubscribe:
210
+ stored = self.changelog.load_state()
211
+ baseline = stored.snapshot if stored else None
212
+ s = self.settings
213
+ self._subscription, self._cursor = self.backend.subscribe(self.client_id, s.ref, s.events, s.logs, baseline)
214
+
215
+ def _next_seq(self) -> int:
216
+ self._seq += 1
217
+ return self._seq
218
+
219
+ def _end_event(self, reason: str, **data: Any) -> Event:
220
+ return Event(WATCH_ENDED, self.settings.ref.key, self._next_seq(), utc_now_iso(), {"reason": reason, **data})
221
+
222
+ def _deliver(self, events: list[Event]) -> None:
223
+ if not events:
224
+ return
225
+ self.changelog.append(events)
226
+ self.sink.emit(events)
227
+
228
+ def run(self) -> int:
229
+ self._install_signal_handlers()
230
+ if self.settings.timeout is not None:
231
+ deadline: float | None = self.clock() + self.settings.timeout
232
+ else:
233
+ deadline = None
234
+ stored = self.changelog.load_state()
235
+ self._snapshot_hash = stored.snapshot_hash if stored else None
236
+ # Continue the changelog's numbering, so events.jsonl stays strictly increasing across watcher runs too.
237
+ self._seq = self.changelog.last_seq()
238
+ resubscribed = False
239
+ exit_code = EXIT_OK
240
+ try:
241
+ self._subscribe(resubscribe=False)
242
+ while True:
243
+ if self._stop_reason is not None:
244
+ self._finish(self._end_event(self._stop_reason))
245
+ return EXIT_OK
246
+ assert self._subscription is not None
247
+ self.sink.tick()
248
+ response = self.backend.poll(self.client_id, {self._subscription: self._cursor})
249
+ if self._subscription in response.get("unknown", []):
250
+ self.status.line("daemon restarted, subscribing again")
251
+ self._subscribe(resubscribe=True)
252
+ resubscribed = True
253
+ continue
254
+ snapshot = (response.get("snapshots") or {}).get(self._subscription)
255
+ if snapshot:
256
+ previous_hash = self._snapshot_hash
257
+ self._snapshot_hash = str(snapshot["hash"])
258
+ self.changelog.save_state(self._snapshot_hash, snapshot.get("data"))
259
+ else:
260
+ previous_hash = self._snapshot_hash
261
+ raw = [Event.from_json(e) for e in (response.get("events") or {}).get(self._subscription) or []]
262
+ for event in raw:
263
+ self._cursor = max(self._cursor, event.seq)
264
+ events = [e for e in raw if e.kind in self.settings.events]
265
+ if resubscribed and any(e.kind == WATCH_STARTED for e in events):
266
+ # After a daemon restart, the new watch_started repeats what we already know unless the
267
+ # snapshot moved; changes in between arrive as normal events, diffed against our stored snapshot.
268
+ events = [
269
+ e
270
+ for e in events
271
+ if not (e.kind == WATCH_STARTED and e.data.get("snapshot_hash") == previous_hash)
272
+ ]
273
+ resubscribed = False
274
+ events = [replace(e, seq=self._next_seq()) for e in events]
275
+ ended = next((e for e in events if e.kind == WATCH_ENDED), None)
276
+ hit = next((e for e in events if self.settings.until and e.kind == self.settings.until), None)
277
+ self._deliver(events)
278
+ if ended is not None:
279
+ reason = str(ended.data.get("reason"))
280
+ self.status.line(f"ended: {reason}")
281
+ return EXIT_ERROR if reason == "not_found" else EXIT_OK
282
+ if hit is not None:
283
+ self._finish(self._end_event("until", event=hit.kind, seq=hit.seq))
284
+ return EXIT_OK
285
+ if not self.sink.alive():
286
+ self._finish(self._end_event("session_gone"))
287
+ return EXIT_OK
288
+ if deadline is not None and self.clock() >= deadline:
289
+ self._finish(self._end_event("timeout"))
290
+ return EXIT_OK
291
+ wait = int(response.get("next_poll_ms", 5000)) / 1000.0
292
+ if deadline is not None:
293
+ wait = max(0.0, min(wait, deadline - self.clock()))
294
+ flush_in = self.sink.seconds_until_flush()
295
+ if flush_in is not None:
296
+ wait = min(wait, flush_in)
297
+ self._wakeup.wait(wait)
298
+ except (BackendError, DaemonUnavailable) as exc:
299
+ self.status.line(f"error: {exc}")
300
+ exit_code = EXIT_ERROR
301
+ return exit_code
302
+ finally:
303
+ if self._subscription is not None:
304
+ with contextlib.suppress(BackendError, DaemonUnavailable, OSError, TimeoutError):
305
+ self.backend.unsubscribe(self.client_id, self._subscription)
306
+ self.sink.close()
307
+
308
+ def _finish(self, event: Event) -> None:
309
+ self._deliver([event])
310
+ self.status.line(f"ended: {event.data.get('reason')}")
311
+
312
+
313
+ def default_backend(no_daemon: bool, cache_root: Path) -> Backend:
314
+ if no_daemon:
315
+ return InProcessBackend(Engine(cache_root=cache_root))
316
+ return DaemonBackend()
@@ -0,0 +1,83 @@
1
+ """Where a watcher delivers events, and where its own status lines go.
2
+
3
+ Status lines (the startup header, the end line, errors) go to stderr, so stdout carries only events. Claude Code puts
4
+ a background Bash task's stderr into the task output next to its stdout, so an agent reads both. Status lines start
5
+ with `glab-watch: ` and never with `{`, so a reader of the merged output can still tell them from events.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Protocol, TextIO
13
+
14
+ from glab_cli_extras.events import Event
15
+
16
+ STATUS_PREFIX = "glab-watch: "
17
+
18
+
19
+ class StatusOutput:
20
+ def __init__(self, stream: TextIO | None = None) -> None:
21
+ self._stream = stream
22
+
23
+ @property
24
+ def stream(self) -> TextIO:
25
+ if self._stream is not None:
26
+ return self._stream
27
+ return sys.stderr
28
+
29
+ def line(self, text: str) -> None:
30
+ self.stream.write(f"{STATUS_PREFIX}{text}\n")
31
+ self.stream.flush()
32
+
33
+
34
+ class Sink(Protocol):
35
+ name: str
36
+
37
+ def describe(self) -> str:
38
+ """One line for the startup header: the stream mode and its target."""
39
+ ...
40
+
41
+ def emit(self, events: list[Event]) -> None: ...
42
+
43
+ def seconds_until_flush(self) -> float | None:
44
+ """When a buffered batch is due, so the watcher wakes up for it; None when nothing is buffered."""
45
+ ...
46
+
47
+ def tick(self) -> None:
48
+ """Deliver a buffered batch whose time has come."""
49
+ ...
50
+
51
+ def alive(self) -> bool:
52
+ """False once the target is gone, which ends the watch."""
53
+ ...
54
+
55
+ def close(self) -> None: ...
56
+
57
+
58
+ class StdoutSink:
59
+ name = "stdout"
60
+
61
+ def __init__(self, stream: TextIO | None = None) -> None:
62
+ self._stream = stream
63
+
64
+ def describe(self) -> str:
65
+ return "stdout (one JSON event per line)"
66
+
67
+ def emit(self, events: list[Event]) -> None:
68
+ stream = self._stream or sys.stdout
69
+ for event in events:
70
+ stream.write(json.dumps(event.to_json(), ensure_ascii=False, separators=(",", ":")) + "\n")
71
+ stream.flush()
72
+
73
+ def seconds_until_flush(self) -> float | None:
74
+ return None
75
+
76
+ def tick(self) -> None:
77
+ pass
78
+
79
+ def alive(self) -> bool:
80
+ return True
81
+
82
+ def close(self) -> None:
83
+ pass
@@ -0,0 +1,80 @@
1
+ """Choosing the events stream, and the default changelog location that comes with it.
2
+
3
+ `auto` picks `claude-message` when the watcher runs under a Claude Code session that accepts messages, else `stdout`.
4
+ Under a Claude session the changelog defaults to `<session scratchpad>/glab-watch` in either mode, so the agent finds
5
+ it next to its other files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import cast
14
+
15
+ from cc_blackbox import ActiveSession, Priority
16
+
17
+ from glab_cli_extras.gitlab.refs import ResourceRef
18
+ from glab_cli_extras.watch.changelog import Changelog
19
+ from glab_cli_extras.watch.claude import ClaudeMessageSink, find_session
20
+ from glab_cli_extras.watch.sinks import Sink, StatusOutput, StdoutSink
21
+
22
+ STREAM_MODES = ("auto", "stdout", "claude-message")
23
+ MESSAGE_PRIORITIES = ("now", "next", "later")
24
+
25
+
26
+ class StreamError(RuntimeError):
27
+ pass
28
+
29
+
30
+ @dataclass
31
+ class Stream:
32
+ sink: Sink
33
+ changelog: Changelog
34
+ fallback: str | None = None
35
+ """Why `auto` picked stdout although the watcher runs under a Claude Code session; printed in the header."""
36
+
37
+
38
+ def scratchpad_root(session: ActiveSession | None) -> Path | None:
39
+ if session is None or session.scratchpad_dir is None:
40
+ return None
41
+ return session.scratchpad_dir / "glab-watch"
42
+
43
+
44
+ def messaging_problem(session: ActiveSession | None) -> str | None:
45
+ """Why the session cannot take messages from this watcher, or None when it can."""
46
+ if sys.platform == "win32":
47
+ return "the claude-message stream is not supported on Windows"
48
+ if session is None:
49
+ return "no Claude Code session found (CLAUDE_CODE_MESSAGING_SOCKET is unset, or the session record is gone)"
50
+ if session.can_receive_messages:
51
+ return None
52
+ reasons = [r for r in session.compatibility.reasons if r.startswith("messaging")]
53
+ if reasons:
54
+ return "the session's messaging is not compatible with this cc-blackbox version: " + " ".join(reasons)
55
+ if session.messaging_socket is None:
56
+ return "the session has messaging turned off"
57
+ return f"the session's messaging socket {session.messaging_socket} does not exist"
58
+
59
+
60
+ def open_stream(
61
+ mode: str,
62
+ ref: ResourceRef,
63
+ priority: str,
64
+ changelog_dir: Path | None,
65
+ default_root: Path,
66
+ status: StatusOutput,
67
+ ) -> Stream:
68
+ session = find_session()
69
+ root = changelog_dir or scratchpad_root(session) or default_root
70
+ changelog = Changelog(root, ref)
71
+ problem = messaging_problem(session)
72
+ if mode == "claude-message" and problem is not None:
73
+ raise StreamError(f"--events-stream=claude-message is not possible: {problem}; use --events-stream=stdout")
74
+ if mode == "claude-message" or (mode == "auto" and problem is None):
75
+ assert session is not None
76
+ sink = ClaudeMessageSink(session, ref, changelog.events_path, cast(Priority, priority), status)
77
+ return Stream(sink, changelog)
78
+ # Under a Claude Code session, say why auto did not message it; outside one, stdout is simply the default.
79
+ fallback = problem if mode == "auto" and session is not None else None
80
+ return Stream(StdoutSink(), changelog, fallback)