import os import json import asyncio from datetime import datetime, timezone from typing import Dict, Any, List import aiohttp import disnake from disnake.ext import commands, tasks # Environment variables for GitLab access GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path 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 ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") def load_issue_tracking() -> Dict[str, Any]: if os.path.exists(ISSUE_TRACK_FILE): with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def save_issue_tracking(data: Dict[str, Any]) -> None: with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) class GitLabIntegration(commands.Cog): """Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added.""" def __init__(self, bot: commands.Bot): self.bot = bot self.issue_data: Dict[str, Any] = load_issue_tracking() # key: issue_iid (str) -> {"requester_id": int, "last_note_id": int} self.check_new_notes.start() 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.""" url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" headers = { "PRIVATE-TOKEN": GITLAB_TOKEN, "Content-Type": "application/json", } payload = { "title": title, "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() async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: """Fetch all notes (comments) for a given issue.""" url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes" headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as resp: resp.raise_for_status() return await resp.json() async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]): """Send a private DM to the requester informing them of a new comment.""" user = await self.bot.fetch_user(requester_id) author = note.get("author", {}).get("name", "Someone") body = note.get("body", "") embed = disnake.Embed( title=f"Nouveau commentaire sur votre demande GitLab (#{issue_iid})", description=body, color=disnake.Color.blurple(), timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc), ) embed.set_footer(text=f"Commentaire de {author}") try: await user.send(embed=embed) except disnake.HTTPException: # The user may have DMs disabled; ignore silently. pass @tasks.loop(minutes=1) async def check_new_notes(self): """Periodic task that checks for new notes on tracked issues and notifies requesters.""" 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. 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) # ---------- Slash command: /suggerer_fonctionnalite ---------- @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="signaler_bug", description="Signaler un bug du projet") async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=BugModal(self)) # ---------- Modals ---------- class FeatureModal(disnake.Modal): def __init__(self, cog: GitLabIntegration): components = [ disnake.ui.TextInput( label="Titre de la fonctionnalité", custom_id="title", style=disnake.TextInputStyle.short, max_length=100, required=True, ), disnake.ui.TextInput( label="Description détaillée", custom_id="description", style=disnake.TextInputStyle.paragraph, max_length=2000, required=True, ), ] super().__init__( title="Suggestion de fonctionnalité", custom_id="feature_modal", components=components, ) self.cog = cog async def callback(self, inter: disnake.ModalInteraction): title = inter.text_values["title"] description = inter.text_values["description"] await inter.response.defer(ephemeral=True) try: issue = await self.cog._create_gitlab_issue( title=title, description=description, labels=["feature", "discord-bot"], ) # 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})") except Exception as e: await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}") class BugModal(disnake.Modal): def __init__(self, cog: GitLabIntegration): components = [ disnake.ui.TextInput( label="Titre du bug", custom_id="title", style=disnake.TextInputStyle.short, max_length=100, required=True, ), disnake.ui.TextInput( label="Description et étapes pour reproduire", custom_id="description", style=disnake.TextInputStyle.paragraph, max_length=2000, required=True, ), ] super().__init__( title="Signalement de bug", custom_id="bug_modal", components=components, ) self.cog = cog async def callback(self, inter: disnake.ModalInteraction): title = inter.text_values["title"] description = inter.text_values["description"] await inter.response.defer(ephemeral=True) try: issue = await self.cog._create_gitlab_issue( title=title, description=description, labels=["bug", "discord-bot"], ) 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})") except Exception as e: await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}") def setup(bot: commands.Bot): bot.add_cog(GitLabIntegration(bot))