Skip to content

Commit 414cb8e

Browse files
feat: repo-contributor roles (PR merged in repo X -> role Contributor-X)
- Add repo_contributor_roles config (repo name -> Discord role) - Planning and apply: grant role when user has merged PR in that repo (all-time) - Case-insensitive GitHub username and Discord role name matching - Discord adapter: clearer logs when role not found or add fails - Bot startup log when repo_contributor_roles enabled; start-bot.sh helper Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b8b4ebf commit 414cb8e

9 files changed

Lines changed: 498 additions & 29 deletions

File tree

config/example.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ assignments:
6161
# identity:
6262
# unlink_cooldown_hours: 24
6363

64+
# Optional: when a contributor has a PR merged in a repo, grant them the corresponding Discord role
65+
# (repo name -> Discord role name). Roles are add-only and never auto-removed.
66+
repo_contributor_roles:
67+
castro: "castro"
68+
6469
identity_mappings:
6570
- github_user: "YOUR_GITHUB_USERNAME"
6671
discord_user_id: "YOUR_DISCORD_USER_ID"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
"""Debug script: check why repo-contributor role might not be assigned.
3+
Run from repo root: python scripts/debug_repo_contributor_roles.py --config config/shubh-olrd.yaml
4+
"""
5+
from __future__ import annotations
6+
7+
import argparse
8+
import sys
9+
from pathlib import Path
10+
11+
# Ensure src is on path
12+
REPO_ROOT = Path(__file__).resolve().parent.parent
13+
SRC = REPO_ROOT / "src"
14+
if str(SRC) not in sys.path:
15+
sys.path.insert(0, str(SRC))
16+
17+
from ghdcbot.config.loader import load_config
18+
from ghdcbot.plugins.registry import build_adapter
19+
20+
21+
def main() -> None:
22+
parser = argparse.ArgumentParser(description="Debug repo-contributor role assignment")
23+
parser.add_argument("--config", required=True, help="Path to config YAML (e.g. config/shubh-olrd.yaml)")
24+
args = parser.parse_args()
25+
config = load_config(args.config)
26+
storage = build_adapter(
27+
config.runtime.storage_adapter,
28+
data_dir=config.runtime.data_dir,
29+
)
30+
storage.init_schema()
31+
32+
repo_contributor_roles = getattr(config, "repo_contributor_roles", None) or {}
33+
if not repo_contributor_roles:
34+
print("No repo_contributor_roles in config. Add e.g. castro: \"Contributor-castro\"")
35+
return
36+
print("Config repo_contributor_roles:", repo_contributor_roles)
37+
print()
38+
39+
# 1) Any pr_merged events for the configured repos?
40+
from datetime import datetime, timezone
41+
from ghdcbot.engine.planning import REPO_CONTRIBUTOR_EPOCH
42+
contributions = storage.list_contributions(REPO_CONTRIBUTOR_EPOCH)
43+
pr_merged_by_repo: dict[str, list[str]] = {}
44+
for e in contributions:
45+
if e.event_type == "pr_merged":
46+
pr_merged_by_repo.setdefault(e.repo, []).append(e.github_user)
47+
for repo in repo_contributor_roles:
48+
users = pr_merged_by_repo.get(repo, [])
49+
print(f" Repo '{repo}' (-> role {repo_contributor_roles[repo]}): {len(users)} user(s) with merged PR: {users or 'none'}")
50+
if not any(pr_merged_by_repo.get(r) for r in repo_contributor_roles):
51+
print("\n -> No pr_merged events found for configured repos. Ensure:")
52+
print(" - You ran run-once or /sync AFTER merging a PR in that repo.")
53+
print(" - The merge was within the last 30 days (or cursor covers that time).")
54+
print(" - GitHub org in config matches the repo owner (e.g. shubham-orld).")
55+
print()
56+
57+
# 2) Verified identity mappings (used for role assignment)
58+
verified = list(storage.list_verified_identity_mappings()) if hasattr(storage, "list_verified_identity_mappings") else []
59+
print(f"Verified Discord <-> GitHub links: {len(verified)}")
60+
for m in verified:
61+
print(f" Discord {m.discord_user_id} <-> GitHub {m.github_user}")
62+
if not verified:
63+
print(" -> No verified links. Either:")
64+
print(" - Use /link and /verify-link in Discord, OR")
65+
print(" - Set identity_mappings in config with real github_user and discord_user_id.")
66+
print()
67+
68+
# 3) Who would get the role? (use same resolution as orchestrator: verified first, then config)
69+
from ghdcbot.engine.orchestrator import _resolve_identity_mappings
70+
identity_list = _resolve_identity_mappings(storage, getattr(config, "identity_mappings", []) or [])
71+
if not identity_list:
72+
print("No identity mappings (verified or config). Role cannot be assigned.")
73+
return
74+
from ghdcbot.engine.planning import repos_with_merged_pr_per_user
75+
repos_per_user = repos_with_merged_pr_per_user(storage, identity_list)
76+
for mapping in identity_list:
77+
gh = mapping.github_user
78+
dc = mapping.discord_user_id
79+
repos = repos_per_user.get(gh, set())
80+
roles_they_get = [repo_contributor_roles[r] for r in repo_contributor_roles if r in repos]
81+
print(f" GitHub {gh} (Discord {dc}): repos with merged PR = {repos}; would get roles = {roles_they_get or 'none'}")
82+
83+
84+
if __name__ == "__main__":
85+
main()

src/ghdcbot/adapters/discord/api.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ def add_role(self, discord_user_id: str, role_name: str) -> None:
9595
"""Assign a role to a guild member. Calls Discord API unless request fails."""
9696
role_id = self._resolve_role_id(role_name)
9797
if role_id is None:
98+
roles, ok = self._list_roles()
99+
role_names = [r.get("name") for r in roles if r.get("name")] if ok else []
98100
self._logger.warning(
99-
"Discord role not found; cannot add",
100-
extra={"user_id": discord_user_id, "role": role_name},
101+
"Discord role not found; cannot add (check name and hierarchy)",
102+
extra={
103+
"user_id": discord_user_id,
104+
"role_requested": role_name,
105+
"guild_role_names": role_names[:30],
106+
},
101107
)
102108
return
103109
path = f"/guilds/{self._guild_id}/members/{discord_user_id}/roles/{role_id}"
@@ -115,12 +121,17 @@ def add_role(self, discord_user_id: str, role_name: str) -> None:
115121
extra={"user_id": discord_user_id, "role": role_name},
116122
)
117123
else:
124+
try:
125+
body = response.text[:500] if response.text else ""
126+
except Exception:
127+
body = ""
118128
self._logger.warning(
119-
"Discord add role failed",
129+
"Discord add role failed (check bot role is above target role and has Manage Roles)",
120130
extra={
121131
"user_id": discord_user_id,
122132
"role": role_name,
123133
"status_code": response.status_code,
134+
"response": body,
124135
},
125136
)
126137

@@ -233,12 +244,14 @@ def send_dm(self, discord_user_id: str, content: str) -> bool:
233244
return False
234245

235246
def _resolve_role_id(self, role_name: str) -> str | None:
236-
"""Resolve role name to Discord role ID. Returns None if not found."""
247+
"""Resolve role name to Discord role ID (case-insensitive). Returns None if not found."""
237248
roles, ok = self._list_roles()
238249
if not ok:
239250
return None
251+
role_name_lower = (role_name or "").strip().lower()
240252
for role in roles:
241-
if role.get("name") == role_name:
253+
rname = role.get("name") or ""
254+
if rname.strip().lower() == role_name_lower:
242255
return role["id"]
243256
return None
244257

src/ghdcbot/bot.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ def run_bot(config_path: str) -> None:
6161
config_path,
6262
config.runtime.data_dir,
6363
)
64+
repo_contributor_roles = getattr(config, "repo_contributor_roles", None) or {}
65+
if repo_contributor_roles:
66+
logger.info(
67+
"repo_contributor_roles enabled: %s (use this same config for /sync to assign these roles)",
68+
list(repo_contributor_roles.keys()),
69+
)
6470

6571
storage = build_adapter(
6672
config.runtime.storage_adapter,
@@ -1072,7 +1078,7 @@ async def _on_select_callback(self, interaction: discord.Interaction) -> None:
10721078
await self._on_repo_chosen(interaction, interaction.data["values"][0])
10731079

10741080
async def _on_repo_chosen(self, interaction: discord.Interaction, repo_value: str) -> None:
1075-
await interaction.response.defer(ephemeral=False)
1081+
await interaction.response.defer(ephemeral=True)
10761082
parts = repo_value.split("/", 1)
10771083
if len(parts) != 2:
10781084
await interaction.followup.send("Invalid repository selection.", ephemeral=True)
@@ -1098,12 +1104,14 @@ async def _on_repo_chosen(self, interaction: discord.Interaction, repo_value: st
10981104
mentor_roles = getattr(self.config, "assignments", None)
10991105
eligible_roles_config = getattr(mentor_roles, "issue_request_eligible_roles", []) if mentor_roles else []
11001106
member_roles_map = self.discord_reader.list_member_roles()
1101-
channel = interaction.channel
11021107

1103-
async def send_repo_list_back(ch: Any) -> None:
1108+
async def send_repo_list_back(interaction_or_channel: Any) -> None:
11041109
pending = self.storage.list_pending_issue_requests()
11051110
if not pending:
1106-
await ch.send("No pending issue requests.")
1111+
if isinstance(interaction_or_channel, discord.Interaction):
1112+
await interaction_or_channel.followup.send("No pending issue requests.", ephemeral=True)
1113+
else:
1114+
await interaction_or_channel.send("No pending issue requests.")
11071115
return
11081116
rl = group_pending_requests_by_repo(pending)
11091117
now = datetime.now(timezone.utc)
@@ -1112,7 +1120,10 @@ async def send_repo_list_back(ch: Any) -> None:
11121120
pending, rl, self.storage, self.github_adapter, self.config,
11131121
self.discord_reader, self.policy,
11141122
)
1115-
await ch.send(embed=emb, view=v)
1123+
if isinstance(interaction_or_channel, discord.Interaction):
1124+
await interaction_or_channel.followup.send(embed=emb, view=v, ephemeral=True)
1125+
else:
1126+
await interaction_or_channel.send(embed=emb, view=v)
11161127

11171128
for req in repo_requests:
11181129
issue = fetch_issue_context(
@@ -1154,11 +1165,16 @@ async def send_repo_list_back(ch: Any) -> None:
11541165
discord_sender=self.discord_reader,
11551166
back_callback=send_repo_list_back,
11561167
)
1157-
msg = await channel.send(embed=discord.Embed.from_dict(embed_dict), view=view)
1168+
# Send as ephemeral followup so only mentor can see it
1169+
msg = await interaction.followup.send(
1170+
embed=discord.Embed.from_dict(embed_dict),
1171+
view=view,
1172+
ephemeral=True,
1173+
)
11581174
view.message = msg
11591175
await interaction.followup.send(
11601176
f"Showing **{len(repo_requests)}** request(s) for **{repo_value}** above.",
1161-
ephemeral=False,
1177+
ephemeral=True,
11621178
)
11631179

11641180
class IssueRequestReviewView(discord.ui.View):
@@ -1202,8 +1218,8 @@ def __init__(
12021218

12031219
async def _back_to_repo_list(self, interaction: discord.Interaction) -> None:
12041220
await interaction.response.defer(ephemeral=True)
1205-
if self.back_callback and interaction.channel:
1206-
await self.back_callback(interaction.channel)
1221+
if self.back_callback:
1222+
await self.back_callback(interaction)
12071223
await interaction.followup.send("Returned to repo list.", ephemeral=True)
12081224

12091225
def _dm_contributor(self, content: str) -> bool:

src/ghdcbot/config/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ class BotConfig(BaseModel):
199199
merge_role_rules: MergeRoleRulesConfig | None = None
200200
# Optional: GitHub snapshot storage
201201
snapshots: SnapshotConfig | None = None
202+
# Optional: repo name -> Discord role for "Contributor-X" (PR merged in repo X grants role)
203+
repo_contributor_roles: dict[str, str] | None = None
204+
205+
@field_validator("repo_contributor_roles")
206+
@classmethod
207+
def validate_repo_contributor_roles(cls, value: dict[str, str] | None) -> dict[str, str] | None:
208+
if value is not None:
209+
for repo, role in value.items():
210+
if not (repo and repo.strip()):
211+
raise ValueError("repo_contributor_roles: repo names must be non-empty")
212+
if not (role and str(role).strip()):
213+
raise ValueError("repo_contributor_roles: discord_role must be non-empty")
214+
return value
202215

203216
@field_validator("role_mappings")
204217
@classmethod

src/ghdcbot/engine/orchestrator.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)