From 8fbdfcafd459f656fba3d53d409b16460019b3d8 Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 10 Mar 2026 18:54:25 +0100 Subject: [PATCH] feat(display): add Rich dashboard rendering (fixes #3) Render repos in a Rich table with [F]ork/[A]rchive/[M]irror indicators, color-coded issue counts, relative release dates, and a milestones section. Handles empty repo lists gracefully. Co-Authored-By: Claude Opus 4.6 --- src/gitea_dashboard/display.py | 128 +++++++++++++++++++ tests/test_display.py | 216 +++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/gitea_dashboard/display.py create mode 100644 tests/test_display.py diff --git a/src/gitea_dashboard/display.py b/src/gitea_dashboard/display.py new file mode 100644 index 0000000..66a751e --- /dev/null +++ b/src/gitea_dashboard/display.py @@ -0,0 +1,128 @@ +"""Formatage et affichage Rich du dashboard Gitea.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from rich.console import Console +from rich.table import Table + +from gitea_dashboard.collector import RepoData + + +def _format_repo_name(repo: RepoData) -> str: + """Formate le nom du repo avec les indicateurs visuels.""" + indicators = [] + if repo.is_fork: + indicators.append("[F]") + if repo.is_archived: + indicators.append("[A]") + if repo.is_mirror: + indicators.append("[M]") + + if indicators: + return f"{repo.name} {' '.join(indicators)}" + return repo.name + + +def _format_relative_date(iso_date: str) -> str: + """Convertit une date ISO en date relative lisible.""" + try: + dt = datetime.fromisoformat(iso_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return "" + + now = datetime.now(timezone.utc) + delta = now - dt + + days = delta.days + if days < 0: + return "dans le futur" + if days == 0: + return "aujourd'hui" + if days == 1: + return "il y a 1j" + if days < 30: + return f"il y a {days}j" + months = days // 30 + if months < 12: + return f"il y a {months}m" + years = days // 365 + return f"il y a {years}a" + + +def _format_release(release: dict | None) -> str: + """Formate la release pour l'affichage.""" + if release is None: + return "\u2014" + + tag = release.get("tag_name", "") + published = release.get("published_at", "") + + if published: + relative = _format_relative_date(published) + if relative: + return f"{tag} ({relative})" + + return tag + + +def render_dashboard(repos: list[RepoData], console: Console | None = None) -> None: + """Affiche le dashboard complet dans le terminal. + + - Tableau principal : nom repo, indicateurs (fork/archive/mirror), + issues ouvertes, derniere release (tag + date relative) + - Section milestones : par repo ayant des milestones, + nom, progression (closed/total), date echeance + + Le parametre console permet l'injection pour les tests. + """ + if console is None: + console = Console() + + if not repos: + console.print("Aucun repo trouve.") + return + + # Tableau principal + table = Table(title="Gitea Dashboard") + table.add_column("Repo", style="bold") + table.add_column("Issues", justify="right") + table.add_column("Release") + + for repo in 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) + + table.add_row(name, f"[{issues_style}]{issues_str}[/{issues_style}]", release_str) + + console.print(table) + + # Section milestones — uniquement si au moins un repo en a + repos_with_milestones = [r for r in repos if r.milestones] + + if repos_with_milestones: + console.print() + console.print("[bold]Milestones[/bold]") + + for repo in repos_with_milestones: + for ms in repo.milestones: + title = ms["title"] + closed = ms["closed_issues"] + total = ms["open_issues"] + ms["closed_issues"] + pct = round(closed / total * 100) if total > 0 else 0 + + line = f" {repo.name} / {title} : {closed}/{total} ({pct}%)" + + due_on = ms.get("due_on") + if due_on: + # Extraire juste la date (YYYY-MM-DD) + try: + dt = datetime.fromisoformat(due_on.replace("Z", "+00:00")) + line += f" \u2014 echeance {dt.strftime('%Y-%m-%d')}" + except (ValueError, AttributeError): + pass + + console.print(line) diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..09ed440 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,216 @@ +"""Tests for Rich dashboard display.""" + +from io import StringIO + +from rich.console import Console + +from gitea_dashboard.collector import RepoData +from gitea_dashboard.display import render_dashboard + + +def _make_console(): + """Create a console that captures output for testing. + + highlight=False desactive le highlighting automatique de Rich + qui fragmente les nombres et noms avec des codes ANSI. + """ + buf = StringIO() + return Console(file=buf, force_terminal=True, width=120, highlight=False), buf + + +def _make_repo( + name="my-repo", + full_name="admin/my-repo", + description="A repo", + open_issues=3, + is_fork=False, + is_archived=False, + is_mirror=False, + latest_release=None, + milestones=None, +): + """Build a RepoData for testing.""" + return RepoData( + name=name, + full_name=full_name, + description=description, + open_issues=open_issues, + is_fork=is_fork, + is_archived=is_archived, + is_mirror=is_mirror, + latest_release=latest_release, + milestones=milestones if milestones is not None else [], + ) + + +class TestRenderDashboardTable: + """Test the main repos table rendering.""" + + def test_basic_repo_displayed(self): + """Repo name and issues count appear in output.""" + console, buf = _make_console() + repos = [ + _make_repo( + name="mon-projet", + open_issues=3, + latest_release={"tag_name": "v1.2.0", "published_at": "2026-03-08T10:00:00Z"}, + ), + ] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "mon-projet" in output + assert "v1.2.0" in output + + def test_repo_without_release_shows_dash(self): + """Repo with no release shows a dash character.""" + console, buf = _make_console() + repos = [_make_repo(name="no-release", latest_release=None)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "no-release" in output + # The dash character for "no release" + assert "\u2014" in output or "—" in output + + def test_fork_indicator(self): + """Fork repos show [F] indicator.""" + console, buf = _make_console() + repos = [_make_repo(name="forked-repo", is_fork=True)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "forked-repo" in output + assert "[F]" in output + + def test_archive_indicator(self): + """Archived repos show [A] indicator.""" + console, buf = _make_console() + repos = [_make_repo(name="old-repo", is_archived=True)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "[A]" in output + + def test_mirror_indicator(self): + """Mirror repos show [M] indicator.""" + console, buf = _make_console() + repos = [_make_repo(name="mirror-repo", is_mirror=True)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "[M]" in output + + def test_multiple_indicators(self): + """Repo with multiple flags shows all indicators.""" + console, buf = _make_console() + repos = [_make_repo(name="special", is_fork=True, is_archived=True)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "[F]" in output + assert "[A]" in output + + def test_issues_zero(self): + """Repos with 0 issues display 0.""" + console, buf = _make_console() + repos = [_make_repo(name="clean-repo", open_issues=0)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "0" in output + + def test_multiple_repos(self): + """Multiple repos all appear in the output.""" + console, buf = _make_console() + repos = [ + _make_repo(name="repo-alpha", open_issues=1), + _make_repo(name="repo-beta", open_issues=5), + ] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "repo-alpha" in output + assert "repo-beta" in output + + +class TestRenderDashboardMilestones: + """Test the milestones section rendering.""" + + def test_milestones_displayed(self): + """Repos with milestones show milestone info.""" + console, buf = _make_console() + repos = [ + _make_repo( + name="mon-projet", + milestones=[ + { + "title": "v2.0", + "open_issues": 2, + "closed_issues": 3, + "due_on": "2026-04-01T00:00:00Z", + }, + ], + ), + ] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "v2.0" in output + assert "3/5" in output # closed/total + assert "60%" in output + + def test_milestone_without_due_date(self): + """Milestone without due_on omits the deadline.""" + console, buf = _make_console() + repos = [ + _make_repo( + name="projet", + milestones=[ + { + "title": "backlog", + "open_issues": 5, + "closed_issues": 1, + "due_on": None, + }, + ], + ), + ] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "backlog" in output + assert "1/6" in output + + def test_no_milestones_section_when_none(self): + """When no repos have milestones, milestone section is absent.""" + console, buf = _make_console() + repos = [_make_repo(name="simple", milestones=[])] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "Milestones" not in output + + +class TestRenderDashboardEmpty: + """Test empty repo list handling.""" + + def test_empty_list_shows_message(self): + """Empty repo list shows informative message.""" + console, buf = _make_console() + + render_dashboard([], console=console) + output = buf.getvalue() + + assert "Aucun repo" in output