11import asyncio
22import functools
3+ import json
34import operator
5+ import pathlib
46import random
57import re
68from collections .abc import Mapping
7- from typing import Any
9+ from typing import Any , TypedDict
810
911import cachingutils
1012import disnake
1113import ghretos
1214import githubkit
1315import githubkit .exception
16+ import githubkit .rest
1417from disnake .ext import commands
18+ from typing_extensions import Protocol , Required , runtime_checkable
1519
1620import monty .utils .services
1721from monty import constants
4650log = 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+
4967class 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 (
0 commit comments