From 399701fa06743af75b7734256382e21f424270eb Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 24 Apr 2026 17:31:13 -0400 Subject: [PATCH 1/2] Filter projects from review board via configurable exclude list (#190) Add excludeReviewRepos to ConfigDefaults so users can hide specific repos from the review board. Filtering applies at both the IssueTracker level (preventing notifications) and reactively in the view via AppState so changes take effect immediately without waiting for the next poll cycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 12 +++++++++++- .../Sources/CrowCore/Models/AppConfig.swift | 19 ++++++++++++++++++- .../Tests/CrowCoreTests/AppConfigTests.swift | 13 ++++++++++++- .../Sources/CrowUI/ReviewBoardView.swift | 12 ++++++------ .../CrowUI/Sources/CrowUI/SettingsView.swift | 17 +++++++++++++++++ Sources/Crow/App/AppDelegate.swift | 2 ++ Sources/Crow/App/IssueTracker.swift | 5 +++++ docs/configuration.md | 4 +++- 8 files changed, 74 insertions(+), 10 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index eb513c5..6d0a7e6 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -103,12 +103,22 @@ public final class AppState { public var reviewRequests: [ReviewRequest] = [] public var isLoadingReviews: Bool = false + /// Repositories to hide from the review board. Mirrors `ConfigDefaults.excludeReviewRepos`. + public var excludeReviewRepos: [String] = [] + + /// Review requests after applying the exclude list. + public var filteredReviewRequests: [ReviewRequest] { + guard !excludeReviewRepos.isEmpty else { return reviewRequests } + let excludeSet = Set(excludeReviewRepos.map { $0.lowercased() }) + return reviewRequests.filter { !excludeSet.contains($0.repo.lowercased()) } + } + /// IDs of review requests the user has already seen (for badge count). public var seenReviewRequestIDs: Set = [] /// Number of unseen review requests (for sidebar badge). public var unseenReviewCount: Int { - reviewRequests.filter { !seenReviewRequestIDs.contains($0.id) }.count + filteredReviewRequests.filter { !seenReviewRequestIDs.contains($0.id) }.count } /// Whether the VS Code `code` CLI is available on this system. diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index bc85d66..011c4de 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -114,6 +114,8 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { public var cli: String public var branchPrefix: String public var excludeDirs: [String] + /// Repositories to hide from the review board (e.g., `["zarf-dev/zarf"]`). + public var excludeReviewRepos: [String] /// Characters that are invalid in git ref names (see `git check-ref-format`). private static let invalidBranchChars = CharacterSet(charactersIn: " ~^:?*[\\") @@ -137,12 +139,27 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { provider: String = "github", cli: String = "gh", branchPrefix: String = "feature/", - excludeDirs: [String] = ["node_modules", ".git", "vendor", "dist", "build", "target"] + excludeDirs: [String] = ["node_modules", ".git", "vendor", "dist", "build", "target"], + excludeReviewRepos: [String] = [] ) { self.provider = provider self.cli = cli self.branchPrefix = branchPrefix self.excludeDirs = excludeDirs + self.excludeReviewRepos = excludeReviewRepos + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + provider = try container.decodeIfPresent(String.self, forKey: .provider) ?? "github" + cli = try container.decodeIfPresent(String.self, forKey: .cli) ?? "gh" + branchPrefix = try container.decodeIfPresent(String.self, forKey: .branchPrefix) ?? "feature/" + excludeDirs = try container.decodeIfPresent([String].self, forKey: .excludeDirs) ?? ["node_modules", ".git", "vendor", "dist", "build", "target"] + excludeReviewRepos = try container.decodeIfPresent([String].self, forKey: .excludeReviewRepos) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case provider, cli, branchPrefix, excludeDirs, excludeReviewRepos } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 815cb57..5d6043a 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -8,7 +8,7 @@ import Testing WorkspaceInfo(name: "TestOrg", provider: "github", cli: "gh", alwaysInclude: ["repo1"]), WorkspaceInfo(name: "GitLabOrg", provider: "gitlab", cli: "glab", host: "gitlab.example.com"), ], - defaults: ConfigDefaults(provider: "gitlab", cli: "glab", branchPrefix: "fix/", excludeDirs: ["vendor"]), + defaults: ConfigDefaults(provider: "gitlab", cli: "glab", branchPrefix: "fix/", excludeDirs: ["vendor"], excludeReviewRepos: ["zarf-dev/zarf", "bmlt-enabled/yap"]), notifications: NotificationSettings(globalMute: true), sidebar: SidebarSettings(hideSessionDetails: true) ) @@ -25,6 +25,7 @@ import Testing #expect(decoded.defaults.provider == "gitlab") #expect(decoded.defaults.branchPrefix == "fix/") #expect(decoded.defaults.excludeDirs == ["vendor"]) + #expect(decoded.defaults.excludeReviewRepos == ["zarf-dev/zarf", "bmlt-enabled/yap"]) #expect(decoded.notifications.globalMute == true) #expect(decoded.sidebar.hideSessionDetails == true) } @@ -36,6 +37,7 @@ import Testing #expect(config.workspaces.isEmpty) #expect(config.defaults.provider == "github") #expect(config.defaults.branchPrefix == "feature/") + #expect(config.defaults.excludeReviewRepos.isEmpty) #expect(config.notifications.globalMute == false) #expect(config.sidebar.hideSessionDetails == false) #expect(config.remoteControlEnabled == false) @@ -105,6 +107,15 @@ import Testing #expect(a != c) } +@Test func configDefaultsDecodeWithoutExcludeReviewRepos() throws { + let json = """ + {"defaults": {"provider": "github", "cli": "gh", "branchPrefix": "feature/", "excludeDirs": ["node_modules"]}} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.defaults.excludeReviewRepos.isEmpty) + #expect(config.defaults.excludeDirs == ["node_modules"]) +} + // MARK: - WorkspaceInfo @Test func workspaceInfoDerivedCLI() { diff --git a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift index a14723b..8a05d37 100644 --- a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift @@ -25,7 +25,7 @@ public struct ReviewBoardView: View { .background(.background) .onAppear { // Mark all current review requests as seen - for request in appState.reviewRequests { + for request in appState.filteredReviewRequests { appState.seenReviewRequestIDs.insert(request.id) } } @@ -44,7 +44,7 @@ public struct ReviewBoardView: View { Spacer() - Text("\(appState.reviewRequests.count) pending") + Text("\(appState.filteredReviewRequests.count) pending") .font(.caption) .foregroundStyle(CorveilTheme.textSecondary) } @@ -55,7 +55,7 @@ public struct ReviewBoardView: View { @ViewBuilder private var reviewList: some View { - if appState.reviewRequests.isEmpty { + if appState.filteredReviewRequests.isEmpty { VStack { Spacer().frame(height: 40) Image(systemName: "eye.circle") @@ -74,7 +74,7 @@ public struct ReviewBoardView: View { } .frame(maxWidth: .infinity) } else { - List(appState.reviewRequests) { request in + List(appState.filteredReviewRequests) { request in ReviewRow(request: request, appState: appState) } .listStyle(.inset) @@ -207,8 +207,8 @@ public struct ReviewTerminalsSidebarRow: View { ProgressView() .controlSize(.mini) } - if appState.reviewRequests.count > 0 { - Text("\(appState.reviewRequests.count)") + if appState.filteredReviewRequests.count > 0 { + Text("\(appState.filteredReviewRequests.count)") .font(.system(size: 10, weight: .semibold)) .monospacedDigit() .padding(.horizontal, 4) diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 111f931..82aa25a 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -12,6 +12,7 @@ public struct SettingsView: View { @State var config: AppConfig @State private var isAddingWorkspace = false @State private var editingWorkspace: WorkspaceInfo? + @State private var excludeReviewReposText: String public var onSave: ((String, AppConfig) -> Void)? public var onRescaffold: ((String) -> Void)? @@ -22,6 +23,7 @@ public struct SettingsView: View { self.appState = appState self._devRoot = State(initialValue: devRoot) self._config = State(initialValue: config) + self._excludeReviewReposText = State(initialValue: config.defaults.excludeReviewRepos.joined(separator: ", ")) self.onSave = onSave self.onRescaffold = onRescaffold } @@ -156,6 +158,21 @@ public struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Reviews") { + TextField("Excluded Repos", text: $excludeReviewReposText) + .textFieldStyle(.roundedBorder) + .onSubmit { + config.defaults.excludeReviewRepos = excludeReviewReposText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + save() + } + Text("Comma-separated list of repos to hide from the review board (e.g., zarf-dev/zarf, bmlt-enabled/yap).") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Remote Control") { Toggle("Enable remote control for new sessions", isOn: $config.remoteControlEnabled) .onChange(of: config.remoteControlEnabled) { _, _ in save() } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 60b52fd..055af2b 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -114,6 +114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // be rebuilt to include (or drop) `--rc` before its surface is pre-initialized. appState.remoteControlEnabled = config.remoteControlEnabled appState.managerAutoPermissionMode = config.managerAutoPermissionMode + appState.excludeReviewRepos = config.defaults.excludeReviewRepos // Create session service and hydrate state let service = SessionService(store: store, appState: appState, telemetryPort: config.telemetry.enabled ? config.telemetry.port : nil) @@ -445,6 +446,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.hideSessionDetails = config.sidebar.hideSessionDetails appState.remoteControlEnabled = config.remoteControlEnabled appState.managerAutoPermissionMode = config.managerAutoPermissionMode + appState.excludeReviewRepos = config.defaults.excludeReviewRepos } // MARK: - Socket Server diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 4d2e7d9..fc3dd01 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -254,6 +254,11 @@ final class IssueTracker { reviews[i].reviewSessionID = session.id } } + // Filter out repos the user has excluded from the review board + let excludeSet = Set(config.defaults.excludeReviewRepos.map { $0.lowercased() }) + if !excludeSet.isEmpty { + reviews = reviews.filter { !excludeSet.contains($0.repo.lowercased()) } + } let currentIDs = Set(reviews.map(\.id)) let newIDs = currentIDs.subtracting(previousReviewRequestIDs) previousReviewRequestIDs = currentIDs diff --git a/docs/configuration.md b/docs/configuration.md index cfa8be8..c84531d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,7 +47,8 @@ All persistent state lives under `~/Library/Application Support/crow/` (see `Pac "provider": "github", "cli": "gh", "branchPrefix": "feature/", - "excludeDirs": ["node_modules", ".git", "vendor", "dist", "build", "target"] + "excludeDirs": ["node_modules", ".git", "vendor", "dist", "build", "target"], + "excludeReviewRepos": ["zarf-dev/zarf", "bmlt-enabled/yap"] } } ``` @@ -57,6 +58,7 @@ All persistent state lives under `~/Library/Application Support/crow/` (see `Pac - **`host`** — set for self-hosted GitLab; exported as `GITLAB_HOST` when invoking `glab`. - **`branchPrefix`** — used by the `/crow-workspace` skill when creating new branches. - **`excludeDirs`** — ignored when scanning repos for git worktrees. +- **`excludeReviewRepos`** — repos to hide from the review board (e.g., `["zarf-dev/zarf"]`). Matching reviews are filtered out from the board, sidebar badge count, and notifications. ## Manager Terminal From 9691da26e97ad1a529f9e66cfa26bfc8761ed57e Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 24 Apr 2026 17:55:50 -0400 Subject: [PATCH 2/2] Address review feedback: fix re-notification on exclude toggle, save on change - Track all review IDs (including excluded) in previousReviewRequestIDs so toggling the exclude list does not re-fire notifications for seen PRs - Switch Settings text field from .onSubmit to .onChange so edits persist without requiring Enter - Remove added code comments Co-Authored-By: Claude Opus 4.6 (1M context) --- Packages/CrowCore/Sources/CrowCore/AppState.swift | 2 -- Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift | 1 - Packages/CrowUI/Sources/CrowUI/SettingsView.swift | 2 +- Sources/Crow/App/IssueTracker.swift | 4 ++-- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index c9b8e3c..4315946 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -103,10 +103,8 @@ public final class AppState { public var reviewRequests: [ReviewRequest] = [] public var isLoadingReviews: Bool = false - /// Repositories to hide from the review board. Mirrors `ConfigDefaults.excludeReviewRepos`. public var excludeReviewRepos: [String] = [] - /// Review requests after applying the exclude list. public var filteredReviewRequests: [ReviewRequest] { guard !excludeReviewRepos.isEmpty else { return reviewRequests } let excludeSet = Set(excludeReviewRepos.map { $0.lowercased() }) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 011c4de..4d11316 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -114,7 +114,6 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { public var cli: String public var branchPrefix: String public var excludeDirs: [String] - /// Repositories to hide from the review board (e.g., `["zarf-dev/zarf"]`). public var excludeReviewRepos: [String] /// Characters that are invalid in git ref names (see `git check-ref-format`). diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 82aa25a..ac97e89 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -161,7 +161,7 @@ public struct SettingsView: View { Section("Reviews") { TextField("Excluded Repos", text: $excludeReviewReposText) .textFieldStyle(.roundedBorder) - .onSubmit { + .onChange(of: excludeReviewReposText) { _, _ in config.defaults.excludeReviewRepos = excludeReviewReposText .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index fc3dd01..4d459c0 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -254,14 +254,14 @@ final class IssueTracker { reviews[i].reviewSessionID = session.id } } - // Filter out repos the user has excluded from the review board + let allCurrentIDs = Set(reviews.map(\.id)) let excludeSet = Set(config.defaults.excludeReviewRepos.map { $0.lowercased() }) if !excludeSet.isEmpty { reviews = reviews.filter { !excludeSet.contains($0.repo.lowercased()) } } let currentIDs = Set(reviews.map(\.id)) let newIDs = currentIDs.subtracting(previousReviewRequestIDs) - previousReviewRequestIDs = currentIDs + previousReviewRequestIDs = allCurrentIDs if !isFirstFetch && !newIDs.isEmpty { let newRequests = reviews.filter { newIDs.contains($0.id) } onNewReviewRequests?(newRequests)