From 818d1c89fd0eefa67b6b1fb0f727d445e4455e2f Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 3 Apr 2026 22:58:32 -0500 Subject: [PATCH 1/7] Add PR review monitoring and auto-session creation (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect incoming GitHub PR review requests and display them in a new Reviews sidebar tab. Users can click "Start Review" to clone the repo, create a dedicated review session, and launch Claude Code with the /review-pr skill for automated code review with human approval gate. Phase 1 — Manual trigger: - ReviewRequest model and SessionKind enum (.work/.review) - IssueTracker polls `gh search prs --review-requested @me` every 60s - ReviewBoardView with sidebar widget and review row list - SessionService.createReviewSession() clones into crow-reviews/ - /review-pr skill reads diff, analyzes, posts review via gh CLI - Auto-complete review sessions when PR merges/closes - Clone cleanup on session deletion Phase 2 — Automatic detection: - Delta detection for new review requests between poll cycles - macOS notifications via reviewRequested NotificationEvent - Unseen badge count on sidebar review widget Closes #33 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 29 +- .../Sources/CrowCore/Models/Enums.swift | 6 + .../CrowCore/Models/NotificationEvent.swift | 4 + .../CrowCore/Models/ReviewRequest.swift | 45 +++ .../Sources/CrowCore/Models/Session.swift | 18 ++ .../NotificationEventTests.swift | 2 +- .../Sources/CrowUI/MainContentView.swift | 2 + .../Sources/CrowUI/ReviewBoardView.swift | 220 +++++++++++++++ .../Sources/CrowUI/SessionListView.swift | 24 ++ Sources/Crow/App/AppDelegate.swift | 11 + Sources/Crow/App/IssueTracker.swift | 110 ++++++++ Sources/Crow/App/NotificationManager.swift | 25 ++ Sources/Crow/App/Scaffolder.swift | 32 +++ Sources/Crow/App/SessionService.swift | 259 ++++++++++++++---- settings.json | 4 + skills/review-pr/SKILL.md | 188 +++++++++++++ 16 files changed, 930 insertions(+), 49 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift create mode 100644 Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift create mode 100644 skills/review-pr/SKILL.md 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..80a9555 --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift @@ -0,0 +1,220 @@ +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) + } + } + + 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..7decf24 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,77 @@ final class IssueTracker { appState.onSetSessionInReview?(sessionID) } + // MARK: - Review Requests + + private func fetchReviewRequests() async -> [ReviewRequest] { + guard let output = try? await shell( + "gh", "search", "prs", + "--review-requested", "@me", + "--state", "open", + "--json", "number,title,url,repository,author,headRefName,baseRefName,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] + + return items.compactMap { item -> ReviewRequest? in + guard let number = item["number"] as? Int, + let title = item["title"] as? String, + let url = item["url"] as? String, + let headBranch = item["headRefName"] as? String, + let baseBranch = item["baseRefName"] as? String else { return nil } + + 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) } + + return 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 + ) + } + } + + /// 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..1d0f919 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,150 @@ 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) + + // Pre-initialize terminal surface in offscreen window + 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/review-pr/SKILL.md b/skills/review-pr/SKILL.md new file mode 100644 index 0000000..b416668 --- /dev/null +++ b/skills/review-pr/SKILL.md @@ -0,0 +1,188 @@ +# PR Review Skill + +## Purpose + +Performs thorough code reviews on GitHub pull requests. Reads the PR diff, understands the codebase context, provides a detailed review, and posts comments back to GitHub — all with user approval before submission. + +## Important: Sandbox Bypass + +All `gh` commands require `dangerouslyDisableSandbox: true` because they need network/TLS access. + +## Activation + +This skill activates when: +- User invokes `/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 Reviews board) + +## Workflow + +### Step 1: Identify the PR + +Get the PR URL from the session's links: + +```bash +crow list-links --session {session_uuid} +``` + +Look for a link with type `pr`. Extract the URL. + +If no PR link is found, ask the user for the PR URL. + +### Step 2: Fetch PR Details + +```bash +gh pr view {pr_url} --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels,reviewRequests +``` + +This gives you: +- **title** and **body**: The PR description and context +- **baseRefName** / **headRefName**: The target and source branches +- **files**: List of changed files with additions/deletions +- **commits**: Commit messages for understanding the work +- **author**: Who created the PR + +### Step 3: Read the Diff + +```bash +gh pr diff {pr_url} +``` + +This returns the full unified diff of all changes. For large PRs, you may want to also read specific files: + +```bash +gh pr diff {pr_url} -- {specific_file_path} +``` + +### Step 4: Understand Context + +1. Read `CLAUDE.md` and `README.md` in the repo root for project conventions +2. For each changed file, read the full file (not just the diff) to understand surrounding context +3. Check if tests were added or updated for the changes +4. Look at the commit history on the branch: + ```bash + git log --oneline origin/{baseRefName}..HEAD + ``` + +### Step 5: Analyze the Changes + +Review the PR for: + +1. **Correctness**: Does the code do what the PR description claims? Are there logic errors, off-by-one mistakes, or missing edge cases? + +2. **Security**: Any injection vulnerabilities (SQL, XSS, command injection)? Improper input validation? Hardcoded secrets? Insecure defaults? + +3. **Performance**: Unnecessary allocations? N+1 queries? Missing indexes? Blocking operations in hot paths? + +4. **Code Quality**: Does it follow the project's conventions? Is it readable? Are abstractions appropriate? Any code duplication? + +5. **Testing**: Are there adequate tests? Do they cover edge cases? Are they testing the right things (behavior, not implementation)? + +6. **Architecture**: Does the change fit the existing architecture? Any accidental coupling? Is the abstraction level right? + +### Step 6: Present the Review + +Format your review as: + +``` +## PR Review: {title} + +### Summary +{1-2 sentence overview of what the PR does and your overall assessment} + +### Verdict: {APPROVE | REQUEST_CHANGES | COMMENT} + +### Issues Found +{Numbered list of issues, each with:} +- Severity: critical / major / minor / nit +- File: {path}:{line} +- Description: what's wrong and why +- Suggestion: how to fix it + +### Positive Notes +{Things done well — acknowledge good patterns, thorough tests, clean abstractions} +``` + +**IMPORTANT**: Always present the review to the user for approval BEFORE submitting. Say: + +> Here is my review. Would you like me to: +> 1. Submit as-is +> 2. Modify the review +> 3. Cancel without submitting + +### Step 7: Submit the Review + +Only after the user approves: + +#### For a simple review comment (no inline comments): + +```bash +gh pr review {pr_url} --comment --body "review text here" +``` + +Or to approve: +```bash +gh pr review {pr_url} --approve --body "review text here" +``` + +Or to request changes: +```bash +gh pr review {pr_url} --request-changes --body "review text here" +``` + +#### For inline comments on specific lines: + +Use the GitHub API to create a review with inline comments: + +```bash +gh api repos/{owner}/{repo}/pulls/{number}/reviews \ + --method POST \ + -f body="Overall review summary" \ + -f event="COMMENT" \ + -f 'comments[][path]=src/example.swift' \ + -f 'comments[][line]=42' \ + -f 'comments[][body]=Specific comment about this line' +``` + +Note: The `line` field refers to the line number in the **new version** of the file (right side of the diff). Use `side=RIGHT` (default) for comments on added/modified lines. + +For comments on deleted lines, use `side=LEFT` with the old line number. + +## Important Constraints + +- **Never auto-submit**: Always wait for user approval before posting any review +- **Never auto-approve**: Even if the code looks good, present the review first +- **Be constructive**: Frame feedback as suggestions, not demands. Explain the "why" +- **Acknowledge good work**: Don't only point out problems +- **Respect conventions**: If the project has a style guide or CLAUDE.md conventions, follow them in your review +- **Be specific**: Reference exact files, line numbers, and code snippets +- **All `gh` and `crow` commands require `dangerouslyDisableSandbox: true`** + +## Error Handling + +| Error | Response | +|-------|----------| +| PR URL not found | Ask user for the PR URL | +| `gh` auth error | Suggest: `gh auth refresh` | +| Large diff (>5000 lines) | Focus on the most critical files; note that you reviewed a subset | +| Rate limit | Wait and retry, inform user | + +## Examples + +### Basic Review +``` +/review-pr +``` +→ Reads PR from session links, fetches diff, analyzes, presents review for approval + +### Review with Specific Focus +``` +/review-pr — focus on security implications +``` +→ Same flow but with security-focused analysis + +### Review a Specific PR +``` +/review-pr https://github.com/org/repo/pull/123 +``` +→ Reviews the specified PR regardless of session links From ab5bced2e5488be6dd2078d7280f0fd37069b0b0 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 6 Apr 2026 11:32:29 -0500 Subject: [PATCH 2/7] Sort review requests newest-first and show relative time Add --sort updated to gh search and sort parsed results by date descending so stale review requests sink to the bottom. Display relative timestamps (e.g., "3 days ago") in each review row. Co-Authored-By: Claude Opus 4.6 (1M context) --- Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift | 8 ++++++++ Sources/Crow/App/IssueTracker.swift | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift index 80a9555..44628c6 100644 --- a/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/ReviewBoardView.swift @@ -112,6 +112,14 @@ struct ReviewRow: View { .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) + } } } diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 7decf24..25b3cb1 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -792,6 +792,7 @@ final class IssueTracker { "gh", "search", "prs", "--review-requested", "@me", "--state", "open", + "--sort", "updated", "--json", "number,title,url,repository,author,headRefName,baseRefName,isDraft,updatedAt", "--limit", "50" ) else { return [] } @@ -802,7 +803,7 @@ final class IssueTracker { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return items.compactMap { item -> ReviewRequest? in + let requests = items.compactMap { item -> ReviewRequest? in guard let number = item["number"] as? Int, let title = item["title"] as? String, let url = item["url"] as? String, @@ -831,6 +832,9 @@ final class IssueTracker { 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). From 6ea3255571d326936742bec8c194d6f36908f8b8 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 6 Apr 2026 11:35:02 -0500 Subject: [PATCH 3/7] Fix review requests not loading: gh search prs doesn't support branch fields gh search prs only supports a subset of JSON fields (no headRefName or baseRefName). The command was failing silently due to try?, so no review requests were ever returned. Fix by fetching the basic list with valid fields, then enriching each result with branch info via gh pr view which does support those fields. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/IssueTracker.swift | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 25b3cb1..f289335 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -788,12 +788,13 @@ final class IssueTracker { // 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,headRefName,baseRefName,isDraft,updatedAt", + "--json", "number,title,url,repository,author,isDraft,updatedAt", "--limit", "50" ) else { return [] } @@ -803,12 +804,12 @@ final class IssueTracker { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let requests = items.compactMap { item -> ReviewRequest? in + 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, - let headBranch = item["headRefName"] as? String, - let baseBranch = item["baseRefName"] as? String else { return nil } + let url = item["url"] as? String else { continue } let repoDict = item["repository"] as? [String: Any] let repoName = repoDict?["nameWithOwner"] as? String ?? "" @@ -818,7 +819,18 @@ final class IssueTracker { let updatedStr = item["updatedAt"] as? String let updatedAt = updatedStr.flatMap { dateFormatter.date(from: $0) } - return ReviewRequest( + // 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, @@ -830,7 +842,7 @@ final class IssueTracker { isDraft: isDraft, requestedAt: updatedAt, provider: .github - ) + )) } // Sort newest first so stale review requests sink to the bottom From 2f04f811b4221f3548d825bc28abbcf04fa89afc Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 6 Apr 2026 11:48:23 -0500 Subject: [PATCH 4/7] Rename review-pr skill to crow-review-pr for app-managed deployment Move the skill from skills/review-pr/ to skills/crow-review-pr/ so it's bundled and deployed by the Crow app via the Scaffolder, matching the crow-workspace pattern. Update all references: Scaffolder paths, bundled resource names, launch command, and SKILL.md activation/examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/{review-pr => crow-review-pr}/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename skills/{review-pr => crow-review-pr}/SKILL.md (96%) diff --git a/skills/review-pr/SKILL.md b/skills/crow-review-pr/SKILL.md similarity index 96% rename from skills/review-pr/SKILL.md rename to skills/crow-review-pr/SKILL.md index b416668..3520365 100644 --- a/skills/review-pr/SKILL.md +++ b/skills/crow-review-pr/SKILL.md @@ -1,4 +1,4 @@ -# PR Review Skill +# Crow Review PR Skill ## Purpose @@ -11,7 +11,7 @@ All `gh` commands require `dangerouslyDisableSandbox: true` because they need ne ## Activation This skill activates when: -- User invokes `/review-pr` command +- 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 Reviews board) @@ -171,18 +171,18 @@ For comments on deleted lines, use `side=LEFT` with the old line number. ### Basic Review ``` -/review-pr +/crow-review-pr ``` → Reads PR from session links, fetches diff, analyzes, presents review for approval ### Review with Specific Focus ``` -/review-pr — focus on security implications +/crow-review-pr — focus on security implications ``` → Same flow but with security-focused analysis ### Review a Specific PR ``` -/review-pr https://github.com/org/repo/pull/123 +/crow-review-pr https://github.com/org/repo/pull/123 ``` → Reviews the specified PR regardless of session links From d654ce0964b1044c53808cbd1f8b9d1ab2ae7e06 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 6 Apr 2026 11:52:12 -0500 Subject: [PATCH 5/7] Deploy skill and prompt into review clone so Claude Code can find them The /crow-review-pr skill wasn't found because Claude Code runs in the clone directory, not the devRoot. Fix by: 1. Copying the crow-review-pr SKILL.md into the clone's .claude/skills/ during createReviewSession() 2. Copying settings.json into the clone's .claude/ for permissions 3. Writing a .crow-review-prompt.md file with the review instructions 4. Launching claude with the prompt file content instead of invoking the skill directly (which requires the skill to be in scope) Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/SessionService.swift | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 1d0f919..c6a060f 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -823,6 +823,36 @@ final class SessionService { return nil } + // MARK: - Review Prompt + + /// Build the initial prompt for a review session. + private static func buildReviewPrompt(prURL: String, prTitle: String, repoSlug: String, prNumber: Int) -> String { + """ + Review the following pull request. All gh commands MUST use dangerouslyDisableSandbox: true. + + ## Pull Request + + **\(repoSlug)#\(prNumber)**: \(prTitle) + + ```bash + gh pr view \(prURL) --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels + ``` + + ## Instructions + + 1. Fetch the PR details and diff using the commands below (all require dangerouslyDisableSandbox: true): + ```bash + gh pr view \(prURL) --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels + gh pr diff \(prURL) + ``` + 2. Read the codebase context — CLAUDE.md, README.md, and files touched in the diff + 3. Analyze the changes for: correctness, security, performance, code quality, testing, architecture + 4. Present your review with a verdict (APPROVE / REQUEST_CHANGES / COMMENT), issues found (with severity, file, line, description, suggestion), and positive notes + 5. **IMPORTANT: Ask for my approval before submitting** — never auto-submit + 6. Submit via `gh pr review \(prURL)` with the appropriate flag (--approve, --request-changes, or --comment) + """ + } + // MARK: - VS Code Integration /// Find the VS Code `code` CLI binary. From fa84720a8e989c3599f13ccab265061c7b31c788 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 6 Apr 2026 11:54:40 -0500 Subject: [PATCH 6/7] Use /crow-review-pr skill invocation instead of inline instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review prompt should just invoke the skill with the PR URL — the skill already has the full review workflow, so duplicating instructions in the prompt was redundant and would drift out of sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/SessionService.swift | 30 --------------------------- 1 file changed, 30 deletions(-) diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index c6a060f..1d0f919 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -823,36 +823,6 @@ final class SessionService { return nil } - // MARK: - Review Prompt - - /// Build the initial prompt for a review session. - private static func buildReviewPrompt(prURL: String, prTitle: String, repoSlug: String, prNumber: Int) -> String { - """ - Review the following pull request. All gh commands MUST use dangerouslyDisableSandbox: true. - - ## Pull Request - - **\(repoSlug)#\(prNumber)**: \(prTitle) - - ```bash - gh pr view \(prURL) --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels - ``` - - ## Instructions - - 1. Fetch the PR details and diff using the commands below (all require dangerouslyDisableSandbox: true): - ```bash - gh pr view \(prURL) --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels - gh pr diff \(prURL) - ``` - 2. Read the codebase context — CLAUDE.md, README.md, and files touched in the diff - 3. Analyze the changes for: correctness, security, performance, code quality, testing, architecture - 4. Present your review with a verdict (APPROVE / REQUEST_CHANGES / COMMENT), issues found (with severity, file, line, description, suggestion), and positive notes - 5. **IMPORTANT: Ask for my approval before submitting** — never auto-submit - 6. Submit via `gh pr review \(prURL)` with the appropriate flag (--approve, --request-changes, or --comment) - """ - } - // MARK: - VS Code Integration /// Find the VS Code `code` CLI binary. From 9502ec1ea474903e3b9f9b75a30264442bca1de0 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 7 Apr 2026 10:13:48 -0500 Subject: [PATCH 7/7] Align crow-review-pr skill with home directory review-pr skill Rewrite the skill to match the proven ~/.claude/skills/review-pr/ structure: checkout PR, gather info, security + code quality review, run static analysis, auto-submit with approve/request-changes/comment based on findings. Removes the manual approval gate and verbose analysis categories in favor of the tighter Red/Yellow/Green format. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/SessionService.swift | 3 +- skills/crow-review-pr/SKILL.md | 223 ++++++++++---------------- 2 files changed, 89 insertions(+), 137 deletions(-) diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 1d0f919..4a8d1a9 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -736,8 +736,7 @@ final class SessionService { appState.links[session.id] = [prLink] appState.terminalReadiness[terminal.id] = .uninitialized TerminalManager.shared.trackReadiness(for: terminal.id) - - // Pre-initialize terminal surface in offscreen window + appState.autoLaunchTerminals.insert(terminal.id) TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: clonePath, command: nil) // Persist diff --git a/skills/crow-review-pr/SKILL.md b/skills/crow-review-pr/SKILL.md index 3520365..30bae54 100644 --- a/skills/crow-review-pr/SKILL.md +++ b/skills/crow-review-pr/SKILL.md @@ -1,188 +1,141 @@ -# Crow Review PR Skill +# Crow Review PR -## Purpose - -Performs thorough code reviews on GitHub pull requests. Reads the PR diff, understands the codebase context, provides a detailed review, and posts comments back to GitHub — all with user approval before submission. +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` commands require `dangerouslyDisableSandbox: true` because they need network/TLS access. +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 Reviews board) - -## Workflow - -### Step 1: Identify the PR +- This is a review session (the session was created via the Crow Reviews board) -Get the PR URL from the session's links: +## Instructions -```bash -crow list-links --session {session_uuid} -``` - -Look for a link with type `pr`. Extract the URL. +You are performing a code and security review on PR $ARGUMENTS. Follow these steps: -If no PR link is found, ask the user for the PR URL. - -### Step 2: Fetch PR Details +### Step 1: Checkout the PR ```bash -gh pr view {pr_url} --json title,body,baseRefName,headRefName,files,additions,deletions,commits,author,labels,reviewRequests +gh pr checkout $ARGUMENTS ``` -This gives you: -- **title** and **body**: The PR description and context -- **baseRefName** / **headRefName**: The target and source branches -- **files**: List of changed files with additions/deletions -- **commits**: Commit messages for understanding the work -- **author**: Who created the PR - -### Step 3: Read the Diff +### Step 2: Gather PR Information -```bash -gh pr diff {pr_url} -``` - -This returns the full unified diff of all changes. For large PRs, you may want to also read specific files: +Get the PR details including title, description, and changed files: ```bash -gh pr diff {pr_url} -- {specific_file_path} +gh pr view $ARGUMENTS --json title,body,headRefName,baseRefName,additions,deletions,changedFiles,files ``` -### Step 4: Understand Context - -1. Read `CLAUDE.md` and `README.md` in the repo root for project conventions -2. For each changed file, read the full file (not just the diff) to understand surrounding context -3. Check if tests were added or updated for the changes -4. Look at the commit history on the branch: - ```bash - git log --oneline origin/{baseRefName}..HEAD - ``` - -### Step 5: Analyze the Changes - -Review the PR for: - -1. **Correctness**: Does the code do what the PR description claims? Are there logic errors, off-by-one mistakes, or missing edge cases? - -2. **Security**: Any injection vulnerabilities (SQL, XSS, command injection)? Improper input validation? Hardcoded secrets? Insecure defaults? - -3. **Performance**: Unnecessary allocations? N+1 queries? Missing indexes? Blocking operations in hot paths? - -4. **Code Quality**: Does it follow the project's conventions? Is it readable? Are abstractions appropriate? Any code duplication? +### Step 3: Review the Code -5. **Testing**: Are there adequate tests? Do they cover edge cases? Are they testing the right things (behavior, not implementation)? +Read all changed files in the PR. For each file, analyze: -6. **Architecture**: Does the change fit the existing architecture? Any accidental coupling? Is the abstraction level right? +**Security Review:** +- Authentication/authorization issues +- Input validation vulnerabilities +- Injection risks (SQL, command, XSS) +- Secrets/credentials exposure +- Cryptographic weaknesses +- Insecure configurations +- OWASP Top 10 concerns -### Step 6: Present the Review +**Code Quality:** +- Logic errors +- Error handling +- Resource leaks +- Race conditions +- API design issues +- Missing tests for new code -Format your review as: +### Step 4: Run Static Analysis +For Go projects: +```bash +cd core && go vet ./... 2>&1 +cd core && go test ./... -v 2>&1 | head -50 ``` -## PR Review: {title} - -### Summary -{1-2 sentence overview of what the PR does and your overall assessment} - -### Verdict: {APPROVE | REQUEST_CHANGES | COMMENT} - -### Issues Found -{Numbered list of issues, each with:} -- Severity: critical / major / minor / nit -- File: {path}:{line} -- Description: what's wrong and why -- Suggestion: how to fix it - -### Positive Notes -{Things done well — acknowledge good patterns, thorough tests, clean abstractions} -``` - -**IMPORTANT**: Always present the review to the user for approval BEFORE submitting. Say: - -> Here is my review. Would you like me to: -> 1. Submit as-is -> 2. Modify the review -> 3. Cancel without submitting - -### Step 7: Submit the Review - -Only after the user approves: - -#### For a simple review comment (no inline comments): +For JavaScript/TypeScript projects: ```bash -gh pr review {pr_url} --comment --body "review text here" +npm run lint 2>&1 | head -50 ``` -Or to approve: +For Swift projects: ```bash -gh pr review {pr_url} --approve --body "review text here" +swift build 2>&1 | tail -20 ``` -Or to request changes: +For Python projects: ```bash -gh pr review {pr_url} --request-changes --body "review text here" +ruff check . 2>&1 | head -50 ``` -#### For inline comments on specific lines: +### 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` -Use the GitHub API to create a review with inline comments: +Post the review using the appropriate flag: ```bash -gh api repos/{owner}/{repo}/pulls/{number}/reviews \ - --method POST \ - -f body="Overall review summary" \ - -f event="COMMENT" \ - -f 'comments[][path]=src/example.swift' \ - -f 'comments[][line]=42' \ - -f 'comments[][body]=Specific comment about this line' +# 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" ``` -Note: The `line` field refers to the line number in the **new version** of the file (right side of the diff). Use `side=RIGHT` (default) for comments on added/modified lines. +Use this format for the review: -For comments on deleted lines, use `side=LEFT` with the old line number. +```markdown +## Code & Security Review -## Important Constraints +### Critical Issues (if any) +[List blocking issues that must be fixed] -- **Never auto-submit**: Always wait for user approval before posting any review -- **Never auto-approve**: Even if the code looks good, present the review first -- **Be constructive**: Frame feedback as suggestions, not demands. Explain the "why" -- **Acknowledge good work**: Don't only point out problems -- **Respect conventions**: If the project has a style guide or CLAUDE.md conventions, follow them in your review -- **Be specific**: Reference exact files, line numbers, and code snippets -- **All `gh` and `crow` commands require `dangerouslyDisableSandbox: true`** +### Security Review +**Strengths:** +- [Positive security aspects] -## Error Handling +**Concerns:** +- [Security issues found] -| Error | Response | -|-------|----------| -| PR URL not found | Ask user for the PR URL | -| `gh` auth error | Suggest: `gh auth refresh` | -| Large diff (>5000 lines) | Focus on the most critical files; note that you reviewed a subset | -| Rate limit | Wait and retry, inform user | +### Code Quality +- [Code quality issues] -## Examples +### Summary Table +| Priority | Issue | +|----------|-------| +| Red | Must fix items | +| Yellow | Should fix items | +| Green | Consider items | -### Basic Review +**Recommendation:** [Approve / Request Changes / Comment — with reasoning] ``` -/crow-review-pr -``` -→ Reads PR from session links, fetches diff, analyzes, presents review for approval -### Review with Specific Focus -``` -/crow-review-pr — focus on security implications -``` -→ Same flow but with security-focused analysis +### Important Notes -### Review a Specific PR -``` -/crow-review-pr https://github.com/org/repo/pull/123 -``` -→ Reviews the specified PR regardless of session links +- 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`