diff --git a/GUI/main.py b/GUI/main.py index ac0f147..67493b2 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -114,6 +114,7 @@ def __init__(self, title): # Data caches self.feed = [] + self._visible_feed = [] self.repos = [] self.starred = [] self.watched = [] @@ -387,8 +388,9 @@ def on_list_key_hook(self, event): def get_selected_feed_event(self): """Get the currently selected feed event.""" selection = self.feed_list.GetSelection() - if selection != wx.NOT_FOUND and selection < len(self.feed): - return self.feed[selection] + visible = getattr(self, '_visible_feed', self.feed) + if selection != wx.NOT_FOUND and selection < len(visible): + return visible[selection] return None def on_feed_list_key_hook(self, event): @@ -925,9 +927,15 @@ def _load_feed(self): def _render_feed_list(self): """Render feed list from current event data while preserving selection.""" + from models.feed_filter import load_visible_types, load_muted_repos, load_user_filters, filter_feed + visible = load_visible_types(self.app.currentAccount.prefs) if self.app.currentAccount else None + muted_repos = load_muted_repos(self.app.currentAccount.prefs) if self.app.currentAccount else None + user_filters = load_user_filters(self.app.currentAccount.prefs) if self.app.currentAccount else None + self._visible_feed = filter_feed(self.feed, visible, muted_repos, user_filters) + selection = self.feed_list.GetSelection() self.feed_list.Clear() - for event in self.feed: + for event in self._visible_feed: self.feed_list.Append(event.format_display()) if selection != wx.NOT_FOUND and selection < self.feed_list.GetCount(): self.feed_list.SetSelection(selection) diff --git a/GUI/options.py b/GUI/options.py index 32882c0..0413959 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -17,6 +17,65 @@ HOTKEY_SUPPORTED = False +# Display names for event types used as section headers (types with sub-actions) +# or as single checkbox labels (types without sub-actions). +EVENT_DISPLAY_NAMES = { + # Types WITH sub-actions — rendered as a static-text section header + "PullRequestEvent": "Pull Requests", + "PullRequestReviewEvent": "Pull Request Reviews", + "PullRequestReviewCommentEvent": "Pull Request Review Comments", + "PullRequestReviewThreadEvent": "Pull Request Review Threads", + "IssuesEvent": "Issues", + "IssueCommentEvent": "Issue Comments", + "CreateEvent": "Create", + "DeleteEvent": "Delete", + "ReleaseEvent": "Releases", + "DiscussionEvent": "Discussions", + "DiscussionCommentEvent": "Discussion Comments", + "MemberEvent": "Member", + "SponsorshipEvent": "Sponsorship", + # Types WITHOUT sub-actions — single checkbox + "PushEvent": "P&ush (commits)", + "CommitCommentEvent": "Commit Comm&ent", + "ForkEvent": "&Fork", + "WatchEvent": "&Star (watch event)", + "GollumEvent": "&Wiki page updated", + "PublicEvent": "Repo made P&ublic", +} + +# Display names for individual actions within a type. +ACTION_DISPLAY_NAMES = { + "opened": "Opened", + "closed": "Closed", + "merged": "Merged", + "reopened": "Reopened", + "labeled": "Labeled", + "unlabeled": "Unlabeled", + "assigned": "Assigned", + "unassigned": "Unassigned", + "locked": "Locked", + "unlocked": "Unlocked", + "approved": "Approved", + "changes_requested": "Changes requested", + "commented": "Commented", + "created": "Created", + "edited": "Edited", + "deleted": "Deleted", + "resolved": "Resolved", + "unresolved": "Unresolved", + "published": "Published", + "prereleased": "Pre-released", + "branch": "Branch", + "tag": "Tag", + "repository": "Repository", + "added": "Added", + "removed": "Removed", + "answered": "Answered", + "category_changed": "Category changed", + "cancelled": "Cancelled", +} + + class OptionsDialog(wx.Dialog): """Dialog for application options.""" @@ -24,7 +83,7 @@ def __init__(self, parent): self.app = get_app() self.parent_window = parent - wx.Dialog.__init__(self, parent, title="Options", size=(560, 860)) + wx.Dialog.__init__(self, parent, title="Options", size=(580, 900)) self.init_ui() self.bind_events() @@ -34,99 +93,101 @@ def __init__(self, parent): def init_ui(self): """Initialize the UI.""" self.panel = wx.Panel(self) + outer_sizer = wx.BoxSizer(wx.VERTICAL) + + self.notebook = wx.Notebook(self.panel) + + self.general_panel = wx.ScrolledWindow(self.notebook) + self.general_panel.SetScrollRate(0, 20) + self._build_general_tab(self.general_panel) + self.notebook.AddPage(self.general_panel, "General") + + self.filters_panel = wx.ScrolledWindow(self.notebook) + self.filters_panel.SetScrollRate(0, 20) + self._build_filters_tab(self.filters_panel) + self.notebook.AddPage(self.filters_panel, "Feed Filters") + + outer_sizer.Add(self.notebook, 1, wx.EXPAND | wx.ALL, 5) + + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.ok_btn = wx.Button(self.panel, wx.ID_OK, label="&OK") + btn_sizer.Add(self.ok_btn, 0, wx.RIGHT, 5) + + self.cancel_btn = wx.Button(self.panel, wx.ID_CANCEL, label="&Cancel") + btn_sizer.Add(self.cancel_btn, 0, wx.RIGHT, 5) + + self.apply_btn = wx.Button(self.panel, label="&Apply") + btn_sizer.Add(self.apply_btn, 0) + + outer_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) + + self.panel.SetSizer(outer_sizer) + + def _build_general_tab(self, panel): + """Build the General settings tab content.""" main_sizer = wx.BoxSizer(wx.VERTICAL) # Commits section - commits_box = wx.StaticBox(self.panel, label="Commits") + commits_box = wx.StaticBox(panel, label="Commits") commits_sizer = wx.StaticBoxSizer(commits_box, wx.VERTICAL) - # Commit limit row limit_row = wx.BoxSizer(wx.HORIZONTAL) - - limit_label = wx.StaticText(self.panel, label="Commit &limit when loading:") + limit_label = wx.StaticText(panel, label="Commit &limit when loading:") limit_row.Add(limit_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.limit_spin = wx.SpinCtrl( - self.panel, - min=0, - max=5000, - initial=0, - style=wx.SP_ARROW_KEYS - ) + self.limit_spin = wx.SpinCtrl(panel, min=0, max=5000, initial=0, style=wx.SP_ARROW_KEYS) self.limit_spin.SetToolTip("Number of commits to load (0 = all commits)") limit_row.Add(self.limit_spin, 0, wx.RIGHT, 10) - - limit_hint = wx.StaticText(self.panel, label="(0 = all)") + limit_hint = wx.StaticText(panel, label="(0 = all)") limit_row.Add(limit_hint, 0, wx.ALIGN_CENTER_VERTICAL) - commits_sizer.Add(limit_row, 0, wx.ALL | wx.EXPAND, 10) main_sizer.Add(commits_sizer, 0, wx.EXPAND | wx.ALL, 10) # Downloads section - downloads_box = wx.StaticBox(self.panel, label="Downloads") + downloads_box = wx.StaticBox(panel, label="Downloads") downloads_sizer = wx.StaticBoxSizer(downloads_box, wx.VERTICAL) - # Download location row download_row = wx.BoxSizer(wx.HORIZONTAL) - - download_label = wx.StaticText(self.panel, label="&Download location:") + download_label = wx.StaticText(panel, label="&Download location:") download_row.Add(download_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.download_path = wx.TextCtrl(self.panel, size=(280, -1)) + self.download_path = wx.TextCtrl(panel, size=(280, -1)) self.download_path.SetToolTip("Default folder for downloading release artifacts") download_row.Add(self.download_path, 1, wx.RIGHT, 5) - - self.browse_btn = wx.Button(self.panel, label="&Browse...") + self.browse_btn = wx.Button(panel, label="&Browse...") download_row.Add(self.browse_btn, 0) - downloads_sizer.Add(download_row, 0, wx.ALL | wx.EXPAND, 10) main_sizer.Add(downloads_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Git section - git_box = wx.StaticBox(self.panel, label="Git") + git_box = wx.StaticBox(panel, label="Git") git_sizer = wx.StaticBoxSizer(git_box, wx.VERTICAL) - # Git path row git_row = wx.BoxSizer(wx.HORIZONTAL) - - git_label = wx.StaticText(self.panel, label="&Git repositories path:") + git_label = wx.StaticText(panel, label="&Git repositories path:") git_row.Add(git_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.git_path = wx.TextCtrl(self.panel, size=(280, -1)) + self.git_path = wx.TextCtrl(panel, size=(280, -1)) self.git_path.SetToolTip("Folder where repositories will be cloned") git_row.Add(self.git_path, 1, wx.RIGHT, 5) - - self.git_browse_btn = wx.Button(self.panel, label="Br&owse...") + self.git_browse_btn = wx.Button(panel, label="Br&owse...") git_row.Add(self.git_browse_btn, 0) - git_sizer.Add(git_row, 0, wx.ALL | wx.EXPAND, 10) - # Git clone options - self.git_org_structure_cb = wx.CheckBox( - self.panel, - label="Clone into &owner/repo folder structure" - ) + self.git_org_structure_cb = wx.CheckBox(panel, label="Clone into &owner/repo folder structure") self.git_org_structure_cb.SetToolTip( "When enabled, repositories are cloned to owner/repo\n" "(e.g., masonasons/FastGH instead of just FastGH)" ) git_sizer.Add(self.git_org_structure_cb, 0, wx.LEFT | wx.BOTTOM, 10) - self.git_recursive_cb = wx.CheckBox( - self.panel, - label="Clone rec&ursively (include submodules)" - ) + self.git_recursive_cb = wx.CheckBox(panel, label="Clone rec&ursively (include submodules)") self.git_recursive_cb.SetToolTip( "When enabled, git clone will use --recursive to also clone submodules" ) git_sizer.Add(self.git_recursive_cb, 0, wx.LEFT | wx.BOTTOM, 10) - self.git_lfs_cb = wx.CheckBox( - self.panel, - label="Enable Git &LFS support for clone/pull/sync" - ) + self.git_lfs_cb = wx.CheckBox(panel, label="Enable Git &LFS support for clone/pull/sync") self.git_lfs_cb.SetToolTip( "Runs git lfs install/pull after clone/pull and during scheduled sync.\n" "If git-lfs is not installed, operations continue with a warning." @@ -136,103 +197,80 @@ def init_ui(self): main_sizer.Add(git_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Repository sync section - sync_box = wx.StaticBox(self.panel, label="Repository Sync") + sync_box = wx.StaticBox(panel, label="Repository Sync") sync_sizer = wx.StaticBoxSizer(sync_box, wx.VERTICAL) self.repo_sync_enabled_cb = wx.CheckBox( - self.panel, - label="Enable scheduled auto sync for configured repositories" + panel, label="Enable scheduled auto sync for configured repositories" ) sync_sizer.Add(self.repo_sync_enabled_cb, 0, wx.LEFT | wx.TOP, 10) interval_row = wx.BoxSizer(wx.HORIZONTAL) - interval_label = wx.StaticText(self.panel, label="Sync interval:") + interval_label = wx.StaticText(panel, label="Sync interval:") interval_row.Add(interval_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - self.repo_sync_interval_spin = wx.SpinCtrl( - self.panel, - min=0, - max=240, - initial=0, - style=wx.SP_ARROW_KEYS - ) + self.repo_sync_interval_spin = wx.SpinCtrl(panel, min=0, max=240, initial=0, style=wx.SP_ARROW_KEYS) self.repo_sync_interval_spin.SetToolTip("Minutes between sync runs (0 = disabled)") interval_row.Add(self.repo_sync_interval_spin, 0, wx.RIGHT, 5) - interval_hint = wx.StaticText(self.panel, label="minutes (0 = disabled)") + interval_hint = wx.StaticText(panel, label="minutes (0 = disabled)") interval_row.Add(interval_hint, 0, wx.ALIGN_CENTER_VERTICAL) sync_sizer.Add(interval_row, 0, wx.ALL, 10) self.repo_sync_use_tools_cb = wx.CheckBox( - self.panel, - label="Use .GITHUB repo bootstrap updater before git sync" + panel, label="Use .GITHUB repo bootstrap updater before git sync" ) sync_sizer.Add(self.repo_sync_use_tools_cb, 0, wx.LEFT | wx.BOTTOM, 10) tools_row = wx.BoxSizer(wx.HORIZONTAL) - tools_label = wx.StaticText(self.panel, label=".GITHUB tools path:") + tools_label = wx.StaticText(panel, label=".GITHUB tools path:") tools_row.Add(tools_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - self.repo_sync_tools_path = wx.TextCtrl(self.panel, size=(300, -1)) + self.repo_sync_tools_path = wx.TextCtrl(panel, size=(300, -1)) tools_row.Add(self.repo_sync_tools_path, 1, wx.RIGHT, 5) - self.repo_sync_tools_browse_btn = wx.Button(self.panel, label="Brow&se...") + self.repo_sync_tools_browse_btn = wx.Button(panel, label="Brow&se...") tools_row.Add(self.repo_sync_tools_browse_btn, 0) sync_sizer.Add(tools_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10) main_sizer.Add(sync_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Notifications section - notif_box = wx.StaticBox(self.panel, label="Desktop Notifications") + notif_box = wx.StaticBox(panel, label="Desktop Notifications") notif_sizer = wx.StaticBoxSizer(notif_box, wx.VERTICAL) - # Checkboxes for notification types - self.notify_activity_cb = wx.CheckBox(self.panel, label="Notify on new &activity feed items") + self.notify_activity_cb = wx.CheckBox(panel, label="Notify on new &activity feed items") notif_sizer.Add(self.notify_activity_cb, 0, wx.LEFT | wx.TOP, 10) - self.notify_notifications_cb = wx.CheckBox(self.panel, label="Notify on new GitHub ¬ifications") + self.notify_notifications_cb = wx.CheckBox(panel, label="Notify on new GitHub ¬ifications") notif_sizer.Add(self.notify_notifications_cb, 0, wx.LEFT | wx.TOP, 5) - self.notify_starred_cb = wx.CheckBox(self.panel, label="Notify on &starred repository updates") + self.notify_starred_cb = wx.CheckBox(panel, label="Notify on &starred repository updates") notif_sizer.Add(self.notify_starred_cb, 0, wx.LEFT | wx.TOP, 5) - self.notify_watched_cb = wx.CheckBox(self.panel, label="Notify on &watched repository updates") + self.notify_watched_cb = wx.CheckBox(panel, label="Notify on &watched repository updates") notif_sizer.Add(self.notify_watched_cb, 0, wx.LEFT | wx.TOP, 5) - self.notify_repo_sync_cb = wx.CheckBox(self.panel, label="Notify on repository &sync results") + self.notify_repo_sync_cb = wx.CheckBox(panel, label="Notify on repository &sync results") notif_sizer.Add(self.notify_repo_sync_cb, 0, wx.LEFT | wx.TOP, 5) - # Auto-refresh interval refresh_row = wx.BoxSizer(wx.HORIZONTAL) - - refresh_label = wx.StaticText(self.panel, label="Auto-&refresh interval:") + refresh_label = wx.StaticText(panel, label="Auto-&refresh interval:") refresh_row.Add(refresh_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.refresh_spin = wx.SpinCtrl( - self.panel, - min=0, - max=60, - initial=0, - style=wx.SP_ARROW_KEYS - ) + self.refresh_spin = wx.SpinCtrl(panel, min=0, max=60, initial=0, style=wx.SP_ARROW_KEYS) self.refresh_spin.SetToolTip("How often to check for updates (0 = disabled)") refresh_row.Add(self.refresh_spin, 0, wx.RIGHT, 5) - - refresh_hint = wx.StaticText(self.panel, label="minutes (0 = disabled)") + refresh_hint = wx.StaticText(panel, label="minutes (0 = disabled)") refresh_row.Add(refresh_hint, 0, wx.ALIGN_CENTER_VERTICAL) - notif_sizer.Add(refresh_row, 0, wx.ALL, 10) main_sizer.Add(notif_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Hotkey section (only show if supported) if HOTKEY_SUPPORTED: - hotkey_box = wx.StaticBox(self.panel, label="Global Hotkey") + hotkey_box = wx.StaticBox(panel, label="Global Hotkey") hotkey_sizer = wx.StaticBoxSizer(hotkey_box, wx.VERTICAL) - # Hotkey row hotkey_row = wx.BoxSizer(wx.HORIZONTAL) - - hotkey_label = wx.StaticText(self.panel, label="Show/&Hide window:") + hotkey_label = wx.StaticText(panel, label="Show/&Hide window:") hotkey_row.Add(hotkey_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.hotkey_text = wx.TextCtrl(self.panel, size=(200, -1)) + self.hotkey_text = wx.TextCtrl(panel, size=(200, -1)) self.hotkey_text.SetToolTip( "Global hotkey to show/hide window.\n" "Format: modifier+modifier+key\n" @@ -240,15 +278,12 @@ def init_ui(self): "Example: control+alt+g" ) hotkey_row.Add(self.hotkey_text, 1, wx.RIGHT, 5) - - self.clear_hotkey_btn = wx.Button(self.panel, label="C&lear") + self.clear_hotkey_btn = wx.Button(panel, label="C&lear") hotkey_row.Add(self.clear_hotkey_btn, 0) - hotkey_sizer.Add(hotkey_row, 0, wx.ALL | wx.EXPAND, 10) - # Hotkey hint hint_label = wx.StaticText( - self.panel, + panel, label="Format: control+alt+g, win+shift+h, etc. Leave empty to disable." ) hotkey_sizer.Add(hint_label, 0, wx.LEFT | wx.BOTTOM, 10) @@ -256,57 +291,144 @@ def init_ui(self): main_sizer.Add(hotkey_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Appearance section - appearance_box = wx.StaticBox(self.panel, label="Appearance") + appearance_box = wx.StaticBox(panel, label="Appearance") appearance_sizer = wx.StaticBoxSizer(appearance_box, wx.VERTICAL) - # Dark mode row dark_mode_row = wx.BoxSizer(wx.HORIZONTAL) - - dark_mode_label = wx.StaticText(self.panel, label="&Dark mode:") + dark_mode_label = wx.StaticText(panel, label="&Dark mode:") dark_mode_row.Add(dark_mode_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) - - self.dark_mode_choice = wx.Choice( - self.panel, - choices=["Off", "Auto (follow system)", "On"] - ) + self.dark_mode_choice = wx.Choice(panel, choices=["Off", "Auto (follow system)", "On"]) self.dark_mode_choice.SetToolTip( "Off: Always use light theme\n" "Auto: Follow system theme setting\n" "On: Always use dark theme" ) dark_mode_row.Add(self.dark_mode_choice, 0) - appearance_sizer.Add(dark_mode_row, 0, wx.ALL, 10) main_sizer.Add(appearance_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) # Updates section - updates_box = wx.StaticBox(self.panel, label="Updates") + updates_box = wx.StaticBox(panel, label="Updates") updates_sizer = wx.StaticBoxSizer(updates_box, wx.VERTICAL) - - self.check_for_updates_cb = wx.CheckBox(self.panel, label="Check for &updates on startup") + self.check_for_updates_cb = wx.CheckBox(panel, label="Check for &updates on startup") updates_sizer.Add(self.check_for_updates_cb, 0, wx.ALL, 10) - main_sizer.Add(updates_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) - # Spacer main_sizer.AddStretchSpacer() + panel.SetSizer(main_sizer) - # Buttons - btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + def _build_filters_tab(self, panel): + """Build the Feed Filters tab content.""" + from models.feed_filter import FILTER_GROUPS, EVENT_TYPE_ACTIONS + main_sizer = wx.BoxSizer(wx.VERTICAL) - self.ok_btn = wx.Button(self.panel, wx.ID_OK, label="&OK") - btn_sizer.Add(self.ok_btn, 0, wx.RIGHT, 5) + intro = wx.StaticText( + panel, + label=( + "Choose which event types and actions appear in your activity feed.\n" + "Changes apply per account and take effect immediately on Apply." + ) + ) + intro.Wrap(520) + main_sizer.Add(intro, 0, wx.ALL, 10) + + self.filter_checkboxes: dict = {} + + for group_label, event_types in FILTER_GROUPS: + group_box = wx.StaticBox(panel, label=group_label) + group_sizer = wx.StaticBoxSizer(group_box, wx.VERTICAL) + + for et in event_types: + actions = EVENT_TYPE_ACTIONS.get(et) + if actions: + # Show type as a bold section header, actions as indented checkboxes + type_label = wx.StaticText(panel, label=EVENT_DISPLAY_NAMES.get(et, et) + ":") + group_sizer.Add(type_label, 0, wx.LEFT | wx.TOP, 8) + for action in actions: + action_label = ACTION_DISPLAY_NAMES.get(action, action) + cb = wx.CheckBox(panel, label=action_label) + self.filter_checkboxes[f"{et}:{action}"] = cb + group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 4) + group_sizer.AddSpacer(4) + else: + label = EVENT_DISPLAY_NAMES.get(et, et) + cb = wx.CheckBox(panel, label=label) + self.filter_checkboxes[et] = cb + group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 8) + + group_sizer.AddSpacer(6) + main_sizer.Add(group_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + # Select All / Deselect All buttons + bulk_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.select_all_btn = wx.Button(panel, label="Select &All") + bulk_sizer.Add(self.select_all_btn, 0, wx.RIGHT, 8) + self.deselect_all_btn = wx.Button(panel, label="&Deselect All") + bulk_sizer.Add(self.deselect_all_btn, 0) + main_sizer.Add(bulk_sizer, 0, wx.LEFT | wx.BOTTOM, 10) + + # User Filters section + user_box = wx.StaticBox(panel, label="User Filters") + user_sizer = wx.StaticBoxSizer(user_box, wx.VERTICAL) + + user_intro = wx.StaticText( + panel, + label=( + "Per-user rules override the global event type filter for that user.\n" + "Muted users are hidden entirely regardless of event type." + ) + ) + user_intro.Wrap(500) + user_sizer.Add(user_intro, 0, wx.ALL, 8) + + self.user_filters_list = wx.ListBox(panel, size=(-1, 90), style=wx.LB_SINGLE) + user_sizer.Add(self.user_filters_list, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) + + user_btn_row = wx.BoxSizer(wx.HORIZONTAL) + self.user_filter_add_btn = wx.Button(panel, label="&Add User...") + user_btn_row.Add(self.user_filter_add_btn, 0, wx.RIGHT, 4) + self.user_filter_edit_btn = wx.Button(panel, label="&Edit...") + self.user_filter_edit_btn.Disable() + user_btn_row.Add(self.user_filter_edit_btn, 0, wx.RIGHT, 4) + self.user_filter_remove_btn = wx.Button(panel, label="Remo&ve") + self.user_filter_remove_btn.Disable() + user_btn_row.Add(self.user_filter_remove_btn, 0) + user_sizer.Add(user_btn_row, 0, wx.ALL, 8) + + main_sizer.Add(user_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + # Muted Repositories section + mute_box = wx.StaticBox(panel, label="Muted Repositories") + mute_sizer = wx.StaticBoxSizer(mute_box, wx.VERTICAL) + + mute_intro = wx.StaticText( + panel, + label="Events from muted repositories are always hidden, regardless of event type." + ) + mute_intro.Wrap(500) + mute_sizer.Add(mute_intro, 0, wx.ALL, 8) - self.cancel_btn = wx.Button(self.panel, wx.ID_CANCEL, label="&Cancel") - btn_sizer.Add(self.cancel_btn, 0, wx.RIGHT, 5) + self.muted_repos_list = wx.ListBox(panel, size=(-1, 100), style=wx.LB_SINGLE) + mute_sizer.Add(self.muted_repos_list, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) - self.apply_btn = wx.Button(self.panel, label="&Apply") - btn_sizer.Add(self.apply_btn, 0) + add_label = wx.StaticText(panel, label="Add repository (owner/repo):") + mute_sizer.Add(add_label, 0, wx.LEFT | wx.TOP, 8) + + add_row = wx.BoxSizer(wx.HORIZONTAL) + self.muted_repo_entry = wx.TextCtrl(panel, size=(280, -1), style=wx.TE_PROCESS_ENTER) + self.muted_repo_entry.SetHint("e.g. torvalds/linux") + add_row.Add(self.muted_repo_entry, 1, wx.RIGHT, 6) + self.mute_add_btn = wx.Button(panel, label="&Add") + add_row.Add(self.mute_add_btn, 0, wx.RIGHT, 4) + self.mute_remove_btn = wx.Button(panel, label="&Remove selected") + self.mute_remove_btn.Disable() + add_row.Add(self.mute_remove_btn, 0) + mute_sizer.Add(add_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) - main_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) + main_sizer.Add(mute_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) - self.panel.SetSizer(main_sizer) + panel.SetSizer(main_sizer) def bind_events(self): """Bind event handlers.""" @@ -322,10 +444,25 @@ def bind_events(self): if HOTKEY_SUPPORTED: self.clear_hotkey_btn.Bind(wx.EVT_BUTTON, self.on_clear_hotkey) + self.select_all_btn.Bind(wx.EVT_BUTTON, self.on_select_all_filters) + self.deselect_all_btn.Bind(wx.EVT_BUTTON, self.on_deselect_all_filters) + self.mute_add_btn.Bind(wx.EVT_BUTTON, self.on_mute_add) + self.mute_remove_btn.Bind(wx.EVT_BUTTON, self.on_mute_remove) + self.muted_repo_entry.Bind(wx.EVT_TEXT_ENTER, self.on_mute_add) + self.user_filter_add_btn.Bind(wx.EVT_BUTTON, self.on_user_filter_add) + self.user_filter_edit_btn.Bind(wx.EVT_BUTTON, self.on_user_filter_edit) + self.user_filter_remove_btn.Bind(wx.EVT_BUTTON, self.on_user_filter_remove) + self.user_filters_list.Bind(wx.EVT_LISTBOX, self._on_user_filter_selection) + self.muted_repos_list.Bind(wx.EVT_LISTBOX, self._on_muted_repo_selection) + def on_char_hook(self, event): """Handle key events.""" - if event.GetKeyCode() == wx.WXK_ESCAPE: + key = event.GetKeyCode() + if key == wx.WXK_ESCAPE: self.on_close(None) + elif key in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER): + if self.save_settings(): + self.EndModal(wx.ID_OK) else: event.Skip() @@ -367,6 +504,26 @@ def load_settings(self): # Updates setting self.check_for_updates_cb.SetValue(self.app.prefs.check_for_updates) + # Feed filter settings (per account) + if self.app.currentAccount: + from models.feed_filter import load_visible_types, load_muted_repos, load_user_filters + visible = load_visible_types(self.app.currentAccount.prefs) + for key, cb in self.filter_checkboxes.items(): + cb.SetValue(True if visible is None else key in visible) + muted = load_muted_repos(self.app.currentAccount.prefs) or set() + self.muted_repos_list.Set(sorted(muted)) + user_filters = load_user_filters(self.app.currentAccount.prefs) or {} + self.user_filters_list.Clear() + for username, types in sorted(user_filters.items()): + label = self._user_filter_label(username, types) + idx = self.user_filters_list.Append(label) + self.user_filters_list.SetClientData(idx, (username, types)) + else: + for cb in self.filter_checkboxes.values(): + cb.SetValue(True) + self.muted_repos_list.Clear() + self.user_filters_list.Clear() + def save_settings(self): """Save settings from the dialog.""" self.app.prefs.commit_limit = self.limit_spin.GetValue() @@ -451,6 +608,23 @@ def save_settings(self): # Save updates setting self.app.prefs.check_for_updates = self.check_for_updates_cb.GetValue() + # Save feed filter settings (per account) + if self.app.currentAccount: + from models.feed_filter import save_visible_types, save_muted_repos + visible = {key for key, cb in self.filter_checkboxes.items() if cb.GetValue()} + save_visible_types(self.app.currentAccount.prefs, visible) + muted = {self.muted_repos_list.GetString(i) for i in range(self.muted_repos_list.GetCount())} + save_muted_repos(self.app.currentAccount.prefs, muted) + user_filters = {} + for i in range(self.user_filters_list.GetCount()): + username, types = self.user_filters_list.GetClientData(i) + user_filters[username] = types + from models.feed_filter import save_user_filters + save_user_filters(self.app.currentAccount.prefs, user_filters) + from GUI import main as _main + if _main.window: + _main.window._render_feed_list() + return True def on_browse(self, event): @@ -511,6 +685,92 @@ def on_clear_hotkey(self, event): """Clear the hotkey field.""" self.hotkey_text.SetValue("") + def on_select_all_filters(self, event): + """Check all feed filter checkboxes.""" + for cb in self.filter_checkboxes.values(): + cb.SetValue(True) + + def on_deselect_all_filters(self, event): + """Uncheck all feed filter checkboxes.""" + for cb in self.filter_checkboxes.values(): + cb.SetValue(False) + + def on_mute_add(self, event): + """Add a repo to the muted list.""" + repo = self.muted_repo_entry.GetValue().strip().lower() + if not repo: + return + # Basic format validation + if "/" not in repo or repo.startswith("/") or repo.endswith("/"): + wx.MessageBox( + "Please enter a repository in owner/repo format.\nExample: torvalds/linux", + "Invalid Format", + wx.OK | wx.ICON_WARNING, + ) + return + existing = [self.muted_repos_list.GetString(i) for i in range(self.muted_repos_list.GetCount())] + if repo not in existing: + self.muted_repos_list.Append(repo) + self.muted_repo_entry.SetValue("") + + def on_mute_remove(self, event): + """Remove the selected repo from the muted list.""" + sel = self.muted_repos_list.GetSelection() + if sel != wx.NOT_FOUND: + self.muted_repos_list.Delete(sel) + self.mute_remove_btn.Disable() + + def _on_user_filter_selection(self, event): + has_sel = self.user_filters_list.GetSelection() != wx.NOT_FOUND + self.user_filter_edit_btn.Enable(has_sel) + self.user_filter_remove_btn.Enable(has_sel) + + def _on_muted_repo_selection(self, event): + has_sel = self.muted_repos_list.GetSelection() != wx.NOT_FOUND + self.mute_remove_btn.Enable(has_sel) + + # ---- User filter helpers ---- + + def _user_filter_label(self, username: str, types: set) -> str: + if not types: + return f"{username} — muted" + return f"{username} — {len(types)} type(s)" + + def on_user_filter_add(self, event): + dlg = UserFilterDialog(self) + if dlg.ShowModal() == wx.ID_OK: + username, types = dlg.get_result() + if username: + # Replace if username already exists + for i in range(self.user_filters_list.GetCount()): + if self.user_filters_list.GetClientData(i) == username: + self.user_filters_list.Delete(i) + break + label = self._user_filter_label(username, types) + idx = self.user_filters_list.Append(label) + self.user_filters_list.SetClientData(idx, (username, types)) + dlg.Destroy() + + def on_user_filter_edit(self, event): + sel = self.user_filters_list.GetSelection() + if sel == wx.NOT_FOUND: + return + username, types = self.user_filters_list.GetClientData(sel) + dlg = UserFilterDialog(self, username=username, visible_types=types) + if dlg.ShowModal() == wx.ID_OK: + new_username, new_types = dlg.get_result() + label = self._user_filter_label(new_username, new_types) + self.user_filters_list.SetString(sel, label) + self.user_filters_list.SetClientData(sel, (new_username, new_types)) + dlg.Destroy() + + def on_user_filter_remove(self, event): + sel = self.user_filters_list.GetSelection() + if sel != wx.NOT_FOUND: + self.user_filters_list.Delete(sel) + self.user_filter_edit_btn.Disable() + self.user_filter_remove_btn.Disable() + def on_ok(self, event): """Handle OK button.""" if self.save_settings(): @@ -522,9 +782,139 @@ def on_cancel(self, event): def on_apply(self, event): """Handle Apply button.""" - if self.save_settings(): - wx.MessageBox("Settings applied.", "Options", wx.OK | wx.ICON_INFORMATION) + self.save_settings() def on_close(self, event): """Handle close.""" self.EndModal(wx.ID_CANCEL) + + +class UserFilterDialog(wx.Dialog): + """Sub-dialog for configuring per-user event type filter rules.""" + + def __init__(self, parent, username: str = "", visible_types=None): + """ + username — pre-filled when editing; empty when adding + visible_types — set of visible event types (None = all checked) + """ + title = "Edit User Filter" if username else "Add User Filter" + super().__init__(parent, title=title, size=(500, 700)) + + self._init_ui(username, visible_types) + self._bind() + self.Center() + + def _init_ui(self, username: str, visible_types): + from models.feed_filter import FILTER_GROUPS, EVENT_TYPE_ACTIONS + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + + # Username row + name_row = wx.BoxSizer(wx.HORIZONTAL) + name_label = wx.StaticText(panel, label="GitHub &username:") + name_row.Add(name_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8) + self.username_ctrl = wx.TextCtrl(panel, value=username, size=(220, -1)) + name_row.Add(self.username_ctrl, 1) + sizer.Add(name_row, 0, wx.ALL | wx.EXPAND, 10) + + hint = wx.StaticText( + panel, + label="Check the event types you want to see from this user.\n" + "Leave all unchecked to hide their events entirely." + ) + sizer.Add(hint, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + # Mute entirely shortcut + self.mute_all_cb = wx.CheckBox(panel, label="&Mute this user entirely (hide all their events)") + sizer.Add(self.mute_all_cb, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + + # Per-type/action checkboxes in groups (scrolled to handle the large count) + scroll = wx.ScrolledWindow(panel) + scroll.SetScrollRate(0, 20) + scroll_sizer = wx.BoxSizer(wx.VERTICAL) + + self._type_checkboxes: dict = {} + for group_label, event_types in FILTER_GROUPS: + group_box = wx.StaticBox(scroll, label=group_label) + group_sizer = wx.StaticBoxSizer(group_box, wx.VERTICAL) + for et in event_types: + actions = EVENT_TYPE_ACTIONS.get(et) + if actions: + type_label = wx.StaticText(scroll, label=EVENT_DISPLAY_NAMES.get(et, et) + ":") + group_sizer.Add(type_label, 0, wx.LEFT | wx.TOP, 6) + for action in actions: + action_label = ACTION_DISPLAY_NAMES.get(action, action) + cb = wx.CheckBox(scroll, label=action_label) + self._type_checkboxes[f"{et}:{action}"] = cb + group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 3) + group_sizer.AddSpacer(3) + else: + label = EVENT_DISPLAY_NAMES.get(et, et) + cb = wx.CheckBox(scroll, label=label) + self._type_checkboxes[et] = cb + group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 6) + group_sizer.AddSpacer(4) + scroll_sizer.Add(group_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 6) + + scroll.SetSizer(scroll_sizer) + sizer.Add(scroll, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 4) + + # Buttons + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.ok_btn = wx.Button(panel, wx.ID_OK, label="&OK") + btn_sizer.Add(self.ok_btn, 0, wx.RIGHT, 5) + self.cancel_btn = wx.Button(panel, wx.ID_CANCEL, label="&Cancel") + btn_sizer.Add(self.cancel_btn, 0) + sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) + + panel.SetSizer(sizer) + + # Initialise checkbox states + if visible_types is None: + # New user — default all unchecked (no selection = hide all events) + for cb in self._type_checkboxes.values(): + cb.SetValue(False) + elif len(visible_types) == 0: + # Muted user + self.mute_all_cb.SetValue(True) + self._set_type_checkboxes_enabled(False) + else: + for key, cb in self._type_checkboxes.items(): + cb.SetValue(key in visible_types) + + def _bind(self): + self.mute_all_cb.Bind(wx.EVT_CHECKBOX, self._on_mute_all_toggled) + self.ok_btn.Bind(wx.EVT_BUTTON, self._on_ok) + self.cancel_btn.Bind(wx.EVT_BUTTON, lambda e: self.EndModal(wx.ID_CANCEL)) + self.Bind(wx.EVT_CHAR_HOOK, self._on_key) + + def _on_key(self, event): + key = event.GetKeyCode() + if key == wx.WXK_ESCAPE: + self.EndModal(wx.ID_CANCEL) + elif key in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER): + self._on_ok(None) + else: + event.Skip() + + def _set_type_checkboxes_enabled(self, enabled: bool): + for cb in self._type_checkboxes.values(): + cb.Enable(enabled) + + def _on_mute_all_toggled(self, event): + muted = self.mute_all_cb.GetValue() + self._set_type_checkboxes_enabled(not muted) + + def _on_ok(self, event): + username = self.username_ctrl.GetValue().strip() + if not username: + wx.MessageBox("Please enter a GitHub username.", "Required", wx.OK | wx.ICON_WARNING) + return + self.EndModal(wx.ID_OK) + + def get_result(self) -> tuple: + """Return (username, set_of_visible_types). Empty set = muted.""" + username = self.username_ctrl.GetValue().strip().lower() + if self.mute_all_cb.GetValue(): + return username, set() + return username, {key for key, cb in self._type_checkboxes.items() if cb.GetValue()} diff --git a/build.py b/build.py index 783bd10..0488aa1 100644 --- a/build.py +++ b/build.py @@ -77,6 +77,7 @@ def get_hidden_imports(): "models.release", "models.notification", "models.event", + "models.feed_filter", "models.discussion", "GUI", "GUI.main", @@ -185,7 +186,8 @@ def build_windows(script_dir: Path, output_dir: Path) -> tuple: for src, dst in get_binaries(): cmd.extend(["--add-binary", f"{src}{os.pathsep}{dst}"]) - # Collect keyboard_handler + # Collect wx and keyboard_handler fully (wx needs this or wx.App etc. fail at runtime) + cmd.extend(["--collect-all", "wx"]) cmd.extend(["--collect-all", "keyboard_handler"]) # Add main script diff --git a/models/feed_filter.py b/models/feed_filter.py new file mode 100644 index 0000000..5ccac76 --- /dev/null +++ b/models/feed_filter.py @@ -0,0 +1,342 @@ +"""Feed filter logic for FastGH activity feed. + +All filter logic lives here as pure Python with no wx dependency so it +can be tested without a display and reused from both GUI/main.py and +GUI/options.py. + +NOTE: account_prefs is a Config object (MutableMapping) that wraps any +stored dict value in another Config. load_user_filters must therefore +accept any Mapping, not just plain dict. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Optional + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# All known event type strings. Includes PullRequestReviewThreadEvent even +# though it is currently absent from Event.EVENT_TYPES in models/event.py +# because GitHub does emit it in the received-events stream. +ALL_EVENT_TYPES: list[str] = [ + "CommitCommentEvent", + "CreateEvent", + "DeleteEvent", + "DiscussionCommentEvent", + "DiscussionEvent", + "ForkEvent", + "GollumEvent", + "IssueCommentEvent", + "IssuesEvent", + "MemberEvent", + "PublicEvent", + "PullRequestEvent", + "PullRequestReviewCommentEvent", + "PullRequestReviewEvent", + "PullRequestReviewThreadEvent", + "PushEvent", + "ReleaseEvent", + "SponsorshipEvent", + "WatchEvent", +] + +# Per event-type, the individual actions exposed for granular filtering. +# Event types absent from this dict (or with empty list) are treated as +# atomic — a single checkbox, no sub-actions. +# +# Action source per type: +# PullRequestEvent payload["action"] ("merged" is synthetic: +# action=="closed" + pull_request.merged==True) +# IssuesEvent payload["action"] +# IssueCommentEvent payload["action"] +# PullRequestReviewEvent payload["review"]["state"] (not payload["action"]) +# PullRequestReviewCommentEvent payload["action"] +# PullRequestReviewThreadEvent payload["action"] +# CreateEvent / DeleteEvent payload["ref_type"] +# ReleaseEvent payload["action"] +# DiscussionEvent payload["action"] +# DiscussionCommentEvent payload["action"] +# MemberEvent payload["action"] +# SponsorshipEvent payload["action"] +EVENT_TYPE_ACTIONS: dict = { + "PullRequestEvent": [ + "opened", "closed", "merged", "reopened", + "labeled", "unlabeled", "assigned", "unassigned", + "locked", "unlocked", + ], + "IssuesEvent": [ + "opened", "closed", "reopened", + "labeled", "unlabeled", "assigned", "unassigned", + "locked", "unlocked", + ], + "IssueCommentEvent": ["created", "edited", "deleted"], + "PullRequestReviewEvent": ["approved", "changes_requested", "commented"], + "PullRequestReviewCommentEvent": ["created", "edited", "deleted"], + "PullRequestReviewThreadEvent": ["resolved", "unresolved"], + "CreateEvent": ["branch", "tag", "repository"], + "DeleteEvent": ["branch", "tag"], + "ReleaseEvent": ["published", "created", "edited", "deleted", "prereleased"], + "DiscussionEvent": ["created", "answered", "category_changed", "labeled", "unlabeled"], + "DiscussionCommentEvent": ["created", "edited", "deleted"], + "MemberEvent": ["added", "removed"], + "SponsorshipEvent": ["created", "cancelled"], +} + +# Ordered display groups used to build the UI checklist. +# Note: wx StaticBox labels require "&&" to render a literal "&". +FILTER_GROUPS: list[tuple[str, list[str]]] = [ + ( + "Pull Requests && Reviews", + [ + "PullRequestEvent", + "PullRequestReviewEvent", + "PullRequestReviewCommentEvent", + "PullRequestReviewThreadEvent", + ], + ), + ( + "Issues", + [ + "IssuesEvent", + "IssueCommentEvent", + ], + ), + ( + "Code", + [ + "PushEvent", + "CommitCommentEvent", + "CreateEvent", + "DeleteEvent", + ], + ), + ( + "Collaboration", + [ + "ForkEvent", + "WatchEvent", + "MemberEvent", + "GollumEvent", + ], + ), + ( + "Releases && Discussions", + [ + "ReleaseEvent", + "DiscussionEvent", + "DiscussionCommentEvent", + "SponsorshipEvent", + "PublicEvent", + ], + ), +] + +# Key written to each account's config JSON. +CONFIG_KEY = "feed_visible_event_types" +MUTED_REPOS_KEY = "feed_muted_repos" +USER_FILTERS_KEY = "feed_user_filters" + +# --------------------------------------------------------------------------- +# Core functions +# --------------------------------------------------------------------------- + + +def load_visible_types(account_prefs) -> Optional[set[str]]: + """Return the set of event type strings that are visible for this account. + + account_prefs is a Config-like object (any MutableMapping, or plain dict). + + Return values: + None — key is absent; caller should treat as "show everything" + (new account / user has never opened Feed Filters) + set() — key is present but empty; user explicitly hid everything + {str, ...} — key is present with one or more types; show only these + + Invalid stored values (not a list, list contains non-strings, etc.) are + treated as "never configured" and return None so the feed degrades + gracefully rather than hiding events unexpectedly. + """ + raw = account_prefs.get(CONFIG_KEY, None) + + if raw is None: + return None + + if not isinstance(raw, list): + return None + + # Keep only string entries; silently drop ints, None, etc. + result: set[str] = {item for item in raw if isinstance(item, str)} + + # If every entry was non-string the list is effectively empty, which is + # a valid "show nothing" state (distinct from the absent-key case). + return result + + +def save_visible_types(account_prefs, visible: set[str]) -> None: + """Persist the visible event type set to the account config. + + Stores a sorted list for deterministic JSON output. + After this call the key is always present — even for an empty set — + so subsequent loads correctly return set() rather than None. + """ + account_prefs[CONFIG_KEY] = sorted(visible) + + +def load_muted_repos(account_prefs) -> Optional[set[str]]: + """Return the set of muted repo full-names (owner/repo) for this account. + + Return values: + None — key is absent; no repos are muted (new account) + set() — key is present but empty; explicitly configured with nothing muted + {str, ...} — one or more repos are muted + + Invalid stored values return None (safe fallback — mute nothing). + """ + raw = account_prefs.get(MUTED_REPOS_KEY, None) + + if raw is None: + return None + + if not isinstance(raw, list): + return None + + return {item for item in raw if isinstance(item, str)} + + +def save_muted_repos(account_prefs, muted: set[str]) -> None: + """Persist the muted repo set to the account config as a sorted list.""" + account_prefs[MUTED_REPOS_KEY] = sorted(muted) + + +def load_user_filters(account_prefs) -> Optional[dict]: + """Return per-user filter rules as {username: set[str]}. + + Return values: + None — key is absent; no per-user rules configured + {} — key is present but no users configured + {user: set} — one or more user rules; empty set means mute that user + + Invalid top-level value returns None. Invalid per-user entries are + silently skipped so one bad entry does not wipe the whole config. + """ + raw = account_prefs.get(USER_FILTERS_KEY, None) + + if raw is None: + return None + + if not isinstance(raw, Mapping): + return None + + result: dict = {} + for username, types in raw.items(): + if not isinstance(username, str): + continue + if not isinstance(types, list): + continue + result[username] = {t for t in types if isinstance(t, str)} + return result + + +def save_user_filters(account_prefs, user_filters: dict) -> None: + """Persist user filter rules. Each type list is stored sorted.""" + account_prefs[USER_FILTERS_KEY] = { + username: sorted(types) for username, types in user_filters.items() + } + + +def _get_event_action(event) -> str: + """Extract the filterable action string for an event. + + Returns an empty string for event types that have no sub-action filtering. + For PullRequestEvent a closed+merged PR is reported as "merged" (synthetic). + For CreateEvent/DeleteEvent the ref_type (branch/tag/repository) is used. + For PullRequestReviewEvent the review state is used instead of "submitted". + """ + t = event.type + if t not in EVENT_TYPE_ACTIONS: + return "" + payload = event.payload or {} + + if t == "PullRequestEvent": + action = payload.get("action", "") + if action == "closed": + pr = payload.get("pull_request") or {} + if pr.get("merged"): + return "merged" + return action + + if t in ("CreateEvent", "DeleteEvent"): + return payload.get("ref_type", "") + + if t == "PullRequestReviewEvent": + review = payload.get("review") or {} + return review.get("state", "") + + return payload.get("action", "") + + +def _is_event_in_visible(event, visible: set) -> bool: + """Return True if the event passes a visible-types filter set. + + For event types with known sub-actions the key is "Type:action". + For types without sub-actions the key is the plain "Type" string. + """ + t = event.type + if t in EVENT_TYPE_ACTIONS: + action = _get_event_action(event) + if action: + return f"{t}:{action}" in visible + return t in visible + + +def is_event_visible(event, visible: Optional[set[str]]) -> bool: + """Return True if *event* should appear in the feed. + + event — a models.event.Event instance + visible — None means "unconfigured, show all" + a set means "show only these type(:action) keys" + """ + if visible is None: + return True + return _is_event_in_visible(event, visible) + + +def filter_feed( + events, + visible: Optional[set[str]], + muted_repos: Optional[set[str]] = None, + user_filters: Optional[dict] = None, +) -> list: + """Return a new list containing only the visible events. + + Filters are applied in order: + 1. Muted repos (blacklist) — events from a muted repo are always hidden. + 2. Per-user override — if the actor has a rule, that rule's type/action + set is used instead of the global visible set (empty = mute user). + 3. Global visible types (whitelist) — applied to actors without a rule. + + The visible set may contain plain "EventType" strings (backward compat) or + granular "EventType:action" strings (current format). Both are handled by + _is_event_in_visible. + + Does not mutate the input iterable. + """ + result = [] + for e in events: + if muted_repos and e.repo.name.lower() in muted_repos: + continue + + if user_filters: + login = e.actor.login.lower() + vis = user_filters[login] if login in user_filters else visible + else: + vis = visible + + if vis is not None and not _is_event_in_visible(e, vis): + continue + + result.append(e) + return result diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py new file mode 100644 index 0000000..def0d1a --- /dev/null +++ b/tests/test_feed_filters.py @@ -0,0 +1,1191 @@ +"""Tests for models/feed_filter.py — feed filter preferences logic. + +All tests are pure Python (no wx). account_prefs is a plain dict because +load_visible_types only calls .get() and save_visible_types only uses +dict-style assignment, both of which work on plain dicts. +""" + +import pytest +from models.event import Event +from models.feed_filter import ( + ALL_EVENT_TYPES, + CONFIG_KEY, + EVENT_TYPE_ACTIONS, + FILTER_GROUPS, + MUTED_REPOS_KEY, + USER_FILTERS_KEY, + _get_event_action, + _is_event_in_visible, + filter_feed, + is_event_visible, + load_muted_repos, + load_user_filters, + load_visible_types, + save_muted_repos, + save_user_filters, + save_visible_types, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_event( + event_type: str, + repo: str = "owner/repo", + actor: str = "alice", + payload: dict | None = None, +) -> Event: + return Event.from_api( + { + "id": "1", + "type": event_type, + "actor": {"id": 1, "login": actor, "avatar_url": ""}, + "repo": {"id": 1, "name": repo, "url": ""}, + "payload": payload or {}, + "public": True, + "created_at": None, + } + ) + + +_sentinel = object() + + +def _prefs(hidden_key_value=_sentinel) -> dict: + """Return a plain dict acting as account prefs.""" + if hidden_key_value is _sentinel: + return {} + return {CONFIG_KEY: hidden_key_value} + + +# --------------------------------------------------------------------------- +# load_visible_types — baseline behaviour +# --------------------------------------------------------------------------- + + +def test_load_visible_types_absent_key_returns_none(): + assert load_visible_types({}) is None + + +def test_load_visible_types_empty_list_returns_empty_set(): + assert load_visible_types({CONFIG_KEY: []}) == set() + + +def test_load_visible_types_single_type(): + assert load_visible_types({CONFIG_KEY: ["PushEvent"]}) == {"PushEvent"} + + +def test_load_visible_types_multiple_types(): + result = load_visible_types({CONFIG_KEY: ["PushEvent", "ForkEvent"]}) + assert result == {"PushEvent", "ForkEvent"} + + +def test_load_visible_types_all_known_types(): + result = load_visible_types({CONFIG_KEY: ALL_EVENT_TYPES}) + assert result == set(ALL_EVENT_TYPES) + + +# --------------------------------------------------------------------------- +# load_visible_types — corrupt / invalid stored data +# --------------------------------------------------------------------------- + + +def test_load_visible_types_string_value_returns_none(): + assert load_visible_types({CONFIG_KEY: "PushEvent"}) is None + + +def test_load_visible_types_dict_value_returns_none(): + assert load_visible_types({CONFIG_KEY: {"PushEvent": True}}) is None + + +def test_load_visible_types_none_value_returns_none(): + assert load_visible_types({CONFIG_KEY: None}) is None + + +def test_load_visible_types_integer_value_returns_none(): + assert load_visible_types({CONFIG_KEY: 42}) is None + + +def test_load_visible_types_boolean_value_returns_none(): + assert load_visible_types({CONFIG_KEY: True}) is None + + +def test_load_visible_types_list_drops_non_string_items(): + result = load_visible_types({CONFIG_KEY: [42, None, "PushEvent", 3.14]}) + assert result == {"PushEvent"} + + +def test_load_visible_types_list_all_non_strings_returns_empty_set(): + result = load_visible_types({CONFIG_KEY: [1, 2, None]}) + assert result == set() + + +def test_load_visible_types_unknown_future_type_kept_in_set(): + # Forward-compat: an unknown type the user somehow added stays in the set + result = load_visible_types({CONFIG_KEY: ["UnknownFutureEvent2099"]}) + assert "UnknownFutureEvent2099" in result + + +# --------------------------------------------------------------------------- +# save_visible_types +# --------------------------------------------------------------------------- + + +def test_save_visible_types_writes_sorted_list(): + prefs = {} + save_visible_types(prefs, {"ForkEvent", "PushEvent", "WatchEvent"}) + assert prefs[CONFIG_KEY] == ["ForkEvent", "PushEvent", "WatchEvent"] + + +def test_save_visible_types_empty_set_writes_empty_list(): + prefs = {} + save_visible_types(prefs, set()) + assert prefs[CONFIG_KEY] == [] + + +def test_save_visible_types_all_types_roundtrip(): + prefs = {} + save_visible_types(prefs, set(ALL_EVENT_TYPES)) + assert sorted(prefs[CONFIG_KEY]) == sorted(ALL_EVENT_TYPES) + + +def test_save_visible_types_uses_correct_key(): + prefs = {} + save_visible_types(prefs, {"PushEvent"}) + assert CONFIG_KEY in prefs + + +def test_save_visible_types_overwrites_existing_value(): + prefs = {CONFIG_KEY: ["PushEvent"]} + save_visible_types(prefs, {"ForkEvent"}) + assert prefs[CONFIG_KEY] == ["ForkEvent"] + + +def test_save_visible_types_key_present_after_empty_set_save(): + # Empty set save must write the key so a subsequent load returns set(), not None + prefs = {} + save_visible_types(prefs, set()) + assert CONFIG_KEY in prefs + assert load_visible_types(prefs) == set() + + +# --------------------------------------------------------------------------- +# is_event_visible +# --------------------------------------------------------------------------- + + +def test_is_event_visible_none_visible_always_true(): + event = _make_event("PushEvent") + assert is_event_visible(event, None) is True + + +def test_is_event_visible_empty_set_always_false(): + event = _make_event("PushEvent") + assert is_event_visible(event, set()) is False + + +def test_is_event_visible_type_in_set_returns_true(): + event = _make_event("PushEvent") + assert is_event_visible(event, {"PushEvent", "ForkEvent"}) is True + + +def test_is_event_visible_type_not_in_set_returns_false(): + event = _make_event("ForkEvent") + assert is_event_visible(event, {"PushEvent"}) is False + + +def test_is_event_visible_unknown_type_with_none_returns_true(): + event = _make_event("NewUnknownEvent2099") + assert is_event_visible(event, None) is True + + +def test_is_event_visible_unknown_type_not_in_set_returns_false(): + event = _make_event("NewUnknownEvent2099") + assert is_event_visible(event, {"PushEvent"}) is False + + +def test_is_event_visible_unknown_type_in_set_returns_true(): + event = _make_event("NewUnknownEvent2099") + assert is_event_visible(event, {"NewUnknownEvent2099"}) is True + + +# --------------------------------------------------------------------------- +# filter_feed +# --------------------------------------------------------------------------- + + +def test_filter_feed_empty_feed_returns_empty_list(): + assert filter_feed([], {"PushEvent"}) == [] + + +def test_filter_feed_none_visible_returns_all_events(): + events = [_make_event("PushEvent"), _make_event("ForkEvent"), _make_event("WatchEvent")] + result = filter_feed(events, None) + assert len(result) == 3 + + +def test_filter_feed_empty_visible_set_returns_empty_list(): + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + assert filter_feed(events, set()) == [] + + +def test_filter_feed_hides_non_visible_type(): + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + result = filter_feed(events, {"ForkEvent"}) + assert len(result) == 1 + assert result[0].type == "ForkEvent" + + +def test_filter_feed_keeps_matching_type(): + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + result = filter_feed(events, {"PushEvent", "ForkEvent"}) + assert len(result) == 2 + + +def test_filter_feed_preserves_order(): + types = ["ForkEvent", "PushEvent", "WatchEvent"] + events = [_make_event(t) for t in types] + result = filter_feed(events, {"ForkEvent", "WatchEvent"}) + assert [e.type for e in result] == ["ForkEvent", "WatchEvent"] + + +def test_filter_feed_does_not_mutate_original_list(): + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + original_len = len(events) + filter_feed(events, {"ForkEvent"}) + assert len(events) == original_len + + +def test_filter_feed_returns_new_list_not_same_object(): + events = [_make_event("PushEvent")] + result = filter_feed(events, None) + assert result is not events + + +def test_filter_feed_multiple_events_of_hidden_type_all_removed(): + events = [_make_event("PushEvent")] * 3 + [_make_event("ForkEvent")] + result = filter_feed(events, {"ForkEvent"}) + assert len(result) == 1 + assert result[0].type == "ForkEvent" + + +def test_filter_feed_multiple_events_of_visible_type_all_kept(): + events = [_make_event("PushEvent")] * 4 + result = filter_feed(events, {"PushEvent"}) + assert len(result) == 4 + + +# --------------------------------------------------------------------------- +# Realistic scenarios +# --------------------------------------------------------------------------- + + +def test_filter_feed_only_pr_events_visible(): + pr_types = ["PullRequestEvent", "PullRequestReviewEvent", + "PullRequestReviewCommentEvent", "PullRequestReviewThreadEvent"] + all_events = [_make_event(t) for t in ALL_EVENT_TYPES] + result = filter_feed(all_events, set(pr_types)) + assert all(e.type in pr_types for e in result) + assert len(result) == len(pr_types) + + +def test_filter_feed_partial_filter_correct_count(): + # One event per type, hide 5 → 14 visible + all_events = [_make_event(t) for t in ALL_EVENT_TYPES] + hidden = {"PushEvent", "ForkEvent", "WatchEvent", "GollumEvent", "MemberEvent"} + visible = set(ALL_EVENT_TYPES) - hidden + result = filter_feed(all_events, visible) + assert len(result) == len(ALL_EVENT_TYPES) - len(hidden) + + +def test_filter_feed_unknown_type_hidden_when_filter_configured(): + # Whitelist model: unknown types not in visible set are hidden + events = [_make_event("NewGitHubEvent2099"), _make_event("PushEvent")] + result = filter_feed(events, {"PushEvent"}) + assert len(result) == 1 + assert result[0].type == "PushEvent" + + +# --------------------------------------------------------------------------- +# Constants integrity +# --------------------------------------------------------------------------- + + +def test_config_key_value(): + assert CONFIG_KEY == "feed_visible_event_types" + + +def test_all_event_types_has_no_duplicates(): + assert len(ALL_EVENT_TYPES) == len(set(ALL_EVENT_TYPES)) + + +def test_filter_groups_cover_all_event_types_exactly_once(): + covered = [] + for _label, types in FILTER_GROUPS: + covered.extend(types) + assert sorted(covered) == sorted(ALL_EVENT_TYPES) + + +def test_filter_groups_has_no_duplicates_within_or_across_groups(): + covered = [] + for _label, types in FILTER_GROUPS: + covered.extend(types) + assert len(covered) == len(set(covered)) + + +# --------------------------------------------------------------------------- +# Account isolation +# --------------------------------------------------------------------------- + + +def test_different_prefs_give_different_visible_sets(): + prefs_a = {CONFIG_KEY: ["PushEvent"]} + prefs_b = {CONFIG_KEY: ["ForkEvent"]} + assert load_visible_types(prefs_a) != load_visible_types(prefs_b) + + +def test_filter_feed_with_account_a_prefs_hides_push(): + prefs_a = {CONFIG_KEY: ["ForkEvent"]} + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + visible = load_visible_types(prefs_a) + result = filter_feed(events, visible) + assert all(e.type == "ForkEvent" for e in result) + + +def test_filter_feed_with_account_b_prefs_hides_fork(): + prefs_b = {CONFIG_KEY: ["PushEvent"]} + events = [_make_event("PushEvent"), _make_event("ForkEvent")] + visible = load_visible_types(prefs_b) + result = filter_feed(events, visible) + assert all(e.type == "PushEvent" for e in result) + + +def test_saving_account_b_prefs_does_not_affect_account_a_prefs(): + prefs_a = {CONFIG_KEY: ["PushEvent"]} + prefs_b = {} + save_visible_types(prefs_b, {"ForkEvent"}) + assert load_visible_types(prefs_a) == {"PushEvent"} + + +# --------------------------------------------------------------------------- +# Roundtrips +# --------------------------------------------------------------------------- + + +def test_roundtrip_single_type(): + prefs = {} + save_visible_types(prefs, {"PushEvent"}) + assert load_visible_types(prefs) == {"PushEvent"} + + +def test_roundtrip_empty_set_returns_empty_set_not_none(): + prefs = {} + save_visible_types(prefs, set()) + result = load_visible_types(prefs) + assert result is not None + assert result == set() + + +def test_roundtrip_partial_set_preserved(): + original = {"PushEvent", "ForkEvent", "WatchEvent"} + prefs = {} + save_visible_types(prefs, original) + assert load_visible_types(prefs) == original + + +def test_roundtrip_all_types(): + prefs = {} + save_visible_types(prefs, set(ALL_EVENT_TYPES)) + assert load_visible_types(prefs) == set(ALL_EVENT_TYPES) + + +# --------------------------------------------------------------------------- +# load_muted_repos — baseline +# --------------------------------------------------------------------------- + + +def test_load_muted_repos_absent_key_returns_none(): + assert load_muted_repos({}) is None + + +def test_load_muted_repos_empty_list_returns_empty_set(): + assert load_muted_repos({MUTED_REPOS_KEY: []}) == set() + + +def test_load_muted_repos_single_repo(): + assert load_muted_repos({MUTED_REPOS_KEY: ["owner/repo"]}) == {"owner/repo"} + + +def test_load_muted_repos_multiple_repos(): + result = load_muted_repos({MUTED_REPOS_KEY: ["alice/foo", "bob/bar"]}) + assert result == {"alice/foo", "bob/bar"} + + +# --------------------------------------------------------------------------- +# load_muted_repos — corrupt / invalid stored data +# --------------------------------------------------------------------------- + + +def test_load_muted_repos_string_value_returns_none(): + assert load_muted_repos({MUTED_REPOS_KEY: "owner/repo"}) is None + + +def test_load_muted_repos_dict_value_returns_none(): + assert load_muted_repos({MUTED_REPOS_KEY: {"owner/repo": True}}) is None + + +def test_load_muted_repos_none_value_returns_none(): + assert load_muted_repos({MUTED_REPOS_KEY: None}) is None + + +def test_load_muted_repos_integer_value_returns_none(): + assert load_muted_repos({MUTED_REPOS_KEY: 42}) is None + + +def test_load_muted_repos_list_drops_non_string_items(): + result = load_muted_repos({MUTED_REPOS_KEY: [42, None, "owner/repo", 3.14]}) + assert result == {"owner/repo"} + + +def test_load_muted_repos_list_all_non_strings_returns_empty_set(): + result = load_muted_repos({MUTED_REPOS_KEY: [1, 2, None]}) + assert result == set() + + +# --------------------------------------------------------------------------- +# save_muted_repos +# --------------------------------------------------------------------------- + + +def test_save_muted_repos_writes_sorted_list(): + prefs = {} + save_muted_repos(prefs, {"charlie/z", "alice/a", "bob/m"}) + assert prefs[MUTED_REPOS_KEY] == ["alice/a", "bob/m", "charlie/z"] + + +def test_save_muted_repos_empty_set_writes_empty_list(): + prefs = {} + save_muted_repos(prefs, set()) + assert prefs[MUTED_REPOS_KEY] == [] + + +def test_save_muted_repos_uses_correct_key(): + prefs = {} + save_muted_repos(prefs, {"owner/repo"}) + assert MUTED_REPOS_KEY in prefs + + +def test_save_muted_repos_overwrites_existing(): + prefs = {MUTED_REPOS_KEY: ["old/repo"]} + save_muted_repos(prefs, {"new/repo"}) + assert prefs[MUTED_REPOS_KEY] == ["new/repo"] + + +def test_save_muted_repos_key_present_after_empty_save(): + prefs = {} + save_muted_repos(prefs, set()) + assert MUTED_REPOS_KEY in prefs + assert load_muted_repos(prefs) == set() + + +# --------------------------------------------------------------------------- +# filter_feed — muted repos +# --------------------------------------------------------------------------- + + +def test_filter_feed_muted_repos_none_passes_all(): + events = [_make_event("PushEvent", "alice/foo"), _make_event("ForkEvent", "bob/bar")] + assert len(filter_feed(events, None, None)) == 2 + + +def test_filter_feed_muted_repos_empty_set_passes_all(): + events = [_make_event("PushEvent", "alice/foo"), _make_event("ForkEvent", "bob/bar")] + assert len(filter_feed(events, None, set())) == 2 + + +def test_filter_feed_muted_repo_hides_matching_events(): + events = [ + _make_event("PushEvent", "alice/foo"), + _make_event("ForkEvent", "bob/bar"), + ] + result = filter_feed(events, None, {"alice/foo"}) + assert len(result) == 1 + assert result[0].repo.name == "bob/bar" + + +def test_filter_feed_muted_repo_hides_all_event_types_from_that_repo(): + events = [_make_event(t, "noisy/repo") for t in ["PushEvent", "ForkEvent", "WatchEvent"]] + result = filter_feed(events, None, {"noisy/repo"}) + assert result == [] + + +def test_filter_feed_non_muted_repo_events_pass_through(): + events = [_make_event("PushEvent", "safe/repo")] + result = filter_feed(events, None, {"other/repo"}) + assert len(result) == 1 + + +def test_filter_feed_muted_repos_and_visible_types_both_applied(): + events = [ + _make_event("PushEvent", "alice/foo"), # wrong repo + _make_event("ForkEvent", "bob/bar"), # wrong type + _make_event("PushEvent", "bob/bar"), # passes both + ] + result = filter_feed(events, {"PushEvent"}, {"alice/foo"}) + assert len(result) == 1 + assert result[0].repo.name == "bob/bar" + assert result[0].type == "PushEvent" + + +def test_filter_feed_muted_repo_beats_visible_type(): + # Even if the event type is in visible, muted repo blocks it + events = [_make_event("PushEvent", "muted/repo")] + result = filter_feed(events, {"PushEvent"}, {"muted/repo"}) + assert result == [] + + +def test_filter_feed_multiple_muted_repos(): + events = [ + _make_event("PushEvent", "muted/one"), + _make_event("PushEvent", "muted/two"), + _make_event("PushEvent", "allowed/repo"), + ] + result = filter_feed(events, None, {"muted/one", "muted/two"}) + assert len(result) == 1 + assert result[0].repo.name == "allowed/repo" + + +def test_filter_feed_muted_repos_preserves_order(): + events = [ + _make_event("PushEvent", "keep/a"), + _make_event("PushEvent", "muted/x"), + _make_event("PushEvent", "keep/b"), + ] + result = filter_feed(events, None, {"muted/x"}) + assert [e.repo.name for e in result] == ["keep/a", "keep/b"] + + +def test_filter_feed_does_not_mutate_original_with_muted_repos(): + events = [_make_event("PushEvent", "muted/repo"), _make_event("ForkEvent", "safe/repo")] + original_len = len(events) + filter_feed(events, None, {"muted/repo"}) + assert len(events) == original_len + + +# --------------------------------------------------------------------------- +# Muted repos roundtrips +# --------------------------------------------------------------------------- + + +def test_muted_repos_roundtrip_single(): + prefs = {} + save_muted_repos(prefs, {"owner/repo"}) + assert load_muted_repos(prefs) == {"owner/repo"} + + +def test_muted_repos_roundtrip_multiple(): + original = {"alice/foo", "bob/bar", "charlie/baz"} + prefs = {} + save_muted_repos(prefs, original) + assert load_muted_repos(prefs) == original + + +def test_muted_repos_roundtrip_empty_returns_empty_not_none(): + prefs = {} + save_muted_repos(prefs, set()) + result = load_muted_repos(prefs) + assert result is not None + assert result == set() + + +# --------------------------------------------------------------------------- +# Muted repos account isolation +# --------------------------------------------------------------------------- + + +def test_muted_repos_different_prefs_give_different_sets(): + prefs_a = {MUTED_REPOS_KEY: ["alice/foo"]} + prefs_b = {MUTED_REPOS_KEY: ["bob/bar"]} + assert load_muted_repos(prefs_a) != load_muted_repos(prefs_b) + + +def test_saving_muted_repos_for_b_does_not_affect_a(): + prefs_a = {MUTED_REPOS_KEY: ["alice/foo"]} + prefs_b = {} + save_muted_repos(prefs_b, {"bob/bar"}) + assert load_muted_repos(prefs_a) == {"alice/foo"} + + +def test_muted_repos_key_value(): + assert MUTED_REPOS_KEY == "feed_muted_repos" + + +# --------------------------------------------------------------------------- +# load_user_filters — baseline +# --------------------------------------------------------------------------- + + +def test_load_user_filters_absent_key_returns_none(): + assert load_user_filters({}) is None + + +def test_load_user_filters_empty_dict_returns_empty_dict(): + assert load_user_filters({USER_FILTERS_KEY: {}}) == {} + + +def test_load_user_filters_single_user_all_types(): + result = load_user_filters({USER_FILTERS_KEY: {"alice": ["PushEvent"]}}) + assert result == {"alice": {"PushEvent"}} + + +def test_load_user_filters_multiple_users(): + result = load_user_filters({ + USER_FILTERS_KEY: {"alice": ["PushEvent"], "bob": ["ForkEvent", "WatchEvent"]} + }) + assert result == {"alice": {"PushEvent"}, "bob": {"ForkEvent", "WatchEvent"}} + + +def test_load_user_filters_empty_list_means_muted(): + result = load_user_filters({USER_FILTERS_KEY: {"alice": []}}) + assert result == {"alice": set()} + + +# --------------------------------------------------------------------------- +# load_user_filters — corrupt / invalid stored data +# --------------------------------------------------------------------------- + + +def test_load_user_filters_non_dict_top_level_returns_none(): + assert load_user_filters({USER_FILTERS_KEY: ["alice"]}) is None + + +def test_load_user_filters_string_top_level_returns_none(): + assert load_user_filters({USER_FILTERS_KEY: "alice"}) is None + + +def test_load_user_filters_none_top_level_returns_none(): + assert load_user_filters({USER_FILTERS_KEY: None}) is None + + +def test_load_user_filters_integer_top_level_returns_none(): + assert load_user_filters({USER_FILTERS_KEY: 42}) is None + + +def test_load_user_filters_accepts_mapping_not_just_dict(): + # Config wraps stored dicts as a MutableMapping subclass — must be accepted. + from collections.abc import MutableMapping + + class FakeConfig(MutableMapping): + def __init__(self, data): + self._d = data + def __getitem__(self, k): return self._d[k] + def __setitem__(self, k, v): self._d[k] = v + def __delitem__(self, k): del self._d[k] + def __iter__(self): return iter(self._d) + def __len__(self): return len(self._d) + + raw = FakeConfig({"alice": ["PushEvent"]}) + prefs = {USER_FILTERS_KEY: raw} + result = load_user_filters(prefs) + assert result == {"alice": {"PushEvent"}} + + +def test_load_user_filters_invalid_username_key_skipped(): + result = load_user_filters({USER_FILTERS_KEY: {42: ["PushEvent"], "alice": ["ForkEvent"]}}) + assert result == {"alice": {"ForkEvent"}} + + +def test_load_user_filters_invalid_types_list_entry_skipped(): + result = load_user_filters({USER_FILTERS_KEY: {"alice": "PushEvent", "bob": ["ForkEvent"]}}) + assert result == {"bob": {"ForkEvent"}} + + +def test_load_user_filters_non_string_types_dropped(): + result = load_user_filters({USER_FILTERS_KEY: {"alice": [42, None, "PushEvent"]}}) + assert result == {"alice": {"PushEvent"}} + + +# --------------------------------------------------------------------------- +# save_user_filters +# --------------------------------------------------------------------------- + + +def test_save_user_filters_writes_sorted_lists(): + prefs = {} + save_user_filters(prefs, {"alice": {"WatchEvent", "ForkEvent", "PushEvent"}}) + assert prefs[USER_FILTERS_KEY] == {"alice": ["ForkEvent", "PushEvent", "WatchEvent"]} + + +def test_save_user_filters_empty_set_means_muted(): + prefs = {} + save_user_filters(prefs, {"alice": set()}) + assert prefs[USER_FILTERS_KEY] == {"alice": []} + + +def test_save_user_filters_multiple_users(): + prefs = {} + save_user_filters(prefs, {"alice": {"PushEvent"}, "bob": set()}) + assert prefs[USER_FILTERS_KEY]["alice"] == ["PushEvent"] + assert prefs[USER_FILTERS_KEY]["bob"] == [] + + +def test_save_user_filters_uses_correct_key(): + prefs = {} + save_user_filters(prefs, {"alice": {"PushEvent"}}) + assert USER_FILTERS_KEY in prefs + + +def test_save_user_filters_overwrites_existing(): + prefs = {USER_FILTERS_KEY: {"old": ["PushEvent"]}} + save_user_filters(prefs, {"new": {"ForkEvent"}}) + assert "old" not in prefs[USER_FILTERS_KEY] + assert prefs[USER_FILTERS_KEY]["new"] == ["ForkEvent"] + + +def test_save_user_filters_empty_dict(): + prefs = {} + save_user_filters(prefs, {}) + assert prefs[USER_FILTERS_KEY] == {} + + +# --------------------------------------------------------------------------- +# filter_feed — user filters +# --------------------------------------------------------------------------- + + +def test_filter_feed_user_filters_none_uses_global(): + events = [_make_event("PushEvent", actor="alice"), _make_event("ForkEvent", actor="alice")] + result = filter_feed(events, {"PushEvent"}, None, None) + assert len(result) == 1 + assert result[0].type == "PushEvent" + + +def test_filter_feed_user_filter_overrides_global_for_that_actor(): + # Global hides ForkEvent, but alice has a rule allowing it + events = [_make_event("ForkEvent", actor="alice")] + result = filter_feed(events, {"PushEvent"}, None, {"alice": {"ForkEvent"}}) + assert len(result) == 1 + + +def test_filter_feed_user_filter_empty_set_mutes_user(): + events = [_make_event("PushEvent", actor="alice"), _make_event("PushEvent", actor="bob")] + result = filter_feed(events, {"PushEvent"}, None, {"alice": set()}) + assert len(result) == 1 + assert result[0].actor.login == "bob" + + +def test_filter_feed_user_filter_does_not_affect_other_actors(): + events = [ + _make_event("ForkEvent", actor="alice"), + _make_event("ForkEvent", actor="bob"), + ] + # alice has a rule (PushEvent only), bob uses global (all) + result = filter_feed(events, None, None, {"alice": {"PushEvent"}}) + assert len(result) == 1 + assert result[0].actor.login == "bob" + + +def test_filter_feed_user_filter_type_in_rule_passes(): + events = [_make_event("PushEvent", actor="alice")] + result = filter_feed(events, None, None, {"alice": {"PushEvent", "ForkEvent"}}) + assert len(result) == 1 + + +def test_filter_feed_user_filter_type_not_in_rule_hidden(): + events = [_make_event("WatchEvent", actor="alice")] + result = filter_feed(events, None, None, {"alice": {"PushEvent"}}) + assert result == [] + + +def test_filter_feed_muted_repo_beats_user_filter(): + # Even if actor has a permissive user rule, muted repo wins + events = [_make_event("PushEvent", repo="muted/repo", actor="alice")] + result = filter_feed(events, None, {"muted/repo"}, {"alice": set(ALL_EVENT_TYPES)}) + assert result == [] + + +def test_filter_feed_all_three_filters_interact(): + events = [ + _make_event("PushEvent", repo="muted/repo", actor="alice"), # blocked by repo + _make_event("ForkEvent", repo="safe/repo", actor="alice"), # alice rule: PushEvent only → hidden + _make_event("PushEvent", repo="safe/repo", actor="alice"), # alice rule: passes + _make_event("ForkEvent", repo="safe/repo", actor="bob"), # global: PushEvent only → hidden + _make_event("PushEvent", repo="safe/repo", actor="bob"), # global: passes + ] + result = filter_feed(events, {"PushEvent"}, {"muted/repo"}, {"alice": {"PushEvent"}}) + assert len(result) == 2 + assert all(e.type == "PushEvent" for e in result) + + +def test_filter_feed_user_filters_empty_dict_uses_global(): + events = [_make_event("ForkEvent", actor="alice")] + result = filter_feed(events, {"PushEvent"}, None, {}) + assert result == [] + + +def test_filter_feed_user_filter_matches_case_insensitive_actor(): + # API may return actor login in any case; stored key is lowercase + events = [_make_event("PushEvent", actor="Alice")] + result = filter_feed(events, None, None, {"alice": set()}) + assert result == [] + + +def test_filter_feed_user_filter_mixed_case_actor_types_respected(): + events = [ + _make_event("PushEvent", actor="Alice"), + _make_event("ForkEvent", actor="Alice"), + ] + result = filter_feed(events, None, None, {"alice": {"PushEvent"}}) + assert len(result) == 1 + assert result[0].type == "PushEvent" + + +def test_filter_feed_muted_repo_matches_case_insensitive(): + # Stored repo names are lowercased; API may return mixed case + events = [_make_event("PushEvent", repo="Owner/Repo")] + result = filter_feed(events, None, {"owner/repo"}, None) + assert result == [] + + +# --------------------------------------------------------------------------- +# User filters roundtrips +# --------------------------------------------------------------------------- + + +def test_user_filters_roundtrip_single_user(): + prefs = {} + save_user_filters(prefs, {"alice": {"PushEvent", "ForkEvent"}}) + result = load_user_filters(prefs) + assert result == {"alice": {"PushEvent", "ForkEvent"}} + + +def test_user_filters_roundtrip_muted_user(): + prefs = {} + save_user_filters(prefs, {"alice": set()}) + result = load_user_filters(prefs) + assert result == {"alice": set()} + + +def test_user_filters_roundtrip_multiple_users(): + original = {"alice": {"PushEvent"}, "bob": set(), "charlie": set(ALL_EVENT_TYPES)} + prefs = {} + save_user_filters(prefs, original) + assert load_user_filters(prefs) == original + + +def test_user_filters_roundtrip_empty_dict(): + prefs = {} + save_user_filters(prefs, {}) + result = load_user_filters(prefs) + assert result == {} + + +# --------------------------------------------------------------------------- +# User filters account isolation +# --------------------------------------------------------------------------- + + +def test_user_filters_different_prefs_independent(): + prefs_a = {USER_FILTERS_KEY: {"alice": ["PushEvent"]}} + prefs_b = {USER_FILTERS_KEY: {"bob": []}} + assert load_user_filters(prefs_a) != load_user_filters(prefs_b) + + +def test_saving_user_filters_for_b_does_not_affect_a(): + prefs_a = {USER_FILTERS_KEY: {"alice": ["PushEvent"]}} + prefs_b = {} + save_user_filters(prefs_b, {"bob": set()}) + assert load_user_filters(prefs_a) == {"alice": {"PushEvent"}} + + +def test_user_filters_key_value(): + assert USER_FILTERS_KEY == "feed_user_filters" + + +# --------------------------------------------------------------------------- +# EVENT_TYPE_ACTIONS constants integrity +# --------------------------------------------------------------------------- + + +def test_event_type_actions_keys_are_subsets_of_all_event_types(): + for key in EVENT_TYPE_ACTIONS: + assert key in ALL_EVENT_TYPES, f"{key!r} not in ALL_EVENT_TYPES" + + +def test_event_type_actions_has_no_empty_action_lists(): + for key, actions in EVENT_TYPE_ACTIONS.items(): + assert len(actions) > 0, f"{key!r} has empty action list" + + +def test_event_type_actions_pr_includes_merged(): + assert "merged" in EVENT_TYPE_ACTIONS["PullRequestEvent"] + + +def test_event_type_actions_pr_review_states(): + states = EVENT_TYPE_ACTIONS["PullRequestReviewEvent"] + assert "approved" in states + assert "changes_requested" in states + assert "commented" in states + + +def test_event_type_actions_create_includes_ref_types(): + actions = EVENT_TYPE_ACTIONS["CreateEvent"] + assert "branch" in actions + assert "tag" in actions + assert "repository" in actions + + +def test_event_type_actions_delete_includes_ref_types(): + actions = EVENT_TYPE_ACTIONS["DeleteEvent"] + assert "branch" in actions + assert "tag" in actions + + +# --------------------------------------------------------------------------- +# _get_event_action +# --------------------------------------------------------------------------- + + +def test_get_event_action_push_event_returns_empty(): + # PushEvent has no sub-actions + e = _make_event("PushEvent") + assert _get_event_action(e) == "" + + +def test_get_event_action_fork_event_returns_empty(): + e = _make_event("ForkEvent") + assert _get_event_action(e) == "" + + +def test_get_event_action_pr_opened(): + e = _make_event("PullRequestEvent", payload={"action": "opened"}) + assert _get_event_action(e) == "opened" + + +def test_get_event_action_pr_closed_not_merged(): + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": False}}) + assert _get_event_action(e) == "closed" + + +def test_get_event_action_pr_merged_synthetic(): + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + assert _get_event_action(e) == "merged" + + +def test_get_event_action_pr_merged_null_pr_field(): + # pull_request key absent — should return "closed", not crash + e = _make_event("PullRequestEvent", payload={"action": "closed"}) + assert _get_event_action(e) == "closed" + + +def test_get_event_action_pr_reopened(): + e = _make_event("PullRequestEvent", payload={"action": "reopened"}) + assert _get_event_action(e) == "reopened" + + +def test_get_event_action_issues_opened(): + e = _make_event("IssuesEvent", payload={"action": "opened"}) + assert _get_event_action(e) == "opened" + + +def test_get_event_action_issue_comment_created(): + e = _make_event("IssueCommentEvent", payload={"action": "created"}) + assert _get_event_action(e) == "created" + + +def test_get_event_action_create_branch(): + e = _make_event("CreateEvent", payload={"ref_type": "branch"}) + assert _get_event_action(e) == "branch" + + +def test_get_event_action_create_tag(): + e = _make_event("CreateEvent", payload={"ref_type": "tag"}) + assert _get_event_action(e) == "tag" + + +def test_get_event_action_create_repository(): + e = _make_event("CreateEvent", payload={"ref_type": "repository"}) + assert _get_event_action(e) == "repository" + + +def test_get_event_action_delete_branch(): + e = _make_event("DeleteEvent", payload={"ref_type": "branch"}) + assert _get_event_action(e) == "branch" + + +def test_get_event_action_pr_review_approved(): + e = _make_event("PullRequestReviewEvent", payload={"review": {"state": "approved"}}) + assert _get_event_action(e) == "approved" + + +def test_get_event_action_pr_review_changes_requested(): + e = _make_event("PullRequestReviewEvent", payload={"review": {"state": "changes_requested"}}) + assert _get_event_action(e) == "changes_requested" + + +def test_get_event_action_pr_review_submitted_action_ignored(): + # PullRequestReviewEvent action is always "submitted" — we use review.state instead + e = _make_event("PullRequestReviewEvent", payload={"action": "submitted", "review": {"state": "approved"}}) + assert _get_event_action(e) == "approved" + + +def test_get_event_action_member_added(): + e = _make_event("MemberEvent", payload={"action": "added"}) + assert _get_event_action(e) == "added" + + +def test_get_event_action_release_published(): + e = _make_event("ReleaseEvent", payload={"action": "published"}) + assert _get_event_action(e) == "published" + + +def test_get_event_action_empty_payload_returns_empty_for_action_type(): + # IssuesEvent with no payload — action is absent, returns "" + e = _make_event("IssuesEvent", payload={}) + assert _get_event_action(e) == "" + + +# --------------------------------------------------------------------------- +# _is_event_in_visible — action-level +# --------------------------------------------------------------------------- + + +def test_is_event_in_visible_pr_opened_correct_key(): + e = _make_event("PullRequestEvent", payload={"action": "opened"}) + assert _is_event_in_visible(e, {"PullRequestEvent:opened"}) is True + + +def test_is_event_in_visible_pr_opened_wrong_action(): + e = _make_event("PullRequestEvent", payload={"action": "opened"}) + assert _is_event_in_visible(e, {"PullRequestEvent:closed"}) is False + + +def test_is_event_in_visible_pr_merged(): + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + assert _is_event_in_visible(e, {"PullRequestEvent:merged"}) is True + + +def test_is_event_in_visible_pr_merged_closed_key_does_not_match(): + # merged PR must use "merged" key, not "closed" + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + assert _is_event_in_visible(e, {"PullRequestEvent:closed"}) is False + + +def test_is_event_in_visible_pr_review_approved(): + e = _make_event("PullRequestReviewEvent", payload={"review": {"state": "approved"}}) + assert _is_event_in_visible(e, {"PullRequestReviewEvent:approved"}) is True + + +def test_is_event_in_visible_create_branch(): + e = _make_event("CreateEvent", payload={"ref_type": "branch"}) + assert _is_event_in_visible(e, {"CreateEvent:branch"}) is True + + +def test_is_event_in_visible_create_tag_does_not_match_branch(): + e = _make_event("CreateEvent", payload={"ref_type": "tag"}) + assert _is_event_in_visible(e, {"CreateEvent:branch"}) is False + + +def test_is_event_in_visible_push_plain_type(): + # PushEvent has no actions — checked by plain type key + e = _make_event("PushEvent") + assert _is_event_in_visible(e, {"PushEvent"}) is True + + +def test_is_event_in_visible_push_not_in_visible(): + e = _make_event("PushEvent") + assert _is_event_in_visible(e, {"ForkEvent"}) is False + + +def test_is_event_in_visible_action_type_empty_payload_fallback(): + # Action type with empty payload → action is "" → falls through to plain type check + e = _make_event("IssuesEvent", payload={}) + assert _is_event_in_visible(e, {"IssuesEvent"}) is True + + +# --------------------------------------------------------------------------- +# filter_feed — action-level filtering +# --------------------------------------------------------------------------- + + +def test_filter_feed_action_level_pr_opened_visible(): + e = _make_event("PullRequestEvent", payload={"action": "opened"}) + result = filter_feed([e], {"PullRequestEvent:opened"}) + assert len(result) == 1 + + +def test_filter_feed_action_level_pr_closed_hidden(): + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": False}}) + result = filter_feed([e], {"PullRequestEvent:opened"}) + assert result == [] + + +def test_filter_feed_action_level_pr_merged_visible(): + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + result = filter_feed([e], {"PullRequestEvent:merged"}) + assert len(result) == 1 + + +def test_filter_feed_action_level_pr_merged_not_in_closed_key(): + # Visible set has "closed" but PR is actually merged — must be hidden + e = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + result = filter_feed([e], {"PullRequestEvent:closed"}) + assert result == [] + + +def test_filter_feed_action_level_multiple_actions_some_visible(): + opened = _make_event("PullRequestEvent", payload={"action": "opened"}) + closed = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": False}}) + merged = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": True}}) + result = filter_feed([opened, closed, merged], {"PullRequestEvent:opened", "PullRequestEvent:merged"}) + assert len(result) == 2 + assert closed not in result + + +def test_filter_feed_action_level_create_branch_only(): + branch = _make_event("CreateEvent", payload={"ref_type": "branch"}) + tag = _make_event("CreateEvent", payload={"ref_type": "tag"}) + repo = _make_event("CreateEvent", payload={"ref_type": "repository"}) + result = filter_feed([branch, tag, repo], {"CreateEvent:branch"}) + assert len(result) == 1 + assert result[0].payload["ref_type"] == "branch" + + +def test_filter_feed_action_level_pr_review_approved_only(): + approved = _make_event("PullRequestReviewEvent", payload={"review": {"state": "approved"}}) + changes = _make_event("PullRequestReviewEvent", payload={"review": {"state": "changes_requested"}}) + commented = _make_event("PullRequestReviewEvent", payload={"review": {"state": "commented"}}) + result = filter_feed([approved, changes, commented], {"PullRequestReviewEvent:approved"}) + assert len(result) == 1 + + +def test_filter_feed_action_and_plain_type_mixed_visible(): + push = _make_event("PushEvent") + pr_opened = _make_event("PullRequestEvent", payload={"action": "opened"}) + pr_closed = _make_event("PullRequestEvent", payload={"action": "closed", "pull_request": {"merged": False}}) + visible = {"PushEvent", "PullRequestEvent:opened"} + result = filter_feed([push, pr_opened, pr_closed], visible) + assert len(result) == 2 + assert pr_closed not in result + + +def test_filter_feed_action_level_user_filter_action_key(): + # Per-user rule using action-level keys + pr_opened = _make_event("PullRequestEvent", actor="alice", payload={"action": "opened"}) + pr_closed = _make_event("PullRequestEvent", actor="alice", payload={"action": "closed", "pull_request": {"merged": False}}) + result = filter_feed([pr_opened, pr_closed], None, None, {"alice": {"PullRequestEvent:opened"}}) + assert len(result) == 1 + assert result[0] is pr_opened + + +def test_filter_feed_action_level_issues_multiple_actions(): + opened = _make_event("IssuesEvent", payload={"action": "opened"}) + closed = _make_event("IssuesEvent", payload={"action": "closed"}) + labeled = _make_event("IssuesEvent", payload={"action": "labeled"}) + visible = {"IssuesEvent:opened", "IssuesEvent:closed"} + result = filter_feed([opened, closed, labeled], visible) + assert len(result) == 2 + assert labeled not in result