From 42cf2e312566f04087ae0d464cabd52a48e67874 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:25:21 -0700 Subject: [PATCH 1/5] Improve feed event text with issue and PR titles --- models/event.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/models/event.py b/models/event.py index e9b712a..5f80f6a 100644 --- a/models/event.py +++ b/models/event.py @@ -169,7 +169,14 @@ def get_action_description(self) -> str: action = payload.get("action", "created") issue = payload.get("issue", {}) number = issue.get("number", "") - return f"commented on issue #{number}" + title = issue.get("title", "")[:50] + if action in ("created", "edited"): + if title: + return f"commented on issue #{number}: {title}" + return f"commented on issue #{number}" + if title: + return f"{action} comment on issue #{number}: {title}" + return f"{action} comment on issue #{number}" elif self.type == "PullRequestEvent": action = payload.get("action", "") @@ -189,17 +196,27 @@ def get_action_description(self) -> str: action = payload.get("action", "") pr = payload.get("pull_request", {}) number = pr.get("number", "") + title = pr.get("title", "")[:50] review = payload.get("review", {}) state = review.get("state", "") if state == "approved": + if title: + return f"approved PR #{number}: {title}" return f"approved PR #{number}" elif state == "changes_requested": + if title: + return f"requested changes on PR #{number}: {title}" return f"requested changes on PR #{number}" + if title: + return f"reviewed PR #{number}: {title}" return f"reviewed PR #{number}" elif self.type == "PullRequestReviewCommentEvent": pr = payload.get("pull_request", {}) number = pr.get("number", "") + title = pr.get("title", "")[:50] + if title: + return f"commented on PR #{number}: {title}" return f"commented on PR #{number}" elif self.type == "ReleaseEvent": From 826fec01d93105c9ac74b3a9cd338414c7778b4c Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:59:17 -0700 Subject: [PATCH 2/5] Add native discussion dialog support for feed and notifications --- GUI/discussions.py | 255 +++++++++++++++++++++++++++++++++++++++++++ GUI/main.py | 62 ++++++++++- build.py | 2 + github_api.py | 226 ++++++++++++++++++++++++++++++++++++++ models/__init__.py | 1 + models/discussion.py | 152 ++++++++++++++++++++++++++ models/event.py | 46 ++++++++ 7 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 GUI/discussions.py create mode 100644 models/discussion.py diff --git a/GUI/discussions.py b/GUI/discussions.py new file mode 100644 index 0000000..0a51195 --- /dev/null +++ b/GUI/discussions.py @@ -0,0 +1,255 @@ +"""Discussion dialog for FastGH.""" + +import wx +import webbrowser +import platform +import threading +from application import get_app +from models.discussion import Discussion, DiscussionComment +from . import theme + + +class ViewDiscussionDialog(wx.Dialog): + """Dialog for viewing a discussion and its comments.""" + + def __init__(self, parent, owner: str, repo_name: str, discussion: Discussion): + self.owner = owner + self.repo_name = repo_name + self.discussion = discussion + self.comments = list(discussion.comments) + self.has_next_page = discussion.comments_has_next_page + self.end_cursor = discussion.comments_end_cursor + self.app = get_app() + self.account = self.app.currentAccount + + title = f"Discussion #{discussion.number} - {discussion.title}" + wx.Dialog.__init__(self, parent, title=title, size=(880, 720)) + + self.init_ui() + self.bind_events() + theme.apply_theme(self) + self.update_details() + self.update_comments_list() + + def init_ui(self): + """Initialize the UI.""" + self.panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + details_label = wx.StaticText(self.panel, label="Discussion &Details:") + main_sizer.Add(details_label, 0, wx.LEFT | wx.TOP, 10) + + self.details_text = wx.TextCtrl( + self.panel, + style=wx.TE_READONLY | wx.TE_MULTILINE | wx.TE_DONTWRAP, + size=(840, 110) + ) + main_sizer.Add(self.details_text, 0, wx.EXPAND | wx.ALL, 10) + + body_label = wx.StaticText(self.panel, label="&Body:") + main_sizer.Add(body_label, 0, wx.LEFT, 10) + + self.body_text = wx.TextCtrl( + self.panel, + style=wx.TE_READONLY | wx.TE_MULTILINE, + size=(840, 130) + ) + main_sizer.Add(self.body_text, 0, wx.EXPAND | wx.ALL, 10) + + comments_label = wx.StaticText(self.panel, label="&Comments:") + main_sizer.Add(comments_label, 0, wx.LEFT, 10) + + self.comments_list = wx.ListBox(self.panel, style=wx.LB_SINGLE) + main_sizer.Add(self.comments_list, 1, wx.EXPAND | wx.ALL, 10) + + content_label = wx.StaticText(self.panel, label="Comment C&ontent:") + main_sizer.Add(content_label, 0, wx.LEFT, 10) + + self.comment_text = wx.TextCtrl( + self.panel, + style=wx.TE_READONLY | wx.TE_MULTILINE, + size=(840, 90) + ) + main_sizer.Add(self.comment_text, 0, wx.EXPAND | wx.ALL, 10) + + add_label = wx.StaticText(self.panel, label="Add C&omment:") + main_sizer.Add(add_label, 0, wx.LEFT, 10) + + self.add_comment_text = wx.TextCtrl( + self.panel, + style=wx.TE_MULTILINE, + size=(840, 100) + ) + main_sizer.Add(self.add_comment_text, 0, wx.EXPAND | wx.ALL, 10) + + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.refresh_btn = wx.Button(self.panel, label="&Refresh Comments") + btn_sizer.Add(self.refresh_btn, 0, wx.RIGHT, 5) + + self.load_more_btn = wx.Button(self.panel, label="Load &More") + btn_sizer.Add(self.load_more_btn, 0, wx.RIGHT, 5) + + self.post_comment_btn = wx.Button(self.panel, label="&Post Comment") + btn_sizer.Add(self.post_comment_btn, 0, wx.RIGHT, 5) + + self.open_browser_btn = wx.Button(self.panel, label="Open in &Browser") + btn_sizer.Add(self.open_browser_btn, 0, wx.RIGHT, 5) + + self.close_btn = wx.Button(self.panel, wx.ID_CLOSE, label="Cl&ose") + btn_sizer.Add(self.close_btn, 0) + + main_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) + + self.panel.SetSizer(main_sizer) + self.body_text.SetFocus() + + def bind_events(self): + """Bind event handlers.""" + self.Bind(wx.EVT_CLOSE, self.on_close) + self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook) + self.comments_list.Bind(wx.EVT_LISTBOX, self.on_comment_select) + self.refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh_comments) + self.load_more_btn.Bind(wx.EVT_BUTTON, self.on_load_more_comments) + self.post_comment_btn.Bind(wx.EVT_BUTTON, self.on_post_comment) + self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser) + self.close_btn.Bind(wx.EVT_BUTTON, self.on_close) + + def on_char_hook(self, event): + """Handle key events.""" + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.on_close(None) + else: + event.Skip() + + def update_details(self): + """Update discussion details section.""" + details = [] + details.append(f"Title: {self.discussion.title}") + details.append(f"Author: {self.discussion.author.login}") + if self.discussion.category_name: + details.append(f"Category: {self.discussion.category_name}") + details.append(f"Answered: {'Yes' if self.discussion.is_answered else 'No'}") + details.append(f"Comments: {self.discussion.comments_count}") + if self.discussion.created_at: + details.append(f"Created: {self.discussion._format_relative_time(self.discussion.created_at)}") + if self.discussion.updated_at: + details.append(f"Updated: {self.discussion._format_relative_time(self.discussion.updated_at)}") + + sep = "\r\n" if platform.system() != "Darwin" else "\n" + self.details_text.SetValue(sep.join(details)) + self.body_text.SetValue(self.discussion.body or "No description provided.") + self.load_more_btn.Enable(self.has_next_page) + + def update_comments_list(self): + """Update comments list from local cache.""" + self.comments_list.Clear() + self.comment_text.SetValue("") + + if not self.comments: + self.comments_list.Append("No comments yet") + return + + for comment in self.comments: + if comment.created_at: + time_str = comment.created_at.strftime("%Y-%m-%d %H:%M") + else: + time_str = "Unknown" + preview = comment.body.replace("\n", " ") + if len(preview) > 60: + preview = preview[:60] + "..." + self.comments_list.Append(f"{comment.author.login} ({time_str}): {preview}") + + def _set_comments_loaded(self, comments: list[DiscussionComment], has_next_page: bool, end_cursor: str | None): + """Replace comments list after a refresh.""" + self.comments = comments + self.has_next_page = has_next_page + self.end_cursor = end_cursor + self.update_comments_list() + self.load_more_btn.Enable(self.has_next_page) + + def _append_comments_loaded(self, comments: list[DiscussionComment], has_next_page: bool, end_cursor: str | None): + """Append one page of comments.""" + if comments: + self.comments.extend(comments) + self.has_next_page = has_next_page + self.end_cursor = end_cursor + self.update_comments_list() + self.load_more_btn.Enable(self.has_next_page) + + def on_comment_select(self, event): + """Show selected comment content.""" + selection = self.comments_list.GetSelection() + if selection != wx.NOT_FOUND and selection < len(self.comments): + self.comment_text.SetValue(self.comments[selection].body) + + def on_refresh_comments(self, event): + """Reload first page of comments.""" + self.comments_list.Clear() + self.comments_list.Append("Loading comments...") + self.comment_text.SetValue("") + + def do_load(): + comments, has_next_page, end_cursor = self.account.get_discussion_comments( + self.owner, self.repo_name, self.discussion.number, first=50 + ) + wx.CallAfter(self._set_comments_loaded, comments, has_next_page, end_cursor) + + threading.Thread(target=do_load, daemon=True).start() + + def on_load_more_comments(self, event): + """Load next page of comments.""" + if not self.has_next_page: + return + + self.load_more_btn.Enable(False) + + def do_load(): + comments, has_next_page, end_cursor = self.account.get_discussion_comments( + self.owner, + self.repo_name, + self.discussion.number, + first=50, + after=self.end_cursor + ) + wx.CallAfter(self._append_comments_loaded, comments, has_next_page, end_cursor) + + threading.Thread(target=do_load, daemon=True).start() + + def _on_post_comment_done(self, new_comment: DiscussionComment | None): + """Handle posted comment result on main thread.""" + self.post_comment_btn.Enable(True) + if not new_comment: + wx.MessageBox("Failed to add comment.", "Error", wx.OK | wx.ICON_ERROR) + return + + self.comments.append(new_comment) + self.discussion.comments_count += 1 + self.add_comment_text.SetValue("") + self.update_details() + self.update_comments_list() + self.comments_list.SetSelection(len(self.comments) - 1) + self.comment_text.SetValue(new_comment.body) + + def on_post_comment(self, event): + """Post a new comment.""" + body = self.add_comment_text.GetValue().strip() + if not body: + wx.MessageBox("Please enter a comment.", "Error", wx.OK | wx.ICON_ERROR) + return + + self.post_comment_btn.Enable(False) + + def do_post(): + new_comment = self.account.create_discussion_comment(self.discussion.id, body) + wx.CallAfter(self._on_post_comment_done, new_comment) + + threading.Thread(target=do_post, daemon=True).start() + + def on_open_browser(self, event): + """Open discussion in browser.""" + webbrowser.open(self.discussion.url) + + def on_close(self, event): + """Close dialog.""" + self.EndModal(wx.ID_CLOSE) diff --git a/GUI/main.py b/GUI/main.py index df8b4ed..480860f 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -435,6 +435,20 @@ def on_open_feed_event(self, event): self._open_feed_pr(owner, repo_name, number) return + elif feed_event.type in ("DiscussionEvent", "DiscussionCommentEvent", "Discussion"): + discussion = payload.get("discussion", {}) + number = discussion.get("number") + if not number: + discussion_url = ( + discussion.get("html_url") + or payload.get("comment", {}).get("html_url") + or feed_event.get_web_url() + ) + number = self._extract_discussion_number(discussion_url) + if number: + self._open_feed_discussion(owner, repo_name, number) + return + elif feed_event.type == "PushEvent": self._open_feed_commits(owner, repo_name) return @@ -494,6 +508,44 @@ def _show_pr_dialog(self, repo, pr, can_merge): dlg.ShowModal() dlg.Destroy() + def _extract_discussion_number(self, url: str) -> int | None: + """Extract discussion number from a discussion URL.""" + if not url: + return None + + parts = url.rstrip("/").split("/") + for i, part in enumerate(parts): + if part == "discussions" and i + 1 < len(parts): + try: + return int(parts[i + 1]) + except ValueError: + return None + return None + + def _open_feed_discussion(self, owner: str, repo_name: str, number: int): + """Open a discussion from the feed.""" + def fetch_and_show(): + discussion = self.app.currentAccount.get_discussion(owner, repo_name, number, comments_first=50) + if discussion: + wx.CallAfter(self._show_discussion_dialog, owner, repo_name, discussion) + else: + reason = self.app.currentAccount.get_last_error() + message = f"Could not load discussion #{number}." + if reason: + message += f"\n\nReason: {reason}" + wx.CallAfter(wx.MessageBox, message, "Error", wx.OK | wx.ICON_ERROR) + + self.status_bar.SetStatusText(f"Loading discussion #{number}...") + threading.Thread(target=fetch_and_show, daemon=True).start() + + def _show_discussion_dialog(self, owner: str, repo_name: str, discussion): + """Show the discussion dialog.""" + self.status_bar.SetStatusText("Ready") + from GUI.discussions import ViewDiscussionDialog + dlg = ViewDiscussionDialog(self, owner, repo_name, discussion) + dlg.ShowModal() + dlg.Destroy() + def _open_feed_commits(self, owner: str, repo_name: str): """Open commits dialog from the feed.""" def fetch_and_show(): @@ -1101,18 +1153,22 @@ def on_open_notification(self, event): number = int(parts[-1]) except ValueError: pass + if subject_type == "Discussion" and not number: + number = self._extract_discussion_number(notification.get_web_url()) # Handle different notification types if subject_type == "Issue" and number: self._open_notification_issue(owner, repo_name, number) elif subject_type == "PullRequest" and number: self._open_notification_pr(owner, repo_name, number) + elif subject_type == "Discussion" and number: + self._open_notification_discussion(owner, repo_name, number) elif subject_type == "Release": self._open_notification_releases(owner, repo_name) elif subject_type == "Commit": self._open_notification_commits(owner, repo_name) else: - # Fallback to browser for unsupported types (Discussion, etc.) + # Fallback to browser for unsupported types. url = notification.get_web_url() if url: webbrowser.open(url) @@ -1147,6 +1203,10 @@ def fetch_and_show(): self.status_bar.SetStatusText(f"Loading PR #{number}...") threading.Thread(target=fetch_and_show, daemon=True).start() + def _open_notification_discussion(self, owner: str, repo_name: str, number: int): + """Open a discussion from notification.""" + self._open_feed_discussion(owner, repo_name, number) + def _open_notification_releases(self, owner: str, repo_name: str): """Open releases dialog from notification.""" def fetch_and_show(): diff --git a/build.py b/build.py index 8ad9aa5..783bd10 100644 --- a/build.py +++ b/build.py @@ -77,6 +77,7 @@ def get_hidden_imports(): "models.release", "models.notification", "models.event", + "models.discussion", "GUI", "GUI.main", "GUI.view", @@ -84,6 +85,7 @@ def get_hidden_imports(): "GUI.accounts", "GUI.issues", "GUI.pullrequests", + "GUI.discussions", "GUI.commits", "GUI.actions", "GUI.releases", diff --git a/github_api.py b/github_api.py index 1e59912..eb3cba1 100644 --- a/github_api.py +++ b/github_api.py @@ -15,6 +15,7 @@ from models.notification import Notification from models.event import Event from models.content import ContentItem +from models.discussion import Discussion, DiscussionComment # GitHub OAuth App Client ID # You need to create an OAuth App at https://github.com/settings/developers @@ -125,6 +126,7 @@ def __init__(self, app, index): self.index = index self.ready = False self.me = None + self._last_error = "" self._session = requests.Session() # Load config @@ -419,6 +421,67 @@ def display_name(self) -> str: return self.me.get("name") or self.me.get("login", "") return "" + def get_last_error(self) -> str: + """Get the last API error message, if any.""" + return self._last_error + + def _set_last_error(self, message: str = ""): + """Store a concise API error message for UI reporting.""" + self._last_error = (message or "").strip() + + def _graphql(self, query: str, variables: dict = None) -> dict | None: + """Execute a GitHub GraphQL query/mutation.""" + self._set_last_error("") + try: + response = self._session.post( + f"{GITHUB_API_URL}/graphql", + json={ + "query": query, + "variables": variables or {} + } + ) + except Exception as e: + self._set_last_error(f"GraphQL request failed: {e}") + return None + + if response.status_code != 200: + body = "" + try: + body = response.text.strip() + except Exception: + body = "" + if body: + self._set_last_error(f"GraphQL HTTP {response.status_code}: {body[:200]}") + else: + self._set_last_error(f"GraphQL HTTP {response.status_code}") + return None + + try: + payload = response.json() + except Exception as e: + self._set_last_error(f"Invalid GraphQL response: {e}") + return None + + errors = payload.get("errors") or [] + if errors: + messages = [] + for err in errors[:3]: + msg = err.get("message") + if msg: + messages.append(msg) + if messages: + self._set_last_error("GraphQL error: " + " | ".join(messages)) + else: + self._set_last_error("GraphQL returned errors.") + return None + + data = payload.get("data") + if data is None: + self._set_last_error("GraphQL returned no data.") + return None + + return data + # ============ Issues API ============ def get_issues(self, owner: str, repo: str, state: str = "open", per_page: int = 100) -> list[Issue]: @@ -682,6 +745,169 @@ def create_pr_comment(self, owner: str, repo: str, number: int, body: str) -> Co """Create a comment on a pull request.""" return self.create_issue_comment(owner, repo, number, body) + # ============ Discussions API (GraphQL) ============ + + def get_discussion(self, owner: str, repo: str, number: int, comments_first: int = 50) -> Discussion | None: + """Get a discussion and its first page of comments.""" + query = """ + query GetDiscussion($owner: String!, $repo: String!, $number: Int!, $commentsFirst: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + number + title + body + url + isAnswered + createdAt + updatedAt + author { + login + avatarUrl + } + category { + name + } + comments(first: $commentsFirst) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + databaseId + body + url + createdAt + updatedAt + author { + login + avatarUrl + } + } + } + } + } + } + """ + + data = self._graphql( + query=query, + variables={ + "owner": owner, + "repo": repo, + "number": number, + "commentsFirst": comments_first + } + ) + if not data: + return None + + repo_data = data.get("repository") or {} + discussion_data = repo_data.get("discussion") + if not discussion_data: + self._set_last_error("Discussion not found or access denied.") + return None + + self._set_last_error("") + return Discussion.from_graphql(discussion_data) + + def get_discussion_comments(self, owner: str, repo: str, number: int, + first: int = 50, after: str = None) -> tuple[list[DiscussionComment], bool, str | None]: + """Get one page of comments for a discussion.""" + query = """ + query GetDiscussionComments($owner: String!, $repo: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + comments(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + databaseId + body + url + createdAt + updatedAt + author { + login + avatarUrl + } + } + } + } + } + } + """ + + data = self._graphql( + query=query, + variables={ + "owner": owner, + "repo": repo, + "number": number, + "first": first, + "after": after + } + ) + if not data: + return [], False, None + + repo_data = data.get("repository") or {} + discussion_data = repo_data.get("discussion") or {} + comments_connection = discussion_data.get("comments") or {} + nodes = comments_connection.get("nodes", []) or [] + page_info = comments_connection.get("pageInfo", {}) or {} + + comments = [DiscussionComment.from_graphql(item) for item in nodes] + has_next_page = page_info.get("hasNextPage", False) + end_cursor = page_info.get("endCursor") + self._set_last_error("") + return comments, has_next_page, end_cursor + + def create_discussion_comment(self, discussion_id: str, body: str) -> DiscussionComment | None: + """Create a comment on a discussion.""" + mutation = """ + mutation AddDiscussionComment($discussionId: ID!, $body: String!) { + addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { + comment { + id + databaseId + body + url + createdAt + updatedAt + author { + login + avatarUrl + } + } + } + } + """ + + data = self._graphql( + query=mutation, + variables={ + "discussionId": discussion_id, + "body": body + } + ) + if not data: + return None + + add_comment = data.get("addDiscussionComment") or {} + comment_data = add_comment.get("comment") + if not comment_data: + self._set_last_error("Comment was not created.") + return None + + self._set_last_error("") + return DiscussionComment.from_graphql(comment_data) + # ============ Repository Permissions ============ def get_repo_permission(self, owner: str, repo: str) -> str | None: diff --git a/models/__init__.py b/models/__init__.py index 6225aa7..aac2a9f 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -6,3 +6,4 @@ from .user import UserProfile from .workflow import Workflow, WorkflowRun, WorkflowJob from .release import Release, ReleaseAsset +from .discussion import Discussion, DiscussionComment diff --git a/models/discussion.py b/models/discussion.py new file mode 100644 index 0000000..28470ad --- /dev/null +++ b/models/discussion.py @@ -0,0 +1,152 @@ +"""Discussion data models.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from .issue import User + + +def _parse_datetime(value: str | None) -> Optional[datetime]: + """Parse ISO datetime strings from GitHub APIs.""" + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def _parse_author(data: dict | None) -> User: + """Parse GraphQL author object into the shared User model.""" + if not data: + return User(login="unknown", id=0, avatar_url="") + raw_id = data.get("databaseId") + if isinstance(raw_id, int): + author_id = raw_id + else: + try: + author_id = int(raw_id) if raw_id is not None else 0 + except (TypeError, ValueError): + author_id = 0 + return User( + login=data.get("login", "unknown"), + id=author_id, + avatar_url=data.get("avatarUrl", "") + ) + + +@dataclass +class DiscussionComment: + """GitHub discussion comment model.""" + id: str + database_id: Optional[int] + body: str + author: User + created_at: Optional[datetime] + updated_at: Optional[datetime] + url: str = "" + + @classmethod + def from_graphql(cls, data: dict) -> "DiscussionComment": + return cls( + id=data.get("id", ""), + database_id=data.get("databaseId"), + body=data.get("body", ""), + author=_parse_author(data.get("author")), + created_at=_parse_datetime(data.get("createdAt")), + updated_at=_parse_datetime(data.get("updatedAt")), + url=data.get("url", "") + ) + + def _format_relative_time(self, dt: datetime) -> str: + """Format datetime as relative time.""" + if not dt: + return "Unknown" + + now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() + diff = now - dt + + if diff.days > 365: + years = diff.days // 365 + return f"{years} year{'s' if years > 1 else ''} ago" + elif diff.days > 30: + months = diff.days // 30 + return f"{months} month{'s' if months > 1 else ''} ago" + elif diff.days > 0: + return f"{diff.days} day{'s' if diff.days > 1 else ''} ago" + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + return f"{hours} hour{'s' if hours > 1 else ''} ago" + elif diff.seconds > 60: + minutes = diff.seconds // 60 + return f"{minutes} minute{'s' if minutes > 1 else ''} ago" + else: + return "just now" + + +@dataclass +class Discussion: + """GitHub discussion model.""" + id: str + number: int + title: str + body: Optional[str] + author: User + category_name: str + is_answered: bool + comments_count: int + created_at: Optional[datetime] + updated_at: Optional[datetime] + url: str + comments: list[DiscussionComment] = field(default_factory=list) + comments_has_next_page: bool = False + comments_end_cursor: Optional[str] = None + + @classmethod + def from_graphql(cls, data: dict) -> "Discussion": + comments_connection = data.get("comments", {}) or {} + comment_nodes = comments_connection.get("nodes", []) or [] + page_info = comments_connection.get("pageInfo", {}) or {} + + return cls( + id=data.get("id", ""), + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", ""), + author=_parse_author(data.get("author")), + category_name=(data.get("category") or {}).get("name", ""), + is_answered=data.get("isAnswered", False), + comments_count=comments_connection.get("totalCount", 0), + created_at=_parse_datetime(data.get("createdAt")), + updated_at=_parse_datetime(data.get("updatedAt")), + url=data.get("url", ""), + comments=[DiscussionComment.from_graphql(item) for item in comment_nodes], + comments_has_next_page=page_info.get("hasNextPage", False), + comments_end_cursor=page_info.get("endCursor") + ) + + def _format_relative_time(self, dt: datetime) -> str: + """Format datetime as relative time.""" + if not dt: + return "Unknown" + + now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() + diff = now - dt + + if diff.days > 365: + years = diff.days // 365 + return f"{years} year{'s' if years > 1 else ''} ago" + elif diff.days > 30: + months = diff.days // 30 + return f"{months} month{'s' if months > 1 else ''} ago" + elif diff.days > 0: + return f"{diff.days} day{'s' if diff.days > 1 else ''} ago" + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + return f"{hours} hour{'s' if hours > 1 else ''} ago" + elif diff.seconds > 60: + minutes = diff.seconds // 60 + return f"{minutes} minute{'s' if minutes > 1 else ''} ago" + else: + return "just now" diff --git a/models/event.py b/models/event.py index 5f80f6a..e8dc406 100644 --- a/models/event.py +++ b/models/event.py @@ -53,6 +53,8 @@ class Event: "CommitCommentEvent": "commented on a commit", "CreateEvent": "created", "DeleteEvent": "deleted", + "DiscussionCommentEvent": "commented on discussion", + "DiscussionEvent": "discussion", "ForkEvent": "forked", "GollumEvent": "updated wiki", "IssueCommentEvent": "commented on issue", @@ -143,6 +145,28 @@ def get_action_description(self) -> str: ref = payload.get("ref", "") return f"deleted {ref_type} {ref}" + elif self.type == "DiscussionEvent": + action = payload.get("action", "") + discussion = payload.get("discussion", {}) + number = discussion.get("number", "") + title = discussion.get("title", "")[:50] + if title: + return f"{action} discussion #{number}: {title}" + return f"{action} discussion #{number}" + + elif self.type == "DiscussionCommentEvent": + action = payload.get("action", "created") + discussion = payload.get("discussion", {}) + number = discussion.get("number", "") + title = discussion.get("title", "")[:50] + if action in ("created", "edited"): + if title: + return f"commented on discussion #{number}: {title}" + return f"commented on discussion #{number}" + if title: + return f"{action} comment on discussion #{number}: {title}" + return f"{action} comment on discussion #{number}" + elif self.type == "PushEvent": # size can be 0 for force pushes, use distinct_size or commits array as fallback size = payload.get("size", 0) @@ -282,6 +306,28 @@ def get_web_url(self) -> str: if number: return f"{base_url}/issues/{number}" + elif self.type == "DiscussionEvent": + discussion = self.payload.get("discussion", {}) + html_url = discussion.get("html_url") + if html_url: + return html_url + number = discussion.get("number") + if number: + return f"{base_url}/discussions/{number}" + + elif self.type == "DiscussionCommentEvent": + comment = self.payload.get("comment", {}) + html_url = comment.get("html_url") + if html_url: + return html_url + discussion = self.payload.get("discussion", {}) + html_url = discussion.get("html_url") + if html_url: + return html_url + number = discussion.get("number") + if number: + return f"{base_url}/discussions/{number}" + elif self.type == "PullRequestEvent": pr = self.payload.get("pull_request", {}) number = pr.get("number") From d31f3b60af7622bbbe401590292ebb8695ca48d3 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:34:25 -0700 Subject: [PATCH 3/5] Backfill missing PR titles in activity feed with cached lookups --- GUI/main.py | 119 +++++++++++++++++++++++++++++++++++++++++++++--- models/event.py | 14 ++++-- 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/GUI/main.py b/GUI/main.py index 480860f..a2faccb 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -124,6 +124,9 @@ def __init__(self, title): self._last_notification_ids = None self._last_starred_ids = None self._last_watched_ids = None + # Feed PR title backfill cache: {(owner, repo, number): title_or_none} + self._feed_pr_title_cache = {} + self._feed_pr_title_backfill_running = False # Auto-refresh timer self.auto_refresh_timer = wx.Timer(self) @@ -852,20 +855,122 @@ def _load_feed(self): except Exception as e: wx.CallAfter(self.status_bar.SetStatusText, f"Error loading feed: {e}") - def _update_feed_list(self): - """Update feed list on main thread.""" - # Check for new items and notify - self._check_and_notify_feed(self.feed) - - # Preserve selection + def _render_feed_list(self): + """Render feed list from current event data while preserving selection.""" selection = self.feed_list.GetSelection() self.feed_list.Clear() for event in self.feed: self.feed_list.Append(event.format_display()) - # Restore selection if still valid if selection != wx.NOT_FOUND and selection < self.feed_list.GetCount(): self.feed_list.SetSelection(selection) + + def _extract_feed_pr_key(self, event): + """Extract a normalized key for PR-related feed events.""" + if event.type not in ("PullRequestEvent", "PullRequestReviewEvent", "PullRequestReviewCommentEvent"): + return None + + pr = event.payload.get("pull_request", {}) or {} + number = pr.get("number") + if not number: + return None + + try: + number = int(number) + except (TypeError, ValueError): + return None + + parts = event.repo.name.split("/") + if len(parts) != 2: + return None + owner, repo_name = parts + return (owner, repo_name, number) + + def _apply_cached_pr_titles_to_feed(self) -> bool: + """Apply cached PR titles into feed payloads where title is missing.""" + updated = False + for event in self.feed: + key = self._extract_feed_pr_key(event) + if not key: + continue + + pr = event.payload.get("pull_request", {}) or {} + if (pr.get("title") or "").strip(): + continue + + if key not in self._feed_pr_title_cache: + continue + + cached_title = self._feed_pr_title_cache.get(key) + if cached_title: + pr["title"] = cached_title + event.payload["pull_request"] = pr + updated = True + return updated + + def _collect_missing_feed_pr_title_keys(self): + """Collect unique PR keys that still need title backfill.""" + keys = [] + seen = set() + + for event in self.feed: + key = self._extract_feed_pr_key(event) + if not key or key in seen: + continue + seen.add(key) + + pr = event.payload.get("pull_request", {}) or {} + if (pr.get("title") or "").strip(): + continue + + # Skip if cached (including known-missing = None) + if key in self._feed_pr_title_cache: + continue + + keys.append(key) + return keys + + def _finish_feed_pr_title_backfill(self): + """Finalize one backfill batch and queue next if needed.""" + self._feed_pr_title_backfill_running = False + if self._apply_cached_pr_titles_to_feed(): + self._render_feed_list() + self._start_feed_pr_title_backfill() + + def _start_feed_pr_title_backfill(self): + """Backfill missing PR titles for feed rows using minimal API calls.""" + if self._feed_pr_title_backfill_running: + return + + missing_keys = self._collect_missing_feed_pr_title_keys() + if not missing_keys: + return + + # Keep call volume low per refresh cycle. + batch = missing_keys[:8] + self._feed_pr_title_backfill_running = True + + def do_backfill(): + for owner, repo_name, number in batch: + pr = self.app.currentAccount.get_pull_request(owner, repo_name, number) + if pr and (pr.title or "").strip(): + self._feed_pr_title_cache[(owner, repo_name, number)] = pr.title + else: + # Cache misses too so we don't re-query repeatedly. + self._feed_pr_title_cache[(owner, repo_name, number)] = None + wx.CallAfter(self._finish_feed_pr_title_backfill) + + threading.Thread(target=do_backfill, daemon=True).start() + + def _update_feed_list(self): + """Update feed list on main thread.""" + # Check for new items and notify + self._check_and_notify_feed(self.feed) + + # Apply any previously fetched titles before rendering. + self._apply_cached_pr_titles_to_feed() + self._render_feed_list() self._update_status() + self._start_feed_pr_title_backfill() def _load_repos(self): """Load user's repositories in background.""" diff --git a/models/event.py b/models/event.py index e8dc406..b067d46 100644 --- a/models/event.py +++ b/models/event.py @@ -208,12 +208,20 @@ def get_action_description(self) -> str: number = pr.get("number", "") title = pr.get("title", "")[:50] if action == "opened": - return f"opened PR #{number}: {title}" + if title: + return f"opened PR #{number}: {title}" + return f"opened PR #{number}" elif action == "closed": merged = pr.get("merged", False) if merged: - return f"merged PR #{number}: {title}" - return f"closed PR #{number}: {title}" + if title: + return f"merged PR #{number}: {title}" + return f"merged PR #{number}" + if title: + return f"closed PR #{number}: {title}" + return f"closed PR #{number}" + if title: + return f"{action} PR #{number}: {title}" return f"{action} PR #{number}" elif self.type == "PullRequestReviewEvent": From 55c3067bbf97988cdf376289d76da41583f58bbe Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:39:50 -0700 Subject: [PATCH 4/5] Backfill missing issue titles in activity feed --- GUI/main.py | 132 +++++++++++++++++++++++++++++++++++------------- models/event.py | 4 +- 2 files changed, 99 insertions(+), 37 deletions(-) diff --git a/GUI/main.py b/GUI/main.py index a2faccb..8ffb46c 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -124,9 +124,10 @@ def __init__(self, title): self._last_notification_ids = None self._last_starred_ids = None self._last_watched_ids = None - # Feed PR title backfill cache: {(owner, repo, number): title_or_none} + # Feed title backfill caches: {(owner, repo, number): title_or_none} self._feed_pr_title_cache = {} - self._feed_pr_title_backfill_running = False + self._feed_issue_title_cache = {} + self._feed_title_backfill_running = False # Auto-refresh timer self.auto_refresh_timer = wx.Timer(self) @@ -885,6 +886,27 @@ def _extract_feed_pr_key(self, event): owner, repo_name = parts return (owner, repo_name, number) + def _extract_feed_issue_key(self, event): + """Extract a normalized key for issue-related feed events.""" + if event.type not in ("IssuesEvent", "IssueCommentEvent"): + return None + + issue = event.payload.get("issue", {}) or {} + number = issue.get("number") + if not number: + return None + + try: + number = int(number) + except (TypeError, ValueError): + return None + + parts = event.repo.name.split("/") + if len(parts) != 2: + return None + owner, repo_name = parts + return (owner, repo_name, number) + def _apply_cached_pr_titles_to_feed(self) -> bool: """Apply cached PR titles into feed payloads where title is missing.""" updated = False @@ -907,57 +929,95 @@ def _apply_cached_pr_titles_to_feed(self) -> bool: updated = True return updated - def _collect_missing_feed_pr_title_keys(self): - """Collect unique PR keys that still need title backfill.""" - keys = [] - seen = set() - + def _apply_cached_issue_titles_to_feed(self) -> bool: + """Apply cached issue titles into feed payloads where title is missing.""" + updated = False for event in self.feed: - key = self._extract_feed_pr_key(event) - if not key or key in seen: + key = self._extract_feed_issue_key(event) + if not key: continue - seen.add(key) - pr = event.payload.get("pull_request", {}) or {} - if (pr.get("title") or "").strip(): + issue = event.payload.get("issue", {}) or {} + if (issue.get("title") or "").strip(): continue - # Skip if cached (including known-missing = None) - if key in self._feed_pr_title_cache: + if key not in self._feed_issue_title_cache: continue - keys.append(key) - return keys + cached_title = self._feed_issue_title_cache.get(key) + if cached_title: + issue["title"] = cached_title + event.payload["issue"] = issue + updated = True + return updated + + def _apply_cached_feed_titles_to_feed(self) -> bool: + """Apply all cached feed title backfills.""" + updated_pr = self._apply_cached_pr_titles_to_feed() + updated_issue = self._apply_cached_issue_titles_to_feed() + return updated_pr or updated_issue + + def _collect_missing_feed_title_jobs(self): + """Collect unique feed title backfill jobs in feed order.""" + jobs = [] + seen_pr = set() + seen_issue = set() - def _finish_feed_pr_title_backfill(self): + for event in self.feed: + pr_key = self._extract_feed_pr_key(event) + if pr_key and pr_key not in seen_pr: + seen_pr.add(pr_key) + pr = event.payload.get("pull_request", {}) or {} + if not (pr.get("title") or "").strip() and pr_key not in self._feed_pr_title_cache: + jobs.append(("pr", pr_key)) + + issue_key = self._extract_feed_issue_key(event) + if issue_key and issue_key not in seen_issue: + seen_issue.add(issue_key) + issue = event.payload.get("issue", {}) or {} + if not (issue.get("title") or "").strip() and issue_key not in self._feed_issue_title_cache: + jobs.append(("issue", issue_key)) + + return jobs + + def _finish_feed_title_backfill(self): """Finalize one backfill batch and queue next if needed.""" - self._feed_pr_title_backfill_running = False - if self._apply_cached_pr_titles_to_feed(): + self._feed_title_backfill_running = False + if self._apply_cached_feed_titles_to_feed(): self._render_feed_list() - self._start_feed_pr_title_backfill() + self._start_feed_title_backfill() - def _start_feed_pr_title_backfill(self): - """Backfill missing PR titles for feed rows using minimal API calls.""" - if self._feed_pr_title_backfill_running: + def _start_feed_title_backfill(self): + """Backfill missing issue/PR titles for feed rows using minimal API calls.""" + if self._feed_title_backfill_running: return - missing_keys = self._collect_missing_feed_pr_title_keys() - if not missing_keys: + jobs = self._collect_missing_feed_title_jobs() + if not jobs: return # Keep call volume low per refresh cycle. - batch = missing_keys[:8] - self._feed_pr_title_backfill_running = True + batch = jobs[:8] + self._feed_title_backfill_running = True def do_backfill(): - for owner, repo_name, number in batch: - pr = self.app.currentAccount.get_pull_request(owner, repo_name, number) - if pr and (pr.title or "").strip(): - self._feed_pr_title_cache[(owner, repo_name, number)] = pr.title + for kind, key in batch: + owner, repo_name, number = key + if kind == "pr": + pr = self.app.currentAccount.get_pull_request(owner, repo_name, number) + if pr and (pr.title or "").strip(): + self._feed_pr_title_cache[key] = pr.title + else: + # Cache misses too so we don't re-query repeatedly. + self._feed_pr_title_cache[key] = None else: - # Cache misses too so we don't re-query repeatedly. - self._feed_pr_title_cache[(owner, repo_name, number)] = None - wx.CallAfter(self._finish_feed_pr_title_backfill) + issue = self.app.currentAccount.get_issue(owner, repo_name, number) + if issue and (issue.title or "").strip(): + self._feed_issue_title_cache[key] = issue.title + else: + # Cache misses too so we don't re-query repeatedly. + self._feed_issue_title_cache[key] = None + wx.CallAfter(self._finish_feed_title_backfill) threading.Thread(target=do_backfill, daemon=True).start() @@ -967,10 +1027,10 @@ def _update_feed_list(self): self._check_and_notify_feed(self.feed) # Apply any previously fetched titles before rendering. - self._apply_cached_pr_titles_to_feed() + self._apply_cached_feed_titles_to_feed() self._render_feed_list() self._update_status() - self._start_feed_pr_title_backfill() + self._start_feed_title_backfill() def _load_repos(self): """Load user's repositories in background.""" diff --git a/models/event.py b/models/event.py index b067d46..f27ea94 100644 --- a/models/event.py +++ b/models/event.py @@ -187,7 +187,9 @@ def get_action_description(self) -> str: issue = payload.get("issue", {}) number = issue.get("number", "") title = issue.get("title", "")[:50] - return f"{action} issue #{number}: {title}" + if title: + return f"{action} issue #{number}: {title}" + return f"{action} issue #{number}" elif self.type == "IssueCommentEvent": action = payload.get("action", "created") From 18df4aed6b1a1458c8b1975984ec4aa8e292b9b3 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Mon, 16 Feb 2026 03:20:46 -0700 Subject: [PATCH 5/5] Include PR review comments in pull request dialog --- GUI/pullrequests.py | 3 ++- github_api.py | 42 ++++++++++++++++++++++++++++++++++++++++-- models/issue.py | 6 ++++-- 3 files changed, 46 insertions(+), 5 deletions(-) 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 eb3cba1..6704bb4 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/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 )