From 4d7a2fe38c79d9ea56da426402f64dc0bea1a1c8 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:01:45 -0600 Subject: [PATCH 1/9] Add per-account feed filter preferences with Feed Filters tab - Add models/feed_filter.py: pure-Python whitelist filter logic (load/save visible types, filter_feed, is_event_visible) - Add tests/test_feed_filters.py: 51 tests covering all edge cases, corrupt data, roundtrips, and account isolation - GUI/main.py: apply filter in _render_feed_list, cache to _visible_feed, fix get_selected_feed_event index mismatch - GUI/options.py: restructure with wx.Notebook (General + Feed Filters tabs), 19 grouped checkboxes, Select/Deselect All, Enter activates OK, Apply no longer shows confirmation dialog - build.py: add models.feed_filter hidden import, --collect-all wx Co-Authored-By: Claude Sonnet 4.6 --- GUI/main.py | 12 +- GUI/options.py | 297 ++++++++++++++++------------ build.py | 4 +- models/feed_filter.py | 159 +++++++++++++++ tests/test_feed_filters.py | 388 +++++++++++++++++++++++++++++++++++++ 5 files changed, 732 insertions(+), 128 deletions(-) create mode 100644 models/feed_filter.py create mode 100644 tests/test_feed_filters.py diff --git a/GUI/main.py b/GUI/main.py index ac0f147..83db6bc 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,13 @@ 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, filter_feed + visible = load_visible_types(self.app.currentAccount.prefs) if self.app.currentAccount else None + self._visible_feed = filter_feed(self.feed, visible) + 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..61ce3e2 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -17,6 +17,29 @@ HOTKEY_SUPPORTED = False +EVENT_DISPLAY_NAMES = { + "PullRequestEvent": "&Pull Request opened/closed/merged", + "PullRequestReviewEvent": "Pull Request &Review submitted", + "PullRequestReviewCommentEvent": "Pull Request Review &Comment", + "PullRequestReviewThreadEvent": "Pull Request Review &Thread", + "IssuesEvent": "&Issue opened/closed/labeled", + "IssueCommentEvent": "Issue C&omment", + "PushEvent": "P&ush (commits)", + "CommitCommentEvent": "Commit Comm&ent", + "CreateEvent": "C&reate branch/tag/repo", + "DeleteEvent": "&Delete branch/tag", + "ForkEvent": "&Fork", + "WatchEvent": "&Star (watch event)", + "MemberEvent": "&Member added as collaborator", + "GollumEvent": "&Wiki page updated", + "ReleaseEvent": "&Release published", + "DiscussionEvent": "&Discussion created/answered", + "DiscussionCommentEvent": "Discussion Co&mment", + "SponsorshipEvent": "Spon&sorship", + "PublicEvent": "Repo made P&ublic", +} + + class OptionsDialog(wx.Dialog): """Dialog for application options.""" @@ -24,7 +47,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 +57,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 +161,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 +242,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 +255,72 @@ 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 + 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 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.cancel_btn = wx.Button(self.panel, wx.ID_CANCEL, label="&Cancel") - btn_sizer.Add(self.cancel_btn, 0, wx.RIGHT, 5) + self.filter_checkboxes: dict = {} - self.apply_btn = wx.Button(self.panel, label="&Apply") - btn_sizer.Add(self.apply_btn, 0) + 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: + 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) - main_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) + group_sizer.AddSpacer(6) + main_sizer.Add(group_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) - self.panel.SetSizer(main_sizer) + # 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) + + panel.SetSizer(main_sizer) def bind_events(self): """Bind event handlers.""" @@ -322,10 +336,17 @@ 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) + 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 +388,16 @@ 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 + visible = load_visible_types(self.app.currentAccount.prefs) + for event_type, cb in self.filter_checkboxes.items(): + cb.SetValue(True if visible is None else event_type in visible) + else: + for cb in self.filter_checkboxes.values(): + cb.SetValue(True) + def save_settings(self): """Save settings from the dialog.""" self.app.prefs.commit_limit = self.limit_spin.GetValue() @@ -451,6 +482,15 @@ 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 + visible = {et for et, cb in self.filter_checkboxes.items() if cb.GetValue()} + save_visible_types(self.app.currentAccount.prefs, visible) + from GUI import main as _main + if _main.window: + _main.window._render_feed_list() + return True def on_browse(self, event): @@ -511,6 +551,16 @@ 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_ok(self, event): """Handle OK button.""" if self.save_settings(): @@ -522,8 +572,7 @@ 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.""" 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..c8e83c4 --- /dev/null +++ b/models/feed_filter.py @@ -0,0 +1,159 @@ +"""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. +""" + +from __future__ import annotations + +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", +] + +# 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" + +# --------------------------------------------------------------------------- +# 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 is_event_visible(event, visible: Optional[set[str]]) -> bool: + """Return True if *event* should appear in the feed. + + event — a models.event.Event instance (only .type is read) + visible — None means "unconfigured, show all" + a set means "show only these types" + """ + if visible is None: + return True + return event.type in visible + + +def filter_feed(events, visible: Optional[set[str]]) -> list: + """Return a new list containing only the visible events. + + Does not mutate the input iterable. + If visible is None every event passes through (copy semantics preserved). + """ + if visible is None: + return list(events) + return [e for e in events if e.type in visible] diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py new file mode 100644 index 0000000..d1ef6ff --- /dev/null +++ b/tests/test_feed_filters.py @@ -0,0 +1,388 @@ +"""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, + FILTER_GROUPS, + filter_feed, + is_event_visible, + load_visible_types, + save_visible_types, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_event(event_type: str) -> Event: + return Event.from_api( + { + "id": "1", + "type": event_type, + "actor": {"id": 1, "login": "alice", "avatar_url": ""}, + "repo": {"id": 1, "name": "owner/repo", "url": ""}, + "payload": {}, + "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) From a014ac2177e4aec378d98ca28c271f0737041917 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:09:43 -0600 Subject: [PATCH 2/9] Add muted repositories filter with per-account persistence - models/feed_filter.py: add load/save_muted_repos and MUTED_REPOS_KEY; update filter_feed to apply muted repo blacklist before type whitelist - tests/test_feed_filters.py: 31 new tests covering load/save/corrupt data, filter interaction, roundtrips, and account isolation (93 total) - GUI/main.py: pass muted_repos to filter_feed in _render_feed_list - GUI/options.py: add Muted Repositories section to Feed Filters tab with ListBox, owner/repo text entry, Add/Remove buttons and validation Co-Authored-By: Claude Sonnet 4.6 --- GUI/main.py | 5 +- GUI/options.py | 62 +++++++++- models/feed_filter.py | 49 +++++++- tests/test_feed_filters.py | 228 ++++++++++++++++++++++++++++++++++++- 4 files changed, 333 insertions(+), 11 deletions(-) diff --git a/GUI/main.py b/GUI/main.py index 83db6bc..aa72d02 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -927,9 +927,10 @@ 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, filter_feed + from models.feed_filter import load_visible_types, load_muted_repos, filter_feed visible = load_visible_types(self.app.currentAccount.prefs) if self.app.currentAccount else None - self._visible_feed = filter_feed(self.feed, visible) + muted_repos = load_muted_repos(self.app.currentAccount.prefs) if self.app.currentAccount else None + self._visible_feed = filter_feed(self.feed, visible, muted_repos) selection = self.feed_list.GetSelection() self.feed_list.Clear() diff --git a/GUI/options.py b/GUI/options.py index 61ce3e2..79c3901 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -320,6 +320,32 @@ def _build_filters_tab(self, panel): bulk_sizer.Add(self.deselect_all_btn, 0) main_sizer.Add(bulk_sizer, 0, wx.LEFT | 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.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) + + add_row = wx.BoxSizer(wx.HORIZONTAL) + self.muted_repo_entry = wx.TextCtrl(panel, size=(280, -1)) + self.muted_repo_entry.SetHint("owner/repo") + 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") + add_row.Add(self.mute_remove_btn, 0) + mute_sizer.Add(add_row, 0, wx.EXPAND | wx.ALL, 8) + + main_sizer.Add(mute_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + panel.SetSizer(main_sizer) def bind_events(self): @@ -338,6 +364,9 @@ def bind_events(self): 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) def on_char_hook(self, event): """Handle key events.""" @@ -390,13 +419,16 @@ def load_settings(self): # Feed filter settings (per account) if self.app.currentAccount: - from models.feed_filter import load_visible_types + from models.feed_filter import load_visible_types, load_muted_repos visible = load_visible_types(self.app.currentAccount.prefs) for event_type, cb in self.filter_checkboxes.items(): cb.SetValue(True if visible is None else event_type in visible) + muted = load_muted_repos(self.app.currentAccount.prefs) or set() + self.muted_repos_list.Set(sorted(muted)) else: for cb in self.filter_checkboxes.values(): cb.SetValue(True) + self.muted_repos_list.Clear() def save_settings(self): """Save settings from the dialog.""" @@ -484,9 +516,11 @@ def save_settings(self): # Save feed filter settings (per account) if self.app.currentAccount: - from models.feed_filter import save_visible_types + from models.feed_filter import save_visible_types, save_muted_repos visible = {et for et, 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) from GUI import main as _main if _main.window: _main.window._render_feed_list() @@ -561,6 +595,30 @@ def on_deselect_all_filters(self, event): 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() + 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) + def on_ok(self, event): """Handle OK button.""" if self.save_settings(): diff --git a/models/feed_filter.py b/models/feed_filter.py index c8e83c4..e39f359 100644 --- a/models/feed_filter.py +++ b/models/feed_filter.py @@ -89,6 +89,7 @@ # Key written to each account's config JSON. CONFIG_KEY = "feed_visible_event_types" +MUTED_REPOS_KEY = "feed_muted_repos" # --------------------------------------------------------------------------- # Core functions @@ -136,6 +137,32 @@ def save_visible_types(account_prefs, visible: set[str]) -> 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 is_event_visible(event, visible: Optional[set[str]]) -> bool: """Return True if *event* should appear in the feed. @@ -148,12 +175,24 @@ def is_event_visible(event, visible: Optional[set[str]]) -> bool: return event.type in visible -def filter_feed(events, visible: Optional[set[str]]) -> list: +def filter_feed( + events, + visible: Optional[set[str]], + muted_repos: Optional[set[str]] = 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. Visible types (whitelist) — if configured, only listed types pass. + Does not mutate the input iterable. - If visible is None every event passes through (copy semantics preserved). """ - if visible is None: - return list(events) - return [e for e in events if e.type in visible] + result = [] + for e in events: + if muted_repos and e.repo.name in muted_repos: + continue + if visible is not None and e.type not in visible: + continue + result.append(e) + return result diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py index d1ef6ff..a86ff2e 100644 --- a/tests/test_feed_filters.py +++ b/tests/test_feed_filters.py @@ -11,9 +11,12 @@ ALL_EVENT_TYPES, CONFIG_KEY, FILTER_GROUPS, + MUTED_REPOS_KEY, filter_feed, is_event_visible, + load_muted_repos, load_visible_types, + save_muted_repos, save_visible_types, ) @@ -23,13 +26,13 @@ # --------------------------------------------------------------------------- -def _make_event(event_type: str) -> Event: +def _make_event(event_type: str, repo: str = "owner/repo") -> Event: return Event.from_api( { "id": "1", "type": event_type, "actor": {"id": 1, "login": "alice", "avatar_url": ""}, - "repo": {"id": 1, "name": "owner/repo", "url": ""}, + "repo": {"id": 1, "name": repo, "url": ""}, "payload": {}, "public": True, "created_at": None, @@ -386,3 +389,224 @@ 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" From 9ef69d77957326ab08b024bdb93b0ea72a8757ee Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:15:51 -0600 Subject: [PATCH 3/9] Fix wx.TE_PROCESS_ENTER missing on muted repo entry field EVT_TEXT_ENTER requires the TE_PROCESS_ENTER style flag on the TextCtrl. Co-Authored-By: Claude Sonnet 4.6 --- GUI/options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GUI/options.py b/GUI/options.py index 79c3901..eb8a5e5 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -335,7 +335,7 @@ def _build_filters_tab(self, panel): mute_sizer.Add(self.muted_repos_list, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) add_row = wx.BoxSizer(wx.HORIZONTAL) - self.muted_repo_entry = wx.TextCtrl(panel, size=(280, -1)) + self.muted_repo_entry = wx.TextCtrl(panel, size=(280, -1), style=wx.TE_PROCESS_ENTER) self.muted_repo_entry.SetHint("owner/repo") add_row.Add(self.muted_repo_entry, 1, wx.RIGHT, 6) self.mute_add_btn = wx.Button(panel, label="&Add") From fb9823ae44e6f1b863b09d3e0fddc418b1b642db Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:48:14 -0600 Subject: [PATCH 4/9] Add per-user event type filter overrides - models/feed_filter.py: add load/save_user_filters and USER_FILTERS_KEY; update filter_feed with user override layer (muted repo > user rule > global type filter) - tests/test_feed_filters.py: 34 new tests covering load/save/corrupt data, all three filter layers interacting, roundtrips, and account isolation (127 total) - GUI/main.py: pass user_filters to filter_feed in _render_feed_list - GUI/options.py: add User Filters section to Feed Filters tab with ListBox, Add/Edit/Remove buttons; add UserFilterDialog sub-dialog with grouped type checkboxes and mute-entirely shortcut Co-Authored-By: Claude Sonnet 4.6 --- GUI/main.py | 5 +- GUI/options.py | 194 ++++++++++++++++++++++++++++- models/feed_filter.py | 47 ++++++- tests/test_feed_filters.py | 246 ++++++++++++++++++++++++++++++++++++- 4 files changed, 485 insertions(+), 7 deletions(-) diff --git a/GUI/main.py b/GUI/main.py index aa72d02..67493b2 100644 --- a/GUI/main.py +++ b/GUI/main.py @@ -927,10 +927,11 @@ 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, filter_feed + 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 - self._visible_feed = filter_feed(self.feed, visible, muted_repos) + 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() diff --git a/GUI/options.py b/GUI/options.py index eb8a5e5..49c4635 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -320,6 +320,34 @@ def _build_filters_tab(self, panel): 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...") + user_btn_row.Add(self.user_filter_edit_btn, 0, wx.RIGHT, 4) + self.user_filter_remove_btn = wx.Button(panel, label="Remo&ve") + 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) @@ -367,6 +395,9 @@ def bind_events(self): 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) def on_char_hook(self, event): """Handle key events.""" @@ -419,16 +450,23 @@ def load_settings(self): # Feed filter settings (per account) if self.app.currentAccount: - from models.feed_filter import load_visible_types, load_muted_repos + from models.feed_filter import load_visible_types, load_muted_repos, load_user_filters visible = load_visible_types(self.app.currentAccount.prefs) for event_type, cb in self.filter_checkboxes.items(): cb.SetValue(True if visible is None else event_type 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.""" @@ -521,6 +559,12 @@ def save_settings(self): 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() @@ -619,6 +663,46 @@ def on_mute_remove(self, event): if sel != wx.NOT_FOUND: self.muted_repos_list.Delete(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) + def on_ok(self, event): """Handle OK button.""" if self.save_settings(): @@ -635,3 +719,111 @@ def on_apply(self, event): 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=(480, 600)) + + 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 + 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)) + if username: + self.username_ctrl.SetEditable(False) + name_row.Add(self.username_ctrl, 1) + sizer.Add(name_row, 0, wx.ALL | wx.EXPAND, 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 checkboxes in groups + self._type_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: + label = EVENT_DISPLAY_NAMES.get(et, et) + cb = wx.CheckBox(panel, label=label) + self._type_checkboxes[et] = cb + group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 6) + group_sizer.AddSpacer(4) + sizer.Add(group_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + + # 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 checked + for cb in self._type_checkboxes.values(): + cb.SetValue(True) + elif len(visible_types) == 0: + # Muted user + self.mute_all_cb.SetValue(True) + self._set_type_checkboxes_enabled(False) + else: + for et, cb in self._type_checkboxes.items(): + cb.SetValue(et 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() + if self.mute_all_cb.GetValue(): + return username, set() + return username, {et for et, cb in self._type_checkboxes.items() if cb.GetValue()} diff --git a/models/feed_filter.py b/models/feed_filter.py index e39f359..2ee3c52 100644 --- a/models/feed_filter.py +++ b/models/feed_filter.py @@ -90,6 +90,7 @@ # 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 @@ -163,6 +164,42 @@ def save_muted_repos(account_prefs, muted: set[str]) -> None: 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, dict): + 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 is_event_visible(event, visible: Optional[set[str]]) -> bool: """Return True if *event* should appear in the feed. @@ -179,12 +216,15 @@ 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. Visible types (whitelist) — if configured, only listed types pass. + 2. Per-user override — if the actor has a rule, that rule's type set is + used instead of the global visible set (empty set = mute that user). + 3. Global visible types (whitelist) — applied to actors without a rule. Does not mutate the input iterable. """ @@ -192,7 +232,10 @@ def filter_feed( for e in events: if muted_repos and e.repo.name in muted_repos: continue - if visible is not None and e.type not in visible: + if user_filters and e.actor.login in user_filters: + if e.type not in user_filters[e.actor.login]: + continue + elif visible is not None and e.type not in visible: continue result.append(e) return result diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py index a86ff2e..384e9be 100644 --- a/tests/test_feed_filters.py +++ b/tests/test_feed_filters.py @@ -12,11 +12,14 @@ CONFIG_KEY, FILTER_GROUPS, MUTED_REPOS_KEY, + USER_FILTERS_KEY, filter_feed, is_event_visible, load_muted_repos, + load_user_filters, load_visible_types, save_muted_repos, + save_user_filters, save_visible_types, ) @@ -26,12 +29,12 @@ # --------------------------------------------------------------------------- -def _make_event(event_type: str, repo: str = "owner/repo") -> Event: +def _make_event(event_type: str, repo: str = "owner/repo", actor: str = "alice") -> Event: return Event.from_api( { "id": "1", "type": event_type, - "actor": {"id": 1, "login": "alice", "avatar_url": ""}, + "actor": {"id": 1, "login": actor, "avatar_url": ""}, "repo": {"id": 1, "name": repo, "url": ""}, "payload": {}, "public": True, @@ -610,3 +613,242 @@ def test_saving_muted_repos_for_b_does_not_affect_a(): 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_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 == [] + + +# --------------------------------------------------------------------------- +# 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" From cd16352b6dd38fbfcd54cf82527ae3c1b3a1205e Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:51:05 -0600 Subject: [PATCH 5/9] Fix user filter not persisting or filtering due to Config dict wrapping Config.__setitem__ wraps any stored dict value in a Config (MutableMapping) subclass. load_user_filters was checking isinstance(raw, dict) which always failed for stored user filters, causing it to return None on every load. Fix: use isinstance(raw, Mapping) from collections.abc to accept both plain dicts and Config-wrapped mappings. Add regression test using a FakeConfig that mimics the same MutableMapping-not-dict pattern. Co-Authored-By: Claude Sonnet 4.6 --- models/feed_filter.py | 7 ++++++- tests/test_feed_filters.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/models/feed_filter.py b/models/feed_filter.py index 2ee3c52..48c2ed8 100644 --- a/models/feed_filter.py +++ b/models/feed_filter.py @@ -3,10 +3,15 @@ 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 # --------------------------------------------------------------------------- @@ -180,7 +185,7 @@ def load_user_filters(account_prefs) -> Optional[dict]: if raw is None: return None - if not isinstance(raw, dict): + if not isinstance(raw, Mapping): return None result: dict = {} diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py index 384e9be..5be3672 100644 --- a/tests/test_feed_filters.py +++ b/tests/test_feed_filters.py @@ -666,6 +666,25 @@ 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"}} From 3e9372aa9d68cc18968649659e4af3cfc0496880 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:00:56 -0600 Subject: [PATCH 6/9] Fix user filter UX: editable usernames, case insensitive, button states - UserFilterDialog: remove read-only on username field so it can be edited - Normalize usernames and repo names to lowercase on add/save - Disable Edit and Remove buttons for user filters until an item is selected - Disable Remove button for muted repos until an item is selected - Add descriptive label above muted repo entry field to clarify its purpose - Re-disable Edit/Remove after deleting an entry Co-Authored-By: Claude Sonnet 4.6 --- GUI/options.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/GUI/options.py b/GUI/options.py index 49c4635..bbeac42 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -341,8 +341,10 @@ def _build_filters_tab(self, panel): 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) @@ -362,15 +364,19 @@ def _build_filters_tab(self, panel): 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) + 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("owner/repo") + 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") + 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.ALL, 8) + mute_sizer.Add(add_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) main_sizer.Add(mute_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) @@ -398,6 +404,8 @@ def bind_events(self): 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.""" @@ -641,7 +649,7 @@ def on_deselect_all_filters(self, event): def on_mute_add(self, event): """Add a repo to the muted list.""" - repo = self.muted_repo_entry.GetValue().strip() + repo = self.muted_repo_entry.GetValue().strip().lower() if not repo: return # Basic format validation @@ -662,6 +670,16 @@ def on_mute_remove(self, event): 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 ---- @@ -702,6 +720,8 @@ 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.""" @@ -746,8 +766,6 @@ def _init_ui(self, username: str, visible_types): 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)) - if username: - self.username_ctrl.SetEditable(False) name_row.Add(self.username_ctrl, 1) sizer.Add(name_row, 0, wx.ALL | wx.EXPAND, 10) @@ -823,7 +841,7 @@ def _on_ok(self, event): def get_result(self) -> tuple: """Return (username, set_of_visible_types). Empty set = muted.""" - username = self.username_ctrl.GetValue().strip() + username = self.username_ctrl.GetValue().strip().lower() if self.mute_all_cb.GetValue(): return username, set() return username, {et for et, cb in self._type_checkboxes.items() if cb.GetValue()} From 939b20f01be14ed05ca5ab20d51e5babf90163fa Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:08:56 -0600 Subject: [PATCH 7/9] Fix case-insensitive actor/repo matching in filter_feed Usernames and repo names are stored lowercase, but GitHub API returns actor logins and repo names in their original case. filter_feed was doing case-sensitive lookups so user filters and muted repos never matched. Fix: lowercase e.actor.login and e.repo.name before lookup. Add 3 regression tests covering mixed-case actor and repo scenarios. Co-Authored-By: Claude Sonnet 4.6 --- models/feed_filter.py | 10 +++++++--- tests/test_feed_filters.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/models/feed_filter.py b/models/feed_filter.py index 48c2ed8..5b3b82c 100644 --- a/models/feed_filter.py +++ b/models/feed_filter.py @@ -235,10 +235,14 @@ def filter_feed( """ result = [] for e in events: - if muted_repos and e.repo.name in muted_repos: + if muted_repos and e.repo.name.lower() in muted_repos: continue - if user_filters and e.actor.login in user_filters: - if e.type not in user_filters[e.actor.login]: + if user_filters: + login = e.actor.login.lower() + if login in user_filters: + if e.type not in user_filters[login]: + continue + elif visible is not None and e.type not in visible: continue elif visible is not None and e.type not in visible: continue diff --git a/tests/test_feed_filters.py b/tests/test_feed_filters.py index 5be3672..72c1ffe 100644 --- a/tests/test_feed_filters.py +++ b/tests/test_feed_filters.py @@ -818,6 +818,30 @@ def test_filter_feed_user_filters_empty_dict_uses_global(): 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 # --------------------------------------------------------------------------- From 1334682ba8f9b439b81dd1e95dd33ed88904f107 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:16:57 -0600 Subject: [PATCH 8/9] Default new user filter to no types checked (hide all by default) Previously all 19 types were checked for new users, so adding a user without changing anything had no effect on the feed. Changed default to all-unchecked so adding a user immediately hides all their events unless specific types are explicitly selected. Added clarifying hint text to the dialog. Co-Authored-By: Claude Sonnet 4.6 --- GUI/options.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/GUI/options.py b/GUI/options.py index bbeac42..2cf6852 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -769,6 +769,13 @@ def _init_ui(self, username: str, visible_types): 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) @@ -798,9 +805,9 @@ def _init_ui(self, username: str, visible_types): # Initialise checkbox states if visible_types is None: - # New user — default all checked + # New user — default all unchecked; no types selected = hide all events for cb in self._type_checkboxes.values(): - cb.SetValue(True) + cb.SetValue(False) elif len(visible_types) == 0: # Muted user self.mute_all_cb.SetValue(True) From 5b7066105421eeed88cd2bb765359ce5e129a556 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:33:02 -0600 Subject: [PATCH 9/9] Add granular action-level feed filtering with 165 tests Event types with sub-actions (PullRequestEvent, IssuesEvent, etc.) now filter at the individual action level using "Type:action" keys instead of a single checkbox per type. Merged PRs use a synthetic "merged" key distinct from "closed". PullRequestReviewEvent uses review.state (approved/changes_requested/commented) rather than the always-"submitted" payload action. CreateEvent/DeleteEvent use ref_type (branch/tag/repository). Co-Authored-By: Claude Sonnet 4.6 --- GUI/options.py | 136 ++++++++++++----- models/feed_filter.py | 114 ++++++++++++-- tests/test_feed_filters.py | 298 ++++++++++++++++++++++++++++++++++++- 3 files changed, 500 insertions(+), 48 deletions(-) diff --git a/GUI/options.py b/GUI/options.py index 2cf6852..0413959 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -17,28 +17,64 @@ 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 = { - "PullRequestEvent": "&Pull Request opened/closed/merged", - "PullRequestReviewEvent": "Pull Request &Review submitted", - "PullRequestReviewCommentEvent": "Pull Request Review &Comment", - "PullRequestReviewThreadEvent": "Pull Request Review &Thread", - "IssuesEvent": "&Issue opened/closed/labeled", - "IssueCommentEvent": "Issue C&omment", + # 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", - "CreateEvent": "C&reate branch/tag/repo", - "DeleteEvent": "&Delete branch/tag", "ForkEvent": "&Fork", "WatchEvent": "&Star (watch event)", - "MemberEvent": "&Member added as collaborator", "GollumEvent": "&Wiki page updated", - "ReleaseEvent": "&Release published", - "DiscussionEvent": "&Discussion created/answered", - "DiscussionCommentEvent": "Discussion Co&mment", - "SponsorshipEvent": "Spon&sorship", "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.""" @@ -284,13 +320,13 @@ def _build_general_tab(self, panel): def _build_filters_tab(self, panel): """Build the Feed Filters tab content.""" - from models.feed_filter import FILTER_GROUPS + from models.feed_filter import FILTER_GROUPS, EVENT_TYPE_ACTIONS main_sizer = wx.BoxSizer(wx.VERTICAL) intro = wx.StaticText( panel, label=( - "Choose which event types appear in your activity feed.\n" + "Choose which event types and actions appear in your activity feed.\n" "Changes apply per account and take effect immediately on Apply." ) ) @@ -304,10 +340,22 @@ def _build_filters_tab(self, panel): group_sizer = wx.StaticBoxSizer(group_box, wx.VERTICAL) for et in event_types: - 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) + 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) @@ -460,8 +508,8 @@ def load_settings(self): 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 event_type, cb in self.filter_checkboxes.items(): - cb.SetValue(True if visible is None else event_type in visible) + 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 {} @@ -563,7 +611,7 @@ def save_settings(self): # Save feed filter settings (per account) if self.app.currentAccount: from models.feed_filter import save_visible_types, save_muted_repos - visible = {et for et, cb in self.filter_checkboxes.items() if cb.GetValue()} + 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) @@ -750,14 +798,14 @@ def __init__(self, parent, username: str = "", visible_types=None): 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=(480, 600)) + 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 + from models.feed_filter import FILTER_GROUPS, EVENT_TYPE_ACTIONS panel = wx.Panel(self) sizer = wx.BoxSizer(wx.VERTICAL) @@ -780,18 +828,36 @@ def _init_ui(self, username: str, visible_types): 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 checkboxes in groups + # 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(panel, label=group_label) + group_box = wx.StaticBox(scroll, label=group_label) group_sizer = wx.StaticBoxSizer(group_box, wx.VERTICAL) for et in event_types: - label = EVENT_DISPLAY_NAMES.get(et, et) - cb = wx.CheckBox(panel, label=label) - self._type_checkboxes[et] = cb - group_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 6) + 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) - sizer.Add(group_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + 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) @@ -805,7 +871,7 @@ def _init_ui(self, username: str, visible_types): # Initialise checkbox states if visible_types is None: - # New user — default all unchecked; no types selected = hide all events + # 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: @@ -813,8 +879,8 @@ def _init_ui(self, username: str, visible_types): self.mute_all_cb.SetValue(True) self._set_type_checkboxes_enabled(False) else: - for et, cb in self._type_checkboxes.items(): - cb.SetValue(et in visible_types) + 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) @@ -851,4 +917,4 @@ def get_result(self) -> tuple: username = self.username_ctrl.GetValue().strip().lower() if self.mute_all_cb.GetValue(): return username, set() - return username, {et for et, cb in self._type_checkboxes.items() if cb.GetValue()} + return username, {key for key, cb in self._type_checkboxes.items() if cb.GetValue()} diff --git a/models/feed_filter.py b/models/feed_filter.py index 5b3b82c..5ccac76 100644 --- a/models/feed_filter.py +++ b/models/feed_filter.py @@ -43,6 +43,48 @@ "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]]] = [ @@ -205,16 +247,61 @@ def save_user_filters(account_prefs, user_filters: dict) -> None: } +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 (only .type is read) + event — a models.event.Event instance visible — None means "unconfigured, show all" - a set means "show only these types" + a set means "show only these type(:action) keys" """ if visible is None: return True - return event.type in visible + return _is_event_in_visible(event, visible) def filter_feed( @@ -227,24 +314,29 @@ def filter_feed( 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 set is - used instead of the global visible set (empty set = mute that user). + 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() - if login in user_filters: - if e.type not in user_filters[login]: - continue - elif visible is not None and e.type not in visible: - continue - elif visible is not None and e.type not in visible: + 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 index 72c1ffe..def0d1a 100644 --- a/tests/test_feed_filters.py +++ b/tests/test_feed_filters.py @@ -10,9 +10,12 @@ 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, @@ -29,14 +32,19 @@ # --------------------------------------------------------------------------- -def _make_event(event_type: str, repo: str = "owner/repo", actor: str = "alice") -> Event: +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": payload or {}, "public": True, "created_at": None, } @@ -895,3 +903,289 @@ def test_saving_user_filters_for_b_does_not_affect_a(): 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