Skip to content

Commit 5beca70

Browse files
feat: add very basic user and repo renderables and =gh user/repo command
1 parent 2201050 commit 5beca70

2 files changed

Lines changed: 259 additions & 6 deletions

File tree

monty/exts/info/github/__init__.py

Lines changed: 157 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import random
55
import re
66
from collections.abc import Mapping
7-
from typing import Any
7+
from typing import Any, overload
88

99
import attrs
1010
import cachingutils
@@ -141,7 +141,38 @@ def _format_github_global_id(self, prefix: str, *ids: int, template: int = 0) ->
141141
encoded = encoded.rstrip("=") # this isn't necessary, but github generates these IDs without padding
142142
return f"{prefix}_{encoded}"
143143

144-
async def fetch_resource(self, obj: ghretos.GitHubResource) -> githubkit.GitHubModel | None:
144+
@overload
145+
async def fetch_resource(self, obj: ghretos.User) -> githubkit.rest.PublicUser: ...
146+
147+
@overload
148+
async def fetch_resource(self, obj: ghretos.Repo) -> githubkit.rest.Repository: ...
149+
150+
@overload
151+
async def fetch_resource(self, obj: ghretos.Issue) -> githubkit.rest.Issue: ...
152+
@overload
153+
async def fetch_resource(self, obj: ghretos.PullRequest) -> githubkit.rest.Issue: ...
154+
@overload
155+
async def fetch_resource(self, obj: ghretos.IssueComment) -> githubkit.rest.IssueComment: ...
156+
@overload
157+
async def fetch_resource(self, obj: ghretos.PullRequestComment) -> githubkit.rest.IssueComment: ...
158+
@overload
159+
async def fetch_resource(
160+
self, obj: ghretos.PullRequestReviewComment
161+
) -> githubkit.rest.PullRequestReviewComment: ...
162+
@overload
163+
async def fetch_resource(self, obj: ghretos.Discussion) -> githubkit.rest.Discussion: ...
164+
@overload
165+
async def fetch_resource(self, obj: ghretos.DiscussionComment) -> graphql_models.DiscussionComment: ...
166+
@overload
167+
async def fetch_resource(self, obj: ghretos.IssueEvent) -> githubkit.rest.IssueEvent: ...
168+
@overload
169+
async def fetch_resource(self, obj: ghretos.PullRequestEvent) -> githubkit.rest.IssueEvent: ...
170+
@overload
171+
async def fetch_resource(self, obj: ghretos.Commit) -> githubkit.rest.Commit: ...
172+
@overload
173+
async def fetch_resource(self, obj: ghretos.Repo) -> githubkit.rest.Repository: ...
174+
175+
async def fetch_resource(self, obj: ghretos.GitHubResource) -> githubkit.GitHubModel:
145176
"""Fetch a GitHub resource."""
146177
# TODO: add repo exists validation before fetching multiple resources from one repo?
147178
# This would reduce wasted requests on private repos, and keep discussions from making many extra requests.
@@ -218,7 +249,16 @@ async def fetch_resource(self, obj: ghretos.GitHubResource) -> githubkit.GitHubM
218249
)
219250
return r.parsed_data
220251

221-
return None # Type is not yet supported
252+
if isinstance(obj, ghretos.User):
253+
r = await self.bot.github.rest.users.async_get_by_username(username=obj.login)
254+
data = r.parsed_data
255+
# Even though we use a token with no additional scopes, validate that we CERTAINLY only have public data.
256+
if data.user_view_type != "public":
257+
msg = "User is not public"
258+
raise ValueError(msg)
259+
return data
260+
261+
raise NotImplementedError # Type is not yet supported
222262

223263
# @github_group.command(name="ratelimit", aliases=("rl",), hidden=True)
224264

@@ -366,12 +406,119 @@ async def get_matcher_settings(self, guild_id: int, config: GuildConfig) -> ghre
366406

367407
return matcher_settings
368408

409+
@commands.group(
410+
name="github", description="Fetch GitHub information.", aliases=("gh",), invoke_without_command=True
411+
)
412+
async def github_group(self, ctx: commands.Context, *args) -> None:
413+
"""Group for GitHub related commands."""
414+
# Shortcut user:
415+
# Intentionally match 2 on the args here
416+
# Allow messages such as !github username extra words
417+
# but also support !github username repo
418+
if len(args) != 2 and re.fullmatch(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,38})$", args[0]):
419+
await self.github_user(ctx, args[0])
420+
return
421+
settings = ghretos.MatcherSettings.none()
422+
# enable what we have handlers for
423+
settings.shorthand = True
424+
settings.short_repo = True
425+
resource = ghretos.parse_shorthand(" ".join(args))
426+
427+
if isinstance(resource, ghretos.User):
428+
await self.github_user(ctx, resource.login)
429+
return
430+
if isinstance(resource, ghretos.Repo):
431+
await self.github_repo(ctx, resource.full_name)
432+
return
433+
if isinstance(resource, tuple(github_handlers.HANDLER_MAPPING.keys())):
434+
data = await self.get_reply({resource: github_handlers.InfoSize.OGP}, limit=850)
435+
if data:
436+
components: list[disnake.ui.Container | disnake.ui.ActionRow] = []
437+
components.append(
438+
disnake.ui.ActionRow(
439+
DeleteButton(
440+
allow_manage_messages=True,
441+
user=ctx.author,
442+
initial_message=ctx.message,
443+
)
444+
)
445+
)
446+
await ctx.reply(
447+
components=components,
448+
fail_if_not_exists=False,
449+
allowed_mentions=disnake.AllowedMentions.none(),
450+
**data,
451+
)
452+
return
453+
454+
await ctx.send(
455+
f"{constants.Emojis.decline} Could not parse GitHub resource from input.",
456+
components=DeleteButton(
457+
allow_manage_messages=True,
458+
user=ctx.author,
459+
initial_message=ctx.message,
460+
),
461+
)
462+
return
463+
464+
@github_group.command(name="user", description="Fetch GitHub user information.")
465+
async def github_user(self, ctx: commands.Context, user: str) -> None:
466+
"""Fetch GitHub user information."""
467+
# validate the user
468+
if not re.fullmatch(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,38})$", user):
469+
await ctx.send(
470+
f"{constants.Emojis.decline} Invalid GitHub username.",
471+
components=[
472+
DeleteButton(
473+
allow_manage_messages=True,
474+
user=ctx.author,
475+
initial_message=ctx.message,
476+
)
477+
],
478+
)
479+
return
480+
context = ghretos.User(login=user)
481+
obj = await self.fetch_resource(context)
482+
components: list[disnake.ui.Container | disnake.ui.ActionRow] = []
483+
components.append(github_handlers.UserRenderer().render_ogp_cv2(obj, context=context))
484+
components.append(
485+
disnake.ui.ActionRow(DeleteButton(allow_manage_messages=True, user=ctx.author, initial_message=ctx.message))
486+
)
487+
await ctx.send(components=components)
488+
489+
@github_group.command(name="repo", description="Fetch GitHub repository information.")
490+
async def github_repo(self, ctx: commands.Context, user_and_repo: str, repo: str = "") -> None:
491+
"""Fetch GitHub repository information."""
492+
# validate the repo
493+
if user_and_repo.count("/") == 1 and not repo:
494+
user, repo = user_and_repo.split("/", 1)
495+
else:
496+
user = user_and_repo
497+
if not repo:
498+
msg = "Repository name is required."
499+
raise commands.UserInputError(msg)
500+
if not re.fullmatch(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,38})$", user):
501+
msg = "Invalid GitHub username."
502+
raise commands.UserInputError(msg)
503+
if not re.fullmatch(r"^[\w\-\.]{1,100}$", repo):
504+
msg = "Invalid GitHub repository name."
505+
raise commands.UserInputError(msg)
506+
507+
context = ghretos.Repo(owner=user, name=repo)
508+
obj = await self.fetch_resource(context)
509+
components: list[disnake.ui.Container | disnake.ui.ActionRow] = []
510+
components.append(github_handlers.RepoRenderer().render_ogp_cv2(obj, context=context))
511+
components.append(
512+
disnake.ui.ActionRow(DeleteButton(allow_manage_messages=True, user=ctx.author, initial_message=ctx.message))
513+
)
514+
await ctx.send(components=components)
515+
369516
@commands.slash_command(name="github", description="Fetch GitHub information.")
370-
async def github_group(self, inter: disnake.ApplicationCommandInteraction) -> None:
517+
async def slash_github_group(self, inter: disnake.ApplicationCommandInteraction) -> None:
371518
"""Group for GitHub related commands."""
372519

373-
@github_group.sub_command(name="info", description="Fetch GitHub information.")
374-
async def github_info(self, inter: disnake.ApplicationCommandInteraction, arg: str) -> None:
520+
@slash_github_group.sub_command(name="info", description="Fetch GitHub information.")
521+
async def slash_github_info(self, inter: disnake.ApplicationCommandInteraction, arg: str) -> None:
375522
"""Fetch GitHub information.
376523
377524
Parameters
@@ -474,6 +621,10 @@ async def on_message_automatic_issue_link(
474621
"""
475622
if not message.guild:
476623
return
624+
command_context = await self.bot.get_context(message)
625+
if command_context.command and command_context.command.cog is self:
626+
return # do not auto-link in own commands
627+
477628
# in order to support shorthand, we need to check guild configuration
478629
guild_config = await self.bot.ensure_guild_config(message.guild.id)
479630

monty/exts/info/github/_handlers.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,107 @@ def render_full(self, obj: T, *, context: V) -> tuple[str, list[disnake.ui.TextD
164164
# region: concrete renderers
165165

166166

167+
class UserRenderer(GitHubRenderer[githubkit.rest.PublicUser, ghretos.User]):
168+
def render_tiny(
169+
self,
170+
obj: githubkit.rest.PublicUser,
171+
*,
172+
context: ghretos.User,
173+
) -> str:
174+
return f"👤 [{obj.login}](<{obj.html_url}>)"
175+
176+
def render_ogp(self, obj: githubkit.rest.PublicUser, *, context: ghretos.User) -> disnake.Embed:
177+
embed = disnake.Embed(
178+
title=obj.name or obj.login,
179+
url=obj.html_url,
180+
description=obj.bio,
181+
color=disnake.Color(0),
182+
)
183+
embed.set_thumbnail(url=obj.avatar_url)
184+
return embed
185+
186+
def render_ogp_cv2(self, obj: githubkit.rest.PublicUser, *, context: ghretos.User) -> disnake.ui.Container:
187+
container = disnake.ui.Container()
188+
text_display = disnake.ui.TextDisplay("")
189+
text_display.content = f"## [{obj.name or obj.login}](<{obj.html_url}>)\n\n"
190+
if obj.bio:
191+
text_display.content += f"{obj.bio}\n"
192+
container.children.append(text_display)
193+
section = disnake.ui.Section(accessory=disnake.ui.Thumbnail(obj.avatar_url))
194+
container.children.append(section)
195+
section.children.append(disnake.ui.TextDisplay(f"**Public Repos:** {obj.public_repos}"))
196+
section.children.append(disnake.ui.TextDisplay(f"**Followers:** {obj.followers}"))
197+
section.children.append(disnake.ui.TextDisplay(f"**Following:** {obj.following}"))
198+
199+
return container
200+
201+
def render_full(
202+
self,
203+
obj: githubkit.rest.PublicUser,
204+
*,
205+
context: ghretos.User,
206+
) -> tuple[str, list[disnake.ui.TextDisplay]]:
207+
# For simplicity, we will just return the OGP embed as a text display
208+
text_display = disnake.ui.TextDisplay("")
209+
text_display.content = f"## [{obj.name}](<{obj.html_url}>)\n\n"
210+
if obj.bio:
211+
text_display.content += f"{obj.bio}\n"
212+
if obj.location:
213+
text_display.content += f"**Location:** {obj.location}\n"
214+
if obj.blog:
215+
text_display.content += f"**Blog:** {obj.blog}\n"
216+
if obj.company:
217+
text_display.content += f"**Company:** {obj.company}\n"
218+
text_display.content += f"**Public Repos:** {obj.public_repos}\n"
219+
text_display.content += f"**Followers:** {obj.followers}\n"
220+
text_display.content += f"**Following:** {obj.following}\n"
221+
return obj.name or obj.login, [text_display]
222+
223+
224+
class RepoRenderer(GitHubRenderer[githubkit.rest.Repository, ghretos.Repo]):
225+
def render_tiny(
226+
self,
227+
obj: githubkit.rest.Repository,
228+
*,
229+
context: ghretos.Repo,
230+
) -> str:
231+
return f"📦 [{obj.name}](<{obj.html_url}>)"
232+
233+
def render_ogp_cv2(self, obj: githubkit.rest.Repository, *, context: ghretos.Repo) -> disnake.ui.Container:
234+
container = disnake.ui.Container()
235+
text_display = disnake.ui.TextDisplay("")
236+
text_display.content = f"## [{obj.name}](<{obj.html_url}>)\n\n"
237+
if obj.description:
238+
text_display.content += f"{obj.description}\n"
239+
container.children.append(text_display)
240+
if obj.owner:
241+
section = disnake.ui.Section(accessory=disnake.ui.Thumbnail(obj.owner.avatar_url))
242+
else:
243+
section = disnake.ui.Section(accessory=disnake.ui.Thumbnail(constants.Icons.github_avatar_url))
244+
container.children.append(section)
245+
section.children.append(disnake.ui.TextDisplay(f"**Stars:** {obj.stargazers_count}"))
246+
section.children.append(disnake.ui.TextDisplay(f"**Forks:** {obj.forks_count}"))
247+
section.children.append(disnake.ui.TextDisplay(f"**Open Issues:** {obj.open_issues_count}"))
248+
249+
return container
250+
251+
def render_full(
252+
self,
253+
obj: githubkit.rest.Repository,
254+
*,
255+
context: ghretos.Repo,
256+
) -> tuple[str, list[disnake.ui.TextDisplay]]:
257+
text_display = disnake.ui.TextDisplay("")
258+
text_display.content = f"## [{obj.full_name}](<{obj.html_url}>)\n\n"
259+
if obj.description:
260+
text_display.content += f"{obj.description}\n"
261+
text_display.content += f"**Stars:** {obj.stargazers_count}\n"
262+
text_display.content += f"**Forks:** {obj.forks_count}\n"
263+
text_display.content += f"**Open Issues:** {obj.open_issues_count}\n"
264+
text_display.content += f"**Watchers:** {obj.watchers_count}\n"
265+
return obj.full_name, [text_display]
266+
267+
167268
class NumberableRenderer(
168269
GitHubRenderer[githubkit.rest.Issue | githubkit.rest.Discussion, ghretos.Issue | ghretos.NumberedResource]
169270
):
@@ -432,6 +533,7 @@ def render_ogp(
432533

433534

434535
HANDLER_MAPPING: dict[type[ghretos.GitHubResource], type[GitHubRenderer]] = {
536+
# Autolinked objects
435537
ghretos.Issue: NumberableRenderer,
436538
ghretos.PullRequest: NumberableRenderer,
437539
ghretos.Discussion: NumberableRenderer,

0 commit comments

Comments
 (0)