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,3 @@
1
+ """Extra commands for the GitLab CLI (glab)."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,95 @@
1
+ """Job logs, fetched once after a job ends.
2
+
3
+ On GitLab 19.0 and later the trace endpoint takes `byte_offset` and `byte_limit` (at most 500 KB), so a large log is
4
+ read as its last 500 KB only. The size comes from the job's trace artifact. Older versions get the full trace.
5
+ A log already in the shared cache is reused, so two sessions watching the same pipeline download it once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from glab_cli_extras.gitlab.client import GitLabClient, encode_project
16
+ from glab_cli_extras.paths import project_dir, sanitize_part
17
+
18
+ LogsMode = Literal["failed", "all", "none"]
19
+ LOGS_MODES: tuple[LogsMode, ...] = ("failed", "all", "none")
20
+ TAIL_BYTES = 500 * 1024
21
+ """GitLab's `Gitlab::Ci::Trace::Stream::LIMIT_SIZE`, the largest `byte_limit` it accepts."""
22
+ BYTE_OFFSET_MIN_VERSION = (19, 0)
23
+
24
+
25
+ def merge_logs_modes(modes: list[LogsMode]) -> LogsMode:
26
+ if "all" in modes:
27
+ return "all"
28
+ if "failed" in modes:
29
+ return "failed"
30
+ return "none"
31
+
32
+
33
+ def wants_log(mode: LogsMode, status: str) -> bool:
34
+ if mode == "all":
35
+ return status in ("success", "failed", "canceled")
36
+ if mode == "failed":
37
+ return status == "failed"
38
+ return False
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class FetchedLog:
43
+ path: Path
44
+ truncated: bool
45
+ size: int | None
46
+ reused: bool
47
+
48
+
49
+ def log_path(cache_root: Path, host: str, project: str, job_id: int, job_name: str) -> Path:
50
+ return project_dir(cache_root, host, project) / "jobs" / f"{job_id}-{sanitize_part(job_name)[:80]}.log"
51
+
52
+
53
+ def _trace_size(job: Any) -> int | None:
54
+ if not isinstance(job, dict):
55
+ return None
56
+ for artifact in job.get("artifacts") or []:
57
+ if (
58
+ isinstance(artifact, dict)
59
+ and artifact.get("file_type") == "trace"
60
+ and isinstance(artifact.get("size"), int)
61
+ ):
62
+ return int(artifact["size"])
63
+ return None
64
+
65
+
66
+ async def fetch_job_log(
67
+ client: GitLabClient,
68
+ cache_root: Path,
69
+ project: str,
70
+ job_id: int,
71
+ job_name: str,
72
+ known_size: int | None,
73
+ ) -> FetchedLog:
74
+ """Download the job's log (or its tail) into the shared cache, unless it is already there."""
75
+ path = log_path(cache_root, client.host, project, job_id, job_name)
76
+ if path.is_file():
77
+ return FetchedLog(path, path.read_bytes().startswith(b"[glab-watch: last "), known_size, True)
78
+ encoded = encode_project(project)
79
+ version = await client.version()
80
+ tail_supported = version is not None and version >= BYTE_OFFSET_MIN_VERSION
81
+ size = known_size
82
+ if size is None and tail_supported:
83
+ size = _trace_size(await client.get_json(f"projects/{encoded}/jobs/{job_id}", kind="job"))
84
+ params: dict[str, str | int] = {}
85
+ truncated = False
86
+ if tail_supported and size is not None and size > TAIL_BYTES:
87
+ params = {"byte_offset": size - TAIL_BYTES, "byte_limit": TAIL_BYTES}
88
+ truncated = True
89
+ response = await client.get(f"projects/{encoded}/jobs/{job_id}/trace", params, kind="trace")
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ tmp = path.with_suffix(".tmp")
92
+ header = f"[glab-watch: last {TAIL_BYTES} of {size} bytes]\n".encode() if truncated else b""
93
+ tmp.write_bytes(header + response.body)
94
+ os.replace(tmp, path)
95
+ return FetchedLog(path, truncated, size, False)
@@ -0,0 +1,147 @@
1
+ """One markdown file per MR discussion thread, in the format glab-discussion uses.
2
+
3
+ A file is rewritten only when its rendered content changed, so its mtime says when the thread last changed. The
4
+ `.meta.json` next to the files maps each discussion id to its file name and content hash.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from glab_cli_extras.paths import sanitize_part
18
+
19
+ META_FILE = ".meta.json"
20
+
21
+
22
+ def _endpoint(old_line: Any, new_line: Any) -> str:
23
+ parts = []
24
+ if new_line is not None:
25
+ parts.append(f"new:{new_line}")
26
+ if old_line is not None:
27
+ parts.append(f"old:{old_line}")
28
+ return " / ".join(parts)
29
+
30
+
31
+ def _position_lines(position: Mapping[str, Any]) -> list[str]:
32
+ lines = [f"File: {position.get('new_path') or position.get('old_path')}"]
33
+ line_range = position.get("line_range")
34
+ if isinstance(line_range, dict) and line_range.get("start") and line_range.get("end"):
35
+ start, end = line_range["start"], line_range["end"]
36
+ lines.append(
37
+ f"Lines: {_endpoint(start.get('old_line'), start.get('new_line'))}"
38
+ f" to {_endpoint(end.get('old_line'), end.get('new_line'))}"
39
+ )
40
+ else:
41
+ line = _endpoint(position.get("old_line"), position.get("new_line"))
42
+ if line:
43
+ lines.append(f"Line: {line}")
44
+ if position.get("head_sha"):
45
+ lines.append(f"Commit: {position['head_sha']}")
46
+ return lines
47
+
48
+
49
+ def is_system_discussion(discussion: Mapping[str, Any]) -> bool:
50
+ notes = discussion.get("notes") or []
51
+ return bool(notes) and all(n.get("system") for n in notes)
52
+
53
+
54
+ def render_discussion(discussion: Mapping[str, Any], mr_url: str) -> str:
55
+ """Render a REST discussion object as text: a header, then one block per note."""
56
+ notes: list[Mapping[str, Any]] = list(discussion.get("notes") or [])
57
+ first = notes[0] if notes else {}
58
+ lines = [f"Discussion: {discussion.get('id')}"]
59
+ position = first.get("position")
60
+ if first.get("type") == "DiffNote" and isinstance(position, dict):
61
+ lines.append("Type: DiffNote")
62
+ lines.extend(_position_lines(position))
63
+ else:
64
+ lines.append("Type: General")
65
+ resolvable = [n for n in notes if n.get("resolvable")]
66
+ if resolvable:
67
+ lines.append(f"Resolved: {'yes' if all(n.get('resolved') for n in resolvable) else 'no'}")
68
+ if first.get("id") is not None:
69
+ lines.append(f"URL: {mr_url}#note_{first['id']}")
70
+ lines.append("---")
71
+ for note in notes:
72
+ author = (note.get("author") or {}).get("username", "unknown")
73
+ timestamp = str(note.get("created_at", "")).replace("T", " ").split(".")[0].removesuffix("Z")
74
+ lines.append(f"[{timestamp}] @{author} (note:{note.get('id')}):")
75
+ lines.append(str(note.get("body", "")))
76
+ lines.append("")
77
+ return "\n".join(lines)
78
+
79
+
80
+ def thread_filename(discussion: Mapping[str, Any]) -> str:
81
+ """`<created, to the minute>-<author>-<id prefix>.md`, so a directory listing sorts threads by start time."""
82
+ notes = discussion.get("notes") or [{}]
83
+ first = notes[0]
84
+ created = sanitize_part(str(first.get("created_at", ""))[:16].replace(":", "-")) or "unknown"
85
+ author = sanitize_part(str((first.get("author") or {}).get("username", "unknown")))
86
+ short_id = sanitize_part(str(discussion.get("id", ""))[:12])
87
+ return f"{created}-{author}-{short_id}.md"
88
+
89
+
90
+ def _hash(content: str) -> str:
91
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
92
+
93
+
94
+ class ThreadStore:
95
+ def __init__(self, directory: Path, mr_url: str) -> None:
96
+ self.directory = directory
97
+ self.mr_url = mr_url
98
+ self._meta_path = directory / META_FILE
99
+ self._meta: dict[str, dict[str, str]] = self._load_meta()
100
+
101
+ def _load_meta(self) -> dict[str, dict[str, str]]:
102
+ try:
103
+ raw = json.loads(self._meta_path.read_text(encoding="utf-8"))
104
+ except (OSError, ValueError):
105
+ return {}
106
+ if not isinstance(raw, dict):
107
+ return {}
108
+ return {str(k): v for k, v in raw.items() if isinstance(v, dict)}
109
+
110
+ def _save_meta(self) -> None:
111
+ self.directory.mkdir(parents=True, exist_ok=True)
112
+ tmp = self._meta_path.with_suffix(".tmp")
113
+ tmp.write_text(json.dumps(self._meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
114
+ os.replace(tmp, self._meta_path)
115
+
116
+ def path_for(self, discussion_id: str) -> Path | None:
117
+ entry = self._meta.get(discussion_id)
118
+ return self.directory / entry["filename"] if entry else None
119
+
120
+ def write(self, discussion: Mapping[str, Any]) -> tuple[Path, bool]:
121
+ """Write the thread's file if its content changed. Return the path and whether it was written."""
122
+ discussion_id = str(discussion["id"])
123
+ content = render_discussion(discussion, self.mr_url)
124
+ filename = thread_filename(discussion)
125
+ path = self.directory / filename
126
+ digest = _hash(content)
127
+ old = self._meta.get(discussion_id)
128
+ if old and old.get("hash") == digest and old.get("filename") == filename and path.is_file():
129
+ return path, False
130
+ self.directory.mkdir(parents=True, exist_ok=True)
131
+ tmp = path.with_suffix(".tmp")
132
+ tmp.write_text(content, encoding="utf-8")
133
+ os.replace(tmp, path)
134
+ if old and old.get("filename") and old["filename"] != filename:
135
+ with contextlib.suppress(OSError):
136
+ (self.directory / old["filename"]).unlink()
137
+ self._meta[discussion_id] = {"filename": filename, "hash": digest}
138
+ self._save_meta()
139
+ return path, True
140
+
141
+ def remove(self, discussion_id: str) -> None:
142
+ entry = self._meta.pop(discussion_id, None)
143
+ if entry is None:
144
+ return
145
+ with contextlib.suppress(OSError):
146
+ (self.directory / entry["filename"]).unlink()
147
+ self._save_meta()
File without changes
@@ -0,0 +1,426 @@
1
+ """`glab-watch`: report changes to a merge request, a pipeline, a project's merge requests, or a branch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, NoReturn
11
+
12
+ import typer
13
+
14
+ from glab_cli_extras import __version__
15
+ from glab_cli_extras.artifacts.job_logs import LOGS_MODES, LogsMode
16
+ from glab_cli_extras.daemon.lifecycle import daemon_running, stop_daemon
17
+ from glab_cli_extras.daemon.transport import DaemonFiles, DaemonUnavailable, request
18
+ from glab_cli_extras.events import GROUPS, KINDS_BY_RESOURCE, LIFECYCLE_KINDS, FilterError, resolve_filter
19
+ from glab_cli_extras.gitlab.glab import GlabNotFoundError
20
+ from glab_cli_extras.gitlab.refs import RefError, ResourceRef, parse_id_ref, parse_repo_ref
21
+ from glab_cli_extras.paths import default_changelog_root, user_cache_dir
22
+ from glab_cli_extras.watch.changelog import Changelog
23
+ from glab_cli_extras.watch.client import Backend, BackendError, Watcher, WatchSettings, default_backend
24
+ from glab_cli_extras.watch.sinks import Sink, StatusOutput
25
+ from glab_cli_extras.watch.streams import MESSAGE_PRIORITIES, STREAM_MODES, StreamError, open_stream
26
+
27
+ EXIT_USAGE = 2
28
+
29
+ app = typer.Typer(
30
+ name="glab-watch",
31
+ help=(
32
+ "Watch a GitLab merge request, pipeline, project or branch and report each change as a JSON event. "
33
+ "Uses the glab CLI for every request; a shared background daemon keeps many watchers cheap on the API."
34
+ ),
35
+ no_args_is_help=True,
36
+ add_completion=False,
37
+ rich_markup_mode=None,
38
+ context_settings={"help_option_names": ["-h", "--help"]},
39
+ )
40
+ daemon_app = typer.Typer(help="Inspect or stop the shared polling daemon.", no_args_is_help=True)
41
+ app.add_typer(daemon_app, name="daemon")
42
+
43
+
44
+ def _version(value: bool) -> None:
45
+ if value:
46
+ typer.echo(f"glab-watch {__version__}")
47
+ raise typer.Exit()
48
+
49
+
50
+ @app.callback()
51
+ def _root(
52
+ version: Annotated[
53
+ bool, typer.Option("--version", "-V", callback=_version, is_eager=True, help="Print the version and exit.")
54
+ ] = False,
55
+ ) -> None:
56
+ pass
57
+
58
+
59
+ EventsOpt = Annotated[
60
+ list[str] | None,
61
+ typer.Option(
62
+ "--events",
63
+ help="Report only these event kinds or groups, comma-separated (groups: status, comments, push, pipeline).",
64
+ ),
65
+ ]
66
+ ExcludeOpt = Annotated[
67
+ list[str] | None,
68
+ typer.Option("--exclude-events", help="Do not report these event kinds or groups, comma-separated."),
69
+ ]
70
+ StreamOpt = Annotated[
71
+ str,
72
+ typer.Option(
73
+ "--events-stream",
74
+ help="auto picks claude-message inside a Claude Code session that accepts messages, else stdout.",
75
+ ),
76
+ ]
77
+ ChangelogOpt = Annotated[
78
+ Path | None, typer.Option("--changelog-dir", help="Root dir of the events.jsonl and state.json files.")
79
+ ]
80
+ UntilOpt = Annotated[str | None, typer.Option("--until", help="Stop after the first event of this kind.")]
81
+ TimeoutOpt = Annotated[
82
+ str | None, typer.Option("--timeout", help="Stop after this long, for example 90s, 30m, 2h, 1h30m.")
83
+ ]
84
+ NoDaemonOpt = Annotated[
85
+ bool, typer.Option("--no-daemon", help="Poll inside this process with a private budget, without the daemon.")
86
+ ]
87
+ PriorityOpt = Annotated[
88
+ str,
89
+ typer.Option(
90
+ "--message-priority",
91
+ help="claude-message only: now or next deliver into the running turn, later waits until the session is idle.",
92
+ ),
93
+ ]
94
+ LogsOpt = Annotated[str, typer.Option("--logs", help="Which finished jobs get their log fetched: failed, all or none.")]
95
+
96
+ _DURATION_RE = re.compile(r"^(?:(?P<h>\d+)h)?(?:(?P<m>\d+)m)?(?:(?P<s>\d+)s)?$")
97
+
98
+
99
+ def parse_duration(value: str) -> float:
100
+ text = value.strip().lower()
101
+ if text.replace(".", "", 1).isdigit():
102
+ return float(text)
103
+ match = _DURATION_RE.match(text)
104
+ if not text or not match or not any(match.groupdict().values()):
105
+ raise ValueError(f"cannot read the duration {value!r}; use for example 90s, 30m, 2h or 1h30m")
106
+ parts = {k: int(v) if v else 0 for k, v in match.groupdict().items()}
107
+ return float(parts["h"] * 3600 + parts["m"] * 60 + parts["s"])
108
+
109
+
110
+ def _fail(message: str, code: int = EXIT_USAGE) -> NoReturn:
111
+ StatusOutput(sys.stderr).line(f"error: {message}")
112
+ raise typer.Exit(code)
113
+
114
+
115
+ def restart_ref(ref: ResourceRef) -> str:
116
+ """The resource as a command-line argument that resolves without the cwd."""
117
+ base = f"https://{ref.host}/{ref.project}"
118
+ if ref.kind == "mr":
119
+ return f"{base}/-/merge_requests/{ref.id}"
120
+ if ref.kind == "pipeline":
121
+ return f"{base}/-/pipelines/{ref.id}"
122
+ if ref.kind == "branch":
123
+ return f"{base} {ref.branch}"
124
+ return base
125
+
126
+
127
+ def print_header(
128
+ status: StatusOutput,
129
+ ref: ResourceRef,
130
+ sink: Sink,
131
+ changelog: Changelog,
132
+ events: frozenset[str],
133
+ logs: LogsMode | None,
134
+ backend: Backend,
135
+ until: str | None,
136
+ timeout: str | None,
137
+ fallback: str | None = None,
138
+ ) -> None:
139
+ """The startup header: what is watched, how events are delivered, and how to restart with other options."""
140
+ tracked = KINDS_BY_RESOURCE[ref.kind]
141
+ subscribed = [k for k in tracked if k in events]
142
+ ignored = [k for k in tracked if k not in events]
143
+ status.line(f"resource: {ref.key}")
144
+ status.line(f"stream: {sink.describe()}")
145
+ if fallback:
146
+ status.line(f"stream fallback: not messaging the Claude Code session: {fallback}")
147
+ status.line(f"changelog: {changelog.events_path}")
148
+ status.line(f"events: {','.join(subscribed) or '(none)'} (always: {','.join(LIFECYCLE_KINDS)})")
149
+ status.line(f"ignored: {','.join(ignored) or '(none)'}")
150
+ if logs is not None:
151
+ status.line(f"logs: {logs}")
152
+ if until or timeout:
153
+ status.line(
154
+ f"ends: {'on ' + until if until else ''}{' or ' if until and timeout else ''}"
155
+ f"{'after ' + timeout if timeout else ''}"
156
+ )
157
+ status.line(f"daemon: {backend.describe()}")
158
+ groups = ", ".join(f"{name}" for name in GROUPS if name != "lifecycle" and set(GROUPS[name]) & set(tracked))
159
+ options = "--events=<kinds|groups> --exclude-events=<kinds|groups>"
160
+ if logs is not None:
161
+ options += " --logs=failed|all|none"
162
+ status.line(f"restart with: glab-watch {ref.kind} {restart_ref(ref)} {options} (groups: {groups})")
163
+
164
+
165
+ def run_watch(
166
+ resolve: Callable[[], ResourceRef],
167
+ kind: str,
168
+ *,
169
+ events: list[str] | None,
170
+ exclude_events: list[str] | None,
171
+ events_stream: str,
172
+ changelog_dir: Path | None,
173
+ until: str | None,
174
+ timeout: str | None,
175
+ no_daemon: bool,
176
+ message_priority: str,
177
+ logs: str | None,
178
+ ) -> None:
179
+ if events_stream not in STREAM_MODES:
180
+ _fail(f"--events-stream must be one of {', '.join(STREAM_MODES)}")
181
+ if message_priority not in MESSAGE_PRIORITIES:
182
+ _fail(f"--message-priority must be one of {', '.join(MESSAGE_PRIORITIES)}")
183
+ if logs is not None and logs not in LOGS_MODES:
184
+ _fail(f"--logs must be one of {', '.join(LOGS_MODES)}")
185
+ try:
186
+ selected = resolve_filter(kind, events, exclude_events)
187
+ except FilterError as exc:
188
+ _fail(str(exc))
189
+ if until is not None and until not in KINDS_BY_RESOURCE[kind]:
190
+ _fail(f"--until: a {kind} watch never produces {until!r}")
191
+ if until is not None and until not in selected:
192
+ _fail(f"--until={until} is filtered out by --events/--exclude-events")
193
+ try:
194
+ timeout_s = parse_duration(timeout) if timeout else None
195
+ except ValueError as exc:
196
+ _fail(str(exc))
197
+ try:
198
+ ref = resolve()
199
+ except (RefError, GlabNotFoundError) as exc:
200
+ _fail(str(exc))
201
+
202
+ status = StatusOutput()
203
+ try:
204
+ stream = open_stream(events_stream, ref, message_priority, changelog_dir, default_changelog_root(), status)
205
+ except StreamError as exc:
206
+ _fail(str(exc))
207
+ changelog = stream.changelog
208
+ logs_mode: LogsMode = logs if logs in LOGS_MODES else "failed"
209
+ try:
210
+ backend = default_backend(no_daemon, user_cache_dir())
211
+ except (DaemonUnavailable, TimeoutError) as exc:
212
+ _fail(f"cannot start the daemon: {exc}; retry with --no-daemon to poll without it", 1)
213
+ print_header(
214
+ status,
215
+ ref,
216
+ stream.sink,
217
+ changelog,
218
+ selected,
219
+ logs if logs is not None else None,
220
+ backend,
221
+ until,
222
+ timeout,
223
+ stream.fallback,
224
+ )
225
+ watcher = Watcher(
226
+ WatchSettings(ref, selected, logs_mode, until, timeout_s), backend, stream.sink, changelog, status
227
+ )
228
+ try:
229
+ code = watcher.run()
230
+ finally:
231
+ backend.close()
232
+ raise typer.Exit(code)
233
+
234
+
235
+ @app.command()
236
+ def mr(
237
+ ref: Annotated[str, typer.Argument(help="group/project!123, !123, 123 (repo from the cwd), or the MR URL.")],
238
+ events: EventsOpt = None,
239
+ exclude_events: ExcludeOpt = None,
240
+ logs: LogsOpt = "failed",
241
+ until: UntilOpt = None,
242
+ timeout: TimeoutOpt = None,
243
+ events_stream: StreamOpt = "auto",
244
+ changelog_dir: ChangelogOpt = None,
245
+ message_priority: PriorityOpt = "next",
246
+ no_daemon: NoDaemonOpt = False,
247
+ ) -> None:
248
+ """Watch one merge request: state, pushes, approvals, threads, and its head pipeline. Ends when merged or closed."""
249
+ run_watch(
250
+ lambda: parse_id_ref("mr", ref),
251
+ "mr",
252
+ events=events,
253
+ exclude_events=exclude_events,
254
+ events_stream=events_stream,
255
+ changelog_dir=changelog_dir,
256
+ until=until,
257
+ timeout=timeout,
258
+ no_daemon=no_daemon,
259
+ message_priority=message_priority,
260
+ logs=logs,
261
+ )
262
+
263
+
264
+ @app.command()
265
+ def pipeline(
266
+ ref: Annotated[str, typer.Argument(help="group/project!4567, !4567, 4567 (repo from the cwd), or the URL.")],
267
+ events: EventsOpt = None,
268
+ exclude_events: ExcludeOpt = None,
269
+ logs: LogsOpt = "failed",
270
+ until: UntilOpt = None,
271
+ timeout: TimeoutOpt = None,
272
+ events_stream: StreamOpt = "auto",
273
+ changelog_dir: ChangelogOpt = None,
274
+ message_priority: PriorityOpt = "next",
275
+ no_daemon: NoDaemonOpt = False,
276
+ ) -> None:
277
+ """Watch one pipeline and its downstream pipelines. Ends when it finishes and the wanted logs are fetched."""
278
+ run_watch(
279
+ lambda: parse_id_ref("pipeline", ref),
280
+ "pipeline",
281
+ events=events,
282
+ exclude_events=exclude_events,
283
+ events_stream=events_stream,
284
+ changelog_dir=changelog_dir,
285
+ until=until,
286
+ timeout=timeout,
287
+ no_daemon=no_daemon,
288
+ message_priority=message_priority,
289
+ logs=logs,
290
+ )
291
+
292
+
293
+ @app.command()
294
+ def mrs(
295
+ repo: Annotated[str | None, typer.Argument(help="group/project or a project URL; default: the cwd's repo.")] = None,
296
+ events: EventsOpt = None,
297
+ exclude_events: ExcludeOpt = None,
298
+ until: UntilOpt = None,
299
+ timeout: TimeoutOpt = None,
300
+ events_stream: StreamOpt = "auto",
301
+ changelog_dir: ChangelogOpt = None,
302
+ message_priority: PriorityOpt = "next",
303
+ no_daemon: NoDaemonOpt = False,
304
+ ) -> None:
305
+ """Watch every merge request of a project: opened, updated, merged, closed, reopened. Runs until stopped."""
306
+ run_watch(
307
+ lambda: parse_repo_ref("mrs", repo),
308
+ "mrs",
309
+ events=events,
310
+ exclude_events=exclude_events,
311
+ events_stream=events_stream,
312
+ changelog_dir=changelog_dir,
313
+ until=until,
314
+ timeout=timeout,
315
+ no_daemon=no_daemon,
316
+ message_priority=message_priority,
317
+ logs=None,
318
+ )
319
+
320
+
321
+ @app.command()
322
+ def branch(
323
+ args: Annotated[list[str], typer.Argument(metavar="[REPO] BRANCH", help="The branch, optionally after the repo.")],
324
+ events: EventsOpt = None,
325
+ exclude_events: ExcludeOpt = None,
326
+ until: UntilOpt = None,
327
+ timeout: TimeoutOpt = None,
328
+ events_stream: StreamOpt = "auto",
329
+ changelog_dir: ChangelogOpt = None,
330
+ message_priority: PriorityOpt = "next",
331
+ no_daemon: NoDaemonOpt = False,
332
+ ) -> None:
333
+ """Watch a branch: pushes, force-pushes, creation, deletion, and its pipelines. Runs until stopped."""
334
+ if len(args) not in (1, 2):
335
+ _fail("expected [REPO] BRANCH")
336
+ repo, name = (None, args[0]) if len(args) == 1 else (args[0], args[1])
337
+ run_watch(
338
+ lambda: parse_repo_ref("branch", repo, name),
339
+ "branch",
340
+ events=events,
341
+ exclude_events=exclude_events,
342
+ events_stream=events_stream,
343
+ changelog_dir=changelog_dir,
344
+ until=until,
345
+ timeout=timeout,
346
+ no_daemon=no_daemon,
347
+ message_priority=message_priority,
348
+ logs=None,
349
+ )
350
+
351
+
352
+ @daemon_app.command("status")
353
+ def daemon_status(
354
+ as_json: Annotated[bool, typer.Option("--json", help="Print the raw status object.")] = False,
355
+ ) -> None:
356
+ """Show the daemon's subscriptions, resources, budgets and request counts."""
357
+ files = DaemonFiles.default()
358
+ try:
359
+ response = request(files, {"op": "status"}, timeout=5)
360
+ except DaemonUnavailable:
361
+ running = daemon_running(files)
362
+ typer.echo("daemon: not running" if not running else "daemon: running but not answering; see daemon logs")
363
+ raise typer.Exit(0 if not running else 1) from None
364
+ if as_json:
365
+ typer.echo(json.dumps(response, indent=2))
366
+ return
367
+ _print_status(response, files)
368
+
369
+
370
+ def _print_status(status: dict[str, Any], files: DaemonFiles) -> None:
371
+ typer.echo(
372
+ f"daemon: pid {status['pid']}, version {status['version']}, protocol {status['protocol']}, "
373
+ f"up {status['uptime_s']} s, endpoint {files.describe_endpoint()}"
374
+ )
375
+ for sub in status.get("subscriptions", []):
376
+ typer.echo(
377
+ f"subscription {sub['id'][:8]} client {sub['client_id'][:8]} {sub['resource']} "
378
+ f"(lease idle {sub['idle_s']} s, logs {sub['logs']})"
379
+ )
380
+ for res in status.get("resources", []):
381
+ state = "ended" if res["ended"] else (f"degraded: {res['degraded']}" if res["degraded"] else "active")
382
+ typer.echo(
383
+ f"resource {res['resource']}: {state}, {res['polls']} polls, seq {res['seq']}, "
384
+ f"next poll in {res['next_poll_in_s']} s"
385
+ )
386
+ for host, info in status.get("hosts", {}).items():
387
+ requests, budget = info["requests"], info["budget"]
388
+ typer.echo(
389
+ f"host {host}: {requests['requests']} requests ({requests['not_modified']} not modified), "
390
+ f"budget {budget['rate_per_minute']}/min, remaining {budget['remaining']}"
391
+ )
392
+
393
+
394
+ @daemon_app.command("stop")
395
+ def daemon_stop() -> None:
396
+ """Stop the daemon. Running watchers start a new one on their next poll."""
397
+ files = DaemonFiles.default()
398
+ try:
399
+ stopped = stop_daemon(files)
400
+ except DaemonUnavailable as exc:
401
+ _fail(str(exc), 1)
402
+ typer.echo("daemon: stopped" if stopped else "daemon: not running")
403
+
404
+
405
+ @daemon_app.command("logs")
406
+ def daemon_logs(
407
+ lines: Annotated[int, typer.Option("--lines", "-n", help="How many lines from the end.")] = 50,
408
+ ) -> None:
409
+ """Print the end of the daemon's log file."""
410
+ files = DaemonFiles.default()
411
+ try:
412
+ content = files.log.read_text(encoding="utf-8", errors="replace").splitlines()
413
+ except FileNotFoundError:
414
+ typer.echo(f"no daemon log at {files.log}")
415
+ return
416
+ typer.echo(f"==> {files.log}")
417
+ for line in content[-lines:]:
418
+ typer.echo(line)
419
+
420
+
421
+ def main() -> None:
422
+ try:
423
+ app()
424
+ except (BackendError, DaemonUnavailable) as exc:
425
+ StatusOutput(sys.stderr).line(f"error: {exc}")
426
+ sys.exit(1)
File without changes
@@ -0,0 +1,10 @@
1
+ """`python -m glab_cli_extras.daemon`: the daemon process. Clients start it; it is not meant to be run by hand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from glab_cli_extras.daemon.lifecycle import run_daemon
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(run_daemon())