1111from crowdgit .database .crud import (
1212 find_github_identity ,
1313 find_maintainer_identity_by_email ,
14+ get_github_maintainer_usernames_for_repo ,
1415 get_maintainers_for_repo ,
1516 save_service_execution ,
1617 set_maintainer_end_date ,
@@ -153,43 +154,58 @@ def make_role(self, title: str):
153154 )
154155 return slugify (title )
155156
157+ async def _resolve_identity (
158+ self , github_username : str | None , email : str | None
159+ ) -> str | None :
160+ # Fall back to email when github_username is missing/"unknown" — the AI
161+ # extractor emits "unknown" for ~4k entries on the linux MAINTAINERS file.
162+ if github_username and github_username != "unknown" :
163+ identity_id = await find_github_identity (github_username )
164+ if identity_id :
165+ return identity_id
166+ if email and email != "unknown" :
167+ return await find_maintainer_identity_by_email (email )
168+ return None
169+
170+ async def _resolve_maintainers (
171+ self , maintainers : list [MaintainerInfoItem ]
172+ ) -> list [tuple [MaintainerInfoItem , str ]]:
173+ # Shared by first-run and incremental paths so lookup semantics stay identical.
174+ semaphore = asyncio .Semaphore (self .MAX_CONCURRENT_CHUNKS )
175+
176+ async def resolve (m : MaintainerInfoItem ) -> tuple [MaintainerInfoItem , str | None ]:
177+ async with semaphore :
178+ identity_id = await self ._resolve_identity (m .github_username , m .email )
179+ return m , identity_id
180+
181+ results = await asyncio .gather (* [resolve (m ) for m in maintainers ])
182+
183+ resolved : list [tuple [MaintainerInfoItem , str ]] = []
184+ for m , identity_id in results :
185+ if identity_id is None :
186+ self .logger .warning (f"Identity not found for maintainer: { m } " )
187+ continue
188+ resolved .append ((m , identity_id ))
189+ return resolved
190+
156191 async def insert_new_maintainers (
157192 self , repo_url : str , repo_id : str , maintainers : list [MaintainerInfoItem ]
158193 ):
159- async def process_maintainer (maintainer : MaintainerInfoItem ):
160- self .logger .info (f"Processing maintainer: { maintainer .github_username } " )
161- role = maintainer .normalized_title
162- original_role = self .make_role (maintainer .title )
163- # Find the identity in the database
164- github_username = maintainer .github_username
165- email = maintainer .email
166-
167- if github_username == "unknown" and email == "unknown" :
168- self .logger .warning ("username & email with value 'unknown' aborting" )
169- return
170- identity_id = (
171- await find_github_identity (github_username )
172- if github_username != "unknown"
173- else await find_maintainer_identity_by_email (email )
174- )
175- self .logger .debug (
176- f"Found identity_id for { github_username } : { identity_id } (type: { type (identity_id )} )"
177- )
178- if identity_id :
194+ resolved = await self ._resolve_maintainers (maintainers )
195+ # Concurrent upserts: large MAINTAINERS files carry thousands of entries.
196+ semaphore = asyncio .Semaphore (self .MAX_CONCURRENT_CHUNKS )
197+
198+ async def upsert (maintainer : MaintainerInfoItem , identity_id : str ) -> None :
199+ async with semaphore :
200+ role = maintainer .normalized_title
201+ original_role = self .make_role (maintainer .title )
179202 await upsert_maintainer (repo_id , identity_id , repo_url , role , original_role )
180203 self .logger .info (
181- f"Successfully upserted maintainer { github_username } with identity_id { identity_id } "
204+ f"Successfully upserted maintainer { maintainer .github_username } "
205+ f"with identity_id { identity_id } "
182206 )
183- else :
184- self .logger .warning (f"Identity not found for GitHub user: { maintainer } " )
185-
186- semaphore = asyncio .Semaphore (3 )
187-
188- async def process_with_semaphore (maintainer : MaintainerInfoItem ):
189- async with semaphore :
190- await process_maintainer (maintainer )
191207
192- await asyncio .gather (* [process_with_semaphore ( maintainer ) for maintainer in maintainers ])
208+ await asyncio .gather (* [upsert ( m , identity_id ) for m , identity_id in resolved ])
193209
194210 async def compare_and_update_maintainers (
195211 self ,
@@ -200,63 +216,73 @@ async def compare_and_update_maintainers(
200216 ):
201217 self .logger .info (f"Comparing and updating maintainers for repo: { repo_id } " )
202218 current_maintainers = await get_maintainers_for_repo (repo_id )
203- current_maintainers_dict = {m ["github_username" ]: m for m in current_maintainers }
204- new_maintainers_dict = {m .github_username : m for m in maintainers }
205219
206- for github_username , maintainer in new_maintainers_dict .items ():
207- role = maintainer .normalized_title
220+ # Key by (identityId, role) — keying by github_username collapsed every
221+ # "unknown" extraction into one slot, silently dropping most email-only
222+ # maintainers (~4k of 4216 entries on the linux MAINTAINERS file).
223+ current_by_key : dict [tuple [str , str ], dict ] = {
224+ (m ["identityId" ], m ["role" ]): m for m in current_maintainers
225+ }
226+
227+ # Resolve before keying so the comparison is identity-based: the same
228+ # person may extract with different github_username values across runs.
229+ resolved = await self ._resolve_maintainers (maintainers )
230+ new_by_key : dict [tuple [str , str ], MaintainerInfoItem ] = {
231+ (identity_id , m .normalized_title ): m for m , identity_id in resolved
232+ }
233+
234+ for (identity_id , role ), maintainer in new_by_key .items ():
235+ if (identity_id , role ) in current_by_key :
236+ continue
208237 original_role = self .make_role (maintainer .title )
209- if github_username == "unknown" and maintainer .email in ("unknown" , None ):
210- self .logger .warning (
211- f"Skipping unknown github_username & email with title { maintainer .title } "
212- )
238+ await upsert_maintainer (
239+ repo_id , identity_id , repo_url , role , original_role , start_date = change_date
240+ )
241+ self .logger .info (
242+ f"Inserted new maintainer { maintainer .github_username } "
243+ f"with identity_id { identity_id } role { role } "
244+ )
245+
246+ # Safety guard scoped to entries whose identity resolution FAILED this run.
247+ # A maintainer who resolves but ends up under a different (identityId, role)
248+ # — i.e. a role change — must still be end-dated on the old role row, so we
249+ # only protect values from extractor entries that did not resolve. Matching
250+ # is kind-aware so a GitHub username "foo" cannot collide with a same-named
251+ # handle on another platform (different person).
252+ resolved_ids = {id (m ) for m , _ in resolved }
253+ unresolved_usernames : set [str ] = set ()
254+ unresolved_emails : set [str ] = set ()
255+ for m in maintainers :
256+ if id (m ) in resolved_ids :
213257 continue
214- elif github_username not in current_maintainers_dict :
215- # New maintainer
216- identity_id = (
217- await find_github_identity (github_username )
218- if github_username != "unknown"
219- else await find_maintainer_identity_by_email (maintainer .email )
220- )
221- self .logger .info (f"Found new maintainer { github_username } to be inserted" )
222- if identity_id :
223- await upsert_maintainer (
224- repo_id , identity_id , repo_url , role , original_role , start_date = change_date
225- )
226- self .logger .info (
227- f"Successfully inserted new maintainer { github_username } with identity_id { identity_id } "
228- )
229- else :
230- # will happen for new users if their identity isn't created yet but should be fixed on the next iteration
231- self .logger .warning (f"Identity not found for username: { github_username } " )
232- else :
233- # Existing maintainer
234- current_maintainer = current_maintainers_dict [github_username ]
235- if current_maintainer ["role" ] != role :
236- # Role has changed: we update maintainer
237- self .logger .info (
238- f"Role changed from { current_maintainer ['role' ]} to { role } for maintainer { current_maintainer ['identityId' ]} "
239- )
240- await upsert_maintainer (
241- repo_id ,
242- current_maintainer ["identityId" ],
243- repo_url ,
244- role ,
245- original_role ,
246- change_date ,
247- )
258+ if m .github_username and m .github_username != "unknown" :
259+ unresolved_usernames .add (m .github_username .lower ())
260+ if m .email and m .email != "unknown" :
261+ unresolved_emails .add (m .email .lower ())
248262
249- for github_username , current_maintainer in current_maintainers_dict .items ():
250- if github_username not in new_maintainers_dict :
251- self .logger .info (
252- f"Maintainer { github_username } with identity { current_maintainer ['identityId' ]} no longer exists, updating its endDate..."
253- )
254- await set_maintainer_end_date (
255- repo_id ,
256- current_maintainer ["identityId" ],
257- current_maintainer ["role" ],
258- change_date ,
263+ for (identity_id , role ), current in current_by_key .items ():
264+ if (identity_id , role ) in new_by_key :
265+ continue
266+ current_value = (current .get ("identity_value" ) or "" ).lower ()
267+ current_platform = current .get ("platform" )
268+ current_type = current .get ("type" )
269+ is_github_username = current_platform == "github" and current_type == "username"
270+ is_email = current_type == "email"
271+ skip_end_date = bool (current_value ) and (
272+ (is_github_username and current_value in unresolved_usernames )
273+ or (is_email and current_value in unresolved_emails )
274+ )
275+ if skip_end_date :
276+ self .logger .warning (
277+ f"Maintainer with identity { identity_id } role { role } could not be "
278+ f"re-resolved but is still mentioned in the source; skipping end-date"
259279 )
280+ continue
281+ self .logger .info (
282+ f"Maintainer with identity { identity_id } role { role } no longer exists, "
283+ f"updating its endDate..."
284+ )
285+ await set_maintainer_end_date (repo_id , identity_id , role , change_date )
260286
261287 async def save_maintainers (
262288 self ,
@@ -941,13 +967,16 @@ async def exclude_parent_repo_maintainers(
941967 if not parent_repo or not extracted_maintainers :
942968 return extracted_maintainers
943969
944- parent_repo_maintainers = await get_maintainers_for_repo (parent_repo .id )
945- if not parent_repo_maintainers :
946- self .logger .info (f"No maintainers found for parent repo { parent_repo .url } " )
970+ # Dedicated github-username lookup: get_maintainers_for_repo now returns any
971+ # identity type (email-linked rows included), but this filter compares against
972+ # extracted github_username values, so we must narrow to platform='github'/type='username'.
973+ parent_github_usernames = await get_github_maintainer_usernames_for_repo (parent_repo .id )
974+ if not parent_github_usernames :
975+ self .logger .info (
976+ f"No github-username maintainers found for parent repo { parent_repo .url } "
977+ )
947978 return extracted_maintainers
948979
949- parent_github_usernames = {m ["github_username" ] for m in parent_repo_maintainers }
950-
951980 fork_only_maintainers = [
952981 maintainer
953982 for maintainer in extracted_maintainers
0 commit comments