@@ -108,6 +108,7 @@ def run_once(self) -> None:
108108 # Generate audit reports before any mutations are attempted.
109109 try :
110110 merge_role_rules = getattr (self .config , "merge_role_rules" , None )
111+ repo_contributor_roles = getattr (self .config , "repo_contributor_roles" , None )
111112 discord_plans = plan_discord_roles (
112113 member_roles ,
113114 scores ,
@@ -117,6 +118,7 @@ def run_once(self) -> None:
117118 period_start = period_start ,
118119 period_end = period_end ,
119120 merge_role_rules = merge_role_rules ,
121+ repo_contributor_roles = repo_contributor_roles ,
120122 )
121123 github_plans = _to_github_assignment_plans (issue_plans , review_plans )
122124 # Pass difficulty_weights if available (optional parameter, backward compatible)
@@ -187,6 +189,7 @@ def run_once(self) -> None:
187189
188190 apply_github_plans (self .github_writer , issue_plans , review_plans , policy , self .config .github .org )
189191 merge_role_rules = getattr (self .config , "merge_role_rules" , None )
192+ repo_contributor_roles = getattr (self .config , "repo_contributor_roles" , None )
190193 apply_discord_roles (
191194 self .discord_writer ,
192195 member_roles ,
@@ -198,6 +201,7 @@ def run_once(self) -> None:
198201 period_start = period_start ,
199202 period_end = period_end ,
200203 merge_role_rules = merge_role_rules ,
204+ repo_contributor_roles = repo_contributor_roles ,
201205 )
202206
203207 # Write GitHub snapshots (additive, non-blocking)
@@ -340,13 +344,14 @@ def apply_discord_roles(
340344 period_start : datetime | None = None ,
341345 period_end : datetime | None = None ,
342346 merge_role_rules : MergeRoleRulesConfig | None = None ,
347+ repo_contributor_roles : dict [str , str ] | None = None ,
343348) -> None :
344349 logger = logging .getLogger ("DiscordMutations" )
345350 if not policy .allow_discord_mutations :
346351 logger .info ("Discord mutations disabled" , extra = {"mode" : policy .mode .value })
347352 return
348353
349- from ghdcbot .engine .planning import count_merged_prs_per_user
354+ from ghdcbot .engine .planning import count_merged_prs_per_user , repos_with_merged_pr_per_user
350355
351356 score_lookup = {score .github_user : score .points for score in scores }
352357 role_thresholds = sorted (role_mappings , key = lambda r : r .min_score )
@@ -366,6 +371,11 @@ def apply_discord_roles(
366371 storage , identity_mappings , period_start , period_end
367372 )
368373
374+ # Get repos-with-merged-PR per user if repo-contributor roles are enabled
375+ repos_per_user : dict [str , set [str ]] = {}
376+ if repo_contributor_roles and storage is not None :
377+ repos_per_user = repos_with_merged_pr_per_user (storage , identity_mappings )
378+
369379 for mapping in identity_mappings :
370380 current_roles = set (member_roles .get (mapping .discord_user_id , []))
371381 points = score_lookup .get (mapping .github_user , 0 )
@@ -393,13 +403,32 @@ def apply_discord_roles(
393403 ]
394404 # Highest eligible role is the last one (rules sorted by threshold ascending)
395405 merge_desired = {eligible_merge_roles [- 1 ]} if eligible_merge_roles else set ()
406+
407+ # Repo-contributor desired roles (if enabled)
408+ repo_contributor_desired : set [str ] = set ()
409+ if repo_contributor_roles :
410+ user_repos = repos_per_user .get (mapping .github_user , set ())
411+ for repo_name , role_name in repo_contributor_roles .items ():
412+ if repo_name in user_repos :
413+ repo_contributor_desired .add (role_name )
396414
397- # Final desired roles = max(score_based, merge_based)
398- desired_roles = score_desired | merge_desired
415+ # Final desired roles = score | merge | repo-contributor
416+ desired_roles = score_desired | merge_desired | repo_contributor_desired
399417
400418 # Track newly added roles for congratulatory messages
401419 newly_added_roles = sorted (desired_roles - current_roles )
402-
420+ if newly_added_roles and repo_contributor_desired :
421+ for r in newly_added_roles :
422+ if r in repo_contributor_desired :
423+ logger .info (
424+ "Repo-contributor role to add" ,
425+ extra = {
426+ "discord_user_id" : mapping .discord_user_id ,
427+ "github_user" : mapping .github_user ,
428+ "role" : r ,
429+ },
430+ )
431+ break
403432 for role in newly_added_roles :
404433 discord_writer .add_role (mapping .discord_user_id , role )
405434 # Send congratulatory message for newly assigned roles
@@ -409,8 +438,8 @@ def apply_discord_roles(
409438 role_name = role ,
410439 policy = policy ,
411440 )
412- # Remove roles that are no longer desired (preserve merge-based roles)
413- roles_to_remove = (current_roles & managed_roles ) - ( score_desired | merge_desired )
441+ # Remove roles only when score-based says so; never remove merge-based or repo-contributor roles
442+ roles_to_remove = (current_roles & managed_roles ) - desired_roles
414443 for role in sorted (roles_to_remove ):
415444 discord_writer .remove_role (mapping .discord_user_id , role )
416445
0 commit comments