Skip to content

Commit 4bb180f

Browse files
committed
feat: CodeRabbit reminders + /verify non-blocking fix
- CodeRabbit reminders: notify verified PR authors when CodeRabbit left review comments older than configurable hours (one reminder per PR, deduped) - Config: coderabbit_reminders, coderabbit_reminder_after_hours, coderabbit_bot_logins under discord.notifications - GitHub: get_pull_request_review_comments(), open PRs include author - /verify: run storage calls in asyncio.to_thread() to avoid blocking event loop and fix 'application did not respond' on new setups Made-with: Cursor
1 parent 414cb8e commit 4bb180f

6 files changed

Lines changed: 234 additions & 53 deletions

File tree

config/example.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ discord:
3333
# Optional: channel names where PR URLs trigger passive preview (requires message content intent)
3434
# Example: ["mentor-chat", "review-queue"]
3535
pr_preview_channels: []
36+
# Optional: verified-only GitHub → Discord notifications (issue assigned, PR review, merged, etc.)
37+
# notifications:
38+
# enabled: true
39+
# issue_assignment: true
40+
# pr_review_requested: true
41+
# pr_review_result: true
42+
# pr_merged: true
43+
# coderabbit_reminders: false
44+
# coderabbit_reminder_after_hours: 48
45+
# coderabbit_bot_logins: ["coderabbitai", "coderabbitai[bot]"]
46+
# channel_id: null # null = DM; set to channel ID to post there
3647

3748
scoring:
3849
period_days: 30

src/ghdcbot/adapters/github/rest.py

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

11291143
def _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

11491163
def _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

12081222
def _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

Comments
 (0)