Initial commit
This commit is contained in:
31
skills/git-pr/scripts/analyze_changes.sh
Executable file
31
skills/git-pr/scripts/analyze_changes.sh
Executable 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
|
||||
146
skills/git-pr/scripts/assign_milestone.py
Executable file
146
skills/git-pr/scripts/assign_milestone.py
Executable 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()
|
||||
182
skills/git-pr/scripts/assign_project.py
Executable file
182
skills/git-pr/scripts/assign_project.py
Executable 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()
|
||||
41
skills/git-pr/scripts/auto_review.sh
Executable file
41
skills/git-pr/scripts/auto_review.sh
Executable 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
|
||||
36
skills/git-pr/scripts/check_scopes.sh
Executable file
36
skills/git-pr/scripts/check_scopes.sh
Executable 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
|
||||
28
skills/git-pr/scripts/cleanup_branch.sh
Executable file
28
skills/git-pr/scripts/cleanup_branch.sh
Executable 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
|
||||
84
skills/git-pr/scripts/confirm_base_branch.py
Executable file
84
skills/git-pr/scripts/confirm_base_branch.py
Executable 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()
|
||||
61
skills/git-pr/scripts/create_pr.sh
Executable file
61
skills/git-pr/scripts/create_pr.sh
Executable 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"
|
||||
70
skills/git-pr/scripts/final_report.sh
Executable file
70
skills/git-pr/scripts/final_report.sh
Executable 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
|
||||
38
skills/git-pr/scripts/gh_auth_setup.sh
Executable file
38
skills/git-pr/scripts/gh_auth_setup.sh
Executable 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
|
||||
156
skills/git-pr/scripts/milestone_cache.py
Normal file
156
skills/git-pr/scripts/milestone_cache.py
Normal 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}"
|
||||
111
skills/git-pr/scripts/project_cache.py
Normal file
111
skills/git-pr/scripts/project_cache.py
Normal 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
|
||||
73
skills/git-pr/scripts/safe_push_pr.sh
Executable file
73
skills/git-pr/scripts/safe_push_pr.sh
Executable 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"
|
||||
31
skills/git-pr/scripts/smart_qa.sh
Executable file
31
skills/git-pr/scripts/smart_qa.sh
Executable 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
|
||||
16
skills/git-pr/scripts/verify_pr_template.sh
Executable file
16
skills/git-pr/scripts/verify_pr_template.sh
Executable 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
|
||||
Reference in New Issue
Block a user