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,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