Compare commits

...

18 Commits

Author SHA1 Message Date
sylvain
cf88ba0ef5 chore(workflow): complete step 10, start step 11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:29:00 +01:00
sylvain
8b4677a6f7 docs(v1.0.0): update README and create CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 19:28:25 +01:00
sylvain
6ed666fb66 chore(workflow): complete step 9 (smoke test OK), start step 10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:08:48 +01:00
sylvain
b15ba9eea8 fix(cli): add __main__.py for python -m gitea_dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:08:25 +01:00
sylvain
6fa8990cae chore(workflow): complete step 8 (audit 97/100), start step 9
Reviewer: 81→100, Guardian: 91→97, 5 findings corrected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:03:09 +01:00
sylvain
01f88a0eca fix(audit): correct findings from review round 1
- client: add raise_for_status() in get_latest_release() for non-404 errors (FINDING-001)
- client: add timeout parameter (default 30s) passed to all session.get() calls (FINDING-004/SEC-002)
- cli: replace return with sys.exit(1) in all except blocks (FINDING-002)
- test_cli: remove duplicate test_exits_when_token_missing, assert GITEA_TOKEN in stderr (FINDING-006)
- test_cli: update connection error tests to expect SystemExit(1) after exit code fix
- test_cli: rework token masking test to inject token into exception message (FINDING-007)
- test_client: add test_raises_on_server_error for HTTP 500 path (FINDING-001)
- test_client: add tests for default and custom timeout values (FINDING-004)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 19:00:57 +01:00
sylvain
e05578676f chore(workflow): complete step 7, start step 8
35 tests pass, 4 modules implemented (client, collector, display, cli).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:55:22 +01:00
sylvain
4aa648fa8c feat(cli): add main entry point with error handling (fixes #4)
Read GITEA_TOKEN (required) and GITEA_URL (default) from env vars,
orchestrate client/collect/render pipeline, handle connection and
timeout errors gracefully. Never expose token in error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:54:29 +01:00
sylvain
8fbdfcafd4 feat(display): add Rich dashboard rendering (fixes #3)
Render repos in a Rich table with [F]ork/[A]rchive/[M]irror indicators,
color-coded issue counts, relative release dates, and a milestones section.
Handles empty repo lists gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:54:25 +01:00
sylvain
b52bc72ce8 feat(collector): add RepoData dataclass and collect_all (fixes #2)
- RepoData dataclass with all repo fields
- collect_all enriches each repo with release and milestones
- Computes open_issues = open_issues_count - open_pr_counter
- 6 unit tests with mocked GiteaClient

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:51:17 +01:00
sylvain
4d66aea6ed feat(client): add GiteaClient with auth and pagination (fixes #1)
- GiteaClient with requests.Session and token auth header
- _get_paginated for automatic pagination (limit=50)
- get_repos, get_latest_release (None on 404), get_milestones
- 9 unit tests with mocked requests.Session
- Fix setuptools build backend in pyproject.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:50:28 +01:00
sylvain
18ce3b953e docs(v1.0.0): plan de version, architecture, ADR-002/003
- Plan en 2 phases dev : client+collecteur puis affichage+CLI
- Architecture 4 modules avec interfaces definies
- ADR-002: 4 modules max, ADR-003: pas de parallelisation en v1

chore(workflow): complete step 6, start step 7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:44:11 +01:00
sylvain
e757c35767 docs(v1.0.0): version plan and ADR
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:43:29 +01:00
sylvain
0bd64d64a9 chore(workflow): complete step 5, start step 6
Gitea milestone v1.0.0 + issues #1-#4 + labels created.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:39:18 +01:00
sylvain
de56585840 docs(research): API Gitea endpoints, pagination, auth, cas limites
chore(workflow): complete step 4, start step 5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:28:03 +01:00
sylvain
2eec10c61a chore(workflow): complete steps 1-3, start step 4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:22:04 +01:00
sylvain
4e72ddc32f chore: init project structure
Discovery synthesis, docs tree, Python src layout, CLAUDE.md, pyproject.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:21:33 +01:00
sylvain
11e5def11c chore(workflow): init v1.0.0 (major-initial)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:19:00 +01:00
25 changed files with 2051 additions and 1 deletions

View File

@@ -0,0 +1,85 @@
# Workflow — gitea-dashboard
## Metadata
| Champ | Valeur |
|-------|--------|
| Projet | gitea-dashboard |
| Chemin | /home/sylvain/nas/perso/sylvain/conserver/code/application_temp/gitea-dashboard |
| Date de creation | 2026-03-10 |
| Origine | gitea@192.168.0.106:admin/gitea-dashboard.git |
| Version courante | v1.0.0 |
| Track | major-initial |
| Phase courante | 4 — PUBLICATION |
| Etape courante | 11 |
| workflow_version | v1.0 |
---
## Phase 1 — FRAMING
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|---|-------|--------|------|-------------|------------|-------|
| 1 | Discovery | done | 2026-03-10 | /forge --discovery | Auto (synthesis.md existe) | step_1: done, deliverable: docs/discovery/synthesis.md |
| 2 | Creation projet | done | 2026-03-10 | forge | Auto (fichiers existent) | step_2: done, files_created: 14 |
| 3 | Specs | done | 2026-03-10 | - | Auto (descriptif.md existe avec contexte, objectifs, perimetre, contraintes) | step_3: done |
| 4 | Recherche | done | 2026-03-10 | researcher | Auto (research.md existe) | step_4: done, topics_researched: 6 (auth, endpoints, pagination, strategie, cas limites, decisions) |
| 5 | Roadmap | done | 2026-03-10 | - | Auto (Gitea milestones + issues created) | step_5: done, milestone: v1.0.0 (id:29), issues: #1-#4, labels: feature/bug/improvement/backlog |
## Phase 2 — DEV (v1.0.0)
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|---|-------|--------|------|-------------|------------|-------|
| 6 | Plan de version | done | 2026-03-10 | architect | Auto (plan avec phases, budget scope, inclusions/exclusions) | step_6: done, plan: docs/plans/v1.0.0-plan.md, phases: 2+1, gitea_milestone: exists (id:29) |
| 7 | Developpement | done | 2026-03-10 | builder | Auto (tests passent) | step_7: done, commits: 4, files_created: 7, files_modified: 1, tests: 35 passed |
| 8 | Audit + corrections | done | 2026-03-10 | reviewer + guardian + fixer | Auto (score 100, floor 50) | step_8: done, audit_initial: 81, audit_final: 97, rounds: 2, corrections: 5, remaining_findings: 1 (minor contextuel) |
## Phase 3 — PRE-RELEASE
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|---|-------|--------|------|-------------|------------|-------|
| 9 | Smoke test | done | 2026-03-10 | tester + checklist | Auto (tests E2E) + Manuel (checklist) | step_9: done, mode: cli, rounds: 1, fix: __main__.py manquant |
| 10 | Documentation | done | 2026-03-10 | documenter | Auto (README.md et CHANGELOG.md a jour) | step_10: done |
## Phase 4 — PUBLICATION
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|---|-------|--------|------|-------------|------------|-------|
| 11 | Release | in_progress | 2026-03-10 | /release | Auto (release creee) | |
| 12 | Deploy (optionnel) | en_attente | | script | Auto (health check OK) | Optionnel |
## Phase 5 — POST-RELEASE
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|---|-------|--------|------|-------------|------------|-------|
| 13 | Retrospective | en_attente | | - | Auto (metriques et MEMORY.md ecrits) | |
---
## Version interrompue (hotfix)
- Version interrompue :
- Etape :
- Phase dev :
- Branche :
---
## Decisions de workflow
| Date | Action | Raison |
|------|--------|--------|
| 2026-03-10 | init v1.0.0 major-initial | Nouveau projet, workflow complet |
| 2026-03-10 | step 1 done | Discovery synthesis produite via /forge |
| 2026-03-10 | step 2 done | Structure projet creee via /forge |
| 2026-03-10 | step 3 done | descriptif.md complet (contexte, objectifs, perimetre, contraintes) |
| 2026-03-10 | step 4 done | Recherche API Gitea : endpoints, pagination, auth, cas limites |
| 2026-03-10 | step 5 done | Milestone v1.0.0 + 4 issues + 4 labels crees sur Gitea |
| 2026-03-10 | step 6 done | Plan v1.0.0 (2 phases dev + 1 finalisation), architecture, ADR-002/003 |
| 2026-03-10 | step 7 done | 4 commits, 35 tests passent, issues #1-#4 fermees |
| 2026-03-10 | step 8 done | Audit: reviewer 81→100, guardian 91→97, 5 corrections, score final 97 |
| 2026-03-10 | step 9 done | Smoke test CLI reel, 13 repos affiches, fix __main__.py, milestone dupliquee nettoyee |
| 2026-03-10 | step 10 done | README complet, CHANGELOG v1.0.0, version bump pyproject.toml |
## Versions completees
| Version | Date debut | Date fin | Notes |
|---------|-----------|----------|-------|

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
venv/
.env
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/

19
CHANGELOG.md Normal file
View File

@@ -0,0 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [1.0.0] - 2026-03-10
### Added
- Client API Gitea avec authentification par token et pagination automatique
- Collecteur de données avec dataclass `RepoData`
- Affichage Rich du dashboard avec tableau repos et section milestones
- Point d'entrée CLI `gitea-dashboard` avec configuration par variables d'environnement
- Indicateurs visuels pour les repos forks, archives et miroirs
- Gestion des erreurs réseau (connexion refusée, timeout, erreurs API)
- Masquage du token dans les messages d'erreur

49
CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# gitea-dashboard
Dashboard CLI Python affichant l'etat des repos Gitea (issues, releases, milestones).
## Stack
- **Langage** : Python 3.x
- **Dependances** : requests, rich
- **API** : Gitea REST v1
- **Instance** : http://192.168.0.106:3000
## Principes
1. **Lecture seule** — le dashboard ne modifie jamais de donnees Gitea
2. **Configuration externalisee** — token et URL en variables d'environnement, jamais dans le code
3. **Separation des responsabilites** — client API / formatage / point d'entree distincts
4. **Gestion gracieuse** — un repo sans release ou milestone ne casse pas l'affichage
## Points d'attention
- Ne jamais committer de token ou secret
- Gerer la pagination API Gitea (reponses potentiellement tronquees)
- Tester avec des repos sans release et sans milestone
- L'API Gitea peut varier selon la version — tester sur l'instance reelle
## Commandes
```bash
# Installation
pip install -e .
# Execution
gitea-dashboard
# ou
python -m gitea_dashboard
# Tests
pytest
# Lint
ruff check src/ tests/
```
## Variables d'environnement
| Variable | Description | Defaut |
|----------|-------------|--------|
| `GITEA_URL` | URL de l'instance Gitea | http://192.168.0.106:3000 |
| `GITEA_TOKEN` | Token API Gitea | (requis) |

View File

@@ -1,3 +1,72 @@
# gitea-dashboard
CLI Python dashboard for Gitea repos status (issues, releases, milestones)
Dashboard CLI affichant en une commande l'état de tous les repos d'une instance Gitea : issues ouvertes, dernières releases et progression des milestones.
## Prérequis
- Python >= 3.10
- Accès à une instance Gitea avec un token API
## Installation
```bash
pip install -e .
```
## Configuration
Le dashboard se configure via deux variables d'environnement :
| Variable | Description | Défaut |
|----------|-------------|--------|
| `GITEA_URL` | URL de l'instance Gitea | `http://192.168.0.106:3000` |
| `GITEA_TOKEN` | Token API Gitea (requis) | — |
Pour créer un token : Gitea > Settings > Applications > Generate Token.
Exemple de configuration dans votre shell :
```bash
export GITEA_URL=https://gitea.tsmse.fr
# Définir GITEA_TOKEN avec la valeur obtenue depuis Gitea > Settings > Applications
```
## Usage
```bash
gitea-dashboard
# ou
python -m gitea_dashboard
```
### Exemple de sortie
```
Gitea Dashboard
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ Repo ┃ Issues ┃ Release ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ mon-projet │ 3 │ v1.2.0 (il y a 2j) │
│ autre-repo │ 0 │ — │
└─────────────────┴────────┴──────────────────────┘
Milestones
mon-projet / v2.0 : 3/5 (60%)
```
## Développement
```bash
# Installer avec les dépendances de développement
pip install -e ".[dev]"
# Lancer les tests
pytest
# Vérifier le style
ruff check src/ tests/
```
## Licence
Usage personnel.

48
docs/README.md Normal file
View File

@@ -0,0 +1,48 @@
<!-- Type: reference (Diataxis). Style: index de navigation, structure de la doc. -->
# Documentation — gitea-dashboard
## Structure
```
docs/
├── project/ # Pilotage projet
│ ├── descriptif.md # Brief initial (etape 3)
│ └── demandes.md # Inbox features (/workflow demande)
├── technical/ # Architecture et decisions
│ ├── ARCHITECTURE.md # Architecture technique (etape 6, architect)
│ ├── decisions.md # ADR (Architecture Decision Records)
│ └── research.md # Resultats de recherche (etape 4, researcher)
├── discovery/ # Livrables discovery (etape 1, majeur uniquement)
│ └── synthesis.md # Synthese d'interview
├── plans/ # Plans de version (etape 6)
│ └── vX.Y.Z-plan.md # Un plan par version (architect)
├── guides/ # Documentation utilisateur (optionnel)
│ └── [sujet].md # deployment.md, api-usage.md, getting-started.md
├── dev/ # Notes de developpement (optionnel)
│ └── [sujet].md # setup.md, conventions.md, troubleshooting.md
└── README.md # Ce fichier
```
## Conventions
- **MAJUSCULES.md** : documents officiels et de pilotage (ARCHITECTURE, CHANGELOG)
- **minuscules.md** : documents de travail et notes (descriptif, demandes, research, decisions)
- **Nommage fichiers** : minuscules, tirets pour separer les mots (deployment.md, api-usage.md)
- Les dossiers `guides/` et `dev/` sont crees quand le besoin apparait
- Chaque document a un emplacement unique — pas de doc ailleurs que dans cette arborescence
- **Roadmap** : Gitea milestones (pas de ROADMAP.md local)
- **Backlog** : Gitea issues avec label `backlog` (pas de BACKLOG.md local)
## Ou mettre ma doc ?
| Type de doc | Ou |
|-------------|-----|
| Guide utilisateur (install, usage, config) | `guides/` |
| Notes dev (setup, conventions, debug) | `dev/` |
| Architecture, ADR, recherche | `technical/` |
| Brief, demandes | `project/` |
| Plan de version | `plans/` |
| Synthese discovery | `discovery/` |
| Roadmap, jalons | Gitea milestones |
| Backlog, idees, dette technique | Gitea issues (label `backlog`) |

View File

@@ -0,0 +1,51 @@
<!-- Type: explanation (Diataxis). Style: discursif, synthese des besoins et decisions, produit par /forge --discovery. -->
# Discovery Synthesis — gitea-dashboard
## Contexte
Besoin d'un outil de supervision rapide pour l'instance Gitea personnelle.
Actuellement, il faut naviguer dans l'interface web repo par repo pour voir
l'etat des issues, releases et milestones. Un dashboard CLI permet d'avoir
une vue d'ensemble en une commande.
## Utilisateurs cibles
Utilisateur unique (admin de l'instance Gitea). Usage en terminal, execution
ponctuelle pour avoir un snapshot de l'etat des repos.
## Besoins identifies
### Fonctionnels
- Lister tous les repos de l'utilisateur
- Afficher le nombre d'issues ouvertes par repo
- Afficher la derniere release par repo (tag, date)
- Afficher l'etat des milestones (ouvertes, progression)
- Formatage terminal lisible et structure (tableaux, couleurs)
### Non-fonctionnels
- Temps de reponse acceptable (< 10s pour une dizaine de repos)
- Code maintenable, teste, structure proprement
- Configuration externalisee (URL, token)
## Contraintes
- API Gitea REST a http://192.168.0.106:3000
- Authentification par token API (variable d'environnement)
- Python 3.x avec requests et rich
- Affichage unique (pas de mode watch/refresh)
- Pas de filtre par owner/org en v1 — tous les repos
## Decisions prises
| Decision | Justification |
|----------|---------------|
| Python + requests | Stack simple, maitrisee, suffisante pour des appels REST |
| rich pour le formatage | Tableaux, couleurs, mise en page terminal de qualite |
| Token en variable d'env | Securite : pas de secret dans le code ou les fichiers |
| Pas de filtre en v1 | Nombre de repos limite, simplifier le scope initial |
## Questions ouvertes
- Pagination API : verifier si l'API Gitea pagine les resultats (a traiter en recherche step 4)
- Gestion des repos sans release ou sans milestone (affichage gracieux)

294
docs/plans/v1.0.0-plan.md Normal file
View File

@@ -0,0 +1,294 @@
<!-- Type: reference (Diataxis). Style: factuel, structure par phases, actionnable par le builder. -->
# Plan de version v1.0.0 — gitea-dashboard
## Objectif
Livrer un dashboard CLI fonctionnel qui affiche en une commande l'etat de tous les repos Gitea : issues ouvertes, derniere release, milestones. Premiere version utilisable.
## Track
**Major initial** (v1.0.0, nouveau projet) : 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 -> 11 -> (12) -> 13
---
## Budget de scope
| Critere | Valeur |
|---------|--------|
| Max fichiers par phase | 4 |
| Total fichiers estimes | 8 (4 modules + 4 tests) |
### Inclus
- Client API Gitea avec auth token et pagination
- Collecte des donnees : repos, issues (hors PRs), derniere release, milestones ouvertes
- Affichage Rich : tableau repos + section milestones + indicateurs visuels
- Point d'entree CLI avec configuration par variables d'environnement
- Gestion des cas limites (repos sans release, sans milestone, forks, archives)
### Exclus
- Interface web ou GUI
- Mode watch / rafraichissement automatique
- Filtrage par owner/organisation
- Modification de donnees Gitea (lecture seule)
- Notifications ou alertes
- Framework CLI (argparse, click, typer)
### Differe (v1.1+)
- Parallelisation des appels API (ThreadPoolExecutor)
- Filtrage/tri des repos (par nom, activite, owner)
- Cache des reponses API
- Options CLI (--format, --filter, --sort)
---
## Etapes skippees
| Etape | Nom | Raison |
|-------|-----|--------|
| 12 | Deploy | Outil CLI local, pas de deploiement serveur |
---
## Phase 1 : Client API et collecte de donnees
**Goal** : Disposer d'un client API Gitea fonctionnel et d'un collecteur qui agrege les donnees de tous les repos.
**Issues Gitea** : fixes #1, fixes #2
### Fichiers
| Action | Fichier | Modifications | Cross-references |
|--------|---------|---------------|------------------|
| Create | `src/gitea_dashboard/client.py` | Client API Gitea : auth, requetes GET, pagination | `collector.py` (consommateur) |
| Create | `src/gitea_dashboard/collector.py` | Collecte et agregation des donnees repos | `client.py` (dependance), `display.py` (producteur de donnees) |
| Create | `tests/test_client.py` | Tests unitaires du client API avec mocks | `client.py` |
| Create | `tests/test_collector.py` | Tests unitaires du collecteur avec mocks | `collector.py` |
### Interfaces
#### client.py
```python
class GiteaClient:
"""Client API Gitea en lecture seule."""
def __init__(self, base_url: str, token: str) -> None:
"""Initialise le client avec l'URL de base et le token API."""
def get_repos(self) -> list[dict]:
"""Retourne tous les repos de l'utilisateur (pagination automatique).
Endpoint: GET /api/v1/user/repos
"""
def get_latest_release(self, owner: str, repo: str) -> dict | None:
"""Retourne la derniere release du repo, ou None si aucune.
Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest
Gere HTTP 404 en retournant None.
"""
def get_milestones(self, owner: str, repo: str) -> list[dict]:
"""Retourne les milestones ouvertes du repo.
Endpoint: GET /api/v1/repos/{owner}/{repo}/milestones?state=open
"""
```
Methode privee interne pour la pagination :
```python
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
"""Requete GET avec pagination automatique.
Boucle tant que len(page) == limit (50).
"""
```
#### collector.py
```python
@dataclass
class RepoData:
"""Donnees agregees d'un repo."""
name: str
full_name: str
description: str
open_issues: int # open_issues_count - open_pr_counter
is_fork: bool
is_archived: bool
is_mirror: bool
latest_release: dict | None # {tag_name, published_at} ou None
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
def collect_all(client: GiteaClient) -> list[RepoData]:
"""Collecte les donnees de tous les repos.
Pour chaque repo : enrichit avec release et milestones.
"""
```
### Comportement attendu
1. `GiteaClient("http://192.168.0.106:3000", "token123")` cree un client
2. `client.get_repos()` retourne la liste complete (pagination transparente si > 50 repos)
3. `client.get_latest_release("admin", "mon-repo")` retourne `{"tag_name": "v1.0", "published_at": "..."}` ou `None`
4. `collect_all(client)` retourne une liste de `RepoData` pour chaque repo, meme ceux sans release ou milestone
### Tests
- `test_client.py` : mock de `requests.Session` pour tester auth header, pagination (1 page, 2 pages), 404 sur release
- `test_collector.py` : mock de `GiteaClient` pour tester l'agregation, les cas limites (repo sans release, sans milestone, fork, archive)
### Livrable
Le client peut interroger l'API Gitea et le collecteur produit une liste de `RepoData` prete pour l'affichage. Les tests passent.
---
## Phase 2 : Affichage Rich et point d'entree CLI
**Goal** : Afficher les donnees collectees dans un dashboard terminal lisible et fournir le point d'entree executable.
**Issues Gitea** : fixes #3, fixes #4
### Fichiers
| Action | Fichier | Modifications | Cross-references |
|--------|---------|---------------|------------------|
| Create | `src/gitea_dashboard/display.py` | Formatage et affichage Rich | `collector.py` (consomme `RepoData`) |
| Modify | `src/gitea_dashboard/cli.py` | Orchestration : config -> client -> collecte -> affichage | `client.py`, `collector.py`, `display.py` |
| Create | `tests/test_display.py` | Tests du formatage (capture console Rich) | `display.py` |
| Create | `tests/test_cli.py` | Tests d'integration du point d'entree | `cli.py` |
### Interfaces
#### display.py
```python
from rich.console import Console
def render_dashboard(repos: list[RepoData], console: Console | None = None) -> None:
"""Affiche le dashboard complet dans le terminal.
- Tableau principal : nom repo, indicateurs (fork/archive/mirror),
issues ouvertes, derniere release (tag + date relative)
- Section milestones : par repo ayant des milestones,
nom, progression (closed/total), date echeance
Le parametre console permet l'injection pour les tests.
"""
```
#### cli.py (modification)
```python
import sys
import os
def main() -> None:
"""Point d'entree principal.
1. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis)
2. Cree le GiteaClient
3. Collecte les donnees via collect_all()
4. Affiche via render_dashboard()
5. Gere les erreurs : config manquante, connexion refusee, timeout
"""
```
### Comportement attendu
Execution nominale :
```
$ export GITEA_TOKEN=abc123
$ gitea-dashboard
Gitea Dashboard
┏━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Repo ┃ Issues ┃ Release ┃
┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ mon-projet │ 3 │ v1.2.0 (il y a 2j) │
│ autre-repo [F] │ 0 │ — │
│ archive [A] │ 1 │ v0.1.0 (il y a 6m) │
└──────────────────┴────────┴─────────────────────┘
Milestones
mon-projet / v2.0 : 3/5 (60%) — echeance 2026-04-01
```
Erreur de configuration :
```
$ gitea-dashboard
Erreur : GITEA_TOKEN non defini. Exportez la variable d'environnement.
```
### Tests
- `test_display.py` : capture de la sortie Rich via `Console(file=StringIO())`, verification du contenu (noms de repos, indicateurs, gestion des valeurs None)
- `test_cli.py` : mock des variables d'environnement, mock du client API, verification des messages d'erreur
### Livrable
`gitea-dashboard` est executable, affiche un dashboard lisible, et gere proprement les erreurs. Tous les tests passent.
---
## Phase 3 : Smoke test et documentation
**Goal** : Valider le fonctionnement reel sur l'instance Gitea et documenter le projet.
**Dependances** : phases 1 et 2 terminees, acces a l'instance Gitea (192.168.0.106:3000)
**Composants cles** :
- Test E2E manuel sur l'instance reelle
- Checklist de validation (repos avec/sans release, avec/sans milestone, forks)
- README.md a jour (installation, usage, configuration)
- CHANGELOG.md pour la v1.0.0
---
## Architecture des modules
| Module | Responsabilite | Dependances |
|--------|---------------|-------------|
| `client.py` | Communication API Gitea (auth, requetes, pagination) | `requests` |
| `collector.py` | Agregation des donnees de tous les repos | `client.py` |
| `display.py` | Formatage et rendu terminal | `rich`, `collector.py` (types) |
| `cli.py` | Point d'entree, configuration, orchestration | tous les modules |
---
## Risques d'audit
| Zone | Risque | Severite estimee |
|------|--------|-----------------|
| `client.py` — pagination | Boucle infinie si l'API repond toujours `limit` elements | major |
| `client.py` — gestion erreurs | Erreurs HTTP non-404 non gerees (500, timeout, connexion) | major |
| `cli.py` — token en clair | Affichage accidentel du token dans les logs/erreurs | critical |
| `collector.py` — types | Champs API manquants ou renommes entre versions Gitea | minor |
---
## Issues Gitea rattachees
| Issue | Titre | Phase |
|-------|-------|-------|
| [#1](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/1) | Client API Gitea avec authentification et pagination | Phase 1 |
| [#2](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/2) | Collecte des donnees : repos, issues, releases, milestones | Phase 1 |
| [#3](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/3) | Affichage dashboard avec Rich (tableaux, couleurs) | Phase 2 |
| [#4](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/4) | Point d'entree CLI et configuration | Phase 2 |
**Milestone** : [v1.0.0](https://gitea.tsmse.fr/admin/gitea-dashboard/milestone/30)
---
## Dependances
| Dependance | Type | Version |
|------------|------|---------|
| Python | Runtime | >= 3.10 |
| requests | Librairie | >= 2.31 |
| rich | Librairie | >= 13.0 |
| pytest | Dev | >= 7.0 |
| ruff | Dev | >= 0.4 |
| Instance Gitea | Service externe | 1.25.1 a 192.168.0.106:3000 |

23
docs/project/demandes.md Normal file
View File

@@ -0,0 +1,23 @@
<!-- Type: reference (Diataxis). Style: inbox format libre, traite par /workflow demande. -->
# Demandes — gitea-dashboard
> **Note** : ce projet utilise Gitea, les issues Gitea sont la source de verite.
> Ce fichier sert de fallback.
> Utiliser `/workflow demande` pour traiter les demandes.
## En attente de classification
-
## Classifiees — en attente
-
## Terminees
-
## Demandes ecosystem
-

View File

@@ -0,0 +1,50 @@
<!-- Type: reference (Diataxis). Style: factuel, fige apres creation. Capture l'intention originale. -->
# Descriptif — gitea-dashboard
## Contexte
Supervision de l'instance Gitea personnelle (192.168.0.106:3000).
Pas de vue d'ensemble disponible sans naviguer repo par repo dans l'interface web.
## Objectifs
- Afficher en une commande l'etat de tous les repos Gitea
- Visualiser les issues ouvertes, dernieres releases et milestones
- Fournir un output terminal lisible et structure
## Perimetre
### Inclus
- Connexion API Gitea avec authentification token
- Liste de tous les repos de l'utilisateur
- Nombre d'issues ouvertes par repo
- Derniere release par repo (tag + date)
- Etat des milestones (nom, progression open/closed)
- Formatage rich (tableaux, couleurs)
### Exclus
- Interface web ou GUI
- Mode watch / rafraichissement automatique
- Filtrage par owner/organisation
- Modification de donnees (lecture seule)
- Notifications ou alertes
## Utilisateurs cibles
Administrateur unique de l'instance Gitea. Usage terminal.
## Contraintes
- Python 3.x
- Dependances : requests, rich
- API Gitea REST v1
- Token en variable d'environnement (GITEA_TOKEN)
- Instance locale : http://192.168.0.106:3000
## References
- API Gitea : https://gitea.io/en-us/ (documentation Swagger disponible sur l'instance)
- Rich : https://rich.readthedocs.io/

View File

@@ -0,0 +1,145 @@
<!-- Type: reference (Diataxis). Style: factuel, exhaustif, structure par le code. Pas de tutoriel ici. -->
# Architecture — gitea-dashboard
## Vue d'ensemble
```
gitea-dashboard
==============
Terminal Application Gitea API
-------- ----------- ---------
+------------------+
$ gitea-dashboard | cli.py |
------------------->| - lit env vars |
| - configure |
| - gere erreurs |
+--------+---------+
|
v
+------------------+
| collector.py |
| - orchestre la |
| collecte | +------------------+
| - agrege en |---->| client.py |
| RepoData | | - auth token |-----> GET /api/v1/user/repos
+--------+---------+ | - pagination |-----> GET /repos/{o}/{r}/releases/latest
| | - erreurs HTTP |-----> GET /repos/{o}/{r}/milestones
v +------------------+
+------------------+
| display.py |
| - tableau repos |
<-------------------| - milestones |
Output Rich | - indicateurs |
(tableaux, +------------------+
couleurs)
```
## Composants
### cli.py — Point d'entree
Responsabilite : orchestration du flux principal.
- Lit les variables d'environnement `GITEA_URL` (defaut: `http://192.168.0.106:3000`) et `GITEA_TOKEN` (requis)
- Valide la configuration, affiche un message d'erreur clair si manquante
- Cree le `GiteaClient`, lance la collecte, passe les resultats a l'affichage
- Gere les erreurs globales (connexion refusee, timeout, erreurs API)
- Enregistre comme entry point : `gitea-dashboard = "gitea_dashboard.cli:main"`
### client.py — Client API Gitea
Responsabilite : communication avec l'API REST Gitea.
- Authentification via header `Authorization: token <GITEA_TOKEN>`
- Methode interne `_get_paginated()` pour la pagination transparente (limit=50, boucle tant que page pleine)
- Methodes publiques : `get_repos()`, `get_latest_release(owner, repo)`, `get_milestones(owner, repo)`
- Gestion du 404 sur `/releases/latest` (retourne `None`)
- Utilise `requests.Session` pour reutiliser les connexions HTTP
### collector.py — Collecteur de donnees
Responsabilite : agregation des donnees de tous les repos.
- Definit le dataclass `RepoData` qui normalise les donnees d'un repo
- Calcul des issues ouvertes reelles : `open_issues_count - open_pr_counter`
- Pour chaque repo : enrichit avec la derniere release et les milestones ouvertes
- Fonction principale `collect_all(client) -> list[RepoData]`
### display.py — Affichage Rich
Responsabilite : formatage et rendu terminal.
- Tableau principal : nom du repo, indicateurs visuels ([F]ork, [A]rchive, [M]irror), issues ouvertes, derniere release (tag + date)
- Section milestones : affichee uniquement pour les repos ayant des milestones ouvertes, avec progression (closed/total)
- Accepte un parametre `Console` optionnel pour l'injection dans les tests
- Fonction principale `render_dashboard(repos, console=None)`
## Structure du projet
```
gitea-dashboard/
CLAUDE.md # Instructions projet
pyproject.toml # Config build, deps, entry point
README.md # Documentation utilisateur
src/
gitea_dashboard/
__init__.py # Docstring du package
cli.py # Point d'entree, config, orchestration
client.py # Client API Gitea (auth, pagination)
collector.py # Agregation des donnees repos
display.py # Formatage Rich (tableaux, couleurs)
tests/
__init__.py
test_client.py # Tests client API (mocks requests)
test_collector.py # Tests collecteur (mocks client)
test_display.py # Tests affichage (capture console)
test_cli.py # Tests integration CLI (mocks env)
docs/
plans/
v1.0.0-plan.md # Plan de version
technical/
ARCHITECTURE.md # Ce fichier
decisions.md # ADR
research.md # Recherche technique API
discovery/
synthesis.md # Synthese discovery
project/
descriptif.md # Descriptif du projet
```
## Flux de donnees principal
```
1. cli.main()
|
+-- Lit os.environ["GITEA_TOKEN"] et os.environ.get("GITEA_URL", default)
|
+-- GiteaClient(url, token)
|
+-- collect_all(client)
| |
| +-- client.get_repos() -> list[dict] (pagine)
| |
| +-- Pour chaque repo:
| +-- client.get_latest_release(owner, name) -> dict | None
| +-- client.get_milestones(owner, name) -> list[dict]
| +-- RepoData(...)
|
+-- render_dashboard(repos)
|
+-- Rich Table (repos)
+-- Rich Table (milestones)
+-- Console.print()
```
## Decisions architecturales
Les decisions sont tracees dans `docs/technical/decisions.md` (format ADR).
Decisions cles pour v1.0.0 :
- **ADR-001** : Stack Python + requests + rich
- **ADR-002** : 4 modules maximum (client, collector, display, cli)
- **ADR-003** : Pas de parallelisation en v1 (sequentiel, plus simple a deboguer)

View File

@@ -0,0 +1,48 @@
<!-- Type: reference (Diataxis). Style: factuel, format ADR Nygard (Contexte/Decision/Consequences). Jamais supprimer un ADR. -->
# Architecture Decision Records — gitea-dashboard
## ADR-001 : Stack Python + requests + rich
**Date** : 2026-03-10
**Statut** : accepte
**Contexte** : Besoin d'un outil CLI de dashboard pour Gitea. Choix du langage et des librairies.
**Decision** : Python avec requests pour les appels API et rich pour le formatage terminal.
**Consequences** :
- Stack simple et maitrisee par l'utilisateur
- Pas de framework CLI lourd (argparse suffit si besoin)
- rich offre des tableaux et couleurs sans configuration complexe
- Dependance a requests (pas de client async, acceptable pour un affichage unique)
## ADR-002 : 4 modules maximum (client, collector, display, cli)
**Date** : 2026-03-10
**Statut** : accepte
**Contexte** : Definir la granularite des modules Python pour un projet simple (dashboard CLI).
**Decision** : Limiter a 4 modules avec des responsabilites claires : `client.py` (API), `collector.py` (agregation), `display.py` (formatage), `cli.py` (entree).
**Consequences** :
- Separation des responsabilites sans over-engineering
- Chaque module est testable independamment
- Un fichier = une responsabilite = un jeu de tests
- Si le projet grandit, chaque module peut evoluer independamment
## ADR-003 : Pas de parallelisation en v1
**Date** : 2026-03-10
**Statut** : accepte
**Contexte** : La collecte de donnees necessite N appels par repo (release + milestones). La parallelisation (ThreadPoolExecutor) est possible mais ajoute de la complexite.
**Decision** : V1 en sequentiel. La parallelisation est differee a v1.1+.
**Consequences** :
- Code plus simple a ecrire, deboguer et tester
- Temps de reponse acceptable pour < 20 repos (estimee < 10s)
- Pas de problemes de concurrence
- Facile a ajouter plus tard sans changer les interfaces (le collecteur est le seul point d'appel)

164
docs/technical/research.md Normal file
View File

@@ -0,0 +1,164 @@
<!-- Type: explanation (Diataxis). Style: discursif, comparaisons argumentees, sources citees. -->
# Recherche technique — gitea-dashboard
Date : 2026-03-10 | Gitea 1.25.1 | Source : swagger.v1.json + docs.gitea.com
## Contexte
Le dashboard CLI doit afficher pour chaque repo Gitea : le nombre d'issues ouvertes, la derniere release, et l'etat des milestones. Cette recherche identifie les endpoints API necessaires, la strategie de pagination, le format d'authentification, et les cas limites.
---
## 1. Authentification
### Format recommande
```
Authorization: token <GITEA_TOKEN>
```
Le header `Authorization` avec le prefixe `token ` (suivi d'un espace) est la methode recommandee. Les anciennes methodes (query parameter `?token=` ou `?access_token=`) sont **deprecated depuis Gitea 1.23** et seront supprimees.
**Source** : schema `securityDefinitions` du swagger + https://docs.gitea.com/development/api-usage
---
## 2. Endpoints API necessaires
### 2.1 Liste des repos de l'utilisateur
**Endpoint** : `GET /api/v1/user/repos`
Retourne les repos dont l'utilisateur authentifie est proprietaire.
| Parametre | Type | Description |
|-----------|------|-------------|
| `page` | integer | Numero de page (defaut : 1) |
| `limit` | integer | Elements par page (defaut : 50, max : 50) |
**Reponse** : Array de `Repository`
**Champs cles** :
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int64 | Identifiant unique |
| `name` | string | Nom du repo |
| `full_name` | string | `owner/name` |
| `description` | string | Description |
| `fork` | boolean | Est un fork |
| `archived` | boolean | Est archive |
| `mirror` | boolean | Est un miroir |
| `open_issues_count` | int64 | Issues ouvertes (inclut les PRs) |
| `open_pr_counter` | int64 | PRs ouvertes (champ separe) |
| `release_counter` | int64 | Nombre total de releases |
| `owner.login` | string | Login du proprietaire |
| `updated_at` | datetime | Derniere mise a jour |
**Alternative** : `GET /api/v1/repos/search` retourne tous les repos visibles (pas seulement ceux de l'utilisateur). Reponse enveloppee dans `{"ok": true, "data": [...]}`.
### 2.2 Derniere release par repo
**Endpoint** : `GET /api/v1/repos/{owner}/{repo}/releases/latest`
Retourne la release la plus recente (hors draft et pre-release). Un seul objet, pas de pagination.
**Champs cles** :
| Champ | Type | Description |
|-------|------|-------------|
| `tag_name` | string | Nom du tag git |
| `name` | string | Titre de la release |
| `published_at` | datetime | Date de publication |
| `prerelease` | boolean | Est une pre-release |
**Retourne HTTP 404** si le repo n'a aucune release.
### 2.3 Milestones par repo
**Endpoint** : `GET /api/v1/repos/{owner}/{repo}/milestones`
| Parametre | Type | Description |
|-----------|------|-------------|
| `state` | string | `open`, `closed`, `all` |
| `page` | integer | Numero de page |
| `limit` | integer | Elements par page |
**Champs cles** :
| Champ | Type | Description |
|-------|------|-------------|
| `title` | string | Nom du milestone |
| `state` | StateType | `open` ou `closed` |
| `open_issues` | int64 | Issues ouvertes dans le milestone |
| `closed_issues` | int64 | Issues fermees dans le milestone |
| `due_on` | datetime | Date d'echeance |
Le modele contient deja `open_issues` et `closed_issues` — progression calculable sans appel supplementaire.
---
## 3. Pagination
| Parametre | Defaut | Maximum |
|-----------|--------|---------|
| `page` | 1 | - |
| `limit` | 50 | 50 |
### Headers de reponse
| Header | Description |
|--------|-------------|
| `X-Total-Count` | Nombre total d'elements |
| `Link` | URLs de navigation (`rel="next"`, `rel="last"`) |
### Strategie recommandee
1. Premiere requete avec `?limit=50&page=1`
2. Lire `X-Total-Count` dans les headers
3. Si `total > limit` : calculer le nombre de pages, lancer les requetes suivantes
4. Alternative simple : boucler tant que `len(results) == limit`
**Source** : https://docs.gitea.com/development/api-usage (section Pagination)
---
## 4. Strategie d'appels
Pour N repos :
| Donnee | Appels | Endpoint |
|--------|--------|----------|
| Liste des repos | ceil(N/50) | `GET /user/repos` |
| Derniere release | N | `GET /repos/{o}/{r}/releases/latest` |
| Milestones | N | `GET /repos/{o}/{r}/milestones` |
| **Total** | **ceil(N/50) + 2N** | |
Issues ouvertes : derivees de `open_issues_count - open_pr_counter` du modele Repository (zero appel supplementaire).
**Parallelisation** : `concurrent.futures.ThreadPoolExecutor` avec 5-10 workers. La lib `requests` est thread-safe.
---
## 5. Cas limites
| Cas | Comportement API | Gestion |
|-----|-----------------|---------|
| Repo sans release | `/releases/latest` retourne 404 | Capturer, afficher "—" |
| Repo sans milestone | `/milestones` retourne `[]` | Tableau vide, rien a afficher |
| Repo sans issues | `open_issues_count` = 0 | Comportement standard |
| Repo fork | `fork` = true | Afficher avec indicateur visuel |
| Repo archive | `archived` = true | Afficher avec indicateur visuel |
| `open_issues_count` inclut PRs | Herite de GitHub | Calculer `open_issues_count - open_pr_counter` |
---
## 6. Decisions recommandees
| # | Decision | Recommandation | Justification |
|---|----------|---------------|---------------|
| 1 | Endpoint repos | `GET /user/repos` | Plus simple, reponse directe, suffisant pour v1 |
| 2 | Comptage issues | `open_issues_count - open_pr_counter` | Zero appel supplementaire |
| 3 | Forks/archives | Tout afficher avec indicateurs | Exhaustivite, pas de filtre en v1 |
| 4 | Parallelisation | ThreadPoolExecutor (5 workers) | Acceptable pour < 200 repos |

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gitea-dashboard"
version = "1.0.0"
description = "CLI dashboard for Gitea repos status"
requires-python = ">=3.10"
dependencies = [
"requests>=2.31",
"rich>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"ruff>=0.4",
]
[project.scripts]
gitea-dashboard = "gitea_dashboard.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
src = ["src", "tests"]
line-length = 100

View File

@@ -0,0 +1 @@
"""Gitea Dashboard — CLI dashboard for Gitea repos status."""

View File

@@ -0,0 +1,5 @@
"""Allow running with python -m gitea_dashboard."""
from gitea_dashboard.cli import main
main()

View File

@@ -0,0 +1,58 @@
"""Point d'entree pour le CLI gitea-dashboard."""
from __future__ import annotations
import os
import sys
import requests
from rich.console import Console
from gitea_dashboard.client import GiteaClient
from gitea_dashboard.collector import collect_all
from gitea_dashboard.display import render_dashboard
_DEFAULT_URL = "http://192.168.0.106:3000"
def main() -> None:
"""Point d'entree principal.
1. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis)
2. Cree le GiteaClient
3. Collecte les donnees via collect_all()
4. Affiche via render_dashboard()
5. Gere les erreurs : config manquante, connexion refusee, timeout
"""
console = Console(stderr=True)
token = os.environ.get("GITEA_TOKEN")
if not token:
console.print(
"[red]Erreur : GITEA_TOKEN non defini. Exportez la variable d'environnement.[/red]"
)
sys.exit(1)
url = os.environ.get("GITEA_URL", _DEFAULT_URL)
client = GiteaClient(url, token)
try:
repos = collect_all(client)
except requests.ConnectionError:
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
sys.exit(1)
except requests.Timeout:
console.print(
"[red]Erreur : delai d'attente depasse. Le serveur Gitea ne repond pas.[/red]"
)
sys.exit(1)
except requests.RequestException as exc:
# Ne jamais afficher le token dans les messages d'erreur
msg = str(exc)
if token in msg:
msg = msg.replace(token, "***")
console.print(f"[red]Erreur API : {msg}[/red]")
sys.exit(1)
render_dashboard(repos)

View File

@@ -0,0 +1,81 @@
"""Client API Gitea en lecture seule."""
from __future__ import annotations
import requests
class GiteaClient:
"""Client API Gitea en lecture seule.
Utilise requests.Session pour reutiliser les connexions HTTP.
Authentification via header Authorization: token <TOKEN>.
"""
_PAGE_LIMIT = 50
def __init__(self, base_url: str, token: str, timeout: int = 30) -> None:
"""Initialise le client avec l'URL de base et le token API.
Args:
base_url: URL de base de l'instance Gitea.
token: Token API pour l'authentification.
timeout: Delai maximum en secondes pour chaque requete (defaut: 30).
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers["Authorization"] = f"token {token}"
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
"""Requete GET avec pagination automatique.
Boucle tant que len(page) == limit (50).
"""
all_items: list[dict] = []
page = 1
merged_params = dict(params) if params else {}
while True:
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.raise_for_status()
items = resp.json()
all_items.extend(items)
if len(items) < self._PAGE_LIMIT:
break
page += 1
return all_items
def get_repos(self) -> list[dict]:
"""Retourne tous les repos de l'utilisateur (pagination automatique).
Endpoint: GET /api/v1/user/repos
"""
return self._get_paginated("/api/v1/user/repos")
def get_latest_release(self, owner: str, repo: str) -> dict | None:
"""Retourne la derniere release du repo, ou None si aucune.
Endpoint: GET /api/v1/repos/{owner}/{repo}/releases/latest
Gere HTTP 404 en retournant None.
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/releases/latest"
resp = self.session.get(url, timeout=self.timeout)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
def get_milestones(self, owner: str, repo: str) -> list[dict]:
"""Retourne les milestones ouvertes du repo.
Endpoint: GET /api/v1/repos/{owner}/{repo}/milestones?state=open
"""
return self._get_paginated(
f"/api/v1/repos/{owner}/{repo}/milestones",
params={"state": "open"},
)

View File

@@ -0,0 +1,52 @@
"""Collecte et agregation des donnees repos Gitea."""
from __future__ import annotations
from dataclasses import dataclass
from gitea_dashboard.client import GiteaClient
@dataclass
class RepoData:
"""Donnees agregees d'un repo."""
name: str
full_name: str
description: str
open_issues: int # open_issues_count - open_pr_counter
is_fork: bool
is_archived: bool
is_mirror: bool
latest_release: dict | None # {tag_name, published_at} ou None
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
def collect_all(client: GiteaClient) -> list[RepoData]:
"""Collecte les donnees de tous les repos.
Pour chaque repo : enrichit avec release et milestones.
Calcule open_issues = open_issues_count - open_pr_counter.
"""
repos = client.get_repos()
result: list[RepoData] = []
for repo in repos:
owner = repo["owner"]["login"]
name = repo["name"]
result.append(
RepoData(
name=name,
full_name=repo["full_name"],
description=repo.get("description", "") or "",
open_issues=repo["open_issues_count"] - repo["open_pr_counter"],
is_fork=repo["fork"],
is_archived=repo["archived"],
is_mirror=repo["mirror"],
latest_release=client.get_latest_release(owner, name),
milestones=client.get_milestones(owner, name),
)
)
return result

View File

@@ -0,0 +1,128 @@
"""Formatage et affichage Rich du dashboard Gitea."""
from __future__ import annotations
from datetime import datetime, timezone
from rich.console import Console
from rich.table import Table
from gitea_dashboard.collector import RepoData
def _format_repo_name(repo: RepoData) -> str:
"""Formate le nom du repo avec les indicateurs visuels."""
indicators = []
if repo.is_fork:
indicators.append("[F]")
if repo.is_archived:
indicators.append("[A]")
if repo.is_mirror:
indicators.append("[M]")
if indicators:
return f"{repo.name} {' '.join(indicators)}"
return repo.name
def _format_relative_date(iso_date: str) -> str:
"""Convertit une date ISO en date relative lisible."""
try:
dt = datetime.fromisoformat(iso_date.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return ""
now = datetime.now(timezone.utc)
delta = now - dt
days = delta.days
if days < 0:
return "dans le futur"
if days == 0:
return "aujourd'hui"
if days == 1:
return "il y a 1j"
if days < 30:
return f"il y a {days}j"
months = days // 30
if months < 12:
return f"il y a {months}m"
years = days // 365
return f"il y a {years}a"
def _format_release(release: dict | None) -> str:
"""Formate la release pour l'affichage."""
if release is None:
return "\u2014"
tag = release.get("tag_name", "")
published = release.get("published_at", "")
if published:
relative = _format_relative_date(published)
if relative:
return f"{tag} ({relative})"
return tag
def render_dashboard(repos: list[RepoData], console: Console | None = None) -> None:
"""Affiche le dashboard complet dans le terminal.
- Tableau principal : nom repo, indicateurs (fork/archive/mirror),
issues ouvertes, derniere release (tag + date relative)
- Section milestones : par repo ayant des milestones,
nom, progression (closed/total), date echeance
Le parametre console permet l'injection pour les tests.
"""
if console is None:
console = Console()
if not repos:
console.print("Aucun repo trouve.")
return
# Tableau principal
table = Table(title="Gitea Dashboard")
table.add_column("Repo", style="bold")
table.add_column("Issues", justify="right")
table.add_column("Release")
for repo in 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)
table.add_row(name, f"[{issues_style}]{issues_str}[/{issues_style}]", release_str)
console.print(table)
# Section milestones — uniquement si au moins un repo en a
repos_with_milestones = [r for r in repos if r.milestones]
if repos_with_milestones:
console.print()
console.print("[bold]Milestones[/bold]")
for repo in repos_with_milestones:
for ms in repo.milestones:
title = ms["title"]
closed = ms["closed_issues"]
total = ms["open_issues"] + ms["closed_issues"]
pct = round(closed / total * 100) if total > 0 else 0
line = f" {repo.name} / {title} : {closed}/{total} ({pct}%)"
due_on = ms.get("due_on")
if due_on:
# Extraire juste la date (YYYY-MM-DD)
try:
dt = datetime.fromisoformat(due_on.replace("Z", "+00:00"))
line += f" \u2014 echeance {dt.strftime('%Y-%m-%d')}"
except (ValueError, AttributeError):
pass
console.print(line)

0
tests/__init__.py Normal file
View File

127
tests/test_cli.py Normal file
View File

@@ -0,0 +1,127 @@
"""Tests for CLI entry point."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from gitea_dashboard.cli import main
class TestMainNominal:
"""Test main() happy path."""
@patch("gitea_dashboard.cli.render_dashboard")
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_main_runs_full_pipeline(self, mock_client_cls, mock_collect, mock_render):
"""main() creates client, collects, and renders."""
env = {"GITEA_TOKEN": "test-token-123", "GITEA_URL": "http://localhost:3000"}
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_collect.return_value = []
with patch.dict("os.environ", env, clear=False):
main()
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
mock_collect.assert_called_once_with(mock_client)
mock_render.assert_called_once_with(mock_collect.return_value)
@patch("gitea_dashboard.cli.render_dashboard")
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_main_uses_default_url(self, mock_client_cls, mock_collect, mock_render):
"""main() uses default URL when GITEA_URL is not set."""
env = {"GITEA_TOKEN": "my-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.return_value = []
with patch.dict("os.environ", env, clear=True):
main()
mock_client_cls.assert_called_once_with("http://192.168.0.106:3000", "my-token")
class TestMainMissingToken:
"""Test main() when GITEA_TOKEN is not set."""
def test_error_message_when_token_missing(self, capsys):
"""main() exits with code 1 and prints message mentioning GITEA_TOKEN."""
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "GITEA_TOKEN" in captured.err
class TestMainConnectionErrors:
"""Test main() error handling for network issues."""
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_connection_error_handled(self, mock_client_cls, mock_collect):
"""ConnectionError is caught and exits with code 1."""
env = {"GITEA_TOKEN": "test-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.side_effect = requests.ConnectionError("Connection refused")
with patch.dict("os.environ", env, clear=True):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_timeout_error_handled(self, mock_client_cls, mock_collect):
"""Timeout is caught and exits with code 1."""
env = {"GITEA_TOKEN": "test-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.side_effect = requests.Timeout("Request timed out")
with patch.dict("os.environ", env, clear=True):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_request_exception_handled(self, mock_client_cls, mock_collect):
"""Generic RequestException is caught and exits with code 1."""
env = {"GITEA_TOKEN": "test-token"}
mock_client_cls.return_value = MagicMock()
mock_collect.side_effect = requests.RequestException("Something went wrong")
with patch.dict("os.environ", env, clear=True):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("gitea_dashboard.cli.collect_all")
@patch("gitea_dashboard.cli.GiteaClient")
def test_token_not_in_error_output(self, mock_client_cls, mock_collect, capsys):
"""Token must never appear in error messages, even when present in the exception."""
env = {"GITEA_TOKEN": "super-secret-token-xyz"}
mock_client_cls.return_value = MagicMock()
# Build exception message that embeds the token value from env
# to simulate a real-world leak scenario
def make_exc(environ):
leaked = environ["GITEA_TOKEN"]
return requests.RequestException(f"HTTP Error: Authorization token {leaked} rejected")
import os as _os
with patch.dict("os.environ", env, clear=True):
mock_collect.side_effect = make_exc(_os.environ)
with pytest.raises(SystemExit):
main()
captured = capsys.readouterr()
assert env["GITEA_TOKEN"] not in captured.out
assert env["GITEA_TOKEN"] not in captured.err

160
tests/test_client.py Normal file
View File

@@ -0,0 +1,160 @@
"""Tests for GiteaClient API client."""
from unittest.mock import MagicMock, patch
from gitea_dashboard.client import GiteaClient
class TestGiteaClientInit:
"""Test client initialization and auth."""
def test_auth_header_is_set(self):
"""Session must carry Authorization header with token prefix."""
client = GiteaClient("http://gitea.local:3000", "my-secret-token")
assert client.session.headers["Authorization"] == "token my-secret-token"
def test_base_url_stored(self):
"""Base URL is stored without trailing slash."""
client = GiteaClient("http://gitea.local:3000/", "tok")
assert client.base_url == "http://gitea.local:3000"
def test_default_timeout_is_30(self):
"""Default timeout is 30 seconds."""
client = GiteaClient("http://gitea.local:3000", "tok")
assert client.timeout == 30
def test_custom_timeout_is_stored(self):
"""Custom timeout is stored and used."""
client = GiteaClient("http://gitea.local:3000", "tok", timeout=10)
assert client.timeout == 10
class TestGetPaginated:
"""Test internal pagination logic."""
def _make_client(self):
return GiteaClient("http://gitea.local:3000", "tok")
def test_single_page(self):
"""When response has fewer items than limit, stop after one request."""
client = self._make_client()
# Return 3 items (< 50 limit) -> single page
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
with patch.object(client.session, "get", return_value=mock_resp) as mock_get:
result = client._get_paginated("/api/v1/user/repos")
assert result == [{"id": 1}, {"id": 2}, {"id": 3}]
# Called exactly once (single page)
mock_get.assert_called_once()
def test_two_pages(self):
"""When first page is full (limit items), fetch second page."""
client = self._make_client()
page1 = [{"id": i} for i in range(50)] # Exactly limit=50
page2 = [{"id": i} for i in range(50, 60)] # 10 items -> last page
mock_resp1 = MagicMock()
mock_resp1.raise_for_status = MagicMock()
mock_resp1.json.return_value = page1
mock_resp2 = MagicMock()
mock_resp2.raise_for_status = MagicMock()
mock_resp2.json.return_value = page2
with patch.object(client.session, "get", side_effect=[mock_resp1, mock_resp2]):
result = client._get_paginated("/api/v1/user/repos")
assert len(result) == 60
assert result == page1 + page2
def test_pagination_params_forwarded(self):
"""Extra params are merged with pagination params."""
client = self._make_client()
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = []
with patch.object(client.session, "get", return_value=mock_resp) as mock_get:
client._get_paginated("/api/v1/repos/o/r/milestones", params={"state": "open"})
call_params = mock_get.call_args[1]["params"]
assert call_params["state"] == "open"
assert call_params["limit"] == 50
assert call_params["page"] == 1
class TestGetRepos:
"""Test get_repos method."""
def test_get_repos_calls_paginated(self):
"""get_repos delegates to _get_paginated with correct endpoint."""
client = GiteaClient("http://gitea.local:3000", "tok")
with patch.object(client, "_get_paginated", return_value=[{"id": 1}]) as mock_pag:
result = client.get_repos()
mock_pag.assert_called_once_with("/api/v1/user/repos")
assert result == [{"id": 1}]
class TestGetLatestRelease:
"""Test get_latest_release method."""
def test_returns_release_on_success(self):
"""Returns release dict when repo has a release."""
client = GiteaClient("http://gitea.local:3000", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"tag_name": "v1.0", "published_at": "2026-01-01"}
with patch.object(client.session, "get", return_value=mock_resp):
result = client.get_latest_release("admin", "my-repo")
assert result == {"tag_name": "v1.0", "published_at": "2026-01-01"}
def test_returns_none_on_404(self):
"""Returns None when repo has no release (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_release("admin", "no-release-repo")
assert result is None
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")
mock_resp = MagicMock()
mock_resp.status_code = 500
mock_resp.raise_for_status.side_effect = req.HTTPError("500 Server Error")
with patch.object(client.session, "get", return_value=mock_resp):
with pytest.raises(req.HTTPError):
client.get_latest_release("admin", "my-repo")
class TestGetMilestones:
"""Test get_milestones method."""
def test_get_milestones_calls_paginated_with_state_open(self):
"""get_milestones delegates to _get_paginated with state=open."""
client = GiteaClient("http://gitea.local:3000", "tok")
milestones = [{"title": "v2.0", "open_issues": 3, "closed_issues": 2}]
with patch.object(client, "_get_paginated", return_value=milestones) as mock_pag:
result = client.get_milestones("admin", "my-repo")
mock_pag.assert_called_once_with(
"/api/v1/repos/admin/my-repo/milestones",
params={"state": "open"},
)
assert result == milestones

130
tests/test_collector.py Normal file
View File

@@ -0,0 +1,130 @@
"""Tests for data collector."""
from unittest.mock import MagicMock
from gitea_dashboard.collector import RepoData, collect_all
def _make_repo(
name="my-repo",
full_name="admin/my-repo",
description="A repo",
open_issues_count=5,
open_pr_counter=2,
fork=False,
archived=False,
mirror=False,
owner_login="admin",
):
"""Build a fake repo dict as returned by the Gitea API."""
return {
"name": name,
"full_name": full_name,
"description": description,
"open_issues_count": open_issues_count,
"open_pr_counter": open_pr_counter,
"fork": fork,
"archived": archived,
"mirror": mirror,
"owner": {"login": owner_login},
}
class TestCollectAll:
"""Test collect_all function."""
def test_basic_repo(self):
"""Collects repo data with release and milestones."""
client = MagicMock()
client.get_repos.return_value = [_make_repo()]
client.get_latest_release.return_value = {
"tag_name": "v1.0",
"published_at": "2026-01-01",
}
client.get_milestones.return_value = [
{"title": "v2.0", "open_issues": 3, "closed_issues": 2, "due_on": None},
]
result = collect_all(client)
assert len(result) == 1
repo = result[0]
assert isinstance(repo, RepoData)
assert repo.name == "my-repo"
assert repo.full_name == "admin/my-repo"
assert repo.description == "A repo"
assert repo.open_issues == 3 # 5 - 2
assert repo.is_fork is False
assert repo.is_archived is False
assert repo.is_mirror is False
assert repo.latest_release == {"tag_name": "v1.0", "published_at": "2026-01-01"}
assert len(repo.milestones) == 1
def test_repo_without_release(self):
"""Repo with no release gets None for latest_release."""
client = MagicMock()
client.get_repos.return_value = [_make_repo()]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
result = collect_all(client)
assert result[0].latest_release is None
def test_repo_without_milestones(self):
"""Repo with no milestones gets empty list."""
client = MagicMock()
client.get_repos.return_value = [_make_repo()]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
result = collect_all(client)
assert result[0].milestones == []
def test_open_issues_subtracts_prs(self):
"""open_issues = open_issues_count - open_pr_counter."""
client = MagicMock()
client.get_repos.return_value = [
_make_repo(open_issues_count=10, open_pr_counter=4),
]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
result = collect_all(client)
assert result[0].open_issues == 6
def test_multiple_repos(self):
"""Collects data for multiple repos."""
client = MagicMock()
client.get_repos.return_value = [
_make_repo(name="repo-a", full_name="admin/repo-a"),
_make_repo(name="repo-b", full_name="org/repo-b", owner_login="org"),
]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
result = collect_all(client)
assert len(result) == 2
assert result[0].name == "repo-a"
assert result[1].name == "repo-b"
# Verify correct owner/repo passed to enrichment calls
client.get_latest_release.assert_any_call("admin", "repo-a")
client.get_latest_release.assert_any_call("org", "repo-b")
def test_fork_and_archived_flags(self):
"""Fork and archived flags are propagated."""
client = MagicMock()
client.get_repos.return_value = [
_make_repo(fork=True, archived=True, mirror=True),
]
client.get_latest_release.return_value = None
client.get_milestones.return_value = []
result = collect_all(client)
assert result[0].is_fork is True
assert result[0].is_archived is True
assert result[0].is_mirror is True

216
tests/test_display.py Normal file
View File

@@ -0,0 +1,216 @@
"""Tests for Rich dashboard display."""
from io import StringIO
from rich.console import Console
from gitea_dashboard.collector import RepoData
from gitea_dashboard.display import render_dashboard
def _make_console():
"""Create a console that captures output for testing.
highlight=False desactive le highlighting automatique de Rich
qui fragmente les nombres et noms avec des codes ANSI.
"""
buf = StringIO()
return Console(file=buf, force_terminal=True, width=120, highlight=False), buf
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,
):
"""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 [],
)
class TestRenderDashboardTable:
"""Test the main repos table rendering."""
def test_basic_repo_displayed(self):
"""Repo name and issues count appear in output."""
console, buf = _make_console()
repos = [
_make_repo(
name="mon-projet",
open_issues=3,
latest_release={"tag_name": "v1.2.0", "published_at": "2026-03-08T10:00:00Z"},
),
]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "mon-projet" in output
assert "v1.2.0" in output
def test_repo_without_release_shows_dash(self):
"""Repo with no release shows a dash character."""
console, buf = _make_console()
repos = [_make_repo(name="no-release", latest_release=None)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "no-release" in output
# The dash character for "no release"
assert "\u2014" in output or "" in output
def test_fork_indicator(self):
"""Fork repos show [F] indicator."""
console, buf = _make_console()
repos = [_make_repo(name="forked-repo", is_fork=True)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "forked-repo" in output
assert "[F]" in output
def test_archive_indicator(self):
"""Archived repos show [A] indicator."""
console, buf = _make_console()
repos = [_make_repo(name="old-repo", is_archived=True)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "[A]" in output
def test_mirror_indicator(self):
"""Mirror repos show [M] indicator."""
console, buf = _make_console()
repos = [_make_repo(name="mirror-repo", is_mirror=True)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "[M]" in output
def test_multiple_indicators(self):
"""Repo with multiple flags shows all indicators."""
console, buf = _make_console()
repos = [_make_repo(name="special", is_fork=True, is_archived=True)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "[F]" in output
assert "[A]" in output
def test_issues_zero(self):
"""Repos with 0 issues display 0."""
console, buf = _make_console()
repos = [_make_repo(name="clean-repo", open_issues=0)]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "0" in output
def test_multiple_repos(self):
"""Multiple repos all appear in the output."""
console, buf = _make_console()
repos = [
_make_repo(name="repo-alpha", open_issues=1),
_make_repo(name="repo-beta", open_issues=5),
]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "repo-alpha" in output
assert "repo-beta" in output
class TestRenderDashboardMilestones:
"""Test the milestones section rendering."""
def test_milestones_displayed(self):
"""Repos with milestones show milestone info."""
console, buf = _make_console()
repos = [
_make_repo(
name="mon-projet",
milestones=[
{
"title": "v2.0",
"open_issues": 2,
"closed_issues": 3,
"due_on": "2026-04-01T00:00:00Z",
},
],
),
]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "v2.0" in output
assert "3/5" in output # closed/total
assert "60%" in output
def test_milestone_without_due_date(self):
"""Milestone without due_on omits the deadline."""
console, buf = _make_console()
repos = [
_make_repo(
name="projet",
milestones=[
{
"title": "backlog",
"open_issues": 5,
"closed_issues": 1,
"due_on": None,
},
],
),
]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "backlog" in output
assert "1/6" in output
def test_no_milestones_section_when_none(self):
"""When no repos have milestones, milestone section is absent."""
console, buf = _make_console()
repos = [_make_repo(name="simple", milestones=[])]
render_dashboard(repos, console=console)
output = buf.getvalue()
assert "Milestones" not in output
class TestRenderDashboardEmpty:
"""Test empty repo list handling."""
def test_empty_list_shows_message(self):
"""Empty repo list shows informative message."""
console, buf = _make_console()
render_dashboard([], console=console)
output = buf.getvalue()
assert "Aucun repo" in output