Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:58:54 +08:00
commit 4d6408436e
32 changed files with 3539 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
# Consistance des Scopes GitHub
## ⚠️ IMPORTANT: Source de Vérité Unique
Pour garantir la cohérence des scopes GitHub demandés, **UNE SEULE source de vérité existe**:
### 📍 Fichier de Référence
```
scripts/gh_auth_setup.sh
```
**Array `REQUIRED_SCOPES`** ligne ~4-10:
```bash
REQUIRED_SCOPES=(
"repo" # Accès complet aux repos (PRs, commits, etc.)
"read:org" # Lecture infos organisation
"read:project" # Lecture projets GitHub
"project" # Écriture/assignation aux projets
"gist" # Gestion des gists
)
```
## 🔒 Règles Strictes
### DO ✅
1. **Modifier uniquement `gh_auth_setup.sh`** pour ajouter/retirer des scopes
2. **Utiliser systématiquement** `bash gh_auth_setup.sh` pour renouveler l'auth
3. **Vérifier les scopes** à chaque début de workflow (Étape 1.5 dans SKILL.md)
4. **Documenter** dans ce fichier toute modification de scopes
### DON'T ❌
1.**NE JAMAIS** utiliser `gh auth refresh -s <scope>` manuellement sans passer par le script
2.**NE JAMAIS** hard-coder les scopes dans SKILL.md ou ailleurs
3.**NE JAMAIS** supposer qu'un scope est présent sans vérification
## 📋 Checklist Modification Scopes
Si ajout/retrait de scope nécessaire:
- [ ] Modifier `REQUIRED_SCOPES` dans `scripts/gh_auth_setup.sh`
- [ ] Mettre à jour section "Scopes Requis" dans `SKILL.md`
- [ ] Mettre à jour tableau dans `README.md`
- [ ] Documenter dans ce fichier (section Historique)
- [ ] Tester avec `bash scripts/gh_auth_setup.sh`
- [ ] Vérifier avec `gh auth status`
- [ ] Tester le workflow complet
## 📜 Historique des Modifications
### 2025-11-05 - Initialisation
**Scopes définis**: `repo`, `read:org`, `read:project`, `project`, `gist`
**Contexte**: Création du système de consistance suite à oubli intermittent de scopes lors des renouvellements d'authentification.
**Changements**:
- Création `scripts/gh_auth_setup.sh` (script centralisant TOUS les scopes)
- Ajout Étape 1.5 dans `SKILL.md` (vérification automatique scopes)
- Création `README.md` (documentation complète)
- Création `.scope-consistency.md` (ce fichier - règles de cohérence)
**Impact**: Garantit qu'aucun scope ne sera oublié lors des futurs renouvellements.
---
### Template Modification Future
```markdown
### YYYY-MM-DD - [Titre Modification]
**Scopes modifiés**: `scope1`, `scope2`
**Contexte**: [Raison de la modification]
**Changements**:
- [Détail changement 1]
- [Détail changement 2]
**Impact**: [Impact sur le workflow]
```
## 🔍 Débogage
### Symptôme: "Scopes manquants" malgré renouvellement récent
**Diagnostic**:
```bash
# 1. Vérifier scopes actuels
gh auth status | grep "Token scopes"
# 2. Comparer avec REQUIRED_SCOPES
cat scripts/gh_auth_setup.sh | grep -A 10 "REQUIRED_SCOPES"
```
**Solution**:
```bash
# Re-run le script de setup
bash scripts/gh_auth_setup.sh
```
### Symptôme: Script fonctionne mais assignation projet échoue
**Diagnostic**:
```bash
# Vérifier que 'project' ET pas seulement 'read:project'
gh auth status | grep -o "project"
```
**Cause Probable**: Token ancien avec seulement `read:project`
**Solution**:
```bash
# Logout complet puis re-setup
gh auth logout
bash scripts/gh_auth_setup.sh
```
## 📚 Références
- Script source: `scripts/gh_auth_setup.sh`
- Workflow: `SKILL.md` (Étape 1.5)
- Documentation: `README.md`
- GitHub Scopes Doc: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps

211
skills/git-pr/README.md Normal file
View File

@@ -0,0 +1,211 @@
# Git PR Skill - Documentation
## Configuration Initiale
### Authentification GitHub
Ce skill nécessite une authentification GitHub avec des scopes spécifiques.
#### Scopes Requis
| Scope | Description | Usage |
|-------|-------------|-------|
| `repo` | Accès complet aux repos | Création PR, lecture commits, gestion branches |
| `read:org` | Lecture infos organisation | Récupération infos repository organisation |
| `read:project` | Lecture projets GitHub | Liste et lecture des projets |
| `project` | Écriture aux projets | Assignation PR aux projets |
| `gist` | Gestion gists | Partage extraits de code si nécessaire |
#### Configuration Automatique (Recommandé)
```bash
bash scripts/gh_auth_setup.sh
```
Ce script configure automatiquement TOUS les scopes requis.
#### Configuration Manuelle
```bash
gh auth refresh --hostname github.com \
-s repo \
-s read:org \
-s read:project \
-s project \
-s gist
```
#### Vérification
```bash
gh auth status
```
Sortie attendue:
```
Token scopes: 'gist', 'project', 'read:org', 'repo'
```
⚠️ **Note**: `read:project` n'apparaît pas toujours explicitement mais est inclus avec `project`.
## Protection Contre les Oublis
Le skill vérifie automatiquement les scopes au démarrage (Étape 1.5).
Si un scope manque :
- ❌ Arrêt immédiat avec message d'erreur
- 📋 Liste des scopes manquants affichée
- 🔄 Commande de renouvellement suggérée
## Utilisation
### Via Slash Command
```bash
/git:pr <branche-base> [milestone] [projet] [--delete] [--no-review]
```
### Via Skill Direct
```bash
ARGUMENTS="develop 1.0.0 TMA --delete" skill dev:git-pr
```
## Dépannage
### Erreur: "Scopes manquants"
```bash
❌ Scopes GitHub manquants: read:project project
```
**Solution**: Relancer `gh_auth_setup.sh`
### Erreur: "Impossible de récupérer les projets"
Vérifier authentification:
```bash
gh auth status
```
Si scopes corrects mais erreur persiste:
```bash
# Forcer renouvellement
gh auth logout
bash scripts/gh_auth_setup.sh
```
### Erreur: "your authentication token is missing required scopes"
**Cause**: Token obsolète ou scopes révoqués
**Solution**:
```bash
bash scripts/gh_auth_setup.sh
```
## Cache Persistant
Le skill utilise un système de cache pour optimiser les performances.
### Cache Milestones
**Fichier**: `.claude/cache/git-milestones.json`
**Fonctionnalités**:
- Stockage des milestones GitHub
- Recherche par titre exact ou alias
- Normalisation semver automatique (`26``26.0.0`)
- Génération d'aliases depuis titres (`26.0.0 (Hotfix)` → alias `26.0.0`)
**Refresh**: Automatique si milestone introuvable
### Cache Projets
**Fichier**: `.claude/cache/git-projects.json`
**Fonctionnalités**:
- Stockage des projets GitHub
- Recherche case-insensitive par titre ou alias
- Génération d'aliases depuis mots-clés (`Bug Tracking``["bug", "tracking"]`)
**Refresh**: Automatique si projet introuvable
### Commandes Utiles
```bash
# Vider cache milestones
rm .claude/cache/git-milestones.json
# Vider cache projets
rm .claude/cache/git-projects.json
# Vider tout le cache
rm -rf .claude/cache/
```
## Architecture
```
git-pr/
├── SKILL.md # Définition du skill et workflow
├── README.md # Cette documentation
├── scripts/
│ ├── gh_auth_setup.sh # Configuration automatique auth
│ ├── verify_pr_template.sh
│ ├── smart_qa.sh
│ ├── analyze_changes.sh
│ ├── confirm_base_branch.py
│ ├── safe_push_pr.sh
│ ├── assign_milestone.py # Assignation milestone avec cache
│ ├── milestone_cache.py # Module cache milestones
│ ├── assign_project.py # Assignation projet avec cache
│ ├── project_cache.py # Module cache projets
│ └── cleanup_branch.sh
└── tests/
├── run_tests.sh # Lance tous les tests
├── test_milestone_cache.py # Tests unitaires milestones
└── test_project_cache.py # Tests unitaires projets
```
## Maintenance
### Ajout d'un Nouveau Scope
1. Modifier `gh_auth_setup.sh`:
```bash
REQUIRED_SCOPES=(
# ... scopes existants
"nouveau_scope" # Description
)
```
2. Modifier `SKILL.md` section "Scopes Requis"
3. Mettre à jour ce README
### Tests Unitaires
```bash
# Lancer tous les tests
bash tests/run_tests.sh
# Lancer un test spécifique
cd tests
python3 test_milestone_cache.py -v
python3 test_project_cache.py -v
```
### Test de Consistance
Après modification:
```bash
# Tester le script
bash scripts/gh_auth_setup.sh
# Vérifier
gh auth status
# Tester assignation projet
python3 scripts/assign_project.py <pr-number> --project <nom>
```

326
skills/git-pr/SKILL.md Normal file
View File

@@ -0,0 +1,326 @@
---
name: git-pr
description: >
Automatise la création de Pull Requests GitHub avec workflow complet incluant:
QA intelligente (PHP), commits structurés, assignation milestone et projet,
code review automatique. Utilisable via /git:pr ou invocation automatique
quand l'utilisateur demande à créer/ouvrir/faire une PR.
allowed-tools: [Bash, Read, Write, TodoWrite, AskUserQuestion]
model: claude-sonnet-4-5-20250929
---
# Git PR Skill - Checklist d'exécution
## Configuration
```bash
SCRIPTS_DIR="/home/atournayre/.claude/plugins/marketplaces/atournayre-claude-plugin-marketplace/git/skills/git-pr/scripts"
PR_TEMPLATE_PATH=".github/pull_request_template.md"
```
## Checklist d'exécution
### 1. Initialisation
EXÉCUTER :
```bash
START_TIME=$(date +%s)
```
EXÉCUTER TodoWrite avec ces todos exacts :
```yaml
todos:
- content: "Vérifier template PR"
status: "pending"
activeForm: "Vérification du template PR"
- content: "Lancer QA intelligente"
status: "pending"
activeForm: "Lancement de la QA intelligente"
- content: "Analyser changements git"
status: "pending"
activeForm: "Analyse des changements git"
- content: "Confirmer branche de base"
status: "pending"
activeForm: "Confirmation de la branche de base"
- content: "Générer description PR intelligente"
status: "pending"
activeForm: "Génération de la description PR intelligente"
- content: "Push et création PR"
status: "pending"
activeForm: "Push et création de la PR"
- content: "Assigner milestone"
status: "pending"
activeForm: "Assignation du milestone"
- content: "Assigner projet GitHub"
status: "pending"
activeForm: "Assignation du projet GitHub"
- content: "Code review automatique"
status: "pending"
activeForm: "Code review automatique"
- content: "Nettoyage branche locale"
status: "pending"
activeForm: "Nettoyage de la branche locale"
```
### 2. Parsing arguments
EXÉCUTER ce bloc pour parser $ARGUMENTS :
```bash
ARGS=($ARGUMENTS)
BRANCH_BASE=""
MILESTONE=""
PROJECT_NAME=""
DELETE_FLAG=""
NO_REVIEW_FLAG=""
for arg in "${ARGS[@]}"; do
case "$arg" in
--delete) DELETE_FLAG="--delete" ;;
--no-review) NO_REVIEW_FLAG="--no-review" ;;
*)
if [ -z "$BRANCH_BASE" ]; then
BRANCH_BASE="$arg"
elif [ -z "$MILESTONE" ]; then
MILESTONE="$arg"
elif [ -z "$PROJECT_NAME" ]; then
PROJECT_NAME="$arg"
fi
;;
esac
done
```
### 3. Vérification scopes GitHub
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/check_scopes.sh
```
- Exit 0 → continuer
- Exit 1 → ARRÊT, afficher message du script
### 4. Template PR
- Marquer todo #1 in_progress
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/verify_pr_template.sh "$PR_TEMPLATE_PATH"
```
- Exit 0 → marquer todo #1 completed
- Exit 1 → ARRÊT
### 5. QA (si pas --no-review)
- Marquer todo #2 in_progress
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/smart_qa.sh
```
- Exit 0 → marquer todo #2 completed
- Exit 1 → ARRÊT
### 6. Analyse changements
- Marquer todo #3 in_progress
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/analyze_changes.sh
```
- Stocker sortie JSON
- Marquer todo #3 completed
### 7. Branche de base
- Marquer todo #4 in_progress
Si BRANCH_BASE fourni :
```bash
python3 $SCRIPTS_DIR/confirm_base_branch.py --branch "$BRANCH_BASE"
```
Sinon :
```bash
python3 $SCRIPTS_DIR/confirm_base_branch.py
```
Si needs_user_input: true → utiliser AskUserQuestion :
```yaml
questions:
- question: "Quelle branche de base pour la PR ?"
header: "Branche"
multiSelect: false
options:
- label: "develop"
description: "Branche développement"
- label: "main"
description: "Branche production"
```
- Marquer todo #4 completed
### 8. Génération description intelligente
- Marquer todo "Générer description PR intelligente" in_progress
EXÉCUTER pour récupérer les informations :
```bash
BRANCH_NAME=$(git branch --show-current)
echo "=== COMMITS ==="
git log $BRANCH_BASE..$BRANCH_NAME --oneline
echo ""
echo "=== DIFF STAT ==="
git diff $BRANCH_BASE..$BRANCH_NAME --stat
echo ""
echo "=== FICHIERS MODIFIÉS ==="
git diff $BRANCH_BASE..$BRANCH_NAME --name-only
```
LIRE le template PR avec Read tool : `$PR_TEMPLATE_PATH`
**GÉNÉRER LA DESCRIPTION** en tant que Claude :
1. Analyser les commits et le diff
2. Remplir intelligemment chaque section du template :
- **Bug fix** : supprimer si pas de fix, sinon lier l'issue
- **Description** : résumer les changements basé sur les commits
- **Type de changement** : cocher (✔️) les types appropriés basé sur les commits
- **Tests** : indiquer si tests ajoutés/modifiés
- **Checklist** : cocher ce qui s'applique
- **Actions** : cocher ce qui est nécessaire
3. Sauvegarder dans `/tmp/pr_body_generated.md` avec Write tool
### 9. Création PR
EXÉCUTER :
```bash
PR_NUMBER=$(bash $SCRIPTS_DIR/create_pr.sh "$BRANCH_BASE" "/tmp/pr_body_generated.md")
```
- Exit 0 → stocker PR_NUMBER, marquer todo "Générer description PR intelligente" completed, puis marquer todo "Push et création PR" completed
- Exit 1 → ARRÊT
### 10. Milestone
- Marquer todo "Assigner milestone" in_progress
Si MILESTONE fourni :
```bash
python3 $SCRIPTS_DIR/assign_milestone.py $PR_NUMBER --milestone "$MILESTONE"
```
Sinon :
```bash
python3 $SCRIPTS_DIR/assign_milestone.py $PR_NUMBER
```
Si needs_user_input: true → utiliser AskUserQuestion avec milestones disponibles
- Marquer todo "Assigner milestone" completed (même si échec, non bloquant)
### 11. Projet
- Marquer todo "Assigner projet GitHub" in_progress
Si PROJECT_NAME fourni :
```bash
python3 $SCRIPTS_DIR/assign_project.py $PR_NUMBER --project "$PROJECT_NAME"
```
Sinon :
```bash
python3 $SCRIPTS_DIR/assign_project.py $PR_NUMBER
```
Si needs_user_input: true → utiliser AskUserQuestion avec projets disponibles
- Marquer todo "Assigner projet GitHub" completed (même si échec, non bloquant)
### 12. Review intelligente (si pas --no-review)
- Marquer todo "Code review automatique" in_progress
EXÉCUTER pour récupérer les données :
```bash
PR_DATA=$(bash $SCRIPTS_DIR/auto_review.sh $PR_NUMBER)
```
**ANALYSER EN TANT QUE CLAUDE** les données JSON retournées et générer une review intelligente :
1. **Conformité template PR** :
- Vérifier que toutes les sections requises sont remplies
- Signaler les sections manquantes ou incomplètes
2. **Qualité du code** :
- Patterns suspects (code dupliqué, fonctions trop longues)
- Problèmes de sécurité potentiels (injections, données sensibles)
- Respect des conventions du projet
3. **Tests** :
- Tests manquants pour les nouvelles fonctionnalités
- Couverture des cas limites
4. **Documentation** :
- Commentaires nécessaires absents
- Mise à jour README si API modifiée
5. **Suggestions d'amélioration** :
- Refactorisation possible
- Performance
- Lisibilité
**GÉNÉRER le commentaire de review** avec structure :
```markdown
## 🔍 Code Review Automatique
### ✅ Points positifs
- [ce qui est bien fait]
### ⚠️ Points d'attention
- [problèmes potentiels à vérifier]
### 💡 Suggestions
- [améliorations possibles]
### 📋 Checklist conformité
- [ ] Template PR complet
- [ ] Tests présents
- [ ] Documentation à jour
---
*Review générée par git-pr skill*
```
EXÉCUTER pour poster le commentaire :
```bash
gh pr comment $PR_NUMBER --body "$REVIEW_COMMENT"
```
- Marquer todo "Code review automatique" completed
### 13. Nettoyage
- Marquer todo "Nettoyage branche locale" in_progress
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/cleanup_branch.sh "$BRANCH_BASE" "$BRANCH_NAME" $DELETE_FLAG
```
Si needs_user_input: true → utiliser AskUserQuestion pour confirmer suppression
- Marquer todo "Nettoyage branche locale" completed
### 14. Rapport final
EXÉCUTER :
```bash
bash $SCRIPTS_DIR/final_report.sh $PR_NUMBER $START_TIME
```
Afficher le rapport YAML généré.

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Collecte les statistiques git en une fois
# Usage: analyze_changes.sh
# Output: JSON avec stats des changements
set -euo pipefail
# Collecter stats
FILES_CHANGED=$(git diff --cached --numstat | wc -l)
ADDITIONS=$(git diff --cached --numstat | awk '{sum+=$1} END {print sum+0}')
DELETIONS=$(git diff --cached --numstat | awk '{sum+=$2} END {print sum+0}')
# Lister fichiers modifiés
MODIFIED_FILES=$(git diff --cached --name-only | jq -R . | jq -s .)
# Détecter fichiers PHP
HAS_PHP_FILES=false
if git diff --cached --name-only | grep -q '\.php$'; then
HAS_PHP_FILES=true
fi
# Générer JSON
cat <<EOF
{
"files_changed": $FILES_CHANGED,
"additions": $ADDITIONS,
"deletions": $DELETIONS,
"modified_files": $MODIFIED_FILES,
"has_php_files": $HAS_PHP_FILES
}
EOF

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Lister milestones et gérer assignation
Usage: assign_milestone.py <pr_number> [--milestone <name>]
Output: Milestone assigné ou "ignored" (stdout)
"""
import argparse
import json
import subprocess
import sys
import re
from milestone_cache import MilestoneCache
def get_repo_info():
"""Récupère owner/repo depuis git remote"""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
# Extraire owner/repo depuis URL GitHub
match = re.search(r'github\.com[:/](.+/.+?)(?:\.git)?$', url)
if match:
return match.group(1)
raise ValueError(f"Format URL invalide: {url}")
except Exception as e:
print(f"❌ Erreur récupération repo: {e}", file=sys.stderr)
sys.exit(1)
def get_open_milestones(repo):
"""Récupère les milestones ouverts via gh API"""
try:
result = subprocess.run(
["gh", "api", f"repos/{repo}/milestones", "--jq",
"[.[] | select(.state == \"open\") | {number, title}]"],
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
print(f"❌ Erreur récupération milestones: {e.stderr}", file=sys.stderr)
return []
except json.JSONDecodeError as e:
print(f"❌ Erreur parsing JSON milestones: {e}", file=sys.stderr)
return []
def get_open_milestones_cached(repo):
"""Récupère milestones avec cache"""
cache = MilestoneCache()
if not cache.cache.get("milestones"):
milestones = get_open_milestones(repo)
cache.refresh_from_api(milestones)
return milestones
return cache.cache["milestones"]
def find_milestone(repo, query):
"""Cherche milestone par query (exact ou partiel)"""
cache = MilestoneCache()
result = cache.find(query)
if result:
return result
normalized = cache.normalize_semver(query)
result = cache.find(normalized)
if result:
return result
milestones = get_open_milestones(repo)
cache.refresh_from_api(milestones)
result = cache.find(normalized)
if result:
return result
return cache.create(normalized)
def assign_milestone(pr_number, milestone_title):
"""Assigne un milestone à la PR"""
try:
subprocess.run(
["gh", "pr", "edit", str(pr_number), "--milestone", milestone_title],
capture_output=True,
text=True,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"❌ Erreur assignation milestone: {e.stderr}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Assigne un milestone à une PR")
parser.add_argument("pr_number", type=int, help="Numéro de la PR")
parser.add_argument("--milestone", help="Nom du milestone à assigner")
args = parser.parse_args()
repo = get_repo_info()
milestones = get_open_milestones_cached(repo)
if not milestones:
print(" Aucun milestone ouvert - ignoré")
print("ignored")
sys.exit(0)
# Si --milestone fourni, utiliser find_milestone pour recherche intelligente
if args.milestone:
try:
milestone = find_milestone(repo, args.milestone)
if assign_milestone(args.pr_number, milestone['title']):
print(f"✅ Milestone '{milestone['title']}' assigné")
print(milestone['title'])
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
print(f"❌ Erreur: {e}", file=sys.stderr)
sys.exit(1)
# Sinon, retourner JSON pour AskUserQuestion
# Suggérer le premier milestone (généralement le plus récent)
milestones_with_suggestion = [
{
"number": m["number"],
"title": m["title"],
"is_suggested": i == 0
}
for i, m in enumerate(milestones)
]
output = {
"milestones": milestones_with_suggestion,
"needs_user_input": True
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
Assigner PR à projet GitHub
Usage: assign_project.py <pr_number> [--project <name>]
Output: Projet assigné ou "ignored" (stdout)
"""
import argparse
import json
import subprocess
import sys
import re
from project_cache import ProjectCache
def get_repo_info():
"""Récupère owner/repo depuis git remote"""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
match = re.search(r'github\.com[:/](.+/.+?)(?:\.git)?$', url)
if match:
repo_full = match.group(1)
owner, repo = repo_full.split('/')
return owner, repo
raise ValueError(f"Format URL invalide: {url}")
except Exception as e:
print(f"❌ Erreur récupération repo: {e}", file=sys.stderr)
sys.exit(1)
def get_projects_list(owner):
"""Récupère les projets via gh project list"""
try:
result = subprocess.run(
["gh", "project", "list", "--owner", owner, "--format", "json"],
capture_output=True,
text=True,
check=True
)
projects_data = json.loads(result.stdout)
projects = []
for project in projects_data.get('projects', []):
projects.append({
'id': project.get('id'),
'title': project.get('title'),
'number': project.get('number')
})
return projects
except subprocess.CalledProcessError as e:
print(f"⚠️ Impossible de récupérer les projets: {e.stderr}", file=sys.stderr)
return []
except json.JSONDecodeError as e:
print(f"❌ Erreur parsing JSON projets: {e}", file=sys.stderr)
return []
def get_projects_cached(owner):
"""Récupère projets avec cache"""
cache = ProjectCache()
if not cache.cache.get("projects"):
projects = get_projects_list(owner)
cache.refresh_from_api(projects)
return projects
return cache.cache["projects"]
def find_project(owner, query):
"""Cherche projet par query (exact ou alias)"""
cache = ProjectCache()
result = cache.find(query)
if result:
return result
projects = get_projects_list(owner)
cache.refresh_from_api(projects)
return cache.find(query)
def assign_pr_to_project(pr_number, project_id, owner, repo):
"""Assigne la PR au projet via GraphQL"""
# Récupérer l'ID de la PR
try:
pr_result = subprocess.run(
["gh", "pr", "view", str(pr_number), "--json", "id"],
capture_output=True,
text=True,
check=True
)
pr_data = json.loads(pr_result.stdout)
pr_id = pr_data['id']
except Exception as e:
print(f"❌ Erreur récupération ID PR: {e}", file=sys.stderr)
return False
# Ajouter la PR au projet
mutation = """
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item {
id
}
}
}
"""
try:
subprocess.run(
["gh", "api", "graphql",
"-f", f"query={mutation}",
"-f", f"projectId={project_id}",
"-f", f"contentId={pr_id}"],
capture_output=True,
text=True,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"❌ Erreur assignation projet: {e.stderr}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Assigne une PR à un projet GitHub")
parser.add_argument("pr_number", type=int, help="Numéro de la PR")
parser.add_argument("--project", help="Nom du projet à assigner")
args = parser.parse_args()
owner, repo = get_repo_info()
projects = get_projects_cached(owner)
if not projects:
print(" Aucun projet trouvé - ignoré")
print("ignored")
sys.exit(0)
# Si --project fourni, utiliser find_project pour recherche intelligente
if args.project:
try:
project = find_project(owner, args.project)
if not project:
print(f"❌ Projet '{args.project}' non trouvé", file=sys.stderr)
available = [p['title'] for p in projects]
print(f"Projets disponibles: {', '.join(available)}", file=sys.stderr)
sys.exit(1)
if assign_pr_to_project(args.pr_number, project['id'], owner, repo):
print(f"✅ Projet '{project['title']}' assigné")
print(project['title'])
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
print(f"❌ Erreur: {e}", file=sys.stderr)
sys.exit(1)
# Sinon, retourner JSON pour AskUserQuestion
projects_list = [
{
"id": p["id"],
"title": p["title"],
"number": p.get("number")
}
for p in projects
]
output = {
"projects": projects_list,
"needs_user_input": True
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Récupère les données de la PR pour analyse intelligente par Claude
# Usage: auto_review.sh <pr_number>
# Sortie: JSON avec toutes les informations nécessaires pour la review
# Exit 0 si OK, Exit 1 si échec
set -euo pipefail
PR_NUMBER="$1"
if [ -z "$PR_NUMBER" ]; then
echo "❌ PR_NUMBER requis" >&2
exit 1
fi
# Récupérer les informations de la PR
PR_INFO=$(gh pr view "$PR_NUMBER" --json title,body,files,additions,deletions,commits,baseRefName,headRefName 2>/dev/null)
if [ -z "$PR_INFO" ]; then
echo "❌ Impossible de récupérer les informations de la PR" >&2
exit 1
fi
# Récupérer le diff complet
DIFF=$(gh pr diff "$PR_NUMBER" 2>/dev/null || echo "")
# Récupérer le template PR s'il existe
PR_TEMPLATE=""
if [ -f ".github/pull_request_template.md" ]; then
PR_TEMPLATE=$(cat .github/pull_request_template.md)
fi
# Générer sortie JSON pour Claude
cat <<EOF
{
"pr_number": $PR_NUMBER,
"pr_info": $PR_INFO,
"diff": $(echo "$DIFF" | jq -Rs .),
"template": $(echo "$PR_TEMPLATE" | jq -Rs .)
}
EOF

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Vérifie que tous les scopes GitHub requis sont présents
# Exit 0 si OK, Exit 1 si scopes manquants
set -e
REQUIRED_SCOPES=(repo read:org project gist)
# Récupérer scopes actuels
CURRENT_SCOPES=$(gh auth status 2>&1 | grep "Token scopes" | sed 's/.*Token scopes: //' | tr -d "'" | tr ',' ' ')
if [ -z "$CURRENT_SCOPES" ]; then
echo "❌ Impossible de récupérer les scopes GitHub"
echo " Vérifier l'authentification avec: gh auth status"
exit 1
fi
# Vérifier chaque scope requis
MISSING_SCOPES=()
for scope in "${REQUIRED_SCOPES[@]}"; do
if ! echo "$CURRENT_SCOPES" | grep -qw "$scope"; then
MISSING_SCOPES+=("$scope")
fi
done
# Rapport
if [ ${#MISSING_SCOPES[@]} -gt 0 ]; then
echo "❌ Scopes GitHub manquants: ${MISSING_SCOPES[*]}"
echo ""
echo "🔄 Pour renouveler l'authentification:"
echo " bash $(dirname "$0")/gh_auth_setup.sh"
exit 1
fi
echo "✅ Scopes GitHub valides"
exit 0

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Proposer/exécuter suppression branche locale
# Usage: cleanup_branch.sh <branch_base> <branch_name> [--delete]
# Exit: 0=supprimée, 1=conservée
set -euo pipefail
BRANCH_BASE="$1"
BRANCH_NAME="$2"
DELETE_FLAG="${3:-}"
# Si --delete fourni, suppression auto
if [ "$DELETE_FLAG" = "--delete" ]; then
echo "🗑️ Suppression automatique de la branche $BRANCH_NAME..."
git checkout "$BRANCH_BASE"
git branch -D "$BRANCH_NAME"
echo "✅ Branche $BRANCH_NAME supprimée"
exit 0
fi
# Sinon, retourner JSON pour AskUserQuestion
cat <<EOF
{
"branch_name": "$BRANCH_NAME",
"branch_base": "$BRANCH_BASE",
"needs_user_input": true
}
EOF

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Détecte les branches disponibles et gère la confirmation
Usage: confirm_base_branch.py [--branch <name>]
Output: Nom branche validé (stdout) ou JSON options
"""
import argparse
import json
import subprocess
import sys
import re
def get_remote_branches():
"""Récupère les branches remote et filtre develop/main/master/release/*"""
try:
result = subprocess.run(
["git", "branch", "-r"],
capture_output=True,
text=True,
check=True
)
branches = []
for line in result.stdout.strip().split('\n'):
branch = line.strip()
# Retirer origin/
branch = re.sub(r'^origin/', '', branch)
# Ignorer HEAD
if 'HEAD' in branch:
continue
# Filtrer develop/main/master/release/*/hotfix/*
if branch in ['develop', 'main', 'master'] or branch.startswith('release/') or branch.startswith('hotfix/'):
branches.append(branch)
return sorted(set(branches))
except subprocess.CalledProcessError as e:
print(f"❌ Erreur lors de la récupération des branches: {e}", file=sys.stderr)
sys.exit(1)
def validate_branch(branch_name, available_branches):
"""Valide qu'une branche existe"""
if branch_name in available_branches:
return True
print(f"❌ Branche '{branch_name}' non trouvée", file=sys.stderr)
print(f"Branches disponibles: {', '.join(available_branches)}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Confirme la branche de base pour la PR")
parser.add_argument("--branch", help="Nom de la branche de base")
args = parser.parse_args()
branches = get_remote_branches()
if not branches:
print("❌ Aucune branche develop/main/master/release/hotfix trouvée", file=sys.stderr)
sys.exit(1)
# Si --branch fourni, valider et retourner
if args.branch:
if validate_branch(args.branch, branches):
print(args.branch)
sys.exit(0)
else:
sys.exit(1)
# Sinon, retourner JSON pour AskUserQuestion
suggested = "develop" if "develop" in branches else branches[0]
output = {
"branches": branches,
"suggested": suggested,
"needs_user_input": True
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Création complète de PR: lit template, génère titre, crée PR
# Usage: create_pr.sh <branch_base> <pr_template_path>
# Output: PR_NUMBER (stdout) ou exit 1
set -euo pipefail
BRANCH_BASE="$1"
PR_TEMPLATE_PATH="$2"
SCRIPTS_DIR="$(dirname "$0")"
# Récupérer branche courante
BRANCH_NAME=$(git branch --show-current)
# Lire template PR
if [ ! -f "$PR_TEMPLATE_PATH" ]; then
echo "❌ Template PR absent: $PR_TEMPLATE_PATH" >&2
exit 1
fi
PR_TEMPLATE=$(cat "$PR_TEMPLATE_PATH")
# Détecter issue depuis nom de branche (ex: feat/123-description, fix/456-bug)
ISSUE_NUMBER=$(echo "$BRANCH_NAME" | grep -oE '[0-9]+' | head -1 || echo "")
# Générer titre PR
if [ -n "$ISSUE_NUMBER" ]; then
# Vérifier que l'issue existe et récupérer son titre
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --json title -q '.title' 2>/dev/null || echo "")
if [ -n "$ISSUE_TITLE" ]; then
PR_TITLE="$ISSUE_TITLE / Issue #$ISSUE_NUMBER"
echo "✅ Titre PR basé sur issue #$ISSUE_NUMBER" >&2
else
# Issue non trouvée, utiliser nom de branche
echo "⚠️ Issue #$ISSUE_NUMBER non trouvée" >&2
# Nettoyer le nom de branche pour créer un titre
PR_TITLE=$(echo "$BRANCH_NAME" | sed 's|^[^/]*/||' | sed 's/-/ /g' | sed 's/[0-9]* *//')
PR_TITLE="${PR_TITLE^}" # Capitalize first letter
fi
else
# Pas de numéro d'issue, utiliser nom de branche
echo " Pas d'issue détectée dans '$BRANCH_NAME'" >&2
PR_TITLE=$(echo "$BRANCH_NAME" | sed 's|^[^/]*/||' | sed 's/-/ /g')
PR_TITLE="${PR_TITLE^}"
fi
# Créer fichier temporaire avec le body
PR_BODY_FILE="/tmp/pr_body_$(date +%s).md"
echo "$PR_TEMPLATE" > "$PR_BODY_FILE"
# Appeler le script de push sécurisé
PR_NUMBER=$(bash "$SCRIPTS_DIR/safe_push_pr.sh" "$BRANCH_BASE" "$BRANCH_NAME" "$PR_TITLE" "$PR_BODY_FILE")
EXIT_CODE=$?
# Nettoyer
rm -f "$PR_BODY_FILE"
if [ $EXIT_CODE -ne 0 ]; then
exit 1
fi
echo "$PR_NUMBER"

View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Génère le rapport final YAML de la PR
# Usage: final_report.sh <pr_number> <start_time>
# Output: Rapport YAML formaté
set -euo pipefail
PR_NUMBER="$1"
START_TIME="$2"
if [ -z "$PR_NUMBER" ] || [ -z "$START_TIME" ]; then
echo "❌ Usage: final_report.sh <pr_number> <start_time>" >&2
exit 1
fi
# Récupérer infos de la PR
PR_INFO=$(gh pr view "$PR_NUMBER" --json title,url,baseRefName,headRefName,additions,deletions,changedFiles 2>/dev/null || echo "{}")
PR_TITLE=$(echo "$PR_INFO" | jq -r '.title // "N/A"')
PR_URL=$(echo "$PR_INFO" | jq -r '.url // "N/A"')
BRANCH_BASE=$(echo "$PR_INFO" | jq -r '.baseRefName // "N/A"')
BRANCH_NAME=$(echo "$PR_INFO" | jq -r '.headRefName // "N/A"')
ADDITIONS=$(echo "$PR_INFO" | jq -r '.additions // 0')
DELETIONS=$(echo "$PR_INFO" | jq -r '.deletions // 0')
FILES_CHANGED=$(echo "$PR_INFO" | jq -r '.changedFiles // 0')
# Calculer durée
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
# Formater durée
if [ $DURATION -lt 60 ]; then
DURATION_STR="${DURATION}s"
elif [ $DURATION -lt 3600 ]; then
MINUTES=$((DURATION / 60))
SECONDS=$((DURATION % 60))
DURATION_STR="${MINUTES}m ${SECONDS}s"
else
HOURS=$((DURATION / 3600))
MINUTES=$(((DURATION % 3600) / 60))
SECONDS=$((DURATION % 60))
DURATION_STR="${HOURS}h ${MINUTES}m ${SECONDS}s"
fi
# Formater timestamps
START_DATE=$(date -d "@$START_TIME" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -r "$START_TIME" '+%Y-%m-%d %H:%M:%S')
END_DATE=$(date '+%Y-%m-%d %H:%M:%S')
# Générer rapport YAML
cat <<EOF
task: "Pull Request créée avec succès"
status: "completed"
details:
pr_number: $PR_NUMBER
pr_title: "$PR_TITLE"
pr_url: "$PR_URL"
branch_source: "$BRANCH_NAME"
branch_base: "$BRANCH_BASE"
stats:
files_changed: $FILES_CHANGED
additions: $ADDITIONS
deletions: $DELETIONS
timing:
start: "$START_DATE"
end: "$END_DATE"
duration: "$DURATION_STR"
EOF

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Configuration authentification GitHub avec tous les scopes requis
# Usage: bash gh_auth_setup.sh
set -e
REQUIRED_SCOPES=(
"repo" # Accès complet aux repos (PRs, commits, etc.)
"read:org" # Lecture infos organisation
"read:project" # Lecture projets GitHub
"project" # Écriture/assignation aux projets
"gist" # Gestion des gists
)
echo "🔐 Configuration authentification GitHub"
echo ""
echo "Scopes requis:"
for scope in "${REQUIRED_SCOPES[@]}"; do
echo " - $scope"
done
echo ""
# Construire la commande avec tous les scopes
CMD="gh auth refresh --hostname github.com"
for scope in "${REQUIRED_SCOPES[@]}"; do
CMD="$CMD -s $scope"
done
echo "🔄 Exécution: $CMD"
echo ""
eval "$CMD"
echo ""
echo "✅ Authentification configurée avec succès"
echo ""
echo "Vérification des scopes:"
gh auth status

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Module de cache pour milestones GitHub"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
CACHE_DIR = Path(".claude/cache")
CACHE_FILE = CACHE_DIR / "git-milestones.json"
class MilestoneCache:
"""Gestion cache milestones"""
def __init__(self):
self.cache = self.load()
def load(self) -> dict:
"""Charge cache depuis fichier"""
if not CACHE_FILE.exists():
CACHE_DIR.mkdir(parents=True, exist_ok=True)
return {"updated_at": None, "milestones": []}
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {"updated_at": None, "milestones": []}
def save(self):
"""Sauvegarde cache vers fichier"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
self.cache["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(self.cache, f, indent=2, ensure_ascii=False)
def find(self, query: str) -> Optional[dict]:
"""Cherche milestone par titre exact ou alias"""
for milestone in self.cache.get("milestones", []):
if milestone["title"] == query:
return milestone
if query in milestone.get("aliases", []):
return milestone
return None
def add(self, milestone: dict):
"""Ajoute milestone au cache avec génération aliases"""
aliases = self.generate_aliases(milestone["title"])
milestone["aliases"] = aliases
existing = [m for m in self.cache.get("milestones", []) if m["number"] == milestone["number"]]
if not existing:
if "milestones" not in self.cache:
self.cache["milestones"] = []
self.cache["milestones"].append(milestone)
self.save()
def generate_aliases(self, title: str) -> list[str]:
"""Génère aliases depuis titre (version exacte + formes courtes)
Exemples:
- "26.1.1 (Hotfix)" → ["26.1.1", "26.1", "26"]
- "26.1.0" → ["26.1.0", "26.1", "26"]
- "26.0.0 (Avenant)" → ["26.0.0", "26.0", "26"]
Logique: extraire version semver et générer formes courtes
"""
match = re.match(r'^(\d+)\.(\d+)\.(\d+)', title)
if match:
major, minor, patch = match.groups()
aliases = []
full_version = f"{major}.{minor}.{patch}"
if full_version != title:
aliases.append(full_version)
aliases.append(f"{major}.{minor}")
aliases.append(major)
return aliases
return []
def refresh_from_api(self, milestones: list[dict]):
"""Remplace cache entièrement avec données API"""
enriched = []
for milestone in milestones:
aliases = self.generate_aliases(milestone["title"])
enriched.append({
"number": milestone["number"],
"title": milestone["title"],
"aliases": aliases
})
self.cache["milestones"] = enriched
self.save()
def create(self, title: str) -> dict:
"""Crée milestone sur GitHub via API et l'ajoute au cache
Récupère repo via get_repo_info()
gh api repos/{repo}/milestones -f title="99.0.0" -f state="open"
Retourne: {"number": 43, "title": "99.0.0", ...}
"""
repo = self.get_repo_info()
try:
result = subprocess.run(
["gh", "api", f"repos/{repo}/milestones", "-f", f"title={title}", "-f", "state=open"],
capture_output=True,
text=True,
check=True
)
milestone = json.loads(result.stdout)
self.add(milestone)
return milestone
except subprocess.CalledProcessError as e:
print(f"❌ Erreur création milestone: {e.stderr}", file=sys.stderr)
raise
except json.JSONDecodeError as e:
print(f"❌ Erreur parsing JSON: {e}", file=sys.stderr)
raise
def get_repo_info(self) -> str:
"""Récupère owner/repo depuis git remote"""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
match = re.search(r'github\.com[:/](.+/.+?)(?:\.git)?$', url)
if match:
return match.group(1)
raise ValueError(f"Format URL invalide: {url}")
except Exception as e:
print(f"❌ Erreur récupération repo: {e}", file=sys.stderr)
raise
def normalize_semver(self, version: str) -> str:
"""Normalise version en semver complet
Exemples:
- "26""26.0.0"
- "26.1""26.1.0"
- "26.1.1""26.1.1"
- "26.0.0 (Avenant)""26.0.0 (Avenant)" (conserve suffixe)
"""
match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)?$', version)
if not match:
return version
major = match.group(1)
minor = match.group(2) or "0"
patch = match.group(3) or "0"
suffix = match.group(4) or ""
return f"{major}.{minor}.{patch}{suffix}"

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Module de cache pour projets GitHub"""
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
CACHE_DIR = Path(".claude/cache")
CACHE_FILE = CACHE_DIR / "git-projects.json"
class ProjectCache:
"""Gestion cache projets"""
def __init__(self):
self.cache = self.load()
def load(self) -> dict:
"""Charge cache depuis fichier"""
if not CACHE_FILE.exists():
CACHE_DIR.mkdir(parents=True, exist_ok=True)
return {"updated_at": None, "projects": []}
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {"updated_at": None, "projects": []}
def save(self):
"""Sauvegarde cache vers fichier"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
self.cache["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(self.cache, f, indent=2, ensure_ascii=False)
def find(self, query: str) -> Optional[dict]:
"""Cherche projet par titre exact ou alias (case-insensitive)"""
query_lower = query.lower()
for project in self.cache.get("projects", []):
if project["title"].lower() == query_lower:
return project
if query_lower in [alias.lower() for alias in project.get("aliases", [])]:
return project
return None
def add(self, project: dict):
"""Ajoute projet au cache avec génération aliases"""
aliases = self.generate_aliases(project["title"])
project["aliases"] = aliases
existing = [p for p in self.cache.get("projects", []) if p["id"] == project["id"]]
if not existing:
if "projects" not in self.cache:
self.cache["projects"] = []
self.cache["projects"].append(project)
self.save()
def generate_aliases(self, title: str) -> list[str]:
"""Génère aliases depuis titre
Exemples:
- "Project Alpha" → ["project", "alpha"]
- "Sprint 2024-Q1" → ["sprint", "2024", "q1"]
- "Bug Tracking" → ["bug", "tracking"]
Logique: extraire mots-clés significatifs
"""
aliases = []
words = re.findall(r'[\w]+', title.lower())
for word in words:
if len(word) >= 2 and word not in ['the', 'and', 'for']:
aliases.append(word)
return list(set(aliases))
def refresh_from_api(self, projects: list[dict]):
"""Remplace cache entièrement avec données API"""
enriched = []
for project in projects:
aliases = self.generate_aliases(project["title"])
enriched.append({
"id": project["id"],
"title": project["title"],
"number": project.get("number"),
"aliases": aliases
})
self.cache["projects"] = enriched
self.save()
def get_repo_info(self) -> str:
"""Récupère owner/repo depuis git remote"""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
match = re.search(r'github\.com[:/](.+/.+?)(?:\.git)?$', url)
if match:
repo_full = match.group(1)
owner, _ = repo_full.split('/')
return owner
raise ValueError(f"Format URL invalide: {url}")
except Exception as e:
print(f"❌ Erreur récupération repo: {e}", file=sys.stderr)
raise

View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Vérifications sécurité + push + création PR
# Usage: safe_push_pr.sh <branch_base> <branch_name> <pr_title> <pr_body_file>
# Output: PR_NUMBER (stdout) ou exit 1
set -euo pipefail
BRANCH_BASE="$1"
BRANCH_NAME="$2"
PR_TITLE="$3"
PR_BODY_FILE="$4"
# Vérification 1: Branche courante
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" != "$BRANCH_NAME" ]; then
echo "❌ Branche courante ($CURRENT_BRANCH) != branche attendue ($BRANCH_NAME)" >&2
exit 1
fi
# Vérification 2: Commits à pousser
COMMITS_TO_PUSH=$(git log --oneline "origin/$BRANCH_BASE..$BRANCH_NAME" 2>/dev/null | wc -l || echo "0")
if [ "$COMMITS_TO_PUSH" -eq 0 ]; then
echo "❌ Aucun commit à pousser vers $BRANCH_BASE" >&2
exit 1
fi
# Vérification 3: Afficher commits
echo "📋 Commits à pousser ($COMMITS_TO_PUSH):"
git log --oneline "origin/$BRANCH_BASE..$BRANCH_NAME" | sed 's/^/ /'
# Vérification 4: Vérifier que le fichier body existe
if [ ! -f "$PR_BODY_FILE" ]; then
echo "❌ Fichier PR body absent: $PR_BODY_FILE" >&2
exit 1
fi
# Push vers origin
echo "🚀 Push vers origin/$BRANCH_NAME..."
if ! git push -u origin "$BRANCH_NAME"; then
echo "❌ Échec du push" >&2
exit 1
fi
# Création PR
echo "📝 Création de la Pull Request..."
PR_URL=$(gh pr create \
--base "$BRANCH_BASE" \
--title "$PR_TITLE" \
--body-file "$PR_BODY_FILE" \
2>&1)
if [ $? -ne 0 ]; then
echo "❌ Échec création PR: $PR_URL" >&2
exit 1
fi
# Extraire le numéro de PR depuis l'URL
PR_NUMBER=$(echo "$PR_URL" | grep -oP '/pull/\K\d+' || echo "")
if [ -z "$PR_NUMBER" ]; then
# Fallback: extraire depuis gh pr view
PR_NUMBER=$(gh pr view --json number -q .number 2>/dev/null || echo "")
fi
if [ -z "$PR_NUMBER" ]; then
echo "❌ Impossible d'extraire le numéro de PR" >&2
echo "URL: $PR_URL" >&2
exit 1
fi
echo "✅ PR #$PR_NUMBER créée: $PR_URL"
echo "$PR_NUMBER"

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Détecte les fichiers PHP modifiés et lance make qa si nécessaire
# Usage: smart_qa.sh
# Exit: 0=ok/ignoré, 1=échec QA
set -euo pipefail
# Détecter fichiers PHP modifiés
PHP_FILES=$(git diff --name-only --cached | grep '\.php$' || true)
if [ -z "$PHP_FILES" ]; then
echo " Aucun fichier PHP modifié - QA ignorée"
exit 0
fi
echo "🔍 Fichiers PHP détectés - Lancement de make qa..."
echo "$PHP_FILES" | sed 's/^/ - /'
# Lancer QA avec timeout
if timeout 600 make qa; then
echo "✅ QA passée avec succès"
exit 0
else
EXIT_CODE=$?
if [ $EXIT_CODE -eq 124 ]; then
echo "❌ QA timeout (>600s)" >&2
else
echo "❌ QA échouée" >&2
fi
exit 1
fi

View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Vérifie l'existence du template PR
# Usage: verify_pr_template.sh <chemin_template>
# Exit: 0=ok, 1=absent
set -euo pipefail
TEMPLATE_PATH="${1:-.github/pull_request_template.md}"
if [ ! -f "$TEMPLATE_PATH" ]; then
echo "❌ Template PR absent: $TEMPLATE_PATH" >&2
exit 1
fi
echo "✅ Template PR trouvé: $TEMPLATE_PATH"
exit 0

15
skills/git-pr/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Cache test local
.claude/
# Éditeurs
*~
*.swp
*.swo
.DS_Store

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Lance tous les tests unitaires du skill git-pr
set -e
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$TEST_DIR"
echo "🧪 Lancement tests git-pr..."
echo ""
python3 test_milestone_cache.py -v
echo ""
python3 test_project_cache.py -v
echo ""
echo "✅ Tous les tests passés"

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Tests unitaires pour milestone_cache"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from milestone_cache import MilestoneCache, CACHE_DIR, CACHE_FILE
class TestNormalizeSemver(unittest.TestCase):
"""Test normalisation semver"""
def test_normalise_un_chiffre_ajoute_zero_zero(self):
cache = MilestoneCache()
self.assertEqual(cache.normalize_semver("26"), "26.0.0")
def test_normalise_deux_chiffres_ajoute_zero(self):
cache = MilestoneCache()
self.assertEqual(cache.normalize_semver("26.1"), "26.1.0")
def test_normalise_trois_chiffres_reste_identique(self):
cache = MilestoneCache()
self.assertEqual(cache.normalize_semver("26.1.1"), "26.1.1")
def test_normalise_conserve_suffixe(self):
cache = MilestoneCache()
self.assertEqual(cache.normalize_semver("26.0.0 (Avenant)"), "26.0.0 (Avenant)")
class TestGenerateAliases(unittest.TestCase):
"""Test génération aliases stricte"""
def test_genere_alias_pour_titre_avec_parentheses(self):
cache = MilestoneCache()
self.assertEqual(cache.generate_aliases("26.1.1 (Hotfix)"), ["26.1.1"])
def test_genere_alias_pour_titre_avec_avenant(self):
cache = MilestoneCache()
self.assertEqual(cache.generate_aliases("26.0.0 (Avenant)"), ["26.0.0"])
def test_genere_pas_alias_pour_titre_sans_parentheses(self):
cache = MilestoneCache()
self.assertEqual(cache.generate_aliases("26.1.0"), [])
def test_genere_pas_alias_pour_titre_non_semver(self):
cache = MilestoneCache()
self.assertEqual(cache.generate_aliases("Release Candidate"), [])
class TestFindExactMatch(unittest.TestCase):
"""Test recherche exacte"""
def test_trouve_par_titre_exact(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 42, "title": "26.0.0 (Avenant)", "aliases": ["26.0.0"]}
]
}
result = cache.find("26.0.0 (Avenant)")
self.assertIsNotNone(result)
self.assertEqual(result["number"], 42)
def test_ne_trouve_pas_si_titre_different(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 42, "title": "26.0.0 (Avenant)", "aliases": ["26.0.0"]}
]
}
result = cache.find("27.0.0")
self.assertIsNone(result)
class TestFindByAlias(unittest.TestCase):
"""Test recherche par alias"""
def test_query_trouve_via_alias(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 43, "title": "26.1.1 (Hotfix)", "aliases": ["26.1.1"]}
]
}
result = cache.find("26.1.1")
self.assertIsNotNone(result)
self.assertEqual(result["title"], "26.1.1 (Hotfix)")
def test_query_trouve_milestone_sans_parentheses(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 44, "title": "26.1.0", "aliases": []}
]
}
result = cache.find("26.1.0")
self.assertIsNotNone(result)
self.assertEqual(result["number"], 44)
class TestNoCollisionMatch(unittest.TestCase):
"""Test pas de collision entre versions"""
def test_query_partielle_ne_trouve_pas_version_complete(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 43, "title": "26.1.1 (Hotfix)", "aliases": ["26.1.1"]}
]
}
result = cache.find("26.1")
self.assertIsNone(result)
def test_query_majeure_ne_trouve_pas_version_mineure(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 42, "title": "26.0.0 (Avenant)", "aliases": ["26.0.0"]}
]
}
result = cache.find("26")
self.assertIsNone(result)
class TestFindWithNormalization(unittest.TestCase):
"""Test recherche avec normalisation"""
def test_query_normalisee_trouve_milestone(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 44, "title": "26.1.0", "aliases": []}
]
}
normalized = cache.normalize_semver("26.1")
result = cache.find(normalized)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "26.1.0")
def test_query_majeure_normalisee_trouve_avenant(self):
cache = MilestoneCache()
cache.cache = {
"milestones": [
{"number": 42, "title": "26.0.0 (Avenant)", "aliases": ["26.0.0"]}
]
}
normalized = cache.normalize_semver("26")
result = cache.find(normalized)
self.assertIsNotNone(result)
self.assertEqual(result["title"], "26.0.0 (Avenant)")
class TestCachePersistence(unittest.TestCase):
"""Test sauvegarde/chargement"""
def test_charge_cache_vide_si_fichier_inexistant(self):
cache = MilestoneCache()
cache.cache = {"milestones": []}
self.assertEqual(cache.cache["milestones"], [])
class TestCreateMilestone(unittest.TestCase):
"""Test création milestone inexistant"""
@patch('milestone_cache.subprocess.run')
def test_cree_milestone_via_api(self, mock_run):
mock_run.side_effect = [
MagicMock(stdout="git@github.com:owner/repo.git\n"),
MagicMock(stdout='{"number": 50, "title": "99.0.0"}')
]
cache = MilestoneCache()
result = cache.create("99.0.0")
self.assertEqual(result["number"], 50)
self.assertEqual(result["title"], "99.0.0")
class TestCreateWithNormalization(unittest.TestCase):
"""Test création avec normalisation semver"""
@patch('milestone_cache.subprocess.run')
def test_cree_milestone_normalise_depuis_majeure(self, mock_run):
mock_run.side_effect = [
MagicMock(stdout="git@github.com:owner/repo.git\n"),
MagicMock(stdout='{"number": 51, "title": "26.0.0"}')
]
cache = MilestoneCache()
normalized = cache.normalize_semver("26")
result = cache.create(normalized)
self.assertEqual(result["title"], "26.0.0")
@patch('milestone_cache.subprocess.run')
def test_cree_milestone_normalise_depuis_mineure(self, mock_run):
mock_run.side_effect = [
MagicMock(stdout="git@github.com:owner/repo.git\n"),
MagicMock(stdout='{"number": 52, "title": "26.1.0"}')
]
cache = MilestoneCache()
normalized = cache.normalize_semver("26.1")
result = cache.create(normalized)
self.assertEqual(result["title"], "26.1.0")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Tests unitaires pour project_cache"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from project_cache import ProjectCache, CACHE_DIR, CACHE_FILE
class TestGenerateAliases(unittest.TestCase):
"""Test génération aliases"""
def test_genere_alias_pour_titre_simple(self):
cache = ProjectCache()
aliases = cache.generate_aliases("Project Alpha")
self.assertIn("project", aliases)
self.assertIn("alpha", aliases)
def test_genere_alias_pour_titre_avec_tirets(self):
cache = ProjectCache()
aliases = cache.generate_aliases("Bug Tracking")
self.assertIn("bug", aliases)
self.assertIn("tracking", aliases)
def test_genere_alias_pour_titre_avec_chiffres(self):
cache = ProjectCache()
aliases = cache.generate_aliases("Sprint 2024-Q1")
self.assertIn("sprint", aliases)
self.assertIn("2024", aliases)
self.assertIn("q1", aliases)
def test_genere_pas_alias_pour_mots_courts(self):
cache = ProjectCache()
aliases = cache.generate_aliases("The Big Project")
self.assertNotIn("the", aliases)
self.assertIn("big", aliases)
self.assertIn("project", aliases)
class TestFindExactMatch(unittest.TestCase):
"""Test recherche exacte"""
def test_trouve_par_titre_exact(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_123", "title": "Project Alpha", "number": 1, "aliases": ["project", "alpha"]}
]
}
result = cache.find("Project Alpha")
self.assertIsNotNone(result)
self.assertEqual(result["id"], "PVT_123")
def test_trouve_par_titre_exact_case_insensitive(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_123", "title": "Project Alpha", "number": 1, "aliases": ["project", "alpha"]}
]
}
result = cache.find("project alpha")
self.assertIsNotNone(result)
self.assertEqual(result["id"], "PVT_123")
def test_ne_trouve_pas_si_titre_different(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_123", "title": "Project Alpha", "number": 1, "aliases": ["project", "alpha"]}
]
}
result = cache.find("Project Beta")
self.assertIsNone(result)
class TestFindByAlias(unittest.TestCase):
"""Test recherche par alias"""
def test_query_trouve_via_alias(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_456", "title": "Bug Tracking", "number": 2, "aliases": ["bug", "tracking"]}
]
}
result = cache.find("bug")
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Bug Tracking")
def test_query_trouve_via_alias_case_insensitive(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_456", "title": "Bug Tracking", "number": 2, "aliases": ["bug", "tracking"]}
]
}
result = cache.find("BUG")
self.assertIsNotNone(result)
self.assertEqual(result["title"], "Bug Tracking")
def test_query_trouve_projet_sans_alias(self):
cache = ProjectCache()
cache.cache = {
"projects": [
{"id": "PVT_789", "title": "Main", "number": 3, "aliases": ["main"]}
]
}
result = cache.find("Main")
self.assertIsNotNone(result)
self.assertEqual(result["number"], 3)
class TestCachePersistence(unittest.TestCase):
"""Test sauvegarde/chargement"""
def test_charge_cache_vide_si_fichier_inexistant(self):
cache = ProjectCache()
cache.cache = {"projects": []}
self.assertEqual(cache.cache["projects"], [])
class TestRefreshFromApi(unittest.TestCase):
"""Test refresh depuis API"""
def test_refresh_remplace_cache_complet(self):
cache = ProjectCache()
projects = [
{"id": "PVT_111", "title": "Project A", "number": 1},
{"id": "PVT_222", "title": "Project B", "number": 2}
]
cache.refresh_from_api(projects)
self.assertEqual(len(cache.cache["projects"]), 2)
self.assertEqual(cache.cache["projects"][0]["id"], "PVT_111")
self.assertEqual(cache.cache["projects"][1]["id"], "PVT_222")
def test_refresh_genere_aliases(self):
cache = ProjectCache()
projects = [
{"id": "PVT_333", "title": "Sprint Planning", "number": 3}
]
cache.refresh_from_api(projects)
self.assertIn("sprint", cache.cache["projects"][0]["aliases"])
self.assertIn("planning", cache.cache["projects"][0]["aliases"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,302 @@
---
name: release-notes
description: >
Génère des notes de release HTML orientées utilisateurs finaux.
Transforme les commits techniques en descriptions accessibles sans jargon.
allowed-tools: [Bash, Read, Write, Grep, Glob, AskUserQuestion]
model: sonnet
---
# Release Notes Skill
Génère un document HTML orienté utilisateurs finaux, sans jargon technique.
## Variables
```bash
REPORT_PATH=".claude/reports"
TARGET="$ARGUMENTS" # Format: "branche-source branche-cible [nom-release]"
```
## Workflow
### Étape 0: Extraction et validation des arguments
```bash
# Parser les arguments
BRANCH_SOURCE=$(echo "$TARGET" | awk '{print $1}')
BRANCH_TARGET=$(echo "$TARGET" | awk '{print $2}')
RELEASE_NAME=$(echo "$TARGET" | awk '{print $3}')
```
**AVANT TOUTE EXÉCUTION**, vérifier que les arguments obligatoires sont fournis :
1. Si `$BRANCH_SOURCE` est manquant ou vide :
- Lister les branches disponibles : `git branch -r --list 'origin/release/*' --list 'origin/feature/*' | head -10`
- Utiliser `AskUserQuestion` pour demander la branche source
- Question : "Quelle est la branche source à analyser ?"
- Proposer les branches récentes de type `release/*` ou `feature/*`
2. Si `$BRANCH_TARGET` est manquant ou vide :
- Utiliser `AskUserQuestion` pour demander la branche cible
- Question : "Quelle est la branche de référence ?"
- Options suggérées : `main`, `develop`, `master`
**Ne pas continuer** tant que les deux arguments obligatoires ne sont pas fournis.
Si `$RELEASE_NAME` n'est pas fourni, utiliser le nom de `$BRANCH_SOURCE` en retirant le préfixe "release/" ou "feature/".
### Étape 1: Validation des branches
```bash
# Vérifier que les branches existent
git rev-parse --verify $BRANCH_SOURCE
git rev-parse --verify $BRANCH_TARGET
# Vérifier qu'il y a des différences
DIFF_COUNT=$(git rev-list --count $BRANCH_TARGET..$BRANCH_SOURCE)
if [ $DIFF_COUNT -eq 0 ]; then
echo "Aucune différence entre les branches"
exit 0
fi
echo "Commits à analyser: $DIFF_COUNT"
```
### Étape 2: Collecte des commits
```bash
# Récupérer tous les commits avec leur message complet
git log $BRANCH_TARGET..$BRANCH_SOURCE --oneline --no-merges
# Pour plus de contexte si nécessaire
git log $BRANCH_TARGET..$BRANCH_SOURCE --pretty=format:"%h|%s|%b" --no-merges
```
### Étape 3: Analyse et catégorisation
Analyser chaque commit et le catégoriser selon son **impact utilisateur** :
#### Catégories
1. **Nouveautés** (icône étoile)
- Nouvelles fonctionnalités visibles par l'utilisateur
- Nouveaux écrans, boutons, options
- Mots-clés : feat, feature, ✨, 🚀, nouveau, ajout
2. **Améliorations** (icône flèche vers le haut)
- Améliorations de fonctionnalités existantes
- Meilleure ergonomie, rapidité
- Mots-clés : improve, enhance, ♻️, ⚡, amélioration, optimisation, perf
3. **Corrections** (icône coche)
- Bugs corrigés impactant l'utilisateur
- Problèmes résolus
- Mots-clés : fix, 🐛, correction, résolution, bug
4. **Sécurité** (icône bouclier) - si applicable
- Améliorations de sécurité
- Mots-clés : security, 🔒, sécurité
#### Commits à IGNORER (ne pas inclure dans les notes)
- `refactor:` - Refactorisation interne
- `test:` / `✅` - Tests
- `chore:` / `🔧` - Maintenance technique
- `ci:` / `👷` - CI/CD
- `docs:` / `📝` - Documentation technique (sauf si user-facing)
- `style:` / `💄` - Formatage code
- Commits de merge
- Mises à jour de dépendances
### Étape 4: Rédaction des notes
Pour chaque commit retenu, rédiger une description **orientée utilisateur** :
**Règles de rédaction STRICTES :**
1. **ZÉRO jargon technique**
- ❌ "Refactoring du composant UserController"
- ✅ "Amélioration de la gestion de votre compte"
- ❌ "Ajout d'un endpoint API REST"
- ✅ "Nouvelle fonctionnalité disponible"
2. **Bénéfice utilisateur en premier**
- ❌ "Ajout d'un cache sur les requêtes API"
- ✅ "L'application est maintenant plus rapide"
- ❌ "Optimisation des requêtes SQL"
- ✅ "Les pages se chargent plus rapidement"
3. **Verbes d'action simples**
- Vous pouvez maintenant...
- Il est désormais possible de...
- Nous avons corrigé...
- Nous avons amélioré...
4. **Phrases courtes et claires**
- Max 1-2 phrases par élément
- Pas d'acronymes sans explication (pas de API, SQL, REST, etc.)
5. **Ton positif et professionnel**
- Éviter les formulations négatives
- Focus sur ce qui est possible/amélioré
### Étape 5: Génération du HTML
Utiliser le template suivant avec CSS inline :
```html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes de version - [RELEASE_NAME]</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f8f9fa;
}
header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
color: white;
}
header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
.version { font-size: 1.2rem; opacity: 0.9; }
.date { font-size: 0.9rem; opacity: 0.7; }
main { display: flex; flex-direction: column; gap: 2rem; }
.category {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.category h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #eee;
}
.category.new h2 { border-color: #ffd700; }
.category.improved h2 { border-color: #4caf50; }
.category.fixed h2 { border-color: #2196f3; }
.category.security h2 { border-color: #ff5722; }
.category ul { list-style: none; }
.category li {
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
}
.category li:last-child { border-bottom: none; }
footer {
text-align: center;
margin-top: 3rem;
padding: 1rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 600px) {
body { padding: 1rem; }
header h1 { font-size: 1.8rem; }
}
</style>
</head>
<body>
<header>
<h1>Quoi de neuf ?</h1>
<p class="version">Version [RELEASE_NAME]</p>
<p class="date">[DATE du jour]</p>
</header>
<main>
<!-- Section Nouveautés (si non vide) -->
<section class="category new">
<h2>⭐ Nouveautés</h2>
<ul>
<li>Description orientée utilisateur...</li>
</ul>
</section>
<!-- Section Améliorations (si non vide) -->
<section class="category improved">
<h2>📈 Améliorations</h2>
<ul>
<li>Description orientée utilisateur...</li>
</ul>
</section>
<!-- Section Corrections (si non vide) -->
<section class="category fixed">
<h2>✅ Corrections</h2>
<ul>
<li>Description orientée utilisateur...</li>
</ul>
</section>
<!-- Section Sécurité (si applicable et non vide) -->
<section class="category security">
<h2>🔒 Sécurité</h2>
<ul>
<li>Description orientée utilisateur...</li>
</ul>
</section>
</main>
<footer>
<p>Merci d'utiliser notre application !</p>
</footer>
</body>
</html>
```
**Notes :**
- Ne pas inclure les sections vides
- Remplacer [RELEASE_NAME] par le nom de la release
- Remplacer [DATE] par la date du jour au format "26 novembre 2025"
### Étape 6: Sauvegarde du fichier
```bash
OUTPUT_FILE="$REPORT_PATH/release_notes_${RELEASE_NAME}.html"
mkdir -p "$REPORT_PATH"
```
Utiliser le tool `Write` pour écrire le contenu HTML dans `$OUTPUT_FILE`.
Afficher le résumé :
```
Notes de release générées : $OUTPUT_FILE
Résumé :
- X nouveautés
- Y améliorations
- Z corrections
```
## Exemples de transformation
| Commit technique | Note utilisateur |
|------------------|------------------|
| `✨ feat: implémenter cache Redis sur endpoint /api/users` | L'affichage de la liste des utilisateurs est maintenant plus rapide |
| `🐛 fix: corriger validation email dans le formulaire d'inscription` | Nous avons corrigé un problème qui empêchait certaines adresses email d'être acceptées lors de l'inscription |
| `⚡ perf: optimiser requêtes N+1 sur la page dashboard` | Le tableau de bord se charge maintenant plus rapidement |
| `✨ feat: ajouter export CSV des factures` | Vous pouvez maintenant exporter vos factures au format Excel |
| `🐛 fix: résoudre crash sur iOS 16 lors de l'upload` | Nous avons corrigé un problème qui pouvait faire fermer l'application lors de l'envoi de fichiers |
## Règles importantes
1. **ZÉRO jargon technique** - L'utilisateur final ne doit pas voir de termes comme "API", "refactoring", "backend", "cache", "endpoint", "requête"
2. **Bénéfice utilisateur** - Chaque item doit répondre à "Qu'est-ce que ça change pour moi ?"
3. **Ignorer l'invisible** - Ne pas mentionner les changements internes (tests, CI, deps, refactoring)
4. **Ton positif** - Utiliser un ton accueillant et positif
5. **Accessibilité** - HTML sémantique, contrastes suffisants, responsive
6. **Concision** - Max 1-2 phrases par item, pas de détails superflus