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,151 @@
|
|
|
1
|
+
"""Watched resources: the `ResourceRef` type and parsing of the command-line forms.
|
|
2
|
+
|
|
3
|
+
Repository names are resolved by glab itself (`glab repo view [repo] -F json`), so `group/project`,
|
|
4
|
+
`host/group/project` and an omitted repo (the cwd's git remotes) mean exactly what they mean to glab.
|
|
5
|
+
URLs carry their host and project, so they need no request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal, cast
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from glab_cli_extras.gitlab.glab import ProcessResult, run_glab_sync, stderr_message
|
|
18
|
+
|
|
19
|
+
ResourceKind = Literal["mr", "pipeline", "mrs", "branch"]
|
|
20
|
+
RESOURCE_KINDS: tuple[ResourceKind, ...] = ("mr", "pipeline", "mrs", "branch")
|
|
21
|
+
|
|
22
|
+
_URL_RE = re.compile(r"^https?://(?P<host>[^/]+)/(?P<project>.+?)/-/(?P<what>merge_requests|pipelines)/(?P<id>\d+)")
|
|
23
|
+
_ID_RE = re.compile(r"^(?:(?P<repo>.*)!)?(?P<id>\d+)$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RefError(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ResourceRef:
|
|
32
|
+
kind: ResourceKind
|
|
33
|
+
host: str
|
|
34
|
+
project: str
|
|
35
|
+
id: int | None = None
|
|
36
|
+
"""MR iid for `mr`, pipeline id for `pipeline`."""
|
|
37
|
+
branch: str | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.kind in ("mr", "pipeline") and self.id is None:
|
|
41
|
+
raise RefError(f"a {self.kind} reference needs an id")
|
|
42
|
+
if self.kind == "branch" and not self.branch:
|
|
43
|
+
raise RefError("a branch reference needs a branch name")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> str:
|
|
47
|
+
"""A stable identity, also used as the `resource` field of events: `mr gitlab.com/group/project!123`."""
|
|
48
|
+
return f"{self.kind} {self.short}"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def short(self) -> str:
|
|
52
|
+
"""The reference without the kind: `gitlab.com/group/project!123`."""
|
|
53
|
+
base = f"{self.host}/{self.project}"
|
|
54
|
+
if self.kind in ("mr", "pipeline"):
|
|
55
|
+
return f"{base}!{self.id}"
|
|
56
|
+
if self.kind == "branch":
|
|
57
|
+
return f"{base}:{self.branch}"
|
|
58
|
+
return base
|
|
59
|
+
|
|
60
|
+
def to_wire(self) -> dict[str, Any]:
|
|
61
|
+
wire: dict[str, Any] = {"kind": self.kind, "host": self.host, "project": self.project}
|
|
62
|
+
if self.id is not None:
|
|
63
|
+
wire["id"] = self.id
|
|
64
|
+
if self.branch is not None:
|
|
65
|
+
wire["branch"] = self.branch
|
|
66
|
+
return wire
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_wire(cls, data: Any) -> ResourceRef:
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
raise RefError("resource must be an object")
|
|
72
|
+
kind = data.get("kind")
|
|
73
|
+
if kind not in RESOURCE_KINDS:
|
|
74
|
+
raise RefError(f"unknown resource kind: {kind!r}")
|
|
75
|
+
host, project = data.get("host"), data.get("project")
|
|
76
|
+
if not isinstance(host, str) or not host or not isinstance(project, str) or not project:
|
|
77
|
+
raise RefError("resource needs host and project")
|
|
78
|
+
raw_id, branch = data.get("id"), data.get("branch")
|
|
79
|
+
if raw_id is not None and not isinstance(raw_id, int):
|
|
80
|
+
raise RefError("resource id must be an integer")
|
|
81
|
+
if branch is not None and not isinstance(branch, str):
|
|
82
|
+
raise RefError("resource branch must be a string")
|
|
83
|
+
return cls(cast(ResourceKind, kind), host, project, raw_id, branch)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class Repo:
|
|
88
|
+
host: str
|
|
89
|
+
project: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
SyncRunner = Callable[[Sequence[str]], ProcessResult]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_runner(args: Sequence[str]) -> ProcessResult:
|
|
96
|
+
return run_glab_sync(args, timeout=30)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_repo(spec: str | None, runner: SyncRunner = _default_runner) -> Repo:
|
|
100
|
+
"""Resolve a repo name the way glab does, through `glab repo view [spec] -F json`. Costs one API request."""
|
|
101
|
+
args = ["repo", "view"] + ([spec] if spec else []) + ["-F", "json"]
|
|
102
|
+
result = runner(args)
|
|
103
|
+
try:
|
|
104
|
+
payload = json.loads(result.stdout or b"null")
|
|
105
|
+
except ValueError:
|
|
106
|
+
payload = None
|
|
107
|
+
if not isinstance(payload, dict) or "error" in payload or "web_url" not in payload:
|
|
108
|
+
detail = ""
|
|
109
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
110
|
+
detail = str(payload["error"].get("message", ""))
|
|
111
|
+
what = f"repository {spec!r}" if spec else "the repository of the current directory"
|
|
112
|
+
raise RefError(f"glab could not resolve {what}: {detail or stderr_message(result)}")
|
|
113
|
+
host = urlsplit(str(payload["web_url"])).netloc
|
|
114
|
+
project = str(payload.get("path_with_namespace") or "")
|
|
115
|
+
if not host or not project:
|
|
116
|
+
raise RefError(f"glab returned no host or project path for {spec or 'the current directory'}")
|
|
117
|
+
return Repo(host, project)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_id_ref(kind: Literal["mr", "pipeline"], arg: str, runner: SyncRunner = _default_runner) -> ResourceRef:
|
|
121
|
+
"""Parse `<repo>!<id>`, `!<id>`, `<id>`, or a merge request or pipeline URL."""
|
|
122
|
+
arg = arg.strip()
|
|
123
|
+
url_match = _URL_RE.match(arg)
|
|
124
|
+
if url_match:
|
|
125
|
+
what = "merge_requests" if kind == "mr" else "pipelines"
|
|
126
|
+
if url_match.group("what") != what:
|
|
127
|
+
raise RefError(f"{arg} is not a {'merge request' if kind == 'mr' else 'pipeline'} URL")
|
|
128
|
+
return ResourceRef(kind, url_match.group("host"), url_match.group("project"), int(url_match.group("id")))
|
|
129
|
+
id_match = _ID_RE.match(arg)
|
|
130
|
+
if not id_match:
|
|
131
|
+
example = "group/project!123" if kind == "mr" else "group/project!4567"
|
|
132
|
+
raise RefError(f"cannot read {arg!r}: expected {example}, !<id>, <id>, or a URL")
|
|
133
|
+
repo = resolve_repo(id_match.group("repo") or None, runner)
|
|
134
|
+
return ResourceRef(kind, repo.host, repo.project, int(id_match.group("id")))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_repo_ref(
|
|
138
|
+
kind: Literal["mrs", "branch"],
|
|
139
|
+
repo_arg: str | None,
|
|
140
|
+
branch: str | None = None,
|
|
141
|
+
runner: SyncRunner = _default_runner,
|
|
142
|
+
) -> ResourceRef:
|
|
143
|
+
"""Parse the repo argument of `mrs` and `branch`. A project URL is accepted as well as a glab repo name."""
|
|
144
|
+
if repo_arg and repo_arg.startswith(("http://", "https://")):
|
|
145
|
+
parts = urlsplit(repo_arg)
|
|
146
|
+
project = parts.path.strip("/").split("/-/")[0].removesuffix(".git")
|
|
147
|
+
if not parts.netloc or not project:
|
|
148
|
+
raise RefError(f"cannot read the project from {repo_arg}")
|
|
149
|
+
return ResourceRef(kind, parts.netloc, project, branch=branch)
|
|
150
|
+
repo = resolve_repo(repo_arg, runner)
|
|
151
|
+
return ResourceRef(kind, repo.host, repo.project, branch=branch)
|
glab_cli_extras/paths.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Filesystem locations: the shared artifact cache, the daemon state dir, and the default changelog dir.
|
|
2
|
+
|
|
3
|
+
Each location can be overridden by an environment variable, which the tests use to stay inside tmp dirs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
CACHE_DIR_ENV = "GLAB_CLI_EXTRAS_CACHE_DIR"
|
|
15
|
+
STATE_DIR_ENV = "GLAB_CLI_EXTRAS_STATE_DIR"
|
|
16
|
+
APP = "glab-cli-extras"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sanitize_part(value: str) -> str:
|
|
20
|
+
"""Make one path component safe: no separators, no traversal, no characters Windows rejects."""
|
|
21
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]", "-", value).strip(".")
|
|
22
|
+
cleaned = cleaned.replace("..", "-")
|
|
23
|
+
return cleaned or "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def project_dir(root: Path, host: str, project: str) -> Path:
|
|
27
|
+
"""`<root>/<host>/<group>/<sub>/<project>`, each component sanitized."""
|
|
28
|
+
path = root / sanitize_part(host)
|
|
29
|
+
for part in project.split("/"):
|
|
30
|
+
path = path / sanitize_part(part)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _uid_suffix() -> str:
|
|
35
|
+
getuid = getattr(os, "getuid", None)
|
|
36
|
+
return str(getuid()) if getuid is not None else (os.environ.get("USERNAME") or "user")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def user_cache_dir() -> Path:
|
|
40
|
+
override = os.environ.get(CACHE_DIR_ENV)
|
|
41
|
+
if override:
|
|
42
|
+
return Path(override)
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
45
|
+
return base / APP / "cache"
|
|
46
|
+
if sys.platform == "darwin":
|
|
47
|
+
return Path.home() / "Library" / "Caches" / APP
|
|
48
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
49
|
+
return (Path(xdg) if xdg else Path.home() / ".cache") / APP
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def daemon_state_dir() -> Path:
|
|
53
|
+
"""The per-user dir holding the daemon's lock, socket (or port file) and log."""
|
|
54
|
+
override = os.environ.get(STATE_DIR_ENV)
|
|
55
|
+
if override:
|
|
56
|
+
return Path(override)
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
59
|
+
return base / APP / "run"
|
|
60
|
+
if sys.platform == "darwin":
|
|
61
|
+
tmpdir = os.environ.get("TMPDIR")
|
|
62
|
+
if tmpdir:
|
|
63
|
+
return Path(tmpdir) / APP
|
|
64
|
+
else:
|
|
65
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
66
|
+
if runtime and Path(runtime).is_dir():
|
|
67
|
+
return Path(runtime) / APP
|
|
68
|
+
return Path(tempfile.gettempdir()) / f"{APP}-{_uid_suffix()}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ensure_private_dir(path: Path) -> Path:
|
|
72
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
if os.name != "nt":
|
|
74
|
+
os.chmod(path, 0o700)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def default_changelog_root() -> Path:
|
|
79
|
+
return Path(tempfile.gettempdir()) / f"glab-watch-{_uid_suffix()}"
|
glab_cli_extras/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""One poller per resource kind."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from glab_cli_extras.resources.base import Poller
|
|
9
|
+
from glab_cli_extras.resources.branch import BranchPoller
|
|
10
|
+
from glab_cli_extras.resources.mr import MrPoller
|
|
11
|
+
from glab_cli_extras.resources.mr_list import MrListPoller
|
|
12
|
+
from glab_cli_extras.resources.pipeline import PipelinePoller
|
|
13
|
+
|
|
14
|
+
POLLERS: Mapping[str, type[Poller[Any]]] = {
|
|
15
|
+
"mr": MrPoller,
|
|
16
|
+
"pipeline": PipelinePoller,
|
|
17
|
+
"mrs": MrListPoller,
|
|
18
|
+
"branch": BranchPoller,
|
|
19
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""What every resource poller has in common.
|
|
2
|
+
|
|
3
|
+
A poller owns one watched resource. Each `poll()` fetches what changed, diffs it against the previous snapshot and
|
|
4
|
+
returns the events. The diff itself lives in a pure function per resource, so it can be tested without any I/O.
|
|
5
|
+
The daemon decides when to call `poll()`, using `next_delay()`, `expected_cost()` and the host's budget.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from glab_cli_extras.artifacts.job_logs import LogsMode
|
|
18
|
+
from glab_cli_extras.events import RawEvent
|
|
19
|
+
from glab_cli_extras.gitlab.client import ApiError, GitLabClient, encode_project
|
|
20
|
+
from glab_cli_extras.gitlab.refs import ResourceKind, ResourceRef
|
|
21
|
+
from glab_cli_extras.serde import from_jsonable, stable_hash, to_jsonable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gid_to_int(gid: Any) -> int:
|
|
25
|
+
"""`gid://gitlab/Ci::Pipeline/123` → 123."""
|
|
26
|
+
return int(str(gid).rsplit("/", 1)[-1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def lower(value: Any) -> str:
|
|
30
|
+
return str(value).lower() if value is not None else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def is_force_push(client: GitLabClient, project: str, old_sha: str, new_sha: str) -> tuple[bool, str | None]:
|
|
34
|
+
"""Whether moving a ref from `old_sha` to `new_sha` rewrote history. Costs one `merge_base` request.
|
|
35
|
+
|
|
36
|
+
The push is a fast-forward exactly when the merge base of the two commits is the old head. If GitLab no longer
|
|
37
|
+
has the old commit (pruned after a force-push), the push is reported as forced, with the reason.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
base = await client.get_json(
|
|
41
|
+
f"projects/{encode_project(project)}/repository/merge_base?refs[]={old_sha}&refs[]={new_sha}",
|
|
42
|
+
kind="merge_base",
|
|
43
|
+
)
|
|
44
|
+
except ApiError as exc:
|
|
45
|
+
if exc.status not in (400, 404):
|
|
46
|
+
raise
|
|
47
|
+
return True, "the previous head is no longer in the repository"
|
|
48
|
+
return (not isinstance(base, dict) or base.get("id") != old_sha), None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class PollerOptions:
|
|
53
|
+
"""What the subscribers of a resource need, as the union over all of them."""
|
|
54
|
+
|
|
55
|
+
events: frozenset[str]
|
|
56
|
+
logs: LogsMode = "failed"
|
|
57
|
+
|
|
58
|
+
def wants(self, *kinds: str) -> bool:
|
|
59
|
+
return any(kind in self.events for kind in kinds)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PushFeedCache:
|
|
64
|
+
"""One `GET /projects/:id/events?action=pushed` call serves every watched branch of a project."""
|
|
65
|
+
|
|
66
|
+
max_age: float = 15.0
|
|
67
|
+
_entries: dict[tuple[str, str], tuple[float, list[Any]]] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
def get(self, host: str, project: str, now: float) -> list[Any] | None:
|
|
70
|
+
entry = self._entries.get((host, project))
|
|
71
|
+
if entry is None or now - entry[0] > self.max_age:
|
|
72
|
+
return None
|
|
73
|
+
return entry[1]
|
|
74
|
+
|
|
75
|
+
def put(self, host: str, project: str, now: float, events: list[Any]) -> None:
|
|
76
|
+
self._entries[(host, project)] = (now, events)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PollContext:
|
|
81
|
+
client: GitLabClient
|
|
82
|
+
cache_root: Path
|
|
83
|
+
clock: Callable[[], float] = time.time
|
|
84
|
+
push_feeds: PushFeedCache = field(default_factory=PushFeedCache)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
S = TypeVar("S")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Poller(ABC, Generic[S]):
|
|
91
|
+
kind: ClassVar[ResourceKind]
|
|
92
|
+
snapshot_type: ClassVar[type[Any]]
|
|
93
|
+
|
|
94
|
+
def __init__(self, ref: ResourceRef, ctx: PollContext, options: PollerOptions) -> None:
|
|
95
|
+
self.ref = ref
|
|
96
|
+
self.ctx = ctx
|
|
97
|
+
self.options = options
|
|
98
|
+
self.snapshot: S | None = None
|
|
99
|
+
self.terminal_reason: str | None = None
|
|
100
|
+
self.terminal_data: dict[str, Any] = {}
|
|
101
|
+
|
|
102
|
+
def set_options(self, options: PollerOptions) -> None:
|
|
103
|
+
self.options = options
|
|
104
|
+
|
|
105
|
+
def seed(self, data: Any) -> bool:
|
|
106
|
+
"""Start from a snapshot a client kept from an earlier daemon, so the first poll reports what changed."""
|
|
107
|
+
if self.snapshot is not None or data is None:
|
|
108
|
+
return False
|
|
109
|
+
try:
|
|
110
|
+
self.snapshot = from_jsonable(self.snapshot_type, data)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
async def poll(self) -> list[RawEvent]:
|
|
117
|
+
"""Fetch, diff, update the snapshot. The first poll without a seed is the baseline and returns no events."""
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def next_delay(self) -> float:
|
|
121
|
+
"""Base seconds until the next poll, before the daemon stretches it under budget pressure."""
|
|
122
|
+
|
|
123
|
+
def expected_cost(self) -> float:
|
|
124
|
+
"""Average requests per poll, for the budget's demand estimate."""
|
|
125
|
+
return 1.0
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def summary(self) -> dict[str, Any]:
|
|
129
|
+
"""A compact description of the current snapshot, sent in `watch_started`."""
|
|
130
|
+
|
|
131
|
+
def children(self) -> frozenset[ResourceRef]:
|
|
132
|
+
"""Other resources whose events this one relays (an MR relays its head pipeline)."""
|
|
133
|
+
return frozenset()
|
|
134
|
+
|
|
135
|
+
def child_options(self, child: ResourceRef) -> PollerOptions | None:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def terminal(self) -> bool:
|
|
140
|
+
return self.terminal_reason is not None
|
|
141
|
+
|
|
142
|
+
def snapshot_json(self) -> Any:
|
|
143
|
+
return to_jsonable(self.snapshot) if self.snapshot is not None else None
|
|
144
|
+
|
|
145
|
+
def snapshot_hash(self) -> str | None:
|
|
146
|
+
return stable_hash(self.snapshot) if self.snapshot is not None else None
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Branch watching: pushes from the project's push-event feed, pipelines through the GraphQL ETag cache.
|
|
2
|
+
|
|
3
|
+
- Pushes come from `GET /projects/:id/events?action=pushed`, shared by every watched branch of the project and read
|
|
4
|
+
every 30 s. Push events carry no "forced" flag, so a push costs one `merge_base` request to tell a force-push.
|
|
5
|
+
A bulk push (more refs than GitLab's push-event limit) has no ref or SHAs; the branch endpoint fills the gap.
|
|
6
|
+
- Pipelines come from one GraphQL query keyed on `project_pipelines/<project id>`, probed every 10 s. GitLab
|
|
7
|
+
touches that key on any pipeline change in the project, and answers an unchanged key with a free 304.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
from glab_cli_extras.events import RawEvent
|
|
18
|
+
from glab_cli_extras.gitlab import queries
|
|
19
|
+
from glab_cli_extras.gitlab.client import NotFound, encode_project
|
|
20
|
+
from glab_cli_extras.resources.base import Poller, gid_to_int, is_force_push, lower
|
|
21
|
+
from glab_cli_extras.resources.pipeline import FINAL_PIPELINE_STATUSES
|
|
22
|
+
|
|
23
|
+
PUSH_INTERVAL = 30.0
|
|
24
|
+
PIPELINE_INTERVAL = 10.0
|
|
25
|
+
FEED_PAGE_SIZE = 20
|
|
26
|
+
MAX_FEED_PAGES = 5
|
|
27
|
+
MERGE_TITLE_RE = re.compile(r"^Merge (branch|remote-tracking branch|pull request) ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class PipelineEntry:
|
|
32
|
+
id: int
|
|
33
|
+
status: str
|
|
34
|
+
sha: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class BranchSnapshot:
|
|
39
|
+
project: str
|
|
40
|
+
branch: str
|
|
41
|
+
exists: bool
|
|
42
|
+
head_sha: str | None
|
|
43
|
+
last_event_id: int
|
|
44
|
+
project_id: int | None = None
|
|
45
|
+
pipelines: tuple[PipelineEntry, ...] = field(default_factory=tuple)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def diff_pipelines(prev: tuple[PipelineEntry, ...], cur: tuple[PipelineEntry, ...], web: str) -> list[RawEvent]:
|
|
49
|
+
known = {p.id: p for p in prev}
|
|
50
|
+
events = []
|
|
51
|
+
for pipeline in sorted(cur, key=lambda p: p.id):
|
|
52
|
+
old = known.get(pipeline.id)
|
|
53
|
+
data = {
|
|
54
|
+
"pipeline_id": pipeline.id,
|
|
55
|
+
"status": pipeline.status,
|
|
56
|
+
"sha": pipeline.sha,
|
|
57
|
+
"web_url": f"{web}/-/pipelines/{pipeline.id}",
|
|
58
|
+
}
|
|
59
|
+
if old is None:
|
|
60
|
+
events.append(RawEvent("pipeline_created", data))
|
|
61
|
+
if pipeline.status in FINAL_PIPELINE_STATUSES:
|
|
62
|
+
events.append(RawEvent("pipeline_finished", data))
|
|
63
|
+
elif pipeline.status in FINAL_PIPELINE_STATUSES and old.status not in FINAL_PIPELINE_STATUSES:
|
|
64
|
+
events.append(RawEvent("pipeline_finished", data))
|
|
65
|
+
return events
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def new_branch_pushes(feed: list[Any], branch: str, after_id: int) -> tuple[list[dict[str, Any]], bool]:
|
|
69
|
+
"""Push events newer than `after_id` for the branch, oldest first, plus whether a bulk push is among them."""
|
|
70
|
+
relevant = []
|
|
71
|
+
bulk = False
|
|
72
|
+
for event in feed:
|
|
73
|
+
if not isinstance(event, dict) or int(event.get("id", 0)) <= after_id:
|
|
74
|
+
continue
|
|
75
|
+
push = event.get("push_data") or {}
|
|
76
|
+
if push.get("ref") is None and push.get("ref_type") in (None, "branch") and push.get("ref_count"):
|
|
77
|
+
bulk = True
|
|
78
|
+
elif push.get("ref_type") == "branch" and push.get("ref") == branch:
|
|
79
|
+
relevant.append(event)
|
|
80
|
+
return sorted(relevant, key=lambda e: int(e["id"])), bulk
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BranchPoller(Poller[BranchSnapshot]):
|
|
84
|
+
kind: ClassVar = "branch"
|
|
85
|
+
snapshot_type: ClassVar = BranchSnapshot
|
|
86
|
+
|
|
87
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
88
|
+
super().__init__(*args, **kwargs)
|
|
89
|
+
self._etag: str | None = None
|
|
90
|
+
self._last_feed_check = 0.0
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def _branch(self) -> str:
|
|
94
|
+
assert self.ref.branch is not None
|
|
95
|
+
return self.ref.branch
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def _encoded(self) -> str:
|
|
99
|
+
return encode_project(self.ref.project)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def _web(self) -> str:
|
|
103
|
+
return f"https://{self.ref.host}/{self.ref.project}"
|
|
104
|
+
|
|
105
|
+
async def _branch_head(self) -> str | None:
|
|
106
|
+
try:
|
|
107
|
+
data = await self.ctx.client.get_json(
|
|
108
|
+
f"projects/{self._encoded}/repository/branches/{quote(self._branch, safe='')}", kind="branch"
|
|
109
|
+
)
|
|
110
|
+
except NotFound:
|
|
111
|
+
return None
|
|
112
|
+
return str((data.get("commit") or {}).get("id")) if isinstance(data, dict) else None
|
|
113
|
+
|
|
114
|
+
async def _feed(self, after_id: int | None) -> list[Any]:
|
|
115
|
+
"""Push events, newest first, reaching back to `after_id`. None means the newest page is enough."""
|
|
116
|
+
now = self.ctx.clock()
|
|
117
|
+
cached = self.ctx.push_feeds.get(self.ref.host, self.ref.project, now)
|
|
118
|
+
if cached is not None and (
|
|
119
|
+
after_id is None or len(cached) < FEED_PAGE_SIZE or min(int(e.get("id", 0)) for e in cached) <= after_id
|
|
120
|
+
):
|
|
121
|
+
return cached
|
|
122
|
+
events: list[Any] = []
|
|
123
|
+
for page in range(1, MAX_FEED_PAGES + 1):
|
|
124
|
+
batch = await self.ctx.client.get_json(
|
|
125
|
+
f"projects/{self._encoded}/events",
|
|
126
|
+
{"action": "pushed", "sort": "desc", "per_page": FEED_PAGE_SIZE, "page": page},
|
|
127
|
+
kind="push_events",
|
|
128
|
+
)
|
|
129
|
+
if not isinstance(batch, list):
|
|
130
|
+
break
|
|
131
|
+
events.extend(batch)
|
|
132
|
+
if after_id is None or len(batch) < FEED_PAGE_SIZE or (batch and int(batch[-1].get("id", 0)) <= after_id):
|
|
133
|
+
break
|
|
134
|
+
self.ctx.push_feeds.put(self.ref.host, self.ref.project, now, events)
|
|
135
|
+
return events
|
|
136
|
+
|
|
137
|
+
async def _pipelines(self, snap: BranchSnapshot | None) -> tuple[int | None, tuple[PipelineEntry, ...] | None]:
|
|
138
|
+
project_id = snap.project_id if snap else None
|
|
139
|
+
result = await self.ctx.client.graphql(
|
|
140
|
+
queries.load("branch_pipelines"),
|
|
141
|
+
{"project": self.ref.project, "ref": self._branch},
|
|
142
|
+
etag_resource=f"project_pipelines/{project_id}" if project_id else None,
|
|
143
|
+
if_none_match=self._etag if project_id else None,
|
|
144
|
+
kind="branch_pipelines",
|
|
145
|
+
)
|
|
146
|
+
if result.not_modified:
|
|
147
|
+
return project_id, None
|
|
148
|
+
project = (result.data or {}).get("project")
|
|
149
|
+
if not project:
|
|
150
|
+
raise NotFound(f"project {self.ref.project} not found", 404)
|
|
151
|
+
self._etag = result.etag if project_id else None
|
|
152
|
+
nodes = (project.get("pipelines") or {}).get("nodes") or []
|
|
153
|
+
entries = tuple(
|
|
154
|
+
sorted(
|
|
155
|
+
(PipelineEntry(gid_to_int(n["id"]), lower(n.get("status")), n.get("sha")) for n in nodes),
|
|
156
|
+
key=lambda p: p.id,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return gid_to_int(project["id"]), entries
|
|
160
|
+
|
|
161
|
+
async def _baseline(self) -> list[RawEvent]:
|
|
162
|
+
head = await self._branch_head()
|
|
163
|
+
feed = await self._feed(None)
|
|
164
|
+
last_id = max((int(e.get("id", 0)) for e in feed if isinstance(e, dict)), default=0)
|
|
165
|
+
project_id, pipelines = await self._pipelines(None)
|
|
166
|
+
self._last_feed_check = self.ctx.clock()
|
|
167
|
+
self.snapshot = BranchSnapshot(
|
|
168
|
+
self.ref.project, self._branch, head is not None, head, last_id, project_id, pipelines or ()
|
|
169
|
+
)
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
async def poll(self) -> list[RawEvent]:
|
|
173
|
+
if self.snapshot is None:
|
|
174
|
+
return await self._baseline()
|
|
175
|
+
snap = self.snapshot
|
|
176
|
+
events: list[RawEvent] = []
|
|
177
|
+
if self.options.wants("pipeline_created", "pipeline_finished"):
|
|
178
|
+
project_id, pipelines = await self._pipelines(snap)
|
|
179
|
+
if pipelines is not None:
|
|
180
|
+
events.extend(diff_pipelines(snap.pipelines, pipelines, self._web))
|
|
181
|
+
snap = replace(snap, project_id=project_id, pipelines=pipelines)
|
|
182
|
+
now = self.ctx.clock()
|
|
183
|
+
if now - self._last_feed_check >= PUSH_INTERVAL:
|
|
184
|
+
push_events, snap = await self._push_events(snap)
|
|
185
|
+
events.extend(push_events)
|
|
186
|
+
self._last_feed_check = now
|
|
187
|
+
self.snapshot = snap
|
|
188
|
+
return events
|
|
189
|
+
|
|
190
|
+
async def _push_events(self, snap: BranchSnapshot) -> tuple[list[RawEvent], BranchSnapshot]:
|
|
191
|
+
feed = await self._feed(snap.last_event_id)
|
|
192
|
+
pushes, bulk = new_branch_pushes(feed, self._branch, snap.last_event_id)
|
|
193
|
+
newest = max((int(e.get("id", 0)) for e in feed if isinstance(e, dict)), default=snap.last_event_id)
|
|
194
|
+
events: list[RawEvent] = []
|
|
195
|
+
head, exists = snap.head_sha, snap.exists
|
|
196
|
+
for push in pushes:
|
|
197
|
+
data = push.get("push_data") or {}
|
|
198
|
+
action = data.get("action")
|
|
199
|
+
author = push.get("author_username")
|
|
200
|
+
if action == "created":
|
|
201
|
+
events.append(RawEvent("created", {"sha": data.get("commit_to"), "author": author}))
|
|
202
|
+
head, exists = data.get("commit_to"), True
|
|
203
|
+
elif action == "removed":
|
|
204
|
+
events.append(RawEvent("deleted", {"sha": data.get("commit_from"), "author": author}))
|
|
205
|
+
head, exists = None, False
|
|
206
|
+
else:
|
|
207
|
+
event = await self._push_event(data.get("commit_from"), data.get("commit_to"), author, data)
|
|
208
|
+
events.append(event)
|
|
209
|
+
head, exists = data.get("commit_to"), True
|
|
210
|
+
if bulk and not pushes:
|
|
211
|
+
current = await self._branch_head()
|
|
212
|
+
if current != head:
|
|
213
|
+
if current is None:
|
|
214
|
+
events.append(RawEvent("deleted", {"sha": head, "bulk_push": True}))
|
|
215
|
+
elif head is None:
|
|
216
|
+
events.append(RawEvent("created", {"sha": current, "bulk_push": True}))
|
|
217
|
+
else:
|
|
218
|
+
event = await self._push_event(head, current, None, {"bulk_push": True})
|
|
219
|
+
events.append(event)
|
|
220
|
+
head, exists = current, current is not None
|
|
221
|
+
return events, replace(snap, head_sha=head, exists=exists, last_event_id=max(newest, snap.last_event_id))
|
|
222
|
+
|
|
223
|
+
async def _push_event(self, old: Any, new: Any, author: Any, push_data: dict[str, Any]) -> RawEvent:
|
|
224
|
+
data: dict[str, Any] = {"from": old, "to": new, "author": author}
|
|
225
|
+
for key in ("commit_count", "commit_title", "bulk_push"):
|
|
226
|
+
if push_data.get(key) is not None:
|
|
227
|
+
data[key] = push_data[key]
|
|
228
|
+
forced = False
|
|
229
|
+
if old and new and self.options.wants("force_pushed", "pushed"):
|
|
230
|
+
forced, reason = await is_force_push(self.ctx.client, self.ref.project, str(old), str(new))
|
|
231
|
+
if reason:
|
|
232
|
+
data["forced_reason"] = reason
|
|
233
|
+
title = str(push_data.get("commit_title") or "")
|
|
234
|
+
if not forced and new and MERGE_TITLE_RE.match(title):
|
|
235
|
+
data["merge_commit"] = True
|
|
236
|
+
merged = await self.ctx.client.get_json(
|
|
237
|
+
f"projects/{self._encoded}/repository/commits/{new}/merge_requests", kind="commit_mrs"
|
|
238
|
+
)
|
|
239
|
+
iids = [m.get("iid") for m in merged or [] if isinstance(m, dict) and m.get("state") == "merged"]
|
|
240
|
+
if iids:
|
|
241
|
+
data["merged_mr_iid"] = iids[0]
|
|
242
|
+
return RawEvent("force_pushed" if forced else "pushed", data)
|
|
243
|
+
|
|
244
|
+
def next_delay(self) -> float:
|
|
245
|
+
if self.options.wants("pipeline_created", "pipeline_finished"):
|
|
246
|
+
return PIPELINE_INTERVAL
|
|
247
|
+
return PUSH_INTERVAL
|
|
248
|
+
|
|
249
|
+
def expected_cost(self) -> float:
|
|
250
|
+
if self.options.wants("pipeline_created", "pipeline_finished"):
|
|
251
|
+
# A 304 probe every 10 s, plus one feed request every third poll.
|
|
252
|
+
return 0.1 + 1.0 / 3
|
|
253
|
+
return 1.0
|
|
254
|
+
|
|
255
|
+
def summary(self) -> dict[str, Any]:
|
|
256
|
+
snap = self.snapshot
|
|
257
|
+
if snap is None:
|
|
258
|
+
return {}
|
|
259
|
+
latest = max(snap.pipelines, key=lambda p: p.id) if snap.pipelines else None
|
|
260
|
+
return {
|
|
261
|
+
"branch": snap.branch,
|
|
262
|
+
"exists": snap.exists,
|
|
263
|
+
"head_sha": snap.head_sha,
|
|
264
|
+
"latest_pipeline": {"id": latest.id, "status": latest.status} if latest else None,
|
|
265
|
+
}
|