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,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,8 @@
1
+ query BranchPipelines($project: ID!, $ref: String!) {
2
+ project(fullPath: $project) {
3
+ id
4
+ pipelines(ref: $ref, first: 10) {
5
+ nodes { id iid status sha path createdAt }
6
+ }
7
+ }
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
+ }