diff --git a/.gitignore b/.gitignore index d59e7cd..ed7acc1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ src/pip-delete-this-directory.txt **/.vs **/.vscode **/.DS_Store + +pbrVenv +src/lib +.env \ No newline at end of file diff --git a/PyBugReporter/_version.py b/PyBugReporter/_version.py index c1a7902..2180e43 100644 --- a/PyBugReporter/_version.py +++ b/PyBugReporter/_version.py @@ -1 +1 @@ -__version__ = '1.0.9' \ No newline at end of file +__version__ = '1.0.9b1' \ No newline at end of file diff --git a/PyBugReporter/requirements.txt b/PyBugReporter/requirements.txt index 38db440..a9ca88a 100644 --- a/PyBugReporter/requirements.txt +++ b/PyBugReporter/requirements.txt @@ -1 +1,2 @@ -python-graphql-client~=0.4.3 \ No newline at end of file +python-graphql-client~=0.4.3 +discord.py~=2.0.1 \ No newline at end of file diff --git a/PyBugReporter/src/BugReporter.py b/PyBugReporter/src/BugReporter.py index cdcd51e..e1ac717 100644 --- a/PyBugReporter/src/BugReporter.py +++ b/PyBugReporter/src/BugReporter.py @@ -2,6 +2,7 @@ import sys import traceback from functools import wraps +from PyBugReporter.src.DiscordBot import DiscordBot from python_graphql_client import GraphqlClient @@ -25,8 +26,11 @@ class BugHandler: repoName: str = '' orgName: str = '' test: bool = False + useDiscord: bool = False + botToken: str = '' + channelId: str | int = '' - def __init__(self, githubKey: str, repoName: str, orgName: str, test: bool) -> None: + def __init__(self, githubKey: str, repoName: str, orgName: str, test: bool, useDiscord: bool = False, botToken: str = "", channelId: str | int = "") -> None: """Saves the given information in the BugHandler object. Args: @@ -34,11 +38,19 @@ def __init__(self, githubKey: str, repoName: str, orgName: str, test: bool) -> N repoName (str): the name of the repo to report to orgName (str): the organization of the repo test (bool): whether or not bugs in this code should actually be reported + useDiscord (bool): whether to send the bug report to Discord + botToken (str): the token for the Discord bot + channelId (str | int): the ID of the Discord channel to send messages to """ self.githubKey = githubKey self.repoName = repoName self.orgName = orgName self.test = test + self.useDiscord = useDiscord + + if useDiscord: + self.botToken = botToken + self.channelId = channelId class BugReporter: """Sends errors to their corresponding repos. @@ -64,7 +76,7 @@ def __init__(self, repoName: str, extraInfo: bool, **kwargs) -> None: self.kwargs = kwargs @classmethod - def setVars(cls, githubKey: str, repoName: str, orgName: str, test: bool) -> None: + def setVars(cls, githubKey: str, repoName: str, orgName: str, test: bool, useDiscord: bool = False, botToken: str = "", channelId: str = "") -> None: """Sets the necessary variables to make bug reports. Args: @@ -73,7 +85,7 @@ def setVars(cls, githubKey: str, repoName: str, orgName: str, test: bool) -> Non orgName (str): the name of the organization test (bool): whether to run in testing mode """ - cls.handlers[repoName] = BugHandler(githubKey, repoName, orgName, test) + cls.handlers[repoName] = BugHandler(githubKey, repoName, orgName, test, useDiscord, botToken, channelId) def __call__(self, func: callable) -> None: """Decorator that catches exceptions and sends a bug report to the github repository. @@ -118,26 +130,51 @@ def _handleError(self, e: Exception, repoName: str, *args, **kwargs) -> None: if self.extraInfo: description += f"\nExtra Info: {self.kwargs}" + # shortened description for discord if too long (shortens the error text) + start = f"# {title}\n\nType: {excType}\nError text: " + compress = f"{e}\nTraceback: {traceback.format_exc()}" + end = f"\n\nFunction Name: {functionName}\nArguments: {args}\nKeyword Arguments: {kwargs}" + if self.extraInfo: + end += f"\nExtra Info: {self.kwargs}" + + staticLength = len(start) + len(end) + if staticLength > 2000: + shortDescription = f"# {title}\n\n" + description[:2000 - len(f"# {title}\n\n") - 3] + "..." + else: + shortDescription = f"{start}{compress[:2000 - staticLength]}{end}" + + print(f"SHORT DESCRIPTION with length {len(shortDescription)}:\n{shortDescription}") + + # Check if we need to send a bug report if not self.handlers[repoName].test: - self._sendBugReport(repoName, title, description) + self._sendBugReport(repoName, title, description, shortDescription) print(title) print(description) raise e - def _sendBugReport(self, repoName: str, errorTitle: str, errorMessage: str) -> None: + def _sendBugReport(self, repoName: str, errorTitle: str, errorMessage: str, shortErrorMessage: str) -> None: """Sends a bug report to the Github repository. Args: errorTitle (str): the title of the error errorMessage (str): the error message - """ + """ + asyncio.run(self._sendBugReport_async(repoName, errorTitle, errorMessage, shortErrorMessage)) + + async def _sendBugReport_async(self, repoName: str, errorTitle: str, errorMessage: str, shortErrorMessage: str) -> None: + """Sends a bug report to the Github repository asynchronously. + + Args: + errorTitle (str): the title of the error + errorMessage (str): the error message + """ client = GraphqlClient(endpoint="https://api.github.com/graphql") headers = {"Authorization": f"Bearer {self.handlers[repoName].githubKey}"} # query variables - repoId = self._getRepoId(self.handlers[repoName]) + repoId = await self._getRepoId_async(self.handlers[repoName]) bugLabel = "LA_kwDOJ3JPj88AAAABU1q15w" autoLabel = "LA_kwDOJ3JPj88AAAABU1q2DA" @@ -171,10 +208,15 @@ def _sendBugReport(self, repoName: str, errorTitle: str, errorMessage: str) -> N } } - issueExists = self._checkIfIssueExists(self.handlers[repoName], errorTitle) + issueExists = await self._checkIfIssueExists_async(self.handlers[repoName], errorTitle) - if (issueExists == False): - result = asyncio.run(client.execute_async(query=createIssue, variables=variables, headers=headers)) + # Send to Discord if applicable + if self.handlers[repoName].useDiscord: + discordBot = DiscordBot(self.handlers[repoName].botToken, self.handlers[repoName].channelId) + await discordBot.send_message(shortErrorMessage, issueExists) + + if (not issueExists): + result = await client.execute_async(query=createIssue, variables=variables, headers=headers) print('\nThis error has been reported to the Tree Growth team.\n') issueId = result['data']['createIssue']['issue']['id'] # Extract the issue ID @@ -191,7 +233,7 @@ def _sendBugReport(self, repoName: str, errorTitle: str, errorMessage: str) -> N """ # Replace with your actual project ID - projectId = self.getProjectId(repoName, "Tree Growth Projects") + projectId = await self.getProjectId_async(repoName, "Tree Growth Projects") variables = { "projectId": projectId, @@ -199,12 +241,11 @@ def _sendBugReport(self, repoName: str, errorTitle: str, errorMessage: str) -> N } # Execute the mutation to add the issue to the project - asyncio.run(client.execute_async(query=addToProject, variables=variables, headers=headers)) + await client.execute_async(query=addToProject, variables=variables, headers=headers) else: print('\nOur team is already aware of this issue.\n') - def getProjectId(self, repoName: str, projectName: str) -> str: - """Retrieves the GitHub project ID for a specified repository and project name.""" + async def getProjectId_async(self, repoName: str, projectName: str) -> str: client = GraphqlClient(endpoint="https://api.github.com/graphql") headers = {"Authorization": f"Bearer {self.handlers[repoName].githubKey}"} @@ -228,7 +269,7 @@ def getProjectId(self, repoName: str, projectName: str) -> str: } # Execute the query - response = asyncio.run(client.execute_async(query=query, variables=variables, headers=headers)) + response = await client.execute_async(query=query, variables=variables, headers=headers) projects = response["data"]["repository"]["projectsV2"]["nodes"] # Find the project with the matching name and return its ID @@ -238,7 +279,7 @@ def getProjectId(self, repoName: str, projectName: str) -> str: raise ValueError(f"Project '{projectName}' not found in repository '{repoName}'.") - def _checkIfIssueExists(self, handler: BugHandler, errorTitle: str) -> bool: + async def _checkIfIssueExists_async(self, handler: BugHandler, errorTitle: str) -> bool: """Checks if an issue already exists in the repository. Args: @@ -276,7 +317,7 @@ def _checkIfIssueExists(self, handler: BugHandler, errorTitle: str) -> bool: "labels": autoLabel, } - result = asyncio.run(client.execute_async(query=findIssue, variables=variables, headers=headers)) + result = await client.execute_async(query=findIssue, variables=variables, headers=headers) nodes = result['data']['organization']['repository']['issues']['nodes'] index = 0 @@ -292,7 +333,7 @@ def _checkIfIssueExists(self, handler: BugHandler, errorTitle: str) -> bool: return issueExists - def _getRepoId(self, handler: BugHandler) -> str: + async def _getRepoId_async(self, handler: BugHandler) -> str: """Gets the repository ID. Args: @@ -318,7 +359,7 @@ def _getRepoId(self, handler: BugHandler) -> str: "name": handler.repoName } - repoID = asyncio.run(client.execute_async(query=getID, variables=variables, headers=headers)) + repoID = await client.execute_async(query=getID, variables=variables, headers=headers) return repoID['data']['repository']['id'] @classmethod diff --git a/PyBugReporter/src/DiscordBot.py b/PyBugReporter/src/DiscordBot.py new file mode 100644 index 0000000..f424f28 --- /dev/null +++ b/PyBugReporter/src/DiscordBot.py @@ -0,0 +1,79 @@ +import asyncio +import discord + +HISTORY_LIMIT = 20 +EMOJI = "‼" + +class DiscordBot(discord.Client): + """ + A simple Discord bot that forwards the bug reports to a given Discord channel. + + Attributes: + token (str): bot token + channel_id (int): the ID of the channel to send messages to + _message (str): message to send + _alreadySent (bool): whether the message has already been sent + _done_future (asyncio.Future): a future that is set when the bot is done + """ + def __init__(self, token: str, channelId: str | int) -> None: + """ + Initializes the Discord bot with the given token and channel ID. + + Args: + token (str): bot token + channel_id (int): the ID of the channel to send messages to + """ + self.token = token + self.channelId = int(channelId) + self._message = None + self._alreadySent = False + self._doneFuture = None + + intents = discord.Intents(emojis = True, + guild_reactions = True, + message_content = True, + guild_messages = True, + guilds = True) + super().__init__(intents=intents) + + async def send_message(self, message, alreadySent = False): + """ + Sends a message to the specified channel by setting the variables and starting the bot, then turning it off when finished. + + Args: + message (str): The message to send. + alreadySent (bool): Whether the message has already been sent. + """ + self._message = message + self._alreadySent = alreadySent + self._doneFuture = asyncio.get_running_loop().create_future() + print("Starting bot...") + # Start the bot as a background task + asyncio.create_task(self.start(self.token)) + # Wait until the message is sent and the bot is closed + await self._doneFuture + + async def on_ready(self): + """ + Called when the bot is ready. Also sends the message to the specified channel, or reacts if it's been sent. + """ + try: + channel = await self.fetch_channel(self.channelId) + if channel and not self._alreadySent: + await channel.send(self._message) + print(f"Sent message to channel {self.channelId}") + elif channel and self._alreadySent: + async for message in channel.history(limit=HISTORY_LIMIT): + if message.content == self._message: + await message.add_reaction(EMOJI) + break + else: + print(f"Channel with ID {self.channelId} not found.") + except Exception as e: + print(f"Error sending message: {e}") + finally: + print("Shutting down bot...") + await self.close() + # Mark the future as done so send_message can return + if self._doneFuture and not self._doneFuture.done(): + self._doneFuture.set_result(True) \ No newline at end of file diff --git a/test.py b/test.py index e9c9a98..009d8a2 100644 --- a/test.py +++ b/test.py @@ -1,16 +1,95 @@ from PyBugReporter.src.BugReporter import BugReporter +import os +import dotenv import boto3 if __name__ == "__main__": - awsSession = boto3.Session(region_name="us-west-2") - client = awsSession.client(service_name="ssm") - response = client.get_parameter(Name='/growth-spurt/github/access-token', WithDecryption=True) - token = response['Parameter']['Value'] - BugReporter.setVars(token, 'PyBugReporter', 'byuawsfhtl', False) + # awsSession = boto3.Session(region_name="us-west-2") + # client = awsSession.client(service_name="ssm") + # response = client.get_parameter(Name='/growth-spurt/github/access-token', WithDecryption=True) + # token = response['Parameter']['Value'] + dotenv.load_dotenv("./.env") + token = os.getenv("GITHUB_TOKEN") + discordToken = os.getenv("DISCORD_TOKEN") + channelId = os.getenv("CHANNEL_ID") + + BugReporter.setVars(token, 'PyBugReporter', 'byuawsfhtl', False, True, discordToken, channelId) @BugReporter('PyBugReporter', extraInfo=True, env='test') def test(item, item2=None): - raise Exception("This is a test exception") + # raise Exception(""" + # This is a really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really + # really really really really really really really really long test exception + # """) + + def this(): + def exception(): + def has(): + def an(): + def insane(): + def traceback(): + def length(): + def that(): + def just(): + def keeps(): + def going(): + def on(): + def yep(): + def still(): + def more(): + def never(): + def do(): + def this(): + raise Exception("This is a test exception") + this() + do() + never() + more() + still() + yep() + on() + going() + keeps() + just() + that() + length() + traceback() + insane() + an() + has() + exception() + this() + test(None, item2='item2') \ No newline at end of file