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.
- glab_cli_extras/__init__.py +3 -0
- glab_cli_extras/artifacts/__init__.py +0 -0
- glab_cli_extras/artifacts/job_logs.py +95 -0
- glab_cli_extras/artifacts/threads.py +147 -0
- glab_cli_extras/cli/__init__.py +0 -0
- glab_cli_extras/cli/watch.py +426 -0
- glab_cli_extras/daemon/__init__.py +0 -0
- glab_cli_extras/daemon/__main__.py +10 -0
- glab_cli_extras/daemon/engine.py +491 -0
- glab_cli_extras/daemon/lifecycle.py +186 -0
- glab_cli_extras/daemon/locks.py +85 -0
- glab_cli_extras/daemon/scheduler.py +52 -0
- glab_cli_extras/daemon/server.py +79 -0
- glab_cli_extras/daemon/transport.py +185 -0
- glab_cli_extras/events.py +165 -0
- glab_cli_extras/gitlab/__init__.py +0 -0
- glab_cli_extras/gitlab/budget.py +135 -0
- glab_cli_extras/gitlab/client.py +225 -0
- glab_cli_extras/gitlab/glab.py +175 -0
- glab_cli_extras/gitlab/queries/__init__.py +11 -0
- glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
- glab_cli_extras/gitlab/queries/mr.graphql +42 -0
- glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
- glab_cli_extras/gitlab/refs.py +151 -0
- glab_cli_extras/paths.py +79 -0
- glab_cli_extras/py.typed +0 -0
- glab_cli_extras/resources/__init__.py +19 -0
- glab_cli_extras/resources/base.py +146 -0
- glab_cli_extras/resources/branch.py +265 -0
- glab_cli_extras/resources/mr.py +445 -0
- glab_cli_extras/resources/mr_list.py +150 -0
- glab_cli_extras/resources/pipeline.py +319 -0
- glab_cli_extras/serde.py +75 -0
- glab_cli_extras/watch/__init__.py +0 -0
- glab_cli_extras/watch/changelog.py +86 -0
- glab_cli_extras/watch/claude.py +193 -0
- glab_cli_extras/watch/client.py +316 -0
- glab_cli_extras/watch/sinks.py +83 -0
- glab_cli_extras/watch/streams.py +80 -0
- glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
- glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
- glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
- glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
- glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Exclusive OS file locks: `fcntl.flock` on POSIX, `msvcrt.locking` on Windows.
|
|
2
|
+
|
|
3
|
+
The OS releases the lock when the holding process dies, so a lock file left on disk means nothing. Being able to take
|
|
4
|
+
the daemon lock proves that no daemon is running.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LockTimeout(TimeoutError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileLock:
|
|
21
|
+
def __init__(self, path: Path) -> None:
|
|
22
|
+
self.path = path
|
|
23
|
+
self._fd: int | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def held(self) -> bool:
|
|
27
|
+
return self._fd is not None
|
|
28
|
+
|
|
29
|
+
def _try_lock(self, fd: int) -> bool:
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
import msvcrt
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
35
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
36
|
+
except OSError:
|
|
37
|
+
return False
|
|
38
|
+
return True
|
|
39
|
+
import fcntl
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def try_acquire(self) -> bool:
|
|
48
|
+
if self._fd is not None:
|
|
49
|
+
return True
|
|
50
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
52
|
+
if self._try_lock(fd):
|
|
53
|
+
self._fd = fd
|
|
54
|
+
return True
|
|
55
|
+
os.close(fd)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def acquire(self, timeout: float) -> None:
|
|
59
|
+
deadline = time.monotonic() + timeout
|
|
60
|
+
while not self.try_acquire():
|
|
61
|
+
if time.monotonic() >= deadline:
|
|
62
|
+
raise LockTimeout(f"could not lock {self.path} within {timeout:.0f} s")
|
|
63
|
+
time.sleep(0.05)
|
|
64
|
+
|
|
65
|
+
def release(self) -> None:
|
|
66
|
+
fd, self._fd = self._fd, None
|
|
67
|
+
if fd is None:
|
|
68
|
+
return
|
|
69
|
+
if sys.platform == "win32":
|
|
70
|
+
import msvcrt
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
74
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
os.close(fd)
|
|
78
|
+
|
|
79
|
+
def __enter__(self) -> FileLock:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(
|
|
83
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
84
|
+
) -> None:
|
|
85
|
+
self.release()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Which resource polls next.
|
|
2
|
+
|
|
3
|
+
Resources wait in due-time order. Among those that are due, urgent ones go first (probes that can end a watch), then
|
|
4
|
+
the one served longest ago. That round-robin keeps one busy pipeline from starving a quiet MR watch when the budget
|
|
5
|
+
is short. The scheduler holds no clock and does no I/O; the engine passes the time in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class _Entry:
|
|
15
|
+
due: float
|
|
16
|
+
urgent: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Scheduler:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._entries: dict[str, _Entry] = {}
|
|
22
|
+
self._last_served: dict[str, float] = {}
|
|
23
|
+
|
|
24
|
+
def schedule(self, key: str, due: float, *, urgent: bool = False) -> None:
|
|
25
|
+
self._entries[key] = _Entry(due, urgent)
|
|
26
|
+
|
|
27
|
+
def remove(self, key: str) -> None:
|
|
28
|
+
self._entries.pop(key, None)
|
|
29
|
+
|
|
30
|
+
def forget(self, key: str) -> None:
|
|
31
|
+
self._entries.pop(key, None)
|
|
32
|
+
self._last_served.pop(key, None)
|
|
33
|
+
|
|
34
|
+
def __contains__(self, key: str) -> bool:
|
|
35
|
+
return key in self._entries
|
|
36
|
+
|
|
37
|
+
def due_at(self, key: str) -> float | None:
|
|
38
|
+
entry = self._entries.get(key)
|
|
39
|
+
return entry.due if entry else None
|
|
40
|
+
|
|
41
|
+
def next_due(self) -> float | None:
|
|
42
|
+
return min((e.due for e in self._entries.values()), default=None)
|
|
43
|
+
|
|
44
|
+
def ready(self, now: float) -> list[str]:
|
|
45
|
+
"""Due keys in serving order: urgent first, then least recently served, then earliest due."""
|
|
46
|
+
due = [(key, entry) for key, entry in self._entries.items() if entry.due <= now]
|
|
47
|
+
due.sort(key=lambda item: (not item[1].urgent, self._last_served.get(item[0], float("-inf")), item[1].due))
|
|
48
|
+
return [key for key, _ in due]
|
|
49
|
+
|
|
50
|
+
def mark_served(self, key: str, now: float) -> None:
|
|
51
|
+
self._entries.pop(key, None)
|
|
52
|
+
self._last_served[key] = now
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Dispatch of daemon requests to the engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from glab_cli_extras import __version__
|
|
10
|
+
from glab_cli_extras.artifacts.job_logs import LOGS_MODES, LogsMode
|
|
11
|
+
from glab_cli_extras.daemon.engine import Engine, EngineError
|
|
12
|
+
from glab_cli_extras.daemon.transport import PROTOCOL_VERSION
|
|
13
|
+
from glab_cli_extras.events import ALL_KINDS
|
|
14
|
+
from glab_cli_extras.gitlab.refs import RefError, ResourceRef
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RequestError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _str(request: dict[str, Any], name: str) -> str:
|
|
22
|
+
value = request.get(name)
|
|
23
|
+
if not isinstance(value, str) or not value:
|
|
24
|
+
raise RequestError(f"{name} must be a non-empty string")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def handle(engine: Engine, request: dict[str, Any], stop: asyncio.Event) -> dict[str, Any]:
|
|
29
|
+
op = request.get("op")
|
|
30
|
+
try:
|
|
31
|
+
if op == "hello":
|
|
32
|
+
return {"ok": True, "protocol": PROTOCOL_VERSION, "version": __version__, "pid": os.getpid()}
|
|
33
|
+
if op == "subscribe":
|
|
34
|
+
options = request.get("options") or {}
|
|
35
|
+
if not isinstance(options, dict):
|
|
36
|
+
raise RequestError("options must be an object")
|
|
37
|
+
raw_events = options.get("events")
|
|
38
|
+
if not isinstance(raw_events, list) or not all(isinstance(e, str) for e in raw_events):
|
|
39
|
+
raise RequestError("options.events must be a list of event kinds")
|
|
40
|
+
unknown = set(raw_events) - ALL_KINDS
|
|
41
|
+
if unknown:
|
|
42
|
+
raise RequestError(f"unknown event kinds: {', '.join(sorted(unknown))}")
|
|
43
|
+
logs = options.get("logs", "failed")
|
|
44
|
+
if logs not in LOGS_MODES:
|
|
45
|
+
raise RequestError(f"options.logs must be one of {', '.join(LOGS_MODES)}")
|
|
46
|
+
logs_mode: LogsMode = logs
|
|
47
|
+
result = engine.subscribe(
|
|
48
|
+
_str(request, "client_id"),
|
|
49
|
+
ResourceRef.from_wire(request.get("resource")),
|
|
50
|
+
frozenset(raw_events),
|
|
51
|
+
logs_mode,
|
|
52
|
+
options.get("baseline"),
|
|
53
|
+
)
|
|
54
|
+
return {"ok": True, **result}
|
|
55
|
+
if op == "poll":
|
|
56
|
+
cursors = request.get("cursors") or {}
|
|
57
|
+
if not isinstance(cursors, dict) or not all(isinstance(v, int) for v in cursors.values()):
|
|
58
|
+
raise RequestError("cursors must map subscription ids to integers")
|
|
59
|
+
return {"ok": True, **engine.poll(_str(request, "client_id"), cursors)}
|
|
60
|
+
if op == "unsubscribe":
|
|
61
|
+
removed = engine.unsubscribe(_str(request, "client_id"), _str(request, "subscription_id"))
|
|
62
|
+
return {"ok": True, "removed": removed}
|
|
63
|
+
if op == "status":
|
|
64
|
+
now = engine.clock()
|
|
65
|
+
return {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"pid": os.getpid(),
|
|
68
|
+
"version": __version__,
|
|
69
|
+
"protocol": PROTOCOL_VERSION,
|
|
70
|
+
"uptime_s": round(now - engine.started_at, 1),
|
|
71
|
+
"idle_s": round(engine.idle_for(now), 1),
|
|
72
|
+
**engine.status(),
|
|
73
|
+
}
|
|
74
|
+
if op == "stop":
|
|
75
|
+
stop.set()
|
|
76
|
+
return {"ok": True, "pid": os.getpid()}
|
|
77
|
+
return {"ok": False, "error": f"unknown op {op!r}"}
|
|
78
|
+
except (RequestError, RefError, EngineError) as exc:
|
|
79
|
+
return {"ok": False, "error": str(exc)}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The daemon transport: NDJSON, one request and one response per connection.
|
|
2
|
+
|
|
3
|
+
POSIX uses a Unix socket in the private state dir. Windows uses loopback TCP on a random port; the port and a random
|
|
4
|
+
token are in a file only the user can read, and every request must carry the token, because any local user can
|
|
5
|
+
connect to a loopback port. Setting `GLAB_CLI_EXTRAS_TRANSPORT=tcp` selects TCP on POSIX too, which is how the
|
|
6
|
+
Windows path is tested on Linux.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import socket
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from glab_cli_extras.paths import daemon_state_dir, ensure_private_dir
|
|
24
|
+
|
|
25
|
+
PROTOCOL_VERSION = 1
|
|
26
|
+
MAX_LINE = 4 * 1024 * 1024
|
|
27
|
+
TRANSPORT_ENV = "GLAB_CLI_EXTRAS_TRANSPORT"
|
|
28
|
+
|
|
29
|
+
Handler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DaemonUnavailable(ConnectionError):
|
|
33
|
+
"""No daemon answers on the endpoint."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def use_tcp() -> bool:
|
|
37
|
+
return os.name == "nt" or os.environ.get(TRANSPORT_ENV) == "tcp" or not hasattr(socket, "AF_UNIX")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DaemonFiles:
|
|
42
|
+
state_dir: Path
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def default(cls) -> DaemonFiles:
|
|
46
|
+
return cls(daemon_state_dir())
|
|
47
|
+
|
|
48
|
+
def _name(self, suffix: str) -> Path:
|
|
49
|
+
return self.state_dir / f"daemon-v{PROTOCOL_VERSION}.{suffix}"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def lock(self) -> Path:
|
|
53
|
+
return self._name("lock")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def spawn_lock(self) -> Path:
|
|
57
|
+
return self.state_dir / f"spawn-v{PROTOCOL_VERSION}.lock"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def socket(self) -> Path:
|
|
61
|
+
return self._name("sock")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def endpoint(self) -> Path:
|
|
65
|
+
return self._name("json")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def log(self) -> Path:
|
|
69
|
+
return self._name("log")
|
|
70
|
+
|
|
71
|
+
def describe_endpoint(self) -> str:
|
|
72
|
+
if use_tcp():
|
|
73
|
+
return str(self.endpoint)
|
|
74
|
+
return str(self.socket)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_private(path: Path, content: str) -> None:
|
|
78
|
+
tmp = path.with_suffix(".tmp")
|
|
79
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
80
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
81
|
+
fh.write(content)
|
|
82
|
+
os.replace(tmp, path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _read_line(reader: asyncio.StreamReader) -> bytes:
|
|
86
|
+
return await reader.readuntil(b"\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def start_server(files: DaemonFiles, handler: Handler) -> tuple[asyncio.AbstractServer, Callable[[], None]]:
|
|
90
|
+
"""Start listening. Return the server and a cleanup function that removes the endpoint file."""
|
|
91
|
+
ensure_private_dir(files.state_dir)
|
|
92
|
+
token = secrets.token_hex(16) if use_tcp() else None
|
|
93
|
+
|
|
94
|
+
async def on_connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
95
|
+
try:
|
|
96
|
+
line = await asyncio.wait_for(_read_line(reader), 10)
|
|
97
|
+
request = json.loads(line)
|
|
98
|
+
if not isinstance(request, dict):
|
|
99
|
+
response: dict[str, Any] = {"ok": False, "error": "request must be a JSON object"}
|
|
100
|
+
elif token is not None and not hmac.compare_digest(str(request.get("token", "")), token):
|
|
101
|
+
response = {"ok": False, "error": "bad token"}
|
|
102
|
+
else:
|
|
103
|
+
request.pop("token", None)
|
|
104
|
+
response = await handler(request)
|
|
105
|
+
writer.write(json.dumps(response, separators=(",", ":")).encode() + b"\n")
|
|
106
|
+
await writer.drain()
|
|
107
|
+
except (TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError, OSError):
|
|
108
|
+
pass
|
|
109
|
+
finally:
|
|
110
|
+
writer.close()
|
|
111
|
+
with contextlib.suppress(OSError, ConnectionError):
|
|
112
|
+
await writer.wait_closed()
|
|
113
|
+
|
|
114
|
+
if token is None:
|
|
115
|
+
with contextlib.suppress(FileNotFoundError):
|
|
116
|
+
files.socket.unlink()
|
|
117
|
+
server = await asyncio.start_unix_server(on_connection, path=str(files.socket), limit=MAX_LINE)
|
|
118
|
+
os.chmod(files.socket, 0o600)
|
|
119
|
+
|
|
120
|
+
def cleanup() -> None:
|
|
121
|
+
with contextlib.suppress(OSError):
|
|
122
|
+
files.socket.unlink()
|
|
123
|
+
|
|
124
|
+
return server, cleanup
|
|
125
|
+
|
|
126
|
+
server = await asyncio.start_server(on_connection, host="127.0.0.1", port=0, limit=MAX_LINE)
|
|
127
|
+
port = server.sockets[0].getsockname()[1]
|
|
128
|
+
_write_private(files.endpoint, json.dumps({"port": port, "token": token, "pid": os.getpid()}))
|
|
129
|
+
|
|
130
|
+
def cleanup_tcp() -> None:
|
|
131
|
+
with contextlib.suppress(OSError):
|
|
132
|
+
files.endpoint.unlink()
|
|
133
|
+
|
|
134
|
+
return server, cleanup_tcp
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _connect(files: DaemonFiles, timeout: float) -> tuple[socket.socket, str | None]:
|
|
138
|
+
if use_tcp():
|
|
139
|
+
try:
|
|
140
|
+
endpoint = json.loads(files.endpoint.read_text(encoding="utf-8"))
|
|
141
|
+
port, token = int(endpoint["port"]), str(endpoint["token"])
|
|
142
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
143
|
+
raise DaemonUnavailable(f"no daemon endpoint in {files.endpoint}") from exc
|
|
144
|
+
try:
|
|
145
|
+
return socket.create_connection(("127.0.0.1", port), timeout=timeout), token
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
raise DaemonUnavailable(str(exc)) from exc
|
|
148
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
149
|
+
sock.settimeout(timeout)
|
|
150
|
+
try:
|
|
151
|
+
sock.connect(str(files.socket))
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
sock.close()
|
|
154
|
+
raise DaemonUnavailable(str(exc)) from exc
|
|
155
|
+
return sock, None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def request(files: DaemonFiles, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
|
|
159
|
+
"""Send one request and return the response. Raises DaemonUnavailable when nothing answers."""
|
|
160
|
+
sock, token = _connect(files, timeout)
|
|
161
|
+
with sock:
|
|
162
|
+
body = dict(payload)
|
|
163
|
+
if token is not None:
|
|
164
|
+
body["token"] = token
|
|
165
|
+
try:
|
|
166
|
+
sock.sendall(json.dumps(body, separators=(",", ":")).encode() + b"\n")
|
|
167
|
+
chunks = []
|
|
168
|
+
total = 0
|
|
169
|
+
while True:
|
|
170
|
+
chunk = sock.recv(65536)
|
|
171
|
+
if not chunk:
|
|
172
|
+
break
|
|
173
|
+
chunks.append(chunk)
|
|
174
|
+
total += len(chunk)
|
|
175
|
+
if chunk.endswith(b"\n") or total > MAX_LINE:
|
|
176
|
+
break
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
raise DaemonUnavailable(str(exc)) from exc
|
|
179
|
+
raw = b"".join(chunks)
|
|
180
|
+
if not raw.strip():
|
|
181
|
+
raise DaemonUnavailable("the daemon closed the connection without answering")
|
|
182
|
+
response = json.loads(raw)
|
|
183
|
+
if not isinstance(response, dict):
|
|
184
|
+
raise DaemonUnavailable("the daemon sent an unexpected response")
|
|
185
|
+
return response
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The event model, the catalogue of event kinds per resource, and the `--events`/`--exclude-events` filter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
WATCH_STARTED = "watch_started"
|
|
11
|
+
WATCH_DEGRADED = "watch_degraded"
|
|
12
|
+
WATCH_ENDED = "watch_ended"
|
|
13
|
+
LIFECYCLE_KINDS: tuple[str, ...] = (WATCH_STARTED, WATCH_DEGRADED, WATCH_ENDED)
|
|
14
|
+
|
|
15
|
+
PIPELINE_KINDS: tuple[str, ...] = ("status_changed", "job_started", "job_finished", "downstream_created", "finished")
|
|
16
|
+
MR_OWN_KINDS: tuple[str, ...] = (
|
|
17
|
+
"state_changed",
|
|
18
|
+
"draft_changed",
|
|
19
|
+
"title_changed",
|
|
20
|
+
"description_changed",
|
|
21
|
+
"pushed",
|
|
22
|
+
"merge_status_changed",
|
|
23
|
+
"approvals_changed",
|
|
24
|
+
"reviewers_changed",
|
|
25
|
+
"head_pipeline_changed",
|
|
26
|
+
"thread_created",
|
|
27
|
+
"thread_updated",
|
|
28
|
+
"thread_resolved",
|
|
29
|
+
"thread_unresolved",
|
|
30
|
+
)
|
|
31
|
+
MRS_KINDS: tuple[str, ...] = ("mr_opened", "mr_updated", "mr_merged", "mr_closed", "mr_reopened")
|
|
32
|
+
BRANCH_KINDS: tuple[str, ...] = (
|
|
33
|
+
"pushed",
|
|
34
|
+
"force_pushed",
|
|
35
|
+
"created",
|
|
36
|
+
"deleted",
|
|
37
|
+
"pipeline_created",
|
|
38
|
+
"pipeline_finished",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
KINDS_BY_RESOURCE: Mapping[str, tuple[str, ...]] = {
|
|
42
|
+
"pipeline": PIPELINE_KINDS,
|
|
43
|
+
"mr": MR_OWN_KINDS + PIPELINE_KINDS,
|
|
44
|
+
"mrs": MRS_KINDS,
|
|
45
|
+
"branch": BRANCH_KINDS,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
GROUPS: Mapping[str, tuple[str, ...]] = {
|
|
49
|
+
"status": (
|
|
50
|
+
"state_changed",
|
|
51
|
+
"draft_changed",
|
|
52
|
+
"merge_status_changed",
|
|
53
|
+
"approvals_changed",
|
|
54
|
+
"reviewers_changed",
|
|
55
|
+
"title_changed",
|
|
56
|
+
"description_changed",
|
|
57
|
+
*MRS_KINDS,
|
|
58
|
+
),
|
|
59
|
+
"comments": ("thread_created", "thread_updated", "thread_resolved", "thread_unresolved"),
|
|
60
|
+
"push": ("pushed", "force_pushed", "created", "deleted"),
|
|
61
|
+
"pipeline": (
|
|
62
|
+
"head_pipeline_changed",
|
|
63
|
+
*PIPELINE_KINDS,
|
|
64
|
+
"pipeline_created",
|
|
65
|
+
"pipeline_finished",
|
|
66
|
+
),
|
|
67
|
+
"lifecycle": LIFECYCLE_KINDS,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ALL_KINDS: frozenset[str] = frozenset(k for kinds in KINDS_BY_RESOURCE.values() for k in kinds) | frozenset(
|
|
71
|
+
LIFECYCLE_KINDS
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FilterError(ValueError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _expand(resource_kind: str, values: Iterable[str], option: str) -> set[str]:
|
|
80
|
+
produced = set(KINDS_BY_RESOURCE[resource_kind]) | set(LIFECYCLE_KINDS)
|
|
81
|
+
expanded: set[str] = set()
|
|
82
|
+
for raw in values:
|
|
83
|
+
name = raw.strip()
|
|
84
|
+
if not name:
|
|
85
|
+
continue
|
|
86
|
+
if name in GROUPS:
|
|
87
|
+
members = set(GROUPS[name]) & produced
|
|
88
|
+
if not members:
|
|
89
|
+
raise FilterError(f"{option}: the group {name!r} has no events for a {resource_kind} watch")
|
|
90
|
+
expanded |= members
|
|
91
|
+
elif name in ALL_KINDS:
|
|
92
|
+
if name not in produced:
|
|
93
|
+
raise FilterError(f"{option}: a {resource_kind} watch never produces {name!r}")
|
|
94
|
+
expanded.add(name)
|
|
95
|
+
else:
|
|
96
|
+
known = ", ".join(sorted(GROUPS))
|
|
97
|
+
raise FilterError(f"{option}: unknown event kind or group {name!r} (groups: {known})")
|
|
98
|
+
return expanded
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def split_values(values: Iterable[str] | None) -> list[str]:
|
|
102
|
+
"""Flatten repeated and comma-separated option values."""
|
|
103
|
+
return [part for value in values or () for part in value.split(",") if part.strip()]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_filter(resource_kind: str, include: Iterable[str] | None, exclude: Iterable[str] | None) -> frozenset[str]:
|
|
107
|
+
"""Return the event kinds a watch reports. Lifecycle events are always in, and cannot be excluded."""
|
|
108
|
+
include_list = split_values(include)
|
|
109
|
+
exclude_list = split_values(exclude)
|
|
110
|
+
if include_list:
|
|
111
|
+
selected = _expand(resource_kind, include_list, "--events")
|
|
112
|
+
else:
|
|
113
|
+
selected = set(KINDS_BY_RESOURCE[resource_kind])
|
|
114
|
+
if exclude_list:
|
|
115
|
+
excluded = _expand(resource_kind, exclude_list, "--exclude-events")
|
|
116
|
+
if excluded & set(LIFECYCLE_KINDS):
|
|
117
|
+
raise FilterError("--exclude-events: lifecycle events are always reported and cannot be excluded")
|
|
118
|
+
selected -= excluded
|
|
119
|
+
return frozenset(selected | set(LIFECYCLE_KINDS))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def utc_now_iso() -> str:
|
|
123
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class RawEvent:
|
|
128
|
+
"""An event as a poller produces it, before the daemon assigns its sequence number."""
|
|
129
|
+
|
|
130
|
+
kind: str
|
|
131
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
132
|
+
artifacts: tuple[str, ...] = ()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class Event:
|
|
137
|
+
kind: str
|
|
138
|
+
resource: str
|
|
139
|
+
seq: int
|
|
140
|
+
observed_at: str
|
|
141
|
+
data: dict[str, Any]
|
|
142
|
+
artifacts: tuple[str, ...] = ()
|
|
143
|
+
|
|
144
|
+
def to_json(self) -> dict[str, Any]:
|
|
145
|
+
payload: dict[str, Any] = {
|
|
146
|
+
"kind": self.kind,
|
|
147
|
+
"resource": self.resource,
|
|
148
|
+
"seq": self.seq,
|
|
149
|
+
"observed_at": self.observed_at,
|
|
150
|
+
"data": self.data,
|
|
151
|
+
}
|
|
152
|
+
if self.artifacts:
|
|
153
|
+
payload["artifacts"] = list(self.artifacts)
|
|
154
|
+
return payload
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_json(cls, data: Mapping[str, Any]) -> Event:
|
|
158
|
+
return cls(
|
|
159
|
+
kind=str(data["kind"]),
|
|
160
|
+
resource=str(data["resource"]),
|
|
161
|
+
seq=int(data["seq"]),
|
|
162
|
+
observed_at=str(data["observed_at"]),
|
|
163
|
+
data=dict(data.get("data") or {}),
|
|
164
|
+
artifacts=tuple(str(a) for a in data.get("artifacts") or ()),
|
|
165
|
+
)
|
|
File without changes
|