From b6d299591670e6e72fd27a07299b3995b39b1335 Mon Sep 17 00:00:00 2001 From: Mathis Date: Thu, 4 Jun 2026 08:40:14 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20r=C3=A9cup=C3=A9ration=20des=20issues=5F?= =?UTF-8?q?gitlab=20apr=C3=A8s=20incident?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/bug_report.py | 434 +++++++++------------ commandes/gitlab_integration.py | 160 +++++--- commandes/gitlab_issues.json | 42 ++ commandes/invites.py | 1 + commandes/modules_security/logs_manager.py | 15 +- scripts/migrate_gitlab_tracking.py | 77 ++++ scripts/recover_gitlab_comments.py | 247 ++++++++++++ serveur_flask.py | 14 + 8 files changed, 682 insertions(+), 308 deletions(-) create mode 100644 commandes/gitlab_issues.json create mode 100644 scripts/migrate_gitlab_tracking.py create mode 100644 scripts/recover_gitlab_comments.py diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 956bac4..b9a6eb1 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -8,53 +8,75 @@ import os import asyncio kuby_logger = logging.getLogger("KubyBot") -# Utilisation de chemins absolus pour la persistance après reboot BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") + +# ⚡ NOUVEAU: Utilise le même fichier que gitlab_integration.py +TRACKING_FILE = os.path.join(BASE_DIR, "commandes", "gitlab_issues.json") +LEGACY_REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") -def save_report(issue_iid, user_id): - os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True) - try: - data = {} - if os.path.exists(REPORTS_FILE): - try: - with open(REPORTS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except json.JSONDecodeError: - kuby_logger.warning("REPORTS_FILE corrupted, re-initializing to avoid tracking loss.") +def load_tracking(): + """Charge le tracking depuis gitlab_issues.json (avec migration automatique)""" + tracking = {} + if os.path.exists(TRACKING_FILE): + try: + with open(TRACKING_FILE, "r", encoding="utf-8") as f: + tracking = json.load(f) + except Exception as e: + kuby_logger.error(f"Erreur chargement gitlab_issues.json: {e}") + + # Migration automatique depuis l'ancien fichier + if os.path.exists(LEGACY_REPORTS_FILE): + try: + with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f: + legacy_data = json.load(f) + for issue_iid, user_id in legacy_data.items(): + if issue_iid not in tracking: + tracking[issue_iid] = { + "requester_id": user_id, + "last_note_id": 0, + "type": "legacy_report" + } + kuby_logger.info(f"Migration automatique: {len(legacy_data)} issues fusionnées") + except Exception as e: + kuby_logger.error(f"Erreur migration gitlab_reports.json: {e}") + + return tracking - data[str(issue_iid)] = user_id - temp_file = f"{REPORTS_FILE}.tmp" - with open(temp_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - os.replace(temp_file, REPORTS_FILE) - except Exception as e: - kuby_logger.error(f"Error saving report to JSON: {e}") +def save_tracking(data: Dict[str, Any]) -> None: + """Sauvegarde dans gitlab_issues.json""" + os.makedirs(os.path.dirname(TRACKING_FILE), exist_ok=True) + temp_file = f"{TRACKING_FILE}.tmp" + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(temp_file, TRACKING_FILE) class BugReportModal(disnake.ui.Modal): - def __init__(self): + def __init__(self, cog): + self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking + components = [ + disnake.ui.TextInput( + label="Titre du bug", + placeholder="Décrivez brièvement le problème...", + custom_id="bug_title", + required=True, + max_length=100, + ), + disnake.ui.TextInput( + label="Description détaillée", + style=disnake.TextInputStyle.paragraph, + placeholder="Que s'est-il passé ?", + custom_id="bug_description", + required=True, + max_length=1000, + ), + ] super().__init__( title="Signaler un Bug", - components=[ - disnake.ui.TextInput( - label="Titre du bug", - placeholder="Décrivez brièvement le problème...", - custom_id="bug_title", - required=True, - max_length=100, - ), - disnake.ui.TextInput( - label="Description détaillée", - style=disnake.TextInputStyle.paragraph, - placeholder="Que s'est-il passé ?", - custom_id="bug_description", - required=True, - max_length=1000, - ), - ] + custom_id="bug_modal", + components=components, ) async def callback(self, interaction: disnake.ModalInteraction): @@ -66,7 +88,6 @@ class BugReportModal(disnake.ui.Modal): priority_name = "Normale" priority_value = "Normal" - description_value = interaction.text_values.get("bug_description") or "" description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n" @@ -82,8 +103,14 @@ class BugReportModal(disnake.ui.Modal): ) if result: - issue_iid = result.get("iid") - save_report(issue_iid, interaction.user.id) + issue_iid = str(result.get("iid")) + # ⚡ NOUVEAU: Utilise le tracking unifié + self.cog.issue_data[issue_iid] = { + "requester_id": interaction.user.id, + "last_note_id": 0, + "type": "manual-report" + } + save_tracking(self.cog.issue_data) await interaction.edit_original_response( content=( "✅ Votre bug a été signalé avec succès ! " @@ -97,37 +124,30 @@ class BugReportModal(disnake.ui.Modal): ) -class BugReportView(disnake.ui.View): - def __init__(self): - super().__init__(timeout=None) - - @disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.primary, custom_id="open_bug_form") - async def open_bug_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): - modal = BugReportModal() - await interaction.response.send_modal(modal) - - class FeatureSuggestionModal(disnake.ui.Modal): - def __init__(self): + def __init__(self, cog): + self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking + components = [ + disnake.ui.TextInput( + label="Titre de la suggestion", + placeholder="Que voulez-vous ajouter ?", + custom_id="suggestion_title", + required=True, + max_length=100, + ), + disnake.ui.TextInput( + label="Détails de la fonctionnalité", + style=disnake.TextInputStyle.paragraph, + placeholder="Décrivez comment cela devrait fonctionner...", + custom_id="suggestion_description", + required=True, + max_length=1000, + ), + ] super().__init__( title="Suggérer une fonctionnalité", - components=[ - disnake.ui.TextInput( - label="Titre de la suggestion", - placeholder="Que voulez-vous ajouter ?", - custom_id="suggestion_title", - required=True, - max_length=100, - ), - disnake.ui.TextInput( - label="Détails de la fonctionnalité", - style=disnake.TextInputStyle.paragraph, - placeholder="Décrivez comment cela devrait fonctionner...", - custom_id="suggestion_description", - required=True, - max_length=1000, - ) - ] + custom_id="suggestion_modal", + components=components, ) async def callback(self, interaction: disnake.ModalInteraction): @@ -148,8 +168,14 @@ class FeatureSuggestionModal(disnake.ui.Modal): ) if result: - issue_iid = result.get("iid") - save_report(issue_iid, interaction.user.id) + issue_iid = str(result.get("iid")) + # ⚡ NOUVEAU: Utilise le tracking unifié + self.cog.issue_data[issue_iid] = { + "requester_id": interaction.user.id, + "last_note_id": 0, + "type": "manual-suggestion" + } + save_tracking(self.cog.issue_data) await interaction.edit_original_response( content=( "✅ Votre suggestion a été envoyée avec succès ! " @@ -163,23 +189,36 @@ class FeatureSuggestionModal(disnake.ui.Modal): ) +class BugReportView(disnake.ui.View): + def __init__(self, cog): + self.cog = cog # ⚡ NOUVEAU: Reçoit le cog + super().__init__(timeout=None) + + @disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.primary, custom_id="open_bug_form") + async def open_bug_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): + modal = BugReportModal(self.cog) + await interaction.response.send_modal(modal) + + class FeatureSuggestionView(disnake.ui.View): - def __init__(self): + def __init__(self, cog): + self.cog = cog # ⚡ NOUVEAU: Reçoit le cog super().__init__(timeout=None) @disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.green, custom_id="open_suggestion_form") async def open_suggestion_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): - modal = FeatureSuggestionModal() + modal = FeatureSuggestionModal(self.cog) await interaction.response.send_modal(modal) class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot + self.issue_data = load_tracking() # ⚡ NOUVEAU: Charge le tracking unifié async def cog_load(self): kuby_logger.info("BugReport cog chargé. Démarrage du serveur de relais interne (port 5001)...") - REPORTS_DIR = os.path.dirname(REPORTS_FILE) + REPORTS_DIR = os.path.dirname(LEGACY_REPORTS_FILE) os.makedirs(REPORTS_DIR, exist_ok=True) await self._start_webhook_server() @@ -205,13 +244,10 @@ class BugReport(commands.Cog): kuby_logger.info("Internal Bot Webhook Server stopped and cleaned up.") except Exception as e: kuby_logger.error(f"Error during webhook server cleanup: {e}") - self.bot.loop.create_task(cleanup()) async def handle_bot_event(self, request): - provided_token = request.headers.get("X-Gitlab-Token") - # On ignore la vérification du token pour faciliter le relais local - + """Gère les webhooks GitLab (notifications instantanées)""" try: if not self.bot.is_ready(): kuby_logger.info("Webhook received but bot is not ready yet. Waiting up to 10s...") @@ -221,206 +257,106 @@ class BugReport(commands.Cog): kuby_logger.warning("Bot still not ready after 10s. Proceeding anyway (best effort).") payload = await request.json() - event_type = payload.get("object_kind") + if event_type not in ["issue", "note"]: return web.json_response({"status": "ignored"}, status=200) - if event_type == "issue": - issue_iid = payload.get("object_attributes", {}).get("iid") - else: - issue_iid = payload.get("issue", {}).get("iid") - + # Récupère l'IID de l'issue + issue_iid = str( + payload.get("object_attributes", {}).get("iid") or + payload.get("issue", {}).get("iid") + ) + if not issue_iid: return web.json_response({"status": "no issue id"}, status=200) - - if not os.path.exists(REPORTS_FILE): - return web.json_response({"status": "no reports file"}, status=200) - - try: - with open(REPORTS_FILE, "r", encoding="utf-8") as f: - reports = json.load(f) - except json.JSONDecodeError: - kuby_logger.error("REPORTS_FILE corrupted during read!") - return web.json_response({"status": "corrupted file"}, status=500) - - user_id = reports.get(str(issue_iid)) - if not user_id: + + # Vérifie si l'issue est trackée + issue_data = self.issue_data.get(issue_iid) + if not issue_data: return web.json_response({"status": "untracked issue"}, status=200) - + + user_id = issue_data.get("requester_id") + if not user_id: + return web.json_response({"status": "no requester"}, status=200) + user = self.bot.get_user(user_id) if not user: try: user = await self.bot.fetch_user(user_id) except: - pass - - if not user: - return web.json_response({"status": "user not found"}, status=200) - - # DEBUG: log brute payload minimal pour comprendre pourquoi aucun DM n'est envoyé - kuby_logger.info( - f"[GitLab webhook] event_type={event_type} issue_iid={issue_iid} " - f"tracked_user_id={user_id} payload_keys={list(payload.keys())[:20]}" - ) - - if event_type == "note": - note_obj_attrs = payload.get("object_attributes") or {} - kuby_logger.info( - f"[GitLab note] system={note_obj_attrs.get('system', False)} " - f"note_len={len(str(note_obj_attrs.get('note') or note_obj_attrs.get('body') or payload.get('note') or ''))}" - ) + return web.json_response({"status": "user not found"}, status=200) app_info = await self.bot.application_info() dev = app_info.owner issue_title = payload.get("object_attributes", {}).get("title", f"#{issue_iid}") + if event_type == "note": - issue_title = payload.get("issue", {}).get("title", f"#{issue_iid}") - - if event_type == "issue": + # ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané) + note_attr = payload.get("object_attributes", {}) + is_system = note_attr.get("system", False) + + if not is_system: + note_body = ( + note_attr.get("note") or + note_attr.get("body") or + payload.get("note", "") or + "" + ) + author_name = payload.get("user", {}).get("name", "Développeur") + + if note_body: + try: + embed = disnake.Embed( + title="💬 Nouveau commentaire sur votre signalement", + description=f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n>>> {note_body}\n\n✍️ **Par :** {author_name}", + color=disnake.Color.orange() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) + await user.send(embed=embed) + kuby_logger.info(f"Commentaire GitLab envoyé à {user} pour l'issue #{issue_iid}") + except (disnake.Forbidden, disnake.HTTPException) as e: + kuby_logger.warning(f"Impossible d'envoyer le DM à {user}: {e}") + if dev and dev.id != user.id: + try: + await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer le commentaire à {user} pour l'issue #{issue_iid}") + except: + pass + + elif event_type == "issue": + # Notifie les changements d'état/label action = payload.get("object_attributes", {}).get("action") changes = payload.get("changes", {}) - + state = payload.get("object_attributes", {}).get("state") + if "labels" in changes: curr_labels = [l.get("title") for l in changes["labels"].get("current", []) if l.get("title")] - display_labels = [ - l - for l in curr_labels - if not l.startswith("Priority::") and l not in ["bug", "manual-report", "manual-suggestion"] - ] - - if display_labels: - current_labels_str = ", ".join(display_labels) - else: - current_labels_str = "Mis à jour" - + display_labels = [l for l in curr_labels if not l.startswith("Priority::") and l not in ["bug", "manual-report", "manual-suggestion"]] + current_labels_str = ", ".join(display_labels) if display_labels else "Mis à jour" + try: embed = disnake.Embed( title="🛠️ Mise à jour de votre signalement !", - description=( - f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.\n\n" - f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}" - ), + description=f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.\n\n📌 **Nouveaux Labels / Statuts :** {current_labels_str}", color=disnake.Color.blue() ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) - - try: - await user.send(embed=embed) - kuby_logger.info( - f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" - ) - if dev and dev.id != user.id: - await dev.send( - f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de statut " - f"**{current_labels_str}** pour l'issue #{issue_iid} ({issue_title})." - ) - except (disnake.Forbidden, disnake.HTTPException) as e: - error_code = getattr(e, 'code', None) - if isinstance(e, disnake.Forbidden) or error_code == 50007: - kuby_logger.warning( - f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés (Code: {error_code})" - ) - if dev and dev.id != user.id: - await dev.send( - f"⚠️ [DM BLOQUÉ] {user} ({user.id}) a ses MPs fermés. Impossible de notifier pour l'issue #{issue_iid}." - ) - else: - kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de statut à {user}: {e}") + await user.send(embed=embed) except Exception as e: - kuby_logger.error(f"Erreur inattendue dans la notification de label : {e}") - - state = payload.get("object_attributes", {}).get("state") + kuby_logger.error(f"Erreur notification label: {e}") + is_closing_now = action == "close" or (state == "closed" and "state_id" in changes) - if is_closing_now: try: embed = disnake.Embed( title="🛠️ Bug / Suggestion Terminé(e) !", - description=( - f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.\n\n" - f"🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !\n" - f"✨ Merci pour votre aide !" - ), + description=f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.\n\n🚀 **Prochaine étape :** La mise à jour arrive prochainement !\n✨ Merci pour votre aide !", color=disnake.Color.green() ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) - - try: - await user.send(embed=embed) - kuby_logger.info( - f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" - ) - if dev and dev.id != user.id: - await dev.send( - f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}." - ) - except (disnake.Forbidden, disnake.HTTPException) as e: - error_code = getattr(e, 'code', None) - if isinstance(e, disnake.Forbidden) or error_code == 50007: - kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.") - if dev and dev.id != user.id: - await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid}." - ) - else: - kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de clôture à {user}: {e}") + await user.send(embed=embed) except Exception as e: - kuby_logger.error(f"Erreur inattendue dans la notification de clôture : {e}") - - elif event_type == "note": - note_attr = payload.get("object_attributes", {}) - is_system = note_attr.get("system", False) - - kuby_logger.info( - f"[GitLab note] dispatch started issue_iid={issue_iid} is_system={is_system} " - f"note_field_present={bool(note_attr.get('note') or note_attr.get('body'))}" - ) - - if not is_system: - note_body = ( - note_attr.get("note") - or note_attr.get("body") - or payload.get("note", "") - or payload.get("object_attributes", {}).get("note", "") - or "" - ) - author_name = payload.get("user", {}).get("name", "Développeur") - - try: - embed = disnake.Embed( - title="💬 Nouveau commentaire sur votre signalement", - description=( - f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n" - f">>> {note_body}\n\n" - f"✍️ **Par :** {author_name}" - ), - color=disnake.Color.orange() - ) - embed.set_thumbnail(url=self.bot.user.display_avatar.url) - - try: - await user.send(embed=embed) - kuby_logger.info( - f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" - ) - if dev and dev.id != user.id: - await dev.send( - f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu votre commentaire pour l'issue #{issue_iid}." - ) - except (disnake.Forbidden, disnake.HTTPException): - if dev and dev.id != user.id: - try: - await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." - ) - except (disnake.Forbidden, disnake.HTTPException): - pass # On ne peut pas envoyer au dev non plus, on abandonne - except Exception as e: - kuby_logger.error( - f"Erreur inattendue dans la notification de commentaire : {e}", - exc_info=True, - ) + kuby_logger.error(f"Erreur notification clôture: {e}") return web.json_response({"status": "success"}, status=200) @@ -428,24 +364,16 @@ class BugReport(commands.Cog): kuby_logger.error(f"Error handling bot event: {e}") return web.json_response({"status": "error", "message": str(e)}, status=500) - @commands.slash_command( - name="signaler_bug", - description="Signaler un bug aux développeurs", - ) + @commands.slash_command(name="signaler_bug", description="Signaler un bug aux développeurs") async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): - """Ouvre un formulaire pour signaler un bug.""" - modal = BugReportModal() + modal = BugReportModal(self) await interaction.response.send_modal(modal) - @commands.slash_command( - name="suggerer_fonctionnalite", - description="Proposer une nouvelle fonctionnalité pour le bot", - ) + @commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité") async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): - """Ouvre un formulaire pour suggérer une fonctionnalité.""" - modal = FeatureSuggestionModal() + modal = FeatureSuggestionModal(self) await interaction.response.send_modal(modal) def setup(bot): - bot.add_cog(BugReport(bot)) \ No newline at end of file + bot.add_cog(BugReport(bot)) diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py index d9569b9..70739a1 100644 --- a/commandes/gitlab_integration.py +++ b/commandes/gitlab_integration.py @@ -2,7 +2,7 @@ import os import json import asyncio from datetime import datetime, timezone -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional import aiohttp import disnake @@ -15,14 +15,39 @@ GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded if not GITLAB_TOKEN or not GITLAB_PROJECT_ID: raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID") -# File to persist issue tracking information +# Files to persist issue tracking information ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") +LEGACY_REPORTS_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "gitlab_reports.json") def load_issue_tracking() -> Dict[str, Any]: + """Charge le tracking et fusionne automatiquement avec gitlab_reports.json (migration)""" + tracking = {} + + # Charge le fichier principal if os.path.exists(ISSUE_TRACK_FILE): - with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: - return json.load(f) - return {} + try: + with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: + tracking = json.load(f) + except Exception as e: + print(f"⚠️ Erreur chargement gitlab_issues.json: {e}") + + # Migration automatique depuis l'ancien système + if os.path.exists(LEGACY_REPORTS_FILE): + try: + with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f: + legacy_data = json.load(f) + for issue_iid, user_id in legacy_data.items(): + if issue_iid not in tracking: + tracking[issue_iid] = { + "requester_id": user_id, + "last_note_id": 0, + "type": "legacy_report" + } + print(f"🔄 Migration automatique: {len(legacy_data)} issues fusionnées depuis gitlab_reports.json") + except Exception as e: + print(f"⚠️ Erreur migration gitlab_reports.json: {e}") + + return tracking def save_issue_tracking(data: Dict[str, Any]) -> None: with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: @@ -39,8 +64,8 @@ class GitLabIntegration(commands.Cog): def cog_unload(self): self.check_new_notes.cancel() - async def _create_gitlab_issue(self, title: str, description: str, labels: List[str]) -> Dict[str, Any]: - """Create an issue on GitLab and return the JSON response.""" + async def _create_gitlab_issue(self, title: str, description: str, labels: List[str], requester_id: int = None) -> Optional[Dict[str, Any]]: + """Crée une issue sur GitLab et la track automatiquement.""" url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" headers = { "PRIVATE-TOKEN": GITLAB_TOKEN, @@ -51,10 +76,24 @@ class GitLabIntegration(commands.Cog): "description": description, "labels": ",".join(labels), } - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=payload) as resp: - resp.raise_for_status() - return await resp.json() + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as resp: + resp.raise_for_status() + result = await resp.json() + # Track automatiquement la nouvelle issue + if requester_id: + issue_iid = str(result["iid"]) + self.issue_data[issue_iid] = { + "requester_id": requester_id, + "last_note_id": 0, + "type": "discord-command" + } + save_issue_tracking(self.issue_data) + return result + except Exception as e: + print(f"❌ Erreur création issue GitLab: {e}") + return None async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: """Fetch all notes (comments) for a given issue.""" @@ -85,41 +124,61 @@ class GitLabIntegration(commands.Cog): @tasks.loop(minutes=1) async def check_new_notes(self): - """Periodic task that checks for new notes on tracked issues and notifies requesters.""" + """Vérifie TOUS les nouveaux commentaires (pas seulement le dernier) et notifie.""" + for issue_iid_str, info in list(self.issue_data.items()): + issue_iid = int(issue_iid_str) + last_known_id = info.get("last_note_id", 0) + requester_id = info.get("requester_id") + + try: + notes = await self._get_issue_notes(issue_iid) + if not notes: + continue + + # ⚡ NOUVEAU: Notifie TOUS les commentaires manquants, pas seulement le dernier + missing_notes = [n for n in notes if n["id"] > last_known_id] + if missing_notes: + for note in missing_notes: + await self._notify_requester(requester_id, issue_iid, note) + # Met à jour avec le dernier ID vu + new_last_id = max(n["id"] for n in notes) + self.issue_data[issue_iid_str]["last_note_id"] = new_last_id + except Exception as e: + print(f"⚠️ Erreur vérification issue #{issue_iid}: {e}") + continue + save_issue_tracking(self.issue_data) + + @commands.slash_command(name="recuperer_suivi_issues", description="Récupère le suivi des commentaires après un incident") + async def recuperer_suivi(self, inter: disnake.ApplicationCommandInteraction): + """Reconstruit gitlab_issues.json avec les derniers commentaires.""" + await inter.response.defer(ephemeral=True) + updated_count = 0 + for issue_iid_str, info in list(self.issue_data.items()): issue_iid = int(issue_iid_str) try: notes = await self._get_issue_notes(issue_iid) - except Exception as exc: - # Skip this issue if we cannot fetch notes. + if notes: + latest_id = max(n["id"] for n in notes) + if latest_id > info.get("last_note_id", 0): + self.issue_data[issue_iid_str]["last_note_id"] = latest_id + updated_count += 1 + except Exception as e: + print(f"⚠️ Erreur récupération issue #{issue_iid}: {e}") continue - # Find the newest note ID - if not notes: - continue - latest_note = max(notes, key=lambda n: n["id"]) - last_known_id = info.get("last_note_id", 0) - if latest_note["id"] > last_known_id: - # New comment detected - await self._notify_requester(info["requester_id"], issue_iid, latest_note) - # Update stored last_note_id - self.issue_data[issue_iid_str]["last_note_id"] = latest_note["id"] + save_issue_tracking(self.issue_data) + await inter.edit_original_message( + content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues. " + f"Fichier `{ISSUE_TRACK_FILE}` mis à jour." + ) - # ---------- Slash command: /suggerer_fonctionnalite ---------- + # ---------- Slash commands ---------- @commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet") async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=FeatureModal(self)) - # ---------- Slash command: /signaler_bug ---------- - @commands.slash_command(name="notify_deploy", description="Envoyer un DM de confirmation du déploiement") - async def notify_deploy(self, inter: disnake.ApplicationCommandInteraction): - """DM the user that the deployment is done.""" - try: - await inter.author.send("✅ C'est fait ! Le code a été déployé sur la branche `main`.") - await inter.response.send_message("✅ DM de confirmation envoyé.", ephemeral=True) - except disnake.HTTPException: - await inter.response.send_message("⚠️ Impossible d'envoyer le DM (DMs désactivés).", ephemeral=True) - + @commands.slash_command(name="signaler_bug", description="Signaler un bug") async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=BugModal(self)) @@ -158,16 +217,14 @@ class FeatureModal(disnake.Modal): title=title, description=description, labels=["feature", "discord-bot"], + requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto ) - # Store tracking info - issue_iid = str(issue["iid"]) - self.cog.issue_data[issue_iid] = { - "requester_id": inter.author.id, - "last_note_id": 0, - "type": "feature", - } - save_issue_tracking(self.cog.issue_data) - await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + if issue: + await inter.edit_original_message( + content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue['iid']})" + ) + else: + await inter.edit_original_message(content="❌ Erreur lors de la création de la fonctionnalité.") except Exception as e: await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}") @@ -205,15 +262,14 @@ class BugModal(disnake.Modal): title=title, description=description, labels=["bug", "discord-bot"], + requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto ) - issue_iid = str(issue["iid"]) - self.cog.issue_data[issue_iid] = { - "requester_id": inter.author.id, - "last_note_id": 0, - "type": "bug", - } - save_issue_tracking(self.cog.issue_data) - await inter.edit_original_message(content=f"✅ Bug créé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + if issue: + await inter.edit_original_message( + content=f"✅ Bug signalé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue['iid']})" + ) + else: + await inter.edit_original_message(content="❌ Erreur lors du signalement du bug.") except Exception as e: await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}") diff --git a/commandes/gitlab_issues.json b/commandes/gitlab_issues.json new file mode 100644 index 0000000..8174dd2 --- /dev/null +++ b/commandes/gitlab_issues.json @@ -0,0 +1,42 @@ +{ + "4": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "5": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "15": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "153": { + "requester_id": 534003230435442690, + "last_note_id": 0, + "type": "legacy_report" + }, + "160": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "161": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "162": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + }, + "164": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "legacy_report" + } +} \ No newline at end of file diff --git a/commandes/invites.py b/commandes/invites.py index 1d45448..3a2ecd6 100644 --- a/commandes/invites.py +++ b/commandes/invites.py @@ -74,6 +74,7 @@ class Invites(commands.Cog): async def on_member_join(self, member): if member.bot: return + self._init_db() guild = member.guild try: old_invites = self.invite_cache.get(guild.id, {}) diff --git a/commandes/modules_security/logs_manager.py b/commandes/modules_security/logs_manager.py index fda1eef..acd5635 100644 --- a/commandes/modules_security/logs_manager.py +++ b/commandes/modules_security/logs_manager.py @@ -18,6 +18,15 @@ def get_log_channel(guild, settings): return guild.get_channel(int(channel_id)) return None +def load_settings(): + if not os.path.exists(SETTINGS_FILE): + return {} + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + class LogsManager(commands.Cog): def __init__(self, bot): self.bot = bot @@ -25,7 +34,7 @@ class LogsManager(commands.Cog): @commands.Cog.listener() async def on_message_delete(self, message): if message.author.bot: return - settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(message.guild.id), {}) + settings = dict(load_settings()).get(str(message.guild.id), {}) log_chan = get_log_channel(message.guild, settings) if log_chan: @@ -40,7 +49,7 @@ class LogsManager(commands.Cog): @commands.Cog.listener() async def on_member_remove(self, member): - settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(member.guild.id), {}) + settings = dict(load_settings()).get(str(member.guild.id), {}) log_chan = get_log_channel(member.guild, settings) if log_chan: embed = disnake.Embed( @@ -53,7 +62,7 @@ class LogsManager(commands.Cog): @commands.Cog.listener() async def on_member_ban(self, guild, user): - settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(guild.id), {}) + settings = dict(load_settings()).get(str(guild.id), {}) log_chan = get_log_channel(guild, settings) if log_chan: embed = disnake.Embed( diff --git a/scripts/migrate_gitlab_tracking.py b/scripts/migrate_gitlab_tracking.py new file mode 100644 index 0000000..0fdac66 --- /dev/null +++ b/scripts/migrate_gitlab_tracking.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Script de migration automatique pour fusionner gitlab_reports.json dans gitlab_issues.json. + +Ce script est conçu pour être appelé AUTOMATIQUEMENT lors du déploiement. +Il garantit que toutes les issues sont suivies dans un seul fichier. + +Usage: + python scripts/migrate_gitlab_tracking.py +""" + +import os +import json +import sys + +# Chemins des fichiers +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TRACKING_FILE = os.path.join(BASE_DIR, "commandes", "gitlab_issues.json") +LEGACY_REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") + + +def migrate_tracking(): + """Fusionne gitlab_reports.json dans gitlab_issues.json""" + print("🔄 Début de la migration GitLab tracking...") + + # Charge le fichier principal + tracking = {} + if os.path.exists(TRACKING_FILE): + try: + with open(TRACKING_FILE, "r", encoding="utf-8") as f: + tracking = json.load(f) + print(f"✅ Chargé {len(tracking)} issues depuis gitlab_issues.json") + except Exception as e: + print(f"⚠️ Erreur chargement gitlab_issues.json: {e}") + + # Fusionne avec l'ancien fichier + migrated_count = 0 + if os.path.exists(LEGACY_REPORTS_FILE): + try: + with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f: + legacy_data = json.load(f) + + for issue_iid, user_id in legacy_data.items(): + if issue_iid not in tracking: + tracking[issue_iid] = { + "requester_id": user_id, + "last_note_id": 0, + "type": "legacy_report" + } + migrated_count += 1 + + print(f"✅ Migration: {migrated_count} issues fusionnées depuis gitlab_reports.json") + except Exception as e: + print(f"⚠️ Erreur migration gitlab_reports.json: {e}") + + # Sauvegarde le résultat + if tracking: + os.makedirs(os.path.dirname(TRACKING_FILE), exist_ok=True) + temp_file = f"{TRACKING_FILE}.tmp" + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(tracking, f, ensure_ascii=False, indent=2) + os.replace(temp_file, TRACKING_FILE) + print(f"✅ Sauvegardé {len(tracking)} issues dans gitlab_issues.json") + + print("✅ Migration terminée avec succès !") + return migrated_count + + +if __name__ == "__main__": + migrated = migrate_tracking() + + # Si des données ont été migrées, on peut optionnellement nettoyer l'ancien fichier + if migrated > 0: + print(f"\n💡 {migrated} issues ont été migrées depuis gitlab_reports.json") + print("💡 Tu peux maintenant supprimer data/gitlab_reports.json si tu veux.") + + sys.exit(0) diff --git a/scripts/recover_gitlab_comments.py b/scripts/recover_gitlab_comments.py new file mode 100644 index 0000000..3812f74 --- /dev/null +++ b/scripts/recover_gitlab_comments.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +Script de récupération des commentaires GitLab pour le bot Kuby. + +Ce script permet de : +1. Récupérer toutes les issues suivies depuis gitlab_issues.json et data/gitlab_reports.json +2. Pour chaque issue, récupérer TOUS les commentaires via l'API GitLab +3. Reconstruire gitlab_issues.json avec les bons last_note_id +4. Afficher un rapport des commentaires manquants + +Usage: + python scripts/recover_gitlab_comments.py + +Requires: + - GITLAB_TOKEN (env var) + - GITLAB_PROJECT_ID (env var) + - aiohttp +""" + +import os +import json +import asyncio +import sys +from datetime import datetime +from typing import Dict, List, Any, Optional + +# Chemin des fichiers de tracking +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GITLAB_ISSUES_FILE = os.path.join(BASE_DIR, "commandes", "gitlab_issues.json") +GITLAB_REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") + +# Configuration GitLab +GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") +GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") + +if not GITLAB_TOKEN or not GITLAB_PROJECT_ID: + print("❌ ERREUR : Les variables d'environnement GITLAB_TOKEN et GITLAB_PROJECT_ID doivent être définies.") + sys.exit(1) + +GITLAB_API_URL = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}" + + +async def fetch_all_notes(issue_iid: int) -> List[Dict[str, Any]]: + """Récupère tous les commentaires d'une issue via l'API GitLab.""" + url = f"{GITLAB_API_URL}/issues/{issue_iid}/notes?per_page=100" + headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + + all_notes = [] + page = 1 + + while True: + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(f"{url}&page={page}", headers=headers) as resp: + if resp.status != 200: + print(f" ⚠️ Erreur API pour l'issue #{issue_iid} (page {page}): {resp.status}") + break + + notes = await resp.json() + if not notes: + break + + all_notes.extend(notes) + + # Vérifie s'il y a plus de pages + if len(notes) < 100: + break + + page += 1 + except Exception as e: + print(f" ❌ Erreur lors de la récupération des notes pour l'issue #{issue_iid}: {e}") + break + + return all_notes + + +def load_existing_tracking() -> Dict[str, Any]: + """Charge les données de suivi existantes.""" + tracking_data = {} + + # Charge gitlab_issues.json + if os.path.exists(GITLAB_ISSUES_FILE): + try: + with open(GITLAB_ISSUES_FILE, "r", encoding="utf-8") as f: + tracking_data = json.load(f) + print(f"✅ Chargé {len(tracking_data)} issues depuis gitlab_issues.json") + except Exception as e: + print(f"⚠️ Erreur lors du chargement de gitlab_issues.json: {e}") + + # Charge gitlab_reports.json et fusionne + if os.path.exists(GITLAB_REPORTS_FILE): + try: + with open(GITLAB_REPORTS_FILE, "r", encoding="utf-8") as f: + reports_data = json.load(f) + + for issue_iid, user_id in reports_data.items(): + if issue_iid not in tracking_data: + tracking_data[issue_iid] = { + "requester_id": user_id, + "last_note_id": 0, + "type": "manual-report" + } + print(f" + Ajout de l'issue #{issue_iid} depuis gitlab_reports.json") + + print(f"✅ Fusionné {len(reports_data)} issues depuis gitlab_reports.json") + except Exception as e: + print(f"⚠️ Erreur lors du chargement de gitlab_reports.json: {e}") + + return tracking_data + + +def save_tracking_data(data: Dict[str, Any]) -> None: + """Sauvegarde les données de suivi dans gitlab_issues.json.""" + os.makedirs(os.path.dirname(GITLAB_ISSUES_FILE), exist_ok=True) + + # Sauvegarde dans un fichier temporaire puis renomme + temp_file = f"{GITLAB_ISSUES_FILE}.tmp" + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(temp_file, GITLAB_ISSUES_FILE) + print(f"✅ Sauvegardé {len(data)} issues dans gitlab_issues.json") + + +async def recover_comments(): + """Fonction principale de récupération.""" + print("=" * 60) + print("🔍 RÉCUPÉRATION DES COMMENTAIRES GITLAB") + print("=" * 60) + + # Charge les données existantes + print("\n📂 Chargement des données de suivi existantes...") + tracking_data = load_existing_tracking() + + if not tracking_data: + print("❌ Aucune issue à suivre trouvée. Vérifiez que les fichiers existent.") + return + + print(f"\n🔄 Traitement de {len(tracking_data)} issues...") + print("-" * 60) + + issues_with_new_comments = [] + total_notes = 0 + total_missing = 0 + + for issue_iid_str, info in tracking_data.items(): + issue_iid = int(issue_iid_str) + last_known_id = info.get("last_note_id", 0) + requester_id = info.get("requester_id", "Inconnu") + + print(f"\n📝 Issue #{issue_iid} (demandeur: {requester_id})") + print(f" Dernier note_id connu: {last_known_id}") + + # Récupère tous les commentaires + notes = await fetch_all_notes(issue_iid) + + if not notes: + print(f" ⚠️ Aucune note trouvée pour cette issue.") + continue + + # Trie les notes par ID + notes.sort(key=lambda n: n["id"]) + latest_note_id = max(n["id"] for n in notes) + total_notes += len(notes) + + print(f" ✅ {len(notes)} commentaires trouvés (dernier ID: {latest_note_id})") + + # Vérifie s'il y a des commentaires manquants + if last_known_id == 0: + # Premier lancement ou fichier réinitialisé + missing_count = len(notes) + total_missing += missing_count + print(f" ⚠️ {missing_count} commentaires NON SUIVIS (fichier réinitialisé)") + issues_with_new_comments.append({ + "issue_iid": issue_iid, + "missing_notes": notes, + "requester_id": requester_id, + "new_last_note_id": latest_note_id + }) + else: + # Vérifie quels commentaires sont nouveaux + missing_notes = [n for n in notes if n["id"] > last_known_id] + if missing_notes: + missing_count = len(missing_notes) + total_missing += missing_count + print(f" ⚠️ {missing_count} NOUVEAUX commentaires détectés") + issues_with_new_comments.append({ + "issue_iid": issue_iid, + "missing_notes": missing_notes, + "requester_id": requester_id, + "new_last_note_id": latest_note_id + }) + else: + print(f" ✅ Aucun nouveau commentaire") + + # Met à jour le last_note_id + tracking_data[issue_iid_str]["last_note_id"] = latest_note_id + + # Sauvegarde les données mises à jour + print("\n" + "=" * 60) + print("💾 SAUVEGARDE DES DONNÉES") + print("=" * 60) + save_tracking_data(tracking_data) + + # Génère le rapport + print("\n" + "=" * 60) + print("📊 RAPPORT DE RÉCUPÉRATION") + print("=" * 60) + print(f"Total issues traitées: {len(tracking_data)}") + print(f"Total commentaires récupérés: {total_notes}") + print(f"Total commentaires manquants: {total_missing}") + + if issues_with_new_comments: + print(f"\n📋 Issues avec des commentaires non suivis ({len(issues_with_new_comments)}):") + for item in issues_with_new_comments: + print(f"\n Issue #{item['issue_iid']}:") + print(f" Demandeur: {item['requester_id']}") + print(f" Nombre de commentaires manquants: {len(item['missing_notes'])}") + print(f" Nouveau last_note_id: {item['new_last_note_id']}") + + # Affiche les commentaires manquants + for note in item["missing_notes"]: + author = note.get("author", {}).get("name", "Inconnu") + created_at = note.get("created_at", "Inconnu") + body = note.get("body", "")[:100] + "..." if len(note.get("body", "")) > 100 else note.get("body", "") + print(f" - [{created_at}] {author}: {body}") + else: + print("\n✅ Aucune issue avec des commentaires manquants !") + + print("\n" + "=" * 60) + print("✅ RÉCUPÉRATION TERMINÉE") + print("=" * 60) + print("\nProchaines étapes:") + print("1. Vérifiez le fichier commandes/gitlab_issues.json") + print("2. Redémarrez le bot pour qu'il utilise les nouvelles données") + print("3. Les nouveaux commentaires seront désormais suivis correctement") + + +if __name__ == "__main__": + print("\n" + "=" * 60) + print("SCRIPT DE RÉCUPÉRATION DES COMMENTAIRES GITLAB") + print("=" * 60) + print(f"Projet GitLab: {GITLAB_PROJECT_ID}") + print(f"Fichier de sortie: {GITLAB_ISSUES_FILE}") + print("=" * 60 + "\n") + + asyncio.run(recover_comments()) diff --git a/serveur_flask.py b/serveur_flask.py index da4f577..e2f92ca 100644 --- a/serveur_flask.py +++ b/serveur_flask.py @@ -63,6 +63,20 @@ def run_mep_logic(author, commit_msg, branch="main"): if use_rsync: subprocess.run(["rsync", "-a", "--exclude=.git", "--exclude=venv", "--exclude=.venv", "--exclude=__pycache__", f"{target_path}/", f"{PROJECT_PATH}/"], check=True) + # ⚡ NOUVEAU: Migration automatique GitLab tracking + send_discord_log("MIGRATION", "Migration GitLab tracking en cours...", 16776960, author, commit_msg) + try: + subprocess.run( + ["python3", os.path.join(target_path, "scripts", "migrate_gitlab_tracking.py")], + cwd=target_path, + check=True, + capture_output=True, + text=True + ) + send_discord_log("MIGRATION", "✅ Migration GitLab tracking terminée.", 3066993, author, commit_msg) + except subprocess.CalledProcessError as e: + send_discord_log("ERREUR MIGRATION", f"❌ Migration GitLab échouée: {e.stderr}", 15158332, author, commit_msg) + duration = (datetime.now() - start_time).total_seconds() msg = f"✅ **Déploiement réussi en {duration:.2f}s**\nLe bot est à jour et redémarré." send_discord_log("VALIDÉ", msg, 3066993, author, commit_msg)