diff --git a/.envtempl b/.envtempl new file mode 100644 index 0000000..c7efa67 --- /dev/null +++ b/.envtempl @@ -0,0 +1,4 @@ +# This is a sample / template enviroment file +# It is relativley straight forward and you dont need too many things to get started +DISCORD_BOT_TOKEN= # Your API key for Discord, you get this in your bot console +DISCORD_CHANNEL_ID= # your channel where you want the bot to deposit its contents \ No newline at end of file diff --git a/ComitsPaginatedView.py b/ComitsPaginatedView.py new file mode 100644 index 0000000..edca02c --- /dev/null +++ b/ComitsPaginatedView.py @@ -0,0 +1,45 @@ +import PaginatedView +from CommitsSelectMenu import CommitSelectMenu +from typing import * +import datetime +from discord.ui import Select, SelectOption +import discord + +BRANCH = "main" + +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 + diff --git a/CommitsActionView.py b/CommitsActionView.py new file mode 100644 index 0000000..e3b6116 --- /dev/null +++ b/CommitsActionView.py @@ -0,0 +1,57 @@ +from typing import Dict, List +import discord +from discord.ui import View, Button +from FileBrowserSelect import FileBrowserSelect + +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] + + # Link button that opens the commit in Gitea + link_button = Button( + label="šŸ”— Open in Gitea", + style=discord.ButtonStyle.link, + url=commit['html_url'], + emoji="šŸ”—" + ) + self.add_item(link_button) + + # Add a file browser select if files exist + if files: + self.add_item(FileBrowserSelect(files, self.commit_sha)) + + @discord.ui.button(label="šŸ“Š View Diff", style=discord.ButtonStyle.primary) + async def view_diff(self, interaction: discord.Interaction, button: Button): + # Import here to avoid circular import with main.py + from main import show_commit_diff_interactive + await show_commit_diff_interactive(interaction, self.commit_sha) + + @discord.ui.button(label="šŸ“ Browse Files", style=discord.ButtonStyle.secondary) + async def browse_files(self, interaction: discord.Interaction, button: Button): + 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) + diff --git a/CommitsSelectMenu.py b/CommitsSelectMenu.py new file mode 100644 index 0000000..f8e81e2 --- /dev/null +++ b/CommitsSelectMenu.py @@ -0,0 +1,36 @@ +import discord +from typing import * +from discord.ui import * + +from main import create_commit_embed, fetch_commit_files +from CommitsActionView import CommitActionsView + +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) \ No newline at end of file diff --git a/FileBrowserSelect.py b/FileBrowserSelect.py new file mode 100644 index 0000000..ddf235b --- /dev/null +++ b/FileBrowserSelect.py @@ -0,0 +1,32 @@ +from discord.ui import * +import discord +from typing import * +from main import show_file_diff + +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) + diff --git a/PaginatedView.py b/PaginatedView.py new file mode 100644 index 0000000..e249c4c --- /dev/null +++ b/PaginatedView.py @@ -0,0 +1,55 @@ +import discord +from discord.ui import View, Button +from typing import List + +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) + diff --git a/SearchModal.py b/SearchModal.py new file mode 100644 index 0000000..267d8ae --- /dev/null +++ b/SearchModal.py @@ -0,0 +1,23 @@ +from discord.ui import * +import discord + +from main import search_commits + +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) + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 9db8351..2d65561 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,11 @@ from datetime import datetime from typing import List, Dict, Optional from dotenv import load_dotenv +from CommitsActionView import CommitActionsView +import SearchModal +import FileBrowserSelect +from ComitsPaginatedView import CommitsPaginatedView + # Load environment variables from .env file load_dotenv() @@ -35,249 +40,6 @@ bot = commands.Bot( # 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): @@ -297,7 +59,6 @@ async def fetch_commits(limit=10, sha=None): print(f"Exception fetching commits: {e}") return None - async def fetch_commit_files(sha): """Fetch file changes for a specific commit""" try: @@ -331,7 +92,6 @@ async def fetch_commit_diff(sha): print(f"Exception fetching diff: {e}") return None - def format_diff_stats(files): """Format file changes into a readable string""" if not files: