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 Required
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+
4960class GithubInfo (
5061 commands .Cog ,
5162 name = "GitHub Information" ,
@@ -59,6 +70,15 @@ class GithubInfo(
5970 def __init__ (self , bot : Monty ) -> None :
6071 self .bot = bot
6172 self .client : GitHubFetcher = GitHubFetcher (bot .github )
73+ self .short_repos : dict [str , GitHubShorthandAliases ] = json .loads (
74+ pathlib .Path ("monty/resources/repo_aliases.json" ).read_text ()
75+ )
76+
77+ # Validate casefold.
78+ assert all (
79+ repo == repo .casefold () and value .get ("repo" ) and value .get ("owner" )
80+ for repo , value in self .short_repos .items ()
81+ ), "Repository shorthand keys must be casefolded and include exactly one `/` in the data file."
6282
6383 self .autolink_cache : cachingutils .MemoryCache [
6484 int , tuple [disnake .Message , dict [ghretos .GitHubResource , github_handlers .InfoSize ]]
@@ -81,6 +101,40 @@ async def fetch_resource(
81101 """Fetch a GitHub resource."""
82102 return await self .client .fetch_resource (resource )
83103
104+ async def resolve_repo (
105+ self ,
106+ repo : ghretos .Repo ,
107+ * ,
108+ default_user : str | None = None ,
109+ ) -> ghretos .Repo :
110+ """Resolve the owner of a GitHub repository."""
111+ if repo .owner :
112+ return repo
113+ if repo .name in self .short_repos :
114+ return ghretos .Repo (
115+ owner = self .short_repos [repo .name ]["owner" ],
116+ name = self .short_repos [repo .name ]["repo" ],
117+ )
118+ r = await self .bot .github .rest .search .async_repos (q = (repo .name + " is:public" ), per_page = 20 , order = "desc" )
119+ for repo_data in r .parsed_data .items :
120+ if repo_data .name .casefold () == repo .name .casefold ():
121+ break
122+
123+ else :
124+ # TODO: Check if the repository belongs to the default user before returning
125+ # Fallback to the default user if provided
126+ if default_user :
127+ return ghretos .Repo (owner = default_user , name = repo .name )
128+ msg = "GitHub repository not found."
129+ raise commands .UserInputError (msg )
130+
131+ user , repo_name = repo_data .full_name .split ("/" , 1 )
132+
133+ if not isinstance (repo_data , (githubkit .rest .RepoSearchResultItem )):
134+ msg = "Could not resolve repository owner."
135+ raise ValueError (msg )
136+ return ghretos .Repo (owner = user , name = repo_name )
137+
84138 async def get_full_reply (
85139 self ,
86140 resource : ghretos .GitHubResource ,
@@ -287,11 +341,22 @@ async def parse_contents(
287341 for segment in context .text .split ():
288342 match = ghretos .parse_shorthand (
289343 segment ,
290- default_user = default_user ,
344+ allow_optional_user = True ,
291345 settings = settings ,
292346 )
293- if match is not None :
294- matches [match ] = github_handlers .InfoSize .TINY
347+ if match is None :
348+ continue
349+ # resolve the repo owner if needed
350+ try :
351+ if isinstance (match , ghretos .Repo ) and not match .owner :
352+ match = await self .resolve_repo (match , default_user = default_user )
353+ elif getattr (match , "repo" , None ) and not match .repo .owner :
354+ object .__setattr__ (match , "repo" , await self .resolve_repo (match .repo , default_user = default_user ))
355+ except commands .UserInputError :
356+ continue
357+
358+ matches [match ] = github_handlers .InfoSize .TINY
359+
295360 for url in context .urls :
296361 match = ghretos .parse_url (
297362 url ,
@@ -403,14 +468,21 @@ async def github_user(self, ctx: commands.Context, user: str) -> None:
403468 )
404469 await ctx .send (components = components )
405470
406- @github_group .command (name = "repo" , description = "Fetch GitHub repository information." )
471+ @github_group .command (name = "repo" , aliases = ( " repository" , "repo_info" ) )
407472 async def github_repo (self , ctx : commands .Context , user_and_repo : str , repo : str = "" ) -> None :
408473 """Fetch GitHub repository information."""
409474 # validate the repo
475+ await ctx .trigger_typing ()
476+ obj : githubkit .rest .FullRepository | githubkit .rest .RepoSearchResultItem | None = None
410477 if user_and_repo .count ("/" ) == 1 and not repo :
411478 user , repo = user_and_repo .split ("/" , 1 )
412479 else :
413- user = user_and_repo
480+ repo = user_and_repo
481+ # Resolve the user from the repo name when possible
482+ repo_shorthand = await self .resolve_repo (ghretos .Repo (owner = "" , name = repo ))
483+ user = repo_shorthand .owner
484+ repo = repo_shorthand .name
485+
414486 if not repo :
415487 msg = "Repository name is required."
416488 raise commands .UserInputError (msg )
@@ -429,6 +501,7 @@ async def github_repo(self, ctx: commands.Context, user_and_repo: str, repo: str
429501 msg = "GitHub repository not found."
430502 raise commands .UserInputError (msg ) from e
431503 raise
504+
432505 components : list [disnake .ui .Container | disnake .ui .ActionRow ] = []
433506 components .append (github_handlers .RepoRenderer ().render_ogp_cv2 (obj , context = context ))
434507 components .append (
0 commit comments