From 21c743ed40d31a599a966b314e58125217c61535 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 6 May 2026 19:12:58 -0400 Subject: [PATCH] Allow ignoring PRs with specific labels from review (#244) Add `ignoreReviewLabels` config option that filters PRs bearing any of the listed labels from the review board, badge counts, and notifications. Fetches labels via the GitHub GraphQL query and applies case-insensitive matching alongside the existing repo-exclude filter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 14 ++++++++++++-- .../Sources/CrowCore/Models/AppConfig.swift | 8 ++++++-- .../Sources/CrowCore/Models/ReviewRequest.swift | 3 +++ .../Tests/CrowCoreTests/AppConfigTests.swift | 17 +++++++++++++++++ .../Sources/CrowUI/AutomationSettingsView.swift | 15 +++++++++++++++ Sources/Crow/App/AppDelegate.swift | 2 ++ Sources/Crow/App/IssueTracker.swift | 11 +++++++++++ 7 files changed, 66 insertions(+), 4 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index fdafa66..7a94e95 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -129,10 +129,20 @@ public final class AppState { public var excludeReviewRepos: [String] = [] public var excludeTicketRepos: [String] = [] + public var ignoreReviewLabels: [String] = [] public var filteredReviewRequests: [ReviewRequest] { - guard !excludeReviewRepos.isEmpty else { return reviewRequests } - return reviewRequests.filter { !repoMatchesExcludePatterns($0.repo, patterns: excludeReviewRepos) } + var result = reviewRequests + if !excludeReviewRepos.isEmpty { + result = result.filter { !repoMatchesExcludePatterns($0.repo, patterns: excludeReviewRepos) } + } + if !ignoreReviewLabels.isEmpty { + let lowerLabels = Set(ignoreReviewLabels.map { $0.lowercased() }) + result = result.filter { request in + !request.labels.contains(where: { lowerLabels.contains($0.lowercased()) }) + } + } + return result } /// IDs of review requests the user has already seen (for badge count). diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index e49aa0e..f448c41 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -176,6 +176,7 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { public var excludeDirs: [String] public var excludeReviewRepos: [String] public var excludeTicketRepos: [String] + public var ignoreReviewLabels: [String] /// Characters that are invalid in git ref names (see `git check-ref-format`). private static let invalidBranchChars = CharacterSet(charactersIn: " ~^:?*[\\") @@ -201,7 +202,8 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { branchPrefix: String = "feature/", excludeDirs: [String] = ["node_modules", ".git", "vendor", "dist", "build", "target"], excludeReviewRepos: [String] = [], - excludeTicketRepos: [String] = [] + excludeTicketRepos: [String] = [], + ignoreReviewLabels: [String] = [] ) { self.provider = provider self.cli = cli @@ -209,6 +211,7 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { self.excludeDirs = excludeDirs self.excludeReviewRepos = excludeReviewRepos self.excludeTicketRepos = excludeTicketRepos + self.ignoreReviewLabels = ignoreReviewLabels } public init(from decoder: Decoder) throws { @@ -219,10 +222,11 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { excludeDirs = try container.decodeIfPresent([String].self, forKey: .excludeDirs) ?? ["node_modules", ".git", "vendor", "dist", "build", "target"] excludeReviewRepos = try container.decodeIfPresent([String].self, forKey: .excludeReviewRepos) ?? [] excludeTicketRepos = try container.decodeIfPresent([String].self, forKey: .excludeTicketRepos) ?? [] + ignoreReviewLabels = try container.decodeIfPresent([String].self, forKey: .ignoreReviewLabels) ?? [] } private enum CodingKeys: String, CodingKey { - case provider, cli, branchPrefix, excludeDirs, excludeReviewRepos, excludeTicketRepos + case provider, cli, branchPrefix, excludeDirs, excludeReviewRepos, excludeTicketRepos, ignoreReviewLabels } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift index fbc4560..aef841f 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift @@ -12,6 +12,7 @@ public struct ReviewRequest: Identifiable, Codable, Sendable { public var baseBranch: String public var isDraft: Bool public var requestedAt: Date? + public var labels: [String] public var provider: Provider public var reviewSessionID: UUID? // set if a review session already exists @@ -26,6 +27,7 @@ public struct ReviewRequest: Identifiable, Codable, Sendable { baseBranch: String, isDraft: Bool = false, requestedAt: Date? = nil, + labels: [String] = [], provider: Provider = .github, reviewSessionID: UUID? = nil ) { @@ -39,6 +41,7 @@ public struct ReviewRequest: Identifiable, Codable, Sendable { self.baseBranch = baseBranch self.isDraft = isDraft self.requestedAt = requestedAt + self.labels = labels self.provider = provider self.reviewSessionID = reviewSessionID } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index a494659..6636f1c 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -245,3 +245,20 @@ import Testing let decoded2 = try JSONDecoder().decode(AppConfig.self, from: data2) #expect(decoded2.experimentalTmuxBackend == false) } + +@Test func ignoreReviewLabelsRoundTrip() throws { + let config = AppConfig( + defaults: ConfigDefaults(ignoreReviewLabels: ["dependencies", "renovate", "automated"]) + ) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.defaults.ignoreReviewLabels == ["dependencies", "renovate", "automated"]) +} + +@Test func ignoreReviewLabelsDefaultsEmptyWhenKeyMissing() throws { + let json = """ + {"defaults": {"provider": "github", "cli": "gh", "branchPrefix": "feature/", "excludeDirs": []}} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.defaults.ignoreReviewLabels.isEmpty) +} diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 6b9da86..7929d53 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -12,6 +12,7 @@ public struct AutomationSettingsView: View { var onSave: (() -> Void)? @State private var excludeReviewReposText: String + @State private var ignoreReviewLabelsText: String @State private var excludeTicketReposText: String public init( @@ -27,6 +28,7 @@ public struct AutomationSettingsView: View { self._autoRespond = autoRespond self.onSave = onSave self._excludeReviewReposText = State(initialValue: defaults.wrappedValue.excludeReviewRepos.joined(separator: ", ")) + self._ignoreReviewLabelsText = State(initialValue: defaults.wrappedValue.ignoreReviewLabels.joined(separator: ", ")) self._excludeTicketReposText = State(initialValue: defaults.wrappedValue.excludeTicketRepos.joined(separator: ", ")) } @@ -48,6 +50,19 @@ public struct AutomationSettingsView: View { Text("Per-workspace auto-review opt-ins are configured in Workspaces → edit a workspace.") .font(.caption) .foregroundStyle(.secondary) + + TextField("Ignored Labels", text: $ignoreReviewLabelsText) + .textFieldStyle(.roundedBorder) + .onChange(of: ignoreReviewLabelsText) { _, _ in + defaults.ignoreReviewLabels = ignoreReviewLabelsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + onSave?() + } + Text("Comma-separated labels to ignore from the review board (e.g., dependencies, renovate, automated).") + .font(.caption) + .foregroundStyle(.secondary) } Section("Tickets") { diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 09636cc..8c5151b 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -263,6 +263,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.managerAutoPermissionMode = config.managerAutoPermissionMode appState.excludeReviewRepos = config.defaults.excludeReviewRepos appState.excludeTicketRepos = config.defaults.excludeTicketRepos + appState.ignoreReviewLabels = config.defaults.ignoreReviewLabels // Create session service and hydrate state let service = SessionService(store: store, appState: appState, telemetryPort: config.telemetry.enabled ? config.telemetry.port : nil) @@ -680,6 +681,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.managerAutoPermissionMode = config.managerAutoPermissionMode appState.excludeReviewRepos = config.defaults.excludeReviewRepos appState.excludeTicketRepos = config.defaults.excludeTicketRepos + appState.ignoreReviewLabels = config.defaults.ignoreReviewLabels } // MARK: - Socket Server diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 3a2f3eb..db8c41e 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -304,6 +304,13 @@ final class IssueTracker { if !reviewExcludePatterns.isEmpty { reviews = reviews.filter { !repoMatchesExcludePatterns($0.repo, patterns: reviewExcludePatterns) } } + let ignoreLabels = config.defaults.ignoreReviewLabels + if !ignoreLabels.isEmpty { + let lowerLabels = Set(ignoreLabels.map { $0.lowercased() }) + reviews = reviews.filter { request in + !request.labels.contains(where: { lowerLabels.contains($0.lowercased()) }) + } + } let currentIDs = Set(reviews.map(\.id)) let newIDs = currentIDs.subtracting(previousReviewRequestIDs) previousReviewRequestIDs = allCurrentIDs @@ -562,6 +569,7 @@ final class IssueTracker { number title url isDraft updatedAt headRefName baseRefName state author { login } repository { nameWithOwner } + labels(first: 20) { nodes { name } } } } } @@ -1095,6 +1103,8 @@ final class IssueTracker { let headBranch = node["headRefName"] as? String ?? "" let baseBranch = node["baseRefName"] as? String ?? "" let updatedAt = (node["updatedAt"] as? String).flatMap { dateFormatter.date(from: $0) } + let labels = ((node["labels"] as? [String: Any])?["nodes"] as? [[String: Any]])? + .compactMap { $0["name"] as? String } ?? [] requests.append(ReviewRequest( id: "github:\(repoName)#\(number)", @@ -1107,6 +1117,7 @@ final class IssueTracker { baseBranch: baseBranch, isDraft: isDraft, requestedAt: updatedAt, + labels: labels, provider: .github )) }