Skip to content

Commit f31dcf8

Browse files
committed
Implement /gh release
1 parent 3358ef4 commit f31dcf8

File tree

13 files changed

+481
-60
lines changed

13 files changed

+481
-60
lines changed

.vscode/python.code-snippets

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"Paginated Select": {
3+
"prefix": "paginated_select",
4+
"body": [
5+
"@paginated_select()",
6+
"async def $1_select(",
7+
" self,",
8+
" interaction: Interaction,",
9+
" select: PaginatedSelect[Any],",
10+
"):",
11+
" ${0:...}",
12+
"",
13+
"@$1_select.page_getter()",
14+
"async def $1_select_page_getter(",
15+
" self,",
16+
" interaction: Interaction,",
17+
" select: PaginatedSelect[Any],",
18+
" page: int,",
19+
") -> list[SelectOption]:",
20+
" ...",
21+
],
22+
},
23+
}

bot/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "ghutils-bot"
88
requires-python = ">=3.12"
99
dependencies = [
1010
"ghutils-common",
11-
"discord-py>=2.6.2",
11+
"discord-py>=2.6.3",
1212
"pydantic>=2.7.4",
1313
"pydantic-settings>=2.3.4",
1414
"fastapi>=0.111.0",
@@ -21,6 +21,7 @@ dependencies = [
2121
"pyyaml>=6.0.2",
2222
"pylette>=4.0.0",
2323
"humanize>=4.13.0",
24+
"babel>=2.17.0",
2425
]
2526

2627
[tool.rye]

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@
2828
from ghutils.ui.components.visibility import MessageVisibility, respond_with_visibility
2929
from ghutils.ui.embeds.commits import create_commit_embed
3030
from ghutils.ui.embeds.issues import create_issue_embed
31+
from ghutils.ui.embeds.releases import create_release_embed, create_release_items
3132
from ghutils.ui.views.get_artifact import GetArtifactView
33+
from ghutils.ui.views.get_release import GetReleaseView
3234
from ghutils.utils.discord.embeds import set_embed_author
3335
from ghutils.utils.discord.references import (
3436
CommitReference,
3537
IssueReference,
3638
PRReference,
3739
)
3840
from ghutils.utils.discord.transformers import RepositoryOption, UserOption
39-
from ghutils.utils.github import gh_request
41+
from ghutils.utils.github import ReleaseState, RepositoryName, gh_request
4042
from ghutils.utils.l10n import translate_text
4143

4244
logger = logging.getLogger(__name__)
@@ -225,6 +227,46 @@ async def user(
225227

226228
await respond_with_visibility(interaction, visibility, embed=embed)
227229

230+
@app_commands.command()
231+
async def release(
232+
self,
233+
interaction: Interaction,
234+
repo: RepositoryOption,
235+
tag: str | None = None,
236+
visibility: MessageVisibility = "private",
237+
):
238+
if not tag:
239+
view = await GetReleaseView.new(interaction, repo, visibility)
240+
await interaction.response.send_message(view=view, ephemeral=True)
241+
return
242+
243+
async with self.bot.github_app(interaction) as (github, _):
244+
try:
245+
release = await gh_request(
246+
github.rest.repos.async_get_release_by_tag(
247+
owner=repo.owner.login,
248+
repo=repo.name,
249+
tag=tag,
250+
)
251+
)
252+
except RequestFailed as e:
253+
if e.response.status_code == 404: # pyright: ignore[reportUnknownMemberType]
254+
raise InvalidInputError(
255+
value=tag,
256+
message="No release found for tag.",
257+
)
258+
raise
259+
260+
repo_name = RepositoryName.from_repo(repo)
261+
state = await ReleaseState.of(github, repo_name, release)
262+
263+
await respond_with_visibility(
264+
interaction,
265+
visibility,
266+
embed=create_release_embed(repo_name, release, state),
267+
items=create_release_items(release),
268+
)
269+
228270
@app_commands.command()
229271
async def login(self, interaction: Interaction):
230272
user_id = interaction.user.id

bot/src/ghutils/resources/l10n/en-US/main.ftl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ gh-user_parameter-description_user =
6464
gh-user_parameter-description_visibility =
6565
{-parameter-description_visibility}
6666
67+
# /gh release
68+
69+
gh-release_description =
70+
Get a link to a GitHub release, or open a menu to select one.
71+
72+
gh-release_parameter-description_repo =
73+
Repository to search in (`owner/repo`).
74+
75+
gh-release_parameter-description_tag =
76+
Tag name to get the release for. If not provided, opens a menu to select a release.
77+
78+
gh-release_parameter-description_visibility =
79+
{-parameter-description_visibility}
80+
6781
# /gh login
6882

6983
gh-login_description =
@@ -124,7 +138,7 @@ gh-actions_description =
124138
# /gh actions artifact
125139

126140
gh-actions-artifact_description =
127-
Get a link to download a workflow artifact.
141+
Open a menu to select a GitHub Actions workflow artifact.
128142
129143
gh-actions-artifact_parameter-description_repo =
130144
Repository to search in (`owner/repo`).

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

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from discord.ui import ActionRow, LayoutView, Select, View
99
from discord.ui.item import ContainedItemCallbackType
1010
from discord.ui.select import SelectCallbackDecorator
11+
from githubkit import Response
1112

13+
from ghutils.utils.github import is_last_page
1214
from ghutils.utils.types import AsyncCallable
1315

1416
logger = logging.getLogger(__name__)
@@ -17,7 +19,7 @@
1719
PREVIOUS_PAGE_VALUE = "e4215656-23ba-4a3d-8386-795778944b4b"
1820
NEXT_PAGE_VALUE = "1e1f5d4c-e908-42cf-8d6c-8b66aadf0998"
1921

20-
MAX_PAGE_LENGTH = 23
22+
MAX_PER_PAGE = 23
2123

2224

2325
type PageGetter[V: View | LayoutView] = AsyncCallable[
@@ -30,6 +32,7 @@ class PaginatedSelect[V: View | LayoutView](Select[V]):
3032
_inner_callback: ContainedItemCallbackType[V | ActionRow[Any], Self] | None
3133
_page_getter: PageGetter[V] | None
3234
_page_cache: dict[int, list[SelectOption]]
35+
_placeholder: str | None
3336

3437
_page: int
3538
_selected_page: int | None
@@ -39,18 +42,22 @@ def __init__(
3942
self,
4043
*,
4144
custom_id: str = MISSING,
42-
placeholder: None = None,
45+
placeholder: str | None = None,
4346
min_values: Literal[0, 1] = 1,
4447
max_values: Literal[0, 1] = 1,
4548
options: list[SelectOption] = MISSING,
4649
disabled: bool = False,
47-
required: bool = True,
50+
required: bool = MISSING,
4851
row: int | None = None,
4952
id: int | None = None,
5053
inner_callback: ContainedItemCallbackType[V | ActionRow[Any], Self]
5154
| None = None,
5255
page_getter: PageGetter[V] | None = None,
5356
) -> None:
57+
# https://github.com/Rapptz/discord.py/issues/10291
58+
if required is MISSING:
59+
required = min_values > 0
60+
5461
super().__init__(
5562
custom_id=custom_id,
5663
placeholder=placeholder,
@@ -70,6 +77,7 @@ def __init__(
7077
self._inner_callback = inner_callback
7178
self._page_getter = page_getter
7279
self._page_cache = {}
80+
self._placeholder = placeholder
7381
self._page = 1
7482
self._selected_page = None
7583
self._selected_index = None
@@ -83,6 +91,21 @@ async def fetch_first_page(self, interaction: Interaction):
8391
self._page_cache.pop(1, None)
8492
await self._switch_to_page(interaction, 1)
8593

94+
def set_last_page(self, page: int, response: Response[Any] | None = None) -> bool:
95+
"""Set the final page. The page after this one is assumed to be empty.
96+
97+
Page getters can use this if they're returning a page with less than 23 options
98+
that they know in advance is the last page, to prevent the select menu from
99+
adding a spurious "next page" option to that page.
100+
101+
If a GitHub response is given, the pagination headers are checked
102+
to see if this is the last page or not.
103+
"""
104+
if response is not None and not is_last_page(response):
105+
return False
106+
self._page_cache[page + 1] = []
107+
return True
108+
86109
def clear_cached_pages(self):
87110
self._page_cache.clear()
88111

@@ -117,9 +140,9 @@ def options(self) -> list[SelectOption]:
117140
@options.setter
118141
@override
119142
def options(self, value: list[SelectOption]):
120-
if len(value) > MAX_PAGE_LENGTH:
143+
if len(value) > MAX_PER_PAGE:
121144
raise ValueError(
122-
f"Pages must not contain more than {MAX_PAGE_LENGTH} options (got {len(value)})"
145+
f"Pages must not contain more than {MAX_PER_PAGE} options (got {len(value)})"
123146
)
124147

125148
assert Select.options.fset is not None
@@ -138,14 +161,14 @@ def options(self, value: list[SelectOption]):
138161
if self.options and (
139162
self.options[0].value == PREVIOUS_PAGE_VALUE
140163
or self.options[-1].value == NEXT_PAGE_VALUE
141-
or len(self.options) > MAX_PAGE_LENGTH
164+
or len(self.options) > MAX_PER_PAGE
142165
):
143166
return
144167

145168
# NOTE: we need to check this *before* mutating self.options
146169
if (
147170
# only allow going to the next page if the current page is full
148-
len(self.options) == MAX_PAGE_LENGTH
171+
len(self.options) == MAX_PER_PAGE
149172
# and either the next page has values or we haven't fetched it yet
150173
and self._page_cache.get(self._page + 1, True)
151174
):
@@ -254,9 +277,9 @@ async def _switch_to_page(self, interaction: Interaction, page: int):
254277
assert self._page_getter
255278
assert self.view
256279
options = await self._page_getter(self.view, interaction, self, page)
257-
if len(options) > MAX_PAGE_LENGTH:
280+
if len(options) > MAX_PER_PAGE:
258281
raise ValueError(
259-
f"Pages must not contain more than {MAX_PAGE_LENGTH} options (got {len(options)})"
282+
f"Pages must not contain more than {MAX_PER_PAGE} options (got {len(options)})"
260283
)
261284
self._page_cache[page] = options
262285

@@ -279,10 +302,10 @@ async def _switch_to_page(self, interaction: Interaction, page: int):
279302
0 if self._selected_page < page else -1
280303
].description = f"(selected on page {self._selected_page})"
281304
else:
282-
self.placeholder = None
305+
self.placeholder = self._placeholder
283306

284307
def _clear_remote_page_selection(self):
285-
self.placeholder = None
308+
self.placeholder = self._placeholder
286309

287310
if self.options[0].value == PREVIOUS_PAGE_VALUE:
288311
self.options[0].description = None
@@ -298,6 +321,7 @@ def paginated_select[
298321
*,
299322
options: list[SelectOption] = MISSING,
300323
custom_id: str = MISSING,
324+
placeholder: str | None = None,
301325
min_values: Literal[0, 1] = 1,
302326
max_values: Literal[0, 1] = 1,
303327
disabled: bool = False,
@@ -308,13 +332,21 @@ def decorator(inner_callback: ContainedItemCallbackType[Any, Any]) -> SelectT:
308332
select = PaginatedSelect[Any](
309333
options=options,
310334
custom_id=custom_id,
335+
placeholder=placeholder,
311336
min_values=min_values,
312337
max_values=max_values,
313338
disabled=disabled,
314339
row=row,
315340
id=id,
316341
inner_callback=inner_callback,
317342
)
343+
344+
# hack: View only adds items as children if __discord_ui_model_type__ is set
345+
def model_type(*args: Any, **kwargs: Any):
346+
raise AssertionError("This should never be called")
347+
348+
setattr(select, "__discord_ui_model_type__", model_type)
349+
318350
return select # pyright: ignore[reportReturnType]
319351

320352
return decorator

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ async def callback(self, interaction: Interaction):
134134
contents = await create_issue_embeds(github, interaction, self.message)
135135
await contents.edit_original_response(interaction, view=self.view)
136136
except Exception:
137-
await interaction.response.edit_message(view=self.view)
137+
await interaction.edit_original_response(view=self.view)
138138
raise
139139

140140

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ async def send_followup(
6161
view=self._get_view(interaction, visibility, show_usage),
6262
)
6363

64+
async def edit_message(
65+
self,
66+
interaction: Interaction,
67+
visibility: MessageVisibility,
68+
show_usage: bool = False,
69+
):
70+
await interaction.response.edit_message(
71+
content=self.content,
72+
embed=self.embed,
73+
embeds=self.embeds,
74+
view=self._get_view(interaction, visibility, show_usage),
75+
)
76+
6477
async def edit_original_response(
6578
self,
6679
interaction: Interaction,

0 commit comments

Comments
 (0)