From 16263d88bcd56304a4c48377cfb79b3f3f1b6fb9 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 28 Mar 2026 21:58:44 +0100 Subject: [PATCH] fix: resolve merge conflicts in ticket_whitelist, add retry logic for port 5001 in bug_report, and implement live GitLab issue deduplication in logger --- commandes/bug_report.py | 35 ++++- commandes/ticket/__init__.py | 263 ++++++++++++++++++++++++++++++++-- commandes/ticket_whitelist.py | 75 ++++++---- data/json-export-history.json | 115 +++++++++++++++ src/logger.py | 62 ++++++-- utils/gitlab_client.py | 29 ++++ 6 files changed, 525 insertions(+), 54 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 5003dc0..eea1af8 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -116,13 +116,40 @@ class BugReport(commands.Cog): self.web_app.router.add_post('/bot_event', self.handle_bot_event) self.runner = web.AppRunner(self.web_app) await self.runner.setup() - self.site = web.TCPSite(self.runner, '127.0.0.1', 5001) - await self.site.start() - kuby_logger.info("Internal Bot Webhook Server started on 127.0.0.1:5001") + + max_retries = 5 + retry_delay = 2 + + for attempt in range(1, max_retries + 1): + try: + self.site = web.TCPSite(self.runner, '127.0.0.1', 5001) + await self.site.start() + kuby_logger.info(f"Internal Bot Webhook Server started on 127.0.0.1:5001 (Attempt {attempt})") + return + except OSError as e: + if e.errno == 98: # Address already in use + if attempt < max_retries: + kuby_logger.warning(f"Port 5001 already in use, retrying in {retry_delay}s... ({attempt}/{max_retries})") + await asyncio.sleep(retry_delay) + else: + kuby_logger.error(f"Failed to bind to port 5001 after {max_retries} attempts.") + raise e + else: + raise e def cog_unload(self): if hasattr(self, 'runner'): - self.bot.loop.create_task(self.runner.cleanup()) + # On utilise create_task car cog_unload est synchrone + async def cleanup(): + try: + if hasattr(self, 'site'): + await self.site.stop() + await self.runner.cleanup() + 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): try: diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index f30db60..b92e829 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -1707,14 +1707,14 @@ class ConfigPanelView(discord.ui.View): self.category_btn.callback = self._category_callback self.add_item(self.category_btn) - # Ajouter catégorie - self.add_cat_btn = discord.ui.Button( + # Gérer les catégories + self.manage_cat_btn = discord.ui.Button( style=discord.ButtonStyle.blurple, - label="Ajouter Catégorie", - custom_id="config_add_cat" + label="📂 Gérer les catégories", + custom_id="config_manage_cat" ) - self.add_cat_btn.callback = self._add_cat_callback - self.add_item(self.add_cat_btn) + self.manage_cat_btn.callback = self._manage_cat_callback + self.add_item(self.manage_cat_btn) # Staff permissions staff_btn = discord.ui.Button( @@ -1756,9 +1756,9 @@ class ConfigPanelView(discord.ui.View): item.style = discord.ButtonStyle.green if self.config.enabled else discord.ButtonStyle.red await interaction.message.edit(view=self) - async def _add_cat_callback(self, interaction: discord.Interaction): - modal = AddCategoryModal(self.bot, self.storage, self.config) - await interaction.response.send_modal(modal) + async def _manage_cat_callback(self, interaction: discord.Interaction): + view = CategoryManagerView(self.bot, self.storage, self.config) + await interaction.response.edit_message(embed=view._get_embed(), view=view) async def _staff_perms_callback(self, interaction: discord.Interaction): view = PermissionConfigView(self.bot, self.storage, self.config, interaction.guild, "staff") @@ -1849,6 +1849,251 @@ class AddCategoryModal(discord.ui.Modal): await interaction.response.send_message(f"Catégorie **{self.name.value}** créée!", ephemeral=True) +class EditCategoryModal(discord.ui.Modal): + """Modal pour modifier une catégorie existante""" + + def __init__(self, bot, storage, config, category: TicketCategory, parent_view): + super().__init__(title=f"Modifier: {category.name}") + self.bot = bot + self.storage = storage + self.config = config + self.category = category + self.parent_view = parent_view + + self.name = discord.ui.TextInput( + label="Nom", + default=category.name, + placeholder="Ex: Support", + required=True + ) + self.add_item(self.name) + + self.description = discord.ui.TextInput( + label="Description", + default=category.description, + placeholder="Description...", + required=False, + style=discord.TextStyle.paragraph + ) + self.add_item(self.description) + + self.emoji = discord.ui.TextInput( + label="Emoji", + default=category.emoji, + placeholder="🎫", + required=False, + max_length=5 + ) + self.add_item(self.emoji) + + async def on_submit(self, interaction: discord.Interaction): + # Update category object + self.category.name = self.name.value + self.category.description = self.description.value or "" + self.category.emoji = self.emoji.value or "🎫" + + # Save config + self.storage.save_config(interaction.guild_id, self.config) + + # Refresh the parent view's message + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + await interaction.followup.send(f"✅ Catégorie **{self.category.name}** mise à jour !", ephemeral=True) + + +class CategoryManagerView(discord.ui.View): + """Vue pour lister et gérer les catégories""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self._add_category_select() + + def _add_category_select(self): + self.clear_items() + + if self.config.categories: + options = [] + for cat in self.config.categories[:25]: # Limite Select menu + options.append(discord.SelectOption( + label=cat.name, + value=cat.id, + description=cat.description[:100] if cat.description else "Aucune description", + emoji=cat.emoji + )) + + select = discord.ui.Select( + placeholder="Sélectionnez une catégorie à gérer...", + options=options, + custom_id="manage_cat_select" + ) + select.callback = self._category_select_callback + self.add_item(select) + + # Bouton Ajouter + add_btn = discord.ui.Button( + style=discord.ButtonStyle.success, + label="➕ Ajouter une catégorie", + custom_id="manage_cat_add" + ) + add_btn.callback = self._add_cat_callback + self.add_item(add_btn) + + # Bouton Retour + back_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="⬅️ Retour", + custom_id="manage_cat_back" + ) + back_btn.callback = self._back_callback + self.add_item(back_btn) + + def _get_embed(self): + embed = discord.Embed( + title="📂 Gestion des Catégories", + description=f"Il y a actuellement **{len(self.config.categories)}** catégorie(s) configurée(s).\n\n" + "Sélectionnez une catégorie dans le menu pour la modifier ou la supprimer.", + color=discord.Color.blue() + ) + + if not self.config.categories: + embed.description += "\n\n⚠️ **Aucune catégorie configurée.** Commencez par en ajouter une !" + + for cat in self.config.categories[:10]: + embed.add_field( + name=f"{cat.emoji} {cat.name}", + value=cat.description or "Pas de description", + inline=False + ) + + return embed + + async def _category_select_callback(self, interaction: discord.Interaction): + # En utilisant interaction.data.get('values') pour obtenir la valeur sélectionnée + cat_id = interaction.data.get('values', [None])[0] + category = self.config.get_category(cat_id) + + if not category: + await interaction.response.send_message("Catégorie introuvable.", ephemeral=True) + return + + view = CategoryActionView(self.bot, self.storage, self.config, category, self) + await interaction.response.edit_message(embed=view._get_embed(), view=view) + + async def _add_cat_callback(self, interaction: discord.Interaction): + # Utilisation de la classe AddCategoryModal existante + class AddCategoryManagerModal(AddCategoryModal): + def __init__(self, bot, storage, config, parent_view): + super().__init__(bot, storage, config) + self.parent_view = parent_view + + async def on_submit(self, interaction: discord.Interaction): + import uuid + cat_id = str(uuid.uuid4())[:8] + category = TicketCategory( + id=cat_id, + name=self.name.value, + description=self.description.value or "", + emoji=self.emoji.value or "🎫" + ) + self.config.categories.append(category) + self.storage.save_config(interaction.guild_id, self.config) + + # Refresh parent view + self.parent_view._add_category_select() + # On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed + await interaction.response.edit_message( + embed=self.parent_view._get_embed(), + view=self.parent_view + ) + await interaction.followup.send(f"Catégorie **{self.name.value}** créée!", ephemeral=True) + + modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self) + await interaction.response.send_modal(modal) + + async def _back_callback(self, interaction: discord.Interaction): + view = ConfigPanelView(self.bot, self.storage, self.config) + embed = discord.Embed( + title="Configuration des Tickets", + description="Gérez la configuration du système de tickets", + color=discord.Color.blue() + ) + await interaction.response.edit_message(embed=embed, view=view) + + +class CategoryActionView(discord.ui.View): + """Actions pour une catégorie spécifique""" + + def __init__(self, bot, storage, config, category, parent_view): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.category = category + self.parent_view = parent_view + + def _get_embed(self): + embed = discord.Embed( + title=f"Gestion: {self.category.name}", + description=f"**ID:** `{self.category.id}`\n" + f"**Emoji:** {self.category.emoji}\n" + f"**Description:** {self.category.description or 'Aucune'}\n" + f"**Couleur:** `{self.category.color}`", + color=discord.Color.blue() + ) + return embed + + @discord.ui.button(label="📝 Modifier", style=discord.ButtonStyle.primary) + async def edit_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="🗑️ Supprimer", style=discord.ButtonStyle.danger) + async def delete_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + view = DeleteCategoryConfirmationView(self.bot, self.storage, self.config, self.category, self.parent_view) + embed = discord.Embed( + title="⚠️ Confirmer la suppression", + description=f"Êtes-vous sûr de vouloir supprimer la catégorie **{self.category.name}** ?\n" + "Cette action est irréversible.", + color=discord.Color.red() + ) + await interaction.response.edit_message(embed=embed, view=view) + + @discord.ui.button(label="⬅️ Retour", style=discord.ButtonStyle.secondary) + async def back_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + self.parent_view._add_category_select() + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + + +class DeleteCategoryConfirmationView(discord.ui.View): + def __init__(self, bot, storage, config, category, parent_view): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.category = category + self.parent_view = parent_view + + @discord.ui.button(label="✅ Confirmer", style=discord.ButtonStyle.danger) + async def confirm_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.category in self.config.categories: + self.config.categories.remove(self.category) + self.storage.save_config(interaction.guild_id, self.config) + + self.parent_view._add_category_select() + await interaction.response.edit_message( + embed=self.parent_view._get_embed(), + view=self.parent_view + ) + await interaction.followup.send(f"✅ Catégorie **{self.category.name}** supprimée.", ephemeral=True) + + @discord.ui.button(label="❌ Annuler", style=discord.ButtonStyle.secondary) + async def cancel_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + view = CategoryActionView(self.bot, self.storage, self.config, self.category, self.parent_view) + await interaction.response.edit_message(embed=view._get_embed(), view=view) + + class SetChannelModal(discord.ui.Modal): """Modal pour définir le canal de logs""" diff --git a/commandes/ticket_whitelist.py b/commandes/ticket_whitelist.py index 62b4647..29b01a0 100644 --- a/commandes/ticket_whitelist.py +++ b/commandes/ticket_whitelist.py @@ -3,7 +3,9 @@ from discord.ext import commands from discord import app_commands, ui import json import os +import asyncio from typing import Optional +from src.logger import kuby_logger TICKET_WHITELIST_SETTINGS_FILE = "data/ticket_whitelist_settings.json" @@ -43,12 +45,6 @@ class TicketWhitelistConfigView(ui.View): def check(m): return m.author == interaction.user and m.channel == interaction.channel - try: - # We use an explicit import here to avoid clashing if __import__ syntax is weird, but using standard asyncio module is fine if we import it at top. Let's add import asyncio at the top instead! - pass - except __import__("asyncio").TimeoutError: - pass - try: msg = await interaction.client.wait_for('message', check=check, timeout=30) target = None @@ -322,6 +318,23 @@ class TicketManagementView(discord.ui.View): def __init__(self): super().__init__(timeout=None) + self.claimed_by = None # Stocke le staff qui claim le ticket + + async def get_ticket_owner(self, interaction: discord.Interaction): + """Récupère le propriétaire du ticket via le topic du salon""" + if interaction.channel.topic and interaction.channel.topic.startswith("Ticket de "): + try: + owner_id = int(interaction.channel.topic.split("Ticket de ")[1]) + member = interaction.guild.get_member(owner_id) + if not member: + try: + member = await interaction.guild.fetch_member(owner_id) + except discord.NotFound: + return None + return member + except (ValueError, IndexError): + return None + return None @discord.ui.button(label="🔒 Fermer le Ticket", style=discord.ButtonStyle.red, custom_id="close_ticket") async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): @@ -332,19 +345,11 @@ class TicketManagementView(discord.ui.View): if not discord.utils.get(interaction.user.roles, id=int(staff_role_id) if staff_role_id else 0) and not interaction.user.guild_permissions.administrator: return await interaction.response.send_message("❌ Seuls les staffs peuvent fermer un ticket.", ephemeral=True) - topic = interaction.channel.topic or "" - if not topic.startswith("Ticket de "): + owner = await self.get_ticket_owner(interaction) + if not owner: return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à fermer.", ephemeral=True) - - try: - owner_id = int(topic.split("Ticket de ")[1]) - ticket_owner = interaction.guild.get_member(owner_id) - if not ticket_owner: - ticket_owner = await interaction.guild.fetch_member(owner_id) - except (IndexError, ValueError, discord.NotFound): - return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True) - await interaction.channel.set_permissions(ticket_owner, read_messages=False, send_messages=False) + await interaction.channel.set_permissions(owner, read_messages=False, send_messages=False) await interaction.channel.edit(name="ticket-fermé") await interaction.response.send_message("🔒 Le ticket a été fermé. Vous ne pouvez plus le voir.", ephemeral=True) @@ -376,6 +381,22 @@ class ClosedTicketView(discord.ui.View): def __init__(self): super().__init__(timeout=None) + async def get_ticket_owner(self, interaction: discord.Interaction): + """Récupère le propriétaire du ticket via le topic du salon""" + if interaction.channel.topic and interaction.channel.topic.startswith("Ticket de "): + try: + owner_id = int(interaction.channel.topic.split("Ticket de ")[1]) + member = interaction.guild.get_member(owner_id) + if not member: + try: + member = await interaction.guild.fetch_member(owner_id) + except discord.NotFound: + return None + return member + except (ValueError, IndexError): + return None + return None + @discord.ui.button(label="♻ Réouvrir le Ticket", style=discord.ButtonStyle.green, custom_id="reopen_ticket") async def reopen_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): """Réouvre le ticket pour le membre""" @@ -385,19 +406,11 @@ class ClosedTicketView(discord.ui.View): if not discord.utils.get(interaction.user.roles, id=int(staff_role_id) if staff_role_id else 0) and not interaction.user.guild_permissions.administrator: return await interaction.response.send_message("❌ Seuls les staffs peuvent réouvrir un ticket.", ephemeral=True) - topic = interaction.channel.topic or "" - if not topic.startswith("Ticket de "): + owner = await self.get_ticket_owner(interaction) + if not owner: return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True) - try: - owner_id = int(topic.split("Ticket de ")[1]) - ticket_owner = interaction.guild.get_member(owner_id) - if not ticket_owner: - ticket_owner = await interaction.guild.fetch_member(owner_id) - except (IndexError, ValueError, discord.NotFound): - return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True) - - await interaction.channel.set_permissions(ticket_owner, read_messages=True, send_messages=True) + await interaction.channel.set_permissions(owner, read_messages=True, send_messages=True) await interaction.response.send_message("✅ Le ticket a été réouvert.", ephemeral=True) # Mettre à jour la vue pour afficher tous les boutons @@ -433,6 +446,12 @@ class Ticket(commands.Cog): def __init__(self, bot): self.bot = bot + async def cog_load(self): + """Enregistre les vues persistantes""" + self.bot.add_view(TicketView()) + self.bot.add_view(TicketManagementView()) + self.bot.add_view(ClosedTicketView()) + @app_commands.command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.") @app_commands.checks.has_permissions(administrator=True) async def ticket_whitelist(self, interaction: discord.Interaction): diff --git a/data/json-export-history.json b/data/json-export-history.json index 0e73f1e..6764632 100644 --- a/data/json-export-history.json +++ b/data/json-export-history.json @@ -1,6 +1,118 @@ [ { +<<<<<<< Updated upstream "date": "2026-03-15T15:50:47.091919+00:00", +======= + "date": "2026-03-28T20:17:38.126055+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:17:08.105930+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:16:37.851812+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:16:08.471789+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:15:38.321159+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:15:08.114852+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:14:38.114389+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:14:07.816703+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:13:37.862896+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:13:07.844778+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:12:37.876620+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-28T20:12:08.173233+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 12, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:26:14.746054+00:00", +>>>>>>> Stashed changes "trigger": "auto", "serversCount": 1, "membersCount": 13, @@ -70,6 +182,7 @@ "guildName": "Omega Kube Bêta Test", "errors": [], "success": true +<<<<<<< Updated upstream }, { "date": "2026-03-15T15:46:47.376868+00:00", @@ -178,5 +291,7 @@ "guildName": "Omega Kube Bêta Test", "errors": [], "success": true +======= +>>>>>>> Stashed changes } ] \ No newline at end of file diff --git a/src/logger.py b/src/logger.py index a5a5057..d04aaaa 100755 --- a/src/logger.py +++ b/src/logger.py @@ -3,10 +3,14 @@ import sys import time import functools import asyncio +import json +import os from datetime import datetime from typing import Any, Callable import traceback +REPORTED_ERRORS_FILE = "data/automated_reports.json" + class KubyLogger: def __init__(self, name="KubyBot"): self.logger = logging.getLogger(name) @@ -53,37 +57,69 @@ class KubyLogger: def __init__(self, client): super().__init__() self.client = client - self.last_errors = {} self.cooldown = 3600 # 1 hour cooldown per unique error + self.last_errors = self._load_reported_errors() + + def _load_reported_errors(self): + if os.path.exists(REPORTED_ERRORS_FILE): + try: + with open(REPORTED_ERRORS_FILE, "r") as f: + return json.load(f) + except Exception as e: + print(f"Error loading reported errors: {e}") + return {} + + def _save_reported_errors(self): + try: + os.makedirs(os.path.dirname(REPORTED_ERRORS_FILE), exist_ok=True) + with open(REPORTED_ERRORS_FILE, "w") as f: + json.dump(self.last_errors, f, indent=4) + except Exception as e: + print(f"Error saving reported errors: {e}") def emit(self, record): if record.levelno >= logging.ERROR: - # Extract message and first line of traceback if available msg = record.getMessage() - error_key = msg - if record.exc_info: - error_key += str(record.exc_info[1]) + # Use message + location + simplified traceback for unique key + # This key is used for the local cooldown + error_key = f"{record.name}.{record.funcName}:{record.lineno} - {msg}" current_time = time.time() - if error_key in self.last_errors and current_time - self.last_errors[error_key] < self.cooldown: - return # Skip if same error within cooldown + last_time = self.last_errors.get(error_key, 0) + + if current_time - last_time < self.cooldown: + return self.last_errors[error_key] = current_time + self._save_reported_errors() - # Create issue (Fire and forget, we don't want to block logging) + # Prepare issue data description = f"**Type:** {record.levelname}\n" description += f"**Message:** {msg}\n" description += f"**Source:** {record.name}.{record.funcName}:{record.lineno}\n\n" if record.exc_info: description += f"**Traceback:**\n```python\n{traceback.format_exc()}\n```" - title = f"[AUTO] Error: {msg[:50]}..." + title = f"[AUTO] Error: {msg[:100]}..." + labels = ["bug", "auto-report"] + + # Async reporting flow + async def report_flow(client, t, d, l): + try: + # Check if an open issue with the SAME title already exists on GitLab + existing = await client.find_issue_by_title(t, state="opened") + if existing: + # If it exists, we don't create a new one to avoid clutter + return + + # Otherwise, create it + await client.create_issue(t, d, l) + except Exception as e: + print(f"Error in automated reporting flow: {e}") + loop = asyncio.get_event_loop() if loop.is_running(): - loop.create_task(self.client.create_issue(title, description, labels=["bug", "auto-report"])) - else: - # Fallback if loop is not running (rare for this bot) - pass + loop.create_task(report_flow(self.client, title, description, labels)) gitlab_handler = GitLabHandler(gitlab_client) gitlab_handler.setLevel(logging.ERROR) diff --git a/utils/gitlab_client.py b/utils/gitlab_client.py index b2bb7d4..40d7993 100644 --- a/utils/gitlab_client.py +++ b/utils/gitlab_client.py @@ -63,6 +63,35 @@ class GitLabClient: gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}") return None + async def find_issue_by_title(self, title: str, state: str = "opened") -> Optional[dict]: + """ + Searches for an issue by its exact title and state. + """ + if not self.token or not self.project_id: + return None + + headers = {"PRIVATE-TOKEN": self.token} + params = { + "search": title, + "state": state + } + + async with aiohttp.ClientSession() as session: + try: + # search param is fuzzy on GitLab, so we must double-check for exact match + async with session.get(self.api_url, headers=headers, params=params) as response: + if response.status == 200: + issues = await response.json() + for issue in issues: + if issue.get("title") == title: + return issue + return None + else: + return None + except Exception as e: + gitlab_internal_logger.error(f"❌ Error searching GitLab issues: {e}") + return None + async def get_closed_issues(self) -> List[dict]: """ Fetches recently closed issues.