1010 get_issue_guidance ,
1111 get_pr_guidance ,
1212)
13+ from .repair_records import clear_repair_marker , store_repair_marker
1314from .review_state import (
1415 clear_current_reviewer ,
1516 ensure_review_entry ,
@@ -39,10 +40,75 @@ def _coerce_attempt(bot, result, *, success_status: int) -> object:
3940 if isinstance (result , bool ):
4041 if result :
4142 return _success_attempt (bot , success_status )
42- return bot .AssignmentAttempt (success = False , status_code = None )
43+ return bot .AssignmentAttempt (success = False , status_code = None , failure_kind = "transport_error" )
4344 return result
4445
4546
47+ def _store_assignment_marker (bot , review_data : dict , issue_number : int , * , phase : str , marker : dict ) -> bool :
48+ changed = store_repair_marker (review_data , phase , marker )
49+ if changed :
50+ bot .collect_touched_item (issue_number )
51+ return changed
52+
53+
54+ def _clear_assignment_marker (bot , review_data : dict , issue_number : int , * , phase : str ) -> bool :
55+ changed = clear_repair_marker (review_data , phase )
56+ if changed :
57+ bot .collect_touched_item (issue_number )
58+ return changed
59+
60+
61+ def _assignment_attempt_marker (bot , * , phase : str , attempt ) -> dict :
62+ return {
63+ "kind" : "reminder_transport_failure" ,
64+ "phase" : phase ,
65+ "status_code" : attempt .status_code ,
66+ "failure_kind" : attempt .failure_kind or "transport_error" ,
67+ "retry_attempts" : attempt .retry_attempts ,
68+ "recorded_at" : bot .clock .now ().isoformat (),
69+ }
70+
71+
72+ def _assignment_authority_mismatch_marker (bot , * , live_assignees : list [str ], reason : str ) -> dict :
73+ return {
74+ "kind" : "reviewer_authority_mismatch" ,
75+ "phase" : "assignment_confirm_read" ,
76+ "status_code" : None ,
77+ "failure_kind" : "reviewer_authority_mismatch" ,
78+ "retry_attempts" : 0 ,
79+ "recorded_at" : bot .clock .now ().isoformat (),
80+ "reason" : reason ,
81+ "live_assignees" : list (live_assignees ),
82+ }
83+
84+
85+ def _hard_fail_if_permission_denied (result , * , action : str , issue_number : int ) -> None :
86+ if result .failure_kind in {"unauthorized" , "forbidden" }:
87+ raise RuntimeError (
88+ f"Permission denied during { action } for #{ issue_number } (status { result .status_code } )."
89+ )
90+
91+
92+ def _read_live_assignees (bot , state : dict , issue_number : int , * , is_pull_request : bool | None = None ):
93+ review_data = ensure_review_entry (state , issue_number , create = True )
94+ result = bot .github .get_issue_assignees_result (issue_number , is_pull_request = is_pull_request )
95+ diagnostic_changed = False
96+ _hard_fail_if_permission_denied (result , action = "assignee confirmation read" , issue_number = issue_number )
97+ if not result .ok or not isinstance (result .payload , list ):
98+ if isinstance (review_data , dict ):
99+ diagnostic_changed = _store_assignment_marker (
100+ bot ,
101+ review_data ,
102+ issue_number ,
103+ phase = "assignment_confirm_read" ,
104+ marker = _assignment_attempt_marker (bot , phase = "assignment_confirm_read" , attempt = result ),
105+ )
106+ return review_data , None , result , diagnostic_changed
107+ if isinstance (review_data , dict ):
108+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_confirm_read" )
109+ return review_data , result .payload , result , diagnostic_changed
110+
111+
46112def _post_assignment_guidance (bot , request , reviewer : str ) -> None :
47113 if request .is_pull_request :
48114 bot .github .post_comment (request .issue_number , get_pr_guidance (reviewer , request .issue_author ))
@@ -85,16 +151,42 @@ def confirm_reviewer_assignment(
85151 pr_head_sha : str | None = None ,
86152) -> dict [str , object ]:
87153 issue_number = request .issue_number
154+ review_data = ensure_review_entry (state , issue_number , create = True )
155+ stored_reviewer = review_data .get ("current_reviewer" ) if isinstance (review_data , dict ) else None
156+ diagnostic_changed = False
88157 live_before = current_assignees
89158 if live_before is None :
90- live_before = bot .github .get_issue_assignees (issue_number )
159+ review_data , live_before , _ , marker_changed = _read_live_assignees (
160+ bot ,
161+ state ,
162+ issue_number ,
163+ is_pull_request = request .is_pull_request ,
164+ )
165+ diagnostic_changed = marker_changed or diagnostic_changed
91166 if live_before is None :
92- return {"confirmed" : False , "reason" : "assignees_unavailable" }
167+ return {
168+ "confirmed" : False ,
169+ "reason" : "assignees_unavailable" ,
170+ "diagnostic_changed" : diagnostic_changed ,
171+ }
93172 if request .issue_author and reviewer .lower () == request .issue_author .lower ():
173+ if isinstance (review_data , dict ):
174+ diagnostic_changed = _store_assignment_marker (
175+ bot ,
176+ review_data ,
177+ issue_number ,
178+ phase = "assignment_confirm_read" ,
179+ marker = _assignment_authority_mismatch_marker (
180+ bot ,
181+ live_assignees = live_before ,
182+ reason = "self_review_not_allowed" ,
183+ ),
184+ ) or diagnostic_changed
94185 return {
95186 "confirmed" : False ,
96187 "reason" : "self_review_not_allowed" ,
97188 "current_assignees" : live_before ,
189+ "diagnostic_changed" : diagnostic_changed ,
98190 }
99191 removal_attempts = {}
100192 live_before_normalized = _normalize_logins (live_before )
@@ -104,25 +196,55 @@ def confirm_reviewer_assignment(
104196 attempt = _remove_live_assignee (bot , request , issue_number , assignee )
105197 removal_attempts [assignee ] = attempt
106198 if not attempt .success :
107- final_assignees = bot .github .get_issue_assignees (issue_number )
199+ if isinstance (review_data , dict ):
200+ diagnostic_changed = _store_assignment_marker (
201+ bot ,
202+ review_data ,
203+ issue_number ,
204+ phase = "assignment_remove_write" ,
205+ marker = _assignment_attempt_marker (bot , phase = "assignment_remove_write" , attempt = attempt ),
206+ ) or diagnostic_changed
207+ _ , final_assignees , _ , marker_changed = _read_live_assignees (
208+ bot ,
209+ state ,
210+ issue_number ,
211+ is_pull_request = request .is_pull_request ,
212+ )
213+ diagnostic_changed = marker_changed or diagnostic_changed
108214 return {
109215 "confirmed" : False ,
110216 "reason" : "remove_failed" ,
111217 "current_assignees" : live_before ,
112218 "final_assignees" : final_assignees ,
113219 "removal_attempts" : removal_attempts ,
220+ "diagnostic_changed" : diagnostic_changed ,
114221 }
115222 assignment_attempt = None
116223 if reviewer .lower () not in live_before_normalized :
117224 assignment_attempt = _add_live_assignee (bot , request , issue_number , reviewer )
118- final_assignees = bot .github .get_issue_assignees (issue_number )
225+ if not assignment_attempt .success and isinstance (review_data , dict ):
226+ diagnostic_changed = _store_assignment_marker (
227+ bot ,
228+ review_data ,
229+ issue_number ,
230+ phase = "assignment_add_write" ,
231+ marker = _assignment_attempt_marker (bot , phase = "assignment_add_write" , attempt = assignment_attempt ),
232+ ) or diagnostic_changed
233+ review_data , final_assignees , _ , marker_changed = _read_live_assignees (
234+ bot ,
235+ state ,
236+ issue_number ,
237+ is_pull_request = request .is_pull_request ,
238+ )
239+ diagnostic_changed = marker_changed or diagnostic_changed
119240 if final_assignees is None :
120241 return {
121242 "confirmed" : False ,
122243 "reason" : "final_assignees_unknown" ,
123244 "current_assignees" : live_before ,
124245 "assignment_attempt" : assignment_attempt ,
125246 "removal_attempts" : removal_attempts ,
247+ "diagnostic_changed" : diagnostic_changed ,
126248 }
127249 final_normalized = _normalize_logins (final_assignees )
128250 if len (final_assignees ) == 1 and final_normalized [0 ] == reviewer .lower ():
@@ -145,17 +267,41 @@ def confirm_reviewer_assignment(
145267 )
146268 if emit_guidance :
147269 _post_assignment_guidance (bot , request , reviewer )
270+ bot .collect_touched_item (issue_number )
271+ if isinstance (review_data , dict ):
272+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_add_write" ) or diagnostic_changed
273+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_remove_write" ) or diagnostic_changed
274+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_confirm_read" ) or diagnostic_changed
148275 return {
149276 "confirmed" : True ,
150277 "reviewer" : reviewer ,
151278 "current_assignees" : live_before ,
152279 "final_assignees" : final_assignees ,
153280 "assignment_attempt" : assignment_attempt or _success_attempt (bot ),
154281 "removal_attempts" : removal_attempts ,
282+ "diagnostic_changed" : diagnostic_changed ,
155283 }
156284 cleared = False
157- if len (final_assignees ) != 1 :
285+ if len (final_assignees ) != 1 or (
286+ len (final_assignees ) == 1
287+ and isinstance (stored_reviewer , str )
288+ and final_normalized [0 ] != stored_reviewer .lower ()
289+ ):
158290 cleared = clear_current_reviewer (state , issue_number )
291+ if cleared :
292+ bot .collect_touched_item (issue_number )
293+ if isinstance (review_data , dict ):
294+ diagnostic_changed = _store_assignment_marker (
295+ bot ,
296+ review_data ,
297+ issue_number ,
298+ phase = "assignment_confirm_read" ,
299+ marker = _assignment_authority_mismatch_marker (
300+ bot ,
301+ live_assignees = final_assignees ,
302+ reason = "final_assignee_mismatch" ,
303+ ),
304+ ) or diagnostic_changed
159305 failure_comment = None
160306 if assignment_attempt is not None and not assignment_attempt .success :
161307 failure_comment = get_assignment_failure_comment (
@@ -174,6 +320,7 @@ def confirm_reviewer_assignment(
174320 "removal_attempts" : removal_attempts ,
175321 "failure_comment" : failure_comment ,
176322 "cleared_current_reviewer" : cleared ,
323+ "diagnostic_changed" : diagnostic_changed ,
177324 }
178325
179326
@@ -186,44 +333,100 @@ def confirm_reviewer_release(
186333 reposition_reviewer : bool = False ,
187334) -> dict [str , object ]:
188335 issue_number = request .issue_number
189- live_before = bot .github .get_issue_assignees (issue_number )
336+ review_data = ensure_review_entry (state , issue_number , create = True )
337+ stored_reviewer = review_data .get ("current_reviewer" ) if isinstance (review_data , dict ) else None
338+ review_data , live_before , _ , diagnostic_changed = _read_live_assignees (
339+ bot ,
340+ state ,
341+ issue_number ,
342+ is_pull_request = request .is_pull_request ,
343+ )
190344 if live_before is None :
191- return {"confirmed" : False , "reason" : "assignees_unavailable" }
345+ return {
346+ "confirmed" : False ,
347+ "reason" : "assignees_unavailable" ,
348+ "diagnostic_changed" : diagnostic_changed ,
349+ }
192350 removal_attempt = None
193351 if reviewer .lower () in _normalize_logins (live_before ):
194352 removal_attempt = _remove_live_assignee (bot , request , issue_number , reviewer )
195353 if not removal_attempt .success :
354+ if isinstance (review_data , dict ):
355+ diagnostic_changed = _store_assignment_marker (
356+ bot ,
357+ review_data ,
358+ issue_number ,
359+ phase = "assignment_remove_write" ,
360+ marker = _assignment_attempt_marker (bot , phase = "assignment_remove_write" , attempt = removal_attempt ),
361+ ) or diagnostic_changed
196362 return {
197363 "confirmed" : False ,
198364 "reason" : "remove_failed" ,
199365 "current_assignees" : live_before ,
200366 "removal_attempt" : removal_attempt ,
367+ "diagnostic_changed" : diagnostic_changed ,
201368 }
202- final_assignees = bot .github .get_issue_assignees (issue_number )
369+ review_data , final_assignees , _ , marker_changed = _read_live_assignees (
370+ bot ,
371+ state ,
372+ issue_number ,
373+ is_pull_request = request .is_pull_request ,
374+ )
375+ diagnostic_changed = marker_changed or diagnostic_changed
203376 if final_assignees is None :
204377 return {
205378 "confirmed" : False ,
206379 "reason" : "final_assignees_unknown" ,
207380 "current_assignees" : live_before ,
208381 "removal_attempt" : removal_attempt ,
382+ "diagnostic_changed" : diagnostic_changed ,
209383 }
210384 if final_assignees :
385+ cleared = False
386+ if len (final_assignees ) != 1 or (
387+ len (final_assignees ) == 1
388+ and isinstance (stored_reviewer , str )
389+ and final_assignees [0 ].lower () != stored_reviewer .lower ()
390+ ):
391+ cleared = clear_current_reviewer (state , issue_number )
392+ if cleared :
393+ bot .collect_touched_item (issue_number )
394+ if isinstance (review_data , dict ):
395+ diagnostic_changed = _store_assignment_marker (
396+ bot ,
397+ review_data ,
398+ issue_number ,
399+ phase = "assignment_confirm_read" ,
400+ marker = _assignment_authority_mismatch_marker (
401+ bot ,
402+ live_assignees = final_assignees ,
403+ reason = "final_assignee_mismatch" ,
404+ ),
405+ ) or diagnostic_changed
211406 return {
212407 "confirmed" : False ,
213408 "reason" : "final_assignee_mismatch" ,
214409 "current_assignees" : live_before ,
215410 "final_assignees" : final_assignees ,
216411 "removal_attempt" : removal_attempt ,
412+ "cleared_current_reviewer" : cleared ,
413+ "diagnostic_changed" : diagnostic_changed ,
217414 }
218415 cleared = clear_current_reviewer (state , issue_number )
416+ if cleared :
417+ bot .collect_touched_item (issue_number )
219418 if reposition_reviewer :
220419 bot .adapters .queue .reposition_member_as_next (state , reviewer )
420+ if isinstance (review_data , dict ):
421+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_remove_write" ) or diagnostic_changed
422+ diagnostic_changed = _clear_assignment_marker (bot , review_data , issue_number , phase = "assignment_confirm_read" ) or diagnostic_changed
221423 return {
222424 "confirmed" : True ,
223425 "current_assignees" : live_before ,
224426 "final_assignees" : final_assignees ,
225427 "removal_attempt" : removal_attempt or _success_attempt (bot , status_code = 204 ),
226428 "cleared_current_reviewer" : cleared ,
429+ "diagnostic_changed" : diagnostic_changed ,
227430 }
228431
229432
0 commit comments