Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d22abbde3 | ||
|
|
540927261e | ||
|
|
d2686971ae | ||
|
|
16344bbb3f | ||
|
|
15ed533d20 | ||
|
|
1b33cd36f9 | ||
|
|
2ef7ec175e | ||
|
|
b40dea32f4 | ||
|
|
9783389bfb | ||
|
|
7dab240dce | ||
|
|
be8e89114c | ||
|
|
da6baf3696 |
@@ -7,10 +7,10 @@
|
|||||||
| Chemin | /home/sylvain/nas/perso/sylvain/conserver/code/application_temp/gitea-dashboard |
|
| Chemin | /home/sylvain/nas/perso/sylvain/conserver/code/application_temp/gitea-dashboard |
|
||||||
| Date de creation | 2026-03-10 |
|
| Date de creation | 2026-03-10 |
|
||||||
| Origine | gitea@192.168.0.106:admin/gitea-dashboard.git |
|
| Origine | gitea@192.168.0.106:admin/gitea-dashboard.git |
|
||||||
| Version courante | v1.2.0 |
|
| Version courante | v1.3.0 |
|
||||||
| Track | minor |
|
| Track | minor |
|
||||||
| Phase courante | 4 — PUBLICATION |
|
| Phase courante | 2 — DEV |
|
||||||
| Etape courante | 11 (pending) |
|
| Etape courante | 9 (done) |
|
||||||
| workflow_version | v1.1 |
|
| workflow_version | v1.1 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -75,9 +75,22 @@
|
|||||||
| 8 | Audit + corrections | done | 2026-03-12 | reviewer + guardian + fixer | Auto (score 100) | step_8: done, audit_initial: 78 (reviewer) / 91 (guardian), audit_final: 100, rounds: 3, corrections: 4 (sort milestones, sort JSON, import lazy, extract helper) |
|
| 8 | Audit + corrections | done | 2026-03-12 | reviewer + guardian + fixer | Auto (score 100) | step_8: done, audit_initial: 78 (reviewer) / 91 (guardian), audit_final: 100, rounds: 3, corrections: 4 (sort milestones, sort JSON, import lazy, extract helper) |
|
||||||
| 9 | Smoke test | done | 2026-03-12 | tester + checklist | Auto (E2E + checklist) | step_9: done, mode: cli, rounds: 1, tests: 7/7 passed, coverage: 98% |
|
| 9 | Smoke test | done | 2026-03-12 | tester + checklist | Auto (E2E + checklist) | step_9: done, mode: cli, rounds: 1, tests: 7/7 passed, coverage: 98% |
|
||||||
| 10 | Documentation | merged_with_11 | 2026-03-12 | - | - | step_10: merged_with_11, pas de docs/guides ni OpenAPI |
|
| 10 | Documentation | merged_with_11 | 2026-03-12 | - | - | step_10: merged_with_11, pas de docs/guides ni OpenAPI |
|
||||||
|
| 11 | Release | done | 2026-03-12 | /release | Auto (release creee) | step_11: done, tag: v1.2.0, mode: lightweight, guardian: APPROVED, issues: #6-#10 closed |
|
||||||
|
| 12 | Deploy (optionnel) | skipped | 2026-03-12 | - | - | CLI local, pas de deploy |
|
||||||
|
| 13 | Retrospective | done | 2026-03-12 | documenter | Auto (metriques et analyse) | step_13: done, metrics_written: true, analysis_written: true, gitea_milestone: closed |
|
||||||
|
|
||||||
|
## Phase 2 — DEV (v1.3.0)
|
||||||
|
|
||||||
|
| # | Etape | Statut | Date | Agent/Skill | Validation | Notes |
|
||||||
|
|---|-------|--------|------|-------------|------------|-------|
|
||||||
|
| 6 | Plan de version | done | 2026-03-12 | architect | Auto (plan avec phases, budget scope) | step_6: done, plan: docs/plans/v1.3.0-plan.md, phases: 3, ADR-009/010/011, gitea_milestone: exists (id:46) |
|
||||||
|
| 7 | Developpement | done | 2026-03-12 | orchestrator | Auto (tests passent) | step_7: done, commits: 3, files_modified: 5, tests: 118 passed (88 existing + 30 new), fixes #11-#15 |
|
||||||
|
| 8 | Audit + corrections | done | 2026-03-12 | reviewer + guardian + fixer | Auto (score 100) | step_8: done, audit_initial: 81 (reviewer) / 87 (guardian), audit_final: 100, rounds: 2, corrections: 3 (Retry-After cap/fallback, test health partial) |
|
||||||
|
| 9 | Smoke test | done | 2026-03-12 | tester + checklist | Auto (E2E + checklist) | step_9: done, mode: cli, rounds: 1, tests: 8/8 passed, coverage: 99% |
|
||||||
|
| 10 | Documentation | - | - | - | - | A determiner (fusion avec 11 probable) |
|
||||||
| 11 | Release | - | - | /release | Auto (release creee) | - |
|
| 11 | Release | - | - | /release | Auto (release creee) | - |
|
||||||
| 12 | Deploy (optionnel) | - | - | - | - | - |
|
| 12 | Deploy (optionnel) | - | - | - | - | CLI local, pas de deploy |
|
||||||
| 13 | Retrospective | - | - | documenter | Auto (metriques + analyse) | - |
|
| 13 | Retrospective | - | - | documenter | Auto (metriques et analyse) | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,6 +136,14 @@
|
|||||||
| 2026-03-12 | step 8 done | Audit: reviewer 78→100, guardian 91 (APPROVED), 3 rounds, 4 corrections, score final 100 |
|
| 2026-03-12 | step 8 done | Audit: reviewer 78→100, guardian 91 (APPROVED), 3 rounds, 4 corrections, score final 100 |
|
||||||
| 2026-03-12 | step 9 done | Smoke test CLI reel, 7/7 tests E2E, retrocompat OK, JSON OK, tri OK, filtre OK |
|
| 2026-03-12 | step 9 done | Smoke test CLI reel, 7/7 tests E2E, retrocompat OK, JSON OK, tri OK, filtre OK |
|
||||||
| 2026-03-12 | step 10 merged_with_11 | Pas de docs/guides ni OpenAPI |
|
| 2026-03-12 | step 10 merged_with_11 | Pas de docs/guides ni OpenAPI |
|
||||||
|
| 2026-03-12 | step 11 done | Tag v1.2.0, release Gitea, push origin, guardian APPROVED, lightweight mode, issues #6-#10 closed |
|
||||||
|
| 2026-03-12 | step 12 skipped | CLI local, pas de deploy |
|
||||||
|
| 2026-03-12 | step 13 done | Retrospective, metriques, analyse, milestone fermee |
|
||||||
|
| 2026-03-12 | Start v1.3.0 at step 6 | Minor track, 5 issues ouvertes: #11 (429 retry), #12 (JSON faux positif), #13 (tests edge), #14 (--health), #15 (description repos) |
|
||||||
|
| 2026-03-12 | step 6 done | Plan v1.3.0 (3 phases, 9 fichiers, ADR-009/010/011), milestone exists (id:46), labels #14/#15 ajoutés |
|
||||||
|
| 2026-03-12 | step 7 done | 3 commits (1/phase), 5 fichiers modifiés, 118 tests (30 nouveaux), fixes #11-#15 |
|
||||||
|
| 2026-03-12 | step 8 done | Audit: reviewer 81→100, guardian 87→100, 2 rounds, 3 corrections (Retry-After), 122 tests |
|
||||||
|
| 2026-03-12 | step 9 done | Smoke test CLI réel, 8/8 tests E2E, rétrocompat OK, --health OK, description OK, JSON pipe OK |
|
||||||
|
|
||||||
## Versions completees
|
## Versions completees
|
||||||
|
|
||||||
@@ -130,3 +151,5 @@
|
|||||||
|---------|-----------|----------|-------|
|
|---------|-----------|----------|-------|
|
||||||
| v1.0.0 | 2026-03-10 | 2026-03-10 | major-initial, 12/13 steps, audit 97, 37 tests |
|
| v1.0.0 | 2026-03-10 | 2026-03-10 | major-initial, 12/13 steps, audit 97, 37 tests |
|
||||||
| v1.1.0 | 2026-03-11 | 2026-03-11 | minor, 7/8 steps (10 merged, 12 skipped), audit 100, 53 tests |
|
| v1.1.0 | 2026-03-11 | 2026-03-11 | minor, 7/8 steps (10 merged, 12 skipped), audit 100, 53 tests |
|
||||||
|
| v1.2.0 | 2026-03-12 | 2026-03-12 | minor, 7/8 steps (10 merged, 12 skipped), audit 100, 88 tests |
|
||||||
|
| v1.3.0 | 2026-03-12 | - | minor, en cours |
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Option `--health` pour vérifier la connexion Gitea (affiche version de l'instance et nombre de repos accessibles, exit code 0 si OK, 1 sinon)
|
||||||
|
- Colonne "Description" dans le tableau principal (tronquée à 40 caractères)
|
||||||
|
- Option `--no-desc` pour masquer la colonne description
|
||||||
|
- Tests edge cases : unicode, repos vides, API malformée, caractères de contrôle (30 nouveaux tests)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Retry : gestion de HTTP 429 (rate limiting) avec respect du header `Retry-After`
|
||||||
|
- Validation du header `Retry-After` (cap à 30 s, fallback sur backoff exponentiel pour les dates HTTP)
|
||||||
|
- Export JSON : sanitisation des caractères de contrôle invalides (issue #12)
|
||||||
|
|
||||||
## [1.2.0] - 2026-03-12
|
## [1.2.0] - 2026-03-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -46,5 +61,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Gestion des erreurs réseau (connexion refusée, timeout, erreurs API)
|
- Gestion des erreurs réseau (connexion refusée, timeout, erreurs API)
|
||||||
- Masquage du token dans les messages d'erreur
|
- Masquage du token dans les messages d'erreur
|
||||||
|
|
||||||
|
[1.3.0]: https://gitea.tsmse.fr/admin/gitea-dashboard/compare/v1.2.0...v1.3.0
|
||||||
[1.2.0]: https://gitea.tsmse.fr/admin/gitea-dashboard/compare/v1.1.0...v1.2.0
|
[1.2.0]: https://gitea.tsmse.fr/admin/gitea-dashboard/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://gitea.tsmse.fr/admin/gitea-dashboard/compare/v1.0.0...v1.1.0
|
[1.1.0]: https://gitea.tsmse.fr/admin/gitea-dashboard/compare/v1.0.0...v1.1.0
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -39,6 +39,15 @@ gitea-dashboard
|
|||||||
python -m gitea_dashboard
|
python -m gitea_dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Vérification de la connexion
|
||||||
|
|
||||||
|
L'option `--health` vérifie que l'instance Gitea est accessible et affiche sa version ainsi que le nombre de repos disponibles. Exit code 0 si la connexion réussit, 1 sinon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-dashboard --health
|
||||||
|
# Gitea 1.21.4 — 12 repos accessibles
|
||||||
|
```
|
||||||
|
|
||||||
### Filtrage des repos
|
### Filtrage des repos
|
||||||
|
|
||||||
L'option `--repo`/`-r` filtre les repos à afficher (sous-chaîne, insensible à la casse).
|
L'option `--repo`/`-r` filtre les repos à afficher (sous-chaîne, insensible à la casse).
|
||||||
@@ -78,6 +87,14 @@ gitea-dashboard --sort issues
|
|||||||
gitea-dashboard -s activity
|
gitea-dashboard -s activity
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Colonne Description
|
||||||
|
|
||||||
|
Le tableau affiche par défaut une colonne "Description" (tronquée à 40 caractères). Pour la masquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-dashboard --no-desc
|
||||||
|
```
|
||||||
|
|
||||||
### Export JSON
|
### Export JSON
|
||||||
|
|
||||||
L'option `--format json` exporte les données du dashboard au format JSON au lieu de l'affichage tabulaire. Utile pour intégrer le dashboard dans d'autres outils.
|
L'option `--format json` exporte les données du dashboard au format JSON au lieu de l'affichage tabulaire. Utile pour intégrer le dashboard dans d'autres outils.
|
||||||
@@ -90,13 +107,13 @@ gitea-dashboard --format json > export.json
|
|||||||
### Exemple de sortie
|
### Exemple de sortie
|
||||||
|
|
||||||
```
|
```
|
||||||
Gitea Dashboard
|
Gitea Dashboard
|
||||||
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
|
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||||
┃ Repo ┃ Issues ┃ Release ┃ Dernier commit ┃
|
┃ Repo ┃ Issues ┃ Release ┃ Dernier commit ┃ Description ┃
|
||||||
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
|
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||||
│ mon-projet │ 3 │ v1.2.0 (il y a 2j) │ il y a 3h │
|
│ mon-projet │ 3 │ v1.3.0 (il y a 2j) │ il y a 3h │ Mon super projet de dashboard │
|
||||||
│ autre-repo │ 0 │ — │ il y a 5j │
|
│ autre-repo │ 0 │ — │ il y a 5j │ — │
|
||||||
└─────────────────┴────────┴──────────────────────┴────────────────┘
|
└─────────────────┴────────┴──────────────────────┴────────────────┴──────────────────────────────────────────┘
|
||||||
|
|
||||||
Milestones
|
Milestones
|
||||||
mon-projet / v2.0 : 3/5 (60%) [échéance dépassée]
|
mon-projet / v2.0 : 3/5 (60%) [échéance dépassée]
|
||||||
|
|||||||
67
docs/analyse/gitea-dashboard-v1.2.0-2026-03-12.md
Normal file
67
docs/analyse/gitea-dashboard-v1.2.0-2026-03-12.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Analyse v1.2.0 — gitea-dashboard
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Track** : minor
|
||||||
|
**Issues** : #6, #7, #8, #9, #10 (5/5 fermees)
|
||||||
|
|
||||||
|
## Metriques
|
||||||
|
|
||||||
|
| Metrique | v1.1.0 | v1.2.0 | Delta | Seuil | Alerte |
|
||||||
|
|----------|--------|--------|-------|-------|--------|
|
||||||
|
| Modules source | 5 | 7 | +2 | — | — |
|
||||||
|
| Lignes source | ~400 | 551 | +38% | — | — |
|
||||||
|
| Fichiers test | 4 | 6 | +2 | — | — |
|
||||||
|
| Tests | 53 | 88 | +66% | +50% | OUI |
|
||||||
|
| Couverture | ~95% | 93% | -2% | -5% | non |
|
||||||
|
| Dependances | 2 | 2 | 0 | +5 | non |
|
||||||
|
| Audit initial | 94 | 78 | -16 | — | — |
|
||||||
|
| Audit final | 100 | 100 | 0 | — | — |
|
||||||
|
| Rounds audit | 2 | 3 | +1 | — | — |
|
||||||
|
|
||||||
|
### Alerte : tests +66%
|
||||||
|
|
||||||
|
La croissance des tests depasse le seuil de +50%. C'est attendu pour une version minor
|
||||||
|
ajoutant 5 fonctionnalites (4 Added + 1 Fixed). Le ratio tests/fonctionnalite reste stable
|
||||||
|
(~7 tests/fonctionnalite). Pas d'action corrective necessaire.
|
||||||
|
|
||||||
|
## Chronologie
|
||||||
|
|
||||||
|
| Etape | Duree estimee | Notes |
|
||||||
|
|-------|--------------|-------|
|
||||||
|
| 6 Plan | rapide | architect, 3 phases, 3 ADR |
|
||||||
|
| 7 Dev | moyen | orchestrator (8 fichiers), 1 commit |
|
||||||
|
| 8 Audit | moyen | 3 rounds (78→94→100), 4 corrections |
|
||||||
|
| 9 Smoke | rapide | 7/7 E2E, 1 round |
|
||||||
|
| 10 Docs | fusionne avec 11 | — |
|
||||||
|
| 11 Release | rapide | lightweight, guardian APPROVED |
|
||||||
|
| 12 Deploy | skip | CLI local |
|
||||||
|
| 13 Retro | rapide | metriques + analyse |
|
||||||
|
|
||||||
|
## Findings d'audit corriges
|
||||||
|
|
||||||
|
1. **Sort milestones** : la section milestones utilisait la liste non triee
|
||||||
|
2. **Sort JSON** : `--sort` etait ignore en mode `--format json`
|
||||||
|
3. **Import lazy** : `export_json` importe conditionnellement dans le corps de main()
|
||||||
|
4. **Helper duplique** : `_make_repo` identique dans test_display.py et test_exporter.py
|
||||||
|
5. **N+1 API** : declasse en dette documentee (ADR-003), 3 appels/repo accepte
|
||||||
|
|
||||||
|
## Decisions notables
|
||||||
|
|
||||||
|
- **ADR-006** : ajout de `exporter.py` (5e module), separation serialisation/affichage
|
||||||
|
- **ADR-007** : retry manuel plutot que urllib3.Retry (simplicite, testabilite)
|
||||||
|
- **ADR-008** : tri dans display.py, pas collector.py (SRP)
|
||||||
|
- **sort_repos rendu public** : necessaire pour le tri JSON dans cli.py
|
||||||
|
|
||||||
|
## Points d'amelioration pour v1.3+
|
||||||
|
|
||||||
|
- Parallelisation des appels API (ADR-003, 3 appels sequentiels par repo)
|
||||||
|
- Export CSV
|
||||||
|
- Cache API local
|
||||||
|
- Couverture display.py a 86% (branches de formatage de dates)
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Version v1.2.0 livree avec les 5 fonctionnalites prevues. Audit final 100/100.
|
||||||
|
Le score initial d'audit (78) est le plus bas depuis v1.0.0, principalement du a
|
||||||
|
des bugs introduits par l'orchestrateur (sort inconsistency, sort JSON). Les corrections
|
||||||
|
ont ete rapides (3 rounds). La dette N+1 est documentee et planifiee.
|
||||||
558
docs/plans/v1.3.0-plan.md
Normal file
558
docs/plans/v1.3.0-plan.md
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
<!-- Type: reference (Diataxis). Style: factuel, structure par phases, actionnable par le builder. -->
|
||||||
|
|
||||||
|
# Plan de version v1.3.0 — gitea-dashboard
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger la gestion du rate limiting HTTP 429 dans le retry, investiguer et corriger les caracteres de controle dans l'export JSON, ajouter des tests edge cases manquants, une commande `--health` de diagnostic, et l'affichage de la description des repos.
|
||||||
|
|
||||||
|
## Track
|
||||||
|
|
||||||
|
**Minor** : 6 -> 7 -> 8 -> 9 -> 10 -> 11 -> (12) -> 13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget de scope
|
||||||
|
|
||||||
|
| Critere | Valeur |
|
||||||
|
|---------|--------|
|
||||||
|
| Max fichiers par phase | 5 |
|
||||||
|
| Total fichiers estimes | 9 (5 modules modifies + 4 fichiers de tests modifies) |
|
||||||
|
| Fichiers crees | 0 |
|
||||||
|
| Tests estimes | ~25 nouveaux (total ~113) |
|
||||||
|
|
||||||
|
### Inclus
|
||||||
|
|
||||||
|
- Gestion du HTTP 429 (rate limiting) dans le retry (#11)
|
||||||
|
- Investigation et correction des caracteres de controle dans l'export JSON (#12)
|
||||||
|
- Tests edge cases : unicode, repos vides, 429, API malformee, caracteres de controle (#13)
|
||||||
|
- Commande `--health` pour verifier la connexion Gitea (#14)
|
||||||
|
- Colonne "Description" dans le tableau avec troncature a 40 chars et option `--no-desc` (#15)
|
||||||
|
|
||||||
|
### Exclus
|
||||||
|
|
||||||
|
- Parallelisation des appels API (ADR-003, differee)
|
||||||
|
- Export CSV (hors scope)
|
||||||
|
- Cache API local (differe)
|
||||||
|
- Dashboard interactif TUI (differe)
|
||||||
|
|
||||||
|
### Differe (v1.4+)
|
||||||
|
|
||||||
|
- Parallelisation des appels API
|
||||||
|
- Export CSV/YAML
|
||||||
|
- Cache API local (fichier/SQLite)
|
||||||
|
- Dashboard interactif (TUI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etapes skippees
|
||||||
|
|
||||||
|
| Etape | Nom | Raison |
|
||||||
|
|-------|-----|--------|
|
||||||
|
| 1 | Discovery | Projet existant, discovery v1.0.0 suffisante |
|
||||||
|
| 2 | Project creation | Projet existant |
|
||||||
|
| 3 | Specs | Minor — specs couvertes par les issues #11-#15 et ce plan |
|
||||||
|
| 4 | Research | Pas de technologie nouvelle, API connue |
|
||||||
|
| 5 | Roadmap | Minor — milestone v1.3.0 deja creee sur Gitea |
|
||||||
|
| 12 | Deploy | Outil CLI local, pas de deploiement serveur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse des dependances entre issues
|
||||||
|
|
||||||
|
```
|
||||||
|
#11 (retry 429) -- fondation, aucune dependance
|
||||||
|
#12 (JSON caracteres de controle) -- aucune dependance, module exporter.py
|
||||||
|
#13 (edge cases) -- necessite #11 (tests 429) et #12 (tests caracteres controle)
|
||||||
|
#14 (--health) -- aucune dependance, nouveau endpoint client
|
||||||
|
#15 (description repos) -- aucune dependance, display + cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Regroupement logique :
|
||||||
|
- #11 + #12 + #13 sont lies : les bugs (#11, #12) doivent etre corriges avant que les tests edge cases (#13) puissent les couvrir.
|
||||||
|
- #14 et #15 sont des features independantes.
|
||||||
|
|
||||||
|
Ordre : (#11 + #12) -> #13 -> (#14 + #15)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 : Corrections et robustesse (#11, #12)
|
||||||
|
|
||||||
|
**Goal** : Corriger le retry pour gerer le rate limiting HTTP 429, et sanitizer les caracteres de controle dans l'export JSON.
|
||||||
|
|
||||||
|
**Issues Gitea** : fixes #11, fixes #12
|
||||||
|
|
||||||
|
### Fichiers
|
||||||
|
|
||||||
|
| Action | Fichier | Modifications | Cross-references |
|
||||||
|
|--------|---------|---------------|------------------|
|
||||||
|
| Modify | `src/gitea_dashboard/client.py` | `_get_with_retry` : intercepter HTTPError pour status 429, respecter le header `Retry-After`, retenter apres le delai indique (ou backoff par defaut si absent) | `cli.py` (gestion erreur finale) |
|
||||||
|
| Modify | `src/gitea_dashboard/exporter.py` | `export_json` : sanitizer les caracteres de controle (ASCII 0x00-0x1F sauf \n \r \t) dans les champs texte avant serialisation JSON. Ou bien desactiver le markup Rich si le format est JSON | `cli.py` (routage format) |
|
||||||
|
| Modify | `tests/test_client.py` | Tests retry sur HTTP 429, avec et sans Retry-After | `client.py` |
|
||||||
|
| Modify | `tests/test_exporter.py` | Tests export JSON avec caracteres de controle dans description | `exporter.py` |
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
#### client.py (modifications)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GiteaClient:
|
||||||
|
def _get_with_retry(self, url: str, params: dict | None = None) -> requests.Response:
|
||||||
|
"""GET avec retry automatique sur timeout ET rate limiting (HTTP 429).
|
||||||
|
|
||||||
|
Comportement actuel : retry sur requests.Timeout uniquement.
|
||||||
|
Ajout : si la reponse HTTP est 429 (Too Many Requests),
|
||||||
|
respecter le header Retry-After (en secondes) pour le delai d'attente.
|
||||||
|
Si Retry-After est absent, utiliser le backoff lineaire standard.
|
||||||
|
|
||||||
|
Retente jusqu'a _MAX_RETRIES fois.
|
||||||
|
Leve requests.HTTPError si 429 persiste apres epuisement des retries.
|
||||||
|
Leve requests.Timeout si timeout persiste.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi intercepter le 429 dans `_get_with_retry`** : le rate limiting est une preoccupation du transport HTTP, au meme titre que le timeout. Le client est le bon endroit car il centralise deja la logique de retry (ADR-007). L'alternative serait de verifier le status code apres chaque appel dans `_get_paginated`, mais cela dupliquerait la logique.
|
||||||
|
|
||||||
|
**Pourquoi respecter Retry-After** : c'est le mecanisme standard HTTP (RFC 7231 Section 7.1.3). Gitea peut indiquer un delai specifique. L'ignorer revient a retenter trop tot et echouer de nouveau.
|
||||||
|
|
||||||
|
#### exporter.py (modifications)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _sanitize_control_chars(text: str) -> str:
|
||||||
|
"""Supprime les caracteres de controle ASCII (0x00-0x1F) sauf newline,
|
||||||
|
carriage return et tab.
|
||||||
|
|
||||||
|
Ces caracteres peuvent provenir de descriptions de repos Gitea
|
||||||
|
et causent des erreurs JSON ('Invalid control character').
|
||||||
|
"""
|
||||||
|
|
||||||
|
def repos_to_dicts(repos: list[RepoData]) -> list[dict]:
|
||||||
|
"""Convertit une liste de RepoData en liste de dicts serialisables.
|
||||||
|
|
||||||
|
Sanitize les champs texte (name, full_name, description) pour
|
||||||
|
supprimer les caracteres de controle invalides en JSON.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi sanitizer dans exporter.py et non dans collector.py** : les caracteres de controle ne posent probleme que pour la serialisation JSON. Le rendu Rich les gere nativement. Sanitizer dans le collecteur modifierait les donnees pour tous les consommateurs, ce qui n'est pas souhaitable. Le point de sortie (exporter) est le bon endroit.
|
||||||
|
|
||||||
|
**Pourquoi ne pas simplement desactiver Rich** : le probleme n'est pas Rich (les codes ANSI ne sont pas injectes dans l'export JSON car `print()` est utilise, pas `Console.print()`). Le probleme vient des caracteres de controle dans les donnees source (descriptions de repos). La sanitisation est la correction correcte.
|
||||||
|
|
||||||
|
### Comportement attendu
|
||||||
|
|
||||||
|
1. HTTP 429 avec Retry-After :
|
||||||
|
```
|
||||||
|
GET /api/v1/user/repos -> 429, Retry-After: 5
|
||||||
|
# Attend 5 secondes
|
||||||
|
GET /api/v1/user/repos -> 200 OK
|
||||||
|
# Transparent pour l'utilisateur
|
||||||
|
```
|
||||||
|
|
||||||
|
2. HTTP 429 sans Retry-After :
|
||||||
|
```
|
||||||
|
GET /api/v1/user/repos -> 429
|
||||||
|
# Attend 1s (backoff lineaire standard)
|
||||||
|
GET /api/v1/user/repos -> 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
3. HTTP 429 persistant (apres max retries) :
|
||||||
|
```
|
||||||
|
GET -> 429, GET -> 429, GET -> 429
|
||||||
|
# Leve HTTPError, attrape par cli.py (RequestException handler)
|
||||||
|
# Message : "Erreur API : 429 Too Many Requests"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Export JSON avec caracteres de controle dans la description :
|
||||||
|
```
|
||||||
|
$ gitea-dashboard --format json | python3 -m json.tool
|
||||||
|
# Plus d'erreur "Invalid control character"
|
||||||
|
# Les caracteres de controle sont supprimes silencieusement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
#### test_client.py (ajouts)
|
||||||
|
|
||||||
|
- `test_retry_on_429_with_retry_after` : reponse 429 avec Retry-After: 2, puis 200. Verifie que `time.sleep` est appele avec 2.0 et que la reponse finale est 200.
|
||||||
|
- `test_retry_on_429_without_retry_after` : reponse 429 sans header, puis 200. Verifie que le backoff lineaire standard est utilise.
|
||||||
|
- `test_retry_on_429_exhausted` : 3 reponses 429 -> leve HTTPError.
|
||||||
|
- `test_retry_on_429_then_timeout` : 429 puis Timeout. Verifie que les deux types sont geres dans la meme boucle.
|
||||||
|
|
||||||
|
#### test_exporter.py (ajouts)
|
||||||
|
|
||||||
|
- `test_export_json_sanitizes_control_chars` : description avec `\x00\x01\x02` -> JSON valide sans ces caracteres.
|
||||||
|
- `test_export_json_preserves_newlines_tabs` : description avec `\n` et `\t` -> preserves dans le JSON.
|
||||||
|
- `test_export_json_unicode_safe` : description avec emojis et accents -> JSON valide.
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Le retry gere les HTTP 429 avec respect du Retry-After. L'export JSON ne contient plus de caracteres de controle invalides. Tous les tests passent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 : Tests edge cases (#13)
|
||||||
|
|
||||||
|
**Goal** : Ajouter une couverture de tests pour les cas limites non couverts par les 88 tests existants.
|
||||||
|
|
||||||
|
**Issues Gitea** : fixes #13
|
||||||
|
|
||||||
|
### Fichiers
|
||||||
|
|
||||||
|
| Action | Fichier | Modifications | Cross-references |
|
||||||
|
|--------|---------|---------------|------------------|
|
||||||
|
| Modify | `tests/test_collector.py` | Tests RepoData avec unicode, repo 0 commits (deja couvert partiellement, completer) | `collector.py` |
|
||||||
|
| Modify | `tests/test_client.py` | Test reponse API malformee (JSON invalide) | `client.py` |
|
||||||
|
| Modify | `tests/test_display.py` | Tests affichage avec description contenant unicode et caracteres speciaux | `display.py` |
|
||||||
|
| Modify | `tests/test_exporter.py` | Tests deja ajoutes en phase 1 pour les caracteres de controle, completer si necessaire | `exporter.py` |
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
Pas de nouvelle interface -- cette phase n'ajoute que des tests.
|
||||||
|
|
||||||
|
### Comportement attendu
|
||||||
|
|
||||||
|
Tous les edge cases identifes sont couverts par des tests unitaires :
|
||||||
|
|
||||||
|
1. **RepoData Unicode** : un RepoData avec `description="Projet avec des accents : e, a, u et des emojis"` se collecte, s'affiche et s'exporte sans erreur.
|
||||||
|
|
||||||
|
2. **Repo 0 commits** : deja partiellement couvert (`test_collect_all_no_commits`), mais verifier que l'affichage et l'export JSON fonctionnent aussi.
|
||||||
|
|
||||||
|
3. **Mock HTTP 429** : couvert par la phase 1, mais ajouter un test d'integration dans `test_collector.py` qui simule un 429 pendant la collecte et verifie que le retry est transparent.
|
||||||
|
|
||||||
|
4. **Reponse API malformee** : le client recoit du HTML au lieu de JSON (ex: page de maintenance Gitea). Doit lever une exception claire, pas un crash obscur.
|
||||||
|
|
||||||
|
5. **Description avec caracteres de controle** : couvert par la phase 1 pour l'export JSON, ajouter un test pour le tableau Rich.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
#### test_collector.py (ajouts)
|
||||||
|
|
||||||
|
- `test_repo_data_unicode_description` : RepoData avec description unicode complete (accents, CJK, emojis).
|
||||||
|
- `test_collect_all_repo_zero_commits_and_no_release` : repo sans commits ET sans release -> RepoData avec `last_commit_date=None` et `latest_release=None`.
|
||||||
|
|
||||||
|
#### test_client.py (ajouts)
|
||||||
|
|
||||||
|
- `test_get_paginated_malformed_json` : mock reponse avec `resp.json()` qui leve `json.JSONDecodeError` -> verifie que l'exception remonte proprement.
|
||||||
|
- `test_get_repos_html_response` : mock reponse HTML (status 200 mais contenu HTML) -> verifie le comportement.
|
||||||
|
|
||||||
|
#### test_display.py (ajouts)
|
||||||
|
|
||||||
|
- `test_render_dashboard_unicode_description` : repo avec description unicode -> le tableau Rich s'affiche sans crash.
|
||||||
|
- `test_render_dashboard_control_chars_in_name` : repo avec caracteres de controle dans le nom -> pas de crash.
|
||||||
|
|
||||||
|
#### test_exporter.py (ajouts, complement phase 1)
|
||||||
|
|
||||||
|
- `test_export_json_empty_description` : description vide -> JSON valide.
|
||||||
|
- `test_export_json_very_long_description` : description de 10000 caracteres -> JSON valide.
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
La couverture de tests passe de 88 a ~103 tests. Tous les edge cases identifies dans l'issue #13 sont couverts. Les tests documentent le comportement attendu pour les cas limites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 : Nouvelles fonctionnalites (#14, #15)
|
||||||
|
|
||||||
|
**Goal** : Ajouter l'option `--health` pour verifier la connexion Gitea, et la colonne "Description" dans le tableau.
|
||||||
|
|
||||||
|
**Issues Gitea** : fixes #14, fixes #15
|
||||||
|
|
||||||
|
### Fichiers
|
||||||
|
|
||||||
|
| Action | Fichier | Modifications | Cross-references |
|
||||||
|
|--------|---------|---------------|------------------|
|
||||||
|
| Modify | `src/gitea_dashboard/client.py` | Ajouter methode `get_version()` qui appelle `GET /api/v1/version` | `cli.py` (consomme pour --health) |
|
||||||
|
| Modify | `src/gitea_dashboard/cli.py` | Ajouter options `--health` et `--no-desc` dans argparse. Logique --health : appeler `get_version()`, compter les repos, afficher, exit 0 ou 1 | `client.py` (get_version), `display.py` (render_dashboard) |
|
||||||
|
| Modify | `src/gitea_dashboard/display.py` | Ajouter colonne "Description" au tableau, troncature a 40 chars avec "...", parametre `show_description` dans `render_dashboard()` | `collector.py` (champ description deja present dans RepoData) |
|
||||||
|
| Modify | `tests/test_client.py` | Tests `get_version()` | `client.py` |
|
||||||
|
| Modify | `tests/test_cli.py` | Tests --health (succes, echec connexion), tests --no-desc | `cli.py` |
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
#### client.py (modifications)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GiteaClient:
|
||||||
|
def get_version(self) -> dict:
|
||||||
|
"""Retourne la version de l'instance Gitea.
|
||||||
|
|
||||||
|
Endpoint: GET /api/v1/version
|
||||||
|
Retourne: {"version": "1.21.0"}
|
||||||
|
Leve HTTPError si l'appel echoue.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi une methode dediee plutot qu'un appel direct dans cli.py** : coherent avec l'architecture (ADR-002) -- toute communication API passe par `client.py`. Le CLI ne connait pas les endpoints.
|
||||||
|
|
||||||
|
#### cli.py (modifications)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
"""Parse les arguments CLI.
|
||||||
|
|
||||||
|
Options existantes : --repo, --exclude, --sort, --format
|
||||||
|
Nouvelles options :
|
||||||
|
--health : verifie la connexion Gitea et affiche la version.
|
||||||
|
Mutuellement exclusif avec le dashboard normal.
|
||||||
|
Exit code 0 si connexion OK, 1 sinon.
|
||||||
|
--no-desc : masque la colonne Description dans le tableau.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _run_health_check(client: GiteaClient, console: Console) -> None:
|
||||||
|
"""Execute le health check et affiche les resultats.
|
||||||
|
|
||||||
|
1. Appelle client.get_version() -> affiche "Gitea vX.Y.Z"
|
||||||
|
2. Appelle client.get_repos() -> affiche "N repos accessibles"
|
||||||
|
3. Exit code 0 si tout OK
|
||||||
|
Leve une exception en cas d'echec (geree par le try/except de main).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi `--health` est mutuellement exclusif** : l'utilisateur veut soit verifier la connexion, soit afficher le dashboard. Les deux en meme temps n'ont pas de sens. Si `--health` est present, les options `--repo`, `--exclude`, `--sort`, `--format` sont ignorees.
|
||||||
|
|
||||||
|
**Pourquoi une fonction `_run_health_check` separee** : eviter de surcharger `main()` avec de la logique conditionnelle. La fonction est interne (prefixe `_`) car elle n'est pas une interface publique.
|
||||||
|
|
||||||
|
#### display.py (modifications)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _truncate(text: str, max_length: int = 40) -> str:
|
||||||
|
"""Tronque le texte a max_length caracteres avec '...' si necessaire."""
|
||||||
|
|
||||||
|
def render_dashboard(
|
||||||
|
repos: list[RepoData],
|
||||||
|
console: Console | None = None,
|
||||||
|
sort_key: str = "name",
|
||||||
|
show_description: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Affiche le dashboard. Nouveau parametre show_description.
|
||||||
|
|
||||||
|
Si show_description est True, ajoute une colonne "Description"
|
||||||
|
entre "Repo" et "Issues", tronquee a 40 caracteres.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi tronquer a 40 caracteres** : les descriptions peuvent etre longues et casser le tableau Rich. 40 chars est un compromis entre informativite et lisibilite. Le suffixe "..." indique visuellement que le texte est tronque.
|
||||||
|
|
||||||
|
**Pourquoi un parametre `show_description` et non un filtre de colonnes generique** : YAGNI. Une seule colonne est optionnelle. Un systeme generique serait over-engineere pour ce cas.
|
||||||
|
|
||||||
|
### Comportement attendu
|
||||||
|
|
||||||
|
1. Health check reussi :
|
||||||
|
```
|
||||||
|
$ gitea-dashboard --health
|
||||||
|
Gitea v1.21.0
|
||||||
|
12 repos accessibles
|
||||||
|
$ echo $?
|
||||||
|
0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Health check echoue :
|
||||||
|
```
|
||||||
|
$ gitea-dashboard --health
|
||||||
|
Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.
|
||||||
|
$ echo $?
|
||||||
|
1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Description dans le tableau :
|
||||||
|
```
|
||||||
|
Gitea Dashboard
|
||||||
|
+------------------+------------------------------------------+--------+------------------+----------------+
|
||||||
|
| Repo | Description | Issues | Release | Dernier commit |
|
||||||
|
+------------------+------------------------------------------+--------+------------------+----------------+
|
||||||
|
| mon-projet | Dashboard CLI pour Gitea | 3 | v1.2.0 (il y a 5j) | il y a 2j |
|
||||||
|
| long-description | Un tres long texte de description qui... | 0 | --- | il y a 1j |
|
||||||
|
+------------------+------------------------------------------+--------+------------------+----------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Sans description :
|
||||||
|
```
|
||||||
|
$ gitea-dashboard --no-desc
|
||||||
|
# Tableau identique a v1.2.0 (pas de colonne Description)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Export JSON : la description est toujours presente dans le JSON (le champ existe deja dans RepoData). `--no-desc` n'affecte que l'affichage tableau.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
#### test_client.py (ajouts)
|
||||||
|
|
||||||
|
- `test_get_version_success` : mock reponse 200 avec `{"version": "1.21.0"}` -> retourne le dict.
|
||||||
|
- `test_get_version_connection_error` : mock ConnectionError -> leve l'exception.
|
||||||
|
|
||||||
|
#### test_cli.py (ajouts)
|
||||||
|
|
||||||
|
- `test_parse_args_health` : `--health` -> `Namespace(health=True)`.
|
||||||
|
- `test_main_health_success` : mock client.get_version et get_repos -> exit 0, affiche version et nombre de repos.
|
||||||
|
- `test_main_health_connection_error` : mock ConnectionError -> exit 1.
|
||||||
|
- `test_parse_args_no_desc` : `--no-desc` -> `Namespace(no_desc=True)`.
|
||||||
|
- `test_main_passes_no_desc_to_render` : verifie que `render_dashboard` est appele avec `show_description=False`.
|
||||||
|
|
||||||
|
#### test_display.py (ajouts)
|
||||||
|
|
||||||
|
- `test_description_column_displayed` : le tableau contient une colonne "Description".
|
||||||
|
- `test_description_truncated_at_40` : description de 60 chars -> tronquee a 40 + "...".
|
||||||
|
- `test_description_short_not_truncated` : description de 20 chars -> affichee telle quelle.
|
||||||
|
- `test_description_empty` : description vide -> cellule vide (pas de crash).
|
||||||
|
- `test_no_description_flag` : `show_description=False` -> pas de colonne "Description".
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
L'option `--health` permet de verifier la connexion Gitea. Le tableau affiche la description des repos, tronquee a 40 chars, masquable avec `--no-desc`. Tous les tests passent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture des modules (impact v1.3.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea-dashboard v1.3.0
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Terminal Application Gitea API
|
||||||
|
-------- ----------- ---------
|
||||||
|
|
||||||
|
+------------------+
|
||||||
|
$ gitea-dashboard | cli.py |
|
||||||
|
--health | - parse args |
|
||||||
|
--no-desc | - route health |
|
||||||
|
| - route format |
|
||||||
|
| - gere erreurs |
|
||||||
|
+--------+---------+
|
||||||
|
|
|
||||||
|
+--------+---------+
|
||||||
|
| --health? |
|
||||||
|
+--+----------+----+
|
||||||
|
| |
|
||||||
|
oui | | non
|
||||||
|
v v
|
||||||
|
get_version() collect_all()
|
||||||
|
get_repos() |
|
||||||
|
(count) +-------+-------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+------------+ +-------------+
|
||||||
|
| display.py | | exporter.py |
|
||||||
|
| + Description| | + sanitize |
|
||||||
|
<-----------------| + troncature | | control ch |---------> stdout (JSON)
|
||||||
|
Output Rich | + --no-desc | +-------------+
|
||||||
|
(tableaux) +------------+
|
||||||
|
|
||||||
|
+------------------+
|
||||||
|
| client.py |
|
||||||
|
| + get_version() |-----> GET /api/v1/version
|
||||||
|
| + retry HTTP 429 |-----> GET /api/v1/user/repos
|
||||||
|
| + Retry-After |-----> GET .../releases/latest
|
||||||
|
+------------------+-----> GET .../milestones
|
||||||
|
-----> GET .../commits?limit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Impact v1.3.0 | Detail |
|
||||||
|
|--------|--------------|--------|
|
||||||
|
| `client.py` | Modifie | Retry HTTP 429 + Retry-After, nouvelle methode `get_version()` |
|
||||||
|
| `collector.py` | Inchange | Pas de modification (RepoData a deja `description`) |
|
||||||
|
| `display.py` | Modifie | Colonne "Description" avec troncature, parametre `show_description` |
|
||||||
|
| `exporter.py` | Modifie | Sanitisation des caracteres de controle |
|
||||||
|
| `cli.py` | Modifie | Options `--health` et `--no-desc`, logique health check |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions architecturales
|
||||||
|
|
||||||
|
### ADR-009 : Retry HTTP 429 avec Retry-After dans _get_with_retry (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : Le retry dans `_get_with_retry` ne gere que `requests.Timeout` (exception Python). Un HTTP 429 (rate limiting) retourne une reponse avec un status code, pas une exception Timeout. Le retry ne se declenche donc pas sur rate limiting.
|
||||||
|
|
||||||
|
**Decision** : Etendre `_get_with_retry` pour intercepter les reponses HTTP 429. Si le header `Retry-After` est present, utiliser sa valeur comme delai d'attente. Sinon, utiliser le backoff lineaire standard. Apres epuisement des retries, lever `requests.HTTPError`.
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- La logique de retry reste centralisee dans une seule methode (coherent avec ADR-007)
|
||||||
|
- Le header Retry-After est un standard HTTP, le respecter evite les retries inutiles
|
||||||
|
- La boucle de retry gere desormais deux cas : Timeout (exception) et 429 (reponse)
|
||||||
|
- Pas de changement d'interface publique -- transparent pour les appelants
|
||||||
|
- Risque : complexite accrue de `_get_with_retry` (2 cas au lieu de 1), mais reste testable
|
||||||
|
|
||||||
|
### ADR-010 : Sanitisation des caracteres de controle dans exporter.py (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : L'export JSON peut contenir des caracteres de controle ASCII (0x00-0x1F) provenant des descriptions de repos. Ces caracteres sont invalides dans une chaine JSON selon RFC 8259, et `python3 -m json.tool` les rejette.
|
||||||
|
|
||||||
|
**Decision** : Sanitiser les champs texte dans `repos_to_dicts()` avant serialisation. Supprimer les caracteres de controle sauf `\n`, `\r` et `\t` (qui sont echappes par `json.dumps`).
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- La sanitisation est au point de sortie (exporter), pas au point d'entree (collector)
|
||||||
|
- Les donnees dans RepoData restent brutes (pas de perte d'information pour le rendu Rich)
|
||||||
|
- `json.dumps` avec `ensure_ascii=False` gere nativement `\n`, `\r`, `\t` -- seuls les autres caracteres de controle posent probleme
|
||||||
|
- Approche defensive : meme si les descriptions actuelles n'ont pas de caracteres de controle, le code est protege
|
||||||
|
|
||||||
|
### ADR-011 : --health comme commande alternative, pas sous-commande (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : L'option `--health` est un mode alternatif au dashboard. Deux approches : flag optionnel (`--health`) ou sous-commande (`gitea-dashboard health`).
|
||||||
|
|
||||||
|
**Decision** : Utiliser un flag optionnel `--health` dans argparse. Pas de sous-commandes.
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- Coherent avec ADR-004 (argparse simple, pas de framework CLI lourd)
|
||||||
|
- Un seul niveau d'arguments -- pas de complexite de sous-commandes
|
||||||
|
- `--health` est mutuellement exclusif avec le mode dashboard (les options --repo, --sort, etc. sont ignorees)
|
||||||
|
- Si d'autres modes alternatifs apparaissent (ex: `--export-config`), il faudra reconsiderer les sous-commandes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risques d'audit
|
||||||
|
|
||||||
|
| Zone | Risque | Severite estimee |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| `client.py` -- retry 429 | La boucle de retry devient plus complexe (2 types de retry). Risque de regression sur le retry timeout existant | major |
|
||||||
|
| `client.py` -- Retry-After | Le header Retry-After peut contenir une date HTTP (RFC 7231) au lieu de secondes. Ne gerer que les secondes (entier) est suffisant mais incomplet | minor |
|
||||||
|
| `exporter.py` -- sanitisation | La regex de sanitisation pourrait supprimer des caracteres Unicode valides si mal ecrite | major |
|
||||||
|
| `cli.py` -- --health | Si `--health` et `--format json` sont combines, le comportement n'est pas defini. Doit etre documente ou interdit | minor |
|
||||||
|
| `display.py` -- troncature | La troncature a 40 chars peut couper au milieu d'un caractere multi-byte (unicode) | minor |
|
||||||
|
| `display.py` -- retrocompatibilite | L'ajout de la colonne "Description" change le rendu par defaut. Les utilisateurs qui parsent la sortie Rich seront affectes | minor |
|
||||||
|
| `tests` -- couverture | L'issue #13 est une issue de tests sans code de production. Le builder doit ecrire les tests APRES les corrections de #11/#12 | minor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Gitea rattachees
|
||||||
|
|
||||||
|
| Issue | Titre | Phase |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| [#11](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/11) | [bug] Le retry ne gere pas le rate limiting (HTTP 429) | Phase 1 |
|
||||||
|
| [#12](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/12) | [bug] Invalid control character dans le JSON en pipe | Phase 1 |
|
||||||
|
| [#13](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/13) | [improvement] Ajouter des tests edge cases | Phase 2 |
|
||||||
|
| [#14](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/14) | [feat] Commande --health pour verifier la connexion Gitea | Phase 3 |
|
||||||
|
| [#15](https://gitea.tsmse.fr/admin/gitea-dashboard/issues/15) | [feat] Afficher la description des repos dans le tableau | Phase 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependances
|
||||||
|
|
||||||
|
| Dependance | Type | Version |
|
||||||
|
|------------|------|---------|
|
||||||
|
| Python | Runtime | >= 3.10 |
|
||||||
|
| requests | Librairie | >= 2.31 (inchange) |
|
||||||
|
| rich | Librairie | >= 13.0 (inchange) |
|
||||||
|
| pytest | Dev | >= 7.0 (inchange) |
|
||||||
|
| ruff | Dev | >= 0.4 (inchange) |
|
||||||
|
| Instance Gitea | Service externe | 192.168.0.106:3000 |
|
||||||
|
|
||||||
|
Aucune nouvelle dependance. Tous les ajouts utilisent la stdlib Python (re pour la sanitisation, pas de nouvelle librairie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criteres de validation par issue
|
||||||
|
|
||||||
|
| Issue | Critere de validation |
|
||||||
|
|-------|----------------------|
|
||||||
|
| #11 | `_get_with_retry` retente sur HTTP 429. Le header `Retry-After` est respecte. Test unitaire avec mock 429 (avec et sans Retry-After). Apres epuisement des retries, leve HTTPError. |
|
||||||
|
| #12 | `gitea-dashboard --format json \| python3 -m json.tool` fonctionne meme si les descriptions contiennent des caracteres de controle. Test avec `\x00`-`\x1f`. |
|
||||||
|
| #13 | Tests edge cases ajoutes : RepoData unicode, repo 0 commits, mock HTTP 429, reponse API malformee, description avec caracteres de controle. Minimum 10 nouveaux tests. |
|
||||||
|
| #14 | `gitea-dashboard --health` appelle `GET /api/v1/version`, affiche la version Gitea et le nombre de repos, exit code 0 si OK, 1 sinon. Tests unitaires pour succes et echec. |
|
||||||
|
| #15 | Le tableau affiche une colonne "Description" tronquee a 40 chars avec "...". `--no-desc` masque la colonne. Tests unitaires pour troncature, description vide, et flag --no-desc. |
|
||||||
@@ -102,6 +102,7 @@ gitea-dashboard/
|
|||||||
v1.0.0-plan.md # Plan de version
|
v1.0.0-plan.md # Plan de version
|
||||||
v1.1.0-plan.md # Plan de version
|
v1.1.0-plan.md # Plan de version
|
||||||
v1.2.0-plan.md # Plan de version
|
v1.2.0-plan.md # Plan de version
|
||||||
|
v1.3.0-plan.md # Plan de version
|
||||||
technical/
|
technical/
|
||||||
ARCHITECTURE.md # Ce fichier
|
ARCHITECTURE.md # Ce fichier
|
||||||
decisions.md # ADR
|
decisions.md # ADR
|
||||||
@@ -154,3 +155,8 @@ Decisions cles pour v1.2.0 :
|
|||||||
- **ADR-006** : Ajout du module exporter.py (5 modules)
|
- **ADR-006** : Ajout du module exporter.py (5 modules)
|
||||||
- **ADR-007** : Retry simple plutot que urllib3.Retry
|
- **ADR-007** : Retry simple plutot que urllib3.Retry
|
||||||
- **ADR-008** : Tri dans display.py, pas dans collector.py
|
- **ADR-008** : Tri dans display.py, pas dans collector.py
|
||||||
|
|
||||||
|
Decisions cles pour v1.3.0 :
|
||||||
|
- **ADR-009** : Retry HTTP 429 avec Retry-After dans _get_with_retry
|
||||||
|
- **ADR-010** : Sanitisation des caracteres de controle dans exporter.py
|
||||||
|
- **ADR-011** : --health comme commande alternative, pas sous-commande
|
||||||
|
|||||||
@@ -123,3 +123,47 @@
|
|||||||
- Le tri est teste independamment de la collecte
|
- Le tri est teste independamment de la collecte
|
||||||
- L'export JSON peut aussi appliquer le tri (via `_sort_repos` importable depuis display)
|
- L'export JSON peut aussi appliquer le tri (via `_sort_repos` importable depuis display)
|
||||||
- Le critere de tri par defaut ("name") garantit un affichage stable entre les executions
|
- Le critere de tri par defaut ("name") garantit un affichage stable entre les executions
|
||||||
|
|
||||||
|
## ADR-009 : Retry HTTP 429 avec Retry-After dans _get_with_retry (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : Le retry dans `_get_with_retry` ne gere que `requests.Timeout`. Un HTTP 429 (rate limiting) retourne une reponse HTTP, pas une exception Timeout. Le retry ne se declenche donc pas sur rate limiting.
|
||||||
|
|
||||||
|
**Decision** : Etendre `_get_with_retry` pour intercepter les reponses HTTP 429. Respecter le header `Retry-After` (en secondes) si present, sinon utiliser le backoff lineaire standard. Apres epuisement des retries, lever `requests.HTTPError`.
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- La logique de retry reste centralisee dans une seule methode (coherent avec ADR-007)
|
||||||
|
- Le header Retry-After est un standard HTTP (RFC 7231), le respecter evite les retries inutiles
|
||||||
|
- La boucle gere desormais 2 cas (Timeout + 429), complexite accrue mais testable
|
||||||
|
- Pas de changement d'interface publique
|
||||||
|
|
||||||
|
## ADR-010 : Sanitisation des caracteres de controle dans exporter.py (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : L'export JSON peut contenir des caracteres de controle ASCII (0x00-0x1F) provenant des descriptions de repos. Ces caracteres sont invalides en JSON (RFC 8259) et causent des erreurs avec `python3 -m json.tool`.
|
||||||
|
|
||||||
|
**Decision** : Sanitiser les champs texte dans `repos_to_dicts()` avant serialisation. Supprimer les caracteres de controle sauf `\n`, `\r` et `\t` (qui sont echappes nativement par `json.dumps`).
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- La sanitisation est au point de sortie (exporter), pas dans le collecteur
|
||||||
|
- Les donnees dans RepoData restent brutes (pas de perte pour le rendu Rich)
|
||||||
|
- Approche defensive contre les donnees inattendues de l'API Gitea
|
||||||
|
|
||||||
|
## ADR-011 : --health comme flag optionnel, pas sous-commande (v1.3.0)
|
||||||
|
|
||||||
|
**Date** : 2026-03-12
|
||||||
|
**Statut** : accepte
|
||||||
|
|
||||||
|
**Contexte** : L'option `--health` est un mode alternatif au dashboard. Deux approches : flag optionnel ou sous-commande.
|
||||||
|
|
||||||
|
**Decision** : Utiliser un flag `--health` dans argparse. Pas de sous-commandes.
|
||||||
|
|
||||||
|
**Consequences** :
|
||||||
|
- Coherent avec ADR-004 (argparse simple)
|
||||||
|
- Un seul niveau d'arguments
|
||||||
|
- `--health` est mutuellement exclusif avec le mode dashboard
|
||||||
|
- Si d'autres modes alternatifs apparaissent, reconsiderer les sous-commandes
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "gitea-dashboard"
|
name = "gitea-dashboard"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
description = "CLI dashboard for Gitea repos status"
|
description = "CLI dashboard for Gitea repos status"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -59,9 +59,35 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
dest="format",
|
dest="format",
|
||||||
help="Format de sortie (defaut: table).",
|
help="Format de sortie (defaut: table).",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--health",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Verifie la connexion Gitea et affiche la version. Exit 0 si OK, 1 sinon.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-desc",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Masque la colonne Description dans le tableau.",
|
||||||
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_health_check(client: GiteaClient, console: Console) -> None:
|
||||||
|
"""Execute le health check et affiche les resultats.
|
||||||
|
|
||||||
|
1. Appelle client.get_version() -> affiche "Gitea vX.Y.Z"
|
||||||
|
2. Appelle client.get_repos() -> affiche "N repos accessibles"
|
||||||
|
"""
|
||||||
|
version_info = client.get_version()
|
||||||
|
version = version_info.get("version", "inconnue")
|
||||||
|
console.print(f"Gitea v{version}")
|
||||||
|
|
||||||
|
repos = client.get_repos()
|
||||||
|
console.print(f"{len(repos)} repos accessibles")
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
"""Point d'entree principal.
|
"""Point d'entree principal.
|
||||||
|
|
||||||
@@ -90,6 +116,10 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
client = GiteaClient(url, token)
|
client = GiteaClient(url, token)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if args.health:
|
||||||
|
_run_health_check(client, console)
|
||||||
|
return
|
||||||
|
|
||||||
repos = collect_all(client, include=args.repo, exclude=args.exclude)
|
repos = collect_all(client, include=args.repo, exclude=args.exclude)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
||||||
@@ -111,4 +141,4 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
sorted_repos = sort_repos(repos, args.sort)
|
sorted_repos = sort_repos(repos, args.sort)
|
||||||
print(export_json(sorted_repos)) # noqa: T201
|
print(export_json(sorted_repos)) # noqa: T201
|
||||||
else:
|
else:
|
||||||
render_dashboard(repos, sort_key=args.sort)
|
render_dashboard(repos, sort_key=args.sort, show_description=not args.no_desc)
|
||||||
|
|||||||
@@ -33,19 +33,52 @@ class GiteaClient:
|
|||||||
self.session.headers["Authorization"] = f"token {token}"
|
self.session.headers["Authorization"] = f"token {token}"
|
||||||
|
|
||||||
def _get_with_retry(self, url: str, params: dict | None = None) -> requests.Response:
|
def _get_with_retry(self, url: str, params: dict | None = None) -> requests.Response:
|
||||||
"""GET avec retry automatique sur timeout.
|
"""GET avec retry automatique sur timeout ET rate limiting (HTTP 429).
|
||||||
|
|
||||||
Retente jusqu'a _MAX_RETRIES fois avec backoff lineaire (1s, 2s).
|
Retente jusqu'a _MAX_RETRIES fois avec backoff lineaire (1s, 2s).
|
||||||
Leve requests.Timeout apres epuisement des retries.
|
Si la reponse HTTP est 429 (Too Many Requests), respecte le header
|
||||||
|
Retry-After (en secondes) pour le delai d'attente. Si Retry-After
|
||||||
|
est absent, utilise le backoff lineaire standard.
|
||||||
|
|
||||||
|
Leve requests.Timeout apres epuisement des retries sur timeout.
|
||||||
|
Leve requests.HTTPError apres epuisement des retries sur 429.
|
||||||
"""
|
"""
|
||||||
last_exc: requests.Timeout | None = None
|
last_exc: requests.Timeout | None = None
|
||||||
|
last_resp: requests.Response | None = None
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
for attempt in range(self._MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
return self.session.get(url, params=params, timeout=self.timeout)
|
resp = self.session.get(url, params=params, timeout=self.timeout)
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if attempt < self._MAX_RETRIES:
|
if attempt < self._MAX_RETRIES:
|
||||||
time.sleep(self._RETRY_DELAY * (attempt + 1))
|
time.sleep(self._RETRY_DELAY * (attempt + 1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
last_resp = resp
|
||||||
|
if attempt < self._MAX_RETRIES:
|
||||||
|
retry_after = resp.headers.get("Retry-After")
|
||||||
|
if retry_after is not None:
|
||||||
|
try:
|
||||||
|
# Cap a 30s pour eviter un blocage indefini.
|
||||||
|
# max() assure un plancher au backoff lineaire
|
||||||
|
# (protege contre Retry-After: 0 ou negatif).
|
||||||
|
delay = min(float(retry_after), 30.0)
|
||||||
|
delay = max(delay, self._RETRY_DELAY)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# Retry-After peut etre une date HTTP RFC 7231
|
||||||
|
# (ex: "Wed, 21 Oct 2025 07:28:00 GMT") :
|
||||||
|
# on retombe sur le backoff lineaire standard.
|
||||||
|
delay = self._RETRY_DELAY * (attempt + 1)
|
||||||
|
else:
|
||||||
|
delay = self._RETRY_DELAY * (attempt + 1)
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
if last_resp is not None:
|
||||||
|
last_resp.raise_for_status()
|
||||||
raise last_exc # type: ignore[misc]
|
raise last_exc # type: ignore[misc]
|
||||||
|
|
||||||
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
|
def _get_paginated(self, endpoint: str, params: dict | None = None) -> list[dict]:
|
||||||
@@ -103,6 +136,18 @@ class GiteaClient:
|
|||||||
params={"state": "open"},
|
params={"state": "open"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_version(self) -> dict:
|
||||||
|
"""Retourne la version de l'instance Gitea.
|
||||||
|
|
||||||
|
Endpoint: GET /api/v1/version
|
||||||
|
Retourne: {"version": "1.21.0"}
|
||||||
|
Leve HTTPError si l'appel echoue.
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/api/v1/version"
|
||||||
|
resp = self._get_with_retry(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
def get_latest_commit(self, owner: str, repo: str) -> dict | None:
|
def get_latest_commit(self, owner: str, repo: str) -> dict | None:
|
||||||
"""Retourne le dernier commit du repo, ou None si aucun.
|
"""Retourne le dernier commit du repo, ou None si aucun.
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ def _colorize_milestone_due(due_on: str | None) -> str:
|
|||||||
return "green"
|
return "green"
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, max_length: int = 40) -> str:
|
||||||
|
"""Tronque le texte a max_length caracteres avec '...' si necessaire."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length] + "..."
|
||||||
|
|
||||||
|
|
||||||
def sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]:
|
def sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]:
|
||||||
"""Trie la liste des repos selon le critere donne.
|
"""Trie la liste des repos selon le critere donne.
|
||||||
|
|
||||||
@@ -126,15 +133,18 @@ def render_dashboard(
|
|||||||
repos: list[RepoData],
|
repos: list[RepoData],
|
||||||
console: Console | None = None,
|
console: Console | None = None,
|
||||||
sort_key: str = "name",
|
sort_key: str = "name",
|
||||||
|
show_description: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Affiche le dashboard complet dans le terminal.
|
"""Affiche le dashboard complet dans le terminal.
|
||||||
|
|
||||||
- Tableau principal : nom repo, indicateurs (fork/archive/mirror),
|
- Tableau principal : nom repo, description (optionnelle, tronquee a 40 chars),
|
||||||
issues ouvertes, derniere release (tag + date relative)
|
indicateurs (fork/archive/mirror), issues ouvertes, derniere release
|
||||||
- Section milestones : par repo ayant des milestones,
|
- Section milestones : par repo ayant des milestones,
|
||||||
nom, progression (closed/total), date echeance
|
nom, progression (closed/total), date echeance
|
||||||
|
|
||||||
Le parametre console permet l'injection pour les tests.
|
Le parametre console permet l'injection pour les tests.
|
||||||
|
Si show_description est True, ajoute une colonne "Description"
|
||||||
|
entre "Repo" et "Issues", tronquee a 40 caracteres.
|
||||||
"""
|
"""
|
||||||
if console is None:
|
if console is None:
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -149,6 +159,8 @@ def render_dashboard(
|
|||||||
# Tableau principal
|
# Tableau principal
|
||||||
table = Table(title="Gitea Dashboard")
|
table = Table(title="Gitea Dashboard")
|
||||||
table.add_column("Repo", style="bold")
|
table.add_column("Repo", style="bold")
|
||||||
|
if show_description:
|
||||||
|
table.add_column("Description")
|
||||||
table.add_column("Issues", justify="right")
|
table.add_column("Issues", justify="right")
|
||||||
table.add_column("Release")
|
table.add_column("Release")
|
||||||
table.add_column("Dernier commit")
|
table.add_column("Dernier commit")
|
||||||
@@ -162,13 +174,19 @@ def render_dashboard(
|
|||||||
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
|
_format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014"
|
||||||
)
|
)
|
||||||
|
|
||||||
table.add_row(
|
row = [name]
|
||||||
name,
|
if show_description:
|
||||||
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
row.append(_truncate(repo.description or ""))
|
||||||
release_str,
|
row.extend(
|
||||||
commit_str,
|
[
|
||||||
|
f"[{issues_style}]{issues_str}[/{issues_style}]",
|
||||||
|
release_str,
|
||||||
|
commit_str,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
table.add_row(*row)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
# Section milestones — uniquement si au moins un repo en a
|
# Section milestones — uniquement si au moins un repo en a
|
||||||
|
|||||||
@@ -3,18 +3,38 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
from gitea_dashboard.collector import RepoData
|
from gitea_dashboard.collector import RepoData
|
||||||
|
|
||||||
|
# Caracteres de controle ASCII (0x00-0x1F) sauf \t (0x09), \n (0x0A), \r (0x0D)
|
||||||
|
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_control_chars(text: str) -> str:
|
||||||
|
"""Supprime les caracteres de controle ASCII (0x00-0x1F) sauf \\n, \\r et \\t.
|
||||||
|
|
||||||
|
Ces caracteres peuvent provenir de descriptions de repos Gitea
|
||||||
|
et causent des erreurs JSON ('Invalid control character').
|
||||||
|
"""
|
||||||
|
return _CONTROL_CHAR_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
def repos_to_dicts(repos: list[RepoData]) -> list[dict]:
|
def repos_to_dicts(repos: list[RepoData]) -> list[dict]:
|
||||||
"""Convertit une liste de RepoData en liste de dicts serialisables.
|
"""Convertit une liste de RepoData en liste de dicts serialisables.
|
||||||
|
|
||||||
Chaque dict contient toutes les donnees du RepoData,
|
Sanitize les champs texte (name, full_name, description) pour
|
||||||
pret pour json.dumps().
|
supprimer les caracteres de controle invalides en JSON.
|
||||||
"""
|
"""
|
||||||
return [asdict(repo) for repo in repos]
|
result = []
|
||||||
|
for repo in repos:
|
||||||
|
d = asdict(repo)
|
||||||
|
for field in ("name", "full_name", "description"):
|
||||||
|
if isinstance(d.get(field), str):
|
||||||
|
d[field] = _sanitize_control_chars(d[field])
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def export_json(repos: list[RepoData], indent: int = 2) -> str:
|
def export_json(repos: list[RepoData], indent: int = 2) -> str:
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class TestMainNominal:
|
|||||||
|
|
||||||
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
||||||
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
||||||
mock_render.assert_called_once_with(mock_collect.return_value, sort_key="name")
|
mock_render.assert_called_once_with(
|
||||||
|
mock_collect.return_value, sort_key="name", show_description=True
|
||||||
|
)
|
||||||
|
|
||||||
@patch("gitea_dashboard.cli.render_dashboard")
|
@patch("gitea_dashboard.cli.render_dashboard")
|
||||||
@patch("gitea_dashboard.cli.collect_all")
|
@patch("gitea_dashboard.cli.collect_all")
|
||||||
@@ -259,6 +261,115 @@ class TestParseArgsFormat:
|
|||||||
parse_args(["--format", "invalid"])
|
parse_args(["--format", "invalid"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseArgsHealth:
|
||||||
|
"""Test --health argument parsing."""
|
||||||
|
|
||||||
|
def test_parse_args_health(self):
|
||||||
|
"""--health sets health=True."""
|
||||||
|
from gitea_dashboard.cli import parse_args
|
||||||
|
|
||||||
|
args = parse_args(["--health"])
|
||||||
|
assert args.health is True
|
||||||
|
|
||||||
|
def test_parse_args_no_health_default(self):
|
||||||
|
"""Without --health, health is False."""
|
||||||
|
from gitea_dashboard.cli import parse_args
|
||||||
|
|
||||||
|
args = parse_args([])
|
||||||
|
assert args.health is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseArgsNoDesc:
|
||||||
|
"""Test --no-desc argument parsing."""
|
||||||
|
|
||||||
|
def test_parse_args_no_desc(self):
|
||||||
|
"""--no-desc sets no_desc=True."""
|
||||||
|
from gitea_dashboard.cli import parse_args
|
||||||
|
|
||||||
|
args = parse_args(["--no-desc"])
|
||||||
|
assert args.no_desc is True
|
||||||
|
|
||||||
|
def test_parse_args_no_desc_default(self):
|
||||||
|
"""Without --no-desc, no_desc is False."""
|
||||||
|
from gitea_dashboard.cli import parse_args
|
||||||
|
|
||||||
|
args = parse_args([])
|
||||||
|
assert args.no_desc is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainHealth:
|
||||||
|
"""Test main() with --health."""
|
||||||
|
|
||||||
|
@patch("gitea_dashboard.cli.GiteaClient")
|
||||||
|
def test_main_health_success(self, mock_client_cls, capsys):
|
||||||
|
"""--health displays version and repo count, exits normally."""
|
||||||
|
env = {"GITEA_TOKEN": "test-token"}
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.get_version.return_value = {"version": "1.21.0"}
|
||||||
|
mock_client.get_repos.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
|
||||||
|
|
||||||
|
with patch.dict("os.environ", env, clear=True):
|
||||||
|
main(["--health"])
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Gitea v1.21.0" in captured.err
|
||||||
|
assert "3 repos accessibles" in captured.err
|
||||||
|
|
||||||
|
@patch("gitea_dashboard.cli.GiteaClient")
|
||||||
|
def test_main_health_connection_error(self, mock_client_cls):
|
||||||
|
"""--health with connection error exits with code 1."""
|
||||||
|
env = {"GITEA_TOKEN": "test-token"}
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.get_version.side_effect = requests.ConnectionError("refused")
|
||||||
|
|
||||||
|
with patch.dict("os.environ", env, clear=True):
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(["--health"])
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
@patch("gitea_dashboard.cli.GiteaClient")
|
||||||
|
def test_main_health_version_ok_repos_fail(self, mock_client_cls):
|
||||||
|
"""--health : get_version reussit mais get_repos leve HTTPError -> exit 1.
|
||||||
|
|
||||||
|
Verifie le cas d'un health check partiel : l'instance Gitea repond
|
||||||
|
sur /version mais l'acces aux repos echoue (ex: token sans permissions).
|
||||||
|
"""
|
||||||
|
env = {"GITEA_TOKEN": "test-token"}
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.get_version.return_value = {"version": "1.21.0"}
|
||||||
|
mock_client.get_repos.side_effect = requests.HTTPError("403 Forbidden")
|
||||||
|
|
||||||
|
with patch.dict("os.environ", env, clear=True):
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(["--health"])
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainNoDesc:
|
||||||
|
"""Test main() with --no-desc."""
|
||||||
|
|
||||||
|
@patch("gitea_dashboard.cli.render_dashboard")
|
||||||
|
@patch("gitea_dashboard.cli.collect_all")
|
||||||
|
@patch("gitea_dashboard.cli.GiteaClient")
|
||||||
|
def test_main_passes_no_desc_to_render(self, mock_client_cls, mock_collect, mock_render):
|
||||||
|
"""--no-desc passes show_description=False to render_dashboard."""
|
||||||
|
env = {"GITEA_TOKEN": "test-token"}
|
||||||
|
mock_client_cls.return_value = MagicMock()
|
||||||
|
mock_collect.return_value = []
|
||||||
|
|
||||||
|
with patch.dict("os.environ", env, clear=True):
|
||||||
|
main(["--no-desc"])
|
||||||
|
|
||||||
|
mock_render.assert_called_once_with(
|
||||||
|
mock_collect.return_value, sort_key="name", show_description=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestMainFormatJson:
|
class TestMainFormatJson:
|
||||||
"""Test main() with --format json."""
|
"""Test main() with --format json."""
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,185 @@ class TestGetWithRetry:
|
|||||||
mock_sleep.assert_any_call(2.0)
|
mock_sleep.assert_any_call(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWithRetry429:
|
||||||
|
"""Test _get_with_retry method (retry on HTTP 429 rate limiting)."""
|
||||||
|
|
||||||
|
def _make_client(self):
|
||||||
|
return GiteaClient("http://gitea.local:3000", "tok")
|
||||||
|
|
||||||
|
def _make_429_response(self, retry_after=None):
|
||||||
|
"""Create a mock 429 response."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = 429
|
||||||
|
resp.headers = {"Retry-After": retry_after} if retry_after is not None else {}
|
||||||
|
resp.raise_for_status.side_effect = requests.HTTPError(
|
||||||
|
"429 Too Many Requests", response=resp
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _make_200_response(self):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = 200
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_on_429_with_retry_after(self, mock_sleep):
|
||||||
|
"""429 with Retry-After header: sleeps for the indicated duration, then succeeds."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response(retry_after="2")
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
mock_sleep.assert_called_once_with(2.0)
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_on_429_without_retry_after(self, mock_sleep):
|
||||||
|
"""429 without Retry-After header: uses linear backoff (1.0s for first retry)."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response()
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
mock_sleep.assert_called_once_with(1.0)
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_on_429_exhausted(self, mock_sleep):
|
||||||
|
"""3 consecutive 429 responses: raises HTTPError after exhausting retries."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", return_value=resp_429):
|
||||||
|
with pytest.raises(requests.HTTPError):
|
||||||
|
client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert mock_sleep.call_count == 2
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_after_http_date_falls_back_to_backoff(self, mock_sleep):
|
||||||
|
"""Retry-After contenant une date HTTP RFC 7231 (non-numerique) :
|
||||||
|
le parsing echoue silencieusement et on retombe sur le backoff lineaire."""
|
||||||
|
client = self._make_client()
|
||||||
|
# Valeur realiste envoyee par certains serveurs
|
||||||
|
resp_429 = self._make_429_response(retry_after="Wed, 21 Oct 2025 07:28:00 GMT")
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
# Backoff lineaire : attempt=0 → 1 * 1.0 = 1.0s
|
||||||
|
mock_sleep.assert_called_once_with(1.0)
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_after_zero_uses_floor(self, mock_sleep):
|
||||||
|
"""Retry-After: 0 ne provoque pas un retry immediat sans backoff.
|
||||||
|
Le plancher (_RETRY_DELAY = 1.0s) est applique."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response(retry_after="0")
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
mock_sleep.assert_called_once_with(1.0) # plancher _RETRY_DELAY
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_after_huge_value_capped_at_30s(self, mock_sleep):
|
||||||
|
"""Retry-After avec une valeur enorme est plafonne a 30s."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response(retry_after="3600") # 1 heure
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
mock_sleep.assert_called_once_with(30.0) # cap a 30s
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_retry_on_429_then_timeout(self, mock_sleep):
|
||||||
|
"""429 followed by Timeout: both retry types handled in same loop."""
|
||||||
|
client = self._make_client()
|
||||||
|
resp_429 = self._make_429_response()
|
||||||
|
resp_200 = self._make_200_response()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client.session,
|
||||||
|
"get",
|
||||||
|
side_effect=[resp_429, requests.Timeout("timeout"), resp_200],
|
||||||
|
):
|
||||||
|
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||||
|
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert mock_sleep.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetVersion:
|
||||||
|
"""Test get_version method."""
|
||||||
|
|
||||||
|
def test_get_version_success(self):
|
||||||
|
"""Returns version dict on success."""
|
||||||
|
client = GiteaClient("http://gitea.local:3000", "tok")
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = {"version": "1.21.0"}
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", return_value=mock_resp):
|
||||||
|
result = client.get_version()
|
||||||
|
|
||||||
|
assert result == {"version": "1.21.0"}
|
||||||
|
|
||||||
|
def test_get_version_connection_error(self):
|
||||||
|
"""ConnectionError propagates to caller."""
|
||||||
|
client = GiteaClient("http://gitea.local:3000", "tok")
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", side_effect=requests.ConnectionError("refused")):
|
||||||
|
with pytest.raises(requests.ConnectionError):
|
||||||
|
client.get_version()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPaginatedEdgeCases:
|
||||||
|
"""Test edge cases for API responses."""
|
||||||
|
|
||||||
|
def _make_client(self):
|
||||||
|
return GiteaClient("http://gitea.local:3000", "tok")
|
||||||
|
|
||||||
|
def test_get_paginated_malformed_json(self):
|
||||||
|
"""Response with invalid JSON raises JSONDecodeError."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
client = self._make_client()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", return_value=mock_resp):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
client._get_paginated("/api/v1/user/repos")
|
||||||
|
|
||||||
|
def test_get_repos_html_response(self):
|
||||||
|
"""HTML response (status 200 but HTML content) raises on json parsing."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
client = self._make_client()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json.side_effect = json.JSONDecodeError(
|
||||||
|
"Expecting value", "<html>Maintenance</html>", 0
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(client.session, "get", return_value=mock_resp):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
client.get_repos()
|
||||||
|
|
||||||
|
|
||||||
class TestGetLatestCommit:
|
class TestGetLatestCommit:
|
||||||
"""Test get_latest_commit method."""
|
"""Test get_latest_commit method."""
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,40 @@ class TestCollectAllLastCommit:
|
|||||||
assert result[0].last_commit_date is None
|
assert result[0].last_commit_date is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepoDataEdgeCases:
|
||||||
|
"""Test RepoData with edge case data."""
|
||||||
|
|
||||||
|
def test_repo_data_unicode_description(self):
|
||||||
|
"""RepoData with full unicode description (accents, CJK, emojis)."""
|
||||||
|
repo = RepoData(
|
||||||
|
name="unicode-test",
|
||||||
|
full_name="admin/unicode-test",
|
||||||
|
description="Projet avec accents : e, a, u, CJK: 中文, emojis: 🚀🎉",
|
||||||
|
open_issues=0,
|
||||||
|
is_fork=False,
|
||||||
|
is_archived=False,
|
||||||
|
is_mirror=False,
|
||||||
|
latest_release=None,
|
||||||
|
milestones=[],
|
||||||
|
last_commit_date=None,
|
||||||
|
)
|
||||||
|
assert "🚀" in repo.description
|
||||||
|
assert "中文" in repo.description
|
||||||
|
|
||||||
|
def test_collect_all_repo_zero_commits_and_no_release(self):
|
||||||
|
"""Repo with no commits AND no release produces valid RepoData."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_repos.return_value = [_make_repo()]
|
||||||
|
client.get_latest_release.return_value = None
|
||||||
|
client.get_milestones.return_value = []
|
||||||
|
client.get_latest_commit.return_value = None
|
||||||
|
|
||||||
|
result = collect_all(client)
|
||||||
|
|
||||||
|
assert result[0].last_commit_date is None
|
||||||
|
assert result[0].latest_release is None
|
||||||
|
|
||||||
|
|
||||||
class TestCollectAllFiltering:
|
class TestCollectAllFiltering:
|
||||||
"""Test collect_all filtering (include/exclude)."""
|
"""Test collect_all filtering (include/exclude)."""
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,91 @@ class TestRenderDashboardEmpty:
|
|||||||
assert "Aucun repo" in output
|
assert "Aucun repo" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescriptionColumn:
|
||||||
|
"""Test Description column in dashboard table."""
|
||||||
|
|
||||||
|
def test_description_column_displayed(self):
|
||||||
|
"""Table contains a Description column by default."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="test", description="My project")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
assert "Description" in output
|
||||||
|
assert "My project" in output
|
||||||
|
|
||||||
|
def test_description_truncated_at_40(self):
|
||||||
|
"""Description longer than 40 chars is truncated with '...'."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
long_desc = "A" * 60
|
||||||
|
repos = [_make_repo(name="test", description=long_desc)]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
# Should contain first 40 chars + "..."
|
||||||
|
assert "A" * 40 + "..." in output
|
||||||
|
# Should NOT contain the full 60-char string
|
||||||
|
assert "A" * 60 not in output
|
||||||
|
|
||||||
|
def test_description_short_not_truncated(self):
|
||||||
|
"""Description of 20 chars is displayed as-is."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="test", description="Short description")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
assert "Short description" in output
|
||||||
|
|
||||||
|
def test_description_empty(self):
|
||||||
|
"""Empty description renders without crash."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="test", description="")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
assert "test" in output
|
||||||
|
|
||||||
|
def test_no_description_flag(self):
|
||||||
|
"""show_description=False hides the Description column."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="test", description="My project")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console, show_description=False)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
assert "Description" not in output
|
||||||
|
assert "test" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderDashboardEdgeCases:
|
||||||
|
"""Test edge cases for dashboard rendering."""
|
||||||
|
|
||||||
|
def test_render_dashboard_unicode_description(self):
|
||||||
|
"""Repo with unicode description renders without crash."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="unicode", description="Projet 🚀 avec accents eaiu 中文")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
assert "unicode" in output
|
||||||
|
|
||||||
|
def test_render_dashboard_control_chars_in_name(self):
|
||||||
|
"""Repo with control characters in name renders without crash."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
repos = [_make_repo(name="test\x00repo")]
|
||||||
|
|
||||||
|
render_dashboard(repos, console=console)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
# Rich may strip or display the control char, but must not crash
|
||||||
|
assert "test" in output
|
||||||
|
|
||||||
|
|
||||||
class TestColorizeMilestoneDue:
|
class TestColorizeMilestoneDue:
|
||||||
"""Test _colorize_milestone_due function."""
|
"""Test _colorize_milestone_due function."""
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,63 @@ class TestReposToDicts:
|
|||||||
assert field in d, f"Missing field: {field}"
|
assert field in d, f"Missing field: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeControlChars:
|
||||||
|
"""Test control character sanitization in export."""
|
||||||
|
|
||||||
|
def test_export_json_sanitizes_control_chars(self):
|
||||||
|
"""Description with control chars (0x00, 0x01, 0x02) produces valid JSON without them."""
|
||||||
|
repo = _make_repo(description="hello\x00\x01\x02world")
|
||||||
|
output = export_json([repo])
|
||||||
|
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed[0]["description"] == "helloworld"
|
||||||
|
|
||||||
|
def test_export_json_preserves_newlines_tabs(self):
|
||||||
|
"""Newlines and tabs are preserved in JSON export (they are valid JSON escapes)."""
|
||||||
|
repo = _make_repo(description="line1\nline2\ttab")
|
||||||
|
output = export_json([repo])
|
||||||
|
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed[0]["description"] == "line1\nline2\ttab"
|
||||||
|
|
||||||
|
def test_export_json_unicode_safe(self):
|
||||||
|
"""Description with emojis and accents produces valid JSON."""
|
||||||
|
repo = _make_repo(description="Projet avec accents : e, a et emojis 🚀🎉")
|
||||||
|
output = export_json([repo])
|
||||||
|
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert "🚀" in parsed[0]["description"]
|
||||||
|
assert "accents" in parsed[0]["description"]
|
||||||
|
|
||||||
|
def test_sanitize_name_and_full_name(self):
|
||||||
|
"""Control chars in name and full_name fields are also sanitized."""
|
||||||
|
repo = _make_repo(name="test\x00repo", full_name="admin/test\x01repo")
|
||||||
|
result = repos_to_dicts([repo])
|
||||||
|
|
||||||
|
assert result[0]["name"] == "testrepo"
|
||||||
|
assert result[0]["full_name"] == "admin/testrepo"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportJsonEdgeCases:
|
||||||
|
"""Test edge cases for JSON export."""
|
||||||
|
|
||||||
|
def test_export_json_empty_description(self):
|
||||||
|
"""Empty description produces valid JSON."""
|
||||||
|
repo = _make_repo(description="")
|
||||||
|
output = export_json([repo])
|
||||||
|
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed[0]["description"] == ""
|
||||||
|
|
||||||
|
def test_export_json_very_long_description(self):
|
||||||
|
"""Very long description (10000 chars) produces valid JSON."""
|
||||||
|
repo = _make_repo(description="x" * 10000)
|
||||||
|
output = export_json([repo])
|
||||||
|
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert len(parsed[0]["description"]) == 10000
|
||||||
|
|
||||||
|
|
||||||
class TestExportJson:
|
class TestExportJson:
|
||||||
"""Test export_json function."""
|
"""Test export_json function."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user