diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index bc85d66..436ce34 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -61,6 +61,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { public var cli: String // "gh" or "glab" — kept for config file compat public var host: String? // GitLab host (e.g., "gitlab.example.com") public var alwaysInclude: [String] // repos to always list in prompt table + public var autoReviewRepos: [String] // repos where review requests auto-create a review session /// The CLI tool name derived from the current `provider` value. /// Unlike `cli` (which may be stale from an old config file), this is always correct. @@ -74,7 +75,8 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { provider: String = "github", cli: String = "gh", host: String? = nil, - alwaysInclude: [String] = [] + alwaysInclude: [String] = [], + autoReviewRepos: [String] = [] ) { self.id = id self.name = name @@ -82,6 +84,22 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { self.cli = cli self.host = host self.alwaysInclude = alwaysInclude + self.autoReviewRepos = autoReviewRepos + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + provider = try container.decode(String.self, forKey: .provider) + cli = try container.decode(String.self, forKey: .cli) + host = try container.decodeIfPresent(String.self, forKey: .host) + alwaysInclude = try container.decodeIfPresent([String].self, forKey: .alwaysInclude) ?? [] + autoReviewRepos = try container.decodeIfPresent([String].self, forKey: .autoReviewRepos) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case id, name, provider, cli, host, alwaysInclude, autoReviewRepos } /// Characters that are unsafe in directory names (workspace names become directory names). diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 815cb57..b60554e 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -119,6 +119,25 @@ import Testing #expect(stale.derivedCLI == "glab") } +@Test func workspaceAutoReviewReposRoundTrip() throws { + let config = AppConfig(workspaces: [ + WorkspaceInfo(name: "Org", autoReviewRepos: ["org/repo1", "org/repo2"]) + ]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.workspaces[0].autoReviewRepos == ["org/repo1", "org/repo2"]) +} + +@Test func workspaceAutoReviewReposDefaultsEmptyWhenKeyMissing() throws { + // Legacy configs without the key should default to empty (feature off). + let json = """ + {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh"}]} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.workspaces[0].autoReviewRepos.isEmpty) + #expect(config.workspaces[0].alwaysInclude.isEmpty) +} + @Test func workspaceNameValidation() { // Valid name #expect(WorkspaceInfo.validateName("MyOrg", existingNames: []) == nil) diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 07d70cd..d3cc4c7 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -12,6 +12,7 @@ public struct WorkspaceFormView: View { @State private var provider: String @State private var host: String @State private var alwaysIncludeText: String + @State private var autoReviewReposText: String private let existingID: UUID? private let existingNames: [String] @@ -31,6 +32,7 @@ public struct WorkspaceFormView: View { self._provider = State(initialValue: workspace?.provider ?? "github") self._host = State(initialValue: workspace?.host ?? "") self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") + self._autoReviewReposText = State(initialValue: workspace?.autoReviewRepos.joined(separator: ", ") ?? "") self.existingNames = existingNames self.onSave = onSave } @@ -70,6 +72,12 @@ public struct WorkspaceFormView: View { Text("Comma-separated list of repos to always show in the workspace prompt.") .font(.caption) .foregroundStyle(.secondary) + + TextField("Auto-Review Repos", text: $autoReviewReposText) + .textFieldStyle(.roundedBorder) + Text("Comma-separated repos (e.g. org/repo). New review requests from these repos will automatically create a review session.") + .font(.caption) + .foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -83,13 +91,19 @@ public struct WorkspaceFormView: View { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } + let autoReviewRepos = autoReviewReposText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let ws = WorkspaceInfo( id: existingID ?? UUID(), name: trimmedName, provider: provider, cli: provider == "github" ? "gh" : "glab", host: provider == "gitlab" && !host.isEmpty ? host : nil, - alwaysInclude: alwaysInclude + alwaysInclude: alwaysInclude, + autoReviewRepos: autoReviewRepos ) onSave(ws) dismiss() @@ -99,6 +113,6 @@ public struct WorkspaceFormView: View { } .padding() } - .frame(width: 400, height: 300) + .frame(width: 440, height: 400) } } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index aaab9ad..3f6d0b5 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -225,8 +225,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Start issue tracker let tracker = IssueTracker(appState: appState) tracker.onNewReviewRequests = { [weak self] newRequests in + guard let self else { return } for request in newRequests { - self?.notificationManager?.notifyReviewRequest(request) + self.notificationManager?.notifyReviewRequest(request) + + // Auto-start a review session if any configured workspace opts this repo in, + // and we don't already have a review session for this PR. + guard request.reviewSessionID == nil else { continue } + let enabledRepos = (self.appConfig?.workspaces ?? []) + .flatMap(\.autoReviewRepos) + .map { $0.lowercased() } + if enabledRepos.contains(request.repo.lowercased()) { + Task { await self.sessionService?.createReviewSession(prURL: request.url) } + } } } tracker.start()