@@ -59,13 +59,21 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None:
5959 self .retriable = retriable
6060
6161
62+ @dataclass (frozen = True )
63+ class ContributionInfo :
64+ login : str
65+ commit_count : int
66+ authored_files : list [str ]
67+
68+
6269@dataclass (frozen = True )
6370class _RepoConfig :
6471 api_base_url : str
6572 fallback_token : str
6673 max_files : int
6774 max_file_bytes : int
6875 timeout_sec : float
76+ max_contrib_commits : int
6977
7078
7179# 리드미, 주요 소스를 읽는다
@@ -78,6 +86,7 @@ def __init__(
7886 max_files : int = 8 ,
7987 max_file_bytes : int = 50_000 ,
8088 timeout_sec : float = 30.0 ,
89+ max_contrib_commits : int = 15 ,
8190 client : httpx .AsyncClient | None = None ,
8291 ) -> None :
8392 self ._cfg = _RepoConfig (
@@ -86,6 +95,7 @@ def __init__(
8695 max_files = max_files ,
8796 max_file_bytes = max_file_bytes ,
8897 timeout_sec = timeout_sec ,
98+ max_contrib_commits = max_contrib_commits ,
8999 )
90100 self ._client = client
91101
@@ -109,12 +119,18 @@ async def extract(
109119 )
110120
111121 effective_token = access_token or self ._cfg .fallback_token
122+ # 기여도 분석은 지원자 본인 token 이 있을 때만 의미 있음(GET /user = 지원자).
123+ has_user_token = bool (access_token )
112124
113125 if self ._client is not None :
114- return await self ._extract_with_client (self ._client , owner_repo )
126+ return await self ._extract_with_client (
127+ self ._client , owner_repo , has_user_token = has_user_token
128+ )
115129
116130 async with self ._build_client (effective_token ) as client :
117- return await self ._extract_with_client (client , owner_repo )
131+ return await self ._extract_with_client (
132+ client , owner_repo , has_user_token = has_user_token
133+ )
118134
119135 def _build_client (self , token : str ) -> httpx .AsyncClient :
120136 headers = {
@@ -133,15 +149,24 @@ async def _extract_with_client(
133149 self ,
134150 client : httpx .AsyncClient ,
135151 owner_repo : str ,
152+ * ,
153+ has_user_token : bool = False ,
136154 ) -> ExtractedSource :
137155 repo_info = await self ._fetch_json (client , f"/repos/{ owner_repo } " )
138156 default_branch = repo_info .get ("default_branch" ) or "main"
139157
158+ contribution : ContributionInfo | None = None
159+ if has_user_token :
160+ contribution = await self ._fetch_contribution (
161+ client , owner_repo , default_branch
162+ )
163+
140164 readme_text = await self ._fetch_readme (client , owner_repo )
141165 tree_paths , truncated = await self ._fetch_tree (
142166 client , owner_repo , default_branch
143167 )
144- picked = _select_files (tree_paths , self ._cfg .max_files )
168+ authored = contribution .authored_files if contribution else []
169+ picked = _select_files (tree_paths , self ._cfg .max_files , prioritized = authored )
145170 snippets = await self ._fetch_snippets (
146171 client , owner_repo , default_branch , picked
147172 )
@@ -151,6 +176,16 @@ async def _extract_with_client(
151176 if desc := repo_info .get ("description" ):
152177 parts .append (f"\n > { desc } " )
153178
179+ # 다중 기여자 레포에서 "지원자가 실제 쓴 코드" 를 구분하기 위한 기여 요약.
180+ if contribution and contribution .commit_count :
181+ parts .append ("\n ## 지원자 기여\n " )
182+ parts .append (
183+ f"- 지원자(@{ contribution .login } ) 커밋 { contribution .commit_count } 개"
184+ )
185+ if contribution .authored_files :
186+ top = contribution .authored_files [:10 ]
187+ parts .append ("- 주요 작성 파일:\n " + "\n " .join (f" - { p } " for p in top ))
188+
154189 if readme_text :
155190 parts .append ("\n ## README\n " )
156191 parts .append (readme_text )
@@ -175,9 +210,64 @@ async def _extract_with_client(
175210 "tree_size" : len (tree_paths ),
176211 "tree_truncated" : truncated ,
177212 "sampled_files" : [p for p , _ in snippets ],
213+ "contributor_login" : contribution .login if contribution else None ,
214+ "contrib_commit_count" : (
215+ contribution .commit_count if contribution else 0
216+ ),
178217 },
179218 )
180219
220+ # 지원자 token 으로 GET /user → login 확보 후, 그 login 이 author 인 커밋을
221+ # 집계해 기여 파일을 추린다. 실패(권한/rate limit/공개레포)는 None 으로 graceful.
222+ async def _fetch_contribution (
223+ self ,
224+ client : httpx .AsyncClient ,
225+ owner_repo : str ,
226+ branch : str ,
227+ ) -> ContributionInfo | None :
228+ try :
229+ me = await self ._fetch_json (client , "/user" )
230+ except RepositoryFetchError as err :
231+ log .warning ("repo.contrib.user_skip" , code = err .code )
232+ return None
233+ login = me .get ("login" ) if isinstance (me , dict ) else None
234+ if not login :
235+ return None
236+
237+ try :
238+ commits = await self ._fetch_json (
239+ client ,
240+ f"/repos/{ owner_repo } /commits"
241+ f"?author={ login } &sha={ branch } &per_page=100" ,
242+ )
243+ except RepositoryFetchError as err :
244+ log .warning ("repo.contrib.commits_skip" , code = err .code )
245+ return None
246+ if not isinstance (commits , list ) or not commits :
247+ return ContributionInfo (login = login , commit_count = 0 , authored_files = [])
248+
249+ file_counter : dict [str , int ] = {}
250+ for commit in commits [: self ._cfg .max_contrib_commits ]:
251+ sha = commit .get ("sha" ) if isinstance (commit , dict ) else None
252+ if not sha :
253+ continue
254+ try :
255+ detail = await self ._fetch_json (
256+ client , f"/repos/{ owner_repo } /commits/{ sha } "
257+ )
258+ except RepositoryFetchError :
259+ continue
260+ files = detail .get ("files" ) if isinstance (detail , dict ) else None
261+ for f in files or []:
262+ path = f .get ("filename" ) if isinstance (f , dict ) else None
263+ if path :
264+ file_counter [path ] = file_counter .get (path , 0 ) + 1
265+
266+ authored = sorted (file_counter , key = lambda p : file_counter [p ], reverse = True )
267+ return ContributionInfo (
268+ login = login , commit_count = len (commits ), authored_files = authored
269+ )
270+
181271 async def _fetch_json (self , client : httpx .AsyncClient , path : str ) -> dict :
182272 resp = await client .get (path )
183273 if resp .status_code == 404 :
@@ -267,10 +357,26 @@ async def _fetch_snippets(
267357 return out
268358
269359
270- def _select_files (paths : list [str ], cap : int ) -> list [str ]:
271- """우선순위 → 디렉토리당 대표 텍스트 파일 1개씩, 합쳐서 cap까지."""
360+ def _select_files (
361+ paths : list [str ], cap : int , * , prioritized : list [str ] | None = None
362+ ) -> list [str ]:
363+ """지원자 기여 파일 → 설정파일 우선순위 → 디렉토리당 대표 텍스트 파일, cap까지."""
272364 chosen : list [str ] = []
273365 seen : set [str ] = set ()
366+ tree = set (paths )
367+
368+ # 지원자가 실제 작성한 텍스트 파일을 가장 먼저 (트리에 존재 + 텍스트 확장자).
369+ for p in prioritized or []:
370+ if p in seen or p not in tree :
371+ continue
372+ if "." not in p .rsplit ("/" , 1 )[- 1 ]:
373+ continue
374+ if ("." + p .rsplit ("." , 1 )[- 1 ].lower ()) not in _TEXT_EXTENSIONS :
375+ continue
376+ chosen .append (p )
377+ seen .add (p )
378+ if len (chosen ) >= cap :
379+ return chosen
274380
275381 for needle in _PRIORITY_FILES :
276382 for p in paths :
0 commit comments