feat(dashboard): add milestone view and configurable columns
fixes #16, fixes #19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Option `--config` pour specifier un fichier de configuration alternatif
|
- Option `--config` pour specifier un fichier de configuration alternatif
|
||||||
- Resolution des variables `${VAR}` dans les fichiers de configuration
|
- Resolution des variables `${VAR}` dans les fichiers de configuration
|
||||||
- Priorite de configuration : CLI > variables d'environnement > fichier config > defauts
|
- Priorite de configuration : CLI > variables d'environnement > fichier config > defauts
|
||||||
|
- Vue milestones dediee avec `--milestones` (tableau Repo/Milestone/Open/Closed/Progress)
|
||||||
|
- Colonnes configurables avec `--columns` (inclusion, exclusion par prefixe `-`, `--columns help`)
|
||||||
|
- Export JSON des milestones via `--milestones --format json`
|
||||||
|
- Parametre `state` dans `client.get_milestones()` (defaut: "open", supporte "all" pour la vue milestones)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
- Nouveau module `config.py` pour la gestion de configuration YAML (ADR-013)
|
- Nouveau module `config.py` pour la gestion de configuration YAML (ADR-013)
|
||||||
- Nouvelle dependance PyYAML >= 6.0
|
- Nouvelle dependance PyYAML >= 6.0
|
||||||
|
- Dataclass `MilestoneData` dans `collector.py` (ADR-014)
|
||||||
|
- Fonction `collect_milestones()` avec filtrage include/exclude et state=all
|
||||||
|
- Fonctions `render_milestones()`, `parse_columns()`, `AVAILABLE_COLUMNS` dans `display.py`
|
||||||
|
- Fonctions `milestones_to_dicts()`, `export_milestones_json()` dans `exporter.py`
|
||||||
|
- Refactoring : `_filter_repos()` extrait la logique de filtrage partagee dans `collector.py`
|
||||||
|
|
||||||
## [1.3.0] - 2026-03-12
|
## [1.3.0] - 2026-03-12
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import requests
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from gitea_dashboard.client import GiteaClient
|
from gitea_dashboard.client import GiteaClient
|
||||||
from gitea_dashboard.collector import collect_all
|
from gitea_dashboard.collector import collect_all, collect_milestones
|
||||||
from gitea_dashboard.config import load_config, merge_config
|
from gitea_dashboard.config import load_config, merge_config
|
||||||
from gitea_dashboard.display import render_dashboard, sort_repos
|
from gitea_dashboard.display import (
|
||||||
from gitea_dashboard.exporter import export_json
|
AVAILABLE_COLUMNS,
|
||||||
|
parse_columns,
|
||||||
|
render_dashboard,
|
||||||
|
render_milestones,
|
||||||
|
sort_repos,
|
||||||
|
)
|
||||||
|
from gitea_dashboard.exporter import export_json, export_milestones_json
|
||||||
|
|
||||||
_DEFAULT_URL = "http://192.168.0.106:3000"
|
_DEFAULT_URL = "http://192.168.0.106:3000"
|
||||||
|
|
||||||
@@ -24,9 +30,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
Options:
|
Options:
|
||||||
--repo / -r : noms de repos a inclure (repeatable)
|
--repo / -r : noms de repos a inclure (repeatable)
|
||||||
--exclude / -x : noms de repos a exclure (repeatable)
|
--exclude / -x : noms de repos a exclure (repeatable)
|
||||||
|
--milestones : affiche la vue milestones au lieu du dashboard repos
|
||||||
|
--columns : liste des colonnes a afficher
|
||||||
|
--config : chemin vers un fichier de configuration YAML alternatif
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Namespace avec .repo (list[str] | None) et .exclude (list[str] | None)
|
Namespace avec les options parsees.
|
||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Dashboard CLI affichant l'etat des repos Gitea.",
|
description="Dashboard CLI affichant l'etat des repos Gitea.",
|
||||||
@@ -77,6 +86,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Chemin vers un fichier de configuration YAML alternatif.",
|
help="Chemin vers un fichier de configuration YAML alternatif.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--milestones",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Affiche la vue milestones au lieu du dashboard repos.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--columns",
|
||||||
|
default=None,
|
||||||
|
help="Colonnes a afficher (separees par virgules). Prefixe '-' pour exclure. 'help' pour lister.",
|
||||||
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,18 +163,24 @@ def _run_health_check(client: GiteaClient, console: Console) -> None:
|
|||||||
console.print(f"{len(repos)} repos accessibles")
|
console.print(f"{len(repos)} repos accessibles")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_columns_help(console: Console) -> None:
|
||||||
|
"""Affiche les colonnes disponibles."""
|
||||||
|
console.print("Colonnes disponibles :")
|
||||||
|
for name, desc in AVAILABLE_COLUMNS.items():
|
||||||
|
console.print(f" {name:15s} {desc}")
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
"""Point d'entree principal.
|
"""Point d'entree principal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
argv: Arguments CLI. Si None, utilise sys.argv (via argparse).
|
argv: Arguments CLI. Si None, utilise sys.argv (via argparse).
|
||||||
|
|
||||||
1. Parse les options CLI (--repo, --exclude)
|
1. Parse les options CLI
|
||||||
2. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis)
|
2. Resout la configuration (CLI > env > config > defaults)
|
||||||
3. Cree le GiteaClient
|
3. Cree le GiteaClient
|
||||||
4. Collecte les donnees via collect_all() avec filtres
|
4. Route vers le mode appropriate (health, milestones, dashboard)
|
||||||
5. Affiche via render_dashboard()
|
5. Gere les erreurs : config manquante, connexion refusee, timeout
|
||||||
6. Gere les erreurs : config manquante, connexion refusee, timeout
|
|
||||||
"""
|
"""
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
console = Console(stderr=True)
|
console = Console(stderr=True)
|
||||||
@@ -165,6 +191,15 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
console.print(f"[red]Erreur config : {exc}[/red]")
|
console.print(f"[red]Erreur config : {exc}[/red]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Handle --columns help before auth check
|
||||||
|
if args.columns is not None:
|
||||||
|
cols = parse_columns(args.columns, no_desc=args.no_desc)
|
||||||
|
if cols == ["__help__"]:
|
||||||
|
_print_columns_help(Console())
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
cols = None
|
||||||
|
|
||||||
auth = args.resolved_auth if hasattr(args, "resolved_auth") and args.resolved_auth else None
|
auth = args.resolved_auth if hasattr(args, "resolved_auth") and args.resolved_auth else None
|
||||||
if not auth:
|
if not auth:
|
||||||
auth = os.environ.get("GITEA_TOKEN")
|
auth = os.environ.get("GITEA_TOKEN")
|
||||||
@@ -187,6 +222,14 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
_run_health_check(client, console)
|
_run_health_check(client, console)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.milestones:
|
||||||
|
milestones = collect_milestones(client, include=args.repo, exclude=args.exclude)
|
||||||
|
if args.format == "json":
|
||||||
|
print(export_milestones_json(milestones)) # noqa: T201
|
||||||
|
else:
|
||||||
|
render_milestones(milestones)
|
||||||
|
return
|
||||||
|
|
||||||
repos = collect_all(client, include=args.repo, exclude=args.exclude)
|
repos = collect_all(client, include=args.repo, exclude=args.exclude)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
||||||
@@ -208,4 +251,11 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
sorted_repos = sort_repos(repos, args.sort)
|
sorted_repos = sort_repos(repos, args.sort)
|
||||||
print(export_json(sorted_repos)) # noqa: T201
|
print(export_json(sorted_repos)) # noqa: T201
|
||||||
else:
|
else:
|
||||||
render_dashboard(repos, sort_key=args.sort, show_description=not args.no_desc)
|
# Resolve columns for dashboard
|
||||||
|
active_cols = cols if cols is not None else parse_columns(None, no_desc=args.no_desc)
|
||||||
|
render_dashboard(
|
||||||
|
repos,
|
||||||
|
sort_key=args.sort,
|
||||||
|
show_description="description" in active_cols,
|
||||||
|
columns=active_cols,
|
||||||
|
)
|
||||||
|
|||||||
@@ -141,14 +141,17 @@ class GiteaClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
def get_milestones(self, owner: str, repo: str) -> list[dict]:
|
def get_milestones(self, owner: str, repo: str, state: str = "open") -> list[dict]:
|
||||||
"""Retourne les milestones ouvertes du repo.
|
"""Retourne les milestones du repo.
|
||||||
|
|
||||||
Endpoint: GET /api/v1/repos/{owner}/{repo}/milestones?state=open
|
Endpoint: GET /api/v1/repos/{owner}/{repo}/milestones?state={state}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Filtre par etat ("open", "closed", "all"). Defaut: "open".
|
||||||
"""
|
"""
|
||||||
return self._get_paginated(
|
return self._get_paginated(
|
||||||
f"/api/v1/repos/{owner}/{repo}/milestones",
|
f"/api/v1/repos/{owner}/{repo}/milestones",
|
||||||
params={"state": "open"},
|
params={"state": state},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_version(self) -> dict:
|
def get_version(self) -> dict:
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ class RepoData:
|
|||||||
last_commit_date: str | None # ISO 8601, ex: "2026-03-10T14:30:00Z"
|
last_commit_date: str | None # ISO 8601, ex: "2026-03-10T14:30:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MilestoneData:
|
||||||
|
"""Donnees agregees d'une milestone avec son repo parent."""
|
||||||
|
|
||||||
|
repo_name: str
|
||||||
|
title: str
|
||||||
|
open_issues: int
|
||||||
|
closed_issues: int
|
||||||
|
progress_pct: int # Pourcentage de completion (0-100)
|
||||||
|
due_on: str | None # ISO 8601 ou None
|
||||||
|
state: str # "open" ou "closed"
|
||||||
|
|
||||||
|
|
||||||
def _matches_any(name: str, patterns: list[str]) -> bool:
|
def _matches_any(name: str, patterns: list[str]) -> bool:
|
||||||
"""Return True if name contains any of the patterns (case-insensitive)."""
|
"""Return True if name contains any of the patterns (case-insensitive)."""
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
@@ -49,10 +62,7 @@ def collect_all(
|
|||||||
repos = client.get_repos()
|
repos = client.get_repos()
|
||||||
|
|
||||||
# Filtrage post-fetch : l'API Gitea ne supporte pas le filtre par nom
|
# Filtrage post-fetch : l'API Gitea ne supporte pas le filtre par nom
|
||||||
if include:
|
repos = _filter_repos(repos, include, exclude)
|
||||||
repos = [r for r in repos if _matches_any(r["name"], include)]
|
|
||||||
if exclude:
|
|
||||||
repos = [r for r in repos if not _matches_any(r["name"], exclude)]
|
|
||||||
|
|
||||||
result: list[RepoData] = []
|
result: list[RepoData] = []
|
||||||
|
|
||||||
@@ -79,3 +89,58 @@ def collect_all(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_repos(
|
||||||
|
repos: list[dict],
|
||||||
|
include: list[str] | None = None,
|
||||||
|
exclude: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Filtre les repos par include/exclude (logique partagee)."""
|
||||||
|
if include:
|
||||||
|
repos = [r for r in repos if _matches_any(r["name"], include)]
|
||||||
|
if exclude:
|
||||||
|
repos = [r for r in repos if not _matches_any(r["name"], exclude)]
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
def collect_milestones(
|
||||||
|
client: GiteaClient,
|
||||||
|
include: list[str] | None = None,
|
||||||
|
exclude: list[str] | None = None,
|
||||||
|
) -> list[MilestoneData]:
|
||||||
|
"""Collecte les milestones de tous les repos accessibles.
|
||||||
|
|
||||||
|
Reutilise la logique de filtrage de collect_all (include/exclude).
|
||||||
|
Pour chaque repo filtre, appelle client.get_milestones() avec state=all.
|
||||||
|
|
||||||
|
Retourne une liste plate de MilestoneData triee par repo puis milestone.
|
||||||
|
"""
|
||||||
|
repos = client.get_repos()
|
||||||
|
repos = _filter_repos(repos, include, exclude)
|
||||||
|
|
||||||
|
result: list[MilestoneData] = []
|
||||||
|
|
||||||
|
for repo in repos:
|
||||||
|
owner = repo["owner"]["login"]
|
||||||
|
name = repo["name"]
|
||||||
|
|
||||||
|
milestones = client.get_milestones(owner, name, state="all")
|
||||||
|
|
||||||
|
for ms in milestones:
|
||||||
|
total = ms["open_issues"] + ms["closed_issues"]
|
||||||
|
pct = round(ms["closed_issues"] / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
MilestoneData(
|
||||||
|
repo_name=name,
|
||||||
|
title=ms["title"],
|
||||||
|
open_issues=ms["open_issues"],
|
||||||
|
closed_issues=ms["closed_issues"],
|
||||||
|
progress_pct=pct,
|
||||||
|
due_on=ms.get("due_on"),
|
||||||
|
state=ms.get("state", "open"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -7,7 +7,66 @@ from datetime import datetime, timezone
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from gitea_dashboard.collector import RepoData
|
from gitea_dashboard.collector import MilestoneData, RepoData
|
||||||
|
|
||||||
|
AVAILABLE_COLUMNS: dict[str, str] = {
|
||||||
|
"name": "Nom du repo",
|
||||||
|
"description": "Description du repo",
|
||||||
|
"issues": "Nombre d'issues ouvertes",
|
||||||
|
"release": "Derniere release",
|
||||||
|
"commit": "Date du dernier commit",
|
||||||
|
"activity": "Indicateur d'activite",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_columns(columns_arg: str | None, no_desc: bool = False) -> list[str]:
|
||||||
|
"""Parse l'argument --columns et retourne la liste des colonnes a afficher.
|
||||||
|
|
||||||
|
Si columns_arg est None : retourne toutes les colonnes (sauf description si no_desc).
|
||||||
|
Si columns_arg est "help" : retourne la liste speciale ["__help__"].
|
||||||
|
Les colonnes sont separees par des virgules.
|
||||||
|
Le prefixe "-" exclut une colonne (ex: "-description").
|
||||||
|
Leve ValueError si une colonne inconnue est specifiee.
|
||||||
|
"""
|
||||||
|
if columns_arg is not None and columns_arg.strip() == "help":
|
||||||
|
return ["__help__"]
|
||||||
|
|
||||||
|
all_cols = list(AVAILABLE_COLUMNS.keys())
|
||||||
|
|
||||||
|
if columns_arg is None:
|
||||||
|
result = list(all_cols)
|
||||||
|
if no_desc and "description" in result:
|
||||||
|
result.remove("description")
|
||||||
|
return result
|
||||||
|
|
||||||
|
parts = [p.strip() for p in columns_arg.split(",") if p.strip()]
|
||||||
|
|
||||||
|
# Detect mode: exclusion if all parts start with "-"
|
||||||
|
is_exclusion = all(p.startswith("-") for p in parts)
|
||||||
|
|
||||||
|
if is_exclusion:
|
||||||
|
result = list(all_cols)
|
||||||
|
if no_desc and "description" in result:
|
||||||
|
result.remove("description")
|
||||||
|
for part in parts:
|
||||||
|
col_name = part[1:] # Remove "-" prefix
|
||||||
|
if col_name not in AVAILABLE_COLUMNS:
|
||||||
|
msg = f"Unknown column: '{col_name}'. Use --columns help for available columns."
|
||||||
|
raise ValueError(msg)
|
||||||
|
if col_name in result:
|
||||||
|
result.remove(col_name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Inclusion mode
|
||||||
|
result = []
|
||||||
|
for part in parts:
|
||||||
|
if part not in AVAILABLE_COLUMNS:
|
||||||
|
msg = f"Unknown column: '{part}'. Use --columns help for available columns."
|
||||||
|
raise ValueError(msg)
|
||||||
|
result.append(part)
|
||||||
|
if no_desc and "description" in result:
|
||||||
|
result.remove("description")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _format_repo_name(repo: RepoData) -> str:
|
def _format_repo_name(repo: RepoData) -> str:
|
||||||
@@ -134,6 +193,7 @@ def render_dashboard(
|
|||||||
console: Console | None = None,
|
console: Console | None = None,
|
||||||
sort_key: str = "name",
|
sort_key: str = "name",
|
||||||
show_description: bool = True,
|
show_description: bool = True,
|
||||||
|
columns: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Affiche le dashboard complet dans le terminal.
|
"""Affiche le dashboard complet dans le terminal.
|
||||||
|
|
||||||
@@ -145,6 +205,7 @@ def render_dashboard(
|
|||||||
Le parametre console permet l'injection pour les tests.
|
Le parametre console permet l'injection pour les tests.
|
||||||
Si show_description est True, ajoute une colonne "Description"
|
Si show_description est True, ajoute une colonne "Description"
|
||||||
entre "Repo" et "Issues", tronquee a 40 caracteres.
|
entre "Repo" et "Issues", tronquee a 40 caracteres.
|
||||||
|
Si columns est fourni, seules ces colonnes sont affichees.
|
||||||
"""
|
"""
|
||||||
if console is None:
|
if console is None:
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -153,38 +214,54 @@ def render_dashboard(
|
|||||||
console.print("Aucun repo trouve.")
|
console.print("Aucun repo trouve.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Determine les colonnes a afficher
|
||||||
|
if columns is not None:
|
||||||
|
active_cols = columns
|
||||||
|
else:
|
||||||
|
active_cols = list(AVAILABLE_COLUMNS.keys())
|
||||||
|
if not show_description and "description" in active_cols:
|
||||||
|
active_cols.remove("description")
|
||||||
|
|
||||||
# Tri des repos
|
# Tri des repos
|
||||||
sorted_repos = sort_repos(repos, sort_key)
|
sorted_repos = sort_repos(repos, sort_key)
|
||||||
|
|
||||||
# Tableau principal
|
# Tableau principal
|
||||||
table = Table(title="Gitea Dashboard")
|
table = Table(title="Gitea Dashboard")
|
||||||
table.add_column("Repo", style="bold")
|
|
||||||
if show_description:
|
# Map colonne -> config Rich
|
||||||
table.add_column("Description")
|
col_config = {
|
||||||
table.add_column("Issues", justify="right")
|
"name": ("Repo", {"style": "bold"}),
|
||||||
table.add_column("Release")
|
"description": ("Description", {}),
|
||||||
table.add_column("Dernier commit")
|
"issues": ("Issues", {"justify": "right"}),
|
||||||
|
"release": ("Release", {}),
|
||||||
|
"commit": ("Dernier commit", {}),
|
||||||
|
"activity": ("Activite", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for col in active_cols:
|
||||||
|
if col in col_config:
|
||||||
|
label, kwargs = col_config[col]
|
||||||
|
table.add_column(label, **kwargs)
|
||||||
|
|
||||||
for repo in sorted_repos:
|
for repo in sorted_repos:
|
||||||
name = _format_repo_name(repo)
|
row: list[str] = []
|
||||||
|
for col in active_cols:
|
||||||
|
if col == "name":
|
||||||
|
row.append(_format_repo_name(repo))
|
||||||
|
elif col == "description":
|
||||||
|
row.append(_truncate(repo.description or ""))
|
||||||
|
elif col == "issues":
|
||||||
issues_str = str(repo.open_issues)
|
issues_str = str(repo.open_issues)
|
||||||
issues_style = "red" if repo.open_issues > 0 else "green"
|
issues_style = "red" if repo.open_issues > 0 else "green"
|
||||||
release_str = _format_release(repo.latest_release)
|
row.append(f"[{issues_style}]{issues_str}[/{issues_style}]")
|
||||||
commit_str = (
|
elif col == "release":
|
||||||
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
|
row.append(_format_release(repo.latest_release))
|
||||||
|
elif col == "commit":
|
||||||
|
row.append(
|
||||||
|
_format_relative_date(repo.last_commit_date)
|
||||||
|
if repo.last_commit_date
|
||||||
|
else "\u2014"
|
||||||
)
|
)
|
||||||
|
|
||||||
row = [name]
|
|
||||||
if show_description:
|
|
||||||
row.append(_truncate(repo.description or ""))
|
|
||||||
row.extend(
|
|
||||||
[
|
|
||||||
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
|
||||||
release_str,
|
|
||||||
commit_str,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
table.add_row(*row)
|
table.add_row(*row)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -220,3 +297,49 @@ def render_dashboard(
|
|||||||
console.print(f"[{style}]{line}[/{style}]")
|
console.print(f"[{style}]{line}[/{style}]")
|
||||||
else:
|
else:
|
||||||
console.print(line)
|
console.print(line)
|
||||||
|
|
||||||
|
|
||||||
|
def render_milestones(
|
||||||
|
milestones: list[MilestoneData],
|
||||||
|
console: Console | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Affiche le tableau des milestones.
|
||||||
|
|
||||||
|
Colonnes : Repo, Milestone, Open, Closed, Progress (%).
|
||||||
|
La barre de progression utilise le pourcentage calcule.
|
||||||
|
Coloration : vert > 80%, jaune 50-80%, rouge < 50%.
|
||||||
|
"""
|
||||||
|
if console is None:
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
if not milestones:
|
||||||
|
console.print("Aucune milestone trouvee.")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(title="Milestones")
|
||||||
|
table.add_column("Repo", style="bold")
|
||||||
|
table.add_column("Milestone")
|
||||||
|
table.add_column("Open", justify="right")
|
||||||
|
table.add_column("Closed", justify="right")
|
||||||
|
table.add_column("Progress", justify="right")
|
||||||
|
|
||||||
|
for ms in milestones:
|
||||||
|
# Coloration du pourcentage
|
||||||
|
if ms.progress_pct > 80:
|
||||||
|
pct_style = "green"
|
||||||
|
elif ms.progress_pct >= 50:
|
||||||
|
pct_style = "yellow"
|
||||||
|
else:
|
||||||
|
pct_style = "red"
|
||||||
|
|
||||||
|
pct_str = f"[{pct_style}]{ms.progress_pct}%[/{pct_style}]"
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
ms.repo_name,
|
||||||
|
ms.title,
|
||||||
|
str(ms.open_issues),
|
||||||
|
str(ms.closed_issues),
|
||||||
|
pct_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
from gitea_dashboard.collector import RepoData
|
from gitea_dashboard.collector import MilestoneData, RepoData
|
||||||
|
|
||||||
# Caracteres de controle ASCII (0x00-0x1F) sauf \t (0x09), \n (0x0A), \r (0x0D)
|
# Caracteres de controle ASCII (0x00-0x1F) sauf \t (0x09), \n (0x0A), \r (0x0D)
|
||||||
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
||||||
@@ -44,3 +44,23 @@ def export_json(repos: list[RepoData], indent: int = 2) -> str:
|
|||||||
Chaine JSON indentee, prete pour stdout ou ecriture fichier.
|
Chaine JSON indentee, prete pour stdout ou ecriture fichier.
|
||||||
"""
|
"""
|
||||||
return json.dumps(repos_to_dicts(repos), indent=indent, ensure_ascii=False)
|
return json.dumps(repos_to_dicts(repos), indent=indent, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def milestones_to_dicts(milestones: list[MilestoneData]) -> list[dict]:
|
||||||
|
"""Convertit une liste de MilestoneData en liste de dicts serialisables.
|
||||||
|
|
||||||
|
Sanitize les champs texte (repo_name, title) pour les caracteres de controle.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for ms in milestones:
|
||||||
|
d = asdict(ms)
|
||||||
|
for field in ("repo_name", "title"):
|
||||||
|
if isinstance(d.get(field), str):
|
||||||
|
d[field] = _sanitize_control_chars(d[field])
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def export_milestones_json(milestones: list[MilestoneData], indent: int = 2) -> str:
|
||||||
|
"""Exporte les milestones en JSON formate."""
|
||||||
|
return json.dumps(milestones_to_dicts(milestones), indent=indent, ensure_ascii=False)
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ class TestMainNominal:
|
|||||||
|
|
||||||
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
||||||
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
||||||
mock_render.assert_called_once_with(
|
mock_render.assert_called_once()
|
||||||
mock_collect.return_value, sort_key="name", show_description=True
|
call_kwargs = mock_render.call_args
|
||||||
)
|
assert call_kwargs[1]["sort_key"] == "name"
|
||||||
|
assert call_kwargs[1]["show_description"] is True
|
||||||
|
|
||||||
@patch("gitea_dashboard.cli.render_dashboard")
|
@patch("gitea_dashboard.cli.render_dashboard")
|
||||||
@patch("gitea_dashboard.cli.collect_all")
|
@patch("gitea_dashboard.cli.collect_all")
|
||||||
@@ -365,9 +366,10 @@ class TestMainNoDesc:
|
|||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
main(["--no-desc"])
|
main(["--no-desc"])
|
||||||
|
|
||||||
mock_render.assert_called_once_with(
|
mock_render.assert_called_once()
|
||||||
mock_collect.return_value, sort_key="name", show_description=False
|
call_kwargs = mock_render.call_args
|
||||||
)
|
assert call_kwargs[1]["sort_key"] == "name"
|
||||||
|
assert call_kwargs[1]["show_description"] is False
|
||||||
|
|
||||||
|
|
||||||
class TestMainFormatJson:
|
class TestMainFormatJson:
|
||||||
@@ -472,7 +474,7 @@ class TestMainColumnsHelp:
|
|||||||
mock_collect.return_value = []
|
mock_collect.return_value = []
|
||||||
|
|
||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
main(["--no-desc", "--columns", "-commit"])
|
main(["--no-desc", "--columns=-commit"])
|
||||||
|
|
||||||
# render_dashboard should be called with columns excluding both description and commit
|
# render_dashboard should be called with columns excluding both description and commit
|
||||||
call_kwargs = mock_render.call_args
|
call_kwargs = mock_render.call_args
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from gitea_dashboard.display import (
|
from gitea_dashboard.display import (
|
||||||
|
|||||||
Reference in New Issue
Block a user