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,135 @@
|
|
|
1
|
+
"""Per-host request budget, paced on GitLab's `RateLimit-*` response headers.
|
|
2
|
+
|
|
3
|
+
GitLab reports `RateLimit-Remaining` and `RateLimit-Reset` for the real throttle period, while `RateLimit-Limit` is
|
|
4
|
+
normalized to one minute. The budget therefore paces on Remaining over the time left until Reset, and uses Limit only
|
|
5
|
+
as a per-minute ceiling. The daemon takes `share` of that allowance and leaves the rest to the user's other tools.
|
|
6
|
+
|
|
7
|
+
A host that sends no rate-limit headers (the throttle is opt-in on self-managed instances) gets a fixed cap.
|
|
8
|
+
|
|
9
|
+
Time is passed in by the caller (wall-clock seconds, since `RateLimit-Reset` is a Unix timestamp), which keeps the
|
|
10
|
+
class deterministic under test.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
DEFAULT_SHARE = 0.5
|
|
20
|
+
DEFAULT_FIXED_PER_MINUTE = 60.0
|
|
21
|
+
DEFAULT_BURST = 10.0
|
|
22
|
+
NOT_MODIFIED_COST = 0.1
|
|
23
|
+
"""An ETag 304 was free in live tests, but that is undocumented, so it still costs a little."""
|
|
24
|
+
MIN_BACKOFF = 5.0
|
|
25
|
+
MAX_BACKOFF = 300.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _int_header(headers: Mapping[str, str], name: str) -> int | None:
|
|
29
|
+
value = headers.get(name)
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return int(value.strip())
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class HostBudget:
|
|
40
|
+
share: float = DEFAULT_SHARE
|
|
41
|
+
fixed_per_minute: float = DEFAULT_FIXED_PER_MINUTE
|
|
42
|
+
burst: float = DEFAULT_BURST
|
|
43
|
+
tokens: float = DEFAULT_BURST
|
|
44
|
+
last_refill: float | None = None
|
|
45
|
+
remaining: int | None = None
|
|
46
|
+
reset_at: float | None = None
|
|
47
|
+
limit_per_minute: int | None = None
|
|
48
|
+
blocked_until: float = 0.0
|
|
49
|
+
backoff: float = MIN_BACKOFF
|
|
50
|
+
rate_limited_count: int = 0
|
|
51
|
+
_saw_headers: bool = field(default=False, repr=False)
|
|
52
|
+
|
|
53
|
+
def rate(self, now: float) -> float:
|
|
54
|
+
"""Requests per second the daemon may spend on this host right now."""
|
|
55
|
+
if self.remaining is not None and self.reset_at is not None and self.reset_at > now:
|
|
56
|
+
rate = self.remaining * self.share / max(1.0, self.reset_at - now)
|
|
57
|
+
if self.limit_per_minute:
|
|
58
|
+
rate = min(rate, self.limit_per_minute * self.share / 60.0)
|
|
59
|
+
return max(rate, 0.01)
|
|
60
|
+
if self.limit_per_minute:
|
|
61
|
+
return self.limit_per_minute * self.share / 60.0
|
|
62
|
+
return self.fixed_per_minute / 60.0
|
|
63
|
+
|
|
64
|
+
def _refill(self, now: float) -> None:
|
|
65
|
+
if self.last_refill is None:
|
|
66
|
+
self.last_refill = now
|
|
67
|
+
return
|
|
68
|
+
elapsed = max(0.0, now - self.last_refill)
|
|
69
|
+
self.tokens = min(self.burst, self.tokens + elapsed * self.rate(now))
|
|
70
|
+
self.last_refill = now
|
|
71
|
+
|
|
72
|
+
def can_spend(self, cost: float, now: float) -> bool:
|
|
73
|
+
self._refill(now)
|
|
74
|
+
return now >= self.blocked_until and self.tokens >= min(cost, self.burst)
|
|
75
|
+
|
|
76
|
+
def wait_time(self, cost: float, now: float) -> float:
|
|
77
|
+
"""Seconds until `can_spend(cost)` could become true."""
|
|
78
|
+
self._refill(now)
|
|
79
|
+
blocked = max(0.0, self.blocked_until - now)
|
|
80
|
+
missing = max(0.0, min(cost, self.burst) - self.tokens)
|
|
81
|
+
return max(blocked, missing / self.rate(now))
|
|
82
|
+
|
|
83
|
+
def charge(self, cost: float, now: float) -> None:
|
|
84
|
+
self._refill(now)
|
|
85
|
+
self.tokens -= cost
|
|
86
|
+
|
|
87
|
+
def observe(self, headers: Mapping[str, str], now: float) -> None:
|
|
88
|
+
"""Update from response headers (lowercase names). A response without rate-limit headers changes nothing."""
|
|
89
|
+
remaining = _int_header(headers, "ratelimit-remaining")
|
|
90
|
+
reset = _int_header(headers, "ratelimit-reset")
|
|
91
|
+
limit = _int_header(headers, "ratelimit-limit")
|
|
92
|
+
if remaining is None and reset is None and limit is None:
|
|
93
|
+
return
|
|
94
|
+
self._saw_headers = True
|
|
95
|
+
if remaining is not None:
|
|
96
|
+
self.remaining = remaining
|
|
97
|
+
if reset is not None:
|
|
98
|
+
self.reset_at = float(reset)
|
|
99
|
+
if limit is not None:
|
|
100
|
+
self.limit_per_minute = limit
|
|
101
|
+
|
|
102
|
+
def on_success(self) -> None:
|
|
103
|
+
self.backoff = MIN_BACKOFF
|
|
104
|
+
|
|
105
|
+
def on_rate_limited(self, retry_after: float | None, now: float) -> None:
|
|
106
|
+
"""Block the whole host: honor Retry-After, and back off exponentially on repeated 429s."""
|
|
107
|
+
wait = max(retry_after or 0.0, self.backoff)
|
|
108
|
+
self.blocked_until = max(self.blocked_until, now + wait)
|
|
109
|
+
self.backoff = min(self.backoff * 2, MAX_BACKOFF)
|
|
110
|
+
self.rate_limited_count += 1
|
|
111
|
+
self.tokens = min(self.tokens, 0.0)
|
|
112
|
+
|
|
113
|
+
def stretch_factor(self, demand_per_second: float, now: float) -> float:
|
|
114
|
+
"""How much every poll interval on this host must grow so the total demand fits the allowance."""
|
|
115
|
+
rate = self.rate(now)
|
|
116
|
+
if demand_per_second <= rate:
|
|
117
|
+
return 1.0
|
|
118
|
+
return demand_per_second / rate
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def premium_level(self) -> bool:
|
|
122
|
+
"""Whether the per-minute limit looks like a paid gitlab.com tier or a generous self-managed throttle."""
|
|
123
|
+
return (self.limit_per_minute or 0) >= 400
|
|
124
|
+
|
|
125
|
+
def describe(self, now: float) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"rate_per_minute": round(self.rate(now) * 60, 1),
|
|
128
|
+
"tokens": round(self.tokens, 2),
|
|
129
|
+
"remaining": self.remaining,
|
|
130
|
+
"reset_in_s": round(self.reset_at - now) if self.reset_at else None,
|
|
131
|
+
"limit_per_minute": self.limit_per_minute,
|
|
132
|
+
"blocked_for_s": round(max(0.0, self.blocked_until - now), 1),
|
|
133
|
+
"rate_limited_count": self.rate_limited_count,
|
|
134
|
+
"headers_seen": self._saw_headers,
|
|
135
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""GitLab REST and GraphQL access for one host, on top of `glab api -i`.
|
|
2
|
+
|
|
3
|
+
Every request charges the host budget and feeds its rate-limit headers back into it. A 429 blocks the whole host
|
|
4
|
+
for its Retry-After. Errors are mapped to a small exception hierarchy the pollers and the daemon understand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import quote, urlencode
|
|
16
|
+
|
|
17
|
+
from glab_cli_extras.gitlab.budget import NOT_MODIFIED_COST, HostBudget
|
|
18
|
+
from glab_cli_extras.gitlab.glab import (
|
|
19
|
+
DEFAULT_TIMEOUT,
|
|
20
|
+
GlabRunner,
|
|
21
|
+
HttpResponse,
|
|
22
|
+
build_api_args,
|
|
23
|
+
parse_include_output,
|
|
24
|
+
run_glab,
|
|
25
|
+
stderr_message,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
MAX_CONCURRENT_REQUESTS = 4
|
|
29
|
+
ETAG_RESOURCE_HEADER = "X-GITLAB-GRAPHQL-RESOURCE-ETAG"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiError(Exception):
|
|
33
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFound(ApiError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Unauthorized(ApiError):
|
|
43
|
+
"""401 or 403. glab owns authentication, so the fix is `glab auth login`, never a retry here."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimited(ApiError):
|
|
47
|
+
def __init__(self, message: str, retry_after: float | None) -> None:
|
|
48
|
+
super().__init__(message, 429)
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TransportError(ApiError):
|
|
53
|
+
"""No HTTP response: network failure, timeout, or glab failed before sending the request."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class GraphQLResult:
|
|
58
|
+
not_modified: bool
|
|
59
|
+
data: Any
|
|
60
|
+
etag: str | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class RequestStats:
|
|
65
|
+
requests: int = 0
|
|
66
|
+
not_modified: int = 0
|
|
67
|
+
statuses: Counter[int] = field(default_factory=Counter)
|
|
68
|
+
transport_errors: int = 0
|
|
69
|
+
by_kind: Counter[str] = field(default_factory=Counter)
|
|
70
|
+
|
|
71
|
+
def describe(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"requests": self.requests,
|
|
74
|
+
"not_modified": self.not_modified,
|
|
75
|
+
"statuses": {str(k): v for k, v in sorted(self.statuses.items())},
|
|
76
|
+
"transport_errors": self.transport_errors,
|
|
77
|
+
"by_kind": dict(sorted(self.by_kind.items())),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encode_project(project: str) -> str:
|
|
82
|
+
return quote(project, safe="")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _error_message(response: HttpResponse) -> str:
|
|
86
|
+
try:
|
|
87
|
+
payload = response.json()
|
|
88
|
+
except ValueError:
|
|
89
|
+
payload = None
|
|
90
|
+
if isinstance(payload, dict):
|
|
91
|
+
for key in ("message", "error", "error_description"):
|
|
92
|
+
if key in payload:
|
|
93
|
+
return str(payload[key])
|
|
94
|
+
return f"HTTP {response.status} {response.reason}".strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GitLabClient:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
host: str,
|
|
101
|
+
*,
|
|
102
|
+
runner: GlabRunner = run_glab,
|
|
103
|
+
budget: HostBudget | None = None,
|
|
104
|
+
clock: Callable[[], float] = time.time,
|
|
105
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.host = host
|
|
108
|
+
self.budget = budget if budget is not None else HostBudget()
|
|
109
|
+
self.stats = RequestStats()
|
|
110
|
+
self._runner = runner
|
|
111
|
+
self._clock = clock
|
|
112
|
+
self._timeout = timeout
|
|
113
|
+
self._semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
|
114
|
+
self._version: tuple[int, int] | None = None
|
|
115
|
+
self._version_checked = False
|
|
116
|
+
|
|
117
|
+
async def _request(self, args: list[str], kind: str) -> HttpResponse:
|
|
118
|
+
async with self._semaphore:
|
|
119
|
+
self.stats.requests += 1
|
|
120
|
+
self.stats.by_kind[kind] += 1
|
|
121
|
+
try:
|
|
122
|
+
result = await self._runner(args, self._timeout)
|
|
123
|
+
except TimeoutError as exc:
|
|
124
|
+
self.stats.transport_errors += 1
|
|
125
|
+
self.budget.charge(1.0, self._clock())
|
|
126
|
+
raise TransportError(f"glab did not finish within {self._timeout:.0f} s") from exc
|
|
127
|
+
now = self._clock()
|
|
128
|
+
response = parse_include_output(result.stdout)
|
|
129
|
+
if response is None:
|
|
130
|
+
self.stats.transport_errors += 1
|
|
131
|
+
self.budget.charge(1.0, now)
|
|
132
|
+
raise TransportError(stderr_message(result))
|
|
133
|
+
self.stats.statuses[response.status] += 1
|
|
134
|
+
self.budget.observe(response.headers, now)
|
|
135
|
+
if response.status == 304:
|
|
136
|
+
self.stats.not_modified += 1
|
|
137
|
+
self.budget.charge(NOT_MODIFIED_COST, now)
|
|
138
|
+
return response
|
|
139
|
+
self.budget.charge(1.0, now)
|
|
140
|
+
if response.status == 429:
|
|
141
|
+
retry_after = response.header("retry-after")
|
|
142
|
+
seconds = float(retry_after) if retry_after and retry_after.strip().isdigit() else None
|
|
143
|
+
self.budget.on_rate_limited(seconds, now)
|
|
144
|
+
raise RateLimited(f"GitLab rate limit hit on {self.host}", seconds)
|
|
145
|
+
if response.status == 404:
|
|
146
|
+
raise NotFound(_error_message(response), 404)
|
|
147
|
+
if response.status in (401, 403):
|
|
148
|
+
raise Unauthorized(
|
|
149
|
+
f"{_error_message(response)} (HTTP {response.status}); check `glab auth status --hostname {self.host}`",
|
|
150
|
+
response.status,
|
|
151
|
+
)
|
|
152
|
+
if response.status >= 400:
|
|
153
|
+
raise ApiError(_error_message(response), response.status)
|
|
154
|
+
self.budget.on_success()
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
async def get(
|
|
158
|
+
self, path: str, params: Mapping[str, str | int] | None = None, *, kind: str = "rest"
|
|
159
|
+
) -> HttpResponse:
|
|
160
|
+
endpoint = path
|
|
161
|
+
if params:
|
|
162
|
+
endpoint += ("&" if "?" in path else "?") + urlencode(list(params.items()), safe="[]")
|
|
163
|
+
return await self._request(build_api_args(self.host, endpoint), kind)
|
|
164
|
+
|
|
165
|
+
async def get_json(self, path: str, params: Mapping[str, str | int] | None = None, *, kind: str = "rest") -> Any:
|
|
166
|
+
response = await self.get(path, params, kind=kind)
|
|
167
|
+
try:
|
|
168
|
+
return response.json()
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise ApiError(f"GitLab returned invalid JSON for {path}: {exc}", response.status) from exc
|
|
171
|
+
|
|
172
|
+
async def graphql(
|
|
173
|
+
self,
|
|
174
|
+
query: str,
|
|
175
|
+
variables: Mapping[str, str | int | bool],
|
|
176
|
+
*,
|
|
177
|
+
etag_resource: str | None = None,
|
|
178
|
+
if_none_match: str | None = None,
|
|
179
|
+
kind: str = "graphql",
|
|
180
|
+
) -> GraphQLResult:
|
|
181
|
+
"""Run a GraphQL query, optionally through GitLab's ETag cache.
|
|
182
|
+
|
|
183
|
+
With `etag_resource` (for example `pipelines/id/123`) and `if_none_match`, an unchanged resource returns
|
|
184
|
+
a 304 from GitLab's cache before the query runs, and the result has `not_modified=True` and no data.
|
|
185
|
+
"""
|
|
186
|
+
headers: dict[str, str] = {}
|
|
187
|
+
if etag_resource is not None:
|
|
188
|
+
headers[ETAG_RESOURCE_HEADER] = f"/api/graphql:{etag_resource}"
|
|
189
|
+
if if_none_match:
|
|
190
|
+
headers["If-None-Match"] = if_none_match
|
|
191
|
+
strings = {k: v for k, v in variables.items() if isinstance(v, str)}
|
|
192
|
+
typed = {k: v for k, v in variables.items() if not isinstance(v, str)}
|
|
193
|
+
args = build_api_args(
|
|
194
|
+
self.host, "graphql", headers=headers, fields={"query": query, **strings}, typed_fields=typed
|
|
195
|
+
)
|
|
196
|
+
response = await self._request(args, kind + ("-etag" if etag_resource else ""))
|
|
197
|
+
etag = response.header("etag")
|
|
198
|
+
if response.status == 304:
|
|
199
|
+
return GraphQLResult(True, None, etag or if_none_match)
|
|
200
|
+
try:
|
|
201
|
+
payload = response.json()
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
raise ApiError(f"GitLab returned invalid GraphQL JSON: {exc}", response.status) from exc
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
raise ApiError("GitLab returned an unexpected GraphQL response", response.status)
|
|
206
|
+
errors = payload.get("errors")
|
|
207
|
+
if errors:
|
|
208
|
+
messages = "; ".join(str(e.get("message", e)) if isinstance(e, dict) else str(e) for e in errors)
|
|
209
|
+
raise ApiError(f"GraphQL error: {messages}", response.status)
|
|
210
|
+
return GraphQLResult(False, payload.get("data"), etag)
|
|
211
|
+
|
|
212
|
+
async def version(self) -> tuple[int, int] | None:
|
|
213
|
+
"""The GitLab version (major, minor), fetched once per client. None when the server does not tell."""
|
|
214
|
+
if self._version_checked:
|
|
215
|
+
return self._version
|
|
216
|
+
self._version_checked = True
|
|
217
|
+
try:
|
|
218
|
+
payload = await self.get_json("version", kind="version")
|
|
219
|
+
except NotFound:
|
|
220
|
+
return None
|
|
221
|
+
raw = str(payload.get("version", "")) if isinstance(payload, dict) else ""
|
|
222
|
+
parts = raw.split(".")
|
|
223
|
+
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
224
|
+
self._version = (int(parts[0]), int(parts[1]))
|
|
225
|
+
return self._version
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Run `glab api -i` as a subprocess and parse its output.
|
|
2
|
+
|
|
3
|
+
glab owns authentication, hosts, the keyring, OAuth refresh, proxies and certificates. This module never sees a token:
|
|
4
|
+
it only builds the `glab api` argument list and reads back what glab prints.
|
|
5
|
+
|
|
6
|
+
With `-i`, glab prints the HTTP status line, the response headers, a blank line and the body. It exits 1 for every
|
|
7
|
+
non-2xx status, a 304 included, but still prints the status line and the headers. The status is therefore read from the
|
|
8
|
+
output, never from the exit code. Output without a status line means the request never got a response (DNS, TLS,
|
|
9
|
+
connection refused, unknown host); glab explains why on stderr.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, Protocol
|
|
23
|
+
|
|
24
|
+
DEFAULT_TIMEOUT = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GlabNotFoundError(RuntimeError):
|
|
28
|
+
"""The `glab` executable is not on PATH."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GlabTransportError(RuntimeError):
|
|
32
|
+
"""glab produced no HTTP response: the request failed before the server answered, or glab itself failed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ProcessResult:
|
|
37
|
+
returncode: int
|
|
38
|
+
stdout: bytes
|
|
39
|
+
stderr: bytes
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class HttpResponse:
|
|
44
|
+
status: int
|
|
45
|
+
reason: str
|
|
46
|
+
headers: Mapping[str, str]
|
|
47
|
+
"""Header names are lowercase. A repeated header keeps its last value."""
|
|
48
|
+
body: bytes
|
|
49
|
+
|
|
50
|
+
def header(self, name: str) -> str | None:
|
|
51
|
+
return self.headers.get(name.lower())
|
|
52
|
+
|
|
53
|
+
def json(self) -> Any:
|
|
54
|
+
if not self.body.strip():
|
|
55
|
+
return None
|
|
56
|
+
return json.loads(self.body)
|
|
57
|
+
|
|
58
|
+
def text(self) -> str:
|
|
59
|
+
return self.body.decode("utf-8", errors="replace")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GlabRunner(Protocol):
|
|
63
|
+
"""Run glab with the given arguments (without the `glab` executable itself)."""
|
|
64
|
+
|
|
65
|
+
async def __call__(self, args: Sequence[str], timeout: float) -> ProcessResult: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def glab_executable() -> str:
|
|
69
|
+
path = shutil.which("glab")
|
|
70
|
+
if path is None:
|
|
71
|
+
raise GlabNotFoundError("The glab CLI is not on PATH. Install it from https://gitlab.com/gitlab-org/cli.")
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _child_env() -> dict[str, str]:
|
|
76
|
+
env = dict(os.environ)
|
|
77
|
+
env["NO_COLOR"] = "1"
|
|
78
|
+
return env
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def run_glab(args: Sequence[str], timeout: float) -> ProcessResult:
|
|
82
|
+
"""The default runner: no shell, stdin from devnull, a hard timeout."""
|
|
83
|
+
process = await asyncio.create_subprocess_exec(
|
|
84
|
+
glab_executable(),
|
|
85
|
+
*args,
|
|
86
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
87
|
+
stdout=asyncio.subprocess.PIPE,
|
|
88
|
+
stderr=asyncio.subprocess.PIPE,
|
|
89
|
+
env=_child_env(),
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
|
|
93
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
94
|
+
with contextlib.suppress(ProcessLookupError):
|
|
95
|
+
process.kill()
|
|
96
|
+
await process.wait()
|
|
97
|
+
raise
|
|
98
|
+
return ProcessResult(process.returncode if process.returncode is not None else -1, stdout, stderr)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_glab_sync(args: Sequence[str], timeout: float = DEFAULT_TIMEOUT) -> ProcessResult:
|
|
102
|
+
"""The blocking variant, for the CLI, which resolves refs before any event loop runs."""
|
|
103
|
+
completed = subprocess.run(
|
|
104
|
+
[glab_executable(), *args],
|
|
105
|
+
stdin=subprocess.DEVNULL,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
env=_child_env(),
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
return ProcessResult(completed.returncode, completed.stdout, completed.stderr)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_api_args(
|
|
115
|
+
host: str,
|
|
116
|
+
endpoint: str,
|
|
117
|
+
*,
|
|
118
|
+
method: str | None = None,
|
|
119
|
+
headers: Mapping[str, str] | None = None,
|
|
120
|
+
fields: Mapping[str, str] | None = None,
|
|
121
|
+
typed_fields: Mapping[str, str | int | bool] | None = None,
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
"""Build the argument list for `glab api -i`.
|
|
124
|
+
|
|
125
|
+
`fields` become `-f name=value` (always strings); `typed_fields` become `-F name=value`, which glab converts to
|
|
126
|
+
JSON numbers and booleans. For `graphql`, glab sends `query` as the query and every other field as a variable.
|
|
127
|
+
"""
|
|
128
|
+
args = ["api", "--hostname", host, "-i"]
|
|
129
|
+
if method is not None:
|
|
130
|
+
args += ["-X", method]
|
|
131
|
+
for name, value in (headers or {}).items():
|
|
132
|
+
args += ["-H", f"{name}: {value}"]
|
|
133
|
+
for name, value in (fields or {}).items():
|
|
134
|
+
args += ["-f", f"{name}={value}"]
|
|
135
|
+
for name, typed in (typed_fields or {}).items():
|
|
136
|
+
rendered = ("true" if typed else "false") if isinstance(typed, bool) else str(typed)
|
|
137
|
+
args += ["-F", f"{name}={rendered}"]
|
|
138
|
+
args.append(endpoint)
|
|
139
|
+
return args
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_include_output(stdout: bytes) -> HttpResponse | None:
|
|
143
|
+
"""Parse the output of `glab api -i`. Return None when it holds no HTTP status line."""
|
|
144
|
+
first_end = stdout.find(b"\n")
|
|
145
|
+
if first_end < 0:
|
|
146
|
+
return None
|
|
147
|
+
status_line = stdout[:first_end].rstrip(b"\r").decode("latin-1")
|
|
148
|
+
parts = status_line.split(" ", 2)
|
|
149
|
+
if len(parts) < 2 or not parts[0].startswith("HTTP/") or not parts[1].isdigit():
|
|
150
|
+
return None
|
|
151
|
+
status = int(parts[1])
|
|
152
|
+
reason = parts[2] if len(parts) > 2 else ""
|
|
153
|
+
|
|
154
|
+
headers: dict[str, str] = {}
|
|
155
|
+
position = first_end + 1
|
|
156
|
+
while position <= len(stdout):
|
|
157
|
+
line_end = stdout.find(b"\n", position)
|
|
158
|
+
if line_end < 0:
|
|
159
|
+
line_end = len(stdout)
|
|
160
|
+
line = stdout[position:line_end].rstrip(b"\r")
|
|
161
|
+
position = line_end + 1
|
|
162
|
+
if not line:
|
|
163
|
+
break
|
|
164
|
+
name, sep, value = line.decode("latin-1").partition(":")
|
|
165
|
+
if sep:
|
|
166
|
+
headers[name.strip().lower()] = value.strip()
|
|
167
|
+
body = stdout[position:] if position <= len(stdout) else b""
|
|
168
|
+
return HttpResponse(status, reason, headers, body)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def stderr_message(result: ProcessResult) -> str:
|
|
172
|
+
"""The useful part of glab's stderr: its box-drawing decoration and blank lines removed."""
|
|
173
|
+
lines = [line.strip() for line in result.stderr.decode("utf-8", errors="replace").splitlines()]
|
|
174
|
+
text = " ".join(line for line in lines if line and line != "ERROR")
|
|
175
|
+
return text or f"glab exited with code {result.returncode}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""GraphQL queries shipped as package data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@cache
|
|
10
|
+
def load(name: str) -> str:
|
|
11
|
+
return files(__name__).joinpath(f"{name}.graphql").read_text(encoding="utf-8")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
query MergeRequestSnapshot(
|
|
2
|
+
$project: ID!
|
|
3
|
+
$iid: String!
|
|
4
|
+
$withThreads: Boolean!
|
|
5
|
+
$withPipeline: Boolean!
|
|
6
|
+
$threadsAfter: String
|
|
7
|
+
) {
|
|
8
|
+
project(fullPath: $project) {
|
|
9
|
+
mergeRequest(iid: $iid) {
|
|
10
|
+
iid
|
|
11
|
+
state
|
|
12
|
+
title
|
|
13
|
+
description
|
|
14
|
+
draft
|
|
15
|
+
detailedMergeStatus
|
|
16
|
+
diffHeadSha
|
|
17
|
+
sourceBranch
|
|
18
|
+
targetBranch
|
|
19
|
+
webUrl
|
|
20
|
+
approved
|
|
21
|
+
approvalsLeft
|
|
22
|
+
approvedBy { nodes { username } }
|
|
23
|
+
reviewers { nodes { username } }
|
|
24
|
+
headPipeline @include(if: $withPipeline) {
|
|
25
|
+
id
|
|
26
|
+
status
|
|
27
|
+
project { fullPath }
|
|
28
|
+
}
|
|
29
|
+
discussions(first: 100, after: $threadsAfter) @include(if: $withThreads) {
|
|
30
|
+
pageInfo { hasNextPage endCursor }
|
|
31
|
+
nodes {
|
|
32
|
+
id
|
|
33
|
+
resolvable
|
|
34
|
+
resolved
|
|
35
|
+
notes(first: 100) {
|
|
36
|
+
nodes { id updatedAt system author { username } }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
query PipelineTree($project: ID!, $id: CiPipelineID!) {
|
|
2
|
+
project(fullPath: $project) {
|
|
3
|
+
pipeline(id: $id) {
|
|
4
|
+
id
|
|
5
|
+
iid
|
|
6
|
+
status
|
|
7
|
+
ref
|
|
8
|
+
sha
|
|
9
|
+
path
|
|
10
|
+
jobs(first: 100, retried: false) {
|
|
11
|
+
nodes { ...WatchedJob }
|
|
12
|
+
}
|
|
13
|
+
downstream {
|
|
14
|
+
nodes {
|
|
15
|
+
id
|
|
16
|
+
iid
|
|
17
|
+
status
|
|
18
|
+
path
|
|
19
|
+
project { fullPath }
|
|
20
|
+
jobs(first: 100, retried: false) {
|
|
21
|
+
nodes { ...WatchedJob }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fragment WatchedJob on CiJob {
|
|
30
|
+
id
|
|
31
|
+
name
|
|
32
|
+
status
|
|
33
|
+
stage { name }
|
|
34
|
+
allowFailure
|
|
35
|
+
startedAt
|
|
36
|
+
finishedAt
|
|
37
|
+
artifacts { nodes { fileType size } }
|
|
38
|
+
}
|