@@ -1117,21 +1117,6 @@ async def _on_repo_chosen(self, interaction: discord.Interaction, repo_value: st
11171117 if r .get ("owner" ) == owner and r .get ("repo" ) == repo
11181118 ]
11191119 repo_requests .sort (key = lambda r : (_request_created_at (r ), r .get ("request_id" , "" )))
1120- if hasattr (self .storage , "append_audit_event" ):
1121- self .storage .append_audit_event ({
1122- "event_type" : "issue_request_viewed_repo" ,
1123- "context" : {
1124- "repo" : repo_value ,
1125- "mentor_discord_id" : str (interaction .user .id ),
1126- "timestamp" : datetime .now (timezone .utc ).isoformat (),
1127- },
1128- })
1129- period_days = self .config .scoring .period_days
1130- period_end = datetime .now (timezone .utc )
1131- period_start = period_end - timedelta (days = period_days )
1132- mentor_roles = getattr (self .config , "assignments" , None )
1133- eligible_roles_config = getattr (mentor_roles , "issue_request_eligible_roles" , []) if mentor_roles else []
1134- member_roles_map = self .discord_reader .list_member_roles ()
11351120
11361121 async def send_repo_list_back (interaction_or_channel : Any ) -> None :
11371122 pending = self .storage .list_pending_issue_requests ()
@@ -1153,33 +1138,78 @@ async def send_repo_list_back(interaction_or_channel: Any) -> None:
11531138 else :
11541139 await interaction_or_channel .send (embed = emb , view = v )
11551140
1156- for req in repo_requests :
1157- issue = fetch_issue_context (
1158- self .github_adapter , req ["owner" ], req ["repo" ], req ["issue_number" ]
1159- )
1160- if not issue :
1161- continue
1162- contributor_roles = member_roles_map .get (req ["discord_user_id" ], [])
1163- merged_count , last_merged_at = get_merged_pr_count_and_last_time (
1164- self .storage , req ["github_user" ], period_start , period_end
1165- )
1166- now = datetime .now (timezone .utc )
1167- verdict , reason = compute_eligibility (
1168- eligible_roles_config , contributor_roles , merged_count , last_merged_at , now
1141+ mentor_discord_id = str (interaction .user .id )
1142+
1143+ def _collect_mentor_review_rows () -> list [dict [str , Any ]]:
1144+ if hasattr (self .storage , "append_audit_event" ):
1145+ self .storage .append_audit_event ({
1146+ "event_type" : "issue_request_viewed_repo" ,
1147+ "context" : {
1148+ "repo" : repo_value ,
1149+ "mentor_discord_id" : mentor_discord_id ,
1150+ "timestamp" : datetime .now (timezone .utc ).isoformat (),
1151+ },
1152+ })
1153+ period_days = self .config .scoring .period_days
1154+ period_end = datetime .now (timezone .utc )
1155+ period_start = period_end - timedelta (days = period_days )
1156+ mentor_roles = getattr (self .config , "assignments" , None )
1157+ eligible_roles_config = (
1158+ getattr (mentor_roles , "issue_request_eligible_roles" , []) if mentor_roles else []
11691159 )
1170- embed_dict = build_mentor_request_embed (
1171- request = req ,
1172- issue = issue ,
1173- contributor_discord_mention = f"<@{ req ['discord_user_id' ]} >" ,
1174- contributor_roles = contributor_roles ,
1175- merged_count = merged_count ,
1176- last_merged_at = last_merged_at ,
1177- eligibility_verdict = verdict ,
1178- eligibility_reason = reason ,
1179- eligible_roles_config = eligible_roles_config ,
1180- period_days = period_days ,
1181- now = now ,
1160+ member_roles_map = self .discord_reader .list_member_roles ()
1161+ rows : list [dict [str , Any ]] = []
1162+ for req in repo_requests :
1163+ issue = fetch_issue_context (
1164+ self .github_adapter , req ["owner" ], req ["repo" ], req ["issue_number" ]
1165+ )
1166+ if not issue :
1167+ continue
1168+ contributor_roles = member_roles_map .get (req ["discord_user_id" ], [])
1169+ merged_count , last_merged_at = get_merged_pr_count_and_last_time (
1170+ self .storage , req ["github_user" ], period_start , period_end
1171+ )
1172+ now = datetime .now (timezone .utc )
1173+ verdict , reason = compute_eligibility (
1174+ eligible_roles_config , contributor_roles , merged_count , last_merged_at , now
1175+ )
1176+ embed_dict = build_mentor_request_embed (
1177+ request = req ,
1178+ issue = issue ,
1179+ contributor_discord_mention = f"<@{ req ['discord_user_id' ]} >" ,
1180+ contributor_roles = contributor_roles ,
1181+ merged_count = merged_count ,
1182+ last_merged_at = last_merged_at ,
1183+ eligibility_verdict = verdict ,
1184+ eligibility_reason = reason ,
1185+ eligible_roles_config = eligible_roles_config ,
1186+ period_days = period_days ,
1187+ now = now ,
1188+ )
1189+ assignees = issue .get ("assignees" ) or []
1190+ has_existing_assignee = any (
1191+ isinstance (a , dict ) and bool (a .get ("login" )) for a in assignees
1192+ )
1193+ rows .append ({
1194+ "req" : req ,
1195+ "embed_dict" : embed_dict ,
1196+ "has_existing_assignee" : has_existing_assignee ,
1197+ })
1198+ return rows
1199+
1200+ try :
1201+ review_rows = await asyncio .to_thread (_collect_mentor_review_rows )
1202+ except Exception as exc :
1203+ logger .exception ("issue-requests: failed after repo select" )
1204+ await interaction .followup .send (
1205+ f"❌ Failed to load issue request details: { exc } " ,
1206+ ephemeral = True ,
11821207 )
1208+ return
1209+
1210+ for row in review_rows :
1211+ req = row ["req" ]
1212+ embed_dict = row ["embed_dict" ]
11831213 view = IssueRequestReviewView (
11841214 request_id = req ["request_id" ],
11851215 owner = req ["owner" ],
@@ -1192,8 +1222,8 @@ async def send_repo_list_back(interaction_or_channel: Any) -> None:
11921222 policy = self .policy ,
11931223 discord_sender = self .discord_reader ,
11941224 back_callback = send_repo_list_back ,
1225+ has_existing_assignee = row ["has_existing_assignee" ],
11951226 )
1196- # Send as ephemeral followup so only mentor can see it
11971227 msg = await interaction .followup .send (
11981228 embed = discord .Embed .from_dict (embed_dict ),
11991229 view = view ,
@@ -1221,6 +1251,7 @@ def __init__(
12211251 policy : MutationPolicy ,
12221252 discord_sender : Any ,
12231253 back_callback : Any = None ,
1254+ has_existing_assignee : bool = False ,
12241255 timeout : float = 300.0 ,
12251256 ) -> None :
12261257 super ().__init__ (timeout = timeout )
@@ -1235,6 +1266,9 @@ def __init__(
12351266 self .policy = policy
12361267 self .discord_sender = discord_sender
12371268 self .back_callback = back_callback
1269+ if not has_existing_assignee :
1270+ self .replace_assignee .disabled = True
1271+ self .replace_assignee .style = discord .ButtonStyle .secondary
12381272 if back_callback :
12391273 back_btn = discord .ui .Button (
12401274 label = "Back to Repo List" ,
@@ -1518,8 +1552,23 @@ async def reject_request(self, interaction: discord.Interaction, button: discord
15181552
15191553 @discord .ui .button (label = "Cancel" , style = discord .ButtonStyle .secondary , emoji = "🚫" )
15201554 async def cancel_action (self , interaction : discord .Interaction , button : discord .ui .Button ) -> None :
1521- await interaction .response .defer (ephemeral = True )
1522- await interaction .followup .send ("No action taken." , ephemeral = True )
1555+ # Replace the card with plain text and remove components (same pattern as reject).
1556+ # Do not use only a zero-width placeholder or delete_after here: Discord often rejects "empty"
1557+ # MESSAGE_UPDATE payloads, and delete_original_response after UPDATE is unreliable
1558+ # for ephemeral follow-up messages.
1559+ try :
1560+ await interaction .response .edit_message (
1561+ content = "Cancelled — no changes made." ,
1562+ embed = None ,
1563+ view = None ,
1564+ )
1565+ except Exception :
1566+ logger .warning ("Could not edit message on cancel" , exc_info = True )
1567+ try :
1568+ await interaction .response .defer (ephemeral = True )
1569+ await interaction .followup .send ("Cancelled — no changes made." , ephemeral = True )
1570+ except Exception :
1571+ logger .warning ("Could not defer/followup on cancel" , exc_info = True )
15231572
15241573 @tree .command (
15251574 name = "request-issue" ,
@@ -1693,11 +1742,12 @@ async def on_message(message: discord.Message) -> None:
16931742 async def sync_cmd (interaction : discord .Interaction ) -> None :
16941743 """Manually trigger run-once to sync GitHub events and send notifications."""
16951744 await interaction .response .defer (ephemeral = True )
1696-
1745+
1746+ status_msg = None
16971747 try :
16981748 # Build orchestrator and run once
16991749 from ghdcbot .engine .orchestrator import Orchestrator
1700-
1750+
17011751 github_adapter_for_sync = build_adapter (
17021752 config .runtime .github_adapter ,
17031753 token = config .github .token ,
@@ -1720,7 +1770,7 @@ async def sync_cmd(interaction: discord.Interaction) -> None:
17201770 token = config .discord .token ,
17211771 guild_id = config .discord .guild_id ,
17221772 )
1723-
1773+
17241774 orchestrator = Orchestrator (
17251775 github_reader = github_adapter_for_sync ,
17261776 github_writer = github_writer_for_sync ,
@@ -1729,8 +1779,12 @@ async def sync_cmd(interaction: discord.Interaction) -> None:
17291779 storage = storage ,
17301780 config = config ,
17311781 )
1732-
1733- await interaction .followup .send ("🔄 Syncing GitHub events and sending notifications..." , ephemeral = True )
1782+
1783+ status_msg = await interaction .followup .send (
1784+ "🔄 Syncing GitHub events and sending notifications..." ,
1785+ ephemeral = True ,
1786+ wait = True ,
1787+ )
17341788
17351789 # run_once() is synchronous and can take many minutes for large orgs; running it on the
17361790 # event loop blocks Discord heartbeats and delays other slash commands (they then hit
@@ -1743,13 +1797,19 @@ def _run_sync_and_close() -> None:
17431797
17441798 await asyncio .to_thread (_run_sync_and_close )
17451799
1746- await interaction .followup .send ("✅ Sync complete! Notifications sent for new GitHub events." , ephemeral = True )
1800+ await status_msg .edit (
1801+ content = "✅ Sync complete! Notifications sent for new GitHub events." ,
1802+ )
17471803 except Exception as exc :
17481804 logger .exception ("Sync failed" , exc_info = True )
1749- await interaction .followup .send (
1750- f"❌ Sync failed: { exc } " ,
1751- ephemeral = True ,
1752- )
1805+ err_text = f"❌ Sync failed: { exc } "
1806+ if status_msg is not None :
1807+ try :
1808+ await status_msg .edit (content = err_text )
1809+ except Exception :
1810+ await interaction .followup .send (err_text , ephemeral = True )
1811+ else :
1812+ await interaction .followup .send (err_text , ephemeral = True )
17531813
17541814 @tree .error
17551815 async def on_app_command_error (interaction : discord .Interaction , error : app_commands .AppCommandError ) -> None :
0 commit comments