diff --git a/GUI/main.py b/GUI/main.py index ac0f147..ff2ffad 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -447,7 +447,7 @@ def on_open_feed_event(self, event): self._open_feed_issue(owner, repo_name, number) return - elif feed_event.type in ("PullRequestEvent", "PullRequestReviewEvent", "PullRequestReviewCommentEvent"): + elif feed_event.type in ("PullRequestEvent", "PullRequestReviewEvent", "PullRequestReviewCommentEvent", "PullRequestReviewThreadEvent"): pr = payload.get("pull_request", {}) number = pr.get("number") if number: @@ -468,7 +468,7 @@ def on_open_feed_event(self, event): self._open_feed_discussion(owner, repo_name, number) return - elif feed_event.type == "PushEvent": + elif feed_event.type in ("PushEvent", "CommitCommentEvent"): self._open_feed_commits(owner, repo_name) return @@ -476,7 +476,7 @@ def on_open_feed_event(self, event): self._open_feed_releases(owner, repo_name) return - elif feed_event.type in ("WatchEvent", "ForkEvent", "CreateEvent", "PublicEvent"): + elif feed_event.type in ("WatchEvent", "ForkEvent", "CreateEvent", "PublicEvent", "GollumEvent", "MemberEvent"): # For repo-level events, show the repo dialog self._open_feed_repo_direct(owner, repo_name) return @@ -934,7 +934,7 @@ def _render_feed_list(self): def _extract_feed_pr_key(self, event): """Extract a normalized key for PR-related feed events.""" - if event.type not in ("PullRequestEvent", "PullRequestReviewEvent", "PullRequestReviewCommentEvent"): + if event.type not in ("PullRequestEvent", "PullRequestReviewEvent", "PullRequestReviewCommentEvent", "PullRequestReviewThreadEvent"): return None pr = event.payload.get("pull_request", {}) or {} diff --git a/GUI/pullrequests.py b/GUI/pullrequests.py index 0a89775..11a67bf 100644 --- a/GUI/pullrequests.py +++ b/GUI/pullrequests.py @@ -447,7 +447,8 @@ def update_comments(self, comments): for comment in comments: time_str = comment.created_at.strftime("%Y-%m-%d %H:%M") if comment.created_at else "Unknown" preview = comment.body[:50].replace("\n", " ") + "..." if len(comment.body) > 50 else comment.body.replace("\n", " ") - self.comments_list.Append(f"{comment.user.login} ({time_str}): {preview}") + source = "[Review] " if comment.kind == "review" else "" + self.comments_list.Append(f"{comment.user.login} ({time_str}): {source}{preview}") def on_comment_select(self, event): """Show selected comment content.""" diff --git a/github_api.py b/github_api.py index a0a2e57..68b0238 100644 --- a/github_api.py +++ b/github_api.py @@ -737,9 +737,47 @@ def close_pull_request(self, owner: str, repo: str, number: int) -> bool: result = self.update_pull_request(owner, repo, number, state="closed") return result is not None + def get_pr_review_comments(self, owner: str, repo: str, number: int, per_page: int = 100) -> list[Comment]: + """Get review comments on a pull request.""" + comments = [] + page = 1 + + while True: + response = self._session.get( + f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{number}/comments", + params={ + "per_page": per_page, + "page": page + } + ) + + if response.status_code != 200: + break + + data = response.json() + if not data: + break + + for item in data: + comments.append(Comment.from_github_api(item, kind="review")) + + if len(data) < per_page: + break + + page += 1 + + return comments + def get_pr_comments(self, owner: str, repo: str, number: int, per_page: int = 100) -> list[Comment]: - """Get comments on a pull request (issue comments, not review comments).""" - return self.get_issue_comments(owner, repo, number, per_page) + """Get all comments on a pull request, including review comments.""" + issue_comments = self.get_issue_comments(owner, repo, number, per_page) + review_comments = self.get_pr_review_comments(owner, repo, number, per_page) + comments = issue_comments + review_comments + + # Keep stable ordering by creation time so the dialog mirrors issue behavior. + comments.sort(key=lambda c: c.created_at.timestamp() if c.created_at else 0) + + return comments def create_pr_comment(self, owner: str, repo: str, number: int, body: str) -> Comment | None: """Create a comment on a pull request.""" diff --git a/models/event.py b/models/event.py index da761c0..5078421 100644 --- a/models/event.py +++ b/models/event.py @@ -63,6 +63,7 @@ class Event: "PublicEvent": "made public", "PullRequestEvent": "pull request", "PullRequestReviewEvent": "reviewed PR", + "PullRequestReviewThreadEvent": "updated PR review thread", "PullRequestReviewCommentEvent": "commented on PR review", "PushEvent": "pushed", "ReleaseEvent": "released", @@ -395,6 +396,18 @@ def get_action_description(self) -> str: return f"commented on PR #{number}: {title}" return f"commented on PR #{number}" + elif self.type == "PullRequestReviewThreadEvent": + action = payload.get("action", "") + pr = payload.get("pull_request", {}) + number = pr.get("number", "") + title = pr.get("title", "")[:50] + title_suffix = f": {title}" if title else "" + if action == "resolved": + return f"resolved review thread on PR #{number}{title_suffix}" + elif action == "unresolved": + return f"unresolved review thread on PR #{number}{title_suffix}" + return f"{action} review thread on PR #{number}{title_suffix}" + elif self.type == "ReleaseEvent": action = payload.get("action", "") release = payload.get("release", {}) @@ -486,7 +499,27 @@ def get_web_url(self) -> str: if number: return f"{base_url}/pull/{number}" - elif self.type == "PullRequestReviewEvent" or self.type == "PullRequestReviewCommentEvent": + elif self.type == "PullRequestReviewCommentEvent": + comment = self.payload.get("comment", {}) + html_url = comment.get("html_url") + if html_url: + return html_url + pr = self.payload.get("pull_request", {}) + number = pr.get("number") + if number: + return f"{base_url}/pull/{number}" + + elif self.type == "PullRequestReviewThreadEvent": + thread = self.payload.get("thread", {}) + html_url = thread.get("html_url") + if html_url: + return html_url + pr = self.payload.get("pull_request", {}) + number = pr.get("number") + if number: + return f"{base_url}/pull/{number}" + + elif self.type == "PullRequestReviewEvent": pr = self.payload.get("pull_request", {}) number = pr.get("number") if number: diff --git a/models/issue.py b/models/issue.py index f6ad7cd..463eaef 100644 --- a/models/issue.py +++ b/models/issue.py @@ -48,9 +48,10 @@ class Comment: created_at: Optional[datetime] updated_at: Optional[datetime] html_url: str = "" + kind: str = "issue" @classmethod - def from_github_api(cls, data: dict) -> 'Comment': + def from_github_api(cls, data: dict, kind: str = "issue") -> 'Comment': created_at = None if data.get('created_at'): try: @@ -71,7 +72,8 @@ def from_github_api(cls, data: dict) -> 'Comment': user=User.from_github_api(data.get('user')), created_at=created_at, updated_at=updated_at, - html_url=data.get('html_url', '') + html_url=data.get('html_url', ''), + kind=kind ) diff --git a/tests/test_feed_pr_review_events.py b/tests/test_feed_pr_review_events.py new file mode 100644 index 0000000..5bd9666 --- /dev/null +++ b/tests/test_feed_pr_review_events.py @@ -0,0 +1,103 @@ +from models.event import Event + + +def _make_event(event_type: str, payload: dict) -> Event: + return Event.from_api( + { + "id": "evt-1", + "type": event_type, + "actor": {"id": 1, "login": "alice", "avatar_url": ""}, + "repo": {"id": 10, "name": "owner/repo", "url": ""}, + "payload": payload, + "public": True, + "created_at": "2026-02-16T11:15:00Z", + } + ) + + +# ── PullRequestReviewThreadEvent: action descriptions ────────────────────── + +def test_pr_review_thread_resolved_with_title(): + event = _make_event( + "PullRequestReviewThreadEvent", + {"action": "resolved", "pull_request": {"number": 42, "title": "Fix the thing"}}, + ) + assert event.get_action_description() == "resolved review thread on PR #42: Fix the thing" + + +def test_pr_review_thread_unresolved_with_title(): + event = _make_event( + "PullRequestReviewThreadEvent", + {"action": "unresolved", "pull_request": {"number": 42, "title": "Fix the thing"}}, + ) + assert event.get_action_description() == "unresolved review thread on PR #42: Fix the thing" + + +def test_pr_review_thread_resolved_no_title(): + event = _make_event( + "PullRequestReviewThreadEvent", + {"action": "resolved", "pull_request": {"number": 7, "title": ""}}, + ) + assert event.get_action_description() == "resolved review thread on PR #7" + + +def test_pr_review_thread_unknown_action_falls_back(): + event = _make_event( + "PullRequestReviewThreadEvent", + {"action": "locked", "pull_request": {"number": 99, "title": "Something"}}, + ) + assert event.get_action_description() == "locked review thread on PR #99: Something" + + +def test_pr_review_thread_in_event_types_dict(): + assert "PullRequestReviewThreadEvent" in Event.EVENT_TYPES + + +# ── PullRequestReviewThreadEvent: web URLs ───────────────────────────────── + +def test_pr_review_thread_web_url_uses_thread_html_url(): + event = _make_event( + "PullRequestReviewThreadEvent", + { + "action": "resolved", + "thread": {"html_url": "https://github.com/owner/repo/pull/42#discussion_r123"}, + "pull_request": {"number": 42}, + }, + ) + assert event.get_web_url() == "https://github.com/owner/repo/pull/42#discussion_r123" + + +def test_pr_review_thread_web_url_falls_back_to_pr_number(): + event = _make_event( + "PullRequestReviewThreadEvent", + { + "action": "resolved", + "thread": {}, + "pull_request": {"number": 42}, + }, + ) + assert event.get_web_url() == "https://github.com/owner/repo/pull/42" + + +# ── PullRequestReviewCommentEvent: web URL uses comment html_url ─────────── + +def test_pr_review_comment_web_url_uses_comment_html_url(): + event = _make_event( + "PullRequestReviewCommentEvent", + { + "comment": {"html_url": "https://github.com/owner/repo/pull/10#pullrequestreview-456"}, + "pull_request": {"number": 10}, + }, + ) + assert event.get_web_url() == "https://github.com/owner/repo/pull/10#pullrequestreview-456" + + +def test_pr_review_comment_web_url_falls_back_to_pr_number(): + event = _make_event( + "PullRequestReviewCommentEvent", + { + "comment": {}, + "pull_request": {"number": 10}, + }, + ) + assert event.get_web_url() == "https://github.com/owner/repo/pull/10"