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:
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
row.append(_truncate(repo.description or ""))
|
||||||
|
row.extend(
|
||||||
|
[
|
||||||
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
||||||
release_str,
|
release_str,
|
||||||
commit_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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user