commit 045e8febaa6920c45688b13ca678cec23931cd68 Author: rattatwinko Date: Fri Dec 19 08:00:32 2025 +0100 initial garbage commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddfa7c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,224 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +## !! IMPORTANT !! + +.env +.environment +.envs + +.qodo \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9db8351 --- /dev/null +++ b/main.py @@ -0,0 +1,1090 @@ +import discord +from discord.ext import commands, tasks +from discord.ui import Button, View, Select, Modal, TextInput +import aiohttp +import os +from datetime import datetime +from typing import List, Dict, Optional +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# Configuration +DISCORD_TOKEN = os.getenv('DISCORD_BOT_TOKEN', '').strip() +CHANNEL_ID = int(os.getenv('DISCORD_CHANNEL_ID', '0')) +GITEA_BASE_URL = 'http://rattatwinko.servecounterstrike.com/gitea' +GITEA_API_BASE = f'{GITEA_BASE_URL}/api/v1' +REPO_OWNER = 'rattatwinko' +REPO_NAME = 'INF6B' +BRANCH = 'main' +CHECK_INTERVAL = 60 # seconds + +# Bot setup +intents = discord.Intents.default() +intents.message_content = True +intents.guilds = True +intents.members = True # For better interactions + +bot = commands.Bot( + command_prefix='!', + intents=intents, + help_command=None # We'll create custom help +) + +# Store last checked commit +last_commit_sha = None + +# ---------- UI COMPONENTS ---------- + +class PaginatedView(View): + """Base class for paginated views""" + def __init__(self, data: List, page_size: int = 5, timeout: int = 60): + super().__init__(timeout=timeout) + self.data = data + self.page_size = page_size + self.current_page = 0 + self.total_pages = (len(data) + page_size - 1) // page_size + self.update_buttons() + + def update_buttons(self): + """Enable/disable navigation buttons based on current page""" + self.children[0].disabled = self.current_page == 0 # First + self.children[1].disabled = self.current_page == 0 # Previous + self.children[2].disabled = self.current_page >= self.total_pages - 1 # Next + self.children[3].disabled = self.current_page >= self.total_pages - 1 # Last + + def get_page_data(self): + """Get data for current page""" + start = self.current_page * self.page_size + end = start + self.page_size + return self.data[start:end] + + async def update_message(self, interaction: discord.Interaction): + """Update the message with current page""" + pass + + @discord.ui.button(emoji="⏪", style=discord.ButtonStyle.secondary) + async def first_page(self, interaction: discord.Interaction, button: Button): + self.current_page = 0 + self.update_buttons() + await self.update_message(interaction) + + @discord.ui.button(emoji="◀️", style=discord.ButtonStyle.secondary) + async def previous_page(self, interaction: discord.Interaction, button: Button): + self.current_page = max(0, self.current_page - 1) + self.update_buttons() + await self.update_message(interaction) + + @discord.ui.button(emoji="▶️", style=discord.ButtonStyle.secondary) + async def next_page(self, interaction: discord.Interaction, button: Button): + self.current_page = min(self.total_pages - 1, self.current_page + 1) + self.update_buttons() + await self.update_message(interaction) + + @discord.ui.button(emoji="⏩", style=discord.ButtonStyle.secondary) + async def last_page(self, interaction: discord.Interaction, button: Button): + self.current_page = self.total_pages - 1 + self.update_buttons() + await self.update_message(interaction) + + +class CommitsPaginatedView(PaginatedView): + """Paginated view for commits""" + def __init__(self, commits: List[Dict], repo_info: str): + super().__init__(commits, page_size=5) + self.repo_info = repo_info + self.commits = commits + # Add file selection button + self.add_item(CommitSelectMenu(self.commits)) + + async def update_message(self, interaction: discord.Interaction): + embed = self.create_embed() + await interaction.response.edit_message(embed=embed, view=self) + + def create_embed(self): + embed = discord.Embed( + title=f"📚 Commits ({self.current_page + 1}/{self.total_pages})", + description=f"**Repository:** {self.repo_info}\n**Branch:** {BRANCH}", + color=discord.Color.blue() + ) + + page_data = self.get_page_data() + for i, commit in enumerate(page_data, start=self.current_page * self.page_size): + msg = commit['commit']['message'].split('\n')[0][:80] + author = commit['commit']['author']['name'] + sha = commit['sha'][:7] + date = datetime.fromisoformat(commit['commit']['committer']['date'].replace('Z', '+00:00')) + + embed.add_field( + name=f"{i+1}. `{sha}` by {author}", + value=f"```{msg}```\n🕒 {discord.utils.format_dt(date, 'R')}", + inline=False + ) + + embed.set_footer(text="Select a commit from the dropdown below to view details") + return embed + + +class CommitSelectMenu(Select): + """Dropdown menu for selecting a commit""" + def __init__(self, commits: List[Dict]): + options = [ + discord.SelectOption( + label=f"{commit['sha'][:7]} - {commit['commit']['message'].split('\n')[0][:45]}", + value=str(i), + description=f"by {commit['commit']['author']['name']}" + ) + for i, commit in enumerate(commits[:25]) # Discord limit: 25 options + ] + super().__init__( + placeholder="📝 Select a commit to view details...", + min_values=1, + max_values=1, + options=options + ) + self.commits = commits + + async def callback(self, interaction: discord.Interaction): + selected_idx = int(self.values[0]) + commit = self.commits[selected_idx] + + # Fetch file info + files = await fetch_commit_files(commit['sha']) + + embed = create_commit_embed(commit, files) + view = CommitActionsView(commit, files) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + +class CommitActionsView(View): + """Action buttons for a specific commit""" + def __init__(self, commit: Dict, files: List[Dict], timeout: int = 60): + super().__init__(timeout=timeout) + self.commit = commit + self.files = files + self.commit_sha = commit['sha'] + self.commit_short = commit['sha'][:7] + + # 1. Regular buttons with callbacks + view_diff_button = Button( + label="📊 View Diff", + style=discord.ButtonStyle.primary, + custom_id=f"view_diff_{self.commit_short}" + ) + view_diff_button.callback = self.view_diff_callback + self.add_item(view_diff_button) + + browse_files_button = Button( + label="📁 Browse Files", + style=discord.ButtonStyle.secondary, + custom_id=f"browse_files_{self.commit_short}" + ) + browse_files_button.callback = self.browse_files_callback + self.add_item(browse_files_button) + + # 2. Link button - MUST have a URL, not a callback + if 'html_url' in commit and commit['html_url']: + link_button = Button( + label="🔗 Open in Gitea", + style=discord.ButtonStyle.link, + url=commit['html_url'] # Required for link buttons + ) + self.add_item(link_button) + else: + # If no URL, disable the button + disabled_button = Button( + label="🔗 No Link Available", + style=discord.ButtonStyle.secondary, + disabled=True + ) + self.add_item(disabled_button) + + # 3. Add file browser dropdown if files exist + if files: + self.add_item(FileBrowserSelect(files, self.commit_sha)) + + async def view_diff_callback(self, interaction: discord.Interaction): + """Callback for viewing diff""" + await interaction.response.defer(ephemeral=True) + await show_commit_diff_interactive(interaction, self.commit_sha) + + async def browse_files_callback(self, interaction: discord.Interaction): + """Callback for browsing files""" + if not self.files: + await interaction.response.send_message("No files changed in this commit.", ephemeral=True) + return + + embed = discord.Embed( + title=f"📁 Files in commit `{self.commit_short}`", + description="Select a file to view its diff:", + color=discord.Color.blue() + ) + + file_list = "\n".join([f"• `{f['filename']}` (+{f.get('additions', 0)}/-{f.get('deletions', 0)})" + for f in self.files[:10]]) + if len(self.files) > 10: + file_list += f"\n... and {len(self.files) - 10} more" + + embed.add_field(name="Changed Files", value=file_list, inline=False) + + view = FileBrowserSelect(self.files, self.commit_sha) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + +class FileBrowserSelect(Select): + """Dropdown for browsing files in a commit""" + def __init__(self, files: List[Dict], commit_sha: str): + options = [ + discord.SelectOption( + label=f"{f['filename'][:45]}", + value=str(i), + description=f"+{f.get('additions', 0)}/-{f.get('deletions', 0)} - {f.get('status', 'modified')}" + ) + for i, f in enumerate(files[:25]) + ] + super().__init__( + placeholder="📄 Select a file to view diff...", + min_values=1, + max_values=1, + options=options + ) + self.files = files + self.commit_sha = commit_sha + + async def callback(self, interaction: discord.Interaction): + selected_idx = int(self.values[0]) + file_info = self.files[selected_idx] + filename = file_info['filename'] + + await show_file_diff(interaction, self.commit_sha, filename) + + +class SearchModal(Modal): + """Modal for searching commits""" + def __init__(self, search_type: str = "message"): + super().__init__(title=f"🔍 Search Commits by {search_type.capitalize()}") + self.search_type = search_type + + self.search_term = TextInput( + label=f"Enter search term:", + placeholder=f"Search in commit {search_type}...", + min_length=2, + max_length=100 + ) + self.add_item(self.search_term) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer() + await search_commits(interaction, self.search_type, self.search_term.value) + + +# ---------- API FUNCTIONS ---------- + +async def fetch_commits(limit=10, sha=None): + """Fetch recent commits from Gitea API""" + try: + url = f'{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits' + params = {'sha': sha or BRANCH, 'limit': limit} + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as resp: + if resp.status == 200: + return await resp.json() + else: + print(f"Error fetching commits: {resp.status}") + return None + except Exception as e: + print(f"Exception fetching commits: {e}") + return None + + +async def fetch_commit_files(sha): + """Fetch file changes for a specific commit""" + try: + url = f'{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/git/commits/{sha}' + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('files', []) + else: + return None + except Exception as e: + print(f"Exception fetching commit files: {e}") + return None + + +async def fetch_commit_diff(sha): + """Fetch the actual diff text for a commit""" + try: + url = f'{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/git/commits/{sha}.diff' + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + try: + return await resp.text(encoding='utf-8') + except UnicodeDecodeError: + return await resp.text(encoding='latin-1') + else: + return None + except Exception as e: + print(f"Exception fetching diff: {e}") + return None + + +def format_diff_stats(files): + """Format file changes into a readable string""" + if not files: + return "No file changes available", 0, 0 + + stats = [] + total_additions = 0 + total_deletions = 0 + + for file in files[:10]: + filename = file.get('filename', 'Unknown') + additions = file.get('additions', 0) + deletions = file.get('deletions', 0) + status = file.get('status', 'modified') + + total_additions += additions + total_deletions += deletions + + # Create a visual indicator + if status == 'added': + icon = '🆕' + elif status == 'removed': + icon = '🗑️' + elif status == 'renamed': + icon = '📝' + else: + icon = '📄' + + stats.append(f"{icon} `{filename}` (+{additions}/-{deletions})") + + if len(files) > 10: + stats.append(f"... and {len(files) - 10} more files") + + return "\n".join(stats), total_additions, total_deletions + + +def create_commit_embed(commit, files=None): + """Create a Discord embed for a commit""" + commit_msg = commit['commit']['message'] + msg_lines = commit_msg.split('\n', 1) + title = msg_lines[0][:256] + description = msg_lines[1][:500] if len(msg_lines) > 1 else "" + + embed = discord.Embed( + title=f"📝 Commit: {title}", + description=description if description else None, + color=discord.Color.green(), + timestamp=datetime.fromisoformat(commit['commit']['committer']['date'].replace('Z', '+00:00')), + url=commit['html_url'] + ) + + embed.set_author( + name=commit['commit']['author']['name'], + icon_url=commit['author']['avatar_url'] if commit.get('author') else None + ) + + embed.add_field(name="👤 Author", value=commit['commit']['author']['name'], inline=True) + embed.add_field(name="🔑 Commit Hash", value=f"`{commit['sha'][:7]}`", inline=True) + embed.add_field(name="🌿 Branch", value=BRANCH, inline=True) + + if files: + diff_text, additions, deletions = format_diff_stats(files) + embed.add_field( + name=f"📊 File Changes (+{additions}/-{deletions})", + value=diff_text, + inline=False + ) + + embed.set_footer(text=f"{REPO_OWNER}/{REPO_NAME} • Use buttons below to explore") + return embed + + +# ---------- INTERACTIVE FUNCTIONS ---------- + +async def show_commit_diff_interactive(interaction: discord.Interaction, commit_hash: str): + """Show diff with interactive controls""" + try: + # Find the commit + commits = await fetch_commits(50) + matching_commit = None + for commit in commits: + if commit['sha'].startswith(commit_hash): + matching_commit = commit + break + + if not matching_commit: + await interaction.followup.send("Commit not found.", ephemeral=True) + return + + diff_text = await fetch_commit_diff(matching_commit['sha']) + if not diff_text: + await interaction.followup.send("Could not fetch diff.", ephemeral=True) + return + + # Split diff into manageable chunks + lines = diff_text.split('\n') + chunks = [] + current_chunk = [] + current_length = 0 + + for line in lines: + line_length = len(line) + 1 + if current_length + line_length > 1800 or len(current_chunk) >= 40: + if current_chunk: + chunks.append('\n'.join(current_chunk)) + current_chunk = [line] + current_length = line_length + else: + current_chunk.append(line) + current_length += line_length + + if current_chunk: + chunks.append('\n'.join(current_chunk)) + + # Create paginated view for diff + if len(chunks) > 1: + embed = discord.Embed( + title=f"📊 Diff for commit `{matching_commit['sha'][:7]}`", + description=f"**Part 1/{len(chunks)}**", + color=discord.Color.blue() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + for i, chunk in enumerate(chunks, 1): + await interaction.followup.send(f"```diff\n{chunk}\n```", ephemeral=True) + else: + embed = discord.Embed( + title=f"📊 Diff for commit `{matching_commit['sha'][:7]}`", + color=discord.Color.blue() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + await interaction.followup.send(f"```diff\n{diff_text[:1800]}\n```", ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"Error: {str(e)}", ephemeral=True) + + +async def show_file_diff(interaction: discord.Interaction, commit_sha: str, filename: str): + """Show diff for a specific file""" + try: + # Fetch full diff + full_diff = await fetch_commit_diff(commit_sha) + if not full_diff: + await interaction.response.send_message("Could not fetch diff.", ephemeral=True) + return + + # Extract file diff + lines = full_diff.split('\n') + file_diff = [] + in_target_file = False + + for line in lines: + if line.startswith('diff --git'): + if f'b/{filename}' in line or f'a/{filename}' in line: + in_target_file = True + file_diff.append(line) + elif in_target_file: + break + elif in_target_file: + file_diff.append(line) + + if not file_diff: + await interaction.response.send_message(f"No diff found for `{filename}`", ephemeral=True) + return + + diff_text = '\n'.join(file_diff) + + # Create embed with file info + embed = discord.Embed( + title=f"📄 {filename}", + description=f"**Commit:** `{commit_sha[:7]}`", + color=discord.Color.blue() + ) + + if len(diff_text) > 1800: + embed.add_field(name="Diff Preview", value=f"```diff\n{diff_text[:500]}...\n```", inline=False) + embed.set_footer(text="Diff too large to display fully") + await interaction.response.send_message(embed=embed, ephemeral=True) + + # Send the rest in follow-up + chunks = [diff_text[i:i+1800] for i in range(0, len(diff_text), 1800)] + for i, chunk in enumerate(chunks[1:], 2): + await interaction.followup.send(f"```diff\n{chunk}\n```", ephemeral=True) + else: + embed.add_field(name="Changes", value=f"```diff\n{diff_text}\n```", inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + + except Exception as e: + await interaction.response.send_message(f"Error: {str(e)}", ephemeral=True) + + +async def search_commits(interaction: discord.Interaction, search_type: str, search_term: str): + """Search commits by various criteria""" + try: + commits = await fetch_commits(100) # Get more commits for searching + if not commits: + await interaction.followup.send("Could not fetch commits.", ephemeral=True) + return + + matching_commits = [] + search_term_lower = search_term.lower() + + for commit in commits: + if search_type == "message": + if search_term_lower in commit['commit']['message'].lower(): + matching_commits.append(commit) + elif search_type == "author": + if search_term_lower in commit['commit']['author']['name'].lower(): + matching_commits.append(commit) + elif search_type == "hash": + if commit['sha'].startswith(search_term_lower): + matching_commits.append(commit) + + if not matching_commits: + await interaction.followup.send(f"No commits found matching '{search_term}'", ephemeral=True) + return + + embed = discord.Embed( + title=f"🔍 Search Results: {len(matching_commits)} commits found", + description=f"Searching by {search_type} for '{search_term}'", + color=discord.Color.purple() + ) + + for i, commit in enumerate(matching_commits[:5]): + msg = commit['commit']['message'].split('\n')[0][:80] + author = commit['commit']['author']['name'] + sha = commit['sha'][:7] + + embed.add_field( + name=f"{i+1}. `{sha}` by {author}", + value=f"```{msg}```\n`!commit view {sha}`", + inline=False + ) + + if len(matching_commits) > 5: + embed.add_field( + name="More Results", + value=f"... and {len(matching_commits) - 5} more commits\nUse `!commit search` for more specific searches", + inline=False + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"Error: {str(e)}", ephemeral=True) + + +# ---------- TASK ---------- + +@tasks.loop(seconds=CHECK_INTERVAL) +async def check_commits(): + """Periodically check for new commits""" + global last_commit_sha + + commits = await fetch_commits() + if not commits or len(commits) == 0: + return + + latest_commit = commits[0] + latest_sha = latest_commit['sha'] + + if last_commit_sha is None: + last_commit_sha = latest_sha + return + + if latest_sha != last_commit_sha: + print(f"New commit detected: {latest_sha[:7]}") + + new_commits = [] + for commit in commits: + if commit['sha'] == last_commit_sha: + break + new_commits.append(commit) + + channel = bot.get_channel(CHANNEL_ID) + if channel: + for commit in reversed(new_commits): + files = await fetch_commit_files(commit['sha']) + embed = create_commit_embed(commit, files) + + # Add action buttons for new commits + view = CommitActionsView(commit, files) + try: + await channel.send(embed=embed, view=view) + except Exception as e: + print(f"Error sending notification: {e}") + + last_commit_sha = latest_sha + + +# ---------- INTUITIVE COMMANDS ---------- + +@bot.command(name="help") +async def help_command(ctx): + """Show interactive help menu""" + embed = discord.Embed( + title="🤖 Gitea Monitor Bot Help", + description=f"Monitoring **{REPO_OWNER}/{REPO_NAME}** on branch **{BRANCH}**\n\n" + "**📋 Quick Commands:**", + color=discord.Color.blue() + ) + + # Main commands + embed.add_field( + name="🔍 `!commits`", + value="Browse recent commits with pagination", + inline=False + ) + + embed.add_field( + name="📝 `!commit view `", + value="View details of a specific commit\nExample: `!commit view abc123`", + inline=False + ) + + embed.add_field( + name="🔎 `!commit search`", + value="Search commits by message, author, or hash", + inline=False + ) + + embed.add_field( + name="📊 `!commit diff `", + value="Show diff for a commit\nExample: `!commit diff abc123`", + inline=False + ) + + embed.add_field( + name="📁 `!files `", + value="Browse files changed in a commit\nExample: `!files abc123`", + inline=False + ) + + embed.add_field( + name="📈 `!status`", + value="Show bot status and latest commit", + inline=False + ) + + embed.add_field( + name="⚙️ `!monitor`", + value="Monitor a different branch or repository\n*(Admin only)*", + inline=False + ) + + # Quick actions view + view = View(timeout=60) + view.add_item(Button( + label="📚 Browse Commits", + style=discord.ButtonStyle.primary, + custom_id="quick_browse" + )) + view.add_item(Button( + label="🔍 Search Commits", + style=discord.ButtonStyle.secondary, + custom_id="quick_search" + )) + view.add_item(Button( + label="📊 View Status", + style=discord.ButtonStyle.success, + custom_id="quick_status" + )) + + # Button callbacks + async def browse_callback(interaction: discord.Interaction): + await ctx.invoke(bot.get_command('commits')) + + async def search_callback(interaction: discord.Interaction): + modal = SearchModal("message") + await interaction.response.send_modal(modal) + + async def status_callback(interaction: discord.Interaction): + await ctx.invoke(bot.get_command('status')) + + # Assign callbacks + for child in view.children: + if child.custom_id == "quick_browse": + child.callback = browse_callback + elif child.custom_id == "quick_search": + child.callback = search_callback + elif child.custom_id == "quick_status": + child.callback = status_callback + + await ctx.send(embed=embed, view=view) + + +@bot.group(name="commit", invoke_without_command=True) +async def commit_group(ctx): + """Main commit command group""" + if ctx.invoked_subcommand is None: + embed = discord.Embed( + title="📝 Commit Commands", + description="Available subcommands:", + color=discord.Color.blue() + ) + + embed.add_field( + name="`!commit view `", + value="View a specific commit\nExample: `!commit view abc123`", + inline=False + ) + + embed.add_field( + name="`!commit browse`", + value="Browse recent commits interactively", + inline=False + ) + + embed.add_field( + name="`!commit search`", + value="Search for commits", + inline=False + ) + + embed.add_field( + name="`!commit diff `", + value="Show diff for a commit", + inline=False + ) + + embed.add_field( + name="`!commit latest [count]`", + value="Show latest commits (default: 10)", + inline=False + ) + + await ctx.send(embed=embed) + + +@commit_group.command(name="view") +async def commit_view(ctx, commit_hash: str): + """View details of a specific commit""" + try: + commits = await fetch_commits(50) + matching_commit = None + + for commit in commits: + if commit['sha'].startswith(commit_hash): + matching_commit = commit + break + + if not matching_commit: + await ctx.send(f"❌ Commit `{commit_hash}` not found.") + return + + files = await fetch_commit_files(matching_commit['sha']) + embed = create_commit_embed(matching_commit, files) + view = CommitActionsView(matching_commit, files) + + await ctx.send(embed=embed, view=view) + + except Exception as e: + await ctx.send(f"❌ Error: {str(e)}") + + +@commit_group.command(name="browse") +async def commit_browse(ctx, count: int = 20): + """Browse recent commits interactively""" + try: + if count < 1 or count > 50: + await ctx.send("❌ Please choose a count between 1 and 50.") + return + + await ctx.send(f"🔍 Fetching {count} recent commits...") + + commits = await fetch_commits(count) + if not commits: + await ctx.send("❌ Could not fetch commits.") + return + + view = CommitsPaginatedView(commits, f"{REPO_OWNER}/{REPO_NAME}") + embed = view.create_embed() + + await ctx.send(embed=embed, view=view) + + except Exception as e: + await ctx.send(f"❌ Error: {str(e)}") + + +@commit_group.command(name="search") +async def commit_search(ctx): + """Search commits interactively""" + view = View(timeout=60) + + async def search_by_message(interaction: discord.Interaction): + modal = SearchModal("message") + await interaction.response.send_modal(modal) + + async def search_by_author(interaction: discord.Interaction): + modal = SearchModal("author") + await interaction.response.send_modal(modal) + + async def search_by_hash(interaction: discord.Interaction): + modal = SearchModal("hash") + await interaction.response.send_modal(modal) + + # Create buttons + message_btn = Button(label="📝 Search by Message", style=discord.ButtonStyle.primary, emoji="📝") + author_btn = Button(label="👤 Search by Author", style=discord.ButtonStyle.primary, emoji="👤") + hash_btn = Button(label="🔑 Search by Hash", style=discord.ButtonStyle.primary, emoji="🔑") + + message_btn.callback = search_by_message + author_btn.callback = search_by_author + hash_btn.callback = search_by_hash + + view.add_item(message_btn) + view.add_item(author_btn) + view.add_item(hash_btn) + + embed = discord.Embed( + title="🔍 Search Commits", + description="Choose how you want to search:", + color=discord.Color.purple() + ) + + embed.add_field(name="📝 By Message", value="Search in commit messages", inline=True) + embed.add_field(name="👤 By Author", value="Search by author name", inline=True) + embed.add_field(name="🔑 By Hash", value="Search by commit hash (partial)", inline=True) + + await ctx.send(embed=embed, view=view) + + +@commit_group.command(name="diff") +async def commit_diff(ctx, commit_hash: str): + """Show diff for a commit""" + await show_commit_diff_interactive(ctx.interaction if hasattr(ctx, 'interaction') else None, commit_hash) + + +@commit_group.command(name="latest") +async def commit_latest(ctx, count: int = 10): + """Show latest commits""" + await commit_browse(ctx, count) + + +@bot.command(name="commits") +async def commits_browse(ctx, count: int = 20): + """Browse commits (alias for !commit browse)""" + await commit_browse(ctx, count) + + +@bot.command(name="files") +async def files_browse(ctx, commit_hash: str): + """Browse files changed in a commit""" + try: + commits = await fetch_commits(50) + matching_commit = None + + for commit in commits: + if commit['sha'].startswith(commit_hash): + matching_commit = commit + break + + if not matching_commit: + await ctx.send(f"❌ Commit `{commit_hash}` not found.") + return + + files = await fetch_commit_files(matching_commit['sha']) + if not files: + await ctx.send("❌ No files changed in this commit.") + return + + view = FileBrowserSelect(files, matching_commit['sha']) + embed = discord.Embed( + title=f"📁 Files in commit `{matching_commit['sha'][:7]}`", + description=f"**{len(files)}** files changed\n" + f"Select a file from the dropdown below:", + color=discord.Color.blue() + ) + + file_preview = "\n".join([f"• `{f['filename']}`" for f in files[:5]]) + if len(files) > 5: + file_preview += f"\n... and {len(files) - 5} more" + + embed.add_field(name="Files Changed", value=file_preview, inline=False) + + await ctx.send(embed=embed, view=view) + + except Exception as e: + await ctx.send(f"❌ Error: {str(e)}") + + +@bot.command(name="status") +async def bot_status(ctx): + """Show bot status and repository info""" + global last_commit_sha + + commits = await fetch_commits(1) + latest_commit = commits[0] if commits else None + + embed = discord.Embed( + title="🤖 Bot Status Dashboard", + color=discord.Color.green() + ) + + # Repository info + embed.add_field( + name="📦 Repository", + value=f"[{REPO_OWNER}/{REPO_NAME}]({GITEA_BASE_URL}/{REPO_OWNER}/{REPO_NAME})", + inline=False + ) + + # Branch and monitoring + embed.add_field(name="🌿 Branch", value=BRANCH, inline=True) + embed.add_field(name="⏰ Check Interval", value=f"{CHECK_INTERVAL}s", inline=True) + embed.add_field( + name="✅ Monitoring", + value="🟢 Active" if check_commits.is_running() else "🔴 Inactive", + inline=True + ) + + # Last commit info + if last_commit_sha: + embed.add_field(name="📝 Last Known Commit", value=f"`{last_commit_sha[:7]}`", inline=True) + + if latest_commit: + msg = latest_commit['commit']['message'].split('\n')[0][:100] + embed.add_field( + name="🆕 Latest Commit", + value=f"[`{latest_commit['sha'][:7]}`]({latest_commit['html_url']})\n```{msg}```", + inline=False + ) + + # Quick actions + embed.add_field( + name="🚀 Quick Actions", + value="• `!commits` - Browse recent commits\n" + "• `!commit view ` - View a specific commit\n" + "• `!files ` - Browse files in a commit", + inline=False + ) + + # Create status view with buttons + view = View(timeout=60) + + refresh_btn = Button(label="🔄 Refresh", style=discord.ButtonStyle.secondary, emoji="🔄") + browse_btn = Button(label="📚 Browse Commits", style=discord.ButtonStyle.primary, emoji="📚") + + async def refresh_callback(interaction: discord.Interaction): + await ctx.invoke(bot.get_command('status')) + await interaction.response.defer() + + async def browse_callback(interaction: discord.Interaction): + await ctx.invoke(bot.get_command('commits')) + + refresh_btn.callback = refresh_callback + browse_btn.callback = browse_callback + + view.add_item(refresh_btn) + view.add_item(browse_btn) + + await ctx.send(embed=embed, view=view) + + +@bot.command(name="monitor") +@commands.has_permissions(administrator=True) +async def monitor_config(ctx, branch: str = None): + """Configure monitoring settings (Admin only)""" + global BRANCH + + if branch: + BRANCH = branch + await ctx.send(f"✅ Now monitoring branch: **{BRANCH}**") + else: + embed = discord.Embed( + title="⚙️ Monitor Configuration", + description=f"Currently monitoring: **{BRANCH}**\n\n" + f"To change branch:\n`!monitor `\n\n" + f"Example: `!monitor develop`", + color=discord.Color.orange() + ) + await ctx.send(embed=embed) + + +# ---------- BOT EVENTS ---------- + +@bot.event +async def on_ready(): + """Called when the bot is ready""" + print(f'🤖 Logged in as {bot.user.name} ({bot.user.id})') + print(f'📦 Monitoring: {REPO_OWNER}/{REPO_NAME}@{BRANCH}') + print(f'📺 Channel ID: {CHANNEL_ID}') + print(f'🔗 Gitea URL: {GITEA_BASE_URL}') + + # Check channel access + channel = bot.get_channel(CHANNEL_ID) + if channel: + print(f'✅ Found channel: #{channel.name} in {channel.guild.name}') + else: + print(f'❌ Cannot find channel with ID {CHANNEL_ID}') + + # Start monitoring task + if not check_commits.is_running(): + check_commits.start() + print("✅ Started commit monitoring") + + # Set bot presence + await bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.watching, + name=f"{REPO_NAME} commits" + ) + ) + + +# ---------- ERROR HANDLING ---------- + +@bot.event +async def on_command_error(ctx, error): + """Handle command errors""" + if isinstance(error, commands.CommandNotFound): + embed = discord.Embed( + title="❌ Command Not Found", + description=f"Use `!help` to see available commands.", + color=discord.Color.red() + ) + await ctx.send(embed=embed) + elif isinstance(error, commands.MissingPermissions): + embed = discord.Embed( + title="❌ Permission Denied", + description="You don't have permission to use this command.", + color=discord.Color.red() + ) + await ctx.send(embed=embed) + elif isinstance(error, commands.MissingRequiredArgument): + embed = discord.Embed( + title="❌ Missing Argument", + description=f"Missing required argument: `{error.param.name}`\n" + f"Use `!help {ctx.command.name}` for usage.", + color=discord.Color.red() + ) + await ctx.send(embed=embed) + else: + embed = discord.Embed( + title="❌ Unexpected Error", + description=f"```{str(error)[:500]}```", + color=discord.Color.red() + ) + await ctx.send(embed=embed) + print(f"Command error: {error}") + + +# ---------- RUN BOT ---------- + +if __name__ == '__main__': + if not DISCORD_TOKEN: + print("❌ Error: DISCORD_BOT_TOKEN not set in .env file") + exit(1) + if CHANNEL_ID == 0: + print("❌ Error: DISCORD_CHANNEL_ID not set in .env file") + exit(1) + + try: + bot.run(DISCORD_TOKEN) + except Exception as e: + print(f"❌ Fatal error: {e}") + exit(1) \ No newline at end of file