From 61dcdcd19072748a82a1fc7261e8db450cf02c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JelNiS=C5=82aw?= Date: Fri, 3 Jun 2022 22:40:54 +0200 Subject: [PATCH] Ability to mention a GitHub issue --- wulkabot/bot.py | 4 +- wulkabot/cogs/development.py | 4 +- wulkabot/cogs/github.py | 137 ++++++++++++++++++++++++++++++++--- wulkabot/utils/github.py | 13 +++- wulkabot/utils/views.py | 2 +- 5 files changed, 146 insertions(+), 14 deletions(-) diff --git a/wulkabot/bot.py b/wulkabot/bot.py index e6c5776..96ae179 100644 --- a/wulkabot/bot.py +++ b/wulkabot/bot.py @@ -29,7 +29,9 @@ class Wulkabot(commands.Bot): async def on_connect(self) -> None: print(f"Connected as {self.user}") - async def on_command_error(self, context: commands.Context, exception: commands.errors.CommandError, /) -> None: + async def on_command_error( + self, context: commands.Context, exception: commands.errors.CommandError, / + ) -> None: await context.send(f"Error! {exception}") async def close(self) -> None: diff --git a/wulkabot/cogs/development.py b/wulkabot/cogs/development.py index f385b52..73a13d4 100644 --- a/wulkabot/cogs/development.py +++ b/wulkabot/cogs/development.py @@ -31,7 +31,9 @@ class Development(commands.Cog): commands = await self.bot.tree.sync(guild=interaction.guild if current_guild else None) commands_str = ", ".join(c.name for c in commands) destination = "guild" if current_guild else "global" - await interaction.response.send_message(f"Synced **{len(commands)} {destination}** commands\n{commands_str}") + await interaction.response.send_message( + f"Synced **{len(commands)} {destination}** commands\n{commands_str}" + ) @app_commands.command() async def reload(self, interaction: discord.Interaction): diff --git a/wulkabot/cogs/github.py b/wulkabot/cogs/github.py index d5fa219..4ebefd7 100644 --- a/wulkabot/cogs/github.py +++ b/wulkabot/cogs/github.py @@ -6,6 +6,7 @@ Copyright (C) 2022-present Stanisław Jelnicki import re from typing import Any +import aiohttp import discord from discord.ext import commands @@ -13,12 +14,68 @@ from .. import bot from ..utils import github from ..utils.views import DeleteButton -GITHUB_REPO = re.compile(r"(?:\s|^)(?P[\w-]+)/(?P[\w-]+)(?:\s|$)", re.ASCII) +Repo = tuple[str, str] + +DEFAULT_REPO: Repo = ("wulkanowy", "wulkanowy") -def match_repo(text: str) -> tuple[str, str] | None: - if match := GITHUB_REPO.search(text): - return (match["owner"], match["repo"]) +def parse_repo(text: str, *, default_owner: str | None = None) -> Repo | None: + """ + Parses repository name and owner + + "wulkanowy/sdk" => ("wulkanowy", "sdk") + "sdk" => None + "sdk", default_owner="wulkanowy" => ("wulkanowy", "sdk") + """ + repo = text.split("/") + + match len(repo): + case 1: + if default_owner is not None: + owner, repo = default_owner, repo[0] + else: + return None + case 2: + owner, repo = repo + case _: + return None + + return (owner, repo) + + +def parse_issue( + text: str, *, default_owner: str | None = None, default_repo: str | None = None +) -> tuple[Repo, int] | None: + """ + Parses an issue or a pull request string + """ + if "#" not in text: + return None + repo, issue_number = text.rsplit("#", 1) + try: + issue_number = int(issue_number) + except ValueError: + return None + if issue_number <= 0: + return None + + if repo: + repo = parse_repo(repo, default_owner=default_owner) + if repo is None: + return None + elif default_owner is None or default_repo is None: + return None + else: + repo = (default_owner, default_repo) + + return (repo, issue_number) + + +def find_repo_in_channel_topic(topic: str) -> Repo | None: + key = "https://github.com/" + for word in topic.split(): + if word.startswith(key): + return parse_repo(word[len(key) :]) class GitHub(commands.Cog): @@ -56,6 +113,35 @@ class GitHub(commands.Cog): .set_footer(text=footer) ) + def github_issue_embed(self, issue: dict[str, Any]) -> discord.Embed: + is_pull_request = "pull_request" in issue + title = f'{"Pull request" if is_pull_request else "Issue"} #{issue["number"]}\n{issue["title"][:128]}' + body = issue["body"] + if len(body) > 256: + body = None + + color = None + if issue["state"] == "open": + color = 0x2DA44E # --color-open-emphasis + elif issue["state"] == "closed": + color = 0xCF222E # --color-closed-emphasis + if is_pull_request: + pull_request = issue["pull_request"] + if pull_request["merged_at"] is not None: + color = 0x8250DF # --color-done-emphasis + elif issue["draft"]: + color = 0x6E7781 # --color-neutral-emphasis + + user = issue["user"] + comments = issue["comments"] + footer = f"💬 {comments}" + + return ( + discord.Embed(title=title, url=issue["html_url"], description=body, color=color) + .set_author(name=user["login"], url=user["html_url"], icon_url=user["avatar_url"]) + .set_footer(text=footer) + ) + def get_github_color(self, language: str | None) -> int | None: if language is None: return None @@ -69,11 +155,44 @@ class GitHub(commands.Cog): if message.author.bot: return - if match := match_repo(message.content): - if repo := await self.github.fetch_repo(*match): - view = DeleteButton(message.author) - reply = await message.reply(embed=self.github_repo_embed(repo), view=view) - view.message = reply + words = message.content.split() + embeds = [] + + try: + topic = message.channel.topic # type: ignore + except AttributeError: + channel_repo = DEFAULT_REPO + else: + if topic is not None: + channel_repo = find_repo_in_channel_topic(topic) + if channel_repo is None: + channel_repo = DEFAULT_REPO + else: + channel_repo = DEFAULT_REPO + + for word in words: + match = parse_issue(word, default_owner=channel_repo[0], default_repo=channel_repo[1]) + if match is not None: + repo, issue_number = match + repo = repo or DEFAULT_REPO + try: + issue = await self.github.fetch_issue(*repo, issue_number) + except aiohttp.ClientResponseError: + continue + embeds.append(self.github_issue_embed(issue)) + else: + match = parse_repo(word) + if match is not None: + try: + repo = await self.github.fetch_repo(*match) + except aiohttp.ClientResponseError: + continue + embeds.append(self.github_repo_embed(repo)) + + if embeds: + view = DeleteButton(message.author) + reply = await message.reply(embeds=embeds[:3], view=view) + view.message = reply async def setup(bot: bot.Wulkabot): diff --git a/wulkabot/utils/github.py b/wulkabot/utils/github.py index 9cfc311..602da5d 100644 --- a/wulkabot/utils/github.py +++ b/wulkabot/utils/github.py @@ -3,14 +3,18 @@ Wulkabot Copyright (C) 2022-present Stanisław Jelnicki """ +from typing import Any + import aiohttp class GitHub: def __init__(self) -> None: - self._http = aiohttp.ClientSession(base_url="https://api.github.com") + self._http = aiohttp.ClientSession( + base_url="https://api.github.com", headers={"Accept": "application/vnd.github.v3+json"} + ) - async def fetch_repo(self, owner: str, repo: str) -> dict[str, str | int | None]: + async def fetch_repo(self, owner: str, repo: str) -> dict[str, Any]: response = await self._http.get(f"/repos/{owner}/{repo}") response.raise_for_status() return await response.json() @@ -21,5 +25,10 @@ class GitHub: branches = await response.json() return [branch["name"] for branch in branches] + async def fetch_issue(self, owner: str, repo: str, issue_number: int) -> dict[str, Any]: + response = await self._http.get(f"/repos/{owner}/{repo}/issues/{issue_number}") + response.raise_for_status() + return await response.json() + async def close(self): await self._http.close() diff --git a/wulkabot/utils/views.py b/wulkabot/utils/views.py index 1379e2b..ca76b88 100644 --- a/wulkabot/utils/views.py +++ b/wulkabot/utils/views.py @@ -7,7 +7,7 @@ class DeleteButton(discord.ui.View): self.invoker = invoker self.message: discord.Message | None = None - def interaction_check(self, interaction: discord.Interaction) -> bool: + async def interaction_check(self, interaction: discord.Interaction) -> bool: return ( interaction.user == self.invoker or isinstance(interaction.user, discord.Member)