gl-pipeline-watcher 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: gl-pipeline-watcher
3
+ Version: 0.1.0
4
+ Summary: Curses TUI for watching GitLab CI pipelines
5
+ Project-URL: Repository, https://gitlab.com/alexandra.tapkova/gl-pipeline-watcher
6
+ Author-email: Alexandra Ťapková <git@ouppy.space>
7
+ License: MIT
8
+ Keywords: ci,curses,devops,gitlab,pipeline,tui
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console :: Curses
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: build; extra == 'dev'
23
+ Requires-Dist: twine; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # gl-pipeline-watcher
27
+
28
+ Terminal UI for watching GitLab CI pipelines. Shows pipeline status, job states, last few log lines, and Docker build progress for running and recently failed pipelines.
29
+
30
+ ## Install
31
+
32
+ ```
33
+ pip install gl-pipeline-watcher
34
+ ```
35
+
36
+ No dependencies outside the standard library.
37
+
38
+ ## Usage
39
+
40
+ Run from inside a git repo:
41
+
42
+ ```
43
+ wp [branch]
44
+ ```
45
+
46
+ If no branch is given, the current branch is detected automatically. The GitLab URL and project path are detected from `git remote get-url origin`. Both SSH (`git@host:group/repo`) and HTTPS remotes work.
47
+
48
+ ## Token setup
49
+
50
+ You need a GitLab personal access token with `read_api` scope.
51
+
52
+ **Simple:** set `GITLAB_TOKEN` in your environment.
53
+
54
+ **Per-domain:** create `~/.config/gitlab-pipeline-tokens.json` mapping hostnames to files containing tokens:
55
+
56
+ ```json
57
+ {
58
+ "gitlab.example.com": "/path/to/token-file"
59
+ }
60
+ ```
61
+
62
+ `GITLAB_TOKEN` takes priority over the file map when both are set.
63
+
64
+ ## Environment variables
65
+
66
+ | Variable | Default | Description |
67
+ |---|---|---|
68
+ | `GITLAB_TOKEN` | | Token (takes priority over token map) |
69
+ | `GITLAB_URL` | auto | Base URL, e.g. `https://gitlab.com` |
70
+ | `GITLAB_PROJECT` | auto | Project path, e.g. `mygroup/myrepo` |
71
+ | `REFRESH` | `10` | Polling interval in seconds |
72
+ | `LOG_LINES` | `5` | Trace lines shown per running/failed job |
73
+
74
+ ## Keys
75
+
76
+ | Key | Action |
77
+ |---|---|
78
+ | `j` / `k`, arrow keys | Scroll |
79
+ | `PgUp` / `PgDn` | Page up/down |
80
+ | `Home` / `End` | Jump to top/bottom |
81
+ | `r` | Force refresh |
82
+ | `q`, `Esc` | Quit |
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,61 @@
1
+ # gl-pipeline-watcher
2
+
3
+ Terminal UI for watching GitLab CI pipelines. Shows pipeline status, job states, last few log lines, and Docker build progress for running and recently failed pipelines.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install gl-pipeline-watcher
9
+ ```
10
+
11
+ No dependencies outside the standard library.
12
+
13
+ ## Usage
14
+
15
+ Run from inside a git repo:
16
+
17
+ ```
18
+ wp [branch]
19
+ ```
20
+
21
+ If no branch is given, the current branch is detected automatically. The GitLab URL and project path are detected from `git remote get-url origin`. Both SSH (`git@host:group/repo`) and HTTPS remotes work.
22
+
23
+ ## Token setup
24
+
25
+ You need a GitLab personal access token with `read_api` scope.
26
+
27
+ **Simple:** set `GITLAB_TOKEN` in your environment.
28
+
29
+ **Per-domain:** create `~/.config/gitlab-pipeline-tokens.json` mapping hostnames to files containing tokens:
30
+
31
+ ```json
32
+ {
33
+ "gitlab.example.com": "/path/to/token-file"
34
+ }
35
+ ```
36
+
37
+ `GITLAB_TOKEN` takes priority over the file map when both are set.
38
+
39
+ ## Environment variables
40
+
41
+ | Variable | Default | Description |
42
+ |---|---|---|
43
+ | `GITLAB_TOKEN` | | Token (takes priority over token map) |
44
+ | `GITLAB_URL` | auto | Base URL, e.g. `https://gitlab.com` |
45
+ | `GITLAB_PROJECT` | auto | Project path, e.g. `mygroup/myrepo` |
46
+ | `REFRESH` | `10` | Polling interval in seconds |
47
+ | `LOG_LINES` | `5` | Trace lines shown per running/failed job |
48
+
49
+ ## Keys
50
+
51
+ | Key | Action |
52
+ |---|---|
53
+ | `j` / `k`, arrow keys | Scroll |
54
+ | `PgUp` / `PgDn` | Page up/down |
55
+ | `Home` / `End` | Jump to top/bottom |
56
+ | `r` | Force refresh |
57
+ | `q`, `Esc` | Quit |
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gl-pipeline-watcher"
7
+ version = "0.1.0"
8
+ description = "Curses TUI for watching GitLab CI pipelines"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Alexandra Ťapková", email = "git@ouppy.space" }]
13
+ keywords = ["gitlab", "ci", "pipeline", "tui", "curses", "devops"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console :: Curses",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Software Development :: Build Tools",
26
+ ]
27
+
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["build", "twine"]
32
+
33
+ [project.scripts]
34
+ wp = "gl_pipeline_watcher.__main__:main"
35
+ gl-pipeline-watcher = "gl_pipeline_watcher.__main__:main"
36
+
37
+ [project.urls]
38
+ Repository = "https://gitlab.com/alexandra.tapkova/gl-pipeline-watcher"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/gl_pipeline_watcher"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,344 @@
1
+ """GitLab pipeline watcher — curses TUI.
2
+ Usage: wp [branch]
3
+ gl-pipeline-watcher [branch]
4
+
5
+ Env:
6
+ GITLAB_TOKEN Personal access token (read_api scope). Takes priority over token map.
7
+ GITLAB_URL GitLab base URL (auto-detected from git remote if unset).
8
+ GITLAB_PROJECT Project path, e.g. mygroup/myrepo (auto-detected from git remote).
9
+ REFRESH Polling interval in seconds (default: 10).
10
+ LOG_LINES Trace lines shown per job (default: 5).
11
+
12
+ Token map (~/.config/gitlab-pipeline-tokens.json):
13
+ Alternative to GITLAB_TOKEN for multi-domain setups. JSON object mapping
14
+ hostname to a file path containing the token, e.g.:
15
+ { "gitlab.example.com": "/run/secrets/gitlab_token" }
16
+ GITLAB_TOKEN takes priority when set.
17
+
18
+ Keys: ↑↓ / j k scroll PgUp/PgDn page Home/End r refresh q quit
19
+ """
20
+
21
+ import curses
22
+ import json
23
+ import os
24
+ import re
25
+ import ssl
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ import time
30
+ import urllib.request
31
+
32
+ # ── config ────────────────────────────────────────────────────────────────────
33
+
34
+ TOKENS_MAP = os.path.expanduser("~/.config/gitlab-pipeline-tokens.json")
35
+ GIT = "git"
36
+
37
+ # These are module-level so setup() and the Watcher class can share them.
38
+ BRANCH = ""
39
+ REFRESH = 10
40
+ LOG_LINES = 5
41
+ GITLAB_URL = ""
42
+ PROJECT = ""
43
+ TOKEN = ""
44
+
45
+ # ── helpers ───────────────────────────────────────────────────────────────────
46
+
47
+ def die(msg):
48
+ sys.exit(f"\033[31mError:\033[0m {msg}")
49
+
50
+ def git_cmd(*args):
51
+ try:
52
+ return subprocess.check_output([GIT] + list(args),
53
+ stderr=subprocess.DEVNULL).decode().strip()
54
+ except (subprocess.CalledProcessError, FileNotFoundError):
55
+ return ""
56
+
57
+ def _ssl_ctx(verify=True):
58
+ ctx = ssl.create_default_context()
59
+ if not verify:
60
+ ctx.check_hostname = False
61
+ ctx.verify_mode = ssl.CERT_NONE
62
+ return ctx
63
+
64
+ def _get(url):
65
+ req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": TOKEN})
66
+ for verify in (True, False):
67
+ try:
68
+ with urllib.request.urlopen(req, timeout=15, context=_ssl_ctx(verify)) as r:
69
+ return r.read()
70
+ except ssl.SSLError:
71
+ continue
72
+ raise RuntimeError(f"SSL error fetching {url}")
73
+
74
+ def api(path):
75
+ return json.loads(_get(f"{GITLAB_URL}/api/v4/{path}"))
76
+
77
+ def strip_ansi(s):
78
+ return re.sub(r'\x1b\[[0-9;?]*[A-Za-z]|\r', "", s)
79
+
80
+ _DOCKER_STEP = re.compile(r'^Step\s+(\d+)/(\d+)\s*:\s*(.*)')
81
+
82
+ # ── setup ─────────────────────────────────────────────────────────────────────
83
+
84
+ def setup():
85
+ global TOKEN, BRANCH, GITLAB_URL, PROJECT, REFRESH, LOG_LINES
86
+
87
+ REFRESH = int(os.environ.get("REFRESH", "10"))
88
+ LOG_LINES = int(os.environ.get("LOG_LINES", "5"))
89
+ GITLAB_URL = os.environ.get("GITLAB_URL", "")
90
+ PROJECT = os.environ.get("GITLAB_PROJECT", "")
91
+ BRANCH = sys.argv[1] if len(sys.argv) > 1 else ""
92
+
93
+ if not BRANCH:
94
+ BRANCH = git_cmd("symbolic-ref", "--short", "HEAD")
95
+ if not BRANCH:
96
+ die("Could not detect current branch. Pass a branch name as an argument.")
97
+
98
+ remote = None
99
+ if not GITLAB_URL or not PROJECT:
100
+ remote = git_cmd("remote", "get-url", "origin")
101
+ if not remote:
102
+ die('No git remote "origin" found. Set GITLAB_URL and GITLAB_PROJECT.')
103
+
104
+ if not PROJECT:
105
+ m = re.match(r'^git@[^:]+:/?(.+?)(?:\.git)?$', remote) or \
106
+ re.match(r'^https?://[^/]+/(.+?)(?:\.git)?$', remote)
107
+ if not m:
108
+ die(f"Cannot parse project path from remote: {remote}\nSet GITLAB_PROJECT.")
109
+ PROJECT = m.group(1)
110
+
111
+ # Extract hostname (needed for token lookup and scheme probe)
112
+ if GITLAB_URL:
113
+ m = re.match(r'^https?://([^/]+)', GITLAB_URL)
114
+ host = m.group(1) if m else None
115
+ else:
116
+ m = re.match(r'^git@([^:]+):', remote) or \
117
+ re.match(r'^https?://([^/]+)/', remote)
118
+ host = m.group(1) if m else "gitlab.com"
119
+
120
+ # Token resolution: env var takes priority, then per-host file map
121
+ TOKEN = os.environ.get("GITLAB_TOKEN", "")
122
+ if not TOKEN:
123
+ if os.path.exists(TOKENS_MAP):
124
+ try:
125
+ tokens_map = json.load(open(TOKENS_MAP))
126
+ except (OSError, json.JSONDecodeError) as e:
127
+ die(f"Cannot read token map {TOKENS_MAP}: {e}")
128
+ token_path = tokens_map.get(host)
129
+ if token_path:
130
+ try:
131
+ TOKEN = open(token_path).read().strip()
132
+ except OSError as e:
133
+ die(f"Cannot read token for '{host}' from {token_path}: {e}")
134
+ if not TOKEN:
135
+ die(
136
+ f"No token found for '{host}'.\n"
137
+ f"Set the GITLAB_TOKEN environment variable, or add an entry for\n"
138
+ f"'{host}' in {TOKENS_MAP}."
139
+ )
140
+
141
+ # Scheme probe (https first, then http; try with and without cert verify)
142
+ if not GITLAB_URL:
143
+ for scheme in ("https", "http"):
144
+ for verify in (True, False):
145
+ try:
146
+ urllib.request.urlopen(
147
+ urllib.request.Request(
148
+ f"{scheme}://{host}/api/v4/version",
149
+ headers={"PRIVATE-TOKEN": TOKEN}),
150
+ timeout=5, context=_ssl_ctx(verify))
151
+ GITLAB_URL = f"{scheme}://{host}"
152
+ break
153
+ except Exception:
154
+ pass
155
+ if GITLAB_URL:
156
+ break
157
+ if not GITLAB_URL:
158
+ GITLAB_URL = f"https://{host}"
159
+
160
+ # ── rendering ─────────────────────────────────────────────────────────────────
161
+
162
+ ICONS = {
163
+ "success": "✓", "failed": "✗", "running": "●",
164
+ "pending": "○", "created": "○", "canceled": "⊘",
165
+ "skipped": "–", "manual": "▶",
166
+ }
167
+ # (color_pair_n, bold)
168
+ STYLE = {
169
+ "success": (2, True), "failed": (1, True), "running": (3, True),
170
+ "pending": (0, False), "created": (0, False), "canceled": (0, False),
171
+ "skipped": (0, False), "manual": (4, False),
172
+ }
173
+
174
+ def fmt_dur(s):
175
+ if not s: return ""
176
+ s = int(s); return f"{s//60}m {s%60:02d}s"
177
+
178
+ # A Row is (text, color_pair, bold, dim)
179
+ def build_rows(pipelines, jobs_map, traces, docker_progress):
180
+ rows = []
181
+ for p in pipelines:
182
+ pid, st = p["id"], p["status"]
183
+ cp, bold = STYLE.get(st, (0, False))
184
+ icon = ICONS.get(st, "?")
185
+ rows.append((f" Pipeline #{pid} · {icon} {st} · {p['updated_at']}", cp, bold, False))
186
+ rows.append((f" {p['web_url']}", 0, False, True))
187
+ rows.append(("", 0, False, False))
188
+ last_stage = None
189
+ for job in jobs_map.get(pid, []):
190
+ jcp, jbold = STYLE.get(job["status"], (0, False))
191
+ if job["stage"] != last_stage:
192
+ rows.append((f" ── {job['stage']}", 0, False, True))
193
+ last_stage = job["stage"]
194
+ rows.append((
195
+ f" {ICONS.get(job['status'], '?')} {job['name']:<30} {fmt_dur(job.get('duration'))}",
196
+ jcp, jbold, False))
197
+ if job["id"] in docker_progress:
198
+ cur, total, step_name = docker_progress[job["id"]]
199
+ bar_w = 20
200
+ filled = round(bar_w * cur / total) if total else 0
201
+ bar = "█" * filled + "░" * (bar_w - filled)
202
+ rows.append((f" │ [{bar}] {cur}/{total} {step_name[:36]}", 4, False, False))
203
+ for ln in traces.get(job["id"], []):
204
+ rows.append((f" │ {ln}", 0, False, True))
205
+ if traces.get(job["id"]) or job["id"] in docker_progress:
206
+ rows.append((" │", 0, False, True))
207
+ rows.append(("", 0, False, False))
208
+ return rows
209
+
210
+ # ── TUI ───────────────────────────────────────────────────────────────────────
211
+
212
+ class Watcher:
213
+ def __init__(self, scr):
214
+ self.scr = scr
215
+ self.rows = []
216
+ self.scroll = 0
217
+ self.fetching = False
218
+ self.error = None
219
+ self.last_fetch = 0.0
220
+ self._lock = threading.Lock()
221
+
222
+ def _fetch(self):
223
+ self.fetching = True
224
+ self.error = None
225
+ enc = PROJECT.replace("/", "%2F")
226
+ try:
227
+ pipelines = api(f"projects/{enc}/pipelines?ref={BRANCH}&per_page=4")
228
+ jobs_map, traces, docker_progress = {}, {}, {}
229
+ for p in pipelines:
230
+ pid = p["id"]
231
+ jobs = sorted(api(f"projects/{enc}/pipelines/{pid}/jobs?per_page=100"),
232
+ key=lambda j: j["id"])
233
+ jobs_map[pid] = jobs
234
+ for job in jobs:
235
+ if job["status"] in ("running", "failed"):
236
+ try:
237
+ raw = _get(f"{GITLAB_URL}/api/v4/projects/{enc}/jobs/{job['id']}/trace")
238
+ lines = [l for l in strip_ansi(
239
+ raw.decode("utf-8", errors="replace")).splitlines() if l.strip()]
240
+ if lines:
241
+ traces[job["id"]] = [l[:80] for l in lines[-LOG_LINES:]]
242
+ last_step = None
243
+ for ln in lines:
244
+ m = _DOCKER_STEP.match(ln)
245
+ if m:
246
+ last_step = (int(m.group(1)), int(m.group(2)), m.group(3).strip())
247
+ if last_step:
248
+ docker_progress[job["id"]] = last_step
249
+ except Exception:
250
+ pass
251
+ with self._lock:
252
+ self.rows = build_rows(pipelines, jobs_map, traces, docker_progress)
253
+ except Exception as e:
254
+ self.error = str(e)
255
+ finally:
256
+ self.fetching = False
257
+ self.last_fetch = time.time()
258
+
259
+ def spawn(self):
260
+ threading.Thread(target=self._fetch, daemon=True).start()
261
+
262
+ def init_colors(self):
263
+ curses.start_color()
264
+ curses.use_default_colors()
265
+ curses.init_pair(1, curses.COLOR_RED, -1)
266
+ curses.init_pair(2, curses.COLOR_GREEN, -1)
267
+ curses.init_pair(3, curses.COLOR_YELLOW, -1)
268
+ curses.init_pair(4, curses.COLOR_BLUE, -1)
269
+ curses.init_pair(5, curses.COLOR_CYAN, -1)
270
+
271
+ def draw(self):
272
+ h, w = self.scr.getmaxyx()
273
+ self.scr.erase()
274
+ ch = h - 3 # content rows (header=2, footer=1)
275
+
276
+ nxt = max(0, int(REFRESH - (time.time() - self.last_fetch)))
277
+ state = "⟳" if self.fetching else f"next in {nxt}s"
278
+ hdr = f" GitLab Pipelines · {BRANCH} · {PROJECT} · {state}"
279
+ c5 = curses.color_pair(5) | curses.A_BOLD
280
+ try:
281
+ self.scr.addstr(0, 0, ("═" * w)[:w], c5)
282
+ self.scr.addstr(1, 0, hdr[:w-1], c5)
283
+ except curses.error:
284
+ pass
285
+
286
+ if not self.last_fetch:
287
+ try: self.scr.addstr(3, 2, "Fetching…")
288
+ except curses.error: pass
289
+ elif self.error:
290
+ try: self.scr.addstr(3, 2, f"Error: {self.error}"[:w-3], curses.color_pair(1))
291
+ except curses.error: pass
292
+ else:
293
+ with self._lock:
294
+ visible = self.rows[self.scroll:self.scroll + ch]
295
+ for i, (text, cp, bold, dim) in enumerate(visible):
296
+ attr = curses.color_pair(cp)
297
+ if bold: attr |= curses.A_BOLD
298
+ if dim: attr |= curses.A_DIM
299
+ try: self.scr.addstr(i + 2, 0, text[:w-1], attr)
300
+ except curses.error: pass
301
+
302
+ total = len(self.rows)
303
+ end = min(self.scroll + ch, total)
304
+ foot = f" ↑↓ jk scroll · PgUp/Dn · r refresh · q quit {self.scroll+1}–{end}/{total} "
305
+ try: self.scr.addstr(h-1, 0, foot[:w], curses.A_REVERSE)
306
+ except curses.error: pass
307
+
308
+ self.scr.refresh()
309
+
310
+ def run(self):
311
+ self.init_colors()
312
+ curses.curs_set(0)
313
+ self.scr.timeout(250)
314
+ self.spawn()
315
+ while True:
316
+ self.draw()
317
+ h, _ = self.scr.getmaxyx()
318
+ ch = h - 3
319
+ total = len(self.rows)
320
+ max_scroll = max(0, total - ch)
321
+ key = self.scr.getch()
322
+ if key in (ord("q"), ord("Q"), 27):
323
+ break
324
+ elif key in (curses.KEY_DOWN, ord("j")): self.scroll = min(self.scroll + 1, max_scroll)
325
+ elif key in (curses.KEY_UP, ord("k")): self.scroll = max(0, self.scroll - 1)
326
+ elif key == curses.KEY_NPAGE: self.scroll = min(self.scroll + ch, max_scroll)
327
+ elif key == curses.KEY_PPAGE: self.scroll = max(0, self.scroll - ch)
328
+ elif key == curses.KEY_HOME: self.scroll = 0
329
+ elif key == curses.KEY_END: self.scroll = max_scroll
330
+ elif key in (ord("r"), ord("R")):
331
+ if not self.fetching: self.spawn()
332
+ elif key == curses.KEY_RESIZE:
333
+ self.scr.clear()
334
+ if not self.fetching and time.time() - self.last_fetch >= REFRESH:
335
+ self.spawn()
336
+
337
+ # ── entry point ───────────────────────────────────────────────────────────────
338
+
339
+ def main():
340
+ setup()
341
+ curses.wrapper(lambda scr: Watcher(scr).run())
342
+
343
+ if __name__ == "__main__":
344
+ main()