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,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)
@@ -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()}"
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
+ }