Compare commits
18 Commits
5bedb1f8ea
...
cf88ba0ef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf88ba0ef5 | ||
|
|
8b4677a6f7 | ||
|
|
6ed666fb66 | ||
|
|
b15ba9eea8 | ||
|
|
6fa8990cae | ||
|
|
01f88a0eca | ||
|
|
e05578676f | ||
|
|
4aa648fa8c | ||
|
|
8fbdfcafd4 | ||
|
|
b52bc72ce8 | ||
|
|
4d66aea6ed | ||
|
|
18ce3b953e | ||
|
|
e757c35767 | ||
|
|
0bd64d64a9 | ||
|
|
de56585840 | ||
|
|
2eec10c61a | ||
|
|
4e72ddc32f | ||
|
|
11e5def11c |
85
.claude/workflow-progress.md
Normal file
85
.claude/workflow-progress.md
Normal 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
15
.gitignore
vendored
Normal 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
19
CHANGELOG.md
Normal 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
49
CLAUDE.md
Normal 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) |
|
||||||
71
README.md
71
README.md
@@ -1,3 +1,72 @@
|
|||||||
# gitea-dashboard
|
# 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
48
docs/README.md
Normal 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`) |
|
||||||
51
docs/discovery/synthesis.md
Normal file
51
docs/discovery/synthesis.md
Normal 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
294
docs/plans/v1.0.0-plan.md
Normal 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
23
docs/project/demandes.md
Normal 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
|
||||||
|
|
||||||
|
-
|
||||||
50
docs/project/descriptif.md
Normal file
50
docs/project/descriptif.md
Normal 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/
|
||||||
145
docs/technical/ARCHITECTURE.md
Normal file
145
docs/technical/ARCHITECTURE.md
Normal 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)
|
||||||
48
docs/technical/decisions.md
Normal file
48
docs/technical/decisions.md
Normal 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
164
docs/technical/research.md
Normal 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
32
pyproject.toml
Normal 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
|
||||||
1
src/gitea_dashboard/__init__.py
Normal file
1
src/gitea_dashboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Gitea Dashboard — CLI dashboard for Gitea repos status."""
|
||||||
5
src/gitea_dashboard/__main__.py
Normal file
5
src/gitea_dashboard/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Allow running with python -m gitea_dashboard."""
|
||||||
|
|
||||||
|
from gitea_dashboard.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
58
src/gitea_dashboard/cli.py
Normal file
58
src/gitea_dashboard/cli.py
Normal 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)
|
||||||
81
src/gitea_dashboard/client.py
Normal file
81
src/gitea_dashboard/client.py
Normal 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"},
|
||||||
|
)
|
||||||
52
src/gitea_dashboard/collector.py
Normal file
52
src/gitea_dashboard/collector.py
Normal 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
|
||||||
128
src/gitea_dashboard/display.py
Normal file
128
src/gitea_dashboard/display.py
Normal 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
0
tests/__init__.py
Normal file
127
tests/test_cli.py
Normal file
127
tests/test_cli.py
Normal 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
160
tests/test_client.py
Normal 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
130
tests/test_collector.py
Normal 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
216
tests/test_display.py
Normal 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
|
||||||
Reference in New Issue
Block a user