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.
- gl_pipeline_watcher-0.1.0/.gitignore +6 -0
- gl_pipeline_watcher-0.1.0/PKG-INFO +86 -0
- gl_pipeline_watcher-0.1.0/README.md +61 -0
- gl_pipeline_watcher-0.1.0/pyproject.toml +41 -0
- gl_pipeline_watcher-0.1.0/src/gl_pipeline_watcher/__init__.py +1 -0
- gl_pipeline_watcher-0.1.0/src/gl_pipeline_watcher/__main__.py +344 -0
|
@@ -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()
|