diff --git a/CHANGELOG.md b/CHANGELOG.md index 066b15e..60542a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Option `--config` pour specifier un fichier de configuration alternatif - Resolution des variables `${VAR}` dans les fichiers de configuration - 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 @@ -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) - 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 diff --git a/src/gitea_dashboard/cli.py b/src/gitea_dashboard/cli.py index 474fe7f..f5a775b 100644 --- a/src/gitea_dashboard/cli.py +++ b/src/gitea_dashboard/cli.py @@ -10,10 +10,16 @@ import requests from rich.console import Console 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.display import render_dashboard, sort_repos -from gitea_dashboard.exporter import export_json +from gitea_dashboard.display import ( + 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" @@ -24,9 +30,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: Options: --repo / -r : noms de repos a inclure (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: - Namespace avec .repo (list[str] | None) et .exclude (list[str] | None) + Namespace avec les options parsees. """ parser = argparse.ArgumentParser( 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, 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) @@ -143,18 +163,24 @@ def _run_health_check(client: GiteaClient, console: Console) -> None: 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: """Point d'entree principal. Args: argv: Arguments CLI. Si None, utilise sys.argv (via argparse). - 1. Parse les options CLI (--repo, --exclude) - 2. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis) + 1. Parse les options CLI + 2. Resout la configuration (CLI > env > config > defaults) 3. Cree le GiteaClient - 4. Collecte les donnees via collect_all() avec filtres - 5. Affiche via render_dashboard() - 6. Gere les erreurs : config manquante, connexion refusee, timeout + 4. Route vers le mode appropriate (health, milestones, dashboard) + 5. Gere les erreurs : config manquante, connexion refusee, timeout """ args = parse_args(argv) console = Console(stderr=True) @@ -165,6 +191,15 @@ def main(argv: list[str] | None = None) -> None: console.print(f"[red]Erreur config : {exc}[/red]") 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 if not auth: auth = os.environ.get("GITEA_TOKEN") @@ -187,6 +222,14 @@ def main(argv: list[str] | None = None) -> None: _run_health_check(client, console) 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) except requests.ConnectionError: 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) print(export_json(sorted_repos)) # noqa: T201 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, + ) diff --git a/src/gitea_dashboard/client.py b/src/gitea_dashboard/client.py index 6fd7aa0..19f17cf 100644 --- a/src/gitea_dashboard/client.py +++ b/src/gitea_dashboard/client.py @@ -141,14 +141,17 @@ class GiteaClient: resp.raise_for_status() return resp.json() - def get_milestones(self, owner: str, repo: str) -> list[dict]: - """Retourne les milestones ouvertes du repo. + def get_milestones(self, owner: str, repo: str, state: str = "open") -> list[dict]: + """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( f"/api/v1/repos/{owner}/{repo}/milestones", - params={"state": "open"}, + params={"state": state}, ) def get_version(self) -> dict: diff --git a/src/gitea_dashboard/collector.py b/src/gitea_dashboard/collector.py index 0ee6da7..c6733f8 100644 --- a/src/gitea_dashboard/collector.py +++ b/src/gitea_dashboard/collector.py @@ -23,6 +23,19 @@ class RepoData: 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: """Return True if name contains any of the patterns (case-insensitive).""" name_lower = name.lower() @@ -49,10 +62,7 @@ def collect_all( repos = client.get_repos() # Filtrage post-fetch : l'API Gitea ne supporte pas le filtre par nom - 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)] + repos = _filter_repos(repos, include, exclude) result: list[RepoData] = [] @@ -79,3 +89,58 @@ def collect_all( ) 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 diff --git a/src/gitea_dashboard/display.py b/src/gitea_dashboard/display.py index 057350e..2df2795 100644 --- a/src/gitea_dashboard/display.py +++ b/src/gitea_dashboard/display.py @@ -7,7 +7,66 @@ from datetime import datetime, timezone from rich.console import Console 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: @@ -134,6 +193,7 @@ def render_dashboard( console: Console | None = None, sort_key: str = "name", show_description: bool = True, + columns: list[str] | None = None, ) -> None: """Affiche le dashboard complet dans le terminal. @@ -145,6 +205,7 @@ def render_dashboard( Le parametre console permet l'injection pour les tests. Si show_description est True, ajoute une colonne "Description" entre "Repo" et "Issues", tronquee a 40 caracteres. + Si columns est fourni, seules ces colonnes sont affichees. """ if console is None: console = Console() @@ -153,38 +214,54 @@ def render_dashboard( console.print("Aucun repo trouve.") 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 sorted_repos = sort_repos(repos, sort_key) # Tableau principal table = Table(title="Gitea Dashboard") - table.add_column("Repo", style="bold") - if show_description: - table.add_column("Description") - table.add_column("Issues", justify="right") - table.add_column("Release") - table.add_column("Dernier commit") + + # Map colonne -> config Rich + col_config = { + "name": ("Repo", {"style": "bold"}), + "description": ("Description", {}), + "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: - 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" - ) - - row = [name] - if show_description: - row.append(_truncate(repo.description or "")) - row.extend( - [ - f"[{issues_style}]{issues_str}[/{issues_style}]", - release_str, - commit_str, - ] - ) - + 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_style = "red" if repo.open_issues > 0 else "green" + row.append(f"[{issues_style}]{issues_str}[/{issues_style}]") + elif col == "release": + 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" + ) table.add_row(*row) console.print(table) @@ -220,3 +297,49 @@ def render_dashboard( console.print(f"[{style}]{line}[/{style}]") else: 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) diff --git a/src/gitea_dashboard/exporter.py b/src/gitea_dashboard/exporter.py index abdf025..a7bf0b5 100644 --- a/src/gitea_dashboard/exporter.py +++ b/src/gitea_dashboard/exporter.py @@ -6,7 +6,7 @@ import json import re 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) _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. """ 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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 40b294f..7ad5f6e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,9 +26,10 @@ class TestMainNominal: 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_render.assert_called_once_with( - mock_collect.return_value, sort_key="name", show_description=True - ) + mock_render.assert_called_once() + 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.collect_all") @@ -365,9 +366,10 @@ class TestMainNoDesc: with patch.dict("os.environ", env, clear=True): main(["--no-desc"]) - mock_render.assert_called_once_with( - mock_collect.return_value, sort_key="name", show_description=False - ) + mock_render.assert_called_once() + call_kwargs = mock_render.call_args + assert call_kwargs[1]["sort_key"] == "name" + assert call_kwargs[1]["show_description"] is False class TestMainFormatJson: @@ -472,7 +474,7 @@ class TestMainColumnsHelp: mock_collect.return_value = [] 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 call_kwargs = mock_render.call_args diff --git a/tests/test_display.py b/tests/test_display.py index 9f5f553..091545c 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -2,6 +2,7 @@ from io import StringIO +import pytest from rich.console import Console from gitea_dashboard.display import (