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:
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
26
src/gitea_dashboard/exporter.py
Normal file
26
src/gitea_dashboard/exporter.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
109
tests/test_exporter.py
Normal 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) == []
|
||||||
Reference in New Issue
Block a user