diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py new file mode 100644 index 0000000..74ac655 --- /dev/null +++ b/commandes/gitlab_integration.py @@ -0,0 +1,213 @@ +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))