@@ -60,13 +60,13 @@ def list_open_pull_requests(self) -> Iterable[dict]:
6060
6161 def assign_issue (self , owner : str , repo : str , issue_number : int , assignee : str ) -> bool :
6262 """Assign a GitHub issue to a user.
63-
63+
6464 Args:
6565 owner: Repository owner
6666 repo: Repository name
6767 issue_number: Issue number
6868 assignee: GitHub username to assign
69-
69+
7070 Returns:
7171 True if assignment succeeded, False otherwise.
7272 """
@@ -93,7 +93,7 @@ def assign_issue(self, owner: str, repo: str, issue_number: int, assignee: str)
9393 extra = {"path" : f"/repos/{ owner } /{ repo } /issues/{ issue_number } /assignees" , "error" : str (exc )},
9494 )
9595 return False
96-
96+
9797 rate_limit = _parse_rate_limit (response .headers )
9898 if rate_limit .remaining is not None and rate_limit .remaining <= 1 :
9999 self ._logger .warning (
@@ -104,7 +104,7 @@ def assign_issue(self, owner: str, repo: str, issue_number: int, assignee: str)
104104 "reset_at" : rate_limit .reset_at .isoformat () if rate_limit .reset_at else None ,
105105 },
106106 )
107-
107+
108108 if response .status_code in {200 , 201 }:
109109 # Log GitHub's response to see what assignees were actually set
110110 try :
@@ -149,13 +149,13 @@ def assign_issue(self, owner: str, repo: str, issue_number: int, assignee: str)
149149
150150 def unassign_issue (self , owner : str , repo : str , issue_number : int , assignee : str ) -> bool :
151151 """Unassign a GitHub issue from a user.
152-
152+
153153 Args:
154154 owner: Repository owner
155155 repo: Repository name
156156 issue_number: Issue number
157157 assignee: GitHub username to unassign
158-
158+
159159 Returns:
160160 True if unassignment succeeded, False otherwise.
161161 """
@@ -170,7 +170,7 @@ def unassign_issue(self, owner: str, repo: str, issue_number: int, assignee: str
170170 extra = {"path" : f"/repos/{ owner } /{ repo } /issues/{ issue_number } /assignees" , "error" : str (exc )},
171171 )
172172 return False
173-
173+
174174 rate_limit = _parse_rate_limit (response .headers )
175175 if rate_limit .remaining is not None and rate_limit .remaining <= 1 :
176176 self ._logger .warning (
@@ -181,7 +181,7 @@ def unassign_issue(self, owner: str, repo: str, issue_number: int, assignee: str
181181 "reset_at" : rate_limit .reset_at .isoformat () if rate_limit .reset_at else None ,
182182 },
183183 )
184-
184+
185185 if response .status_code in {200 , 201 }:
186186 self ._logger .info (
187187 "Issue unassigned successfully" ,
@@ -209,7 +209,7 @@ def request_review(self, repo: str, pr_number: int, reviewer: str) -> None:
209209
210210 def get_pull_request (self , owner : str , repo : str , pr_number : int ) -> dict | None :
211211 """Fetch a single pull request by number.
212-
212+
213213 Returns PR dict or None if not found/accessible.
214214 """
215215 response = self ._request ("GET" , f"/repos/{ owner } /{ repo } /pulls/{ pr_number } " , params = {})
@@ -219,17 +219,30 @@ def get_pull_request(self, owner: str, repo: str, pr_number: int) -> dict | None
219219
220220 def get_pull_request_reviews (self , owner : str , repo : str , pr_number : int ) -> list [dict ]:
221221 """Fetch reviews for a pull request.
222-
222+
223223 Returns list of review dicts (empty list on error).
224224 """
225225 reviews = []
226226 for page in self ._paginate (f"/repos/{ owner } /{ repo } /pulls/{ pr_number } /reviews" , params = {"per_page" : 100 }):
227227 reviews .extend (page )
228228 return reviews
229229
230+ def get_pull_request_review_comments (self , owner : str , repo : str , pr_number : int ) -> list [dict ]:
231+ """Fetch inline review comments for a pull request.
232+
233+ Returns list of comment dicts (each has user.login, created_at, id, body, etc.).
234+ Empty list on error.
235+ """
236+ comments : list [dict ] = []
237+ for page in self ._paginate (
238+ f"/repos/{ owner } /{ repo } /pulls/{ pr_number } /comments" , params = {"per_page" : 100 }
239+ ):
240+ comments .extend (page )
241+ return comments
242+
230243 def get_pull_request_check_runs (self , owner : str , repo : str , head_sha : str ) -> list [dict ]:
231244 """Fetch check runs for a commit (used for CI status).
232-
245+
233246 Returns list of check run dicts (empty list on error).
234247 Note: Requires 'checks:read' permission for private repos.
235248 """
@@ -248,7 +261,7 @@ def get_pull_request_check_runs(self, owner: str, repo: str, head_sha: str) -> l
248261
249262 def get_issue (self , owner : str , repo : str , issue_number : int ) -> dict | None :
250263 """Fetch a single issue by number.
251-
264+
252265 Returns issue dict or None if not found/accessible.
253266 Note: GitHub API uses /issues/{number} for both issues and PRs.
254267 """
@@ -261,22 +274,22 @@ def write_file(
261274 self , owner : str , repo : str , file_path : str , content : str , commit_message : str , branch : str | None = None
262275 ) -> bool :
263276 """Write a file to GitHub repo using Contents API.
264-
277+
265278 Creates or updates a file in the repository. Uses the default branch if branch is not specified.
266-
279+
267280 Args:
268281 owner: Repository owner
269282 repo: Repository name
270283 file_path: Path to file within repo (e.g., "snapshots/2024-01-01/meta.json")
271284 content: File content (will be base64 encoded)
272285 commit_message: Commit message
273286 branch: Branch name (default: main or master)
274-
287+
275288 Returns:
276289 True if successful, False otherwise.
277290 """
278291 import base64
279-
292+
280293 try :
281294 # Get default branch if not specified
282295 if not branch :
@@ -285,7 +298,7 @@ def write_file(
285298 branch = repo_info .json ().get ("default_branch" , "main" )
286299 else :
287300 branch = "main"
288-
301+
289302 # Check if file exists to get SHA for update
290303 file_sha = None
291304 try :
@@ -299,11 +312,11 @@ def write_file(
299312 except Exception :
300313 # File doesn't exist yet, will create new
301314 pass
302-
315+
303316 # Prepare content (base64 encode)
304317 content_bytes = content .encode ("utf-8" )
305318 content_b64 = base64 .b64encode (content_bytes ).decode ("ascii" )
306-
319+
307320 # Create/update file
308321 payload = {
309322 "message" : commit_message ,
@@ -312,7 +325,7 @@ def write_file(
312325 }
313326 if file_sha :
314327 payload ["sha" ] = file_sha
315-
328+
316329 # Use _client directly for PUT with JSON body
317330 try :
318331 response = self ._client .put (
@@ -325,7 +338,7 @@ def write_file(
325338 extra = {"path" : f"/repos/{ owner } /{ repo } /contents/{ file_path } " , "error" : str (exc )},
326339 )
327340 return False
328-
341+
329342 if response and response .status_code in {200 , 201 }:
330343 self ._logger .info (
331344 "File written to GitHub" ,
@@ -575,7 +588,7 @@ def _fetch_issue_difficulty_labels(
575588 self , owner : str , repo : str , issue_numbers : list [int ]
576589 ) -> list [str ]:
577590 """Fetch labels from linked issues and return difficulty labels only.
578-
591+
579592 Returns list of difficulty label names (case-normalized) found in any linked issue.
580593 If an issue doesn't exist or API call fails, it's silently skipped.
581594 """
@@ -652,7 +665,7 @@ def _ingest_issue_comments(
652665 self , owner : str , repo : str , issue_numbers : Sequence [int ], since : datetime
653666 ) -> tuple [list [ContributionEvent ], dict [int , list [dict ]]]:
654667 """Ingest issue comments and return both events and raw comments for reuse.
655-
668+
656669 Returns:
657670 Tuple of (comment_events, comments_by_number) where comments_by_number
658671 maps issue_number -> list of comment dicts.
@@ -673,7 +686,7 @@ def _ingest_issue_comments(
673686 ):
674687 comments_list .extend (page )
675688 comments_by_number [issue_number ] = comments_list
676-
689+
677690 for comment in comments_list :
678691 created_at = _parse_iso8601 (comment .get ("created_at" ))
679692 if not created_at or created_at < since :
@@ -708,7 +721,7 @@ def _ingest_pr_comments(
708721 self , owner : str , repo : str , pr_numbers : Sequence [int ], since : datetime
709722 ) -> tuple [list [ContributionEvent ], dict [int , list [dict ]]]:
710723 """Ingest PR comments and return both events and raw comments for reuse.
711-
724+
712725 Returns:
713726 Tuple of (comment_events, comments_by_number) where comments_by_number
714727 maps pr_number -> list of comment dicts.
@@ -740,7 +753,7 @@ def _ingest_pr_comments(
740753 seen .add (key )
741754 comments_list .append (comment )
742755 comments_by_number [pr_number ] = comments_list
743-
756+
744757 for comment in comments_list :
745758 created_at = _parse_iso8601 (comment .get ("created_at" ))
746759 if not created_at or created_at < since :
@@ -783,17 +796,17 @@ def _ingest_helpful_comments(
783796 pr_authors : dict [int , str ] | None = None ,
784797 ) -> Iterable [ContributionEvent ]:
785798 """Emit helpful_comment events for non-author comments on issues and PRs.
786-
799+
787800 A comment is "helpful" if:
788801 - It's on an issue/PR
789802 - The commenter is not the issue/PR author
790803 - It's not a bot comment
791-
804+
792805 Bonus is capped per PR/issue (max 5 helpful comments count for bonus).
793-
806+
794807 issue_authors and pr_authors should be precomputed by callers (e.g. from
795808 _collect_issue_events / _collect_pull_request_events) to avoid N+1 API calls.
796-
809+
797810 issue_comments_by_number and pr_comments_by_number should contain pre-fetched
798811 comment iterables to avoid duplicate API pagination.
799812 """
@@ -830,7 +843,7 @@ def _ingest_helpful_comments(
830843 )
831844 )
832845 helpful_count += 1
833-
846+
834847 # Process PR comments
835848 for pr_number , comments in pr_comments_by_number .items ():
836849 helpful_count = 0
@@ -860,7 +873,7 @@ def _ingest_helpful_comments(
860873 )
861874 )
862875 helpful_count += 1
863-
876+
864877 return helpful_events
865878
866879 def _issue_events (
@@ -961,7 +974,8 @@ def _list_repo_open_prs(self, repo: dict) -> Iterable[dict]:
961974 params = {"state" : "open" , "per_page" : 100 }
962975 for page in self ._paginate (f"/repos/{ owner } /{ repo_name } /pulls" , params = params ):
963976 for pr in page :
964- yield {"repo" : repo ["name" ], "number" : pr ["number" ]}
977+ author = (pr .get ("user" ) or {}).get ("login" ) if pr .get ("user" ) else None
978+ yield {"repo" : repo ["name" ], "number" : pr ["number" ], "author" : author }
965979
966980 def _paginate (self , path : str , params : dict ) -> Iterator [list ]:
967981 page = 1
@@ -1128,15 +1142,15 @@ def _is_bot_user(user: dict) -> bool:
11281142
11291143def _extract_linked_issue_numbers (pr_body : str ) -> list [int ]:
11301144 """Extract issue numbers from PR body that are explicitly closed/fixed/resolved.
1131-
1145+
11321146 Only matches closing-keyword patterns: closes #123, fixes #456, resolves #789.
11331147 Does not match bare #number references to avoid unrelated issue lookups.
11341148 Returns list of unique issue numbers (integers).
11351149 """
11361150 if not pr_body :
11371151 return []
1138- # Only match explicit closing keywords + #number (not bare #number )
1139- pattern = r"(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)"
1152+ # Only match explicit closing keywords + #number (optional backticks around #num )
1153+ pattern = r"(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+`? #(\d+)`? "
11401154 issue_numbers = set ()
11411155 for match in re .finditer (pattern , pr_body , re .IGNORECASE ):
11421156 try :
@@ -1148,14 +1162,14 @@ def _extract_linked_issue_numbers(pr_body: str) -> list[int]:
11481162
11491163def _detect_reverted_pr (pr : dict , owner : str , repo : str , client : httpx .Client ) -> int | None :
11501164 """Detect if a PR reverts another PR.
1151-
1165+
11521166 Checks PR title/body and commit messages for revert patterns.
11531167 Returns the PR number being reverted, or None if not a revert.
11541168 """
11551169 pr_title = (pr .get ("title" ) or "" ).lower ()
11561170 pr_body = (pr .get ("body" ) or "" ).lower ()
11571171 combined_text = f"{ pr_title } { pr_body } "
1158-
1172+
11591173 # Match: revert #123, reverts #123, rollback #123, etc.
11601174 revert_patterns = [
11611175 r"(?:revert|reverts|reverted|rollback|rollbacks|rollbacked)\s+#(\d+)" ,
@@ -1167,7 +1181,7 @@ def _detect_reverted_pr(pr: dict, owner: str, repo: str, client: httpx.Client) -
11671181 return int (match .group (1 ))
11681182 except (ValueError , IndexError ):
11691183 continue
1170-
1184+
11711185 # Also check commit messages (if PR has commits)
11721186 pr_number = pr .get ("number" )
11731187 if pr_number :
@@ -1207,15 +1221,15 @@ def _detect_reverted_pr(pr: dict, owner: str, repo: str, client: httpx.Client) -
12071221
12081222def _check_pr_ci_status (pr : dict , owner : str , repo : str , client : httpx .Client ) -> bool :
12091223 """Check if PR was merged with failing CI status.
1210-
1224+
12111225 Returns True if merged_at exists and CI checks failed at merge time.
12121226 Uses GitHub Checks API to check status.
12131227 """
12141228 merged_at = pr .get ("merged_at" )
12151229 merge_sha = pr .get ("merge_commit_sha" )
12161230 if not merged_at or not merge_sha :
12171231 return False
1218-
1232+
12191233 try :
12201234 # Check check runs for the merge commit
12211235 response = client .get (
0 commit comments