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 <noreply@anthropic.com>
This commit is contained in:
128
src/gitea_dashboard/display.py
Normal file
128
src/gitea_dashboard/display.py
Normal file
@@ -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)
|
||||
216
tests/test_display.py
Normal file
216
tests/test_display.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user