feat(v1.2.0): retry API, dernier commit, tri, coloration, export JSON

- client.py: _get_with_retry (max 2 retries, backoff lineaire), get_latest_commit
- collector.py: champ last_commit_date dans RepoData
- display.py: colonne "Dernier commit", _sort_repos (name/issues/release/activity),
  _colorize_milestone_due (rouge/jaune/vert selon echeance)
- cli.py: options --sort/-s et --format/-f (table/json)
- exporter.py: nouveau module, repos_to_dicts + export_json
- 88 tests (35 nouveaux), ruff clean

fixes #8, fixes #7, fixes #10, fixes #9, fixes #6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sylvain
2026-03-12 03:58:45 +01:00
parent 19f300ccdb
commit 4c66fbe98d
10 changed files with 639 additions and 10 deletions

View File

@@ -26,7 +26,7 @@ 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)
mock_render.assert_called_once_with(mock_collect.return_value, sort_key="name")
@patch("gitea_dashboard.cli.render_dashboard")
@patch("gitea_dashboard.cli.collect_all")
@@ -193,3 +193,88 @@ class TestMainWithFilters:
main([])
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
class TestParseArgsSort:
"""Test --sort argument parsing."""
def test_sort_default(self):
"""Without --sort, default is 'name'."""
from gitea_dashboard.cli import parse_args
args = parse_args([])
assert args.sort == "name"
def test_sort_issues(self):
"""--sort issues is accepted."""
from gitea_dashboard.cli import parse_args
args = parse_args(["--sort", "issues"])
assert args.sort == "issues"
def test_sort_short_flag(self):
"""-s activity is accepted."""
from gitea_dashboard.cli import parse_args
args = parse_args(["-s", "activity"])
assert args.sort == "activity"
def test_sort_invalid(self):
"""--sort invalid raises SystemExit (argparse error)."""
from gitea_dashboard.cli import parse_args
with pytest.raises(SystemExit):
parse_args(["--sort", "invalid"])
class TestParseArgsFormat:
"""Test --format argument parsing."""
def test_format_default(self):
"""Without --format, default is 'table'."""
from gitea_dashboard.cli import parse_args
args = parse_args([])
assert args.format == "table"
def test_format_json(self):
"""--format json is accepted."""
from gitea_dashboard.cli import parse_args
args = parse_args(["--format", "json"])
assert args.format == "json"
def test_format_short_flag(self):
"""-f json is accepted."""
from gitea_dashboard.cli import parse_args
args = parse_args(["-f", "json"])
assert args.format == "json"
def test_format_invalid(self):
"""--format invalid raises SystemExit."""
from gitea_dashboard.cli import parse_args
with pytest.raises(SystemExit):
parse_args(["--format", "invalid"])
class TestMainFormatJson:
"""Test main() with --format json."""
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_json_output(self, mock_client_cls, mock_collect, capsys):
"""--format json produces valid JSON on stdout."""
import json
env = {"GITEA_TOKEN": "test-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.return_value = []
with patch.dict("os.environ", env, clear=True):
main(["--format", "json"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert isinstance(parsed, list)

View File

@@ -2,6 +2,8 @@
from unittest.mock import MagicMock, patch
import pytest
import requests
from gitea_dashboard.client import GiteaClient
@@ -129,7 +131,6 @@ class TestGetLatestRelease:
def test_raises_on_server_error(self):
"""HTTP 500 raises an exception instead of silently returning bad data."""
import pytest
import requests as req
client = GiteaClient("http://gitea.local:3000", "tok")
@@ -158,3 +159,92 @@ class TestGetMilestones:
params={"state": "open"},
)
assert result == milestones
class TestGetWithRetry:
"""Test _get_with_retry method (retry on timeout)."""
def _make_client(self):
return GiteaClient("http://gitea.local:3000", "tok")
@patch("time.sleep")
def test_success_first_attempt(self, mock_sleep):
"""No timeout — returns response directly without sleeping."""
client = self._make_client()
mock_resp = MagicMock()
with patch.object(client.session, "get", return_value=mock_resp):
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
assert result is mock_resp
mock_sleep.assert_not_called()
@patch("time.sleep")
def test_success_after_timeout(self, mock_sleep):
"""First call times out, second succeeds — one sleep of 1.0s."""
client = self._make_client()
mock_resp = MagicMock()
with patch.object(
client.session, "get", side_effect=[requests.Timeout("timeout"), mock_resp]
):
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
assert result is mock_resp
mock_sleep.assert_called_once_with(1.0)
@patch("time.sleep")
def test_all_timeouts(self, mock_sleep):
"""All 3 attempts timeout — raises Timeout, sleeps twice (1.0, 2.0)."""
client = self._make_client()
timeout_exc = requests.Timeout("timeout")
with patch.object(
client.session, "get", side_effect=[timeout_exc, timeout_exc, timeout_exc]
):
with pytest.raises(requests.Timeout):
client._get_with_retry("http://gitea.local:3000/api/v1/test")
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(1.0)
mock_sleep.assert_any_call(2.0)
class TestGetLatestCommit:
"""Test get_latest_commit method."""
def test_returns_first_commit(self):
"""Returns the first commit from the list."""
client = GiteaClient("http://gitea.local:3000", "tok")
commit = {"sha": "abc123", "created": "2026-03-10T14:30:00Z"}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [commit]
with patch.object(client.session, "get", return_value=mock_resp):
result = client.get_latest_commit("admin", "my-repo")
assert result == commit
def test_empty_repo_returns_none(self):
"""Returns None when repo has no commits."""
client = GiteaClient("http://gitea.local:3000", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = []
with patch.object(client.session, "get", return_value=mock_resp):
result = client.get_latest_commit("admin", "empty-repo")
assert result is None
def test_404_returns_none(self):
"""Returns None when repo is not found (404)."""
client = GiteaClient("http://gitea.local:3000", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 404
with patch.object(client.session, "get", return_value=mock_resp):
result = client.get_latest_commit("admin", "missing-repo")
assert result is None

View File

@@ -130,6 +130,54 @@ class TestCollectAll:
assert result[0].is_mirror is True
class TestCollectAllLastCommit:
"""Test last_commit_date field in RepoData."""
def test_repo_data_has_last_commit_date(self):
"""RepoData includes last_commit_date field."""
repo = RepoData(
name="test",
full_name="admin/test",
description="",
open_issues=0,
is_fork=False,
is_archived=False,
is_mirror=False,
latest_release=None,
milestones=[],
last_commit_date="2026-03-10T14:30:00Z",
)
assert repo.last_commit_date == "2026-03-10T14:30:00Z"
def test_collect_all_calls_get_latest_commit(self):
"""collect_all calls get_latest_commit and fills last_commit_date."""
client = MagicMock()
client.get_repos.return_value = [_make_repo()]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
client.get_latest_commit.return_value = {
"sha": "abc123",
"created": "2026-03-10T14:30:00Z",
}
result = collect_all(client)
client.get_latest_commit.assert_called_once_with("admin", "my-repo")
assert result[0].last_commit_date == "2026-03-10T14:30:00Z"
def test_collect_all_no_commits(self):
"""Repo without commits gets last_commit_date=None."""
client = MagicMock()
client.get_repos.return_value = [_make_repo()]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
client.get_latest_commit.return_value = None
result = collect_all(client)
assert result[0].last_commit_date is None
class TestCollectAllFiltering:
"""Test collect_all filtering (include/exclude)."""

View File

@@ -5,7 +5,9 @@ from io import StringIO
from rich.console import Console
from gitea_dashboard.collector import RepoData
from gitea_dashboard.display import render_dashboard
from gitea_dashboard.display import (
render_dashboard,
)
def _make_console():
@@ -28,6 +30,7 @@ def _make_repo(
is_mirror=False,
latest_release=None,
milestones=None,
last_commit_date=None,
):
"""Build a RepoData for testing."""
return RepoData(
@@ -40,6 +43,7 @@ def _make_repo(
is_mirror=is_mirror,
latest_release=latest_release,
milestones=milestones if milestones is not None else [],
last_commit_date=last_commit_date,
)
@@ -142,6 +146,41 @@ class TestRenderDashboardTable:
assert "repo-beta" in output
class TestRenderDashboardLastCommit:
"""Test the last commit column rendering."""
def test_last_commit_column_displayed(self):
"""Column 'Dernier commit' appears in the table."""
console, buf = _make_console()
repos = [_make_repo(name="projet", last_commit_date="2026-03-10T14:30:00Z")]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "Dernier commit" in output
def test_last_commit_shows_relative_date(self):
"""Last commit date is shown as relative date."""
console, buf = _make_console()
repos = [_make_repo(name="projet", last_commit_date="2026-03-10T14:30:00Z")]
render_dashboard(repos, console=console)
output = buf.getvalue()
# Should show some relative date (il y a Xj, etc.)
assert "il y a" in output or "aujourd'hui" in output
def test_last_commit_none_shows_dash(self):
"""Repo without commit shows dash."""
console, buf = _make_console()
repos = [_make_repo(name="vide", last_commit_date=None)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "\u2014" in output or "" in output
class TestRenderDashboardMilestones:
"""Test the milestones section rendering."""
@@ -214,3 +253,95 @@ class TestRenderDashboardEmpty:
output = buf.getvalue()
assert "Aucun repo" in output
class TestColorizeMilestoneDue:
"""Test _colorize_milestone_due function."""
def test_overdue(self):
"""Past due date returns 'red'."""
from gitea_dashboard.display import _colorize_milestone_due
assert _colorize_milestone_due("2020-01-01T00:00:00Z") == "red"
def test_soon(self):
"""Due date within 7 days returns 'yellow'."""
from datetime import datetime, timedelta, timezone
from gitea_dashboard.display import _colorize_milestone_due
soon = datetime.now(timezone.utc) + timedelta(days=3)
assert _colorize_milestone_due(soon.isoformat()) == "yellow"
def test_ok(self):
"""Due date more than 7 days away returns 'green'."""
from datetime import datetime, timedelta, timezone
from gitea_dashboard.display import _colorize_milestone_due
future = datetime.now(timezone.utc) + timedelta(days=15)
assert _colorize_milestone_due(future.isoformat()) == "green"
def test_no_due(self):
"""No due date returns empty string."""
from gitea_dashboard.display import _colorize_milestone_due
assert _colorize_milestone_due(None) == ""
class TestSortRepos:
"""Test _sort_repos function."""
def test_sort_by_name(self):
"""Sorts alphabetically by name (case-insensitive)."""
from gitea_dashboard.display import _sort_repos
repos = [
_make_repo(name="Charlie"),
_make_repo(name="alpha"),
_make_repo(name="Bravo"),
]
result = _sort_repos(repos, "name")
assert [r.name for r in result] == ["alpha", "Bravo", "Charlie"]
def test_sort_by_issues(self):
"""Sorts by issues count descending."""
from gitea_dashboard.display import _sort_repos
repos = [
_make_repo(name="low", open_issues=1),
_make_repo(name="high", open_issues=10),
_make_repo(name="mid", open_issues=5),
]
result = _sort_repos(repos, "issues")
assert [r.name for r in result] == ["high", "mid", "low"]
def test_sort_by_release(self):
"""Sorts by release date descending; repos without release last."""
from gitea_dashboard.display import _sort_repos
repos = [
_make_repo(name="no-rel", latest_release=None),
_make_repo(
name="old",
latest_release={"tag_name": "v1.0", "published_at": "2025-01-01T00:00:00Z"},
),
_make_repo(
name="new",
latest_release={"tag_name": "v2.0", "published_at": "2026-03-01T00:00:00Z"},
),
]
result = _sort_repos(repos, "release")
assert [r.name for r in result] == ["new", "old", "no-rel"]
def test_sort_by_activity(self):
"""Sorts by last commit date descending; repos without commit last."""
from gitea_dashboard.display import _sort_repos
repos = [
_make_repo(name="inactive", last_commit_date=None),
_make_repo(name="old-commit", last_commit_date="2025-06-01T00:00:00Z"),
_make_repo(name="recent", last_commit_date="2026-03-10T00:00:00Z"),
]
result = _sort_repos(repos, "activity")
assert [r.name for r in result] == ["recent", "old-commit", "inactive"]

109
tests/test_exporter.py Normal file
View File

@@ -0,0 +1,109 @@
"""Tests for JSON exporter module."""
import json
from gitea_dashboard.collector import RepoData
from gitea_dashboard.exporter import export_json, repos_to_dicts
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,
last_commit_date=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 [],
last_commit_date=last_commit_date,
)
class TestReposToDicts:
"""Test repos_to_dicts function."""
def test_basic_conversion(self):
"""Converts a RepoData to dict with correct values."""
repo = _make_repo(name="test-repo", open_issues=5)
result = repos_to_dicts([repo])
assert len(result) == 1
assert result[0]["name"] == "test-repo"
assert result[0]["open_issues"] == 5
def test_empty_list(self):
"""Empty input returns empty list."""
assert repos_to_dicts([]) == []
def test_preserves_all_fields(self):
"""All RepoData fields are present in the output dict."""
repo = _make_repo(
name="full",
full_name="admin/full",
description="desc",
open_issues=2,
is_fork=True,
is_archived=False,
is_mirror=True,
latest_release={"tag_name": "v1.0", "published_at": "2026-01-01"},
milestones=[{"title": "v2.0"}],
last_commit_date="2026-03-10T00:00:00Z",
)
result = repos_to_dicts([repo])
d = result[0]
expected_fields = [
"name",
"full_name",
"description",
"open_issues",
"is_fork",
"is_archived",
"is_mirror",
"latest_release",
"milestones",
"last_commit_date",
]
for field in expected_fields:
assert field in d, f"Missing field: {field}"
class TestExportJson:
"""Test export_json function."""
def test_valid_json(self):
"""Output is valid JSON (json.loads does not raise)."""
repos = [_make_repo(name="repo-a"), _make_repo(name="repo-b")]
output = export_json(repos)
parsed = json.loads(output)
assert isinstance(parsed, list)
assert len(parsed) == 2
def test_indented(self):
"""JSON output is indented by default."""
repos = [_make_repo()]
output = export_json(repos)
# Indented JSON has newlines and spaces
assert "\n" in output
assert " " in output
def test_empty_list(self):
"""Empty repo list produces '[]'."""
output = export_json([])
assert json.loads(output) == []