feat(v1.2.0): retry API, dernier commit, tri, coloration, export JSON
- client.py: _get_with_retry (max 2 retries, backoff lineaire), get_latest_commit - collector.py: champ last_commit_date dans RepoData - display.py: colonne "Dernier commit", _sort_repos (name/issues/release/activity), _colorize_milestone_due (rouge/jaune/vert selon echeance) - cli.py: options --sort/-s et --format/-f (table/json) - exporter.py: nouveau module, repos_to_dicts + export_json - 88 tests (35 nouveaux), ruff clean fixes #8, fixes #7, fixes #10, fixes #9, fixes #6 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,21 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Exclure les repos par nom (sous-chaine, insensible a la casse). Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sort",
|
||||
"-s",
|
||||
choices=["name", "issues", "release", "activity"],
|
||||
default="name",
|
||||
help="Critere de tri des repos (defaut: name).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
"-f",
|
||||
choices=["table", "json"],
|
||||
default="table",
|
||||
dest="format",
|
||||
help="Format de sortie (defaut: table).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -91,4 +106,9 @@ def main(argv: list[str] | None = None) -> None:
|
||||
console.print(f"[red]Erreur API : {msg}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
render_dashboard(repos)
|
||||
if args.format == "json":
|
||||
from gitea_dashboard.exporter import export_json
|
||||
|
||||
print(export_json(repos)) # noqa: T201
|
||||
else:
|
||||
render_dashboard(repos, sort_key=args.sort)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -10,9 +12,12 @@ class GiteaClient:
|
||||
|
||||
Utilise requests.Session pour reutiliser les connexions HTTP.
|
||||
Authentification via header Authorization: token <TOKEN>.
|
||||
Retry automatique sur timeout (max 2 retries, backoff lineaire).
|
||||
"""
|
||||
|
||||
_PAGE_LIMIT = 50
|
||||
_MAX_RETRIES = 2
|
||||
_RETRY_DELAY = 1.0 # secondes
|
||||
|
||||
def __init__(self, base_url: str, token: str, timeout: int = 30) -> None:
|
||||
"""Initialise le client avec l'URL de base et le token API.
|
||||
@@ -27,10 +32,27 @@ class GiteaClient:
|
||||
self.session = requests.Session()
|
||||
self.session.headers["Authorization"] = f"token {token}"
|
||||
|
||||
def _get_with_retry(self, url: str, params: dict | None = None) -> requests.Response:
|
||||
"""GET avec retry automatique sur timeout.
|
||||
|
||||
Retente jusqu'a _MAX_RETRIES fois avec backoff lineaire (1s, 2s).
|
||||
Leve requests.Timeout apres epuisement des retries.
|
||||
"""
|
||||
last_exc: requests.Timeout | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
return self.session.get(url, params=params, timeout=self.timeout)
|
||||
except requests.Timeout as exc:
|
||||
last_exc = exc
|
||||
if attempt < self._MAX_RETRIES:
|
||||
time.sleep(self._RETRY_DELAY * (attempt + 1))
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
|
||||
"""Requete GET avec pagination automatique.
|
||||
|
||||
Boucle tant que len(page) == limit (50).
|
||||
Utilise _get_with_retry pour la resilience aux timeouts.
|
||||
"""
|
||||
all_items: list[dict] = []
|
||||
page = 1
|
||||
@@ -40,7 +62,7 @@ class GiteaClient:
|
||||
merged_params["limit"] = self._PAGE_LIMIT
|
||||
merged_params["page"] = page
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
resp = self.session.get(url, params=merged_params, timeout=self.timeout)
|
||||
resp = self._get_with_retry(url, params=merged_params)
|
||||
resp.raise_for_status()
|
||||
items = resp.json()
|
||||
all_items.extend(items)
|
||||
@@ -62,9 +84,10 @@ class GiteaClient:
|
||||
|
||||
Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest
|
||||
Gere HTTP 404 en retournant None.
|
||||
Utilise _get_with_retry pour la resilience aux timeouts.
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/releases/latest"
|
||||
resp = self.session.get(url, timeout=self.timeout)
|
||||
resp = self._get_with_retry(url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
@@ -79,3 +102,19 @@ class GiteaClient:
|
||||
f"/api/v1/repos/{owner}/{repo}/milestones",
|
||||
params={"state": "open"},
|
||||
)
|
||||
|
||||
def get_latest_commit(self, owner: str, repo: str) -> dict | None:
|
||||
"""Retourne le dernier commit du repo, ou None si aucun.
|
||||
|
||||
Endpoint: GET /api/v1/repos/{owner}/{repo}/commits?limit=1
|
||||
Retourne le premier element de la liste, ou None si vide ou 404.
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/commits"
|
||||
resp = self._get_with_retry(url, params={"limit": 1})
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
commits = resp.json()
|
||||
if not commits:
|
||||
return None
|
||||
return commits[0]
|
||||
|
||||
@@ -20,6 +20,7 @@ class RepoData:
|
||||
is_mirror: bool
|
||||
latest_release: dict | None # {tag_name, published_at} ou None
|
||||
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
|
||||
last_commit_date: str | None # ISO 8601, ex: "2026-03-10T14:30:00Z"
|
||||
|
||||
|
||||
def _matches_any(name: str, patterns: list[str]) -> bool:
|
||||
@@ -59,6 +60,9 @@ def collect_all(
|
||||
owner = repo["owner"]["login"]
|
||||
name = repo["name"]
|
||||
|
||||
commit = client.get_latest_commit(owner, name)
|
||||
last_commit_date = commit["created"] if commit else None
|
||||
|
||||
result.append(
|
||||
RepoData(
|
||||
name=name,
|
||||
@@ -70,6 +74,7 @@ def collect_all(
|
||||
is_mirror=repo["mirror"],
|
||||
latest_release=client.get_latest_release(owner, name),
|
||||
milestones=client.get_milestones(owner, name),
|
||||
last_commit_date=last_commit_date,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +67,66 @@ def _format_release(release: dict | None) -> str:
|
||||
return tag
|
||||
|
||||
|
||||
def render_dashboard(repos: list[RepoData], console: Console | None = None) -> None:
|
||||
def _colorize_milestone_due(due_on: str | None) -> str:
|
||||
"""Retourne le style Rich selon la proximite de l'echeance.
|
||||
|
||||
- Rouge : echeance depassee
|
||||
- Jaune : echeance dans les 7 prochains jours
|
||||
- Vert : echeance dans plus de 7 jours
|
||||
- Chaine vide : pas d'echeance definie
|
||||
"""
|
||||
if not due_on:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(due_on.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return ""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = dt - now
|
||||
days = delta.days
|
||||
|
||||
if days < 0:
|
||||
return "red"
|
||||
if days < 7:
|
||||
return "yellow"
|
||||
return "green"
|
||||
|
||||
|
||||
def _sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]:
|
||||
"""Trie la liste des repos selon le critere donne.
|
||||
|
||||
Args:
|
||||
repos: Liste des repos a trier.
|
||||
sort_key: Critere de tri parmi :
|
||||
- "name" : alphabetique par nom (defaut)
|
||||
- "issues" : par nombre d'issues ouvertes (decroissant)
|
||||
- "release" : par date de derniere release (plus recent d'abord)
|
||||
- "activity" : par date du dernier commit (plus recent d'abord)
|
||||
"""
|
||||
if sort_key == "name":
|
||||
return sorted(repos, key=lambda r: r.name.lower())
|
||||
if sort_key == "issues":
|
||||
return sorted(repos, key=lambda r: r.open_issues, reverse=True)
|
||||
if sort_key == "release":
|
||||
# Repos sans release en dernier (date vide = epoch 0)
|
||||
def release_date(r: RepoData) -> str:
|
||||
if r.latest_release and r.latest_release.get("published_at"):
|
||||
return r.latest_release["published_at"]
|
||||
return ""
|
||||
|
||||
return sorted(repos, key=release_date, reverse=True)
|
||||
if sort_key == "activity":
|
||||
# Repos sans commit en dernier (date vide = epoch 0)
|
||||
return sorted(repos, key=lambda r: r.last_commit_date or "", reverse=True)
|
||||
return repos
|
||||
|
||||
|
||||
def render_dashboard(
|
||||
repos: list[RepoData],
|
||||
console: Console | None = None,
|
||||
sort_key: str = "name",
|
||||
) -> None:
|
||||
"""Affiche le dashboard complet dans le terminal.
|
||||
|
||||
- Tableau principal : nom repo, indicateurs (fork/archive/mirror),
|
||||
@@ -84,19 +143,31 @@ def render_dashboard(repos: list[RepoData], console: Console | None = None) -> N
|
||||
console.print("Aucun repo trouve.")
|
||||
return
|
||||
|
||||
# Tri des repos
|
||||
sorted_repos = _sort_repos(repos, sort_key)
|
||||
|
||||
# Tableau principal
|
||||
table = Table(title="Gitea Dashboard")
|
||||
table.add_column("Repo", style="bold")
|
||||
table.add_column("Issues", justify="right")
|
||||
table.add_column("Release")
|
||||
table.add_column("Dernier commit")
|
||||
|
||||
for repo in repos:
|
||||
for repo in sorted_repos:
|
||||
name = _format_repo_name(repo)
|
||||
issues_str = str(repo.open_issues)
|
||||
issues_style = "red" if repo.open_issues > 0 else "green"
|
||||
release_str = _format_release(repo.latest_release)
|
||||
commit_str = (
|
||||
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
|
||||
)
|
||||
|
||||
table.add_row(name, f"[{issues_style}]{issues_str}[/{issues_style}]", release_str)
|
||||
table.add_row(
|
||||
name,
|
||||
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
||||
release_str,
|
||||
commit_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
@@ -125,4 +196,9 @@ def render_dashboard(repos: list[RepoData], console: Console | None = None) -> N
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
console.print(line)
|
||||
# Coloration selon la proximite de l'echeance
|
||||
style = _colorize_milestone_due(due_on)
|
||||
if style:
|
||||
console.print(f"[{style}]{line}[/{style}]")
|
||||
else:
|
||||
console.print(line)
|
||||
|
||||
26
src/gitea_dashboard/exporter.py
Normal file
26
src/gitea_dashboard/exporter.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Export des donnees du dashboard en formats structures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
from gitea_dashboard.collector import RepoData
|
||||
|
||||
|
||||
def repos_to_dicts(repos: list[RepoData]) -> list[dict]:
|
||||
"""Convertit une liste de RepoData en liste de dicts serialisables.
|
||||
|
||||
Chaque dict contient toutes les donnees du RepoData,
|
||||
pret pour json.dumps().
|
||||
"""
|
||||
return [asdict(repo) for repo in repos]
|
||||
|
||||
|
||||
def export_json(repos: list[RepoData], indent: int = 2) -> str:
|
||||
"""Exporte les repos en JSON formate.
|
||||
|
||||
Returns:
|
||||
Chaine JSON indentee, prete pour stdout ou ecriture fichier.
|
||||
"""
|
||||
return json.dumps(repos_to_dicts(repos), indent=indent, ensure_ascii=False)
|
||||
Reference in New Issue
Block a user