From 14b0c9d95960423f1e39c8cabc75dda384c053c0 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 21 Mar 2023 13:00:15 -0400 Subject: [PATCH 1/6] feat: initial comment linking implementation --- monty/exts/info/github_info.py | 68 +++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 1dee2676..20c282ef 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -47,6 +47,9 @@ PR_ENDPOINT = f"{GITHUB_API_URL}/repos/{{user}}/{{repository}}/pulls/{{number}}" LIST_PULLS_ENDPOINT = f"{GITHUB_API_URL}/repos/{{user}}/{{repository}}/pulls?per_page=100" LIST_ISSUES_ENDPOINT = f"{GITHUB_API_URL}/repos/{{user}}/{{repository}}/issues?per_page=100" +ISSUE_COMMENT_ENDPOINT = f"{GITHUB_API_URL}/repos/{{user}}/{{repository}}/issues/comments/{{comment_id}}" +PULL_REVIEW_COMMENT_ENDPOINT = f"{GITHUB_API_URL}/repos/{{user}}/{{repository}}/pulls/comments/{{comment_id}}" + REQUEST_HEADERS = { "Accept": "application/vnd.github.v3+json", @@ -123,6 +126,7 @@ class FoundIssue: repository: str number: str source_format: IssueSourceFormat + url_fragment: str = "" def __hash__(self) -> int: return hash((self.organisation, self.repository, self.number)) @@ -732,6 +736,7 @@ async def extract_issues_from_message( else: matches = itertools.chain(AUTOMATIC_REGEX.finditer(stripped_content)) for match in matches: + fragment = "" if match.re is GITHUB_ISSUE_LINK_REGEX: source_format = IssueSourceFormat.direct_github_url # handle custom checks here @@ -740,8 +745,8 @@ async def extract_issues_from_message( # don't match if we didn't end with the hash if not url.path.rstrip("/").endswith(match.group("number")): continue - if url.fragment or url.query: # saving fragments for later - continue + if url.fragment or url.query: # used to match for comments later + fragment = url.fragment else: # match.re is AUTOMATIC_REGEX, which doesn't require special handling right now source_format = IssueSourceFormat.github_form_with_repo @@ -764,6 +769,7 @@ async def extract_issues_from_message( repo, match.group("number"), source_format=source_format, + url_fragment=fragment, ) ) @@ -865,6 +871,60 @@ async def swap_embed_state(self, inter: disnake.MessageInteraction) -> None: await inter.response.edit_message(embed=embed, components=rows) + async def handle_issue_comment(self, message: disnake.Message, issues: list[FoundIssue]) -> None: + """Expand an issue or pull request comment.""" + comments = [] + for issue in issues: + assert issue.url_fragment + + # figure out which endpoint we want to use + frag = issue.url_fragment + if frag.startswith("issuecomment-"): + endpoint = ISSUE_COMMENT_ENDPOINT.format( + user=issue.organisation, + repository=issue.repository, + comment_id=frag.removeprefix("issuecomment-"), + ) + elif frag.startswith("pullrequestreview-"): + endpoint = PULL_REVIEW_COMMENT_ENDPOINT.format( + user=issue.organisation, + repository=issue.repository, + comment_id=frag.removeprefix("pullrequestreview-"), + ) + elif frag.startswith("discussion_r"): + endpoint = PULL_REVIEW_COMMENT_ENDPOINT.format( + user=issue.organisation, + repository=issue.repository, + comment_id=frag.removeprefix("discussion_r"), + ) + else: + continue + + comment: dict[str, Any] = await self.fetch_data(endpoint, as_text=False) # type: ignore + if "message" in comment: + log.warn("encountered error fetching %s: %s", endpoint, comment) + continue + + body = self.render_github_markdown(comment["body"]) + e = disnake.Embed(url=comment["html_url"], description=body) + + author = comment["user"] + e.set_author(name=author["login"], icon_url=author["avatar_url"], url=author["html_url"]) + + e.timestamp = datetime.strptime(comment["created_at"], "%Y-%m-%dT%H:%M:%SZ") + + comments.append(e) + + if not comments: + return + + components = [DeleteButton(message.author)] + await message.reply( + embeds=comments, + components=components, + allowed_mentions=disnake.AllowedMentions(replied_user=False), + ) + @commands.Cog.listener("on_message") async def on_message_automatic_issue_link(self, message: disnake.Message) -> None: """ @@ -903,6 +963,10 @@ async def on_message_automatic_issue_link(self, message: disnake.Message) -> Non if not issues: return + if issue_comments := list(filter(lambda issue: issue.url_fragment, issues)): + await self.handle_issue_comment(message, issue_comments) + return + links: list[IssueState] = [] log.trace(f"Found {issues = }") From 10c88ce766725142ee4ee23dbecb04ae49bef31f Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 21 Mar 2023 15:54:14 -0400 Subject: [PATCH 2/6] fix: suppress embeds for issue comments --- monty/exts/info/github_info.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 20c282ef..ab080f03 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -918,6 +918,9 @@ async def handle_issue_comment(self, message: disnake.Message, issues: list[Foun if not comments: return + if message.channel.permissions_for(message.guild.me).manage_messages: + scheduling.create_task(suppress_embeds(self.bot, message)) + components = [DeleteButton(message.author)] await message.reply( embeds=comments, From 8aba078a7d3269d0d26b3586c3ecf0afccfe14ea Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 28 Mar 2023 17:07:13 -0400 Subject: [PATCH 3/6] feat: show repo, include view button validate correct link --- monty/exts/info/github_info.py | 53 +++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index ab080f03..9359d3cd 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -127,6 +127,7 @@ class FoundIssue: number: str source_format: IssueSourceFormat url_fragment: str = "" + user_url: Optional[str] = None def __hash__(self) -> int: return hash((self.organisation, self.repository, self.number)) @@ -279,7 +280,7 @@ async def fetch_user_and_repo( # type: ignore user = user or await self.fetch_guild_to_org(guild_id) if not user: - raise commands.UserInputError("user must be provided" " or configured for this guild." if guild_id else ".") + raise commands.UserInputError("user must be provided or configured for this guild." if guild_id else ".") return RepoTarget(user, repo) # type: ignore @@ -750,6 +751,7 @@ async def extract_issues_from_message( else: # match.re is AUTOMATIC_REGEX, which doesn't require special handling right now source_format = IssueSourceFormat.github_form_with_repo + url = None repo = match.group("repo").lower() if not (org := match.group("org")): @@ -770,6 +772,7 @@ async def extract_issues_from_message( match.group("number"), source_format=source_format, url_fragment=fragment, + user_url=str(url) if url is not None else None, ) ) @@ -874,6 +877,8 @@ async def swap_embed_state(self, inter: disnake.MessageInteraction) -> None: async def handle_issue_comment(self, message: disnake.Message, issues: list[FoundIssue]) -> None: """Expand an issue or pull request comment.""" comments = [] + components = [] + for issue in issues: assert issue.url_fragment @@ -905,23 +910,63 @@ async def handle_issue_comment(self, message: disnake.Message, issues: list[Foun log.warn("encountered error fetching %s: %s", endpoint, comment) continue + # assert the url was not tampered with + if issue.user_url != (html_url := comment["html_url"]): + # this is a warning as its the best way I currently have to track how often the wrong url is used + log.warning("[comment autolink] issue url %s does not match comment url %s", issue.user_url, html_url) + continue + body = self.render_github_markdown(comment["body"]) - e = disnake.Embed(url=comment["html_url"], description=body) + e = disnake.Embed( + url=html_url, + description=body, + ) author = comment["user"] - e.set_author(name=author["login"], icon_url=author["avatar_url"], url=author["html_url"]) + e.set_author( + name=author["login"], + icon_url=author["avatar_url"], + url=author["html_url"], + ) + + e.set_footer(text=f"Comment on {issue.organisation}/{issue.repository}#{issue.number}") e.timestamp = datetime.strptime(comment["created_at"], "%Y-%m-%dT%H:%M:%SZ") comments.append(e) + components.append(disnake.ui.Button(url=comment["html_url"], label="View comment")) if not comments: return + if len(comments) > 4: + await message.reply( + ( + "Only 4 comments can be expanded at a time. Please send with only four comments if you would like" + " them to be expanded!" + ), + components=DeleteButton(message.author), + allowed_mentions=disnake.AllowedMentions(replied_user=False), + ) + return + if message.channel.permissions_for(message.guild.me).manage_messages: scheduling.create_task(suppress_embeds(self.bot, message)) - components = [DeleteButton(message.author)] + if len(comments) > 1: + for num, component in enumerate(components, 1): + if num == 1: + suffix = "st" + elif num == 2: + suffix = "nd" + elif num == 3: + suffix = "rd" + else: + suffix = "th" + + component.label = f"View {num}{suffix} comment" + + components.insert(0, DeleteButton(message.author)) await message.reply( embeds=comments, components=components, From 0c53d71a14cc2ae23521c0389c3d3713b90a22a4 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 3 Apr 2023 03:16:14 -0400 Subject: [PATCH 4/6] refactor: move number suffixes to a util --- monty/exts/info/github_info.py | 11 ++--------- monty/exts/info/python_discourse.py | 11 ++--------- monty/utils/helpers.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 9359d3cd..735f8682 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -26,6 +26,7 @@ from monty.utils import scheduling from monty.utils.caching import redis_cache from monty.utils.extensions import invoke_help_command +from monty.utils.helpers import get_num_suffix from monty.utils.markdown import DiscordRenderer, remove_codeblocks from monty.utils.messages import DeleteButton, extract_urls, suppress_embeds @@ -955,15 +956,7 @@ async def handle_issue_comment(self, message: disnake.Message, issues: list[Foun if len(comments) > 1: for num, component in enumerate(components, 1): - if num == 1: - suffix = "st" - elif num == 2: - suffix = "nd" - elif num == 3: - suffix = "rd" - else: - suffix = "th" - + suffix = get_num_suffix(num) component.label = f"View {num}{suffix} comment" components.insert(0, DeleteButton(message.author)) diff --git a/monty/exts/info/python_discourse.py b/monty/exts/info/python_discourse.py index 4f5560c6..d69a362c 100644 --- a/monty/exts/info/python_discourse.py +++ b/monty/exts/info/python_discourse.py @@ -15,6 +15,7 @@ from monty.constants import Feature, Icons from monty.log import get_logger from monty.utils import scheduling +from monty.utils.helpers import get_num_suffix from monty.utils.messages import DeleteButton, extract_urls, suppress_embeds @@ -179,15 +180,7 @@ async def on_message(self, message: disnake.Message) -> None: if len(components) > 1: for num, component in enumerate(components, 1): - if num == 1: - suffix = "st" - elif num == 2: - suffix = "nd" - elif num == 3: - suffix = "rd" - else: - suffix = "th" - + suffix = get_num_suffix(num) component.label = f"View {num}{suffix} comment" components.insert(0, DeleteButton(message.author)) diff --git a/monty/utils/helpers.py b/monty/utils/helpers.py index 432edfed..4e3cbaee 100644 --- a/monty/utils/helpers.py +++ b/monty/utils/helpers.py @@ -40,6 +40,22 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: return index +def get_num_suffix(num: int) -> str: + """Get the suffix for the provided number. Currently a lazy implementation so this only supports 1-20.""" + if num == 1: + suffix = "st" + elif num == 2: + suffix = "nd" + elif num == 3: + suffix = "rd" + elif 4 <= num < 20: + suffix = "th" + else: + err = "num must be within 1-20. If you receive this error you should refactor the get_num_suffix method." + raise RuntimeError(err) + return suffix + + def has_lines(string: str, count: int) -> bool: """Return True if `string` has at least `count` lines.""" # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. From de29f127ee668280a5a6ce39b1d0d9b7ac34cc37 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 3 Apr 2023 03:49:12 -0400 Subject: [PATCH 5/6] feat: support 'issue-' fragments --- monty/exts/info/github_info.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 735f8682..52ff512f 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -903,6 +903,29 @@ async def handle_issue_comment(self, message: disnake.Message, issues: list[Foun repository=issue.repository, comment_id=frag.removeprefix("discussion_r"), ) + elif frag.startswith("issue-"): + # in a perfect world we'd show the full issue display, and fetch the issue endpoint + # while we don't live in a perfect world we're going to make the necessary convoluted code + # to actually loop back anyways + + # why + issue.user_url = (issue.user_url or "#").rsplit("#")[0] # I don't even care right now + # github, why is this fragment even a thing? + fetched_issue = await self.fetch_issues( + int(issue.number), + issue.repository, + issue.organisation, # type: ignore + ) + if isinstance(fetched_issue, FetchError): + continue + comments.append(self.format_embed_expanded_issue(fetched_issue)) + components.append( + disnake.ui.Button( + url=fetched_issue.raw_json["html_url"], # type: ignore + label="View comment", + ) + ) + continue else: continue @@ -957,6 +980,8 @@ async def handle_issue_comment(self, message: disnake.Message, issues: list[Foun if len(comments) > 1: for num, component in enumerate(components, 1): suffix = get_num_suffix(num) + # current implemenation does allow mixing comments and actual issues + # this will be wrong in that case. Oh well. component.label = f"View {num}{suffix} comment" components.insert(0, DeleteButton(message.author)) From 49cfe05354f04028286e0525821f3d66937d98d9 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Thu, 8 Jun 2023 17:23:34 -0400 Subject: [PATCH 6/6] feat: add configuration for comment linking --- ...2023_06_08_1_add_github_comment_linking.py | 32 +++++++++++++++++++ monty/config_metadata.py | 19 ++++++++--- monty/database/guild_config.py | 1 + monty/exts/info/github_info.py | 8 ++++- 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py diff --git a/monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py b/monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py new file mode 100644 index 00000000..3f9a001b --- /dev/null +++ b/monty/alembic/versions/2023_06_08_1_add_github_comment_linking.py @@ -0,0 +1,32 @@ +"""add github comment linking + +Revision ID: 2023_06_08_1 +Revises: 2023_03_17_1 +Create Date: 2023-06-08 16:08:05.842221 + +""" +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "2023_06_08_1" +down_revision = "2023_03_17_1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("guild_config", sa.Column("github_comment_linking", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + op.execute("UPDATE guild_config SET github_comment_linking = true") + + op.alter_column("guild_config", "github_comment_linking", nullable=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("guild_config", "github_comment_linking") + # ### end Alembic commands ### diff --git a/monty/config_metadata.py b/monty/config_metadata.py index 489b28a2..70fc1ccd 100644 --- a/monty/config_metadata.py +++ b/monty/config_metadata.py @@ -7,6 +7,8 @@ from disnake import Locale from disnake.ext import commands +from monty.constants import Feature + if TYPE_CHECKING: from monty.bot import Monty @@ -82,7 +84,7 @@ def __post_init__(self): type=str, name={ Locale.en_US: "GitHub Issue Organization", - Locale.en_GB: "Github Issue Organisation", + Locale.en_GB: "GitHub Issue Organisation", }, description={ Locale.en_US: "A specific organization or user to use as the default org for GitHub related commands.", @@ -92,16 +94,25 @@ def __post_init__(self): ), git_file_expansions=ConfigAttrMetadata( type=bool, - name="Git File Expansions", - description="Bitbucket, GitLab, and GitHub automatic file expansions.", + name="GitHub/GitLab/BitBucket File Expansions", + description="BitBucket, GitLab, and GitHub automatic file expansions.", + long_description=( + "Automatically expand links to specific lines for GitHub, GitLab, and BitBucket when possible." + ), ), github_issue_linking=ConfigAttrMetadata( type=bool, - name="Github Automatic Issue Linking", + name="GitHub Issue Linking", description="Automatically link GitHub issues if they match the inline markdown syntax on GitHub.", long_description=( "Automatically link GitHub issues if they match the inline markdown syntax on GitHub. " "For example, `onerandomusername/monty-python#223` will provide a link to issue 223." ), ), + github_comment_linking=ConfigAttrMetadata( + type=bool, + name="GitHub Comment Linking", + depends_on_features=(Feature.GITHUB_COMMENT_LINKS,), + description="Automatically expand a GitHub comment link. Requires GitHub Issue Linking to have an effect.", + ), ) diff --git a/monty/database/guild_config.py b/monty/database/guild_config.py index 1eb70aa1..9238ec87 100644 --- a/monty/database/guild_config.py +++ b/monty/database/guild_config.py @@ -24,3 +24,4 @@ class GuildConfig(MappedAsDataclass, Base): github_issues_org: Mapped[Optional[str]] = mapped_column(sa.String(length=39), nullable=True, default=None) git_file_expansions: Mapped[bool] = mapped_column(sa.Boolean, default=True) github_issue_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True) + github_comment_linking: Mapped[bool] = mapped_column(sa.Boolean, default=True) diff --git a/monty/exts/info/github_info.py b/monty/exts/info/github_info.py index 52ff512f..f4dfa01d 100644 --- a/monty/exts/info/github_info.py +++ b/monty/exts/info/github_info.py @@ -1030,7 +1030,13 @@ async def on_message_automatic_issue_link(self, message: disnake.Message) -> Non return if issue_comments := list(filter(lambda issue: issue.url_fragment, issues)): - await self.handle_issue_comment(message, issue_comments) + # if there are issue comments found, we do not want to expand the entire issue + # we also only want to expand the issue if the feature is enabled + # AND both options of the guild configuration are enabled + if config.github_comment_linking and await self.bot.guild_has_feature( + message.guild, Feature.GITHUB_COMMENT_LINKS + ): + await self.handle_issue_comment(message, issue_comments) return links: list[IssueState] = []