Skip to content

Commit d4833ae

Browse files
joanagmaiaclaude
andauthored
fix: link email-only maintainers on incremental runs (DE-980) (#4277)
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 17c0bc9 commit d4833ae

2 files changed

Lines changed: 146 additions & 89 deletions

File tree

services/apps/git_integration/src/crowdgit/database/crud.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ async def find_github_identity(github_username: str):
334334
WHERE
335335
platform = 'github'
336336
AND value = $1
337+
AND "verified" = TRUE
337338
AND "deletedAt" is null
338339
LIMIT 1
339340
"""
@@ -405,18 +406,45 @@ async def update_maintainer_run(repo_id: str, maintainer_file: str):
405406

406407

407408
async def get_maintainers_for_repo(repo_id: str):
409+
# Active rows only (endDate IS NULL) — reappearing maintainers hit the "new"
410+
# branch and get reactivated by upsert_maintainer's ON CONFLICT clause.
411+
# verified=TRUE mirrors find_github_identity / find_maintainer_identity_by_email.
412+
# platform/type are returned so the diff's safety guard can match identifiers
413+
# by kind and avoid cross-platform value collisions (e.g. a GitHub username
414+
# "foo" colliding with a same-named handle on another platform).
408415
maintainers_sql_query = """
409-
SELECT mi.role, mi."originalRole", mi."repoUrl", mi."repoId", mi."identityId", mem.value as github_username
416+
SELECT mi.role, mi."originalRole", mi."repoUrl", mi."repoId", mi."identityId",
417+
mem.value as identity_value, mem.platform, mem.type
410418
FROM "maintainersInternal" mi
411419
JOIN "memberIdentities" mem ON mi."identityId" = mem.id
412-
WHERE mi."repoId" = $1 AND mem.platform = 'github' AND mem.type = 'username' and mem.verified = True AND mem."deletedAt" is null
420+
WHERE mi."repoId" = $1
421+
AND mi."endDate" IS NULL
422+
AND mem."verified" = TRUE
423+
AND mem."deletedAt" is null
413424
"""
414425
return await query(
415426
maintainers_sql_query,
416427
(repo_id,),
417428
)
418429

419430

431+
async def get_github_maintainer_usernames_for_repo(repo_id: str) -> set[str]:
432+
"""Return GitHub usernames of active maintainers for fork/parent-repo filtering."""
433+
sql_query = """
434+
SELECT mem.value
435+
FROM "maintainersInternal" mi
436+
JOIN "memberIdentities" mem ON mi."identityId" = mem.id
437+
WHERE mi."repoId" = $1
438+
AND mi."endDate" IS NULL
439+
AND mem.platform = 'github'
440+
AND mem.type = 'username'
441+
AND mem."verified" = TRUE
442+
AND mem."deletedAt" is null
443+
"""
444+
rows = await query(sql_query, (repo_id,))
445+
return {row["value"] for row in rows}
446+
447+
420448
async def set_maintainer_end_date(
421449
repo_id: str, identity_id: str, role: str, change_date: datetime
422450
):

services/apps/git_integration/src/crowdgit/services/maintainer/maintainer_service.py

Lines changed: 116 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from 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

Comments
 (0)