release-status 0.2.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.
File without changes
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ class Cache:
10
+ def __init__(
11
+ self,
12
+ cache_dir: Path,
13
+ ttl: timedelta,
14
+ ) -> None:
15
+ self.cache_dir = cache_dir
16
+ self.ttl = ttl
17
+ self.enabled = True
18
+
19
+ def _key_path(self, key: str) -> Path:
20
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
21
+ return self.cache_dir / f"{h}.json"
22
+
23
+ def get(self, key: str) -> Any | None:
24
+ if not self.enabled:
25
+ return None
26
+ path = self._key_path(key)
27
+ if not path.exists():
28
+ return None
29
+ entry = json.loads(path.read_text())
30
+ cached_at = datetime.fromisoformat(entry["cached_at"])
31
+ if datetime.now(timezone.utc) - cached_at > self.ttl:
32
+ path.unlink()
33
+ return None
34
+ return entry["data"]
35
+
36
+ def set(self, key: str, data: Any) -> None:
37
+ if not self.enabled:
38
+ return
39
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
40
+ entry = {
41
+ "cached_at": datetime.now(timezone.utc).isoformat(),
42
+ "data": data,
43
+ }
44
+ self._key_path(key).write_text(json.dumps(entry))
45
+
46
+ def clear(self) -> int:
47
+ if not self.cache_dir.exists():
48
+ return 0
49
+ count = 0
50
+ for f in self.cache_dir.glob("*.json"):
51
+ f.unlink()
52
+ count += 1
53
+ return count
release_status/cli.py ADDED
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional
9
+
10
+ import typer
11
+ from importlib.metadata import version as pkg_version
12
+ from pydantic import ValidationError
13
+ from rich.console import Console
14
+
15
+ from release_status.cache import Cache
16
+ from release_status.config import (
17
+ AppConfig,
18
+ ProjectConfig,
19
+ generate_schema,
20
+ load_config,
21
+ resolve_config_path,
22
+ )
23
+ from release_status.models import Commit, EnvironmentStatus, ProviderError, ToolNotFoundError
24
+ from release_status.providers import check_cli_tools, get_provider
25
+ from release_status.resolvers import resolve_environment
26
+ from release_status.version import check_for_update, check_for_update_strict, clear_update_cache, get_current_version, PACKAGE_NAME
27
+ from release_status.views import render_commits, render_environments, render_projects
28
+
29
+ app = typer.Typer(
30
+ name="release-status",
31
+ help="Show release/deployment status across multiple projects.",
32
+ no_args_is_help=True,
33
+ )
34
+ console = Console()
35
+
36
+
37
+ class _State:
38
+ config_path: Path | None = None
39
+ no_cache: bool = False
40
+ since_days: int | None = None
41
+ branch: str | None = None
42
+
43
+
44
+ _state = _State()
45
+
46
+
47
+ def _complete_project(incomplete: str) -> list[str]:
48
+ try:
49
+ path = resolve_config_path(_state.config_path)
50
+ config = load_config(path)
51
+ return [p.name for p in config.projects if p.name.lower().startswith(incomplete.lower())]
52
+ except Exception:
53
+ return []
54
+
55
+
56
+ def _version_callback(value: bool) -> None:
57
+ if value:
58
+ console.print(pkg_version("release-status"))
59
+ raise typer.Exit()
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ _version: Annotated[
65
+ bool, typer.Option("--version", "-v", help="Show version", callback=_version_callback, is_eager=True)
66
+ ] = False,
67
+ config: Annotated[
68
+ Optional[Path], typer.Option("--config", "-c", help="Path to config file")
69
+ ] = None,
70
+ no_cache: Annotated[
71
+ bool, typer.Option("--no-cache", help="Disable cache")
72
+ ] = False,
73
+ since: Annotated[
74
+ Optional[int], typer.Option("--since", help="Override days to look back for commits")
75
+ ] = None,
76
+ branch: Annotated[
77
+ Optional[str], typer.Option("--branch", "-b", help="Override branch to fetch commits from")
78
+ ] = None,
79
+ ) -> None:
80
+ """Release Status — deployment tracking across projects."""
81
+ _state.config_path = config
82
+ _state.no_cache = no_cache
83
+ _state.since_days = since
84
+ _state.branch = branch
85
+
86
+
87
+ @app.command()
88
+ def commits(
89
+ project: Annotated[str, typer.Argument(help="Project name", autocompletion=_complete_project)],
90
+ ) -> None:
91
+ """Show recent commits with environment deployment markers."""
92
+ cfg = _load_config()
93
+ proj = _apply_branch_override(_find_project(cfg, project))
94
+ cache = _make_cache(cfg)
95
+ since = _since_days(cfg)
96
+ cache_ttl = 0 if _state.no_cache else cfg.cache_ttl_minutes
97
+ current_version = get_current_version()
98
+
99
+ with console.status("Fetching commits..."):
100
+ update_available = check_for_update(current_version)
101
+ commit_list = _fetch_commits(proj, cache, since)
102
+
103
+ with console.status("Checking environments..."):
104
+ env_statuses = _fetch_environments(proj, cache)
105
+
106
+ _fetch_missing_commits(commit_list, env_statuses, proj, cache)
107
+ render_commits(proj, commit_list, env_statuses, since, cache_ttl, console, current_version, update_available)
108
+
109
+
110
+ @app.command()
111
+ def envs(
112
+ project: Annotated[str, typer.Argument(help="Project name", autocompletion=_complete_project)],
113
+ ) -> None:
114
+ """Show environment deployment status."""
115
+ cfg = _load_config()
116
+ proj = _apply_branch_override(_find_project(cfg, project))
117
+ cache = _make_cache(cfg)
118
+ since = _since_days(cfg)
119
+ cache_ttl = 0 if _state.no_cache else cfg.cache_ttl_minutes
120
+ current_version = get_current_version()
121
+
122
+ with console.status("Fetching data..."):
123
+ update_available = check_for_update(current_version)
124
+ commit_list = _fetch_commits(proj, cache, since)
125
+ env_statuses = _fetch_environments(proj, cache)
126
+
127
+ _fetch_missing_commits(commit_list, env_statuses, proj, cache)
128
+ render_environments(proj, commit_list, env_statuses, since, cache_ttl, console, current_version, update_available)
129
+
130
+
131
+ @app.command()
132
+ def projects() -> None:
133
+ """List all configured projects."""
134
+ cfg = _load_config()
135
+ render_projects(cfg, console)
136
+
137
+
138
+ @app.command()
139
+ def check() -> None:
140
+ """Validate configuration and check CLI tool availability."""
141
+ path = resolve_config_path(_state.config_path)
142
+ cfg = _load_config()
143
+ console.print(f"[green]Config is valid:[/green] {path}")
144
+ console.print(f" Projects: {len(cfg.projects)}")
145
+
146
+ has_issues = False
147
+ for proj in cfg.projects:
148
+ issue = check_cli_tools(proj.repository.provider)
149
+ if issue:
150
+ console.print(f" [red]WARN[/red] {proj.name}: {issue}")
151
+ has_issues = True
152
+ else:
153
+ console.print(f" [green]OK[/green] {proj.name}: {proj.repository.provider.type}")
154
+
155
+ if not has_issues:
156
+ console.print("[green]All checks passed.[/green]")
157
+
158
+
159
+ @app.command()
160
+ def schema() -> None:
161
+ """Print JSON Schema for the config file."""
162
+ console.print(json.dumps(generate_schema(), indent=2))
163
+
164
+
165
+ @app.command(name="clear-cache")
166
+ def clear_cache() -> None:
167
+ """Clear all cached data."""
168
+ cfg = _load_config()
169
+ count = _make_cache(cfg).clear()
170
+ console.print(f"Cleared {count} cache entries.")
171
+
172
+
173
+ @app.command()
174
+ def update() -> None:
175
+ """Update release-status to the latest version."""
176
+ current = get_current_version()
177
+ update_version, check_ok = check_for_update_strict(current)
178
+
179
+ if not update_version:
180
+ if check_ok:
181
+ console.print(f"Already up to date (v{current}).")
182
+ else:
183
+ console.print(f"[red]Could not check for updates.[/red] Current version: v{current}")
184
+ raise typer.Exit()
185
+
186
+ if not shutil.which("uv"):
187
+ console.print("[red]uv not found.[/red] Install it from https://docs.astral.sh/uv/")
188
+ console.print(f"Or update manually: pip install --upgrade {PACKAGE_NAME}")
189
+ raise typer.Exit(1)
190
+
191
+ console.print(f"Updating: v{current} → v{update_version}")
192
+ result = subprocess.run(
193
+ ["uv", "tool", "install", PACKAGE_NAME, "--force", "--reinstall"],
194
+ capture_output=True,
195
+ text=True,
196
+ )
197
+
198
+ if result.returncode == 0:
199
+ clear_update_cache()
200
+ console.print(f"[green]Updated to v{update_version}.[/green]")
201
+ else:
202
+ console.print(f"[red]Update failed:[/red]\n{result.stderr.strip()}")
203
+ raise typer.Exit(1)
204
+
205
+
206
+ @app.command()
207
+ def init() -> None:
208
+ """Create a starter config file."""
209
+ path = resolve_config_path(_state.config_path)
210
+ if path.exists():
211
+ console.print(f"Config already exists: {path}")
212
+ raise typer.Exit(1)
213
+
214
+ starter = {
215
+ "cache_dir": str(Path.home() / ".cache" / "release-status"),
216
+ "cache_ttl_minutes": 5,
217
+ "since_days": 180,
218
+ "projects": [
219
+ {
220
+ "name": "project-github-cli",
221
+ "repository": {
222
+ "url": "https://github.com/org/repo.git",
223
+ "branch": "main",
224
+ "provider": {"type": "github-cli"},
225
+ },
226
+ "environments": [
227
+ {
228
+ "name": "dev",
229
+ "url": "https://dev.example.com/build.json",
230
+ "source": {
231
+ "type": "json",
232
+ "fields": {"version": "$.version"},
233
+ },
234
+ }
235
+ ],
236
+ },
237
+ {
238
+ "name": "project-github-api",
239
+ "repository": {
240
+ "url": "https://github.com/org/repo.git",
241
+ "branch": "main",
242
+ "provider": {
243
+ "type": "github-api",
244
+ "token_env": "GITHUB_TOKEN",
245
+ },
246
+ },
247
+ "environments": [
248
+ {
249
+ "name": "prod",
250
+ "url": "https://prod.example.com/build.json",
251
+ "source": {
252
+ "type": "json",
253
+ "fields": {"version": "$.version"},
254
+ },
255
+ }
256
+ ],
257
+ },
258
+ {
259
+ "name": "project-gitlab-cli",
260
+ "repository": {
261
+ "url": "https://gitlab.com/org/repo.git",
262
+ "branch": "main",
263
+ "provider": {"type": "gitlab-cli"},
264
+ },
265
+ "environments": [
266
+ {
267
+ "name": "dev",
268
+ "url": "https://dev.example.com/build.html",
269
+ "source": {
270
+ "type": "regex",
271
+ "pattern": r"(.*)\t(?P<commit_time>.*)\n(?P<version>.*)\t(?P<pipeline_time>.*)",
272
+ "fields": {"version": "version"},
273
+ },
274
+ }
275
+ ],
276
+ },
277
+ {
278
+ "name": "project-gitlab-api",
279
+ "repository": {
280
+ "url": "https://gitlab.com/org/repo.git",
281
+ "branch": "main",
282
+ "provider": {
283
+ "type": "gitlab-api",
284
+ "token_env": "GITLAB_API_TOKEN",
285
+ },
286
+ },
287
+ "environments": [
288
+ {
289
+ "name": "prod",
290
+ "url": "https://prod.example.com/build.json",
291
+ "source": {
292
+ "type": "json",
293
+ "fields": {"version": "$.version"},
294
+ },
295
+ }
296
+ ],
297
+ },
298
+ ],
299
+ }
300
+
301
+ path.parent.mkdir(parents=True, exist_ok=True)
302
+ path.write_text(json.dumps(starter, indent=2) + "\n")
303
+ console.print(f"Created config: {path}")
304
+ console.print("Edit it with your projects, then run: release-status check")
305
+
306
+
307
+ # --- Helpers ---
308
+
309
+
310
+ def _load_config() -> AppConfig:
311
+ path = resolve_config_path(_state.config_path)
312
+ try:
313
+ return load_config(path)
314
+ except FileNotFoundError:
315
+ console.print(f"[red]Config file not found:[/red] {path}")
316
+ console.print(
317
+ f"Create one with: release-status init"
318
+ )
319
+ raise typer.Exit(1)
320
+ except ValidationError as e:
321
+ console.print(f"[red]Config validation error:[/red] {path}")
322
+ for err in e.errors():
323
+ loc = " → ".join(str(x) for x in err["loc"])
324
+ console.print(f" {loc}: {err['msg']}")
325
+ raise typer.Exit(1)
326
+
327
+
328
+ def _find_project(config: AppConfig, name: str) -> ProjectConfig:
329
+ for p in config.projects:
330
+ if p.name.lower() == name.lower():
331
+ return p
332
+ available = ", ".join(p.name for p in config.projects)
333
+ console.print(f"[red]Project '{name}' not found.[/red] Available: {available}")
334
+ raise typer.Exit(1)
335
+
336
+
337
+ def _make_cache(config: AppConfig) -> Cache:
338
+ cache = Cache(
339
+ cache_dir=config.cache_dir,
340
+ ttl=timedelta(minutes=config.cache_ttl_minutes),
341
+ )
342
+ cache.enabled = not _state.no_cache and config.cache_ttl_minutes > 0
343
+ return cache
344
+
345
+
346
+ def _apply_branch_override(proj: ProjectConfig) -> ProjectConfig:
347
+ # Uses Pydantic model_copy to create a modified config — all downstream code
348
+ # (providers, cache keys, views) reads repo.branch and gets the override automatically
349
+ if _state.branch:
350
+ repo = proj.repository.model_copy(update={"branch": _state.branch})
351
+ return proj.model_copy(update={"repository": repo})
352
+ return proj
353
+
354
+
355
+ def _since_days(config: AppConfig) -> int:
356
+ return _state.since_days if _state.since_days is not None else config.since_days
357
+
358
+
359
+ def _cache_key_commits(proj: ProjectConfig, since_days: int) -> str:
360
+ return (
361
+ f"commits:{proj.repository.provider.type}"
362
+ f":{proj.repository.url}:{proj.repository.branch}"
363
+ f":{since_days}"
364
+ )
365
+
366
+
367
+ def _fetch_commits(proj: ProjectConfig, cache: Cache, since_days: int) -> list[Commit]:
368
+ key = _cache_key_commits(proj, since_days)
369
+ cached = cache.get(key)
370
+ if cached is not None:
371
+ return [
372
+ Commit(
373
+ sha=c["sha"],
374
+ short_sha=c["short_sha"],
375
+ message=c["message"],
376
+ author=c["author"],
377
+ date=datetime.fromisoformat(c["date"]),
378
+ )
379
+ for c in cached
380
+ ]
381
+
382
+ try:
383
+ provider = get_provider(proj.repository.provider)
384
+ commits = provider.fetch_commits(proj.repository, since_days)
385
+ except (ProviderError, ToolNotFoundError) as e:
386
+ console.print(f"[red]Error fetching commits:[/red] {e}")
387
+ raise typer.Exit(1)
388
+
389
+ cache.set(
390
+ key,
391
+ [
392
+ {
393
+ "sha": c.sha,
394
+ "short_sha": c.short_sha,
395
+ "message": c.message,
396
+ "author": c.author,
397
+ "date": c.date.isoformat(),
398
+ }
399
+ for c in commits
400
+ ],
401
+ )
402
+ return commits
403
+
404
+
405
+ def _fetch_environments(
406
+ proj: ProjectConfig, cache: Cache
407
+ ) -> list[EnvironmentStatus]:
408
+ results: list[EnvironmentStatus] = []
409
+ for env_config in proj.environments:
410
+ key = f"env:{env_config.url}"
411
+ cached = cache.get(key)
412
+ if cached is not None:
413
+ results.append(
414
+ EnvironmentStatus(
415
+ name=cached["name"],
416
+ fields=cached["fields"],
417
+ error=cached["error"],
418
+ url=cached["url"],
419
+ )
420
+ )
421
+ continue
422
+
423
+ status = resolve_environment(env_config)
424
+ cache.set(
425
+ key,
426
+ {
427
+ "name": status.name,
428
+ "fields": status.fields,
429
+ "error": status.error,
430
+ "url": status.url,
431
+ },
432
+ )
433
+ results.append(status)
434
+
435
+ return results
436
+
437
+
438
+ def _fetch_missing_commits(
439
+ commit_list: list[Commit],
440
+ env_statuses: list[EnvironmentStatus],
441
+ proj: ProjectConfig,
442
+ cache: Cache,
443
+ ) -> None:
444
+ """Fetch individual commits for deployed SHAs not in the commit list.
445
+
446
+ This happens when a deployed version is older than since_days or on a
447
+ different branch. Fetched commits are marked with fetched=True and sorted
448
+ into the list by datetime.
449
+ """
450
+ for env in env_statuses:
451
+ if not env.version or any(c.sha_matches(env.version) for c in commit_list):
452
+ continue
453
+
454
+ key = f"commit:{env.version}"
455
+ cached = cache.get(key)
456
+ if cached is not None:
457
+ commit_list.append(
458
+ Commit(
459
+ sha=cached["sha"],
460
+ short_sha=cached["short_sha"],
461
+ message=cached["message"],
462
+ author=cached["author"],
463
+ date=datetime.fromisoformat(cached["date"]),
464
+ fetched=True,
465
+ )
466
+ )
467
+ continue
468
+
469
+ try:
470
+ provider = get_provider(proj.repository.provider)
471
+ commit = provider.fetch_commit(proj.repository, env.version)
472
+ except (ProviderError, ToolNotFoundError):
473
+ continue
474
+
475
+ cache.set(
476
+ key,
477
+ {
478
+ "sha": commit.sha,
479
+ "short_sha": commit.short_sha,
480
+ "message": commit.message,
481
+ "author": commit.author,
482
+ "date": commit.date.isoformat(),
483
+ },
484
+ )
485
+ commit_list.append(
486
+ Commit.from_raw(
487
+ sha=commit.sha,
488
+ message=commit.message,
489
+ author=commit.author,
490
+ date=commit.date,
491
+ fetched=True,
492
+ )
493
+ )
494
+
495
+ commit_list.sort(key=lambda c: c.date, reverse=True)