diff --git a/src/gitea_dashboard/cli.py b/src/gitea_dashboard/cli.py index 279043f..a7cbfca 100644 --- a/src/gitea_dashboard/cli.py +++ b/src/gitea_dashboard/cli.py @@ -43,6 +43,21 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=None, 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) @@ -91,4 +106,9 @@ def main(argv: list[str] | None = None) -> None: console.print(f"[red]Erreur API : {msg}[/red]") 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) diff --git a/src/gitea_dashboard/client.py b/src/gitea_dashboard/client.py index d40214c..aa3ab71 100644 --- a/src/gitea_dashboard/client.py +++ b/src/gitea_dashboard/client.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time + import requests @@ -10,9 +12,12 @@ class GiteaClient: Utilise requests.Session pour reutiliser les connexions HTTP. Authentification via header Authorization: token . + Retry automatique sur timeout (max 2 retries, backoff lineaire). """ _PAGE_LIMIT = 50 + _MAX_RETRIES = 2 + _RETRY_DELAY = 1.0 # secondes def __init__(self, base_url: str, token: str, timeout: int = 30) -> None: """Initialise le client avec l'URL de base et le token API. @@ -27,10 +32,27 @@ class GiteaClient: self.session = requests.Session() 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]: """Requete GET avec pagination automatique. Boucle tant que len(page) == limit (50). + Utilise _get_with_retry pour la resilience aux timeouts. """ all_items: list[dict] = [] page = 1 @@ -40,7 +62,7 @@ class GiteaClient: merged_params["limit"] = self._PAGE_LIMIT merged_params["page"] = page 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() items = resp.json() all_items.extend(items) @@ -62,9 +84,10 @@ class GiteaClient: Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest 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" - resp = self.session.get(url, timeout=self.timeout) + resp = self._get_with_retry(url) if resp.status_code == 404: return None resp.raise_for_status() @@ -79,3 +102,19 @@ class GiteaClient: f"/api/v1/repos/{owner}/{repo}/milestones", 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] diff --git a/src/gitea_dashboard/collector.py b/src/gitea_dashboard/collector.py index 2bed6a8..0ee6da7 100644 --- a/src/gitea_dashboard/collector.py +++ b/src/gitea_dashboard/collector.py @@ -20,6 +20,7 @@ class RepoData: is_mirror: bool latest_release: dict | None # {tag_name, published_at} ou None 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: @@ -59,6 +60,9 @@ def collect_all( owner = repo["owner"]["login"] name = repo["name"] + commit = client.get_latest_commit(owner, name) + last_commit_date = commit["created"] if commit else None + result.append( RepoData( name=name, @@ -70,6 +74,7 @@ def collect_all( is_mirror=repo["mirror"], latest_release=client.get_latest_release(owner, name), milestones=client.get_milestones(owner, name), + last_commit_date=last_commit_date, ) ) diff --git a/src/gitea_dashboard/display.py b/src/gitea_dashboard/display.py index 66a751e..653ee3b 100644 --- a/src/gitea_dashboard/display.py +++ b/src/gitea_dashboard/display.py @@ -67,7 +67,66 @@ def _format_release(release: dict | None) -> str: 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. - 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.") return + # Tri des repos + sorted_repos = _sort_repos(repos, sort_key) + # Tableau principal table = Table(title="Gitea Dashboard") table.add_column("Repo", style="bold") table.add_column("Issues", justify="right") table.add_column("Release") + table.add_column("Dernier commit") - for repo in repos: + for repo in sorted_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) + 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) @@ -125,4 +196,9 @@ def render_dashboard(repos: list[RepoData], console: Console | None = None) -> N except (ValueError, AttributeError): pass - console.print(line) + # 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) diff --git a/src/gitea_dashboard/exporter.py b/src/gitea_dashboard/exporter.py new file mode 100644 index 0000000..62719fe --- /dev/null +++ b/src/gitea_dashboard/exporter.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index d4070b7..aebaad4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) diff --git a/tests/test_client.py b/tests/test_client.py index 120c5a1..65f0b67 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 diff --git a/tests/test_collector.py b/tests/test_collector.py index 379afa6..a8e1f8c 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -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).""" diff --git a/tests/test_display.py b/tests/test_display.py index 09ed440..20a7987 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -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"] diff --git a/tests/test_exporter.py b/tests/test_exporter.py new file mode 100644 index 0000000..408070b --- /dev/null +++ b/tests/test_exporter.py @@ -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) == []