Skip to content

Commit 4c1e414

Browse files
committed
Add message command to get issue links
1 parent 9285434 commit 4c1e414

File tree

5 files changed

+185
-36
lines changed

5 files changed

+185
-36
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Pydantic's HISTORY.md](https://github.com/pydantic/pydantic/blob/main/HISTORY.md), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## `0.2.4` - 2025-07-14
8+
9+
### Added
10+
11+
* Added a message command to get links to inline issue references.
12+
713
## `0.2.3` - 2025-05-07
814

915
### Changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import logging
2+
import re
3+
from dataclasses import dataclass
4+
from types import MethodType
5+
from typing import Any, Callable
6+
7+
from discord import Embed, Interaction, Message, app_commands
8+
from discord.abc import MISSING
9+
from discord.app_commands import ContextMenu
10+
from discord.utils import Coro
11+
12+
from ghutils.core.cog import GHUtilsCog
13+
from ghutils.utils.discord.embeds import create_issue_embed
14+
from ghutils.utils.discord.references import IssueReferenceTransformer
15+
from ghutils.utils.discord.visibility import respond_with_visibility
16+
17+
logger = logging.getLogger(__name__)
18+
19+
type ContextMenuCallback[GroupT: GHUtilsCog] = Callable[
20+
[GroupT, Interaction, Message], Coro[Any]
21+
]
22+
23+
type ContextMenuBuilder[GroupT: GHUtilsCog] = Callable[[GroupT], ContextMenu]
24+
25+
26+
_builders = list[ContextMenuBuilder[Any]]()
27+
28+
29+
def context_menu[GroupT: GHUtilsCog](
30+
*,
31+
name: str | app_commands.locale_str,
32+
nsfw: bool = False,
33+
auto_locale_strings: bool = True,
34+
extras: dict[Any, Any] = MISSING,
35+
) -> Callable[[ContextMenuCallback[GroupT]], ContextMenuCallback[GroupT]]:
36+
def decorator(f: ContextMenuCallback[GroupT]):
37+
def builder(group: GroupT):
38+
return ContextMenu(
39+
name=name,
40+
callback=MethodType(f, group),
41+
nsfw=nsfw,
42+
auto_locale_strings=auto_locale_strings,
43+
extras=extras,
44+
)
45+
46+
_builders.append(builder)
47+
return f
48+
49+
return decorator
50+
51+
52+
_issue_pattern = re.compile(
53+
r"""
54+
(?<![a-zA-Z`</])
55+
(?P<value>
56+
(?P<repo>[\w-]+/[\w-]+)?
57+
\#
58+
(?P<reference>[0-9]+)
59+
)
60+
(?![a-zA-Z`>])
61+
""",
62+
flags=re.VERBOSE,
63+
)
64+
65+
66+
@dataclass(eq=False)
67+
class ContextMenusCog(GHUtilsCog):
68+
"""Context menu commands."""
69+
70+
def __post_init__(self):
71+
self._ctx_menus = list[ContextMenu]()
72+
73+
async def cog_load(self):
74+
await super().cog_load()
75+
76+
for builder in _builders:
77+
ctx_menu = builder(self)
78+
self._ctx_menus.append(ctx_menu)
79+
self.bot.tree.add_command(ctx_menu)
80+
81+
async def cog_unload(self) -> None:
82+
await super().cog_unload()
83+
84+
for ctx_menu in self._ctx_menus:
85+
self.bot.tree.remove_command(ctx_menu.name, type=ctx_menu.type)
86+
self._ctx_menus.clear()
87+
88+
@context_menu(name="Show GitHub issues")
89+
async def show_issues(self, interaction: Interaction, message: Message):
90+
await interaction.response.defer()
91+
92+
seen = set[str]()
93+
embeds = list[Embed]()
94+
transformer = IssueReferenceTransformer()
95+
96+
for match in _issue_pattern.finditer(message.content):
97+
value = match.group("value")
98+
try:
99+
repo, issue = await transformer.transform(interaction, value)
100+
except Exception:
101+
logger.warning(
102+
f"Failed to transform issue reference: {value}", exc_info=True
103+
)
104+
continue
105+
106+
if issue.html_url in seen:
107+
continue
108+
seen.add(issue.html_url)
109+
110+
embeds.append(create_issue_embed(repo, issue, add_body=False))
111+
if len(embeds) >= 10:
112+
break
113+
114+
if not embeds:
115+
await respond_with_visibility(
116+
interaction,
117+
"public",
118+
content="No issue references found.",
119+
)
120+
return
121+
122+
await respond_with_visibility(
123+
interaction,
124+
"public",
125+
embeds=embeds,
126+
)

bot/src/ghutils/cogs/app_commands/github.py

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from discord.ui import Button, View
1515
from githubkit import GitHub
1616
from githubkit.exception import GitHubException, RequestFailed
17-
from githubkit.rest import Issue, IssuePropPullRequest, PullRequest, SimpleUser
17+
from githubkit.rest import SimpleUser
1818
from more_itertools import consecutive_groups, ilen
1919
from Pylette import extract_colors # pyright: ignore[reportUnknownVariableType]
2020
from yarl import URL
@@ -27,7 +27,7 @@
2727
UserGitHubTokens,
2828
UserLogin,
2929
)
30-
from ghutils.utils.discord.embeds import set_embed_author
30+
from ghutils.utils.discord.embeds import create_issue_embed, set_embed_author
3131
from ghutils.utils.discord.references import (
3232
CommitReference,
3333
IssueReference,
@@ -37,8 +37,6 @@
3737
from ghutils.utils.discord.visibility import MessageVisibility, respond_with_visibility
3838
from ghutils.utils.github import (
3939
CommitCheckState,
40-
IssueState,
41-
PullRequestState,
4240
RepositoryName,
4341
SmartPaginator,
4442
gh_request,
@@ -66,7 +64,7 @@ async def issue(
6664
await respond_with_visibility(
6765
interaction,
6866
visibility,
69-
embed=_create_issue_embed(*reference),
67+
embed=create_issue_embed(*reference),
7068
)
7169

7270
@app_commands.command()
@@ -80,7 +78,7 @@ async def pr(
8078
await respond_with_visibility(
8179
interaction,
8280
visibility,
83-
embed=_create_issue_embed(*reference),
81+
embed=create_issue_embed(*reference),
8482
)
8583

8684
@app_commands.command()
@@ -480,34 +478,6 @@ def _discord_date(timestamp: int | float | datetime):
480478
return f"<t:{timestamp}:f> (<t:{timestamp}:R>)"
481479

482480

483-
def _create_issue_embed(repo: RepositoryName, issue: Issue | PullRequest):
484-
match issue:
485-
case Issue(pull_request=IssuePropPullRequest()) | PullRequest():
486-
issue_type = "PR"
487-
state = PullRequestState.of(issue)
488-
assert state
489-
case Issue():
490-
issue_type = "Issue"
491-
state = IssueState.of(issue)
492-
493-
embed = Embed(
494-
title=truncate_str(f"[{issue_type} #{issue.number}] {issue.title}", 256),
495-
url=issue.html_url,
496-
timestamp=issue.created_at,
497-
color=state.color,
498-
).set_footer(
499-
text=f"{repo}#{issue.number}",
500-
)
501-
502-
if issue.body:
503-
embed.description = truncate_str(issue.body, 200)
504-
505-
if issue.user:
506-
set_embed_author(embed, issue.user)
507-
508-
return embed
509-
510-
511481
# we need to look at both checks and commit statuses
512482
# https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#types-of-status-checks-on-github
513483
# if anything is in progress, return PENDING

bot/src/ghutils/utils/discord/embeds.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
from __future__ import annotations
2+
13
from discord import Embed
2-
from githubkit.rest import SimpleUser
4+
from githubkit.rest import Issue, IssuePropPullRequest, PullRequest, SimpleUser
5+
6+
from ..github import (
7+
IssueState,
8+
PullRequestState,
9+
RepositoryName,
10+
)
11+
from ..strings import truncate_str
312

413

514
def set_embed_author(embed: Embed, user: SimpleUser):
@@ -9,3 +18,36 @@ def set_embed_author(embed: Embed, user: SimpleUser):
918
icon_url=user.avatar_url,
1019
)
1120
return embed
21+
22+
23+
def create_issue_embed(
24+
repo: RepositoryName,
25+
issue: Issue | PullRequest,
26+
*,
27+
add_body: bool = True,
28+
):
29+
match issue:
30+
case Issue(pull_request=IssuePropPullRequest()) | PullRequest():
31+
issue_type = "PR"
32+
state = PullRequestState.of(issue)
33+
assert state
34+
case Issue():
35+
issue_type = "Issue"
36+
state = IssueState.of(issue)
37+
38+
embed = Embed(
39+
title=truncate_str(f"[{issue_type} #{issue.number}] {issue.title}", 256),
40+
url=issue.html_url,
41+
timestamp=issue.created_at,
42+
color=state.color,
43+
).set_footer(
44+
text=f"{repo}#{issue.number}",
45+
)
46+
47+
if issue.body and add_body:
48+
embed.description = truncate_str(issue.body, 200)
49+
50+
if issue.user:
51+
set_embed_author(embed, issue.user)
52+
53+
return embed

bot/src/ghutils/utils/discord/visibility.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from dataclasses import dataclass
22
from datetime import UTC, datetime
33
from re import Match
4-
from typing import Any, Literal, Self
4+
from typing import Any, Literal, Self, Sequence
55

66
from discord import Embed, Interaction, ui
77
from discord.app_commands import Command, ContextMenu
@@ -22,11 +22,13 @@ async def respond_with_visibility(
2222
*,
2323
content: Any | None = None,
2424
embed: Embed = MISSING,
25+
embeds: Sequence[Embed] = MISSING,
2526
):
2627
data = MessageContents(
2728
command=interaction.command,
2829
content=content,
2930
embed=embed,
31+
embeds=embeds,
3032
)
3133
if interaction.response.is_done():
3234
await data.send_followup(interaction, visibility)
@@ -39,6 +41,7 @@ class MessageContents:
3941
command: AnyCommand | ContextMenu | None
4042
content: Any | None = None
4143
embed: Embed = MISSING
44+
embeds: Sequence[Embed] = MISSING
4245

4346
async def send_response(
4447
self,
@@ -49,6 +52,7 @@ async def send_response(
4952
await interaction.response.send_message(
5053
content=self.content,
5154
embed=self.embed,
55+
embeds=self.embeds,
5256
ephemeral=visibility == "private",
5357
view=self._get_view(interaction, visibility, show_user),
5458
)
@@ -62,6 +66,7 @@ async def send_followup(
6266
await interaction.followup.send(
6367
content=self.content or MISSING,
6468
embed=self.embed or MISSING,
69+
embeds=self.embeds or MISSING,
6570
ephemeral=visibility == "private",
6671
view=self._get_view(interaction, visibility, show_user),
6772
)

0 commit comments

Comments
 (0)