Skip to content

Commit 73ed73c

Browse files
committed
Refactor UI code and remove parent imports
1 parent 0dc883d commit 73ed73c

File tree

18 files changed

+518
-496
lines changed

18 files changed

+518
-496
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from discord.utils import Coro
1010

1111
from ghutils.core.cog import GHUtilsCog
12-
from ghutils.utils.discord.components import RefreshIssuesButton
13-
from ghutils.utils.discord.embeds import create_issue_embeds
12+
from ghutils.ui.components.refresh import RefreshIssuesButton
13+
from ghutils.ui.embeds.issues import create_issue_embeds
1414

1515
logger = logging.getLogger(__name__)
1616

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,18 @@
2424
UserGitHubTokens,
2525
UserLogin,
2626
)
27-
from ghutils.utils.discord.components import RefreshCommitButton, RefreshIssueButton
28-
from ghutils.utils.discord.embeds import (
29-
create_commit_embed,
30-
create_issue_embed,
31-
set_embed_author,
32-
)
27+
from ghutils.ui.components.refresh import RefreshCommitButton, RefreshIssueButton
28+
from ghutils.ui.components.visibility import MessageVisibility, respond_with_visibility
29+
from ghutils.ui.embeds.commits import create_commit_embed
30+
from ghutils.ui.embeds.issues import create_issue_embed
31+
from ghutils.ui.views.select_artifact import SelectArtifactView
32+
from ghutils.utils.discord.embeds import set_embed_author
3333
from ghutils.utils.discord.references import (
3434
CommitReference,
3535
IssueReference,
3636
PRReference,
3737
)
3838
from ghutils.utils.discord.transformers import RepositoryOption, UserOption
39-
from ghutils.utils.discord.views.select_artifact import SelectArtifactView
40-
from ghutils.utils.discord.visibility import MessageVisibility, respond_with_visibility
4139
from ghutils.utils.github import gh_request
4240
from ghutils.utils.l10n import translate_text
4341

bot/src/ghutils/cogs/events.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
from discord.ext.commands import Cog
55

66
from ghutils.core.cog import GHUtilsCog
7-
from ghutils.utils.discord.commands import get_command, print_command
8-
from ghutils.utils.discord.components import (
7+
from ghutils.ui.components.refresh import (
98
RefreshCommitButton,
109
RefreshIssueButton,
1110
RefreshIssuesButton,
1211
)
13-
from ghutils.utils.discord.visibility import DeleteButton
12+
from ghutils.ui.components.visibility import DeleteButton
13+
from ghutils.utils.discord.commands import get_command, print_command
1414

1515
logger = logging.getLogger(__name__)
1616

File renamed without changes.

bot/src/ghutils/ui/components/__init__.py

Whitespace-only changes.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
from dataclasses import dataclass
2+
from datetime import UTC, datetime, timedelta
3+
from re import Match
4+
from typing import Any, override
5+
6+
from discord import Color, Embed, Interaction, Message
7+
from discord.ui import Button, DynamicItem, Item
8+
from githubkit import GitHub
9+
from githubkit.rest import Commit, Issue
10+
from pydantic import TypeAdapter
11+
from pydantic.dataclasses import dataclass as pydantic_dataclass
12+
13+
from ghutils.core.bot import GHUtilsBot
14+
from ghutils.core.types import LoginState
15+
from ghutils.ui.embeds.commits import create_commit_embed
16+
from ghutils.ui.embeds.issues import (
17+
create_issue_embed,
18+
create_issue_embeds,
19+
)
20+
from ghutils.utils.discord.mentions import relative_timestamp
21+
from ghutils.utils.discord.references import (
22+
CommitReference,
23+
IssueReference,
24+
PRReference,
25+
)
26+
from ghutils.utils.github import RepositoryName, gh_request
27+
28+
29+
@pydantic_dataclass
30+
class RefreshIssueButton(
31+
DynamicItem[Button[Any]],
32+
template=r"RefreshIssue:(?P<repo_id>[0-9]+):(?P<issue>[0-9]+)",
33+
):
34+
repo_id: int
35+
issue: int
36+
37+
def __post_init__(self):
38+
super().__init__(
39+
Button(
40+
emoji="🔄",
41+
custom_id=f"RefreshIssue:{self.repo_id}:{self.issue}",
42+
)
43+
)
44+
45+
@classmethod
46+
@override
47+
async def from_custom_id(
48+
cls,
49+
interaction: Interaction,
50+
item: Item[Any],
51+
match: Match[str],
52+
):
53+
return TypeAdapter(cls).validate_python(match.groupdict())
54+
55+
@classmethod
56+
async def from_reference(
57+
cls,
58+
github: GitHub[Any],
59+
reference: IssueReference | PRReference,
60+
):
61+
repo_name, issue = reference
62+
repo = await gh_request(
63+
github.rest.repos.async_get(owner=repo_name.owner, repo=repo_name.repo)
64+
)
65+
return cls(
66+
repo_id=repo.id,
67+
issue=issue.number,
68+
)
69+
70+
@override
71+
async def callback(self, interaction: Interaction):
72+
async with GHUtilsBot.github_app_of(interaction) as (github, state):
73+
if not await _check_ratelimit(interaction, state):
74+
return
75+
76+
# NOTE: this is an undocumented endpoint, but it seems like it's probably stable (https://stackoverflow.com/a/75527854)
77+
# we use this because user and repository names may be too long to fit in a custom id
78+
issue = await gh_request(
79+
github.arequest( # pyright: ignore[reportUnknownMemberType]
80+
"GET",
81+
f"/repositories/{self.repo_id}/issues/{self.issue}",
82+
response_model=Issue,
83+
)
84+
)
85+
86+
repo = RepositoryName.from_url(issue.html_url)
87+
88+
await interaction.response.edit_message(
89+
embed=create_issue_embed(repo, issue),
90+
)
91+
92+
93+
@dataclass
94+
class RefreshIssuesButton(
95+
DynamicItem[Button[Any]],
96+
template=r"RefreshIssues:(?P<message_id>[0-9]+)",
97+
):
98+
message: Message
99+
100+
def __post_init__(self):
101+
super().__init__(
102+
Button(
103+
emoji="🔄",
104+
custom_id=f"RefreshIssues:{self.message.id}",
105+
)
106+
)
107+
108+
@classmethod
109+
@override
110+
async def from_custom_id(
111+
cls,
112+
interaction: Interaction,
113+
item: Item[Any],
114+
match: Match[str],
115+
):
116+
assert interaction.message is not None
117+
message_id = int(match["message_id"])
118+
return cls(
119+
message=await interaction.message.channel.fetch_message(message_id),
120+
)
121+
122+
@override
123+
async def callback(self, interaction: Interaction):
124+
async with GHUtilsBot.github_app_of(interaction) as (github, state):
125+
if not await _check_ratelimit(interaction, state):
126+
return
127+
128+
# disable the button while we're working to give a loading indication
129+
self.item.disabled = True
130+
await interaction.response.edit_message(view=self.view)
131+
132+
self.item.disabled = False
133+
try:
134+
contents = await create_issue_embeds(github, interaction, self.message)
135+
await contents.edit_original_response(interaction, view=self.view)
136+
except Exception:
137+
await interaction.response.edit_message(view=self.view)
138+
raise
139+
140+
141+
@pydantic_dataclass
142+
class RefreshCommitButton(
143+
DynamicItem[Button[Any]],
144+
template=r"RefreshCommit:(?P<repo_id>[0-9]+):(?P<sha>[^:]+)",
145+
):
146+
repo_id: int
147+
sha: str
148+
149+
def __post_init__(self):
150+
super().__init__(
151+
Button(
152+
emoji="🔄",
153+
custom_id=f"RefreshCommit:{self.repo_id}:{self.sha}",
154+
)
155+
)
156+
157+
@classmethod
158+
@override
159+
async def from_custom_id(
160+
cls,
161+
interaction: Interaction,
162+
item: Item[Any],
163+
match: Match[str],
164+
):
165+
return TypeAdapter(cls).validate_python(match.groupdict())
166+
167+
@classmethod
168+
async def from_reference(
169+
cls,
170+
github: GitHub[Any],
171+
reference: CommitReference,
172+
):
173+
repo_name, commit = reference
174+
repo = await gh_request(
175+
github.rest.repos.async_get(owner=repo_name.owner, repo=repo_name.repo)
176+
)
177+
return cls(
178+
repo_id=repo.id,
179+
sha=commit.sha,
180+
)
181+
182+
@override
183+
async def callback(self, interaction: Interaction):
184+
async with GHUtilsBot.github_app_of(interaction) as (github, state):
185+
if not await _check_ratelimit(interaction, state):
186+
return
187+
188+
commit = await gh_request(
189+
github.arequest( # pyright: ignore[reportUnknownMemberType]
190+
"GET",
191+
f"/repositories/{self.repo_id}/commits/{self.sha}",
192+
response_model=Commit,
193+
)
194+
)
195+
196+
repo = RepositoryName.from_url(commit.html_url)
197+
198+
await interaction.response.edit_message(
199+
embed=await create_commit_embed(github, repo, commit),
200+
)
201+
202+
203+
async def _check_ratelimit(interaction: Interaction, state: LoginState) -> bool:
204+
now = datetime.now(UTC)
205+
if (
206+
state.logged_out()
207+
and interaction.message
208+
and (edited_at := interaction.message.edited_at)
209+
and (retry_time := edited_at + timedelta(seconds=60))
210+
and retry_time > now
211+
):
212+
await interaction.response.send_message(
213+
embed=Embed(
214+
title="Slow down!",
215+
description="This button can only be used once per minute by unauthenticated users."
216+
+ f" Use `/gh login` to authenticate, or try again {relative_timestamp(retry_time)}.",
217+
color=Color.red(),
218+
),
219+
ephemeral=True,
220+
# delete the message after the timeout, but wait at least 10 seconds to allow reading it fully
221+
delete_after=max((retry_time - now).total_seconds(), 10),
222+
)
223+
return False
224+
return True

bot/src/ghutils/utils/discord/visibility.py renamed to bot/src/ghutils/ui/components/visibility.py

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,11 @@
99

1010
from ghutils.core.bot import GHUtilsBot
1111
from ghutils.core.types import CustomEmoji
12-
13-
from .commands import AnyInteractionCommand
12+
from ghutils.utils.discord.commands import AnyInteractionCommand
1413

1514
type MessageVisibility = Literal["public", "private"]
1615

1716

18-
async def respond_with_visibility(
19-
interaction: Interaction,
20-
visibility: MessageVisibility,
21-
*,
22-
content: Any | None = None,
23-
embed: Embed = MISSING,
24-
embeds: Sequence[Embed] = MISSING,
25-
items: list[Item[Any]] | None = None,
26-
):
27-
data = MessageContents(
28-
command=interaction.command,
29-
content=content,
30-
embed=embed,
31-
embeds=embeds,
32-
items=items if items is not None else [],
33-
)
34-
await data.send(interaction, visibility)
35-
36-
3717
@dataclass(kw_only=True)
3818
class MessageContents:
3919
command: AnyInteractionCommand
@@ -164,21 +144,23 @@ async def callback(self, interaction: Interaction):
164144
await interaction.delete_original_response()
165145

166146

167-
def get_command_usage_button(
147+
async def respond_with_visibility(
168148
interaction: Interaction,
169-
command: AnyInteractionCommand,
170-
) -> Button[Any]:
171-
match command:
172-
case Command(qualified_name=command_name):
173-
label = f"{interaction.user.name} used /{command_name}"
174-
case _:
175-
label = f"Sent by {interaction.user.name}"
176-
bot = GHUtilsBot.of(interaction)
177-
return Button(
178-
emoji=bot.get_custom_emoji(CustomEmoji.apps_icon),
179-
label=label,
180-
disabled=True,
149+
visibility: MessageVisibility,
150+
*,
151+
content: Any | None = None,
152+
embed: Embed = MISSING,
153+
embeds: Sequence[Embed] = MISSING,
154+
items: list[Item[Any]] | None = None,
155+
):
156+
data = MessageContents(
157+
command=interaction.command,
158+
content=content,
159+
embed=embed,
160+
embeds=embeds,
161+
items=items if items is not None else [],
181162
)
163+
await data.send(interaction, visibility)
182164

183165

184166
@overload
@@ -232,3 +214,20 @@ def add_visibility_buttons(
232214
parent.add_item(DeleteButton(user_id=interaction.user.id))
233215
if show_usage:
234216
parent.add_item(get_command_usage_button(interaction, command))
217+
218+
219+
def get_command_usage_button(
220+
interaction: Interaction,
221+
command: AnyInteractionCommand,
222+
) -> Button[Any]:
223+
match command:
224+
case Command(qualified_name=command_name):
225+
label = f"{interaction.user.name} used /{command_name}"
226+
case _:
227+
label = f"Sent by {interaction.user.name}"
228+
bot = GHUtilsBot.of(interaction)
229+
return Button(
230+
emoji=bot.get_custom_emoji(CustomEmoji.apps_icon),
231+
label=label,
232+
disabled=True,
233+
)

bot/src/ghutils/ui/embeds/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)