@@ -272,14 +272,9 @@ async def pick_affiliation_file_with_ai(
272272
273273 total_cost = 0.0
274274 batch_size = self .FILE_PICKER_BATCH_SIZE
275- total_batches = (len (candidates ) + batch_size - 1 ) // batch_size
276275
277- for batch_index , batch_start in enumerate ( range (0 , len (candidates ), batch_size ), start = 1 ):
276+ for batch_start in range (0 , len (candidates ), batch_size ):
278277 batch = candidates [batch_start : batch_start + batch_size ]
279- self .logger .debug (
280- f"Picking affiliation file with AI "
281- f"(batch { batch_index } /{ total_batches } , { len (batch )} candidates)"
282- )
283278 candidates_with_previews = await self .format_candidates_with_previews (repo_path , batch )
284279 prompt = self .get_file_picker_prompt (
285280 repo_url ,
@@ -292,9 +287,7 @@ async def pick_affiliation_file_with_ai(
292287 if result .output .file_name is not None :
293288 picked_path = result .output .file_name
294289 if picked_path not in batch :
295- self .logger .debug (
296- f"AI picked path not in candidate batch, skipping: { picked_path !r} "
297- )
290+ self .logger .debug (f"AI picked invalid path, skipping: { picked_path !r} " )
298291 continue
299292 full_path = os .path .join (repo_path , picked_path )
300293 if not await aiofiles .os .path .isfile (full_path ):
@@ -316,25 +309,19 @@ async def discover_affiliation_file(
316309 ai_cost = 0.0
317310
318311 matches = await self .find_known_file_matches (repo_path )
319- self .logger .debug (f"Known filename matches: { len (matches )} " )
320312
321313 if len (matches ) == 1 :
322- self .logger .info (f"Affiliation file: { matches [0 ]} " )
323- return matches [0 ], ai_cost
314+ only_match = matches [0 ]
315+ if self .is_text_file_path (only_match ):
316+ self .logger .info (f"Affiliation file: { only_match } " )
317+ return only_match , ai_cost
324318
325319 if len (matches ) > 1 :
326320 candidates = [path for path in matches if self .is_text_file_path (path )]
327321 root_files_only = False
328- if len (matches ) != len (candidates ):
329- self .logger .debug (
330- f"Skipped { len (matches ) - len (candidates )} known matches with non-text extensions"
331- )
332322 else :
333323 candidates = await self .list_root_text_files (repo_path )
334324 root_files_only = True
335- self .logger .debug (
336- f"No known filename matches, checking { len (candidates )} repo root files with AI"
337- )
338325
339326 if not candidates :
340327 return None , ai_cost
@@ -360,7 +347,6 @@ async def resolve_affiliation_file(
360347 if saved_file_path :
361348 saved_on_disk = os .path .join (repo_path , saved_file_path )
362349 if await aiofiles .os .path .isfile (saved_on_disk ):
363- self .logger .debug (f"Using saved affiliation file: { saved_file_path } " )
364350 return saved_file_path , 0.0
365351 self .logger .info ("Saved affiliation file is missing, looking for a new one" )
366352
@@ -483,7 +469,6 @@ async def parse_affiliations(
483469 if not affiliations :
484470 return [], parse_result .cost
485471
486- raw_count = len (affiliations )
487472 normalized = self .normalize_parsed_affiliations (affiliations )
488473
489474 if not normalized :
@@ -492,11 +477,6 @@ async def parse_affiliations(
492477 error_message = "Affiliation file had rows but none were usable" ,
493478 )
494479
495- if len (normalized ) < raw_count :
496- self .logger .debug (
497- f"Dropped { raw_count - len (normalized )} rows missing email, github, or domain"
498- )
499-
500480 return normalized , parse_result .cost
501481
502482 if parse_result .output .error == "not_found" :
@@ -506,7 +486,6 @@ async def parse_affiliations(
506486 error_message = "Unexpected response while parsing the affiliation file" ,
507487 )
508488
509- self .logger .debug ("Affiliation file is large, parsing in chunks" )
510489 chunks : list [str ] = []
511490 remaining = content
512491 while remaining :
@@ -542,7 +521,6 @@ async def process_chunk(chunk_index: int, chunk: str):
542521 total_cost += chunk_result .cost
543522
544523 if affiliations :
545- raw_count = len (affiliations )
546524 normalized = self .normalize_parsed_affiliations (affiliations )
547525
548526 if not normalized :
@@ -551,11 +529,6 @@ async def process_chunk(chunk_index: int, chunk: str):
551529 error_message = "Affiliation file had rows but none were usable" ,
552530 )
553531
554- if len (normalized ) < raw_count :
555- self .logger .debug (
556- f"Dropped { raw_count - len (normalized )} rows missing email, github, or domain"
557- )
558-
559532 return normalized , total_cost
560533
561534 return [], total_cost
@@ -577,13 +550,11 @@ async def resolve_snapshot(
577550
578551 if not needs_parse :
579552 if not existing_snapshot :
580- self .logger .debug ("Using cached empty snapshot, file unchanged" )
581553 return [], 0.0
582554
583555 applyable = self .normalize_parsed_affiliations (existing_snapshot )
584556
585557 if applyable :
586- self .logger .debug ("Using cached snapshot, file unchanged" )
587558 return applyable , 0.0
588559
589560 self .logger .info ("Cached snapshot had no usable rows, reparsing file" )
@@ -598,26 +569,17 @@ async def check_if_interval_elapsed(self, registry: dict | None) -> tuple[bool,
598569 Repos with a saved file use the update interval; repos still searching use the retry interval.
599570 """
600571 if registry is None or registry .get ("last_run_at" ) is None :
601- self .logger .debug ("First affiliation run for this repo" )
602572 return True , 0.0
603573
604574 time_since_last_run = datetime .now (timezone .utc ) - registry ["last_run_at" ]
605575 hours_since_last_run = time_since_last_run .total_seconds () / 3600
606576
607577 if registry .get ("file_path" ):
608578 remaining_hours = max (0 , AFFILIATION_UPDATE_INTERVAL_HOURS - hours_since_last_run )
609- self .logger .debug (
610- f"Last run { hours_since_last_run :.1f} h ago, "
611- f"update interval is { AFFILIATION_UPDATE_INTERVAL_HOURS } h"
612- )
613579 return hours_since_last_run >= AFFILIATION_UPDATE_INTERVAL_HOURS , remaining_hours
614580
615581 required_hours = AFFILIATION_RETRY_INTERVAL_DAYS * 24
616582 remaining_hours = max (0 , required_hours - hours_since_last_run )
617- self .logger .debug (
618- f"Last run { hours_since_last_run :.1f} h ago, "
619- f"retry interval is { AFFILIATION_RETRY_INTERVAL_DAYS } d"
620- )
621583 return hours_since_last_run >= required_hours , remaining_hours
622584
623585 @staticmethod
@@ -627,6 +589,63 @@ def is_undated_or_open_ended(date_start, date_end) -> bool:
627589 return True
628590 return date_start is not None and date_end is None
629591
592+ @staticmethod
593+ def affiliation_identity_key (item : AffiliationInfoItem ) -> tuple [str , str , str ] | None :
594+ domain = item .organization .domain
595+ if not domain :
596+ return None
597+ domain = domain .lower ()
598+ if item .contributor .github :
599+ return ("github" , item .contributor .github .lower (), domain )
600+ if item .contributor .email :
601+ return ("email" , item .contributor .email .lower (), domain )
602+ return None
603+
604+ async def exclude_parent_repo_affiliations (
605+ self ,
606+ parent_repo : Repository ,
607+ extracted_affiliations : list [AffiliationInfoItem ] | None ,
608+ ) -> list [AffiliationInfoItem ] | None :
609+ if not parent_repo or not extracted_affiliations :
610+ return extracted_affiliations
611+
612+ parent_registry = await get_repo_affiliation_registry (parent_repo .id )
613+ parent_repo_affiliations = (
614+ parent_registry .get ("snapshot" ) if parent_registry else None
615+ ) or []
616+ if not parent_repo_affiliations :
617+ return extracted_affiliations
618+
619+ parent_affiliation_keys = {
620+ key
621+ for item in parent_repo_affiliations
622+ if (key := self .affiliation_identity_key (item )) is not None
623+ }
624+
625+ fork_only_affiliations = [
626+ affiliation
627+ for affiliation in extracted_affiliations
628+ if (key := self .affiliation_identity_key (affiliation )) is None
629+ or key not in parent_affiliation_keys
630+ ]
631+
632+ return fork_only_affiliations
633+
634+ @staticmethod
635+ def resolve_registry_status (
636+ affiliations : list [AffiliationInfoItem ],
637+ registry : dict | None ,
638+ file_hash : str ,
639+ ) -> str :
640+ if (
641+ registry
642+ and registry .get ("status" ) == AffiliationRegistryStatus .UNUSABLE .value
643+ and registry .get ("file_hash" ) == file_hash
644+ and not affiliations
645+ ):
646+ return AffiliationRegistryStatus .UNUSABLE .value
647+ return AffiliationRegistryStatus .SUCCESS .value
648+
630649 def has_undated_affiliation_for_org (
631650 self , existing_rows : list [dict ], organization_id : str
632651 ) -> bool :
@@ -702,17 +721,14 @@ async def apply_affiliations(
702721
703722 unique_pairs : list [tuple [str , str ]] = []
704723 seen_pairs : set [tuple [str , str ]] = set ()
705- skipped_unresolved = 0
706724
707725 for member_idx , org_idx in row_identity_refs :
708726 if member_idx is None or org_idx is None :
709- skipped_unresolved += 1
710727 continue
711728
712729 member_id = resolved_members [member_idx ].get ("member_id" )
713730 organization_id = resolved_organizations [org_idx ].get ("organization_id" )
714731 if not member_id or not organization_id :
715- skipped_unresolved += 1
716732 continue
717733
718734 pair = (member_id , organization_id )
@@ -722,9 +738,7 @@ async def apply_affiliations(
722738 unique_pairs .append (pair )
723739
724740 if not unique_pairs :
725- self .logger .debug (
726- f"No member/org pairs resolved ({ skipped_unresolved } rows could not be matched)"
727- )
741+ self .logger .debug ("No member/org pairs resolved" )
728742 return
729743
730744 member_ids_to_fetch = list ({member_id for member_id , _ in unique_pairs })
@@ -765,6 +779,7 @@ async def apply_affiliations(
765779 # await insert_member_organizations(mo_inserts)
766780 # await insert_member_segment_affiliations(msa_inserts)
767781
782+ # TODO: Remove this after testing
768783 self .logger .debug (
769784 f"Apply dry run: { len (mo_inserts )} MO and { len (msa_inserts )} MSA rows ready to write"
770785 )
@@ -822,13 +837,18 @@ async def process_affiliations(
822837 )
823838 ai_cost += parse_cost
824839
840+ if repository .parent_repo :
841+ affiliations = await self .exclude_parent_repo_affiliations (
842+ repository .parent_repo , affiliations
843+ )
844+
825845 await self .apply_affiliations (repository , affiliations )
826846
827847 await upsert_repo_affiliation_registry (
828848 repository .id ,
829849 file_path = latest_file_path ,
830850 file_hash = file_hash ,
831- status = AffiliationRegistryStatus . SUCCESS . value ,
851+ status = self . resolve_registry_status ( affiliations , registry , file_hash ) ,
832852 snapshot = affiliations ,
833853 )
834854
@@ -854,7 +874,11 @@ async def process_affiliations(
854874 repository .id ,
855875 file_path = latest_file_path ,
856876 file_hash = latest_file_hash if e .retain_file_hash else None ,
857- status = AffiliationRegistryStatus .ERROR .value ,
877+ status = (
878+ AffiliationRegistryStatus .UNUSABLE .value
879+ if e .retain_file_hash
880+ else AffiliationRegistryStatus .ERROR .value
881+ ),
858882 snapshot = []
859883 if e .retain_file_hash
860884 else (registry .get ("snapshot" ) if registry else None ),
@@ -877,7 +901,7 @@ async def process_affiliations(
877901
878902 service_execution = ServiceExecution (
879903 repo_id = repository .id ,
880- operation_type = OperationType .REPO_AFFILIATION ,
904+ operation_type = OperationType .AFFILIATION ,
881905 status = execution_status ,
882906 error_code = error_code ,
883907 error_message = error_message ,
0 commit comments