feat(cli,display): add --health check and repo description column

Add --health option to verify Gitea connectivity and display version.
Add Description column (truncated at 40 chars) with --no-desc to hide
it. Add get_version() method to GiteaClient.

fixes #14
fixes #15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sylvain
2026-03-12 19:18:03 +01:00
parent 2ef7ec175e
commit 1b33cd36f9
6 changed files with 245 additions and 9 deletions

View File

@@ -59,9 +59,35 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
dest="format", dest="format",
help="Format de sortie (defaut: table).", help="Format de sortie (defaut: table).",
) )
parser.add_argument(
"--health",
action="store_true",
default=False,
help="Verifie la connexion Gitea et affiche la version. Exit 0 si OK, 1 sinon.",
)
parser.add_argument(
"--no-desc",
action="store_true",
default=False,
help="Masque la colonne Description dans le tableau.",
)
return parser.parse_args(argv) return parser.parse_args(argv)
def _run_health_check(client: GiteaClient, console: Console) -> None:
"""Execute le health check et affiche les resultats.
1. Appelle client.get_version() -> affiche "Gitea vX.Y.Z"
2. Appelle client.get_repos() -> affiche "N repos accessibles"
"""
version_info = client.get_version()
version = version_info.get("version", "inconnue")
console.print(f"Gitea v{version}")
repos = client.get_repos()
console.print(f"{len(repos)} repos accessibles")
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
"""Point d'entree principal. """Point d'entree principal.
@@ -90,6 +116,10 @@ def main(argv: list[str] | None = None) -> None:
client = GiteaClient(url, token) client = GiteaClient(url, token)
try: try:
if args.health:
_run_health_check(client, console)
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]")
@@ -111,4 +141,4 @@ 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) render_dashboard(repos, sort_key=args.sort, show_description=not args.no_desc)

View File

@@ -126,6 +126,18 @@ class GiteaClient:
params={"state": "open"}, params={"state": "open"},
) )
def get_version(self) -> dict:
"""Retourne la version de l'instance Gitea.
Endpoint: GET /api/v1/version
Retourne: {"version": "1.21.0"}
Leve HTTPError si l'appel echoue.
"""
url = f"{self.base_url}/api/v1/version"
resp = self._get_with_retry(url)
resp.raise_for_status()
return resp.json()
def get_latest_commit(self, owner: str, repo: str) -> dict | None: def get_latest_commit(self, owner: str, repo: str) -> dict | None:
"""Retourne le dernier commit du repo, ou None si aucun. """Retourne le dernier commit du repo, ou None si aucun.

View File

@@ -93,6 +93,13 @@ def _colorize_milestone_due(due_on: str | None) -> str:
return "green" return "green"
def _truncate(text: str, max_length: int = 40) -> str:
"""Tronque le texte a max_length caracteres avec '...' si necessaire."""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
def sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]: def sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]:
"""Trie la liste des repos selon le critere donne. """Trie la liste des repos selon le critere donne.
@@ -126,15 +133,18 @@ def render_dashboard(
repos: list[RepoData], repos: list[RepoData],
console: Console | None = None, console: Console | None = None,
sort_key: str = "name", sort_key: str = "name",
show_description: bool = True,
) -> None: ) -> None:
"""Affiche le dashboard complet dans le terminal. """Affiche le dashboard complet dans le terminal.
- Tableau principal : nom repo, indicateurs (fork/archive/mirror), - Tableau principal : nom repo, description (optionnelle, tronquee a 40 chars),
issues ouvertes, derniere release (tag + date relative) indicateurs (fork/archive/mirror), issues ouvertes, derniere release
- Section milestones : par repo ayant des milestones, - Section milestones : par repo ayant des milestones,
nom, progression (closed/total), date echeance nom, progression (closed/total), date echeance
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"
entre "Repo" et "Issues", tronquee a 40 caracteres.
""" """
if console is None: if console is None:
console = Console() console = Console()
@@ -149,6 +159,8 @@ def render_dashboard(
# Tableau principal # Tableau principal
table = Table(title="Gitea Dashboard") table = Table(title="Gitea Dashboard")
table.add_column("Repo", style="bold") table.add_column("Repo", style="bold")
if show_description:
table.add_column("Description")
table.add_column("Issues", justify="right") table.add_column("Issues", justify="right")
table.add_column("Release") table.add_column("Release")
table.add_column("Dernier commit") table.add_column("Dernier commit")
@@ -162,13 +174,19 @@ def render_dashboard(
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014" _format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
) )
table.add_row( row = [name]
name, if show_description:
f"[{issues_style}]{issues_str}[/{issues_style}]", row.append(_truncate(repo.description or ""))
release_str, row.extend(
commit_str, [
f"[{issues_style}]{issues_str}[/{issues_style}]",
release_str,
commit_str,
]
) )
table.add_row(*row)
console.print(table) console.print(table)
# Section milestones — uniquement si au moins un repo en a # Section milestones — uniquement si au moins un repo en a

View File

@@ -26,7 +26,9 @@ 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_collect.return_value, sort_key="name") mock_render.assert_called_once_with(
mock_collect.return_value, sort_key="name", show_description=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")
@@ -259,6 +261,96 @@ class TestParseArgsFormat:
parse_args(["--format", "invalid"]) parse_args(["--format", "invalid"])
class TestParseArgsHealth:
"""Test --health argument parsing."""
def test_parse_args_health(self):
"""--health sets health=True."""
from gitea_dashboard.cli import parse_args
args = parse_args(["--health"])
assert args.health is True
def test_parse_args_no_health_default(self):
"""Without --health, health is False."""
from gitea_dashboard.cli import parse_args
args = parse_args([])
assert args.health is False
class TestParseArgsNoDesc:
"""Test --no-desc argument parsing."""
def test_parse_args_no_desc(self):
"""--no-desc sets no_desc=True."""
from gitea_dashboard.cli import parse_args
args = parse_args(["--no-desc"])
assert args.no_desc is True
def test_parse_args_no_desc_default(self):
"""Without --no-desc, no_desc is False."""
from gitea_dashboard.cli import parse_args
args = parse_args([])
assert args.no_desc is False
class TestMainHealth:
"""Test main() with --health."""
@patch("gitea_dashboard.cli.GiteaClient")
def test_main_health_success(self, mock_client_cls, capsys):
"""--health displays version and repo count, exits normally."""
env = {"GITEA_TOKEN": "test-token"}
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_version.return_value = {"version": "1.21.0"}
mock_client.get_repos.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
with patch.dict("os.environ", env, clear=True):
main(["--health"])
captured = capsys.readouterr()
assert "Gitea v1.21.0" in captured.err
assert "3 repos accessibles" in captured.err
@patch("gitea_dashboard.cli.GiteaClient")
def test_main_health_connection_error(self, mock_client_cls):
"""--health with connection error exits with code 1."""
env = {"GITEA_TOKEN": "test-token"}
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_version.side_effect = requests.ConnectionError("refused")
with patch.dict("os.environ", env, clear=True):
with pytest.raises(SystemExit) as exc_info:
main(["--health"])
assert exc_info.value.code == 1
class TestMainNoDesc:
"""Test main() with --no-desc."""
@patch("gitea_dashboard.cli.render_dashboard")
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_main_passes_no_desc_to_render(self, mock_client_cls, mock_collect, mock_render):
"""--no-desc passes show_description=False to render_dashboard."""
env = {"GITEA_TOKEN": "test-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.return_value = []
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
)
class TestMainFormatJson: class TestMainFormatJson:
"""Test main() with --format json.""" """Test main() with --format json."""

View File

@@ -287,6 +287,30 @@ class TestGetWithRetry429:
assert mock_sleep.call_count == 2 assert mock_sleep.call_count == 2
class TestGetVersion:
"""Test get_version method."""
def test_get_version_success(self):
"""Returns version dict on success."""
client = GiteaClient("http://gitea.local:3000", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"version": "1.21.0"}
with patch.object(client.session, "get", return_value=mock_resp):
result = client.get_version()
assert result == {"version": "1.21.0"}
def test_get_version_connection_error(self):
"""ConnectionError propagates to caller."""
client = GiteaClient("http://gitea.local:3000", "tok")
with patch.object(client.session, "get", side_effect=requests.ConnectionError("refused")):
with pytest.raises(requests.ConnectionError):
client.get_version()
class TestGetPaginatedEdgeCases: class TestGetPaginatedEdgeCases:
"""Test edge cases for API responses.""" """Test edge cases for API responses."""

View File

@@ -230,6 +230,66 @@ class TestRenderDashboardEmpty:
assert "Aucun repo" in output assert "Aucun repo" in output
class TestDescriptionColumn:
"""Test Description column in dashboard table."""
def test_description_column_displayed(self):
"""Table contains a Description column by default."""
console, buf = _make_console()
repos = [_make_repo(name="test", description="My project")]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "Description" in output
assert "My project" in output
def test_description_truncated_at_40(self):
"""Description longer than 40 chars is truncated with '...'."""
console, buf = _make_console()
long_desc = "A" * 60
repos = [_make_repo(name="test", description=long_desc)]
render_dashboard(repos, console=console)
output = buf.getvalue()
# Should contain first 40 chars + "..."
assert "A" * 40 + "..." in output
# Should NOT contain the full 60-char string
assert "A" * 60 not in output
def test_description_short_not_truncated(self):
"""Description of 20 chars is displayed as-is."""
console, buf = _make_console()
repos = [_make_repo(name="test", description="Short description")]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "Short description" in output
def test_description_empty(self):
"""Empty description renders without crash."""
console, buf = _make_console()
repos = [_make_repo(name="test", description="")]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "test" in output
def test_no_description_flag(self):
"""show_description=False hides the Description column."""
console, buf = _make_console()
repos = [_make_repo(name="test", description="My project")]
render_dashboard(repos, console=console, show_description=False)
output = buf.getvalue()
assert "Description" not in output
assert "test" in output
class TestRenderDashboardEdgeCases: class TestRenderDashboardEdgeCases:
"""Test edge cases for dashboard rendering.""" """Test edge cases for dashboard rendering."""