diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index f79f530..2322891 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -36,6 +36,9 @@ public final class AppState { /// Fixed UUID for the allow list tab. nonisolated public static let allowListSessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + /// Fixed UUID for the review board tab. + nonisolated public static let reviewBoardSessionID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + public var managerSession: Session? { sessions.first { $0.id == Self.managerSessionID } } @@ -76,6 +79,20 @@ public final class AppState { /// Must be cleaned up when a session is deleted (see `SessionService.deleteSession`). public var prStatus: [UUID: PRStatus] = [:] + // MARK: - Review Requests + + /// PRs where the current user has been requested as a reviewer. + public var reviewRequests: [ReviewRequest] = [] + public var isLoadingReviews: Bool = false + + /// 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 + } + /// Whether the VS Code `code` CLI is available on this system. public var vsCodeAvailable: Bool = false @@ -114,6 +131,9 @@ public final class AppState { /// Called when user clicks "Work on" for an assigned issue. public var onWorkOnIssue: ((String) -> Void)? // receives issue URL + /// Called when user clicks "Start Review" for a PR review request. + public var onStartReview: ((String) -> Void)? // receives PR URL + /// Called to launch Claude in a terminal that just became ready. public var onLaunchClaude: ((UUID) -> Void)? // receives terminal ID @@ -154,12 +174,13 @@ public final class AppState { public var selectedSession: Session? { guard selectedSessionID != Self.ticketBoardSessionID, - selectedSessionID != Self.allowListSessionID else { return nil } + selectedSessionID != Self.allowListSessionID, + selectedSessionID != Self.reviewBoardSessionID else { return nil } return sessions.first { $0.id == selectedSessionID } } public var activeSessions: [Session] { - sessions.filter { $0.status == .active && $0.id != Self.managerSessionID } + sessions.filter { $0.status == .active && $0.id != Self.managerSessionID && $0.kind == .work } } public var inReviewSessions: [Session] { @@ -170,6 +191,10 @@ public final class AppState { sessions.filter { $0.status == .completed || $0.status == .archived } } + public var reviewSessions: [Session] { + sessions.filter { $0.kind == .review && $0.status != .completed && $0.status != .archived } + } + public func worktrees(for sessionID: UUID) -> [SessionWorktree] { worktrees[sessionID] ?? [] } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift index a49686e..bc3284a 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift @@ -1,5 +1,11 @@ import Foundation +/// Whether a session is a normal work session or a PR review session. +public enum SessionKind: String, Codable, Sendable { + case work // Normal development session (default) + case review // PR review session +} + /// Status of a development session. public enum SessionStatus: String, Codable, Sendable { case active diff --git a/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift b/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift index 6c87dbd..708a30a 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift @@ -8,6 +8,7 @@ import Foundation public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifiable { case taskComplete case agentWaiting + case reviewRequested public var id: String { rawValue } @@ -15,6 +16,7 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi switch self { case .taskComplete: "Task Complete" case .agentWaiting: "Agent Waiting" + case .reviewRequested: "Review Requested" } } @@ -22,6 +24,7 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi switch self { case .taskComplete: "Claude finished responding" case .agentWaiting: "Claude needs your input or permission" + case .reviewRequested: "Someone requested your review on a PR" } } @@ -29,6 +32,7 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi switch self { case .taskComplete: "Glass" case .agentWaiting: "Funk" + case .reviewRequested: "Glass" } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift new file mode 100644 index 0000000..fbc4560 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A pull request where the current user has been requested as a reviewer. +public struct ReviewRequest: Identifiable, Codable, Sendable { + public let id: String // "github:org/repo#123" + public var prNumber: Int + public var title: String + public var url: String // full PR URL + public var repo: String // "org/repo" + public var author: String // PR author login + public var headBranch: String + public var baseBranch: String + public var isDraft: Bool + public var requestedAt: Date? + public var provider: Provider + public var reviewSessionID: UUID? // set if a review session already exists + + public init( + id: String, + prNumber: Int, + title: String, + url: String, + repo: String, + author: String, + headBranch: String, + baseBranch: String, + isDraft: Bool = false, + requestedAt: Date? = nil, + provider: Provider = .github, + reviewSessionID: UUID? = nil + ) { + self.id = id + self.prNumber = prNumber + self.title = title + self.url = url + self.repo = repo + self.author = author + self.headBranch = headBranch + self.baseBranch = baseBranch + self.isDraft = isDraft + self.requestedAt = requestedAt + self.provider = provider + self.reviewSessionID = reviewSessionID + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift index 19f7e62..25e34cb 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift @@ -5,6 +5,7 @@ public struct Session: Identifiable, Codable, Sendable { public let id: UUID public var name: String public var status: SessionStatus + public var kind: SessionKind public var ticketURL: String? public var ticketTitle: String? public var ticketNumber: Int? @@ -16,6 +17,7 @@ public struct Session: Identifiable, Codable, Sendable { id: UUID = UUID(), name: String, status: SessionStatus = .active, + kind: SessionKind = .work, ticketURL: String? = nil, ticketTitle: String? = nil, ticketNumber: Int? = nil, @@ -26,6 +28,7 @@ public struct Session: Identifiable, Codable, Sendable { self.id = id self.name = name self.status = status + self.kind = kind self.ticketURL = ticketURL self.ticketTitle = ticketTitle self.ticketNumber = ticketNumber @@ -33,4 +36,19 @@ public struct Session: Identifiable, Codable, Sendable { self.createdAt = createdAt self.updatedAt = updatedAt } + + // Backward-compatible decoding: default `kind` to `.work` when missing from older persisted data. + 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) + status = try container.decode(SessionStatus.self, forKey: .status) + kind = try container.decodeIfPresent(SessionKind.self, forKey: .kind) ?? .work + ticketURL = try container.decodeIfPresent(String.self, forKey: .ticketURL) + ticketTitle = try container.decodeIfPresent(String.self, forKey: .ticketTitle) + ticketNumber = try container.decodeIfPresent(Int.self, forKey: .ticketNumber) + provider = try container.decodeIfPresent(Provider.self, forKey: .provider) + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift index 1eace81..11cc0df 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CrowCore @Test func notificationEventAllCasesCount() { - #expect(NotificationEvent.allCases.count == 2) + #expect(NotificationEvent.allCases.count == 3) } @Test func notificationEventDefaultSoundsNonEmpty() { diff --git a/Packages/CrowUI/Sources/CrowUI/MainContentView.swift b/Packages/CrowUI/Sources/CrowUI/MainContentView.swift index 4aa8b02..dc4b874 100644 --- a/Packages/CrowUI/Sources/CrowUI/MainContentView.swift +++ b/Packages/CrowUI/Sources/CrowUI/MainContentView.swift @@ -18,6 +18,8 @@ public struct MainContentView: View { TicketBoardView(appState: appState) } else if appState.selectedSessionID == AppState.allowListSessionID { AllowListView(appState: appState) + } else if appState.selectedSessionID == AppState.reviewBoardSessionID { + ReviewBoardView(appState: appState) } else if let session = appState.selectedSession { SessionDetailView(session: session, appState: appState) } else { diff --git a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift new file mode 100644 index 0000000..44628c6 --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift @@ -0,0 +1,228 @@ +import SwiftUI +import CrowCore + +// MARK: - Main Review Board View + +/// Full-pane review board shown when the Review Board tab is selected. +public struct ReviewBoardView: View { + @Bindable var appState: AppState + + public init(appState: AppState) { + self.appState = appState + } + + public var body: some View { + VStack(spacing: 0) { + reviewBoardHeader + Divider() + reviewList + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + .onAppear { + // Mark all current review requests as seen + for request in appState.reviewRequests { + appState.seenReviewRequestIDs.insert(request.id) + } + } + } + + private var reviewBoardHeader: some View { + HStack { + Text("Reviews") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(CorveilTheme.gold) + + if appState.isLoadingReviews { + ProgressView() + .controlSize(.small) + } + + Spacer() + + Text("\(appState.reviewRequests.count) pending") + .font(.caption) + .foregroundStyle(CorveilTheme.textSecondary) + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(CorveilTheme.bgSurface) + } + + @ViewBuilder + private var reviewList: some View { + if appState.reviewRequests.isEmpty { + VStack { + Spacer().frame(height: 40) + Image(systemName: "eye.circle") + .font(.system(size: 32)) + .foregroundStyle(CorveilTheme.textMuted) + Text("No Pending Reviews") + .font(.headline) + .foregroundStyle(CorveilTheme.textSecondary) + .padding(.top, 8) + Text("When someone requests your review on a PR, it will appear here.") + .font(.caption) + .foregroundStyle(CorveilTheme.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + Spacer() + } + .frame(maxWidth: .infinity) + } else { + List(appState.reviewRequests) { request in + ReviewRow(request: request, appState: appState) + } + .listStyle(.inset) + } + } +} + +// MARK: - Review Row + +struct ReviewRow: View { + let request: ReviewRequest + @Bindable var appState: AppState + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(request.repo) + .font(.caption) + .foregroundStyle(.secondary) + Text("#\(request.prNumber)") + .font(.callout) + .fontWeight(.medium) + if request.isDraft { + draftBadge + } + } + Text(request.title) + .font(.body) + .lineLimit(2) + HStack(spacing: 4) { + Text("by @\(request.author)") + .font(.caption) + .foregroundStyle(CorveilTheme.textMuted) + Text("\u{2022}") + .font(.caption2) + .foregroundStyle(CorveilTheme.textMuted) + Text(request.headBranch) + .font(.caption) + .foregroundStyle(CorveilTheme.textMuted) + .lineLimit(1) + if let date = request.requestedAt { + Text("\u{2022}") + .font(.caption2) + .foregroundStyle(CorveilTheme.textMuted) + Text(date, style: .relative) + .font(.caption) + .foregroundStyle(CorveilTheme.textMuted) + } + } + } + + Spacer() + + reviewAction + } + .padding(.vertical, 4) + } + + private var draftBadge: some View { + Text("Draft") + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.15)) + .foregroundStyle(.secondary) + .clipShape(Capsule()) + } + + @ViewBuilder + private var reviewAction: some View { + if let sessionID = request.reviewSessionID, + appState.sessions.contains(where: { $0.id == sessionID }) { + Button { + appState.selectedSessionID = sessionID + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.right.circle") + Text("Go to Session") + .lineLimit(1) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.green.opacity(0.15)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } else { + Button { + appState.onStartReview?(request.url) + } label: { + Label("Start Review", systemImage: "eye.circle") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } +} + +// MARK: - Sidebar Row + +/// Compact sidebar row showing pending review count. +public struct ReviewBoardSidebarRow: View { + @Bindable var appState: AppState + + public init(appState: AppState) { + self.appState = appState + } + + public var body: some View { + HStack { + Spacer() + HStack(spacing: 6) { + Image(systemName: "eye.circle") + .font(.system(size: 12)) + .foregroundStyle(CorveilTheme.gold) + Text("Reviews") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(CorveilTheme.gold) + if appState.isLoadingReviews { + ProgressView() + .controlSize(.mini) + } + if appState.reviewRequests.count > 0 { + Text("\(appState.reviewRequests.count)") + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(unseenCount > 0 ? CorveilTheme.gold.opacity(0.2) : Color.secondary.opacity(0.15)) + .foregroundStyle(unseenCount > 0 ? CorveilTheme.gold : CorveilTheme.textSecondary) + .clipShape(Capsule()) + } + } + Spacer() + } + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(CorveilTheme.bgSurface) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(CorveilTheme.borderSubtle, lineWidth: 1) + ) + ) + .padding(.vertical, 2) + } + + private var unseenCount: Int { + appState.unseenReviewCount + } +} diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index ac71045..010ff32 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -24,6 +24,12 @@ public struct SessionListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) + // Review board row + ReviewBoardSidebarRow(appState: appState) + .tag(AppState.reviewBoardSessionID) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + // Manager + Allow List row if appState.managerSession != nil { ManagerAllowListRow(appState: appState) @@ -45,6 +51,24 @@ public struct SessionListView: View { } } + // Review sessions + if !appState.reviewSessions.isEmpty { + SectionDivider(title: "Reviews") + ForEach(filteredSessions(appState.reviewSessions)) { session in + SessionRow(session: session, appState: appState) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + // In Review sessions if !appState.inReviewSessions.isEmpty { SectionDivider(title: "In Review") diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 11b57dc..3aec2fc 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -197,8 +197,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.appState.selectedSessionID = AppState.managerSessionID } + // Wire "Start Review" action — creates review session for a PR + appState.onStartReview = { [weak self] prURL in + guard let self else { return } + Task { await self.sessionService?.createReviewSession(prURL: prURL) } + } + // Start issue tracker let tracker = IssueTracker(appState: appState) + tracker.onNewReviewRequests = { [weak self] newRequests in + for request in newRequests { + self?.notificationManager?.notifyReviewRequest(request) + } + } tracker.start() self.issueTracker = tracker diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 5a1f1a7..f289335 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -11,6 +11,13 @@ final class IssueTracker { private let pollInterval: TimeInterval = 60 // 1 minute private var isRefreshing = false + /// Callback for new review request notifications (set by AppDelegate). + var onNewReviewRequests: (([ReviewRequest]) -> Void)? + + /// Previously seen review request IDs for delta detection. + private var previousReviewRequestIDs: Set = [] + private var isFirstFetch = true + init(appState: AppState) { self.appState = appState } @@ -95,11 +102,43 @@ final class IssueTracker { appState.assignedIssues = allIssues + // Fetch PR review requests for the current user + if checkedGitHub { + appState.isLoadingReviews = true + var reviews = await fetchReviewRequests() + + // Cross-reference with existing review sessions + for i in reviews.indices { + if let session = appState.reviewSessions.first(where: { + appState.links(for: $0.id).contains(where: { $0.linkType == .pr && $0.url == reviews[i].url }) + }) { + reviews[i].reviewSessionID = session.id + } + } + + // Delta detection for notifications + let currentIDs = Set(reviews.map(\.id)) + let newIDs = currentIDs.subtracting(previousReviewRequestIDs) + previousReviewRequestIDs = currentIDs + + if !isFirstFetch && !newIDs.isEmpty { + let newRequests = reviews.filter { newIDs.contains($0.id) } + onNewReviewRequests?(newRequests) + } + isFirstFetch = false + + appState.reviewRequests = reviews + appState.isLoadingReviews = false + } + // Sync session status for tickets that are "In Review" on the project board syncInReviewSessions(issues: allIssues) // Auto-complete sessions whose linked issue/PR is no longer open await autoCompleteFinishedSessions(openIssues: allIssues.filter { $0.state == "open" }) + + // Auto-complete review sessions whose linked PR is no longer open + await autoCompleteFinishedReviews() } // MARK: - GitHub @@ -746,6 +785,93 @@ final class IssueTracker { appState.onSetSessionInReview?(sessionID) } + // MARK: - Review Requests + + private func fetchReviewRequests() async -> [ReviewRequest] { + // gh search prs doesn't support headRefName/baseRefName fields — fetch basic list first + guard let output = try? await shell( + "gh", "search", "prs", + "--review-requested", "@me", + "--state", "open", + "--sort", "updated", + "--json", "number,title,url,repository,author,isDraft,updatedAt", + "--limit", "50" + ) else { return [] } + + guard let data = output.data(using: .utf8), + let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var requests: [ReviewRequest] = [] + + for item in items { + guard let number = item["number"] as? Int, + let title = item["title"] as? String, + let url = item["url"] as? String else { continue } + + let repoDict = item["repository"] as? [String: Any] + let repoName = repoDict?["nameWithOwner"] as? String ?? "" + let authorDict = item["author"] as? [String: Any] + let authorLogin = authorDict?["login"] as? String ?? "" + let isDraft = item["isDraft"] as? Bool ?? false + let updatedStr = item["updatedAt"] as? String + let updatedAt = updatedStr.flatMap { dateFormatter.date(from: $0) } + + // Fetch branch info via gh pr view (supports headRefName/baseRefName) + var headBranch = "" + var baseBranch = "" + if let prOutput = try? await shell( + "gh", "pr", "view", url, "--json", "headRefName,baseRefName" + ), let prData = prOutput.data(using: .utf8), + let prJSON = try? JSONSerialization.jsonObject(with: prData) as? [String: Any] { + headBranch = prJSON["headRefName"] as? String ?? "" + baseBranch = prJSON["baseRefName"] as? String ?? "" + } + + requests.append(ReviewRequest( + id: "github:\(repoName)#\(number)", + prNumber: number, + title: title, + url: url, + repo: repoName, + author: authorLogin, + headBranch: headBranch, + baseBranch: baseBranch, + isDraft: isDraft, + requestedAt: updatedAt, + provider: .github + )) + } + + // Sort newest first so stale review requests sink to the bottom + return requests.sorted { ($0.requestedAt ?? .distantPast) > ($1.requestedAt ?? .distantPast) } + } + + /// Auto-complete review sessions whose linked PR is no longer open (merged or closed). + private func autoCompleteFinishedReviews() async { + let activeReviews = appState.sessions.filter { $0.kind == .review && $0.status == .active } + + for session in activeReviews { + let sessionLinks = appState.links(for: session.id) + guard let prLink = sessionLinks.first(where: { $0.linkType == .pr }) else { continue } + + guard let output = try? await shell( + "gh", "pr", "view", prLink.url, "--json", "state" + ) else { continue } + + guard let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = json["state"] as? String else { continue } + + if state == "MERGED" || state == "CLOSED" { + print("[IssueTracker] Review session '\(session.name)' — PR \(state.lowercased()), marking completed") + appState.onCompleteSession?(session.id) + } + } + } + // MARK: - Shell private func shell(env: [String: String] = [:], _ args: String...) async throws -> String { diff --git a/Sources/Crow/App/NotificationManager.swift b/Sources/Crow/App/NotificationManager.swift index 7eaf79e..ce172dd 100644 --- a/Sources/Crow/App/NotificationManager.swift +++ b/Sources/Crow/App/NotificationManager.swift @@ -98,6 +98,31 @@ final class NotificationManager: NSObject, UNUserNotificationCenterDelegate { } } + // MARK: - Review Request Notifications + + /// Notify the user about a new PR review request. + func notifyReviewRequest(_ request: ReviewRequest) { + guard !settings.globalMute else { return } + + let config = settings.config(for: .reviewRequested) + guard config.enabled else { return } + + // Play sound + if settings.soundEnabled && config.soundEnabled { + playSound(named: config.soundName) + } + + // Post system notification + if settings.systemNotificationsEnabled && config.systemNotificationEnabled { + postSystemNotification( + title: "Review Requested \u{2014} \(request.repo)", + body: "PR #\(request.prNumber): \(request.title) (by @\(request.author))", + sessionID: UUID(), + eventName: "ReviewRequested" + ) + } + } + // MARK: - Sound Playback private func playSound(named name: String) { diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index 24e99de..9bd92ab 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -22,6 +22,13 @@ struct Scaffolder { let skillsDir = (claudeDir as NSString).appendingPathComponent("skills/crow-workspace") try fm.createDirectory(atPath: skillsDir, withIntermediateDirectories: true) + let reviewSkillsDir = (claudeDir as NSString).appendingPathComponent("skills/crow-review-pr") + try fm.createDirectory(atPath: reviewSkillsDir, withIntermediateDirectories: true) + + // Create crow-reviews directory for PR review clones + let reviewsDir = (devRoot as NSString).appendingPathComponent("crow-reviews") + try fm.createDirectory(atPath: reviewsDir, withIntermediateDirectories: true) + // Always update CLAUDE.md — but preserve the "Known Issues / Corrections" section let claudeMDPath = (claudeDir as NSString).appendingPathComponent("CLAUDE.md") let template = Self.bundledCLAUDEMD() @@ -53,6 +60,11 @@ struct Scaffolder { let skillTemplate = Self.bundledSkill() try skillTemplate.write(toFile: skillPath, atomically: true, encoding: .utf8) + // Always overwrite the review-pr skill with the latest version + let reviewSkillPath = (reviewSkillsDir as NSString).appendingPathComponent("SKILL.md") + let reviewSkillTemplate = Self.bundledReviewSkill() + try reviewSkillTemplate.write(toFile: reviewSkillPath, atomically: true, encoding: .utf8) + // Always overwrite settings.json (permissions for crow, gh, git commands) let settingsPath = (claudeDir as NSString).appendingPathComponent("settings.json") let settingsTemplate = Self.bundledSettings() @@ -109,6 +121,26 @@ struct Scaffolder { """ } + /// The crow-review-pr SKILL.md template bundled with the app. + static func bundledReviewSkill() -> String { + if let content = loadFromRepo("skills/crow-review-pr/SKILL.md") { + return content + } + if let url = Bundle.main.url(forResource: "crow-review-pr-SKILL.md", withExtension: "template"), + let content = try? String(contentsOf: url) { + return content + } + return """ + # Crow Review PR Skill + + ## Activation + This skill activates when user invokes `/crow-review-pr` command or in a review session. + + ## Important + All `gh` commands require `dangerouslyDisableSandbox: true`. + """ + } + /// The settings.json template with pre-approved permissions. static func bundledSettings() -> String { if let content = loadFromRepo("settings.json") { diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 24e3e27..4a8d1a9 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -110,7 +110,7 @@ final class SessionService { } } - /// Send `claude --continue` to a terminal and mark it as launched. + /// Send `claude --continue` (or a review prompt for review sessions) to a terminal and mark it as launched. /// /// Writes hook configuration to the session's worktree first so that /// Claude Code picks up the hooks on startup. @@ -119,10 +119,13 @@ final class SessionService { // Only auto-launch for restored/recovered terminals, not brand-new ones guard appState.autoLaunchTerminals.remove(terminalID) != nil else { return } - // Write/refresh hook config for the session's worktree - if let sessionID = appState.terminals.first(where: { _, terminals in + // Find the session this terminal belongs to + let sessionID = appState.terminals.first(where: { _, terminals in terminals.contains(where: { $0.id == terminalID }) - })?.key, + })?.key + + // Write/refresh hook config for the session's worktree + if let sessionID, let worktree = appState.primaryWorktree(for: sessionID), let crowPath = HookConfigGenerator.findCrowBinary() { do { @@ -138,7 +141,18 @@ final class SessionService { } let claudePath = Self.findClaudeBinary() ?? "claude" - TerminalManager.shared.send(id: terminalID, text: "\(claudePath) --continue\n") + + // For review sessions, launch claude with the review prompt file + if let sessionID, + let session = appState.sessions.first(where: { $0.id == sessionID }), + session.kind == .review, + let worktree = appState.primaryWorktree(for: sessionID) { + let promptPath = (worktree.worktreePath as NSString).appendingPathComponent(".crow-review-prompt.md") + TerminalManager.shared.send(id: terminalID, text: "\(claudePath) \"$(cat \(promptPath))\"\n") + } else { + TerminalManager.shared.send(id: terminalID, text: "\(claudePath) --continue\n") + } + appState.terminalReadiness[terminalID] = .claudeLaunched } @@ -200,6 +214,7 @@ final class SessionService { func deleteSession(id: UUID) async { guard id != AppState.managerSessionID else { return } + let session = appState.sessions.first(where: { $0.id == id }) let wts = appState.worktrees(for: id) let terminals = appState.terminals(for: id) @@ -208,56 +223,64 @@ final class SessionService { TerminalManager.shared.destroy(id: terminal.id) } - // Remove worktrees from disk: git worktree remove + branch delete - // Skip cleanup for worktrees that point at the main repo checkout (not a real worktree) - for wt in wts { - let isMainCheckout = wt.isMainRepoCheckout - - if isMainCheckout { - NSLog("Skipping worktree cleanup for main checkout: \(wt.worktreePath) (branch: \(wt.branch))") - continue + if session?.kind == .review { + // For review sessions, clean up the clone directory + for wt in wts { + try? FileManager.default.removeItem(atPath: wt.worktreePath) + NSLog("[SessionService] Cleaned up review clone: \(wt.worktreePath)") } + } else { + // Remove worktrees from disk: git worktree remove + branch delete + // Skip cleanup for worktrees that point at the main repo checkout (not a real worktree) + for wt in wts { + let isMainCheckout = wt.isMainRepoCheckout + + if isMainCheckout { + NSLog("Skipping worktree cleanup for main checkout: \(wt.worktreePath) (branch: \(wt.branch))") + continue + } - // Remove our hook config from settings.local.json before deleting the worktree - HookConfigGenerator.removeHookConfig(worktreePath: wt.worktreePath) + // Remove our hook config from settings.local.json before deleting the worktree + HookConfigGenerator.removeHookConfig(worktreePath: wt.worktreePath) - do { - // Remove the worktree - let removeResult = try await shell("git", "-C", wt.repoPath, "worktree", "remove", "--force", wt.worktreePath) - NSLog("Removed worktree: \(wt.worktreePath) \(removeResult)") + do { + // Remove the worktree + let removeResult = try await shell("git", "-C", wt.repoPath, "worktree", "remove", "--force", wt.worktreePath) + NSLog("Removed worktree: \(wt.worktreePath) \(removeResult)") + + // Delete the local branch (only if not a protected branch) + if !SessionWorktree.isProtectedBranch(wt.branch) { + do { + _ = try await shell("git", "-C", wt.repoPath, "branch", "-D", wt.branch) + } catch { + NSLog("[SessionService] Failed to delete branch \(wt.branch): \(error)") + } + } - // Delete the local branch (only if not a protected branch) - if !SessionWorktree.isProtectedBranch(wt.branch) { + // Prune worktree metadata do { - _ = try await shell("git", "-C", wt.repoPath, "branch", "-D", wt.branch) + _ = try await shell("git", "-C", wt.repoPath, "worktree", "prune") } catch { - NSLog("[SessionService] Failed to delete branch \(wt.branch): \(error)") + NSLog("[SessionService] Failed to prune worktree metadata: \(error)") } - } - - // Prune worktree metadata - do { - _ = try await shell("git", "-C", wt.repoPath, "worktree", "prune") - } catch { - NSLog("[SessionService] Failed to prune worktree metadata: \(error)") - } - // Remove the directory if it still exists - if FileManager.default.fileExists(atPath: wt.worktreePath) { - do { - try FileManager.default.removeItem(atPath: wt.worktreePath) - } catch { - NSLog("[SessionService] Failed to remove directory \(wt.worktreePath): \(error)") + // Remove the directory if it still exists + if FileManager.default.fileExists(atPath: wt.worktreePath) { + do { + try FileManager.default.removeItem(atPath: wt.worktreePath) + } catch { + NSLog("[SessionService] Failed to remove directory \(wt.worktreePath): \(error)") + } } - } - } catch { - NSLog("[SessionService] Failed to remove worktree \(wt.worktreePath): \(error)") - // Still try to remove the directory (but not if it's the main repo) - if FileManager.default.fileExists(atPath: wt.worktreePath) { - do { - try FileManager.default.removeItem(atPath: wt.worktreePath) - } catch { - NSLog("[SessionService] Failed to remove directory \(wt.worktreePath): \(error)") + } catch { + NSLog("[SessionService] Failed to remove worktree \(wt.worktreePath): \(error)") + // Still try to remove the directory (but not if it's the main repo) + if FileManager.default.fileExists(atPath: wt.worktreePath) { + do { + try FileManager.default.removeItem(atPath: wt.worktreePath) + } catch { + NSLog("[SessionService] Failed to remove directory \(wt.worktreePath): \(error)") + } } } } @@ -596,6 +619,149 @@ final class SessionService { store.mutate { data in data.terminals.removeAll { $0.id == terminalID } } } + // MARK: - Review Session + + /// Create a review session for an incoming PR review request. + func createReviewSession(prURL: String) async { + // Parse org/repo and PR number from URL like "https://github.com/org/repo/pull/123" + let components = prURL.split(separator: "/") + guard components.count >= 5, + let prNumber = Int(components.last ?? "") else { + NSLog("[SessionService] Could not parse PR URL: \(prURL)") + return + } + let owner = String(components[components.count - 4]) + let repoName = String(components[components.count - 3]) + let repoSlug = "\(owner)/\(repoName)" + + // Fetch PR metadata + guard let prOutput = try? await shell( + "gh", "pr", "view", prURL, + "--json", "title,headRefName,baseRefName,number" + ) else { + NSLog("[SessionService] Failed to fetch PR metadata for \(prURL)") + return + } + + guard let prData = prOutput.data(using: .utf8), + let prJSON = try? JSONSerialization.jsonObject(with: prData) as? [String: Any], + let prTitle = prJSON["title"] as? String, + let headBranch = prJSON["headRefName"] as? String else { + NSLog("[SessionService] Failed to parse PR metadata for \(prURL)") + return + } + + // Determine clone path + guard let devRoot = ConfigStore.loadDevRoot() else { + NSLog("[SessionService] No devRoot configured") + return + } + let reviewsDir = (devRoot as NSString).appendingPathComponent("crow-reviews") + let cloneDirName = "\(repoName)-pr-\(prNumber)" + let clonePath = (reviewsDir as NSString).appendingPathComponent(cloneDirName) + + let fm = FileManager.default + + // Ensure reviews directory exists + try? fm.createDirectory(atPath: reviewsDir, withIntermediateDirectories: true) + + // Clone or update the repo + if !fm.fileExists(atPath: (clonePath as NSString).appendingPathComponent(".git")) { + NSLog("[SessionService] Cloning \(repoSlug) into \(clonePath)") + _ = try? await shell("gh", "repo", "clone", repoSlug, clonePath) + } + + // Fetch and checkout the PR branch + _ = try? await shell("git", "-C", clonePath, "fetch", "origin", headBranch) + _ = try? await shell("git", "-C", clonePath, "checkout", headBranch) + _ = try? await shell("git", "-C", clonePath, "pull", "origin", headBranch) + + // Write review prompt file into the clone directory + let promptPath = (clonePath as NSString).appendingPathComponent(".crow-review-prompt.md") + let reviewPrompt = Self.buildReviewPrompt(prURL: prURL, prTitle: prTitle, repoSlug: repoSlug, prNumber: prNumber) + try? reviewPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + + // Copy the crow-review-pr skill into the clone's .claude/skills/ so Claude Code can find it + let cloneSkillsDir = (clonePath as NSString).appendingPathComponent(".claude/skills/crow-review-pr") + try? fm.createDirectory(atPath: cloneSkillsDir, withIntermediateDirectories: true) + let skillContent = Scaffolder.bundledReviewSkill() + try? skillContent.write( + toFile: (cloneSkillsDir as NSString).appendingPathComponent("SKILL.md"), + atomically: true, encoding: .utf8 + ) + + // Copy settings.json into the clone's .claude/ for permissions + let cloneSettingsDir = (clonePath as NSString).appendingPathComponent(".claude") + let settingsContent = Scaffolder.bundledSettings() + try? settingsContent.write( + toFile: (cloneSettingsDir as NSString).appendingPathComponent("settings.json"), + atomically: true, encoding: .utf8 + ) + + // Create session + let session = Session( + name: "review-\(repoName)-\(prNumber)", + kind: .review, + ticketTitle: prTitle, + provider: .github + ) + + let worktree = SessionWorktree( + sessionID: session.id, + repoName: repoName, + repoPath: clonePath, + worktreePath: clonePath, + branch: headBranch, + isPrimary: true + ) + + let terminal = SessionTerminal( + sessionID: session.id, + name: "Claude Code", + cwd: clonePath, + isManaged: true + ) + + let prLink = SessionLink( + sessionID: session.id, + label: "PR #\(prNumber)", + url: prURL, + linkType: .pr + ) + + // Add to state + appState.sessions.append(session) + appState.worktrees[session.id] = [worktree] + appState.terminals[session.id] = [terminal] + appState.links[session.id] = [prLink] + appState.terminalReadiness[terminal.id] = .uninitialized + TerminalManager.shared.trackReadiness(for: terminal.id) + appState.autoLaunchTerminals.insert(terminal.id) + TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: clonePath, command: nil) + + // Persist + store.mutate { data in + data.sessions.append(session) + data.worktrees.append(worktree) + data.terminals.append(terminal) + data.links.append(prLink) + } + + // Select the new session + appState.selectedSessionID = session.id + + NSLog("[SessionService] Created review session '\(session.name)' for \(prURL)") + } + + // MARK: - Review Prompt + + /// Build the initial prompt for a review session. + private static func buildReviewPrompt(prURL: String, prTitle: String, repoSlug: String, prNumber: Int) -> String { + """ + /crow-review-pr \(prURL) + """ + } + // MARK: - Session Status /// Update a session's status and persist the change. diff --git a/settings.json b/settings.json index 8033782..a2e8818 100644 --- a/settings.json +++ b/settings.json @@ -20,6 +20,10 @@ "Bash(gh issue view:*)", "Bash(gh pr view:*)", "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr review:*)", + "Bash(gh search prs:*)", + "Bash(gh repo clone:*)", "Bash(gh repo view:*)", "Bash(gh issue create:*)", "Bash(gh issue edit:*)", diff --git a/skills/crow-review-pr/SKILL.md b/skills/crow-review-pr/SKILL.md new file mode 100644 index 0000000..30bae54 --- /dev/null +++ b/skills/crow-review-pr/SKILL.md @@ -0,0 +1,141 @@ +# Crow Review PR + +Perform a comprehensive code and security review on a GitHub pull request, then post the findings as a PR review. + +## Important: Sandbox Bypass + +All `gh` and `git` commands require `dangerouslyDisableSandbox: true` because they need network/TLS access. + +## Arguments + +- `$ARGUMENTS` - The PR URL or number to review (required) + +## Activation + +This skill activates when: +- User invokes `/crow-review-pr` command +- User asks to "review a PR" or "review this pull request" +- This is a review session (the session was created via the Crow Reviews board) + +## Instructions + +You are performing a code and security review on PR $ARGUMENTS. Follow these steps: + +### Step 1: Checkout the PR + +```bash +gh pr checkout $ARGUMENTS +``` + +### Step 2: Gather PR Information + +Get the PR details including title, description, and changed files: + +```bash +gh pr view $ARGUMENTS --json title,body,headRefName,baseRefName,additions,deletions,changedFiles,files +``` + +### Step 3: Review the Code + +Read all changed files in the PR. For each file, analyze: + +**Security Review:** +- Authentication/authorization issues +- Input validation vulnerabilities +- Injection risks (SQL, command, XSS) +- Secrets/credentials exposure +- Cryptographic weaknesses +- Insecure configurations +- OWASP Top 10 concerns + +**Code Quality:** +- Logic errors +- Error handling +- Resource leaks +- Race conditions +- API design issues +- Missing tests for new code + +### Step 4: Run Static Analysis + +For Go projects: +```bash +cd core && go vet ./... 2>&1 +cd core && go test ./... -v 2>&1 | head -50 +``` + +For JavaScript/TypeScript projects: +```bash +npm run lint 2>&1 | head -50 +``` + +For Swift projects: +```bash +swift build 2>&1 | tail -20 +``` + +For Python projects: +```bash +ruff check . 2>&1 | head -50 +``` + +### Step 5: Post Review + +Based on your findings, determine the appropriate review action: + +- **Approve**: No critical or blocking issues found → use `--approve` +- **Request Changes**: Critical or blocking issues found → use `--request-changes` +- **Comment**: Informational only, no strong opinion either way → use `--comment` + +Post the review using the appropriate flag: + +```bash +# If approving: +gh pr review $ARGUMENTS --approve --body "YOUR_REVIEW_HERE" + +# If requesting changes: +gh pr review $ARGUMENTS --request-changes --body "YOUR_REVIEW_HERE" + +# If commenting only: +gh pr review $ARGUMENTS --comment --body "YOUR_REVIEW_HERE" +``` + +Use this format for the review: + +```markdown +## Code & Security Review + +### Critical Issues (if any) +[List blocking issues that must be fixed] + +### Security Review +**Strengths:** +- [Positive security aspects] + +**Concerns:** +- [Security issues found] + +### Code Quality +- [Code quality issues] + +### Summary Table +| Priority | Issue | +|----------|-------| +| Red | Must fix items | +| Yellow | Should fix items | +| Green | Consider items | + +**Recommendation:** [Approve / Request Changes / Comment — with reasoning] +``` + +### Important Notes + +- Be thorough but concise +- Prioritize security issues +- Include file:line references for specific issues +- Don't include sensitive information in the review +- If tests fail, note which ones and why +- Use `--approve` when the PR looks good (no red/blocking items) +- Use `--request-changes` when there are critical issues that must be fixed before merge +- Use `--comment` only when you have no strong recommendation either way +- All `gh` and `git` commands require `dangerouslyDisableSandbox: true`