Skip to content

Commit 6f4302b

Browse files
feat: use github search for issue and repo linking
1 parent c82adae commit 6f4302b

4 files changed

Lines changed: 149 additions & 36 deletions

File tree

monty/exts/info/github/_handlers.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,17 +259,24 @@ def render_full(
259259
return obj.name or obj.login, [text_display]
260260

261261

262-
class RepoRenderer(GitHubRenderer[githubkit.rest.Repository | githubkit.rest.FullRepository, ghretos.Repo]):
262+
class RepoRenderer(
263+
GitHubRenderer[
264+
githubkit.rest.Repository | githubkit.rest.FullRepository | githubkit.rest.RepoSearchResultItem, ghretos.Repo
265+
]
266+
):
263267
def render_tiny(
264268
self,
265-
obj: githubkit.rest.Repository | githubkit.rest.FullRepository,
269+
obj: githubkit.rest.Repository | githubkit.rest.FullRepository | githubkit.rest.RepoSearchResultItem,
266270
*,
267271
context: ghretos.Repo,
268272
) -> str:
269273
return f"📦 [{obj.name}](<{obj.html_url}>)"
270274

271275
def render_ogp_cv2(
272-
self, obj: githubkit.rest.Repository | githubkit.rest.FullRepository, *, context: ghretos.Repo
276+
self,
277+
obj: githubkit.rest.Repository | githubkit.rest.FullRepository | githubkit.rest.RepoSearchResultItem,
278+
*,
279+
context: ghretos.Repo,
273280
) -> disnake.ui.Container:
274281
container = disnake.ui.Container()
275282
text_display = disnake.ui.TextDisplay("")
@@ -290,7 +297,7 @@ def render_ogp_cv2(
290297

291298
def render_full(
292299
self,
293-
obj: githubkit.rest.Repository | githubkit.rest.FullRepository,
300+
obj: githubkit.rest.Repository | githubkit.rest.FullRepository | githubkit.rest.RepoSearchResultItem,
294301
*,
295302
context: ghretos.Repo,
296303
) -> tuple[str, list[disnake.ui.TextDisplay]]:

monty/exts/info/github/cog.py

Lines changed: 115 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import asyncio
22
import functools
3+
import json
34
import operator
5+
import pathlib
46
import random
57
import re
68
from collections.abc import Mapping
7-
from typing import Any
9+
from typing import Any, TypedDict
810

911
import cachingutils
1012
import disnake
1113
import ghretos
1214
import githubkit
1315
import githubkit.exception
16+
import githubkit.rest
1417
from disnake.ext import commands
18+
from typing_extensions import Protocol, Required, runtime_checkable
1519

1620
import monty.utils.services
1721
from monty import constants
@@ -46,6 +50,20 @@
4650
log = get_logger(__name__)
4751

4852

53+
class GitHubShorthandAliases(TypedDict, total=False):
54+
owner: Required[str]
55+
repo: Required[str]
56+
alias_for_issues: bool
57+
"""Whether this shorthand can be used for issues/PRs in addition to repos."""
58+
59+
60+
@runtime_checkable
61+
class ImplementsRepository(Protocol):
62+
"""Protocol for GitHub models that implement a repository."""
63+
64+
repo: ghretos.Repo
65+
66+
4967
class GithubInfo(
5068
commands.Cog,
5169
name="GitHub Information",
@@ -59,6 +77,15 @@ class GithubInfo(
5977
def __init__(self, bot: Monty) -> None:
6078
self.bot = bot
6179
self.client: GitHubFetcher = GitHubFetcher(bot.github)
80+
self.short_repos: dict[str, GitHubShorthandAliases] = json.loads(
81+
pathlib.Path("monty/resources/repo_aliases.json").read_text()
82+
)
83+
84+
# Validate casefold.
85+
assert all(
86+
repo == repo.casefold() and value.get("repo") and value.get("owner")
87+
for repo, value in self.short_repos.items()
88+
), "Repository shorthand keys must be casefolded and include exactly one `/` in the data file."
6289

6390
self.autolink_cache: cachingutils.MemoryCache[
6491
int, tuple[disnake.Message, dict[ghretos.GitHubResource, github_handlers.InfoSize]]
@@ -81,6 +108,40 @@ async def fetch_resource(
81108
"""Fetch a GitHub resource."""
82109
return await self.client.fetch_resource(resource)
83110

111+
async def resolve_repo(
112+
self,
113+
repo: ghretos.Repo,
114+
*,
115+
default_user: str | None = None,
116+
) -> ghretos.Repo:
117+
"""Resolve the owner of a GitHub repository."""
118+
if repo.owner:
119+
return repo
120+
if repo.name in self.short_repos:
121+
return ghretos.Repo(
122+
owner=self.short_repos[repo.name]["owner"],
123+
name=self.short_repos[repo.name]["repo"],
124+
)
125+
r = await self.bot.github.rest.search.async_repos(q=(repo.name + " is:public"), per_page=20, order="desc")
126+
for repo_data in r.parsed_data.items:
127+
if repo_data.name.casefold() == repo.name.casefold():
128+
break
129+
130+
else:
131+
# TODO: Check if the repository belongs to the default user before returning
132+
# Fallback to the default user if provided
133+
if default_user:
134+
return ghretos.Repo(owner=default_user, name=repo.name)
135+
msg = "GitHub repository not found."
136+
raise commands.UserInputError(msg)
137+
138+
user, repo_name = repo_data.full_name.split("/", 1)
139+
140+
if not isinstance(repo_data, (githubkit.rest.RepoSearchResultItem)):
141+
msg = "Could not resolve repository owner."
142+
raise ValueError(msg)
143+
return ghretos.Repo(owner=user, name=repo_name)
144+
84145
async def get_full_reply(
85146
self,
86147
resource: ghretos.GitHubResource,
@@ -142,15 +203,15 @@ async def get_reply(
142203
if not handler:
143204
continue
144205

145-
if match_repo := getattr(match, "repo", None):
206+
if isinstance(match, ImplementsRepository):
146207
if repo is None:
147-
repo = match_repo.name.casefold()
148-
if repo != match_repo.name.casefold():
208+
repo = match.repo.name.casefold()
209+
if repo != match.repo.name.casefold():
149210
repo = True # multiple repos
150211
if owner is not True:
151212
if owner is None:
152-
owner = match_repo.owner.casefold()
153-
if owner != match_repo.owner.casefold():
213+
owner = match.repo.owner.casefold()
214+
if owner != match.repo.owner.casefold():
154215
owner = True # multiple owners
155216

156217
# Run resource validation
@@ -171,15 +232,15 @@ async def get_reply(
171232
)
172233
continue # skip invalid data
173234

174-
if match != reparsed and (match_repo := getattr(reparsed, "repo", None)):
235+
if match != reparsed and isinstance(reparsed, ImplementsRepository):
175236
if repo is None:
176-
repo = match_repo.name.casefold()
177-
if repo != match_repo.name.casefold():
237+
repo = reparsed.repo.name.casefold()
238+
if repo != reparsed.repo.name.casefold():
178239
repo = True # multiple repos
179240
if owner is not True:
180241
if owner is None:
181-
owner = match_repo.owner.casefold()
182-
if owner != match_repo.owner.casefold():
242+
owner = reparsed.repo.owner.casefold()
243+
if owner != reparsed.repo.owner.casefold():
183244
owner = True # multiple owners
184245

185246
match = reparsed # use the reparsed version for more accurate data
@@ -287,11 +348,22 @@ async def parse_contents(
287348
for segment in context.text.split():
288349
match = ghretos.parse_shorthand(
289350
segment,
290-
default_user=default_user,
351+
allow_optional_user=True,
291352
settings=settings,
292353
)
293-
if match is not None:
294-
matches[match] = github_handlers.InfoSize.TINY
354+
if match is None:
355+
continue
356+
# resolve the repo owner if needed
357+
try:
358+
if isinstance(match, ghretos.Repo) and not match.owner:
359+
match = await self.resolve_repo(match, default_user=default_user)
360+
elif isinstance(match, ImplementsRepository) and not match.repo.owner:
361+
object.__setattr__(match, "repo", await self.resolve_repo(match.repo, default_user=default_user))
362+
except commands.UserInputError:
363+
continue
364+
365+
matches[match] = github_handlers.InfoSize.TINY
366+
295367
for url in context.urls:
296368
match = ghretos.parse_url(
297369
url,
@@ -403,32 +475,43 @@ async def github_user(self, ctx: commands.Context, user: str) -> None:
403475
)
404476
await ctx.send(components=components)
405477

406-
@github_group.command(name="repo", description="Fetch GitHub repository information.")
478+
@github_group.command(name="repo", aliases=("repository", "repo_info"))
407479
async def github_repo(self, ctx: commands.Context, user_and_repo: str, repo: str = "") -> None:
408480
"""Fetch GitHub repository information."""
409481
# validate the repo
482+
await ctx.trigger_typing()
483+
obj: githubkit.rest.FullRepository | githubkit.rest.RepoSearchResultItem | None = None
410484
if user_and_repo.count("/") == 1 and not repo:
411485
user, repo = user_and_repo.split("/", 1)
486+
elif user_and_repo.count("/") > 1:
487+
msg = "Invalid repository format. Please use `user/repo`."
488+
raise commands.BadArgument(msg)
412489
else:
413-
user = user_and_repo
490+
repo = user_and_repo
491+
# Resolve the user from the repo name when possible
492+
repo_shorthand = await self.resolve_repo(ghretos.Repo(owner="", name=repo))
493+
user = repo_shorthand.owner
494+
repo = repo_shorthand.name
495+
414496
if not repo:
415497
msg = "Repository name is required."
416-
raise commands.UserInputError(msg)
498+
raise commands.BadArgument(msg)
417499
if not re.fullmatch(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,38})$", user):
418500
msg = "Invalid GitHub username."
419-
raise commands.UserInputError(msg)
501+
raise commands.BadArgument(msg)
420502
if not re.fullmatch(r"^[\w\-\.]{1,100}$", repo):
421503
msg = "Invalid GitHub repository name."
422-
raise commands.UserInputError(msg)
504+
raise commands.BadArgument(msg)
423505

424506
context = ghretos.Repo(owner=user, name=repo)
425507
try:
426508
obj = await self.client.fetch_repo(owner=user, repo=repo)
427509
except githubkit.exception.RequestFailed as e:
428510
if e.response.status_code == 404:
429511
msg = "GitHub repository not found."
430-
raise commands.UserInputError(msg) from e
512+
raise commands.BadArgument(msg) from e
431513
raise
514+
432515
components: list[disnake.ui.Container | disnake.ui.ActionRow] = []
433516
components.append(github_handlers.RepoRenderer().render_ogp_cv2(obj, context=context))
434517
components.append(
@@ -475,27 +558,28 @@ async def slash_github_info(self, inter: disnake.ApplicationCommandInteraction,
475558
Parameters
476559
----------
477560
arg: str
478-
The GitHub resource(s) to fetch information about. Can be a URL or shorthand like
561+
The GitHub resource to view . Can be a URL or shorthand like owner/repo#issue_number.
479562
"""
480563
context = MessageContext(arg)
481564
matches: dict[ghretos.GitHubResource, github_handlers.InfoSize] = {}
482565

483566
settings = self.get_command_matcher_settings()
484567
for segment in context.text.split():
485-
match = ghretos.parse_shorthand(segment, settings=settings)
568+
match = ghretos.parse_shorthand(segment, settings=settings, allow_optional_user=True)
486569
if match is not None:
570+
if isinstance(match, ghretos.Repo) and not match.owner:
571+
match = await self.resolve_repo(match)
572+
elif isinstance(match, ImplementsRepository) and not match.repo.owner:
573+
object.__setattr__(match, "repo", await self.resolve_repo(match.repo))
487574
matches[match] = github_handlers.InfoSize.OGP
488575
for url in context.urls:
489576
match = ghretos.parse_url(url, settings=settings)
490577
if match is not None:
491578
matches[match] = github_handlers.InfoSize.OGP
492579

493580
if not matches:
494-
await inter.response.send_message(
495-
f"{constants.Emojis.decline} No GitHub resources found in input.",
496-
ephemeral=True,
497-
)
498-
return
581+
msg = "Could not parse any GitHub resources from input. Provide either a GitHub URL or supported shorthand."
582+
raise commands.BadArgument(msg)
499583

500584
def sort_key(item: ghretos.GitHubResource) -> tuple:
501585
try:
@@ -508,11 +592,11 @@ def sort_key(item: ghretos.GitHubResource) -> tuple:
508592
data = await self.get_reply(matches, limit=650, settings=settings)
509593

510594
if not data:
511-
await inter.response.send_message(
512-
f"{constants.Emojis.decline} Could not fetch any GitHub resources from input.",
513-
ephemeral=True,
595+
msg = (
596+
"Could not fetch information for the provided GitHub resources. "
597+
"Please check your input and be sure they exist."
514598
)
515-
return
599+
raise commands.BadArgument(msg)
516600

517601
components: list[disnake.ui.ActionRow] = []
518602
components.append(

monty/resources/repo_aliases.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"k8s": {
3+
"owner": "kubernetes",
4+
"repo": "kubernetes",
5+
"alias_for_issues": true
6+
},
7+
"python": {
8+
"owner": "python",
9+
"repo": "cpython",
10+
"alias_for_issues": true
11+
},
12+
"monty": {
13+
"owner": "onerandomusername",
14+
"repo": "monty-python",
15+
"alias_for_issues": false
16+
},
17+
"monty-python": {
18+
"owner": "onerandomusername",
19+
"repo": "monty-python",
20+
"alias_for_issues": true
21+
}
22+
}

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)