From fedbc5cb3a89cc3c1940ec0ce4bf64f59b057e79 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 24 Apr 2026 17:15:30 -0500 Subject: [PATCH] Auto-start review sessions for opted-in repos Adds a per-workspace "Auto-Review Repos" CSV field. When IssueTracker detects a new review request whose repo is listed in any workspace's autoReviewRepos, AppDelegate triggers the same createReviewSession path as the manual Start Review button. Dedups via ReviewRequest.reviewSessionID so restarts don't create duplicates; existing notifications still fire. Closes #193 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/CrowCore/Models/AppConfig.swift | 20 ++++++++++++++++++- .../Tests/CrowCoreTests/AppConfigTests.swift | 19 ++++++++++++++++++ .../Sources/CrowUI/WorkspaceFormView.swift | 18 +++++++++++++++-- Sources/Crow/App/AppDelegate.swift | 13 +++++++++++- 4 files changed, 66 insertions(+), 4 deletions(-) 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()