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

@@ -43,6 +43,21 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default=None, default=None,
help="Exclure les repos par nom (sous-chaine, insensible a la casse). Repeatable.", help="Exclure les repos par nom (sous-chaine, insensible a la casse). Repeatable.",
) )
parser.add_argument(
"--sort",
"-s",
choices=["name", "issues", "release", "activity"],
default="name",
help="Critere de tri des repos (defaut: name).",
)
parser.add_argument(
"--format",
"-f",
choices=["table", "json"],
default="table",
dest="format",
help="Format de sortie (defaut: table).",
)
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -91,4 +106,9 @@ def main(argv: list[str] | None = None) -> None:
console.print(f"[red]Erreur API : {msg}[/red]") console.print(f"[red]Erreur API : {msg}[/red]")
sys.exit(1) sys.exit(1)
render_dashboard(repos) if args.format == "json":
from gitea_dashboard.exporter import export_json
print(export_json(repos)) # noqa: T201
else:
render_dashboard(repos, sort_key=args.sort)

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import time
import requests import requests
@@ -10,9 +12,12 @@ class GiteaClient:
Utilise requests.Session pour reutiliser les connexions HTTP. Utilise requests.Session pour reutiliser les connexions HTTP.
Authentification via header Authorization: token <TOKEN>. Authentification via header Authorization: token <TOKEN>.
Retry automatique sur timeout (max 2 retries, backoff lineaire).
""" """
_PAGE_LIMIT = 50 _PAGE_LIMIT = 50
_MAX_RETRIES = 2
_RETRY_DELAY = 1.0 # secondes
def __init__(self, base_url: str, token: str, timeout: int = 30) -> None: def __init__(self, base_url: str, token: str, timeout: int = 30) -> None:
"""Initialise le client avec l'URL de base et le token API. """Initialise le client avec l'URL de base et le token API.
@@ -27,10 +32,27 @@ class GiteaClient:
self.session = requests.Session() self.session = requests.Session()
self.session.headers["Authorization"] = f"token {token}" self.session.headers["Authorization"] = f"token {token}"
def _get_with_retry(self, url: str, params: dict | None = None) -> requests.Response:
"""GET avec retry automatique sur timeout.
Retente jusqu'a _MAX_RETRIES fois avec backoff lineaire (1s, 2s).
Leve requests.Timeout apres epuisement des retries.
"""
last_exc: requests.Timeout | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
return self.session.get(url, params=params, timeout=self.timeout)
except requests.Timeout as exc:
last_exc = exc
if attempt < self._MAX_RETRIES:
time.sleep(self._RETRY_DELAY * (attempt + 1))
raise last_exc # type: ignore[misc]
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]: def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
"""Requete GET avec pagination automatique. """Requete GET avec pagination automatique.
Boucle tant que len(page) == limit (50). Boucle tant que len(page) == limit (50).
Utilise _get_with_retry pour la resilience aux timeouts.
""" """
all_items: list[dict] = [] all_items: list[dict] = []
page = 1 page = 1
@@ -40,7 +62,7 @@ class GiteaClient:
merged_params["limit"] = self._PAGE_LIMIT merged_params["limit"] = self._PAGE_LIMIT
merged_params["page"] = page merged_params["page"] = page
url = f"{self.base_url}{endpoint}" url = f"{self.base_url}{endpoint}"
resp = self.session.get(url, params=merged_params, timeout=self.timeout) resp = self._get_with_retry(url, params=merged_params)
resp.raise_for_status() resp.raise_for_status()
items = resp.json() items = resp.json()
all_items.extend(items) all_items.extend(items)
@@ -62,9 +84,10 @@ class GiteaClient:
Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest
Gere HTTP 404 en retournant None. Gere HTTP 404 en retournant None.
Utilise _get_with_retry pour la resilience aux timeouts.
""" """
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/releases/latest" url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/releases/latest"
resp = self.session.get(url, timeout=self.timeout) resp = self._get_with_retry(url)
if resp.status_code == 404: if resp.status_code == 404:
return None return None
resp.raise_for_status() resp.raise_for_status()
@@ -79,3 +102,19 @@ class GiteaClient:
f"/api/v1/repos/{owner}/{repo}/milestones", f"/api/v1/repos/{owner}/{repo}/milestones",
params={"state": "open"}, params={"state": "open"},
) )
def get_latest_commit(self, owner: str, repo: str) -> dict | None:
"""Retourne le dernier commit du repo, ou None si aucun.
Endpoint: GET /api/v1/repos/{owner}/{repo}/commits?limit=1
Retourne le premier element de la liste, ou None si vide ou 404.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/commits"
resp = self._get_with_retry(url, params={"limit": 1})
if resp.status_code == 404:
return None
resp.raise_for_status()
commits = resp.json()
if not commits:
return None
return commits[0]

View File

@@ -20,6 +20,7 @@ class RepoData:
is_mirror: bool is_mirror: bool
latest_release: dict | None # {tag_name, published_at} ou None latest_release: dict | None # {tag_name, published_at} ou None
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}] milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
last_commit_date: str | None # ISO 8601, ex: "2026-03-10T14:30:00Z"
def _matches_any(name: str, patterns: list[str]) -> bool: def _matches_any(name: str, patterns: list[str]) -> bool:
@@ -59,6 +60,9 @@ def collect_all(
owner = repo["owner"]["login"] owner = repo["owner"]["login"]
name = repo["name"] name = repo["name"]
commit = client.get_latest_commit(owner, name)
last_commit_date = commit["created"] if commit else None
result.append( result.append(
RepoData( RepoData(
name=name, name=name,
@@ -70,6 +74,7 @@ def collect_all(
is_mirror=repo["mirror"], is_mirror=repo["mirror"],
latest_release=client.get_latest_release(owner, name), latest_release=client.get_latest_release(owner, name),
milestones=client.get_milestones(owner, name), milestones=client.get_milestones(owner, name),
last_commit_date=last_commit_date,
) )
) )

View File

@@ -67,7 +67,66 @@ def _format_release(release: dict | None) -> str:
return tag return tag
def render_dashboard(repos: list[RepoData], console: Console | None = None) -> None: def _colorize_milestone_due(due_on: str | None) -> str:
"""Retourne le style Rich selon la proximite de l'echeance.
- Rouge : echeance depassee
- Jaune : echeance dans les 7 prochains jours
- Vert : echeance dans plus de 7 jours
- Chaine vide : pas d'echeance definie
"""
if not due_on:
return ""
try:
dt = datetime.fromisoformat(due_on.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return ""
now = datetime.now(timezone.utc)
delta = dt - now
days = delta.days
if days < 0:
return "red"
if days < 7:
return "yellow"
return "green"
def _sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]:
"""Trie la liste des repos selon le critere donne.
Args:
repos: Liste des repos a trier.
sort_key: Critere de tri parmi :
- "name" : alphabetique par nom (defaut)
- "issues" : par nombre d'issues ouvertes (decroissant)
- "release" : par date de derniere release (plus recent d'abord)
- "activity" : par date du dernier commit (plus recent d'abord)
"""
if sort_key == "name":
return sorted(repos, key=lambda r: r.name.lower())
if sort_key == "issues":
return sorted(repos, key=lambda r: r.open_issues, reverse=True)
if sort_key == "release":
# Repos sans release en dernier (date vide = epoch 0)
def release_date(r: RepoData) -> str:
if r.latest_release and r.latest_release.get("published_at"):
return r.latest_release["published_at"]
return ""
return sorted(repos, key=release_date, reverse=True)
if sort_key == "activity":
# Repos sans commit en dernier (date vide = epoch 0)
return sorted(repos, key=lambda r: r.last_commit_date or "", reverse=True)
return repos
def render_dashboard(
repos: list[RepoData],
console: Console | None = None,
sort_key: str = "name",
) -> 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, indicateurs (fork/archive/mirror),
@@ -84,19 +143,31 @@ def render_dashboard(repos: list[RepoData], console: Console | None = None) -> N
console.print("Aucun repo trouve.") console.print("Aucun repo trouve.")
return return
# Tri des repos
sorted_repos = _sort_repos(repos, sort_key)
# 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")
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")
for repo in repos: for repo in sorted_repos:
name = _format_repo_name(repo) name = _format_repo_name(repo)
issues_str = str(repo.open_issues) issues_str = str(repo.open_issues)
issues_style = "red" if repo.open_issues > 0 else "green" issues_style = "red" if repo.open_issues > 0 else "green"
release_str = _format_release(repo.latest_release) release_str = _format_release(repo.latest_release)
commit_str = (
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
)
table.add_row(name, f"[{issues_style}]{issues_str}[/{issues_style}]", release_str) table.add_row(
name,
f"[{issues_style}]{issues_str}[/{issues_style}]",
release_str,
commit_str,
)
console.print(table) console.print(table)
@@ -125,4 +196,9 @@ def render_dashboard(repos: list[RepoData], console: Console | None = None) -> N
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass pass
# Coloration selon la proximite de l'echeance
style = _colorize_milestone_due(due_on)
if style:
console.print(f"[{style}]{line}[/{style}]")
else:
console.print(line) console.print(line)

View File

@@ -0,0 +1,26 @@
"""Export des donnees du dashboard en formats structures."""
from __future__ import annotations
import json
from dataclasses import asdict
from gitea_dashboard.collector import RepoData
def repos_to_dicts(repos: list[RepoData]) -> list[dict]:
"""Convertit une liste de RepoData en liste de dicts serialisables.
Chaque dict contient toutes les donnees du RepoData,
pret pour json.dumps().
"""
return [asdict(repo) for repo in repos]
def export_json(repos: list[RepoData], indent: int = 2) -> str:
"""Exporte les repos en JSON formate.
Returns:
Chaine JSON indentee, prete pour stdout ou ecriture fichier.
"""
return json.dumps(repos_to_dicts(repos), indent=indent, ensure_ascii=False)

View File

@@ -26,7 +26,7 @@ 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) mock_render.assert_called_once_with(mock_collect.return_value, sort_key="name")
@patch("gitea_dashboard.cli.render_dashboard") @patch("gitea_dashboard.cli.render_dashboard")
@patch("gitea_dashboard.cli.collect_all") @patch("gitea_dashboard.cli.collect_all")
@@ -193,3 +193,88 @@ class TestMainWithFilters:
main([]) main([])
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None) 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 from unittest.mock import MagicMock, patch
import pytest
import requests
from gitea_dashboard.client import GiteaClient from gitea_dashboard.client import GiteaClient
@@ -129,7 +131,6 @@ class TestGetLatestRelease:
def test_raises_on_server_error(self): def test_raises_on_server_error(self):
"""HTTP 500 raises an exception instead of silently returning bad data.""" """HTTP 500 raises an exception instead of silently returning bad data."""
import pytest
import requests as req import requests as req
client = GiteaClient("http://gitea.local:3000", "tok") client = GiteaClient("http://gitea.local:3000", "tok")
@@ -158,3 +159,92 @@ class TestGetMilestones:
params={"state": "open"}, params={"state": "open"},
) )
assert result == milestones 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 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: class TestCollectAllFiltering:
"""Test collect_all filtering (include/exclude).""" """Test collect_all filtering (include/exclude)."""

View File

@@ -5,7 +5,9 @@ from io import StringIO
from rich.console import Console from rich.console import Console
from gitea_dashboard.collector import RepoData from gitea_dashboard.collector import RepoData
from gitea_dashboard.display import render_dashboard from gitea_dashboard.display import (
render_dashboard,
)
def _make_console(): def _make_console():
@@ -28,6 +30,7 @@ def _make_repo(
is_mirror=False, is_mirror=False,
latest_release=None, latest_release=None,
milestones=None, milestones=None,
last_commit_date=None,
): ):
"""Build a RepoData for testing.""" """Build a RepoData for testing."""
return RepoData( return RepoData(
@@ -40,6 +43,7 @@ def _make_repo(
is_mirror=is_mirror, is_mirror=is_mirror,
latest_release=latest_release, latest_release=latest_release,
milestones=milestones if milestones is not None else [], 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 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: class TestRenderDashboardMilestones:
"""Test the milestones section rendering.""" """Test the milestones section rendering."""
@@ -214,3 +253,95 @@ class TestRenderDashboardEmpty:
output = buf.getvalue() output = buf.getvalue()
assert "Aucun repo" in output 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) == []