diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 3cbda44..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef7b4b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# EnvironmentTokenBootstrapper reads either GITHUB_TOKEN or GH_TOKEN. +# They are equivalent for local token bootstrapping, so set only one. +GITHUB_TOKEN=ghp_your_token_here +# Optional alias if you prefer GH_TOKEN instead of GITHUB_TOKEN: +# GH_TOKEN=ghp_your_token_here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..84180e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + name: fix-and-check + runs-on: macos-15 + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + env: + XCODE_VERSION: "26.3" + steps: + - uses: actions/checkout@v4 + + # Keep CI on the Xcode 26.3 toolchain expected by the project. + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1.7.0 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Setup mise + uses: jdx/mise-action@v4.0.1 + with: + install: true + + - name: Cache SwiftPM and DerivedData + uses: actions/cache@v4 + with: + path: | + ~/.cache/org.swift.swiftpm + ~/Library/Caches/org.swift.swiftpm + ~/Library/Developer/Xcode/DerivedData + build/DerivedData + .build + key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('project.yml', 'Package.resolved') }} + restore-keys: | + ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}- + ${{ runner.os }}-xcode- + + - name: Run auto-fixes + run: mise run fix + + - name: Verify auto-fixes produced no changes + run: | + if [[ -n "$(git status --short --untracked-files=all)" ]]; then + git status --short --untracked-files=all + exit 1 + fi + + - name: Run repo checks + run: mise run check + + - name: Verify checks produced no changes + run: | + if [[ -n "$(git status --short --untracked-files=all)" ]]; then + git status --short --untracked-files=all + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c862c09 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: macos-15 + env: + APP_NAME: GitHubCommandCenter.app + DERIVED_DATA_ROOT: build/DerivedData + XCODE_VERSION: "26.3" + steps: + - uses: actions/checkout@v4 + + - uses: jdx/mise-action@v4.0.1 + with: + install: true + + # Release builds are produced with the Xcode 26.3 toolchain used in CI. + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1.7.0 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Build (unsigned) + run: mise run build-release + + - name: Create .zip for distribution + run: | + MATCHES="$(find "$DERIVED_DATA_ROOT" -type d -path "*/Build/Products/Release/$APP_NAME" -print)" + MATCH_COUNT="$(printf '%s\n' "$MATCHES" | sed '/^$/d' | wc -l | tr -d ' ')" + + if [ "$MATCH_COUNT" -eq 0 ]; then + echo "App bundle not found under $DERIVED_DATA_ROOT" + exit 1 + fi + + if [ "$MATCH_COUNT" -gt 1 ]; then + echo "Multiple app bundles found under $DERIVED_DATA_ROOT:" + printf '%s\n' "$MATCHES" + exit 1 + fi + + APP_PATH="$(printf '%s\n' "$MATCHES" | head -n 1)" + APP_BUNDLE_NAME="$(basename "$APP_PATH")" + echo "Found app at: $APP_PATH" + cp -R "$APP_PATH" . + zip -r "GitHubCommandCenter-${{ github.ref_name }}.zip" "$APP_BUNDLE_NAME" + # Print macOS installation/quarantine note to the job log. + echo "To install: unzip, move to /Applications, then right-click > Open on first launch (unsigned build)." + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: "GitHubCommandCenter-${{ github.ref_name }}.zip" + body: | + ## GitHub Command Center ${{ github.ref_name }} + + **Install:** + 1. Download the `.zip` below and unzip it + 2. Move `GitHubCommandCenter.app` to `/Applications` + 3. First launch: right-click the app → **Open** (bypasses Gatekeeper for unsigned builds) + + **Requirements:** macOS 14 (Sonoma) or later + + **Setup:** Click the menu bar icon → enter your GitHub Personal Access Token (needs `repo` read scope) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..393c7c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Xcode +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate + +# Swift Package Manager +.build/ + +# macOS +.DS_Store +*.swp +*~ + +# XcodeGen (project is regenerated from project.yml) +*.xcodeproj/project.pbxproj + +# Claude Code +.claude/settings.local.json +.mise.local.toml +.env +.env.local + +# gstack +.gstack/ +.conductor/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..a1beeac --- /dev/null +++ b/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : true, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 120, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1d818aa --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,69 @@ +included: + - GitHubCommandCenter + - GitHubCommandCenterTests + +excluded: + - GitHubCommandCenter.xcodeproj + - build + - DerivedData + +disabled_rules: + - closing_brace + - colon + - comma + - control_statement + - empty_parentheses_with_trailing_closure + - indentation_width + - opening_brace + - operator_usage_whitespace + - return_arrow_whitespace + - static_over_final_class + - statement_position + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - vertical_parameter_alignment + - vertical_parameter_alignment_on_call + - vertical_whitespace + - vertical_whitespace_between_cases + +opt_in_rules: + - empty_count + - first_where + - force_unwrapping + - implicitly_unwrapped_optional + - modifier_order + - toggle_bool + +line_length: + warning: 120 + error: 200 + +file_length: + warning: 700 + error: 1000 + +function_body_length: + warning: 110 + error: 140 + +type_body_length: + warning: 600 + error: 700 + +cyclomatic_complexity: + warning: 20 + error: 30 + +identifier_name: + min_length: + warning: 2 + error: 1 + # Keep global exclusions empty. For legitimate API-shaped short names such as `id`, + # use scoped inline disables instead; do not add generic shortcuts like id/x/y/i/j/k here. + excluded: [] + +nesting: + type_level: + warning: 2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..31ddbbc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to GitHub Command Center will be documented in this file. + +## [0.1.0] - 2026-03-31 + +Initial MVP release. A macOS menu bar app that monitors your GitHub PRs and tells you what needs attention. + +### Added + +- **PR triage dashboard** in the menu bar — PRs are split into "Needs Your Action", "Waiting on Others", and "Your Drafts" so you always know what to look at first +- **Smart attention badge** — the menu bar icon shows how many PRs need your action, not the total count +- **Status dots** with click popovers showing CI, review, and merge status for each PR at a glance +- **Real-time notifications** for CI failures on your PRs, review requests, changes requested, approval, and merge conflicts +- **Adaptive polling** — polls every 60s for small workloads, backs off to 5min for large ones, with ETag caching to minimize API usage +- **Rate limit awareness** — detects GitHub API rate limits and pauses polling with a recovery timer +- **Token validation** — checks that your PAT has the right scopes and warns about missing permissions +- **Environment token bootstrapper** — automatically loads tokens from `GITHUB_TOKEN`, `GH_TOKEN`, or `.env.local` files during development +- **Inline settings panel** — configure your token and manage repos without leaving the menu bar popover +- **Draft PR section** — your drafts are tracked separately so they don't clutter the action list +- **Pagination safety** — caps API pagination to prevent runaway requests on very active accounts +- **Keychain storage** with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` for secure token persistence +- **Notification threading** — PR notifications are grouped under a shared thread identifier + +### Changed + +- Menu bar icon animates during initial load to show the app is working + +### Fixed + +- CI notification fires correctly on pending-to-failing transitions (not just passing-to-failing) +- Check-run API 403/404 falls back gracefully instead of failing the entire PR fetch +- Thread-safe access to notification handlers, date formatters, and mock test infrastructure +- Unterminated quoted tokens in `.env` files are rejected instead of silently truncated diff --git a/CLAUDE.md b/CLAUDE.md index d2a297c..d87b87a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,17 @@ +## Project + +Swift/SwiftUI macOS 14+ menu bar app monitoring GitHub PR status. No external Swift dependencies. + +## Build & Test (CLI) + +- First-time setup: `mise install` +- Sync tools and regenerate the Xcode project: `mise run sync` +- Build: `mise run build` +- Test: `mise run test` +- Style checks: `mise run check` +- Fix, then re-run style checks and tests: `mise run verify` +- After editing `project.yml`: run `mise run sync` + ## Skill routing When the user's request matches an available skill, ALWAYS invoke it using the Skill diff --git a/GitHubCommandCenter.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/GitHubCommandCenter.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/GitHubCommandCenter.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/GitHubCommandCenter/Assets.xcassets/Contents.json b/GitHubCommandCenter/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/GitHubCommandCenter/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GitHubCommandCenter/GitHubCommandCenter.entitlements b/GitHubCommandCenter/GitHubCommandCenter.entitlements new file mode 100644 index 0000000..8c60721 --- /dev/null +++ b/GitHubCommandCenter/GitHubCommandCenter.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.network.client + + + diff --git a/GitHubCommandCenter/GitHubCommandCenterApp.swift b/GitHubCommandCenter/GitHubCommandCenterApp.swift new file mode 100644 index 0000000..ea2320b --- /dev/null +++ b/GitHubCommandCenter/GitHubCommandCenterApp.swift @@ -0,0 +1,41 @@ +import SwiftUI + +@main +@MainActor +struct GitHubCommandCenterApp: App { + @State private var appState: AppState + + init() { + self.init( + appState: AppState(), + shouldStartPolling: !RuntimeEnvironment.isRunningTests + ) + } + + init(appState: AppState, shouldStartPolling: Bool = true) { + _appState = State(initialValue: appState) + if shouldStartPolling { + appState.startPollingIfNeeded() + } + } + + var body: some Scene { + MenuBarExtra { + PRListView() + .environment(appState) + } label: { + MenuBarIconView( + healthStatus: appState.healthStatus, + prCount: appState.menuBarBadgeCount, + isLoading: appState.panel.isLoading + ) + } + .menuBarExtraStyle(.window) + } +} + +private enum RuntimeEnvironment { + static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } +} diff --git a/GitHubCommandCenter/Info.plist b/GitHubCommandCenter/Info.plist new file mode 100644 index 0000000..f490c56 --- /dev/null +++ b/GitHubCommandCenter/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GitHub Command Center + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026. MIT License. + NSUserNotificationUsageDescription + GitHub Command Center sends notifications when your PR statuses change. + NSPrincipalClass + NSApplication + DevEnvSearchRoot + $(DEV_ENV_SEARCH_ROOT) + + diff --git a/GitHubCommandCenter/Logging.swift b/GitHubCommandCenter/Logging.swift new file mode 100644 index 0000000..b7d1f0a --- /dev/null +++ b/GitHubCommandCenter/Logging.swift @@ -0,0 +1,5 @@ +import Foundation + +enum Log { + static let subsystem = Bundle.main.bundleIdentifier ?? "GitHubCommandCenter" +} diff --git a/GitHubCommandCenter/Models/AppError.swift b/GitHubCommandCenter/Models/AppError.swift new file mode 100644 index 0000000..406b6fe --- /dev/null +++ b/GitHubCommandCenter/Models/AppError.swift @@ -0,0 +1,48 @@ +import Foundation + +enum AppError: LocalizedError, Sendable, Equatable { + struct RateLimitContext: Sendable, Equatable { + let resetAt: Date + var nextUpdateDescription: String { + resetAt.formatted(.relative(presentation: .named, unitsStyle: .wide)) + } + + init(resetAt: Date) { + self.resetAt = resetAt + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.resetAt == rhs.resetAt + } + } + + case authError + case incompleteSearchResults + case keychainError + case noToken + case paginationLimitExceeded + case rateLimitExceeded(RateLimitContext) + case networkError + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .authError: + return "Authentication failed. Check your GitHub token." + case .incompleteSearchResults: + return "GitHub search returned incomplete results. Try again later or narrow the search." + case .keychainError: + return "Unable to access the GitHub token in Keychain." + case .noToken: + return "No GitHub token configured." + case .paginationLimitExceeded: + return "GitHub pagination exceeded the safe page limit. Try again later." + case .rateLimitExceeded(let context): + return "Rate limited — next update \(context.nextUpdateDescription)" + case .networkError: + return "Network connection failed." + case .serverError(let code): + return "GitHub API error (\(code))." + } + } +} diff --git a/GitHubCommandCenter/Models/AppState.swift b/GitHubCommandCenter/Models/AppState.swift new file mode 100644 index 0000000..7f61700 --- /dev/null +++ b/GitHubCommandCenter/Models/AppState.swift @@ -0,0 +1,378 @@ +import Foundation +import Observation + +@MainActor +protocol PollingControlling: AnyObject { + func start() + func stop() + func reset() + func forceRefresh() +} + +@MainActor +final class NoOpPollingController: PollingControlling { + func start() {} + func stop() {} + func reset() {} + func forceRefresh() {} +} + +@MainActor +@Observable +final class AppState { + enum AuthStatus: Equatable, Sendable { + case unknown + case authenticated(username: String) + case failed + case noToken + } + + enum HealthStatus: Equatable, Sendable { + case green + case yellow + case red + } + + enum PanelContentState: Equatable, Sendable { + case loading + case setupRequired + case authError + case loadError + case empty + case prList + } + + struct TriageSnapshot: Equatable, Sendable { + var prs: [PRState] + var needsActionPRs: [PRState] + var waitingOnOthersPRs: [PRState] + var yourDraftPRs: [PRState] + var menuBarBadgeCount: Int + var healthStatus: HealthStatus + + static let empty = build(from: []) + private static let triageCategoryPriority: [PRState.TriageCategory: Int] = [ + .needsYourAction: 0, + .waitingOnOthers: 1, + .yourDraft: 2, + ] + + static func build(from prs: [PRState]) -> TriageSnapshot { + let sortedPRs = prs.sorted(by: triageSortComparator) + let needsActionPRs = sortedPRs.filter { $0.triageCategory == .needsYourAction } + let waitingOnOthersPRs = sortedPRs.filter { $0.triageCategory == .waitingOnOthers } + let yourDraftPRs = sortedPRs.filter { $0.triageCategory == .yourDraft } + let menuBarBadgeCount = needsActionPRs.count + let healthStatus: HealthStatus + + if sortedPRs.isEmpty || needsActionPRs.isEmpty { + healthStatus = .green + } else { + healthStatus = needsActionPRs.contains { $0.urgencyScore >= 3 } ? .red : .yellow + } + + return TriageSnapshot( + prs: sortedPRs, + needsActionPRs: needsActionPRs, + waitingOnOthersPRs: waitingOnOthersPRs, + yourDraftPRs: yourDraftPRs, + menuBarBadgeCount: menuBarBadgeCount, + healthStatus: healthStatus + ) + } + + private static func triageSortComparator(_ lhs: PRState, _ rhs: PRState) -> Bool { + let lhsPriority = triageCategoryPriority[lhs.triageCategory] ?? .max + let rhsPriority = triageCategoryPriority[rhs.triageCategory] ?? .max + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + + switch lhs.triageCategory { + case .needsYourAction: + let comparison = PRState.needsActionComparator.compare(lhs, rhs) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + case .waitingOnOthers, .yourDraft: + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + } + + return lhs.id < rhs.id + } + } + + struct PanelState: Equatable, Sendable { + var triageSnapshot: TriageSnapshot = .empty + var recentlyClosedPRs: [PRState] = [] + var lastUpdated: Date? + var isLoading = true + var warningMessage: String? + var error: AppError? + var isStale = false + } + + struct AuthState: Equatable, Sendable { + var authenticationStatus: AuthStatus = .unknown + var tokenValidationWarningMessage: String? + } + + struct PollSnapshot: Equatable, Sendable { + var panel: PanelState + var auth: AuthState + } + + struct PollContext: Equatable, Sendable { + var panel: PanelState + var auth: AuthState + } + + var panel = PanelState() + var auth = AuthState() + + @ObservationIgnored private var pollingEngine: (any PollingControlling)? + @ObservationIgnored private let makePollingEngine: (AppState) -> any PollingControlling + @ObservationIgnored private let requestNotificationPermission: () async -> Void + @ObservationIgnored private let preloadTokenIfNeeded: () -> Void + + init() { + self.makePollingEngine = { PollingEngine(appState: $0) } + self.requestNotificationPermission = { _ = await NotificationService.shared.requestPermission() } + self.preloadTokenIfNeeded = { + _ = EnvironmentTokenBootstrapper().preloadIfNeeded() + } + } + + init( + makePollingEngine: @escaping (AppState) -> any PollingControlling, + requestNotificationPermission: @escaping () async -> Void, + preloadTokenIfNeeded: @escaping () -> Void = {} + ) { + self.makePollingEngine = makePollingEngine + self.requestNotificationPermission = requestNotificationPermission + self.preloadTokenIfNeeded = preloadTokenIfNeeded + } + + var prs: [PRState] { + get { panel.triageSnapshot.prs } + set { panel.triageSnapshot = TriageSnapshot.build(from: newValue) } + } + + var recentlyClosedPRs: [PRState] { + get { panel.recentlyClosedPRs } + set { panel.recentlyClosedPRs = newValue } + } + + var lastUpdated: Date? { + get { panel.lastUpdated } + set { panel.lastUpdated = newValue } + } + + var isLoading: Bool { + get { panel.isLoading } + set { panel.isLoading = newValue } + } + + var error: AppError? { + get { panel.error } + set { panel.error = newValue } + } + + var warningMessage: String? { + get { panel.warningMessage } + set { panel.warningMessage = newValue } + } + + var isStale: Bool { + get { panel.isStale } + set { panel.isStale = newValue } + } + + var authenticationStatus: AuthStatus { + get { auth.authenticationStatus } + set { auth.authenticationStatus = newValue } + } + + var tokenValidationWarningMessage: String? { + get { auth.tokenValidationWarningMessage } + set { auth.tokenValidationWarningMessage = newValue } + } + + var needsActionPRs: [PRState] { + panel.triageSnapshot.needsActionPRs + } + + var waitingOnOthersPRs: [PRState] { + panel.triageSnapshot.waitingOnOthersPRs + } + + var yourDraftPRs: [PRState] { + panel.triageSnapshot.yourDraftPRs + } + + var menuBarBadgeCount: Int { + panel.triageSnapshot.menuBarBadgeCount + } + + var healthStatus: HealthStatus { + if case .failed = auth.authenticationStatus { + return .red + } + + if let error = panel.error { + switch error { + case .keychainError, .rateLimitExceeded, .networkError, .serverError, .paginationLimitExceeded: + return .red + case .incompleteSearchResults: + return .yellow + case .authError, .noToken: + break + } + } + + if case .noToken = auth.authenticationStatus { + return .yellow + } + + if panel.isStale { + return .yellow + } + + return panel.triageSnapshot.healthStatus + } + + var isRateLimited: Bool { + if case .rateLimitExceeded = panel.error { + return true + } + + return false + } + + var rateLimitResetDate: Date? { + if case .rateLimitExceeded(let context) = panel.error { + return context.resetAt + } + + return nil + } + + var panelSubtitleText: String { + let prCount = panel.triageSnapshot.prs.count + + switch auth.authenticationStatus { + case .authenticated: + return "\(prCount) tracked PR\(prCount == 1 ? "" : "s")" + default: + return "\(prCount) open PR\(prCount == 1 ? "" : "s")" + } + } + + var emptyStateMessage: String? { + guard !panel.isLoading, panel.triageSnapshot.prs.isEmpty else { return nil } + guard case .authenticated(let username) = auth.authenticationStatus else { return nil } + + return + "This app tracks pull requests involving @\(username). " + + "If you expected results here, make sure your GitHub token can access those repositories." + } + + var panelContentState: PanelContentState { + if panel.isLoading { + return .loading + } + + guard panel.triageSnapshot.prs.isEmpty else { + return .prList + } + + switch auth.authenticationStatus { + case .noToken: + return .setupRequired + case .failed: + return .authError + default: + if panel.error != nil { + return .loadError + } + + return .empty + } + } + + var showsRecentlyClosedSection: Bool { + guard !panel.recentlyClosedPRs.isEmpty else { return false } + + switch panelContentState { + case .empty, .prList: + return true + case .loading, .setupRequired, .authError, .loadError: + return false + } + } + + func startPollingIfNeeded() { + guard pollingEngine == nil else { return } + preloadTokenIfNeeded() + let engine = makePollingEngine(self) + pollingEngine = engine + Task { await requestNotificationPermission() } + engine.start() + } + + func forceRefresh() { + pollingEngine?.forceRefresh() + } + + func currentPollContext() -> PollContext { + PollContext(panel: panel, auth: auth) + } + + func applyPollSnapshot(_ snapshot: PollSnapshot) { + panel = snapshot.panel + auth = snapshot.auth + } + + func clearRecentlyClosedPRs() { + panel.recentlyClosedPRs = [] + } + + func recomputeStaleness(now: Date = Date()) { + panel.isStale = Self.staleStatus(lastUpdated: panel.lastUpdated, now: now) + } + + nonisolated static func staleStatus(lastUpdated: Date?, now: Date = Date()) -> Bool { + guard let lastUpdated else { return false } + return now.timeIntervalSince(lastUpdated) > 300 + } + + func clearSessionStateForNewSession() { + pollingEngine?.reset() + clearObservedSessionState() + } + + func stopPollingForMissingToken() { + pollingEngine?.reset() + pollingEngine?.stop() + pollingEngine = nil + clearObservedSessionState() + panel.isLoading = false + auth.authenticationStatus = .noToken + } + + func resetPolling() { + pollingEngine?.reset() + pollingEngine?.stop() + clearObservedSessionState(preserveTokenValidationWarning: true) + pollingEngine = nil + startPollingIfNeeded() + } + + private func clearObservedSessionState(preserveTokenValidationWarning: Bool = false) { + let warningMessage = preserveTokenValidationWarning ? auth.tokenValidationWarningMessage : nil + panel = PanelState() + auth = AuthState(authenticationStatus: .unknown, tokenValidationWarningMessage: warningMessage) + } +} diff --git a/GitHubCommandCenter/Models/PRState.swift b/GitHubCommandCenter/Models/PRState.swift new file mode 100644 index 0000000..813e170 --- /dev/null +++ b/GitHubCommandCenter/Models/PRState.swift @@ -0,0 +1,157 @@ +import Foundation + +struct PRState: Identifiable, Equatable, Sendable { + let number: Int + let title: String + let repoFullName: String // "owner/repo" + let url: URL + let headSHA: String + let draftStatus: DraftStatus + let ciStatus: CIStatus + let reviewStatus: ReviewStatus + let mergeStatus: MergeStatus + let assignment: Assignment + let updatedAt: Date + + enum DraftStatus: Equatable, Sendable { + case draft + case ready + } + + struct FailingCheck: Identifiable, Equatable, Sendable { + let name: String + let conclusion: String // "failure", "timed_out", "cancelled", "action_required", "error" + let url: URL? + + var id: String { + if let url { + return url.absoluteString + } + + return "\(name)|\(conclusion)" + } + } + + enum CIStatus: Equatable, Sendable { + case passing + case failing(checks: [FailingCheck], totalChecks: Int) + case pending + case none // no CI configured + } + + enum ReviewStatus: Equatable, Sendable { + case none + case requested(by: [String]) + case changesRequested(by: [String]) + case approved(by: [String]) + } + + // Mapped from GitHub's undocumented mergeable_state field + enum MergeStatus: Equatable, Sendable { + case ready // "clean" + case conflicts // "dirty" + case behind // branch needs updating before merge + case blocked // "blocked" — branch protection not satisfied + case pending // "unknown" or null — not yet computed + } + + struct Assignment: Equatable, Sendable { + let createdByMe: Bool + let reviewRequestedFromMe: Bool + let assignedToMe: Bool + } + + enum TriageCategory: Sendable { + case needsYourAction + case waitingOnOthers + case yourDraft + } + + // MARK: - Computed + + var id: String { + "\(repoFullName)#\(number)" + } + + var triageCategory: TriageCategory { + guard draftStatus == .ready else { + if assignment.createdByMe || assignment.assignedToMe { + return .yourDraft + } + return .waitingOnOthers + } + + if assignment.reviewRequestedFromMe || assignment.assignedToMe { + return .needsYourAction + } + + if assignment.createdByMe { + let ciNeedsAction: Bool + if case .failing = ciStatus { ciNeedsAction = true } else { ciNeedsAction = false } + + let mergeNeedsAction = mergeStatus == .conflicts || mergeStatus == .behind + + let reviewNeedsAction: Bool + switch reviewStatus { + case .changesRequested: reviewNeedsAction = true + case .approved where mergeStatus == .ready: reviewNeedsAction = true // ready to merge + default: reviewNeedsAction = false + } + + if ciNeedsAction || mergeNeedsAction || reviewNeedsAction { + return .needsYourAction + } + } + + return .waitingOnOthers + } + + var urgencyScore: Int { + guard draftStatus == .ready else { return 0 } + var score = 0 + if case .failing = ciStatus, assignment.createdByMe { score += 3 } + if case .changesRequested = reviewStatus, assignment.createdByMe { score += 3 } + if mergeStatus == .conflicts || mergeStatus == .behind { score += 2 } + if assignment.reviewRequestedFromMe { score += 2 } + if assignment.assignedToMe && !assignment.createdByMe { score += 1 } + if case .approved = reviewStatus, mergeStatus == .ready, assignment.createdByMe { score += 1 } + return score + } + + /// higher urgency first, then more recent first + struct NeedsActionComparator: SortComparator { + var order: SortOrder = .forward + + func compare(_ lhs: PRState, _ rhs: PRState) -> ComparisonResult { + let result: ComparisonResult + + if lhs.urgencyScore != rhs.urgencyScore { + result = lhs.urgencyScore > rhs.urgencyScore ? .orderedAscending : .orderedDescending + } else if lhs.updatedAt != rhs.updatedAt { + result = lhs.updatedAt > rhs.updatedAt ? .orderedAscending : .orderedDescending + } else { + result = .orderedSame + } + + switch order { + case .forward: + return result + case .reverse: + switch result { + case .orderedAscending: return .orderedDescending + case .orderedDescending: return .orderedAscending + case .orderedSame: return .orderedSame + } + } + } + } + static let needsActionComparator = NeedsActionComparator() + + // Highest-precedence role label for display in the PR row + var displayRole: String { + if assignment.reviewRequestedFromMe { return "Review requested" } + if assignment.assignedToMe { return "Assigned" } + if assignment.createdByMe { return "Your PR" } + return "" + } +} diff --git a/GitHubCommandCenter/Models/TokenAccessDetails.swift b/GitHubCommandCenter/Models/TokenAccessDetails.swift new file mode 100644 index 0000000..5a14303 --- /dev/null +++ b/GitHubCommandCenter/Models/TokenAccessDetails.swift @@ -0,0 +1,19 @@ +import Foundation + +struct TokenAccessDetails: Equatable, Sendable { + let username: String + let oauthScopes: [String] + let accessibleRepositories: [AccessibleRepository] + + struct AccessibleRepository: Identifiable, Equatable, Sendable { + let id: Int + let fullName: String + let accessLevel: AccessLevel + + enum AccessLevel: String, Equatable, Sendable { + case admin + case write + case read + } + } +} diff --git a/GitHubCommandCenter/Services/EnvironmentTokenBootstrapper.swift b/GitHubCommandCenter/Services/EnvironmentTokenBootstrapper.swift new file mode 100644 index 0000000..be2d56e --- /dev/null +++ b/GitHubCommandCenter/Services/EnvironmentTokenBootstrapper.swift @@ -0,0 +1,246 @@ +import Foundation +import OSLog + +struct EnvironmentTokenBootstrapper { + private static let logger = Logger(subsystem: Log.subsystem, category: "EnvironmentTokenBootstrapper") + private static let defaultSearchDepthLimit = 12 + private let keychain: KeychainService + private let environment: [String: String] + private let searchRoots: [URL] + private let searchDepthLimit: Int + private let fileManager: FileManager + + init( + keychain: KeychainService = .shared, + environment: [String: String] = ProcessInfo.processInfo.environment, + searchRoots: [URL] = Self.defaultSearchRoots(bundle: .main), + searchDepthLimit: Int = Self.defaultSearchDepthLimit, + fileManager: FileManager = .default + ) { + self.keychain = keychain + self.environment = environment + self.searchRoots = searchRoots + self.searchDepthLimit = searchDepthLimit + self.fileManager = fileManager + } + + @discardableResult + func preloadIfNeeded() -> Bool { + do { + let savedToken = try keychain.loadToken() + if let savedToken, !savedToken.isEmpty { + return false + } + + guard let preload = tokenToPreload() else { + return false + } + + try keychain.saveToken(preload.token) + return true + } catch { + let errorDescription = error.localizedDescription + Self.logger.error( + "Error preloading environment tokens: \(errorDescription, privacy: .public)" + ) + return false + } + } + + private func tokenToPreload() -> (source: String, token: String)? { + if let token = normalizedToken(from: environment["GITHUB_TOKEN"]) { + return ("GITHUB_TOKEN", token) + } + + if let token = normalizedToken(from: environment["GH_TOKEN"]) { + return ("GH_TOKEN", token) + } + + let envFiles = candidateEnvFiles() + for fileURL in envFiles { + guard + fileManager.fileExists(atPath: fileURL.path), + let contents = try? String(contentsOf: fileURL, encoding: .utf8), + let token = tokenFromEnvFile(contents) + else { + continue + } + + return (fileURL.lastPathComponent, token) + } + + return nil + } + + private func candidateEnvFiles() -> [URL] { + var seenPaths = Set() + var files: [URL] = [] + + for directoryPath in searchDirectoryPaths() { + for fileName in [".env.local", ".env"] { + let filePath = (directoryPath as NSString).appendingPathComponent(fileName) + let standardizedPath = (filePath as NSString).standardizingPath + if seenPaths.insert(standardizedPath).inserted { + files.append(URL(fileURLWithPath: standardizedPath, isDirectory: false)) + } + } + } + + return files + } + + private func searchDirectoryPaths() -> [String] { + var seenPaths = Set() + var directories: [String] = [] + let effectiveSearchDepthLimit = max(searchDepthLimit, 1) + + for root in searchRoots { + var directoryPath = normalizedDirectoryPath(for: root) + var depth = 0 + + while depth < effectiveSearchDepthLimit { + if seenPaths.insert(directoryPath).inserted { + directories.append(directoryPath) + } + + let parentPath = (directoryPath as NSString).deletingLastPathComponent + if parentPath.isEmpty || parentPath == directoryPath { + break + } + + depth += 1 + if depth >= effectiveSearchDepthLimit { + break + } + directoryPath = parentPath + } + } + + return directories + } + + private func normalizedDirectoryPath(for root: URL) -> String { + let standardizedPath = (root.path as NSString).standardizingPath + if URL(fileURLWithPath: standardizedPath).pathExtension == "app" { + return (standardizedPath as NSString).deletingLastPathComponent + } + if root.hasDirectoryPath { + return standardizedPath + } + return (standardizedPath as NSString).deletingLastPathComponent + } + + private func tokenFromEnvFile(_ contents: String) -> String? { + for rawLine in contents.split(whereSeparator: \.isNewline) { + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("#") else { + continue + } + + let normalizedLine = + line.hasPrefix("export ") + ? String(line.dropFirst("export ".count)).trimmingCharacters(in: .whitespaces) + : line + + guard let equalsIndex = normalizedLine.firstIndex(of: "=") else { + continue + } + + let key = String(normalizedLine[.. String? { + guard let rawValue else { + return nil + } + + let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + return nil + } + + if let firstCharacter = trimmedValue.first, firstCharacter == "\"" || firstCharacter == "'" { + return quotedToken(in: trimmedValue, quote: firstCharacter) + } + + let uncommentedValue: String + if let commentRange = trimmedValue.range(of: " #") { + uncommentedValue = String(trimmedValue[.. String? { + var token = "" + var isEscaping = false + var foundClosingQuote = false + + for character in value.dropFirst() { + if isEscaping { + token.append(character) + isEscaping = false + continue + } + + if quote == "\"", character == "\\" { + isEscaping = true + continue + } + + if character == quote { + foundClosingQuote = true + break + } + + token.append(character) + } + + // Empty quoted values should behave like an unset token rather than clearing a valid stored token. + guard foundClosingQuote, !token.isEmpty else { + return nil + } + + return token + } + + /// Search roots for `.env.local` / `.env`, in priority order. + /// + /// Debug builds set `DevEnvSearchRoot` in Info.plist to `$(SRCROOT)` so repo-root env files are found + /// even when the process working directory and app bundle live under DerivedData. + static func defaultSearchRoots(bundle: Bundle = .main) -> [URL] { + var roots: [URL] = [] + + if let plistPath = bundle.object(forInfoDictionaryKey: "DevEnvSearchRoot") as? String { + let trimmed = plistPath.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + roots.append(URL(fileURLWithPath: trimmed, isDirectory: true)) + } + } + + let currentDirectory = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + let bundleParent = bundle.bundleURL.deletingLastPathComponent() + + roots.append(currentDirectory) + roots.append(bundleParent) + + return roots + } +} diff --git a/GitHubCommandCenter/Services/GitHubDataSource.swift b/GitHubCommandCenter/Services/GitHubDataSource.swift new file mode 100644 index 0000000..a931c14 --- /dev/null +++ b/GitHubCommandCenter/Services/GitHubDataSource.swift @@ -0,0 +1,45 @@ +import Foundation + +struct PRFetchResult: Equatable, Sendable, RandomAccessCollection, ExpressibleByArrayLiteral { + typealias Element = PRState + typealias Index = Array.Index + typealias ArrayLiteralElement = PRState + + let prs: [PRState] + let warningMessage: String? + + init(prs: [PRState], warningMessage: String? = nil) { + self.prs = prs + self.warningMessage = warningMessage + } + + init(arrayLiteral elements: PRState...) { + self.init(prs: elements) + } + + var startIndex: Index { prs.startIndex } + var endIndex: Index { prs.endIndex } + + subscript(position: Index) -> PRState { + prs[position] + } + + func index(after position: Index) -> Index { + prs.index(after: position) + } + + func index(before position: Index) -> Index { + prs.index(before: position) + } +} + +protocol GitHubDataSource: Sendable { + /// Validates the token and returns the authenticated username. + func validateToken() async throws -> String + + /// Fetches all open PRs involving the given username across all accessible repos. + func fetchAllPRStates(username: String) async throws -> PRFetchResult + + /// Confirms which PRs that disappeared from the open/involved search are actually closed. + func resolveDisappearedPRs(_ prs: [PRState]) async -> [PRState] +} diff --git a/GitHubCommandCenter/Services/GitHubRESTClient+Models.swift b/GitHubCommandCenter/Services/GitHubRESTClient+Models.swift new file mode 100644 index 0000000..144c85b --- /dev/null +++ b/GitHubCommandCenter/Services/GitHubRESTClient+Models.swift @@ -0,0 +1,121 @@ +import Foundation + +extension GitHubRESTClient { + struct APIErrorResponse: Codable, Sendable { + let message: String + } + + struct HTTPDataResponse { + let data: Data + let response: HTTPURLResponse + } + + struct AggregatedCICheck { + let identifier: String + let failingCheck: PRState.FailingCheck? + let isPending: Bool + + func merged(with other: AggregatedCICheck) -> AggregatedCICheck { + if let failingCheck { + return .init(identifier: identifier, failingCheck: failingCheck, isPending: false) + } + + if let otherFailingCheck = other.failingCheck { + return .init(identifier: identifier, failingCheck: otherFailingCheck, isPending: false) + } + + return .init( + identifier: identifier, + failingCheck: nil, + isPending: isPending || other.isPending + ) + } + } + + struct GitHubUser: Codable, Sendable { + let login: String + } + + struct SearchResponse: Codable, Sendable { + let totalCount: Int + let incompleteResults: Bool + let items: [SearchItem] + } + + struct SearchItem: Codable, Sendable { + let number: Int + let title: String + let htmlUrl: String + let draft: Bool? + let updatedAt: String + let repositoryUrl: String + } + + struct PRDetail: Codable, Sendable { + let head: Head + let state: String + let user: GitHubUser + let assignees: [GitHubUser] + let requestedReviewers: [GitHubUser] + let mergeableState: String? + + struct Head: Codable, Sendable { + let sha: String + } + } + + struct Review: Codable, Sendable { + let user: GitHubUser + let state: String + } + + struct CheckRunsResponse: Codable, Sendable { + let totalCount: Int + let checkRuns: [CheckRun] + } + + struct CheckRun: Codable, Sendable { + let name: String + let status: String + let conclusion: String? + let htmlUrl: String? + } + + struct CombinedStatusResponse: Codable, Sendable { + let state: String + let statuses: [CommitStatus] + } + + struct CommitStatus: Codable, Sendable { + let context: String + let state: String + let targetUrl: String? + } + + struct AccessibleRepositoryResponse: Codable, Sendable { + let id: Int + let fullName: String + let permissions: RepositoryPermissions? + + var tokenAccessRepository: TokenAccessDetails.AccessibleRepository { + TokenAccessDetails.AccessibleRepository( + id: id, + fullName: fullName, + accessLevel: accessLevel + ) + } + + private var accessLevel: TokenAccessDetails.AccessibleRepository.AccessLevel { + guard let permissions else { return .read } + if permissions.admin { return .admin } + if permissions.push { return .write } + return .read + } + } + + struct RepositoryPermissions: Codable, Sendable { + let admin: Bool + let push: Bool + let pull: Bool + } +} diff --git a/GitHubCommandCenter/Services/GitHubRESTClient.swift b/GitHubCommandCenter/Services/GitHubRESTClient.swift new file mode 100644 index 0000000..d0f5673 --- /dev/null +++ b/GitHubCommandCenter/Services/GitHubRESTClient.swift @@ -0,0 +1,885 @@ +import Foundation +import OSLog + +// swiftlint:disable file_length type_body_length + +actor GitHubRESTClient: GitHubDataSource { + private static let searchResultCap = 1_000 + private static let searchResultsMaxPageLimit = 100 + private static let reviewMaxPageLimit = 10 + private static let checkRunsMaxPageLimit = 10 + private static let logger = Logger(subsystem: Log.subsystem, category: "GitHubRESTClient") + private static let searchCapWarningMessage = + "Showing the first 1,000 matching pull requests due to GitHub search limits." + private static let incompleteSearchWarningMessage = + "GitHub search results are temporarily incomplete. " + + "The token was saved, but full PR status access could not be verified yet." + private static let deferredValidationWarningMessage = + "Token saved, but full PR status access could not be verified yet. " + + "The warning will clear after a successful poll loads PR status data." + + enum TokenValidationResult: Equatable, Sendable { + case verified(username: String) + case warning(username: String, message: String) + } + + private enum RequestError: Error { + case app(AppError) + case statusCode(Int) + } + + private struct CachedResponseEntry { + let data: Data + let etag: String + } + + private struct SearchItemsResult { + let items: [SearchItem] + let warningMessage: String? + } + + private struct LRUCache { + private final class Node { + let key: Key + weak var previous: Node? + var next: Node? + + init(key: Key) { + self.key = key + } + } + + private var values: [Key: Value] = [:] + private var nodes: [Key: Node] = [:] + private var head: Node? + private var tail: Node? + + var count: Int { + values.count + } + + subscript(key: Key) -> Value? { + mutating get { + guard let value = values[key] else { return nil } + touch(key) + return value + } + set { + switch newValue { + case .some(let value): + values[key] = value + touch(key) + case .none: + _ = removeValue(forKey: key) + } + } + } + + @discardableResult + mutating func removeValue(forKey key: Key) -> Value? { + guard let value = values.removeValue(forKey: key) else { return nil } + if let node = nodes.removeValue(forKey: key) { + remove(node) + } + return value + } + + mutating func trim(to maxEntries: Int) { + guard maxEntries >= 0 else { return } + while values.count > maxEntries, let oldestKey = head?.key { + _ = removeValue(forKey: oldestKey) + } + } + + private mutating func touch(_ key: Key) { + if let node = nodes[key] { + moveToTail(node) + return + } + + let node = Node(key: key) + nodes[key] = node + append(node) + } + + private mutating func append(_ node: Node) { + node.previous = tail + node.next = nil + + if let tail { + tail.next = node + } else { + head = node + } + + tail = node + } + + private mutating func moveToTail(_ node: Node) { + guard tail !== node else { return } + remove(node) + append(node) + } + + private mutating func remove(_ node: Node) { + let previous = node.previous + let next = node.next + + previous?.next = next + next?.previous = previous + + if head === node { + head = next + } + + if tail === node { + tail = previous + } + + node.previous = nil + node.next = nil + } + } + + private let token: String + private let session: URLSession + private let mergeabilityRetryDelayNanoseconds: UInt64 + private let maxCacheEntries: Int + private var responseCache = LRUCache() + + init( + token: String, + session: URLSession = .shared, + mergeabilityRetryDelayNanoseconds: UInt64 = 2_500_000_000, + maxCacheEntries: Int = 500 + ) { + self.token = token + self.session = session + self.mergeabilityRetryDelayNanoseconds = mergeabilityRetryDelayNanoseconds + self.maxCacheEntries = maxCacheEntries + } + + // MARK: - GitHubDataSource + + func validateToken() async throws -> String { + let data = try await get("/user") + let user = try decode(GitHubUser.self, from: data) + return user.login + } + + func validateTokenForAppAccess() async throws -> TokenValidationResult { + let username = try await validateToken() + let response = try await searchOpenPRsPage( + username: username, + perPage: 1, + page: 1, + treatIncompleteResultsAsError: false + ) + + if response.incompleteResults { + return .warning( + username: username, + message: Self.incompleteSearchWarningMessage + ) + } + + guard let firstPR = response.items.first else { + return .warning( + username: username, + message: Self.deferredValidationWarningMessage + ) + } + + try await probePRStatusAccess(using: firstPR) + return .verified(username: username) + } + + func fetchTokenAccessDetails() async throws -> TokenAccessDetails { + let userResponse = try await getResponse("/user") + let user = try decode(GitHubUser.self, from: userResponse.data) + + return TokenAccessDetails( + username: user.login, + oauthScopes: parseOAuthScopes(from: userResponse.response), + accessibleRepositories: try await fetchAccessibleRepositories() + ) + } + + func fetchAllPRStates(username: String) async throws -> PRFetchResult { + let searchResult = try await searchOpenPRs(username: username) + let searchItems = searchResult.items + let maxConcurrency = 8 + let prs = try await withThrowingTaskGroup(of: PRState?.self) { group in + var results: [PRState] = [] + var index = 0 + + func addNextTask() { + guard index < searchItems.count else { return } + let item = searchItems[index] + index += 1 + group.addTask { [self] in + try await buildPRState(from: item, username: username) + } + } + + for _ in 0.. [PRState] { + guard !prs.isEmpty else { return [] } + + return await withTaskGroup(of: PRState?.self) { group in + for pr in prs { + group.addTask { [self] in + await confirmedClosedPR(pr) + } + } + + var confirmedClosed: [PRState] = [] + for await pr in group { + if let pr { + confirmedClosed.append(pr) + } + } + return confirmedClosed + } + } + + // MARK: - Search + + private func searchOpenPRsPage( + username: String, + perPage: Int, + page: Int, + treatIncompleteResultsAsError: Bool = true + ) async throws -> SearchResponse { + let query = "is:pr is:open involves:\(username)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + throw AppError.networkError + } + let path = "/search/issues?q=\(encoded)&per_page=\(perPage)&page=\(page)&sort=updated&order=desc" + let data = try await get(path) + let response = try decode(SearchResponse.self, from: data) + if treatIncompleteResultsAsError, response.incompleteResults { + throw AppError.incompleteSearchResults + } + return response + } + + private func searchOpenPRs(username: String, perPage: Int = 100) async throws -> SearchItemsResult { + var allItems: [SearchItem] = [] + var page = 1 + var warningMessage: String? + + while true { + guard page <= Self.searchResultsMaxPageLimit else { + throw AppError.paginationLimitExceeded + } + let response = try await searchOpenPRsPage(username: username, perPage: perPage, page: page) + if response.totalCount > Self.searchResultCap { + warningMessage = Self.searchCapWarningMessage + } + + let remainingCapacity = Self.searchResultCap - allItems.count + guard remainingCapacity > 0 else { break } + + allItems.append(contentsOf: response.items.prefix(remainingCapacity)) + if allItems.count >= min(response.totalCount, Self.searchResultCap) || response.items.count < perPage { + break + } + page += 1 + } + + return SearchItemsResult(items: allItems, warningMessage: warningMessage) + } + + // MARK: - PR state building + + private func buildPRState(from item: SearchItem, username: String) async throws -> PRState? { + guard let repoFullName = extractRepoName(from: item.repositoryUrl), + let (owner, repo) = splitRepoFullName(repoFullName) + else { return nil } + let number = item.number + + // Fetch PR detail, reviews, and check runs concurrently + async let detailTask = fetchPRDetail(owner: owner, repo: repo, number: number) + async let reviewsTask = fetchReviews(owner: owner, repo: repo, number: number) + let (detail, reviews) = try await (detailTask, reviewsTask) + + async let checkRunsTask = fetchCheckRuns(owner: owner, repo: repo, sha: detail.head.sha) + async let commitStatusesTask = fetchCommitStatuses(owner: owner, repo: repo, sha: detail.head.sha) + let (checkRuns, commitStatuses) = try await (checkRunsTask, commitStatusesTask) + + let assignment = PRState.Assignment( + createdByMe: detail.user.login == username, + reviewRequestedFromMe: detail.requestedReviewers.contains { $0.login == username }, + assignedToMe: detail.assignees.contains { $0.login == username } + ) + + let parsedUpdatedAt = Self.parseGitHubDate(item.updatedAt) + let updatedAt: Date + if let parsedUpdatedAt { + updatedAt = parsedUpdatedAt + } else { + Self.logger.warning( + """ + Failed to parse updated_at for \(repoFullName, privacy: .public)#\(number, privacy: .public): \ + \(item.updatedAt, privacy: .public) + """ + ) + updatedAt = .distantPast + } + + guard let prURL = URL(string: item.htmlUrl) else { return nil } + + return PRState( + number: number, + title: item.title, + repoFullName: repoFullName, + url: prURL, + headSHA: detail.head.sha, + draftStatus: item.draft == true ? .draft : .ready, + ciStatus: buildCIStatus( + from: checkRuns, + commitStatuses: commitStatuses.statuses, + combinedStatusState: commitStatuses.state + ), + reviewStatus: buildReviewStatus(reviews: reviews, requestedReviewers: detail.requestedReviewers), + mergeStatus: buildMergeStatus(from: detail.mergeableState), + assignment: assignment, + updatedAt: updatedAt + ) + } + + private func probePRStatusAccess(using item: SearchItem) async throws { + guard let repoFullName = extractRepoName(from: item.repositoryUrl), + let (owner, repo) = splitRepoFullName(repoFullName) + else { + return + } + + let detail = try await fetchPRDetail(owner: owner, repo: repo, number: item.number) + _ = try await fetchCommitStatuses(owner: owner, repo: repo, sha: detail.head.sha) + } + + private func fetchPRDetail( + owner: String, + repo: String, + number: Int, + retryForMergeability: Bool = true + ) async throws -> PRDetail { + let data = try await get("/repos/\(owner)/\(repo)/pulls/\(number)") + var detail = try decode(PRDetail.self, from: data) + + // GitHub's mergeable field may be null or "unknown" while being computed — retry once after a delay + if retryForMergeability && (detail.mergeableState == nil || detail.mergeableState == "unknown") { + try await Task.sleep(nanoseconds: mergeabilityRetryDelayNanoseconds) + let retryData = try await get("/repos/\(owner)/\(repo)/pulls/\(number)") + detail = try decode(PRDetail.self, from: retryData) + } + return detail + } + + private func fetchReviews(owner: String, repo: String, number: Int) async throws -> [Review] { + let perPage = 100 + var page = 1 + var reviews: [Review] = [] + + while true { + // Cap pagination for long-lived PRs so review fetching stays bounded. + guard page <= Self.reviewMaxPageLimit else { + return reviews + } + let data = try await get("/repos/\(owner)/\(repo)/pulls/\(number)/reviews?per_page=\(perPage)&page=\(page)") + let pageReviews = try decode([Review].self, from: data) + reviews.append(contentsOf: pageReviews) + + if pageReviews.count < perPage { + return reviews + } + + page += 1 + } + } + + private func fetchCheckRuns(owner: String, repo: String, sha: String) async throws -> [CheckRun] { + let perPage = 100 + var page = 1 + var allCheckRuns: [CheckRun] = [] + + while page <= Self.checkRunsMaxPageLimit { + let data: Data + do { + data = try await requestData( + "/repos/\(owner)/\(repo)/commits/\(sha)/check-runs?per_page=\(perPage)&page=\(page)" + ) + } catch RequestError.statusCode(let statusCode) + where statusCode == 401 || statusCode == 403 || statusCode == 404 + { + let repository = "\(owner)/\(repo)" + Self.logger.info( + """ + fetchCheckRuns falling back after check-runs request returned \ + \(statusCode, privacy: .public) for \(repository, privacy: .public) \ + @\(sha, privacy: .public) + """ + ) + return allCheckRuns + } catch { + throw mapRequestError(error) + } + + let response = try decode(CheckRunsResponse.self, from: data) + allCheckRuns.append(contentsOf: response.checkRuns) + + if allCheckRuns.count >= response.totalCount || response.checkRuns.count < perPage { + return allCheckRuns + } + + page += 1 + } + + return allCheckRuns + } + + private func fetchCommitStatuses(owner: String, repo: String, sha: String) async throws -> CombinedStatusResponse { + let data = try await get("/repos/\(owner)/\(repo)/commits/\(sha)/status") + return try decode(CombinedStatusResponse.self, from: data) + } + + private func confirmedClosedPR(_ pr: PRState) async -> PRState? { + guard let (owner, repo) = splitRepoFullName(pr.repoFullName) else { + return nil + } + + do { + let detail = try await fetchPRDetail( + owner: owner, + repo: repo, + number: pr.number, + retryForMergeability: false + ) + return detail.state == "closed" ? pr : nil + } catch { + return nil + } + } + + private func fetchAccessibleRepositories() async throws -> [TokenAccessDetails.AccessibleRepository] { + let perPage = 100 + let maxRepositories = 1_000 + let maxPages = maxRepositories / perPage + var page = 1 + var repositories: [TokenAccessDetails.AccessibleRepository] = [] + + while page <= maxPages { + let path = + "/user/repos?affiliation=owner,collaborator,organization_member&per_page=\(perPage)&page=\(page)" + let data = try await get(path) + let pageRepositories = try decode([AccessibleRepositoryResponse].self, from: data) + repositories.append(contentsOf: pageRepositories.map(\.tokenAccessRepository)) + + if repositories.count >= maxRepositories { + return Array(repositories.prefix(maxRepositories)) + } + + if pageRepositories.count < perPage { + return repositories + } + + page += 1 + } + + return repositories + } + + // MARK: - State builders + + private func buildCIStatus( + from checkRuns: [CheckRun], + commitStatuses: [CommitStatus], + combinedStatusState: String + ) -> PRState.CIStatus { + let uniqueChecks = aggregatedCIChecks(from: checkRuns, commitStatuses: commitStatuses) + guard !uniqueChecks.isEmpty else { return .none } + + let failingChecks = uniqueChecks.compactMap(\.failingCheck) + if !failingChecks.isEmpty { + return .failing(checks: failingChecks, totalChecks: uniqueChecks.count) + } + + if uniqueChecks.contains(where: \.isPending) || combinedStatusState == "pending" { + return .pending + } + + return .passing + } + + private func aggregatedCIChecks( + from checkRuns: [CheckRun], + commitStatuses: [CommitStatus] + ) -> [AggregatedCICheck] { + let latestStatuses = deduplicatedCommitStatuses(commitStatuses) + var orderedChecks: [AggregatedCICheck] = [] + var indexByIdentifier: [String: Int] = [:] + + func merge(_ check: AggregatedCICheck) { + if let index = indexByIdentifier[check.identifier] { + orderedChecks[index] = orderedChecks[index].merged(with: check) + } else { + indexByIdentifier[check.identifier] = orderedChecks.count + orderedChecks.append(check) + } + } + + for run in checkRuns { + merge( + AggregatedCICheck( + identifier: ciCheckIdentifier(name: run.name, urlString: run.htmlUrl), + failingCheck: failingCheck(for: run), + isPending: run.status != "completed" + ) + ) + } + + for status in latestStatuses { + merge( + AggregatedCICheck( + identifier: ciCheckIdentifier(name: status.context, urlString: status.targetUrl), + failingCheck: failingCheck(for: status), + isPending: status.state == "pending" + ) + ) + } + + return orderedChecks + } + + private func buildReviewStatus(reviews: [Review], requestedReviewers: [GitHubUser]) -> PRState.ReviewStatus { + // Latest review per reviewer. Keep DISMISSED so it clears older approvals/changes requests. + var latestByReviewer: [String: Review] = [:] + for review in reviews where review.state != "COMMENTED" { + latestByReviewer[review.user.login] = review + } + + let states = latestByReviewer.values.map(\.state) + + if states.contains("CHANGES_REQUESTED") { + let who = latestByReviewer.values + .filter { $0.state == "CHANGES_REQUESTED" } + .map(\.user.login) + return .changesRequested(by: who) + } + + if !requestedReviewers.isEmpty { + // Outstanding review requests should keep the PR in a "waiting for review" + // state even if it already has older approvals. + return .requested(by: requestedReviewers.map(\.login)) + } + + if states.contains("APPROVED") { + let who = latestByReviewer.values + .filter { $0.state == "APPROVED" } + .map(\.user.login) + return .approved(by: who) + } + + return .none + } + + private func buildMergeStatus(from mergeableState: String?) -> PRState.MergeStatus { + switch mergeableState { + case "clean": return .ready + case "dirty": return .conflicts + case "blocked": return .blocked + case "behind": return .behind + case "has_hooks": return .blocked + case "unstable": return .ready // mergeable despite failing checks; CI tracked separately + default: return .pending // "unknown", nil, or any future undocumented value + } + } + + private func deduplicatedCommitStatuses(_ statuses: [CommitStatus]) -> [CommitStatus] { + var seenContexts = Set() + var latestStatuses: [CommitStatus] = [] + + for status in statuses { + guard seenContexts.insert(status.context).inserted else { continue } + latestStatuses.append(status) + } + + return latestStatuses + } + + private func failingCheck(for run: CheckRun) -> PRState.FailingCheck? { + guard run.status == "completed", + run.conclusion != "success", + run.conclusion != "skipped", + run.conclusion != "neutral" + else { + return nil + } + + return PRState.FailingCheck( + name: run.name, + conclusion: run.conclusion ?? "failure", + url: run.htmlUrl.flatMap(URL.init(string:)) + ) + } + + private func failingCheck(for status: CommitStatus) -> PRState.FailingCheck? { + guard ["error", "failure"].contains(status.state) else { + return nil + } + + return PRState.FailingCheck( + name: status.context, + conclusion: status.state, + url: status.targetUrl.flatMap(URL.init(string:)) + ) + } + + private func ciCheckIdentifier(name: String, urlString: String?) -> String { + if let urlString, + let url = URL(string: urlString)?.absoluteString.lowercased(), + !url.isEmpty + { + return "url:\(url)" + } + + return "name:\(normalizedCheckName(name))" + } + + private func normalizedCheckName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + // MARK: - HTTP + + private func get( + _ path: String, + allowRetryWithoutETag: Bool = true + ) async throws -> Data { + do { + return try await requestData(path, allowRetryWithoutETag: allowRetryWithoutETag) + } catch { + throw mapRequestError(error) + } + } + + private func getResponse(_ path: String) async throws -> HTTPDataResponse { + do { + return try await requestResponse(path) + } catch { + throw mapRequestError(error) + } + } + + // MARK: - Request infrastructure + + private func buildRequest(_ path: String) throws -> URLRequest { + guard let url = URL(string: "https://api.github.com\(path)") else { + throw RequestError.app(.networkError) + } + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + return request + } + + private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw RequestError.app(.networkError) + } + guard let http = response as? HTTPURLResponse else { + throw RequestError.app(.networkError) + } + return (data, http) + } + + private func checkForErrors(data: Data, response: HTTPURLResponse) throws { + switch response.statusCode { + case 200...299, 304: + return + case 401: + throw RequestError.statusCode(401) + case 403: + if isRateLimitedResponse(data, response) { + throw RequestError.app(.rateLimitExceeded(.init(resetAt: rateLimitResetDate(from: response)))) + } + throw RequestError.statusCode(403) + case 404: + throw RequestError.statusCode(404) + case 429: + throw RequestError.app(.rateLimitExceeded(.init(resetAt: rateLimitResetDate(from: response)))) + case 500...599: + throw RequestError.app(.serverError(statusCode: response.statusCode)) + default: + throw RequestError.app(.networkError) + } + } + + private func requestData( + _ path: String, + allowRetryWithoutETag: Bool = true + ) async throws -> Data { + var request = try buildRequest(path) + guard let url = request.url else { throw RequestError.app(.networkError) } + + if let cachedEntry = responseCache[url] { + request.setValue(cachedEntry.etag, forHTTPHeaderField: "If-None-Match") + } + + let (data, http) = try await performRequest(request) + try checkForErrors(data: data, response: http) + + switch http.statusCode { + case 304: + if let cachedEntry = responseCache[url] { return cachedEntry.data } + guard allowRetryWithoutETag else { throw RequestError.app(.networkError) } + + let previousEntry = responseCache.removeValue(forKey: url) + do { + return try await requestData(path, allowRetryWithoutETag: false) + } catch { + if let previousEntry, responseCache[url] == nil { + responseCache[url] = previousEntry + } + throw error + } + default: + if let etag = http.value(forHTTPHeaderField: "ETag") { + responseCache[url] = CachedResponseEntry(data: data, etag: etag) + evictCacheIfNeeded() + } + return data + } + } + + private func evictCacheIfNeeded() { + guard responseCache.count > maxCacheEntries else { return } + let trimmedTarget = bufferedCacheTrimTarget() + guard trimmedTarget < responseCache.count else { return } + responseCache.trim(to: trimmedTarget) + } + + private func bufferedCacheTrimTarget() -> Int { + guard maxCacheEntries > 2 else { return maxCacheEntries } + return max(2, Int(Double(maxCacheEntries) * 0.9)) + } + + private func requestResponse(_ path: String) async throws -> HTTPDataResponse { + let request = try buildRequest(path) + let (data, http) = try await performRequest(request) + try checkForErrors(data: data, response: http) + return HTTPDataResponse(data: data, response: http) + } + + private func mapRequestError(_ error: Error) -> Error { + switch error { + case let requestError as RequestError: + switch requestError { + case .app(let appError): + return appError + case .statusCode(401), .statusCode(403): + return AppError.authError + case .statusCode: + return AppError.networkError + } + default: + return error + } + } + + private func rateLimitResetDate(from response: HTTPURLResponse) -> Date { + if let ts = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(TimeInterval.init) { + return Date(timeIntervalSince1970: ts) + } + return Date().addingTimeInterval(60) + } + + private func isRateLimitedResponse(_ data: Data, _ response: HTTPURLResponse) -> Bool { + if response.value(forHTTPHeaderField: "Retry-After") != nil { + return true + } + + if response.value(forHTTPHeaderField: "X-RateLimit-Remaining") == "0" { + return true + } + + guard let apiError = try? decode(APIErrorResponse.self, from: data) else { + return false + } + + return apiError.message.range( + of: "rate limit", + options: [.caseInsensitive, .diacriticInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ) != nil + } + + private func parseOAuthScopes(from response: HTTPURLResponse) -> [String] { + response.value(forHTTPHeaderField: "X-OAuth-Scopes")? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } ?? [] + } + + private func extractRepoName(from repositoryURL: String) -> String? { + guard let url = URL(string: repositoryURL) else { return nil } + let components = url.pathComponents + guard let reposIdx = components.firstIndex(of: "repos"), + reposIdx + 2 < components.count + else { return nil } + return "\(components[reposIdx + 1])/\(components[reposIdx + 2])" + } + + private func splitRepoFullName(_ repoFullName: String) -> (String, String)? { + let parts = repoFullName.split(separator: "/", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return nil } + return (parts[0], parts[1]) + } + + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let internetDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static func parseGitHubDate(_ value: String) -> Date? { + fractionalDateFormatter.date(from: value) ?? internetDateFormatter.date(from: value) + } + + private static let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + private func decode(_ type: T.Type, from data: Data) throws -> T { + try Self.jsonDecoder.decode(type, from: data) + } +} +// swiftlint:enable file_length type_body_length diff --git a/GitHubCommandCenter/Services/KeychainService.swift b/GitHubCommandCenter/Services/KeychainService.swift new file mode 100644 index 0000000..afca539 --- /dev/null +++ b/GitHubCommandCenter/Services/KeychainService.swift @@ -0,0 +1,96 @@ +import Foundation +import Security + +final class KeychainService: Sendable { + static let shared = KeychainService() + static let tokenAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + private let serviceName: String + private let accountName: String + + init(serviceName: String = "com.githubcommandcenter", accountName: String = "github-pat") { + self.serviceName = serviceName + self.accountName = accountName + } + + func baseQuery() -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: serviceName, + kSecAttrAccount: accountName, + kSecAttrAccessible: Self.tokenAccessibility, + ] + } + + func saveToken(_ token: String) throws { + let data = Data(token.utf8) + let query = baseQuery() + + let addQuery = query.merging([kSecValueData: data] as [CFString: Any]) { _, new in new } + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus == errSecDuplicateItem { + let updateAttributes: [CFString: Any] = [kSecValueData: data] + let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeychainError.saveFailed(status: updateStatus) + } + return + } + + guard addStatus == errSecSuccess else { + throw KeychainError.saveFailed(status: addStatus) + } + } + + func loadToken() throws -> String? { + let query = baseQuery().merging( + [ + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] as [CFString: Any] + ) { _, new in new } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { + throw KeychainError.loadFailed(status: errSecInternalError) + } + guard let token = String(data: data, encoding: .utf8) else { + throw KeychainError.decodingFailed + } + return token + case errSecItemNotFound: + return nil + default: + throw KeychainError.loadFailed(status: status) + } + } + + func deleteToken() throws { + let query = baseQuery() + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status: status) + } + } + + enum KeychainError: LocalizedError, Equatable { + case decodingFailed + case saveFailed(status: OSStatus) + case loadFailed(status: OSStatus) + case deleteFailed(status: OSStatus) + + var errorDescription: String? { + switch self { + case .decodingFailed: + return "Stored token data could not be decoded as UTF-8." + case .saveFailed(let status): return "Failed to save token (OSStatus \(status))" + case .loadFailed(let status): return "Failed to load token (OSStatus \(status))" + case .deleteFailed(let status): return "Failed to delete token (OSStatus \(status))" + } + } + } +} diff --git a/GitHubCommandCenter/Services/NotificationService.swift b/GitHubCommandCenter/Services/NotificationService.swift new file mode 100644 index 0000000..37418c1 --- /dev/null +++ b/GitHubCommandCenter/Services/NotificationService.swift @@ -0,0 +1,197 @@ +import Foundation +import OSLog +import UserNotifications + +final class NotificationService { + static let shared = NotificationService() + private static let pullRequestUpdatesThreadIdentifier = "pull_request_updates" + private static let logger = Logger(subsystem: Log.subsystem, category: "NotificationService") + private let stateQueue = DispatchQueue(label: "NotificationService.state") + private var storedNotificationHandler: ((String, String) -> Void)? + private var storedNotificationScheduler: + ( + (UNNotificationRequest, @escaping (Error?) -> Void) -> Void + )? + private var storedAuthorizationRequester: ((UNAuthorizationOptions) async throws -> Bool)? + private var storedNotificationDeliveryErrorHandler: ((Error) -> Void)? + + // Injected in tests to capture fired notifications without UNUserNotificationCenter. + var notificationHandler: ((String, String) -> Void)? { // (title, body) + get { stateQueue.sync { storedNotificationHandler } } + set { stateQueue.sync { storedNotificationHandler = newValue } } + } + + var notificationScheduler: ((UNNotificationRequest, @escaping (Error?) -> Void) -> Void)? { + get { stateQueue.sync { storedNotificationScheduler } } + set { stateQueue.sync { storedNotificationScheduler = newValue } } + } + + var notificationDeliveryErrorHandler: ((Error) -> Void)? { + get { stateQueue.sync { storedNotificationDeliveryErrorHandler } } + set { stateQueue.sync { storedNotificationDeliveryErrorHandler = newValue } } + } + + var authorizationRequester: ((UNAuthorizationOptions) async throws -> Bool)? { + get { stateQueue.sync { storedAuthorizationRequester } } + set { stateQueue.sync { storedAuthorizationRequester = newValue } } + } + + init() {} + + @discardableResult + func requestPermission() async -> Bool { + do { + let requester = stateQueue.sync { storedAuthorizationRequester } + let granted: Bool + if let requester { + granted = try await requester([.alert, .sound, .badge]) + } else { + granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + } + + if granted { + Self.logger.info("Notification permission granted") + } else { + Self.logger.info("Notification permission denied") + } + return granted + } catch { + let errorDescription = error.localizedDescription + Self.logger.error( + "Notification permission request failed: \(errorDescription, privacy: .public)" + ) + return false + } + } + + // Detects state transitions between two poll cycles and fires notifications. + func checkTransitions(from oldPRs: [PRState], to newPRs: [PRState], disappeared: [PRState]) { + let oldByID = Self.prsByID(oldPRs) + + for new in newPRs { + guard let old = oldByID[new.id] else { continue } + + // CI: any non-failing state → failing (only on your PRs) + if new.assignment.createdByMe { + let wasFailing: Bool + if case .failing = old.ciStatus { + wasFailing = true + } else { + wasFailing = false + } + if !wasFailing, case .failing(let checks, _) = new.ciStatus { + let check = checks.first?.name ?? "a check" + fire( + title: "CI Failing — #\(new.number)", + body: "\(check) failed on \"\(new.title)\"" + ) + } + + if case .changesRequested(let reviewers) = new.reviewStatus { + notifyChangesRequestedIfNeeded(from: old.reviewStatus, reviewers: reviewers, pr: new) + } + + if case .approved(let reviewers) = new.reviewStatus { + notifyApprovalIfNeeded(from: old.reviewStatus, reviewers: reviewers, pr: new) + } + + // Merge conflicts detected (on your PRs) + if new.mergeStatus == .conflicts && old.mergeStatus != .conflicts { + fire( + title: "Merge Conflict — #\(new.number)", + body: "Conflicts detected in \"\(new.title)\"" + ) + } + } + + // Review requested from you (any PR) + if new.assignment.reviewRequestedFromMe && !old.assignment.reviewRequestedFromMe { + fire( + title: "Review Requested — #\(new.number)", + body: "You've been asked to review \"\(new.title)\"" + ) + } + } + + // PR merged or closed + for pr in disappeared where pr.assignment.createdByMe { + fire( + title: "PR Closed — #\(pr.number)", + body: "\"\(pr.title)\" was merged or closed" + ) + } + } + + private func notifyChangesRequestedIfNeeded( + from oldStatus: PRState.ReviewStatus, + reviewers: [String], + pr: PRState + ) { + if case .changesRequested = oldStatus { return } + + let who = reviewers.first.map { "@\($0)" } ?? "A reviewer" + fire( + title: "Changes Requested — #\(pr.number)", + body: "\(who) requested changes on \"\(pr.title)\"" + ) + } + + private func notifyApprovalIfNeeded( + from oldStatus: PRState.ReviewStatus, + reviewers: [String], + pr: PRState + ) { + if case .approved = oldStatus { return } + + let who = reviewers.first.map { "@\($0)" } ?? "A reviewer" + fire( + title: "PR Approved — #\(pr.number)", + body: "\(who) approved \"\(pr.title)\"" + ) + } + + static func prsByID(_ prs: [PRState]) -> [String: PRState] { + Dictionary(prs.map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest }) + } + + private func fire(title: String, body: String) { + let state = stateQueue.sync { + ( + handler: storedNotificationHandler, + scheduler: storedNotificationScheduler, + errorHandler: storedNotificationDeliveryErrorHandler + ) + } + + if let handler = state.handler { + handler(title, body) + return + } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.threadIdentifier = Self.pullRequestUpdatesThreadIdentifier + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + let submitRequest = + state.scheduler + ?? { request, completion in + UNUserNotificationCenter.current().add(request, withCompletionHandler: completion) + } + + submitRequest(request) { error in + guard let error else { return } + let errorDescription = error.localizedDescription + Self.logger.error( + "Failed to deliver notification: \(errorDescription, privacy: .public)" + ) + state.errorHandler?(error) + } + } +} diff --git a/GitHubCommandCenter/Services/PollingEngine.swift b/GitHubCommandCenter/Services/PollingEngine.swift new file mode 100644 index 0000000..b5cfe3c --- /dev/null +++ b/GitHubCommandCenter/Services/PollingEngine.swift @@ -0,0 +1,277 @@ +import Foundation +import OSLog + +@MainActor +final class PollingEngine: PollingControlling { + enum PollOutcome: Equatable { + case useRegularInterval + case continueImmediately + case stopLoop + } + + private let appState: AppState + private let recentlyClosedClearDelay: TimeInterval + private let loadToken: @Sendable () throws -> String? + private let sleep: @Sendable (TimeInterval) async -> Void + private static let logger = Logger(subsystem: Log.subsystem, category: "PollingEngine") + private(set) var client: (any GitHubDataSource)? + private var pollingTask: Task? + private var clearRecentlyClosedTask: Task? + private var clearRecentlyClosedTaskID: UUID? + private var previousPRs: [PRState] = [] + private var consecutiveFailures = 0 + + init( + appState: AppState, + dataSource: (any GitHubDataSource)? = nil, + recentlyClosedClearDelay: TimeInterval = 5, + loadToken: @escaping @Sendable () throws -> String? = { try KeychainService.shared.loadToken() }, + sleep: @escaping @Sendable (TimeInterval) async -> Void = { interval in + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + ) { + self.appState = appState + self.client = dataSource + self.recentlyClosedClearDelay = recentlyClosedClearDelay + self.loadToken = loadToken + self.sleep = sleep + } + + func start() { + guard pollingTask == nil else { return } + pollingTask = Task { await runLoop() } + } + + func stop() { + pollingTask?.cancel() + pollingTask = nil + } + + func reset() { + clearRecentlyClosedTask?.cancel() + clearRecentlyClosedTask = nil + clearRecentlyClosedTaskID = nil + previousPRs = [] + consecutiveFailures = 0 + } + + func forceRefresh() { + pollingTask?.cancel() + pollingTask = Task { + let outcome = await poll() + guard outcome != .stopLoop else { return } + await runLoop() + } + } + + var pollInterval: TimeInterval { + switch appState.prs.count { + case 0..<20: + return 60 + case 20..<40: + return 120 + default: + return 300 + } + } + + private func runLoop() async { + pollingLoop: while !Task.isCancelled { + let outcome = await poll() + guard !Task.isCancelled else { break } + + switch outcome { + case .useRegularInterval: + let interval = pollInterval + await sleep(interval) + case .continueImmediately: + continue pollingLoop + case .stopLoop: + break pollingLoop + } + } + } + + @discardableResult + func poll() async -> PollOutcome { + var context = await appState.currentPollContext() + context.panel.isStale = AppState.staleStatus(lastUpdated: context.panel.lastUpdated) + + if client == nil { + do { + let token = try loadToken() + guard let token, !token.isEmpty else { + await handleAuthFailure(status: .noToken, error: .noToken, context: &context) + return .stopLoop + } + client = GitHubRESTClient(token: token) + } catch let error as KeychainService.KeychainError { + let errorDescription = error.localizedDescription + Self.logger.error( + "Failed to load token from keychain before polling: \(errorDescription, privacy: .public)" + ) + await handleAuthFailure(status: .unknown, error: .keychainError, context: &context) + return .stopLoop + } catch { + Self.logger.error( + "Failed to load token from keychain before polling: \(String(describing: error), privacy: .public)" + ) + await handleAuthFailure(status: .failed, error: .authError, context: &context) + return .stopLoop + } + } + + guard let client else { return .stopLoop } + + do { + let username: String + if case .authenticated(let authenticatedUsername) = context.auth.authenticationStatus { + username = authenticatedUsername + } else { + username = try await client.validateToken() + context.auth.authenticationStatus = .authenticated(username: username) + } + + let fetchResult = try await client.fetchAllPRStates(username: username) + let newPRs = fetchResult.prs + let newIDs = Set(newPRs.map(\.id)) + let disappeared = previousPRs.filter { !newIDs.contains($0.id) } + let recentlyClosed = await client.resolveDisappearedPRs(disappeared) + + if !previousPRs.isEmpty { + NotificationService.shared.checkTransitions( + from: previousPRs, + to: newPRs, + disappeared: recentlyClosed + ) + } + + context.panel.triageSnapshot = .build(from: newPRs) + context.panel.recentlyClosedPRs = recentlyClosed + context.panel.lastUpdated = Date() + context.panel.isLoading = false + context.panel.warningMessage = fetchResult.warningMessage + context.panel.error = nil + context.panel.isStale = false + + if !newPRs.isEmpty { + context.auth.tokenValidationWarningMessage = nil + } + + if !recentlyClosed.isEmpty { + scheduleRecentlyClosedClearTask() + } + + previousPRs = newPRs + consecutiveFailures = 0 + await appState.applyPollSnapshot(.init(panel: context.panel, auth: context.auth)) + return .useRegularInterval + + } catch let error as AppError { + switch error { + case .authError: + await handleAuthFailure(status: .failed, error: .authError, context: &context) + return .stopLoop + case .keychainError: + await handleAuthFailure(status: .unknown, error: .keychainError, context: &context) + return .stopLoop + case .noToken: + await handleAuthFailure(status: .noToken, error: .noToken, context: &context) + return .stopLoop + case .rateLimitExceeded(let rateLimit): + return await handleRateLimitExceeded(rateLimit, context: &context) + case .incompleteSearchResults, .paginationLimitExceeded: + return await handleRegularPollError(error, context: &context) + case .networkError, .serverError: + return await handleImmediateRetryError(error, context: &context) + } + } catch { + return await handleImmediateRetryError(.networkError, context: &context) + } + } + + private func handleAuthFailure( + status: AppState.AuthStatus, + error: AppError, + context: inout AppState.PollContext + ) async { + context.auth.authenticationStatus = status + context.panel.error = error + context.panel.isLoading = false + context.panel.isStale = AppState.staleStatus(lastUpdated: context.panel.lastUpdated) + await appState.applyPollSnapshot(.init(panel: context.panel, auth: context.auth)) + stop() + } + + private func handleRateLimitExceeded( + _ rateLimit: AppError.RateLimitContext, + context: inout AppState.PollContext + ) async -> PollOutcome { + consecutiveFailures = 0 + await applyPollingError(.rateLimitExceeded(rateLimit), context: &context) + + let delay = min(max(rateLimit.resetAt.timeIntervalSinceNow + 5, 60), 3600) + stop() + pollingTask = Task { + await self.sleep(delay) + guard !Task.isCancelled else { return } + + var recoveryContext = await self.appState.currentPollContext() + recoveryContext.panel.error = nil + recoveryContext.panel.isStale = AppState.staleStatus(lastUpdated: recoveryContext.panel.lastUpdated) + await self.appState.applyPollSnapshot(.init(panel: recoveryContext.panel, auth: recoveryContext.auth)) + await self.runLoop() + } + return .stopLoop + } + + private func handleRegularPollError( + _ error: AppError, + context: inout AppState.PollContext + ) async -> PollOutcome { + consecutiveFailures = 0 + await applyPollingError(error, context: &context) + return .useRegularInterval + } + + private func handleImmediateRetryError( + _ error: AppError, + context: inout AppState.PollContext + ) async -> PollOutcome { + consecutiveFailures += 1 + await applyPollingError(error, context: &context) + + let zeroBasedFailureCount = max(consecutiveFailures - 1, 0) + let backoff = min(pow(2, Double(zeroBasedFailureCount + 1)), 60.0) + await sleep(backoff) + return .continueImmediately + } + + private func applyPollingError( + _ error: AppError, + context: inout AppState.PollContext + ) async { + context.panel.error = error + context.panel.isLoading = false + context.panel.isStale = AppState.staleStatus(lastUpdated: context.panel.lastUpdated) + await appState.applyPollSnapshot(.init(panel: context.panel, auth: context.auth)) + } + + private func scheduleRecentlyClosedClearTask() { + clearRecentlyClosedTask?.cancel() + let taskID = UUID() + clearRecentlyClosedTaskID = taskID + clearRecentlyClosedTask = Task { [weak self] in + guard let self else { return } + + await self.sleep(self.recentlyClosedClearDelay) + guard !Task.isCancelled else { return } + + await self.appState.clearRecentlyClosedPRs() + if self.clearRecentlyClosedTaskID == taskID { + self.clearRecentlyClosedTask = nil + self.clearRecentlyClosedTaskID = nil + } + } + } +} diff --git a/GitHubCommandCenter/Views/MenuBarIconView.swift b/GitHubCommandCenter/Views/MenuBarIconView.swift new file mode 100644 index 0000000..f4bc963 --- /dev/null +++ b/GitHubCommandCenter/Views/MenuBarIconView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct MenuBarIconView: View { + let healthStatus: AppState.HealthStatus + let prCount: Int + let isLoading: Bool + + // Shape-based icons — macOS renders menu bar images as templates (monochrome), + // so we use distinct SF Symbol shapes to convey state without relying on color. + private var symbolName: String { + switch healthStatus { + case .green: return "checkmark.circle" + case .yellow: return "exclamationmark.triangle" + case .red: return "xmark.circle" + } + } + + var body: some View { + HStack(spacing: Theme.Spacing.xxxs) { + if isLoading { + MenuBarLoadingIndicator() + } else { + Image(systemName: symbolName) + .imageScale(.medium) + if prCount > 0 { + Text("\(prCount)") + .font(.system(size: 10, weight: .semibold)) + } + } + } + } +} + +private struct MenuBarLoadingIndicator: View { + @State private var tick = false + + var body: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .imageScale(.medium) + .opacity(tick ? 1.0 : 0.35) + .task { + tick = true + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(700)) + withAnimation(.easeInOut(duration: 0.35)) { + tick.toggle() + } + } + } + } +} + +#Preview("Loading") { + MenuBarIconView(healthStatus: .green, prCount: 0, isLoading: true) + .padding() +} + +#Preview("Green Empty") { + MenuBarIconView(healthStatus: .green, prCount: 0, isLoading: false) + .padding() +} + +#Preview("Green Count") { + MenuBarIconView(healthStatus: .green, prCount: 3, isLoading: false) + .padding() +} + +#Preview("Yellow Empty") { + MenuBarIconView(healthStatus: .yellow, prCount: 0, isLoading: false) + .padding() +} + +#Preview("Yellow Count") { + MenuBarIconView(healthStatus: .yellow, prCount: 2, isLoading: false) + .padding() +} + +#Preview("Red Empty") { + MenuBarIconView(healthStatus: .red, prCount: 0, isLoading: false) + .padding() +} + +#Preview("Red Count") { + MenuBarIconView(healthStatus: .red, prCount: 5, isLoading: false) + .padding() +} diff --git a/GitHubCommandCenter/Views/PRListView.swift b/GitHubCommandCenter/Views/PRListView.swift new file mode 100644 index 0000000..4ddc3a8 --- /dev/null +++ b/GitHubCommandCenter/Views/PRListView.swift @@ -0,0 +1,571 @@ +import SwiftUI + +enum PanelMode { + case pullRequests + case settings + + static let width: CGFloat = 480 + + var headerTitle: String { + switch self { + case .pullRequests: "GitHub Command Center" + case .settings: "Settings" + } + } + + var showsBackButton: Bool { + self == .settings + } + + var showsRefreshRow: Bool { + self == .pullRequests + } + + var showsInlineAppControls: Bool { + self == .settings + } +} + +private enum RowOpacity { + static let active = 1.0 + static let waiting = 0.55 + static let draft = 0.5 + static let recentlyClosed = 0.5 +} + +struct PRListView: View { + @Environment(AppState.self) private var appState + @State private var panelMode: PanelMode + + init(initialPanelMode: PanelMode = .pullRequests) { + _panelMode = State(initialValue: initialPanelMode) + } + + var body: some View { + VStack(spacing: 0) { + headerView + bannerSection + contentView + footerView + } + .frame(width: PanelMode.width) + .background(Theme.Colors.panelBackground) + .animation(.easeInOut(duration: 0.2), value: panelMode) + .onAppear { + appState.startPollingIfNeeded() + } + } + + // MARK: - Header + + private var headerView: some View { + HStack { + if panelMode.showsBackButton { + Button { + panelMode = .pullRequests + } label: { + Image(systemName: "chevron.left") + .imageScale(.small) + .foregroundColor(Theme.Colors.textTertiary) + } + .buttonStyle(.plain) + .help("Back") + } + + VStack(alignment: .leading, spacing: Theme.Spacing.hairline) { + Text(panelMode.headerTitle) + .font(Theme.Fonts.panelTitle) + .foregroundColor(Theme.Colors.textPrimary) + panelSubtitleView + } + Spacer() + + if panelMode == .pullRequests { + Button { + panelMode = .settings + } label: { + Image(systemName: "gear") + .imageScale(.medium) + .foregroundColor(Theme.Colors.textTertiary) + } + .buttonStyle(.plain) + .help("Settings") + } + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.lg) + } + + private var panelSubtitle: String { + switch panelMode { + case .pullRequests: + appState.panelSubtitleText + case .settings: + "" + } + } + + @ViewBuilder + private var panelSubtitleView: some View { + let actionCount = appState.menuBarBadgeCount + if panelMode == .pullRequests && actionCount > 0 { + (Text(panelSubtitle) + .foregroundColor(Theme.Colors.textTertiary) + + Text(" · ") + .foregroundColor(Theme.Colors.textMuted) + + Text("\(actionCount) need attention") + .foregroundColor(Theme.Colors.statusYellow)) + .font(Theme.Fonts.panelSubtitle) + } else if !panelSubtitle.isEmpty { + Text(panelSubtitle) + .font(Theme.Fonts.panelSubtitle) + .foregroundColor(Theme.Colors.textTertiary) + } + } + + @ViewBuilder + private var bannerSection: some View { + if panelMode == .pullRequests { + if appState.isRateLimited { + warningBar( + text: "Rate limited" + + (appState.rateLimitResetDate.map { + " — resets \(Self.relativeDateFormatter.localizedString(for: $0, relativeTo: Date()))" + } ?? ""), + color: Theme.Colors.statusRed + ) + } else if appState.isStale { + warningBar( + text: "Data may be outdated — last update \(formattedLastUpdated)", + color: Theme.Colors.statusYellow + ) + } + + if let warningMessage = appState.warningMessage { + warningBar( + text: warningMessage, + color: Theme.Colors.statusYellow + ) + } + + if case .failed = appState.authenticationStatus { + authErrorBanner + } else if case .noToken = appState.authenticationStatus { + noTokenBanner + } + } + } + + private var contentView: some View { + ScrollView { + if panelMode == .pullRequests { + LazyVStack(spacing: 0) { + switch appState.panelContentState { + case .loading: + skeletonView + case .setupRequired: + setupStateView + case .authError: + authFailedStateView + case .loadError: + loadErrorStateView + case .empty: + emptyStateView + case .prList: + prSections + } + + if appState.showsRecentlyClosedSection { + sectionHeader("RECENTLY CLOSED") + ForEach(appState.recentlyClosedPRs) { pr in + PRRowView(pr: pr).opacity(RowOpacity.recentlyClosed) + themedDivider() + } + } + } + } else { + SettingsContentView(showsAppControls: panelMode.showsInlineAppControls) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxHeight: panelMode == .settings ? 700 : 600) + } + + // MARK: - PR sections + + @ViewBuilder + private var prSections: some View { + let needsAction = appState.needsActionPRs + let waiting = appState.waitingOnOthersPRs + let drafts = appState.yourDraftPRs + + if !needsAction.isEmpty { + sectionHeader("NEEDS YOUR ACTION") + ForEach(needsAction) { pr in + PRRowView(pr: pr).opacity(RowOpacity.active) + themedDivider() + } + } + + if !waiting.isEmpty { + sectionHeader("WAITING ON OTHERS") + ForEach(waiting) { pr in + PRRowView(pr: pr).opacity(RowOpacity.waiting) + themedDivider() + } + } + + if !drafts.isEmpty { + sectionHeader("YOUR DRAFT PRs") + ForEach(drafts) { pr in + PRRowView(pr: pr).opacity(RowOpacity.draft) + themedDivider() + } + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(Theme.Fonts.sectionLabel) + .tracking(1) + .foregroundColor(Theme.Colors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Theme.Spacing.xl) + .padding(.top, Theme.Spacing.md) + .padding(.bottom, Theme.Spacing.xxs) + } + + // MARK: - Loading skeleton + + private var skeletonView: some View { + VStack(spacing: 0) { + ForEach(0..<3, id: \.self) { _ in + SkeletonRowView() + themedDivider() + } + } + } + + // MARK: - Empty state + + private var emptyStateView: some View { + VStack(spacing: Theme.Spacing.sm) { + Image(systemName: "checkmark.circle") + .font(.system(size: 28)) + .foregroundColor(Theme.Colors.statusGreen) + Text("No tracked pull requests right now.") + .font(Theme.Fonts.prTitle) + .foregroundColor(Theme.Colors.textSecondary) + if let emptyStateMessage = appState.emptyStateMessage { + Text(emptyStateMessage) + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, Theme.Spacing.xxl) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.xxxl) + } + + private var setupStateView: some View { + VStack(spacing: Theme.Spacing.sm) { + Image(systemName: "key.fill") + .font(.system(size: 28)) + .foregroundColor(Theme.Colors.statusYellow) + Text("Add a GitHub token in Settings to start tracking pull requests.") + .font(Theme.Fonts.prTitle) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, Theme.Spacing.xxl) + .padding(.vertical, Theme.Spacing.xxxl) + } + + private var authFailedStateView: some View { + VStack(spacing: Theme.Spacing.sm) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 28)) + .foregroundColor(Theme.Colors.statusRed) + Text("Update your GitHub token in Settings to resume tracking pull requests.") + .font(Theme.Fonts.prTitle) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, Theme.Spacing.xxl) + .padding(.vertical, Theme.Spacing.xxxl) + } + + private var loadErrorStateView: some View { + VStack(spacing: Theme.Spacing.md) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 28)) + .foregroundColor(Theme.Colors.statusRed) + Text(appState.error?.errorDescription ?? "Unable to load pull requests right now.") + .font(Theme.Fonts.prTitle) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.center) + Button("Retry") { + appState.forceRefresh() + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, Theme.Spacing.xxl) + .padding(.vertical, Theme.Spacing.xxxl) + } + + // MARK: - Error banners + + private var authErrorBanner: some View { + actionBanner( + icon: "exclamationmark.circle.fill", + text: "Authentication failed", + buttonLabel: "Fix", + color: Theme.Colors.statusRed + ) + } + + private var noTokenBanner: some View { + actionBanner( + icon: "key.fill", + text: "Add a GitHub token to get started", + buttonLabel: "Add token", + color: Theme.Colors.statusYellow + ) + } + + private func actionBanner(icon: String, text: String, buttonLabel: String, color: Color) -> some View { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(text) + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textSecondary) + Spacer() + Button { + panelMode = .settings + } label: { + Text(buttonLabel) + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.linkBlue) + } + .buttonStyle(.plain) + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.sm) + .background(color.opacity(0.15)) + } + + private func warningBar(text: String, color: Color) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(color) + Text(text) + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textSecondary) + Spacer() + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.xs) + .background(color.opacity(0.12)) + } + + // MARK: - Footer + + @ViewBuilder + private var footerView: some View { + if panelMode.showsRefreshRow { + VStack(spacing: 0) { + themedDivider() + + HStack { + if appState.lastUpdated != nil { + Text("Updated \(formattedLastUpdated)") + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textMuted) + } else { + Text(appState.isLoading ? "Loading…" : "Never updated") + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textMuted) + } + + Spacer() + + Button { + appState.forceRefresh() + } label: { + Image(systemName: "arrow.clockwise") + .imageScale(.small) + .foregroundColor(Theme.Colors.textTertiary) + } + .buttonStyle(.plain) + .help("Refresh (⌘R)") + .keyboardShortcut("r", modifiers: .command) + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.sm) + } + } + } + + // MARK: - Helpers + + private static let relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + private func themedDivider() -> some View { + Divider().background(Theme.Colors.textMuted.opacity(0.2)) + } + + private var formattedLastUpdated: String { + guard let date = appState.lastUpdated else { return "—" } + return Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Previews + +#Preview("PR Panel") { + PRListView() + .environment(previewAppState(with: previewPRs)) +} + +#Preview("Settings Panel") { + PRListView(initialPanelMode: .settings) + .environment(previewAppState()) +} + +@MainActor +private func previewAppState(with prs: [PRState] = []) -> AppState { + let appState = AppState( + makePollingEngine: { _ in NoOpPollingController() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.prs = prs + appState.authenticationStatus = .noToken + return appState +} + +private let previewPRs = [ + PRState( + number: 12, + title: "Refine menu bar panel layout", + repoFullName: "awjdean/github-command-center", + url: previewURL("https://github.com/awjdean/github-command-center/pull/12"), + headSHA: "abc123", + draftStatus: .ready, + ciStatus: .failing(checks: [.init(name: "unit-tests", conclusion: "failure", url: nil)], totalChecks: 3), + reviewStatus: .changesRequested(by: ["octocat"]), + mergeStatus: .conflicts, + assignment: .init(createdByMe: true, reviewRequestedFromMe: false, assignedToMe: false), + updatedAt: .now + ), + PRState( + number: 34, + title: "Add inline settings screen", + repoFullName: "awjdean/github-command-center", + url: previewURL("https://github.com/awjdean/github-command-center/pull/34"), + headSHA: "def456", + draftStatus: .ready, + ciStatus: .passing, + reviewStatus: .requested(by: ["teammate"]), + mergeStatus: .ready, + assignment: .init(createdByMe: true, reviewRequestedFromMe: false, assignedToMe: false), + updatedAt: .now.addingTimeInterval(-3_600) + ), + PRState( + number: 56, + title: "WIP: Extract polling into background actor", + repoFullName: "awjdean/github-command-center", + url: previewURL("https://github.com/awjdean/github-command-center/pull/56"), + headSHA: "ghi789", + draftStatus: .draft, + ciStatus: .pending, + reviewStatus: .none, + mergeStatus: .pending, + assignment: .init(createdByMe: true, reviewRequestedFromMe: false, assignedToMe: false), + updatedAt: .now.addingTimeInterval(-7_200) + ), +] + +private func previewURL(_ string: String) -> URL { + guard let url = URL(string: string) else { + preconditionFailure("Invalid preview URL: \(string)") + } + + return url +} + +// MARK: - Skeleton row + +private struct SkeletonRowView: View { + @State private var shimmerOffset: CGFloat = -200 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + skeletonContent + .overlay(alignment: .leading) { + shimmerSweep + .mask(skeletonContent) + } + .onAppear { + guard !reduceMotion else { return } + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + shimmerOffset = 200 + } + } + .onDisappear { + shimmerOffset = -200 + } + } + + private var skeletonContent: some View { + HStack(spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + RoundedRectangle(cornerRadius: 3).fill(shimmerColor).frame(width: 60, height: 10) + RoundedRectangle(cornerRadius: 3).fill(shimmerColor).frame( + maxWidth: .infinity, + minHeight: 10, + maxHeight: 10 + ) + HStack { + RoundedRectangle(cornerRadius: 3).fill(shimmerColor).frame(width: 80, height: 8) + Spacer() + HStack(spacing: 5) { + ForEach(0..<3, id: \.self) { _ in + Circle().fill(shimmerColor).frame(width: 8, height: 8) + } + } + } + } + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.sm) + } + + private var shimmerColor: Color { + Theme.Colors.panelSurface.opacity(0.8) + } + + private var shimmerSweep: some View { + LinearGradient( + colors: [ + shimmerColor.opacity(0), + shimmerColor.opacity(0.9), + shimmerColor.opacity(0), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 120) + .offset(x: shimmerOffset) + } +} diff --git a/GitHubCommandCenter/Views/PRRowView.swift b/GitHubCommandCenter/Views/PRRowView.swift new file mode 100644 index 0000000..156dac5 --- /dev/null +++ b/GitHubCommandCenter/Views/PRRowView.swift @@ -0,0 +1,224 @@ +import OSLog +import SwiftUI + +struct PRRowView: View { + private static let logger = Logger(subsystem: Log.subsystem, category: "PRRowView") + + let pr: PRState + @State private var isHovered = false + @State private var isShowingOpenError = false + @State private var openErrorMessage = "" + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + VStack(alignment: .leading, spacing: Theme.Spacing.micro) { + HStack(spacing: Theme.Spacing.xs) { + Button { + openPullRequest() + } label: { + Text("#\(pr.number)") + .font(Theme.Fonts.prNumber) + .foregroundColor(Theme.Colors.linkBlue) + .underline() + } + .buttonStyle(.plain) + + if !pr.displayRole.isEmpty { + Text(pr.displayRole) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Theme.Colors.textMuted) + .padding(.horizontal, Theme.Spacing.xxs) + .padding(.vertical, 1) + .background(Theme.Colors.panelSurface) + .cornerRadius(Theme.Spacing.xxxs) + } + + Spacer() + + if pr.draftStatus == .draft { + Text("DRAFT") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(Theme.Colors.textMuted) + .padding(.horizontal, Theme.Spacing.xxxs) + .padding(.vertical, 1) + .overlay( + RoundedRectangle(cornerRadius: Theme.Spacing.xxxs) + .stroke(Theme.Colors.textMuted.opacity(0.5), lineWidth: 0.5) + ) + } + } + + Text(pr.title) + .font(Theme.Fonts.prTitle) + .foregroundColor(Theme.Colors.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: Theme.Spacing.xs) { + Text(pr.repoFullName) + .font(Theme.Fonts.prRepo) + .foregroundColor(Theme.Colors.textMuted) + + Spacer() + + HStack(spacing: 5) { + StatusDotView(dimension: .ci, pr: pr) + StatusDotView(dimension: .review, pr: pr) + StatusDotView(dimension: .merge, pr: pr) + } + } + } + } + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.sm) + .background(isHovered ? Theme.Colors.panelSurface : Color.clear) + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { isHovered = hovering } + } + .accessibilityLabel(PRRowPresentation.accessibilityLabel(for: pr)) + .alert("Unable to Open Pull Request", isPresented: $isShowingOpenError) { + Button("OK", role: .cancel) {} + } message: { + Text(openErrorMessage) + } + } + + private func openPullRequest() { + if let failureMessage = ExternalURLPresentation.open( + pr.url, + logger: Self.logger, + failureMessage: PRRowPresentation.openFailureMessage(for: pr) + ) { + openErrorMessage = failureMessage + isShowingOpenError = true + } + } +} + +enum PRRowPresentation { + static func accessibilityLabel(for pr: PRState) -> String { + "PR \(pr.number), \(pr.title), \(pr.repoFullName), \(statusSummary(for: pr))" + } + + static func openFailureMessage(for pr: PRState) -> String { + "GitHub Command Center couldn't open pull request #\(pr.number). URL: \(pr.url.absoluteString)" + } + + private static func statusSummary(for pr: PRState) -> String { + let ciSummary = ciStatusText(for: pr.ciStatus) + let reviewSummary = reviewStatusText(for: pr.reviewStatus) + let mergeSummary = mergeStatusText(for: pr.mergeStatus) + return "CI \(ciSummary), review \(reviewSummary), merge \(mergeSummary)" + } + + private static func ciStatusText(for status: PRState.CIStatus) -> String { + switch status { + case .passing: + return "passing" + case .failing: + return "failing" + case .pending: + return "pending" + case .none: + return "not configured" + } + } + + private static func reviewStatusText(for status: PRState.ReviewStatus) -> String { + switch status { + case .approved: + return "approved" + case .changesRequested: + return "changes requested" + case .requested: + return "requested" + case .none: + return "not started" + } + } + + private static func mergeStatusText(for status: PRState.MergeStatus) -> String { + switch status { + case .ready: + return "ready" + case .conflicts: + return "conflicts" + case .behind: + return "branch behind" + case .blocked: + return "blocked" + case .pending: + return "pending" + } + } +} + +enum ExternalURLPresentation { + static func open( + _ url: URL, + logger: Logger, + failureMessage: @autoclosure () -> String + ) -> String? { + guard NSWorkspace.shared.open(url) else { + logger.error("Failed to open external URL: \(url.absoluteString, privacy: .public)") + return failureMessage() + } + + return nil + } +} + +#Preview("PR Row - Needs Action") { + PRRowView( + pr: prRowPreviewPR( + number: 42, + title: "Stabilize review queue ordering", + repoFullName: "openai/github-command-center", + ciStatus: .failing( + checks: [.init(name: "Unit Tests", conclusion: "failure", url: URL(string: "https://github.com"))], + totalChecks: 6 + ), + reviewStatus: .changesRequested(by: ["alice"]), + createdByMe: true + ) + ) + .padding() + .background(Theme.Colors.panelBackground) +} + +private func prRowPreviewPR( + number: Int, + title: String, + repoFullName: String, + draftStatus: PRState.DraftStatus = .ready, + ciStatus: PRState.CIStatus = .passing, + reviewStatus: PRState.ReviewStatus = .none, + mergeStatus: PRState.MergeStatus = .ready, + createdByMe: Bool = false, + reviewRequestedFromMe: Bool = false, + assignedToMe: Bool = false, + updatedAt: Date = Date() +) -> PRState { + guard let pullRequestURL = URL(string: "https://github.com/\(repoFullName)/pull/\(number)") else { + preconditionFailure("Invalid PR row preview URL for \(repoFullName)#\(number)") + } + + return PRState( + number: number, + title: title, + repoFullName: repoFullName, + url: pullRequestURL, + headSHA: "abc123def456", + draftStatus: draftStatus, + ciStatus: ciStatus, + reviewStatus: reviewStatus, + mergeStatus: mergeStatus, + assignment: .init( + createdByMe: createdByMe, + reviewRequestedFromMe: reviewRequestedFromMe, + assignedToMe: assignedToMe + ), + updatedAt: updatedAt + ) +} diff --git a/GitHubCommandCenter/Views/SettingsView.swift b/GitHubCommandCenter/Views/SettingsView.swift new file mode 100644 index 0000000..ad3b5ff --- /dev/null +++ b/GitHubCommandCenter/Views/SettingsView.swift @@ -0,0 +1,694 @@ +import OSLog +import ServiceManagement +import SwiftUI + +enum SettingsLinks { + static let personalAccessTokens = URL(string: "https://github.com/settings/personal-access-tokens") + static let projectRepository = URL(string: "https://github.com/awjdean/github-command-center") +} + +struct TokenSaveFailureOutcome: Equatable { + let tokenState: SettingsContentView.TokenState + let message: String? +} + +struct TokenSaveSuccessOutcome: Equatable { + let tokenState: SettingsContentView.TokenState + let warningMessage: String? +} + +struct SettingsContentView: View { + private static let logger = Logger(subsystem: Log.subsystem, category: "SettingsView") + + @Environment(AppState.self) private var appState + @State private var tokenInput = "" + @State private var tokenState: TokenState = .empty + @State private var tokenSaveErrorMessage: String? + @State private var tokenAccessDetails: TokenAccessDetails? + @State private var tokenAccessErrorMessage: String? + @State private var tokenSaveTask: Task? + @State private var tokenSaveTaskID: UUID? + @State private var tokenAccessTask: Task? + @State private var tokenAccessTaskID: UUID? + @State private var isValidating = false + @State private var isLoadingTokenAccess = false + @State private var isReposExpanded = false + @State private var launchAtLogin = false + let showsAppControls: Bool + + init(showsAppControls: Bool = true) { + self.showsAppControls = showsAppControls + } + + enum TokenState: Equatable { + case empty + case unvalidated + case valid(username: String) + case invalid + } + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xl) { + tokenSection + aboutSection + if showsAppControls { + appControlsSection + } + } + .padding(Theme.Spacing.xxl) + .preferredColorScheme(.dark) + .onAppear { + launchAtLogin = (SMAppService.mainApp.status == .enabled) + loadSavedToken() + } + } + + // MARK: - Token Section + + private var tokenSection: some View { + VStack(alignment: .leading, spacing: 14) { + Label { + Text("Authentication") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Theme.Colors.textPrimary) + } icon: { + Image(systemName: "key.fill") + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.linkBlue) + } + + if case .valid(let username) = tokenState { + connectedView(username: username) + } else { + tokenInputView + } + } + .settingsCard() + } + + private func connectedView(username: String) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.lg) { + HStack(spacing: Theme.Spacing.md) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(Theme.Colors.statusGreen) + VStack(alignment: .leading, spacing: Theme.Spacing.micro) { + Text("@\(username)") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.Colors.textPrimary) + Text("Token stored in Keychain") + .font(.system(size: 10)) + .foregroundColor(Theme.Colors.textTertiary) + } + Spacer() + } + .padding(Theme.Spacing.lg) + .background(Theme.Colors.statusGreen.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.statusGreen.opacity(0.2), lineWidth: 1) + ) + + if let tokenValidationWarningMessage = appState.tokenValidationWarningMessage { + HStack(alignment: .top, spacing: Theme.Spacing.xs) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundColor(Theme.Colors.statusYellow) + Text(tokenValidationWarningMessage) + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.statusYellow) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Theme.Spacing.md) + .background(Theme.Colors.statusYellow.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.statusYellow.opacity(0.2), lineWidth: 1) + ) + } + + tokenAccessSection + + Button(role: .destructive) { + clearToken() + } label: { + HStack(spacing: Theme.Spacing.xxs) { + Image(systemName: "trash") + .font(.system(size: 10)) + Text("Remove Token") + .font(.system(size: 12, weight: .medium)) + } + } + .buttonStyle(.bordered) + } + } + + private var tokenInputView: some View { + VStack(alignment: .leading, spacing: 14) { + SecureField("ghp_xxxxxxxxxxxxxxxxxxxx", text: $tokenInput) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(Theme.Colors.textPrimary) + .padding(Theme.Spacing.md) + .background(Color.black.opacity(0.25)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(tokenBorderColor, lineWidth: 1) + ) + .onChange(of: tokenInput) { + clearTokenAccessState() + clearTokenSaveValidationState() + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = tokenInput.isEmpty ? .empty : .unvalidated + } + } + + if case .invalid = tokenState { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(Theme.Colors.statusRed) + Text("Invalid token \u{2014} check scopes and try again") + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.statusRed) + } + .transition(.opacity) + } + + if let tokenSaveErrorMessage { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundColor(Theme.Colors.statusRed) + Text(tokenSaveErrorMessage) + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.statusRed) + } + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } + + scopeInfo + + if let url = SettingsLinks.personalAccessTokens { + Link(destination: url) { + HStack(spacing: Theme.Spacing.xxs) { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 10)) + Text("Manage personal access tokens") + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(Theme.Colors.linkBlue) + } + } + + HStack(spacing: Theme.Spacing.sm) { + Button { + saveToken() + } label: { + HStack(spacing: Theme.Spacing.xxs) { + if isValidating { + ProgressView() + .controlSize(.small) + } + Text(isValidating ? "Validating…" : "Save Token") + .font(.system(size: 12, weight: .medium)) + } + .frame(minWidth: 100) + } + .disabled(tokenInput.isEmpty || isValidating) + .buttonStyle(.borderedProminent) + .tint(Theme.Colors.linkBlue) + + if !tokenInput.isEmpty && !isValidating { + Button("Clear") { + tokenInput = "" + tokenState = .empty + } + .font(.system(size: 12)) + .buttonStyle(.bordered) + } + } + } + } + + // MARK: - Scope Info + + private var scopeInfo: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("APP REQUIREMENTS") + .font(.system(size: 9, weight: .bold)) + .tracking(1.2) + .foregroundColor(Theme.Colors.textMuted) + + scopeRow( + scopes: [ + .init(name: "Pull requests: Read"), + .init(name: "Commit statuses: Read"), + .init(name: "Checks: Read", isOptional: true), + ] + ) + + Text( + "Grant repository access to every repo you want tracked. " + + "Commit statuses are required for full verification. " + + "Checks access is recommended for richer CI detail. " + + "This app shows pull requests involving the authenticated account." + ) + .font(.system(size: 10)) + .foregroundColor(Theme.Colors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func scopeRow(scopes: [ScopeItem]) -> some View { + FlowLayout(spacing: Theme.Spacing.xxs) { + ForEach(scopes) { scope in + scopeBadge(scope.name, optional: scope.isOptional) + } + } + } + + @ViewBuilder + private var tokenAccessSection: some View { + if isLoadingTokenAccess { + HStack(spacing: Theme.Spacing.sm) { + ProgressView() + .controlSize(.small) + Text("Loading token access…") + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.textSecondary) + } + } else if let tokenAccessDetails { + VStack(alignment: .leading, spacing: Theme.Spacing.lg) { + tokenPermissionsSection(details: tokenAccessDetails) + accessibleRepositoriesSection(details: tokenAccessDetails) + } + } else if let tokenAccessErrorMessage { + Text(tokenAccessErrorMessage) + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.statusRed) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func tokenPermissionsSection(details: TokenAccessDetails) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("REPORTED PERMISSIONS") + .font(.system(size: 9, weight: .bold)) + .tracking(1.2) + .foregroundColor(Theme.Colors.textMuted) + + if details.oauthScopes.isEmpty { + Text( + "GitHub did not report OAuth scopes for this token. " + + "Fine-grained personal access token permissions are not " + + "fully introspectable via the REST API." + ) + .font(.system(size: 10)) + .foregroundColor(Theme.Colors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } else { + scopeRow( + scopes: details.oauthScopes.enumerated().map { index, scope in + ScopeItem(id: "oauth-scope-\(index)", name: scope) + } + ) + } + } + } + + private func accessibleRepositoriesSection(details: TokenAccessDetails) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isReposExpanded.toggle() + } + } label: { + HStack { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(Theme.Colors.textMuted) + .rotationEffect(.degrees(isReposExpanded ? 90 : 0)) + Text("ACCESSIBLE REPOSITORIES") + .font(.system(size: 9, weight: .bold)) + .tracking(1.2) + .foregroundColor(Theme.Colors.textMuted) + Spacer() + Text("\(details.accessibleRepositories.count)") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(Theme.Colors.textTertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isReposExpanded { + if details.accessibleRepositories.isEmpty { + Text("No accessible repositories were returned for this token.") + .font(.system(size: 10)) + .foregroundColor(Theme.Colors.textTertiary) + } else { + VStack(spacing: Theme.Spacing.xs) { + ForEach(details.accessibleRepositories) { repository in + accessibleRepositoryRow(repository) + } + } + } + } + } + } + + private func accessibleRepositoryRow(_ repository: TokenAccessDetails.AccessibleRepository) -> some View { + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.sm) { + Text(repository.fullName) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Theme.Colors.textSecondary) + .lineLimit(1) + + Spacer(minLength: Theme.Spacing.sm) + + Text(repository.accessLevel.rawValue) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(accessLevelColor(repository.accessLevel)) + .padding(.horizontal, Theme.Spacing.xs) + .padding(.vertical, Theme.Spacing.micro) + .background(accessLevelColor(repository.accessLevel).opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Spacing.xxs)) + } + } + + private func accessLevelColor(_ accessLevel: TokenAccessDetails.AccessibleRepository.AccessLevel) -> Color { + switch accessLevel { + case .admin: + Theme.Colors.statusRed + case .write: + Theme.Colors.statusYellow + case .read: + Theme.Colors.statusGreen + } + } + + private func scopeBadge(_ text: String, optional: Bool = false) -> some View { + HStack(spacing: Theme.Spacing.xxxs) { + Text(text) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + if optional { + Text("optional") + .font(.system(size: 8)) + .foregroundColor(Theme.Colors.textMuted) + } + } + .foregroundColor(optional ? Theme.Colors.textTertiary : Theme.Colors.textSecondary) + .padding(.horizontal, 7) + .padding(.vertical, Theme.Spacing.xxxs) + .background(Theme.Colors.panelBackground.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Spacing.xxs)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Spacing.xxs) + .stroke(Theme.Colors.textMuted.opacity(0.25), lineWidth: 0.5) + ) + } + + // MARK: - App Controls + + private var appControlsSection: some View { + HStack { + Toggle("Launch at Login", isOn: $launchAtLogin) + .font(.system(size: 12)) + .foregroundColor(Theme.Colors.textSecondary) + .toggleStyle(.checkbox) + .onChange(of: launchAtLogin) { _, enabled in + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + let action = enabled ? "register" : "unregister" + let errorDescription = error.localizedDescription + Self.logger.error( + "\(action, privacy: .public) launch at login failed: \(errorDescription, privacy: .public)" + ) + launchAtLogin = (SMAppService.mainApp.status == .enabled) + } + } + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Theme.Colors.textSecondary) + .buttonStyle(.bordered) + } + .settingsCard() + } + + private var aboutSection: some View { + HStack { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("GitHub Command Center") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Theme.Colors.textPrimary) + HStack(spacing: Theme.Spacing.sm) { + Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(Theme.Colors.textTertiary) + .padding(.horizontal, Theme.Spacing.xs) + .padding(.vertical, Theme.Spacing.micro) + .background(Theme.Colors.panelBackground.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Spacing.xxs)) + Text("MIT License") + .font(.system(size: 11)) + .foregroundColor(Theme.Colors.textTertiary) + } + } + Spacer() + if let url = SettingsLinks.projectRepository { + Link(destination: url) { + HStack(spacing: Theme.Spacing.xxs) { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 10)) + Text("View on GitHub") + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(Theme.Colors.linkBlue) + } + } + } + .settingsCard() + } + + private var tokenBorderColor: Color { + switch tokenState { + case .empty, .unvalidated: Theme.Colors.textMuted.opacity(0.25) + case .valid: Theme.Colors.statusGreen.opacity(0.6) + case .invalid: Theme.Colors.statusRed.opacity(0.6) + } + } + + private func loadSavedToken() { + do { + if let saved = try KeychainService.shared.loadToken(), !saved.isEmpty { + tokenInput = saved + if case .authenticated(let username) = appState.authenticationStatus { + tokenState = .valid(username: username) + } else { + tokenState = .unvalidated + } + refreshTokenAccessDetails(using: saved) + } + } catch { + tokenInput = "" + tokenState = .empty + let errorDescription = error.localizedDescription + Self.logger.error( + "loadSavedToken Keychain read failed: \(errorDescription, privacy: .public)" + ) + } + } + + private func saveToken() { + guard !tokenInput.isEmpty else { return } + let tokenToSave = tokenInput + tokenSaveTask?.cancel() + let taskID = UUID() + tokenSaveTaskID = taskID + isValidating = true + + tokenSaveTask = Task { @MainActor in + defer { + if tokenSaveTaskID == taskID { + isValidating = false + tokenSaveTask = nil + tokenSaveTaskID = nil + } + } + + do { + clearTokenSaveValidationState() + let client = GitHubRESTClient(token: tokenToSave) + let validationResult = try await client.validateTokenForAppAccess() + guard !Task.isCancelled, tokenInput == tokenToSave else { return } + try KeychainService.shared.saveToken(tokenToSave) + guard !Task.isCancelled, tokenInput == tokenToSave else { return } + let successOutcome = Self.tokenSaveSuccessOutcome(for: validationResult) + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = successOutcome.tokenState + } + appState.tokenValidationWarningMessage = successOutcome.warningMessage + refreshTokenAccessDetails(using: tokenToSave) + appState.resetPolling() + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + let outcome = Self.tokenSaveFailureOutcome(for: error) + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = outcome.tokenState + } + tokenSaveErrorMessage = outcome.message + } + } + } + + private func clearToken() { + tokenSaveTask?.cancel() + tokenSaveTask = nil + tokenSaveTaskID = nil + isValidating = false + clearTokenAccessState() + do { + try KeychainService.shared.deleteToken() + } catch { + let errorDescription = error.localizedDescription + Self.logger.error( + "clearToken Keychain deletion failed: \(errorDescription, privacy: .public)" + ) + // Token may still exist in Keychain — don't clear UI state + tokenSaveErrorMessage = "Could not remove token: \(errorDescription)" + return + } + tokenInput = "" + clearTokenSaveValidationState() + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = .empty + } + appState.stopPollingForMissingToken() + } + + private func clearTokenSaveValidationState() { + tokenSaveErrorMessage = nil + } + + private func clearTokenAccessState() { + tokenAccessTask?.cancel() + tokenAccessTask = nil + tokenAccessTaskID = nil + tokenAccessDetails = nil + tokenAccessErrorMessage = nil + isLoadingTokenAccess = false + } + + private func refreshTokenAccessDetails(using token: String) { + clearTokenAccessState() + isLoadingTokenAccess = true + let taskID = UUID() + tokenAccessTaskID = taskID + + tokenAccessTask = Task { @MainActor in + defer { + if tokenAccessTaskID == taskID { + isLoadingTokenAccess = false + tokenAccessTask = nil + tokenAccessTaskID = nil + } + } + + do { + let details = try await GitHubRESTClient(token: token).fetchTokenAccessDetails() + guard !Task.isCancelled, tokenInput == token else { return } + + tokenAccessDetails = details + tokenAccessErrorMessage = nil + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = .valid(username: details.username) + } + } catch let error as AppError { + guard !Task.isCancelled, tokenInput == token else { return } + + if error == .authError { + withAnimation(.easeInOut(duration: 0.2)) { + tokenState = .invalid + } + } + + tokenAccessErrorMessage = + error.errorDescription ?? "Unable to load token access details right now." + } catch { + guard !Task.isCancelled, tokenInput == token else { return } + tokenAccessErrorMessage = "Unable to load token access details right now." + } + } + } + + static func tokenSaveSuccessOutcome( + for result: GitHubRESTClient.TokenValidationResult + ) -> TokenSaveSuccessOutcome { + switch result { + case .verified(let username): + return TokenSaveSuccessOutcome( + tokenState: .valid(username: username), + warningMessage: nil + ) + case .warning(let username, let message): + return TokenSaveSuccessOutcome( + tokenState: .valid(username: username), + warningMessage: message + ) + } + } + + static func tokenSaveFailureOutcome(for error: Error) -> TokenSaveFailureOutcome { + if let appError = error as? AppError { + switch appError { + case .authError: + return TokenSaveFailureOutcome(tokenState: .invalid, message: nil) + default: + return TokenSaveFailureOutcome( + tokenState: .unvalidated, + message: appError.errorDescription ?? "Unable to save token right now." + ) + } + } + + return TokenSaveFailureOutcome( + tokenState: .unvalidated, + message: (error as NSError).localizedDescription + ) + } +} + +#Preview("Settings Content") { + SettingsContentView() + .environment(settingsPreviewAppState()) + .frame(width: 360) + .background(Theme.Colors.panelBackground) +} + +@MainActor +private func settingsPreviewAppState() -> AppState { + let appState = AppState( + makePollingEngine: { _ in NoOpPollingController() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .noToken + return appState +} diff --git a/GitHubCommandCenter/Views/SettingsViewSupport.swift b/GitHubCommandCenter/Views/SettingsViewSupport.swift new file mode 100644 index 0000000..5cfa17d --- /dev/null +++ b/GitHubCommandCenter/Views/SettingsViewSupport.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct ScopeItem: Identifiable { + let id: String + let name: String + var isOptional: Bool = false + + init(id: String = UUID().uuidString, name: String, isOptional: Bool = false) { + self.id = id + self.name = name + self.isOptional = isOptional + } +} + +private struct SettingsCardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(Theme.Spacing.xl) + .background(Theme.Colors.panelSurface) + .clipShape(RoundedRectangle(cornerRadius: Theme.Spacing.lg)) + } +} + +extension View { + func settingsCard() -> some View { + modifier(SettingsCardModifier()) + } +} + +struct FlowLayout: Layout { + var spacing: CGFloat = Theme.Spacing.xxs + + struct Cache { + var size: CGSize = .zero + var positions: [CGPoint] = [] + } + + func makeCache(subviews: Subviews) -> Cache { + Cache() + } + + func updateCache(_ cache: inout Cache, subviews: Subviews) { + cache = Cache() + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + cache = arrange(in: proposal.width ?? .infinity, subviews: subviews) + return cache.size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + for (index, position) in cache.positions.enumerated() { + subviews[index].place( + at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), + proposal: .unspecified + ) + } + } + + private func arrange(in maxWidth: CGFloat, subviews: Subviews) -> Cache { + var positions: [CGPoint] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > maxWidth, currentX > 0 { + currentX = 0 + currentY += rowHeight + spacing + rowHeight = 0 + } + positions.append(CGPoint(x: currentX, y: currentY)) + rowHeight = max(rowHeight, size.height) + currentX += size.width + spacing + totalWidth = max(totalWidth, currentX - spacing) + } + + return Cache( + size: CGSize(width: totalWidth, height: currentY + rowHeight), + positions: positions + ) + } +} diff --git a/GitHubCommandCenter/Views/StatusDotView.swift b/GitHubCommandCenter/Views/StatusDotView.swift new file mode 100644 index 0000000..919d9ab --- /dev/null +++ b/GitHubCommandCenter/Views/StatusDotView.swift @@ -0,0 +1,415 @@ +import OSLog +import SwiftUI + +enum StatusDimension { + case ci, review, merge +} + +struct StatusDotView: View { + let dimension: StatusDimension + let pr: PRState + + @State private var showingPopover = false + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + var body: some View { + Button { + showingPopover.toggle() + } label: { + ZStack { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + + // Accessibility: show letter inside dot when "Differentiate without color" is on + if differentiateWithoutColor { + Text(accessibilityLetter) + .font(.system(size: 6, weight: .bold)) + .foregroundColor(accessibilityForegroundColor) + } + } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + if let failing = tooltip.failingCIChecks { + CIFailingPopoverView( + checks: failing.checks, + totalChecks: failing.totalChecks + ) + } else { + StatusPopoverView( + label: tooltip.label, + detail: tooltip.detail + ) + } + } + } + + private var tooltip: StatusDotTooltip { + StatusDotTooltip(dimension: dimension, pr: pr) + } + + // MARK: - Color + + private var dotColor: Color { + switch dimension { + case .ci: return ciColor + case .review: return reviewColor + case .merge: return mergeColor + } + } + + private var ciColor: Color { + switch pr.ciStatus { + case .passing: return Theme.Colors.statusGreen + case .failing: return Theme.Colors.statusRed + case .pending: return Theme.Colors.statusYellow + case .none: return Theme.Colors.statusGray + } + } + + private var reviewColor: Color { + switch pr.reviewStatus { + case .approved: return Theme.Colors.statusGreen + case .changesRequested: return Theme.Colors.statusRed + case .requested: return Theme.Colors.statusYellow + case .none: return Theme.Colors.statusGray + } + } + + private var mergeColor: Color { + switch pr.mergeStatus { + case .ready: return Theme.Colors.statusGreen + case .conflicts: return Theme.Colors.statusRed + case .behind, .blocked: return Theme.Colors.statusYellow + case .pending: return Theme.Colors.statusGray + } + } + + // MARK: - Accessibility letter + + private var accessibilityLetter: String { + switch dimension { + case .ci: + switch pr.ciStatus { + case .passing: return "P" + case .failing: return "X" + case .pending: return "~" + case .none: return "-" + } + case .review: + switch pr.reviewStatus { + case .approved: return "A" + case .changesRequested: return "X" + case .requested: return "~" + case .none: return "-" + } + case .merge: + switch pr.mergeStatus { + case .ready: return "M" + case .conflicts: return "C" + case .behind: return "U" + case .blocked: return "B" + case .pending: return "~" + } + } + } + + private var accessibilityForegroundColor: Color { + switch dimension { + case .ci: + switch pr.ciStatus { + case .pending: + return .black + case .passing, .failing, .none: + return .white + } + case .review: + switch pr.reviewStatus { + case .requested: + return .black + case .approved, .changesRequested, .none: + return .white + } + case .merge: + switch pr.mergeStatus { + case .behind, .blocked: + return .black + case .ready, .conflicts, .pending: + return .white + } + } + } + +} + +// MARK: - Tooltip data (extracted for testability) + +struct StatusDotTooltip { + private static let reviewerDisplayLimit = 2 + + let dimension: StatusDimension + let pr: PRState + + var label: String { + switch dimension { + case .ci: return "CI" + case .review: return "REVIEW" + case .merge: return "MERGE" + } + } + + var failingCIChecks: (checks: [PRState.FailingCheck], totalChecks: Int)? { + guard dimension == .ci, case .failing(let checks, let total) = pr.ciStatus else { + return nil + } + return (checks, total) + } + + var detail: String { + switch dimension { + case .ci: + switch pr.ciStatus { + case .passing: + return "All checks passing" + case .failing(let checks, let total): + let names = checks.map(\.name) + guard !names.isEmpty else { + return "Failing (\(total) total)" + } + let display = names.prefix(2).joined(separator: ", ") + let more = names.count > 2 ? " (+\(names.count - 2) more)" : "" + return "\(display)\(more) failing (\(total) total)" + case .pending: + return "Checks in progress" + case .none: + return "No CI configured" + } + case .review: + switch pr.reviewStatus { + case .approved(let by): + return "Approved by \(formattedReviewers(by, maxCount: Self.reviewerDisplayLimit))" + case .changesRequested(let by): + return "Changes requested by \(formattedReviewers(by, maxCount: Self.reviewerDisplayLimit))" + case .requested(let from): + return "Review requested from \(formattedReviewers(from, maxCount: Self.reviewerDisplayLimit))" + case .none: + return "No reviews" + } + case .merge: + switch pr.mergeStatus { + case .ready: + return "Ready to merge" + case .conflicts: + return "Has merge conflicts" + case .behind: + return "Branch is behind base and needs updating" + case .blocked: + return "Merge blocked by branch protection" + case .pending: + return "Checking mergeability…" + } + } + } + + private func formattedReviewers(_ reviewers: [String], maxCount: Int) -> String { + let displayedReviewers = reviewers.prefix(maxCount).map { "@\($0)" } + guard reviewers.count > maxCount else { + return displayedReviewers.joined(separator: ", ") + } + + return "\(displayedReviewers.joined(separator: ", ")) and \(reviewers.count - maxCount) more" + } +} + +// MARK: - Popover content + +private struct CIFailingPopoverView: View { + private static let logger = Logger(subsystem: Log.subsystem, category: "StatusDotView") + + let checks: [PRState.FailingCheck] + let totalChecks: Int + @State private var isShowingOpenError = false + @State private var openErrorMessage = "" + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("CI") + .font(Theme.Fonts.sectionLabel) + .tracking(0.5) + .foregroundColor(Theme.Colors.textTertiary) + + Text("\(checks.count) of \(totalChecks) checks failing") + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textSecondary) + + Divider().background(Theme.Colors.textMuted.opacity(0.3)) + + ScrollView { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + ForEach(checks) { check in + failingCheckRow(check) + } + } + } + .frame(maxHeight: 300) + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.vertical, Theme.Spacing.md) + .frame(maxWidth: 320) + .alert("Unable to Open Pull Request", isPresented: $isShowingOpenError) { + Button("OK", role: .cancel) {} + } message: { + Text(openErrorMessage) + } + } + + private func failingCheckRow(_ check: PRState.FailingCheck) -> some View { + HStack(spacing: Theme.Spacing.xs) { + Circle() + .fill(Theme.Colors.statusRed) + .frame(width: 6, height: 6) + + VStack(alignment: .leading, spacing: Theme.Spacing.hairline) { + if let url = check.url { + Button { + let failureMessage = + "GitHub Command Center couldn't open CI link \"\(check.name)\". " + + "URL: \(url.absoluteString)" + if let failureMessage = ExternalURLPresentation.open( + url, + logger: Self.logger, + failureMessage: failureMessage + ) { + openErrorMessage = failureMessage + isShowingOpenError = true + } + } label: { + Text(check.name) + .font(Theme.Fonts.tooltipDetail) + .foregroundColor(Theme.Colors.linkBlue) + .underline() + .lineLimit(1) + } + .buttonStyle(.plain) + } else { + Text(check.name) + .font(Theme.Fonts.tooltipDetail) + .foregroundColor(Theme.Colors.textSecondary) + .lineLimit(1) + } + + Text(conclusionLabel(check.conclusion)) + .font(Theme.Fonts.tooltipLabel) + .foregroundColor(Theme.Colors.textMuted) + } + + Spacer() + } + } + + private func conclusionLabel(_ conclusion: String) -> String { + switch conclusion { + case "failure": return "Failed" + case "timed_out": return "Timed out" + case "cancelled": return "Cancelled" + case "action_required": return "Action required" + case "error": return "Error" + case "startup_failure": return "Startup failure" + default: return conclusion.capitalized + } + } +} + +private struct StatusPopoverView: View { + let label: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { + Text(label) + .font(Theme.Fonts.sectionLabel) + .tracking(0.5) + .foregroundColor(Theme.Colors.textTertiary) + Text(detail) + .font(Theme.Fonts.footerText) + .foregroundColor(Theme.Colors.textSecondary) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .fixedSize() + } +} + +#Preview("CI Passing") { + StatusDotView(dimension: .ci, pr: statusDotPreviewPR(ciStatus: .passing)) + .padding() + .background(Theme.Colors.panelBackground) +} + +#Preview("CI Failing") { + StatusDotView( + dimension: .ci, + pr: statusDotPreviewPR( + ciStatus: .failing( + checks: [ + .init(name: "lint", conclusion: "failure", url: nil), + .init( + name: "unit-tests", + conclusion: "failure", + url: statusDotPreviewURL( + "https://github.com/awjdean/github-command-center/actions/runs/1" + ), + ), + ], + totalChecks: 4 + ) + ) + ) + .padding() + .background(Theme.Colors.panelBackground) +} + +#Preview("Review Requested") { + StatusDotView( + dimension: .review, + pr: statusDotPreviewPR(reviewStatus: .requested(by: ["octocat"])) + ) + .padding() + .background(Theme.Colors.panelBackground) +} + +#Preview("Merge Conflicts") { + StatusDotView(dimension: .merge, pr: statusDotPreviewPR(mergeStatus: .conflicts)) + .padding() + .background(Theme.Colors.panelBackground) +} + +private func statusDotPreviewPR( + ciStatus: PRState.CIStatus = .passing, + reviewStatus: PRState.ReviewStatus = .none, + mergeStatus: PRState.MergeStatus = .ready +) -> PRState { + PRState( + number: 42, + title: "Refine PR status previews", + repoFullName: "awjdean/github-command-center", + url: statusDotPreviewURL("https://github.com/awjdean/github-command-center/pull/42"), + headSHA: "abc123def456", + draftStatus: .ready, + ciStatus: ciStatus, + reviewStatus: reviewStatus, + mergeStatus: mergeStatus, + assignment: .init(createdByMe: true, reviewRequestedFromMe: false, assignedToMe: false), + updatedAt: .now + ) +} + +private func statusDotPreviewURL(_ string: String) -> URL { + guard let url = URL(string: string) else { + preconditionFailure("Invalid StatusDotView preview URL: \(string)") + } + return url +} diff --git a/GitHubCommandCenter/Views/Theme.swift b/GitHubCommandCenter/Views/Theme.swift new file mode 100644 index 0000000..66c7950 --- /dev/null +++ b/GitHubCommandCenter/Views/Theme.swift @@ -0,0 +1,43 @@ +import SwiftUI + +enum Theme { + enum Colors { + static let panelBackground = Color(red: 0.176, green: 0.176, blue: 0.239) // #2D2D3D + static let panelSurface = Color(red: 0.227, green: 0.227, blue: 0.290) // #3A3A4A + static let textPrimary = Color(red: 1.000, green: 1.000, blue: 1.000) // #FFFFFF + static let textSecondary = Color(red: 0.878, green: 0.878, blue: 0.878) // #E0E0E0 + static let textTertiary = Color(red: 0.545, green: 0.580, blue: 0.620) // #8B949E + static let textMuted = Color(red: 0.400, green: 0.400, blue: 0.400) // #666666 + static let linkBlue = Color(red: 0.345, green: 0.651, blue: 1.000) // #58A6FF + static let statusGreen = Color(red: 0.188, green: 0.631, blue: 0.306) // #30A14E + static let statusYellow = Color(red: 0.831, green: 0.627, blue: 0.090) // #D4A017 + static let statusRed = Color(red: 0.878, green: 0.365, blue: 0.267) // #E05D44 + static let statusGray = Color(red: 0.333, green: 0.333, blue: 0.333) // #555555 + } + + enum Spacing { + static let hairline: CGFloat = 1 + static let micro: CGFloat = 2 + static let xxxs: CGFloat = 3 + static let xxs: CGFloat = 4 + static let xs: CGFloat = 6 + static let sm: CGFloat = 8 + static let md: CGFloat = 10 + static let lg: CGFloat = 12 + static let xl: CGFloat = 16 + static let xxl: CGFloat = 24 + static let xxxl: CGFloat = 40 + } + + enum Fonts { + static let panelTitle = Font.system(size: 15, weight: .semibold) + static let panelSubtitle = Font.system(size: 13, weight: .regular) + static let sectionLabel = Font.system(size: 12, weight: .semibold) + static let prNumber = Font.system(size: 13, weight: .semibold) + static let prTitle = Font.system(size: 14, weight: .regular) + static let prRepo = Font.system(size: 12, weight: .regular) + static let footerText = Font.system(size: 12, weight: .regular) + static let tooltipLabel = Font.system(size: 11, weight: .medium) + static let tooltipDetail = Font.system(size: 12, weight: .regular) + } +} diff --git a/GitHubCommandCenterTests/AppErrorTests.swift b/GitHubCommandCenterTests/AppErrorTests.swift new file mode 100644 index 0000000..b945ac4 --- /dev/null +++ b/GitHubCommandCenterTests/AppErrorTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite(.serialized) +struct AppErrorTests { + @Test(.timeLimit(.minutes(1))) + func rateLimitContext_nextUpdateDescription_isComputedAtReadTime() async throws { + let context = AppError.RateLimitContext(resetAt: Date().addingTimeInterval(1)) + let initialDescription = context.nextUpdateDescription + + try await Task.sleep(nanoseconds: 2_100_000_000) + + let refreshedDescription = context.nextUpdateDescription + + #expect(refreshedDescription != initialDescription) + } +} diff --git a/GitHubCommandCenterTests/AppStateTests.swift b/GitHubCommandCenterTests/AppStateTests.swift new file mode 100644 index 0000000..60bb91a --- /dev/null +++ b/GitHubCommandCenterTests/AppStateTests.swift @@ -0,0 +1,520 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@MainActor +@Suite +struct AppStateTests { + private final class StubPollingEngine: PollingControlling { + var onStart: (() -> Void)? + var startCallCount = 0 + var stopCallCount = 0 + var resetCallCount = 0 + var forceRefreshCallCount = 0 + + func start() { + startCallCount += 1 + onStart?() + } + + func stop() { + stopCallCount += 1 + } + + func reset() { + resetCallCount += 1 + } + + func forceRefresh() { + forceRefreshCallCount += 1 + } + } + + @Test + func appInit_startsPollingBeforeMenuAppears() { + let engine = StubPollingEngine() + let appState = AppState( + makePollingEngine: { _ in engine }, + requestNotificationPermission: {} + ) + + _ = GitHubCommandCenterApp(appState: appState) + + #expect(engine.startCallCount == 1) + } + + @Test + func appInit_preloadsTokenBeforePollingStarts() { + let engine = StubPollingEngine() + var preloadCallCount = 0 + engine.onStart = { + #expect(preloadCallCount == 1) + } + + let appState = AppState( + makePollingEngine: { _ in engine }, + requestNotificationPermission: {}, + preloadTokenIfNeeded: { preloadCallCount += 1 } + ) + + _ = GitHubCommandCenterApp(appState: appState) + + #expect(preloadCallCount == 1) + #expect(engine.startCallCount == 1) + } + + @Test + func stopPollingForMissingToken_stopsWithoutRestartingOrPreloading() { + let engine = StubPollingEngine() + var preloadCallCount = 0 + let appState = AppState( + makePollingEngine: { _ in engine }, + requestNotificationPermission: {}, + preloadTokenIfNeeded: { preloadCallCount += 1 } + ) + + appState.startPollingIfNeeded() + appState.prs = [.fixture(number: 1)] + appState.recentlyClosedPRs = [.fixture(number: 2)] + appState.lastUpdated = Date() + appState.isLoading = false + appState.error = .networkError + appState.authenticationStatus = .authenticated(username: "octocat") + + appState.stopPollingForMissingToken() + + #expect(preloadCallCount == 1) + #expect(engine.startCallCount == 1) + #expect(engine.resetCallCount == 1) + #expect(engine.stopCallCount == 1) + #expect(appState.prs.isEmpty) + #expect(appState.recentlyClosedPRs.isEmpty) + #expect(appState.lastUpdated == nil) + #expect(appState.error == nil) + #expect(!appState.isLoading) + #expect(appState.panelContentState == .setupRequired) + + guard case .noToken = appState.authenticationStatus else { + Issue.record("Expected authentication status to be noToken") + return + } + } + + @Test + func panelContentState_networkErrorWithNoPRs_prefersLoadError() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.error = .networkError + + #expect(appState.panelContentState == .loadError) + } + + @Test + func panelContentState_incompleteResultsWithNoPRs_prefersLoadError() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.error = .incompleteSearchResults + + #expect(appState.panelContentState == .loadError) + } + + @Test + func recentlyClosedSectionVisibility_successfulEmptyState_showsSection() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .authenticated(username: "octocat") + appState.recentlyClosedPRs = [.fixture(number: 99)] + + #expect(appState.showsRecentlyClosedSection) + } + + @Test + func recentlyClosedSectionVisibility_loadError_hidesSection() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.error = .networkError + appState.recentlyClosedPRs = [.fixture(number: 99)] + + #expect(!appState.showsRecentlyClosedSection) + } + + @Test + func panelSubtitle_authenticatedEmptyStateUsesTrackedLanguage() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.authenticationStatus = .authenticated(username: "octocat") + + #expect(appState.panelSubtitleText == "0 tracked PRs") + } + + @Test + func healthStatus_noTokenWithNoPRs_isYellow() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .noToken + + #expect(appState.healthStatus == .yellow) + } + + @Test + func healthStatus_authFailureWithNoPRs_isRed() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .failed + + #expect(appState.healthStatus == .red) + } + + @Test + func healthStatus_rateLimitedWithExistingSnapshot_isRed() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.prs = [.fixture(number: 1)] + appState.error = .rateLimitExceeded(.init(resetAt: Date().addingTimeInterval(60))) + + #expect(appState.healthStatus == .red) + } + + @Test + func healthStatus_staleWithoutHardError_isYellow() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.isStale = true + + #expect(appState.healthStatus == .yellow) + } + + @Test + func healthStatus_incompleteSearchResults_isYellow() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.error = .incompleteSearchResults + + #expect(appState.healthStatus == .yellow) + } + + @Test + func healthStatus_healthyEmptyState_isGreen() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .authenticated(username: "octocat") + + #expect(appState.healthStatus == .green) + } + + @Test + func healthStatus_healthyNeedsActionLowUrgency_usesTriageYellow() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.prs = [.fixture(number: 1, reviewRequestedFromMe: true)] + + #expect(appState.healthStatus == .yellow) + } + + @Test + func healthStatus_healthyNeedsActionHighUrgency_usesTriageRed() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.prs = [ + .fixture( + number: 1, + reviewStatus: .changesRequested(by: ["alice"]), + createdByMe: true + ) + ] + + #expect(appState.healthStatus == .red) + } + + @Test + func menuBarBadgeCount_countsOnlyNeedsActionPRs() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.prs = [ + .fixture(number: 1, reviewRequestedFromMe: true), // needs action + .fixture(number: 2), // waiting on others (default: not mine, no review requested) + ] + + #expect(appState.menuBarBadgeCount == 1) + } + + @Test + func menuBarBadgeCount_zeroPRs_returnsZero() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + + #expect(appState.menuBarBadgeCount == 0) + } + + @Test + func menuBarBadgeCount_allNeedAction_returnsTotal() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.prs = [ + .fixture(number: 1, reviewRequestedFromMe: true), + .fixture(number: 2, reviewRequestedFromMe: true), + .fixture(number: 3, reviewRequestedFromMe: true), + ] + + #expect(appState.menuBarBadgeCount == 3) + } + + @Test + func triageSnapshot_sortsBackingPRsBeforeDerivingSections() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + let waitingOlder = PRState.fixture( + number: 1, + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + let needsActionNewer = PRState.fixture( + number: 2, + reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 4_000) + ) + let draftNewest = PRState.fixture( + number: 3, + draftStatus: .draft, + createdByMe: true, + updatedAt: Date(timeIntervalSince1970: 5_000) + ) + let waitingNewer = PRState.fixture( + number: 4, + updatedAt: Date(timeIntervalSince1970: 3_000) + ) + let needsActionOlder = PRState.fixture( + number: 5, + reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 2_000) + ) + + appState.prs = [waitingOlder, needsActionOlder, draftNewest, waitingNewer, needsActionNewer] + + #expect(appState.prs.map(\.number) == [2, 5, 4, 1, 3]) + #expect(appState.needsActionPRs.map(\.number) == [2, 5]) + #expect(appState.waitingOnOthersPRs.map(\.number) == [4, 1]) + #expect(appState.yourDraftPRs.map(\.number) == [3]) + } + + @Test + func yourDraftPRs_containsDraftsCreatedByMeOrAssignedToMe() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.prs = [ + .fixture(number: 1, draftStatus: .draft, createdByMe: true), + .fixture(number: 2, draftStatus: .draft, assignedToMe: true), + .fixture(number: 3, draftStatus: .draft), // not mine + .fixture(number: 4, reviewRequestedFromMe: true), // needs action (not draft) + ] + + #expect(appState.yourDraftPRs.count == 2) + #expect(appState.yourDraftPRs.map(\.number).contains(1)) + #expect(appState.yourDraftPRs.map(\.number).contains(2)) + #expect(appState.waitingOnOthersPRs.count == 1) + #expect(appState.waitingOnOthersPRs.first?.number == 3) + #expect(appState.needsActionPRs.count == 1) + #expect(appState.needsActionPRs.first?.number == 4) + } + + @Test + func menuBarBadgeCount_excludesYourDrafts() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.prs = [ + .fixture(number: 1, reviewRequestedFromMe: true), // needs action + .fixture(number: 2, draftStatus: .draft, createdByMe: true), // your draft + ] + + #expect(appState.menuBarBadgeCount == 1) + } + + @Test + func emptyStateMessage_authenticatedExplainsTrackedScopeAndRepoAccess() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.authenticationStatus = .authenticated(username: "octocat") + + #expect( + appState.emptyStateMessage + == "This app tracks pull requests involving @octocat. " + + "If you expected results here, make sure your GitHub token can access those repositories." + ) + } + + @Test + func applyPollSnapshot_updatesPanelAndAuthState() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + let primaryPR = PRState.fixture(number: 1, reviewRequestedFromMe: true) + let closedPR = PRState.fixture(number: 99) + let timestamp = Date(timeIntervalSince1970: 12_345) + let snapshot = AppState.PollSnapshot( + panel: .init( + triageSnapshot: .build(from: [primaryPR]), + recentlyClosedPRs: [closedPR], + lastUpdated: timestamp, + isLoading: false, + warningMessage: "Results truncated", + error: nil, + isStale: false + ), + auth: .init( + authenticationStatus: .authenticated(username: "octocat"), + tokenValidationWarningMessage: nil + ) + ) + + appState.applyPollSnapshot(snapshot) + + #expect(appState.panel.triageSnapshot.prs.map(\.number) == [1]) + #expect(appState.panel.recentlyClosedPRs.map(\.number) == [99]) + #expect(appState.panel.lastUpdated == timestamp) + #expect(appState.panel.isLoading == false) + #expect(appState.panel.warningMessage == "Results truncated") + #expect(appState.panel.triageSnapshot.menuBarBadgeCount == 1) + + assertAuthenticated( + appState: appState, + expectedUsername: "octocat" + ) + } + + @Test + func authStateMutation_preservesExistingPanelSnapshot() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + let existingPRs = [ + PRState.fixture(number: 1, reviewRequestedFromMe: true), + PRState.fixture(number: 2), + ] + appState.applyPollSnapshot( + .init( + panel: .init( + triageSnapshot: .build(from: existingPRs), + recentlyClosedPRs: [], + lastUpdated: nil, + isLoading: false, + warningMessage: nil, + error: nil, + isStale: false + ), + auth: .init(authenticationStatus: .unknown, tokenValidationWarningMessage: nil) + ) + ) + let originalSnapshot = appState.panel.triageSnapshot + + appState.auth.authenticationStatus = .authenticated(username: "octocat") + + #expect(appState.panel.triageSnapshot == originalSnapshot) + } + + @Test + func panelContentState_warningWithExistingPRs_staysPrList() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.applyPollSnapshot( + .init( + panel: .init( + triageSnapshot: .build(from: [.fixture(number: 1, reviewRequestedFromMe: true)]), + recentlyClosedPRs: [], + lastUpdated: nil, + isLoading: false, + warningMessage: "Results truncated", + error: nil, + isStale: false + ), + auth: .init( + authenticationStatus: .authenticated(username: "octocat"), + tokenValidationWarningMessage: nil + ) + ) + ) + + #expect(appState.panelContentState == .prList) + } + + @Test + func healthStatus_warningDoesNotOverrideExistingTriageHealth() { + let appState = AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {} + ) + appState.isLoading = false + appState.prs = [.fixture(number: 1, reviewRequestedFromMe: true)] + appState.panel.warningMessage = "Results truncated" + + #expect(appState.healthStatus == .yellow) + } + + private func assertAuthenticated( + appState: AppState, + expectedUsername: String + ) { + guard case .authenticated(let username) = appState.auth.authenticationStatus else { + Issue.record("Expected authenticated auth state") + return + } + + #expect(username == expectedUsername) + } +} diff --git a/GitHubCommandCenterTests/EnvironmentTokenBootstrapperTests.swift b/GitHubCommandCenterTests/EnvironmentTokenBootstrapperTests.swift new file mode 100644 index 0000000..f325070 --- /dev/null +++ b/GitHubCommandCenterTests/EnvironmentTokenBootstrapperTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct EnvironmentTokenBootstrapperTests { + @Test + func preloadIfNeeded_environmentVariableSavesTokenToKeychain() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: ["GITHUB_TOKEN": "ghp_from_env"], + searchRoots: [] + ) + + #expect(bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == "ghp_from_env") + } + + @Test + func preloadIfNeeded_existingKeychainTokenIsPreserved() throws { + let service = makeService() + try service.saveToken("ghp_existing") + defer { try? service.deleteToken() } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: ["GITHUB_TOKEN": "ghp_from_env"], + searchRoots: [] + ) + + #expect(!bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == "ghp_existing") + } + + @Test + func preloadIfNeeded_envFileInAncestorDirectoryIsLoaded() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let nestedDirectory = + rootDirectory + .appendingPathComponent("build/DerivedData/Build/Products/Debug", isDirectory: true) + + try FileManager.default.createDirectory(at: nestedDirectory, withIntermediateDirectories: true) + try "GITHUB_TOKEN=ghp_from_file\n".write( + to: rootDirectory.appendingPathComponent(".env.local"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: [:], + searchRoots: [nestedDirectory] + ) + + #expect(bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == "ghp_from_file") + } + + @Test + func preloadIfNeeded_envFileParsesExportedQuotedToken() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try "export GITHUB_TOKEN=\"ghp_from_quoted_file\" # local token\n".write( + to: rootDirectory.appendingPathComponent(".env"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: [:], + searchRoots: [rootDirectory] + ) + + #expect(bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == "ghp_from_quoted_file") + } + + @Test + func preloadIfNeeded_ignoresUnterminatedQuotedToken() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try "export GITHUB_TOKEN=\"ghp_missing_quote\n".write( + to: rootDirectory.appendingPathComponent(".env"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: [:], + searchRoots: [rootDirectory] + ) + + #expect(!bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == nil) + } + + @Test + func defaultSearchRoots_putsDevEnvSearchRootFromPlistFirst() throws { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let devRootDir = base.appendingPathComponent("repo_root", isDirectory: true) + try FileManager.default.createDirectory(at: devRootDir, withIntermediateDirectories: true) + + let bundleURL = base.appendingPathComponent("Test.bundle", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + + let devRoot = devRootDir.path + let plist: [String: Any] = ["CFBundleIdentifier": "test.bundle", "DevEnvSearchRoot": devRoot] + let plistData = try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + try plistData.write(to: contentsURL.appendingPathComponent("Info.plist")) + defer { try? FileManager.default.removeItem(at: base) } + + let bundle = try #require(Bundle(url: bundleURL)) + let roots = EnvironmentTokenBootstrapper.defaultSearchRoots(bundle: bundle) + + #expect(roots.count >= 1) + #expect(roots[0].standardizedFileURL.path == devRootDir.standardizedFileURL.path) + } + + @Test + func defaultSearchRoots_omitsDevRootWhenPlistEmptyOrMissing() throws { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let bundleURL = base.appendingPathComponent("Test.bundle", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + + let plist: [String: Any] = [ + "CFBundleIdentifier": "test.bundle", + "DevEnvSearchRoot": " \n ", + ] + let plistData = try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + try plistData.write(to: contentsURL.appendingPathComponent("Info.plist")) + defer { try? FileManager.default.removeItem(at: base) } + + let bundle = try #require(Bundle(url: bundleURL)) + let roots = EnvironmentTokenBootstrapper.defaultSearchRoots(bundle: bundle) + + // Expected roots: current working directory and the bundle's parent directory. + #expect(roots.count == 2) + } + + @Test + func preloadIfNeeded_respectsConfiguredSearchDepthLimit() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executablePath = + rootDirectory + .appendingPathComponent( + "Build/Products/Debug/GitHubCommandCenter.app/Contents/MacOS/GitHubCommandCenter", + isDirectory: false + ) + + try FileManager.default.createDirectory( + at: executablePath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "GITHUB_TOKEN=ghp_depth_limited\n".write( + to: rootDirectory.appendingPathComponent(".env.local"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: [:], + searchRoots: [executablePath], + searchDepthLimit: 3 + ) + + let preloaded = bootstrapper.preloadIfNeeded() + + #expect(preloaded == false) + #expect(try service.loadToken() == nil) + } + + @Test + func preloadIfNeeded_envFileInAncestorDirectoryIsLoadedFromExecutableLikePath() throws { + let service = makeService() + defer { try? service.deleteToken() } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executablePath = + rootDirectory + .appendingPathComponent( + "Build/Products/Debug/GitHubCommandCenter.app/Contents/MacOS/GitHubCommandCenter", + isDirectory: false + ) + + try FileManager.default.createDirectory( + at: executablePath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "GITHUB_TOKEN=ghp_from_executable_path\n".write( + to: rootDirectory.appendingPathComponent(".env.local"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let bootstrapper = EnvironmentTokenBootstrapper( + keychain: service, + environment: [:], + searchRoots: [executablePath] + ) + + #expect(bootstrapper.preloadIfNeeded()) + #expect(try service.loadToken() == "ghp_from_executable_path") + } + + private func makeService() -> KeychainService { + KeychainService(serviceName: "com.githubcommandcenter.tests.\(UUID().uuidString)") + } +} diff --git a/GitHubCommandCenterTests/GitHubRESTClientMergeStateTests.swift b/GitHubCommandCenterTests/GitHubRESTClientMergeStateTests.swift new file mode 100644 index 0000000..9d2664c --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientMergeStateTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +extension GitHubRESTClientTests { + @Test + func fetchAllPRStates_behindMergeableState_mapsToBehind() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "behind") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .behind) + } + + @Test + func fetchAllPRStates_hasHooksMergeableState_mapsToBlocked() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "has_hooks") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .blocked) + } + + @Test + func fetchAllPRStates_nilMergeableState_retriesUsingInjectedDelay() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "Test PR", + "html_url": "https://github.com/org/repo/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/org/repo", + ] + ], + ] + ) + MockURLProtocol.stub(urlContains: "/pulls/1/reviews?per_page=100&page=1", json: []) + MockURLProtocol.stub(urlContains: "/pulls/1/reviews?per_page=100&page=2", json: []) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/status", + json: [ + "state": "success", + "statuses": [], + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": NSNull(), + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "behind", + ] + ) + + let client = GitHubRESTClient( + token: "test-token", + session: harness.session, + mergeabilityRetryDelayNanoseconds: 0 + ) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .behind) + let detailRequests = MockURLProtocol.capturedRequests.filter { + ($0.url?.absoluteString.contains("/pulls/1") ?? false) + && ($0.url?.absoluteString.contains("/reviews") ?? false) == false + } + #expect(detailRequests.count == 2) + } + + @Test + func resolveDisappearedPRs_unknownMergeableStateWithRetryDisabled_doesNotRetry() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/repos/org/repo/pulls/1", + persistent: true, + json: [ + "head": ["sha": "abc123def456"], + "state": "closed", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "unknown", + ] + ) + + let client = GitHubRESTClient( + token: "test-token", + session: harness.session, + mergeabilityRetryDelayNanoseconds: 0 + ) + let confirmed = await client.resolveDisappearedPRs([.fixture(number: 1, repoFullName: "org/repo")]) + + #expect(confirmed.map(\.number) == [1]) + let detailRequests = MockURLProtocol.capturedRequests.filter { + ($0.url?.absoluteString.contains("/repos/org/repo/pulls/1") ?? false) + && ($0.url?.absoluteString.contains("/reviews") ?? false) == false + } + #expect(detailRequests.count == 1) + } +} diff --git a/GitHubCommandCenterTests/GitHubRESTClientPaginationTests.swift b/GitHubCommandCenterTests/GitHubRESTClientPaginationTests.swift new file mode 100644 index 0000000..860bff7 --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientPaginationTests.swift @@ -0,0 +1,210 @@ +import Testing + +@testable import GitHubCommandCenter + +extension GitHubRESTClientTests { + @Test + func validateToken_persistentStub_supportsRepeatedIdenticalRequests() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + persistent: true, + json: ["login": "octocat"] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let first = try await client.validateToken() + let second = try await client.validateToken() + + #expect(first == "octocat") + #expect(second == "octocat") + #expect(MockURLProtocol.capturedRequests.count == 2) + } + + @Test + func validateTokenForAppAccess_largeSearchResultOnlyRequestsFirstPage() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + persistent: true, + json: ["login": "octocat"] + ) + + MockURLProtocol.stub( + urlContains: "per_page=1&page=1&sort=updated&order=desc", + json: [ + "total_count": 101, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "PR 1", + "html_url": "https://github.com/org/repo/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/org/repo", + ] + ], + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/status", + json: [ + "state": "success", + "statuses": [], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let result = try await client.validateTokenForAppAccess() + + #expect(result == .verified(username: "octocat")) + let searchRequests = MockURLProtocol.capturedRequests.filter { + $0.url?.absoluteString.contains("/search/issues") == true + } + #expect(searchRequests.count == 1) + #expect(searchRequests.first?.url?.absoluteString.contains("per_page=1&page=1") == true) + } + + @Test + func fetchAllPRStates_aggregatesReviewsAcrossPages() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "Test PR", + "html_url": "https://github.com/owner/repo/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/owner/repo", + ] + ], + ] + ) + for page in 1...9 { + MockURLProtocol.stub( + urlContains: "/pulls/1/reviews?per_page=100&page=\(page)", + json: (0..<100).map { reviewIndex in + [ + "user": ["login": "alice\(page)-\(reviewIndex)"], + "state": "APPROVED", + ] + } + ) + } + MockURLProtocol.stub( + urlContains: "/pulls/1/reviews?per_page=100&page=10", + json: (0..<20).map { reviewIndex in + [ + "user": ["login": "alice10-\(reviewIndex)"], + "state": "APPROVED", + ] + } + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/status", + json: [ + "state": "success", + "statuses": [], + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + let pageRequests = MockURLProtocol.capturedRequests.filter { + $0.url?.absoluteString.contains("/pulls/1/reviews?per_page=100&page=") == true + } + #expect(pageRequests.count == 10) + #expect(pageRequests.contains { $0.url?.absoluteString.contains("page=1") == true }) + #expect(pageRequests.contains { $0.url?.absoluteString.contains("page=10") == true }) + #expect(pageRequests.contains { $0.url?.absoluteString.contains("page=11") == true } == false) + + if case .approved(let reviewers) = prs.first?.reviewStatus { + #expect(reviewers.count == 920) + #expect(reviewers.contains("alice1-0")) + #expect(reviewers.contains("alice9-99")) + #expect(reviewers.contains("alice10-19")) + } else { + Issue.record("Expected review status aggregated from all review pages") + } + } + + @Test + func fetchAllPRStates_searchCap_returnsWarningAndStopsAtPage10() async throws { + let harness = Harness() + defer { harness.teardown() } + + for page in 1...10 { + let items: [[String: Any]] = (1...100).map { offset in + let number = ((page - 1) * 100) + offset + return [ + "number": number, + "title": "PR \(number)", + "html_url": "https://github.com/org/repo/pull/\(number)", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "not-a-github-repository-url", + ] + } + let searchURL = + "/search/issues?q=is:pr%20is:open%20involves:octocat&per_page=100&page=\(page)&sort=updated&order=desc" + MockURLProtocol.stub( + urlContains: searchURL, + json: [ + "total_count": 1500, + "incomplete_results": false, + "items": items, + ] + ) + } + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let result = try await client.fetchAllPRStates(username: "octocat") + + #expect(result.prs.isEmpty) + #expect( + result.warningMessage + == "Showing the first 1,000 matching pull requests due to GitHub search limits." + ) + let searchRequests = MockURLProtocol.capturedRequests.filter { + $0.url?.absoluteString.contains("/search/issues") == true + } + #expect(searchRequests.count == 10) + #expect(searchRequests.contains { $0.url?.absoluteString.contains("page=11") == true } == false) + } +} diff --git a/GitHubCommandCenterTests/GitHubRESTClientTestSupport.swift b/GitHubCommandCenterTests/GitHubRESTClientTestSupport.swift new file mode 100644 index 0000000..f3ed1ee --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientTestSupport.swift @@ -0,0 +1,89 @@ +import Foundation + +@testable import GitHubCommandCenter + +final class Harness { + let session: URLSession + + init() { + MockURLProtocol.reset() + session = MockURLProtocol.makeSession() + } + + func teardown() { + MockURLProtocol.reset() + } +} + +extension GitHubRESTClientTests { + func stubFullPRFlow( + number: Int = 42, + title: String = "Fix the bug", + owner: String = "org", + repo: String = "app", + authorLogin: String = "octocat", + requestedReviewers: [[String: Any]] = [], + assignees: [[String: Any]] = [], + mergeableState: String = "clean", + reviews: [[String: Any]] = [], + checkRuns: [[String: Any]] = [["name": "CI", "status": "completed", "conclusion": "success"]], + checkRunsStatusCode: Int = 200, + updatedAt: String = "2026-03-30T10:00:00.000Z", + statusState: String = "success", + statusCodeForCommitStatuses: Int = 200, + commitStatuses: [[String: Any]] = [] + ) { + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": false, + "items": [ + [ + "number": number, + "title": title, + "html_url": "https://github.com/\(owner)/\(repo)/pull/\(number)", + "draft": false, + "updated_at": updatedAt, + "repository_url": "https://api.github.com/repos/\(owner)/\(repo)", + ] + ], + ] + ) + MockURLProtocol.stub(urlContains: "/pulls/\(number)/reviews?per_page=100&page=1", json: reviews) + MockURLProtocol.stub(urlContains: "/pulls/\(number)/reviews?per_page=100&page=2", json: []) + if checkRunsStatusCode == 200 { + MockURLProtocol.stub( + urlContains: "/check-runs", + json: ["total_count": checkRuns.count, "check_runs": checkRuns] + ) + } else { + MockURLProtocol.stub(urlContains: "/check-runs", statusCode: checkRunsStatusCode) + } + if statusCodeForCommitStatuses == 200 { + MockURLProtocol.stub( + urlContains: "/status", + json: [ + "state": statusState, + "statuses": commitStatuses, + ] + ) + } else { + MockURLProtocol.stub(urlContains: "/status", statusCode: statusCodeForCommitStatuses) + } + MockURLProtocol.stub( + description: "pull details \(owner)/\(repo)#\(number)", + matching: { url in + url.path == "/repos/\(owner)/\(repo)/pulls/\(number)" + }, + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": authorLogin], + "assignees": assignees, + "requested_reviewers": requestedReviewers, + "mergeable_state": mergeableState, + ] + ) + } +} diff --git a/GitHubCommandCenterTests/GitHubRESTClientTests.swift b/GitHubCommandCenterTests/GitHubRESTClientTests.swift new file mode 100644 index 0000000..a9c3f81 --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientTests.swift @@ -0,0 +1,917 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +// swiftlint:disable file_length type_body_length + +@Suite(.serialized) +struct GitHubRESTClientTests { + @Test + func validateToken_success_returnsUsername() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let username = try await client.validateToken() + + #expect(username == "octocat") + } + + @Test + func validateToken_401_throwsAuthError() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", statusCode: 401) + let client = GitHubRESTClient(token: "bad-token", session: harness.session) + + do { + _ = try await client.validateToken() + Issue.record("Expected authError") + } catch let error as AppError { + #expect(error == .authError) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateToken_403_throwsAuthError() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", statusCode: 403) + let client = GitHubRESTClient(token: "forbidden-token", session: harness.session) + + do { + _ = try await client.validateToken() + Issue.record("Expected authError") + } catch let error as AppError { + #expect(error == .authError) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateToken_403RateLimit_throwsRateLimitExceeded() async { + let harness = Harness() + defer { harness.teardown() } + let resetTS = Int(Date().timeIntervalSince1970) + 120 + MockURLProtocol.stub( + urlContains: "/user", + statusCode: 403, + headers: [ + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": "\(resetTS)", + ], + json: ["message": "API rate limit exceeded"] + ) + + let client = GitHubRESTClient(token: "throttled-token", session: harness.session) + + do { + _ = try await client.validateToken() + Issue.record("Expected rateLimitExceeded") + } catch AppError.rateLimitExceeded(let context) { + #expect(context.resetAt > Date()) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateToken_429_throwsRateLimitExceeded() async { + let harness = Harness() + defer { harness.teardown() } + let resetTS = Int(Date().timeIntervalSince1970) + 3600 + MockURLProtocol.stub( + urlContains: "/user", + statusCode: 429, + headers: ["X-RateLimit-Reset": "\(resetTS)"] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.validateToken() + Issue.record("Expected rateLimitExceeded") + } catch AppError.rateLimitExceeded(let context) { + #expect(context.resetAt > Date()) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateToken_500_throwsServerError() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", statusCode: 500) + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.validateToken() + Issue.record("Expected serverError") + } catch AppError.serverError(let code) { + #expect(code == 500) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateToken_304WithoutCachedResponse_retriesWithoutETag() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", statusCode: 304) + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let username = try await client.validateToken() + + #expect(username == "octocat") + #expect(MockURLProtocol.capturedRequests.count == 2) + } + + @Test + func fetchAllPRStates_duplicateFailingCheckAcrossApis_deduplicatesDisplayedChecksAndTotals() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + checkRuns: [ + [ + "name": "Unit Tests", + "status": "completed", + "conclusion": "failure", + "html_url": "https://github.com/org/app/actions/runs/1", + ] + ], + statusState: "failure", + commitStatuses: [ + [ + "context": "Unit Tests", + "state": "failure", + "target_url": "https://github.com/org/app/actions/runs/1", + ] + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let pr = try #require(try await client.fetchAllPRStates(username: "octocat").first) + + if case .failing(let checks, let totalChecks) = pr.ciStatus { + #expect(checks.count == 1) + #expect(checks.map(\.name) == ["Unit Tests"]) + #expect(totalChecks == 1) + } else { + Issue.record("Expected failing CI status") + } + } + + @Test + func fetchAllPRStates_emptySearch_returnsEmptyArray() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 0, + "incomplete_results": false, + "items": [], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.isEmpty) + } + + @Test + func fetchAllPRStates_singlePR_buildsCorrectState() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow() + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + let pr = try #require(prs.first) + + #expect(prs.count == 1) + #expect(pr.number == 42) + #expect(pr.title == "Fix the bug") + #expect(pr.repoFullName == "org/app") + #expect(pr.assignment.createdByMe) + #expect(pr.ciStatus == .passing) + #expect(pr.mergeStatus == .ready) + } + + @Test + func fetchAllPRStates_whenOnePRBuildFails_throwsInsteadOfDroppingIt() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 2, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "Healthy PR", + "html_url": "https://github.com/org/one/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/org/one", + ], + [ + "number": 2, + "title": "Broken PR", + "html_url": "https://github.com/org/two/pull/2", + "draft": false, + "updated_at": "2026-03-30T11:00:00Z", + "repository_url": "https://api.github.com/repos/org/two", + ], + ], + ] + ) + + MockURLProtocol.stub(urlContains: "/repos/org/one/pulls/1/reviews?per_page=100&page=1", json: []) + MockURLProtocol.stub( + urlContains: "/repos/org/one/commits/sha-one/status", + json: ["state": "success", "statuses": []] + ) + MockURLProtocol.stub( + urlContains: "/repos/org/one/commits/sha-one/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub( + urlContains: "/repos/org/one/pulls/1", + json: [ + "head": ["sha": "sha-one"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + + MockURLProtocol.stub(urlContains: "/repos/org/two/pulls/2/reviews?per_page=100&page=1", json: []) + MockURLProtocol.stub( + urlContains: "/repos/org/two/commits/sha-two/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub(urlContains: "/repos/org/two/commits/sha-two/status", statusCode: 500) + MockURLProtocol.stub( + urlContains: "/repos/org/two/pulls/2", + json: [ + "head": ["sha": "sha-two"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.fetchAllPRStates(username: "octocat") + Issue.record("Expected fetchAllPRStates to throw when one PR build fails") + } catch AppError.serverError(let code) { + #expect(code == 500) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func fetchAllPRStates_sameNumberDifferentRepos_haveDistinctIDs() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 2, + "incomplete_results": false, + "items": [ + [ + "number": 42, + "title": "Repo one", + "html_url": "https://github.com/org/one/pull/42", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/org/one", + ], + [ + "number": 42, + "title": "Repo two", + "html_url": "https://github.com/org/two/pull/42", + "draft": false, + "updated_at": "2026-03-30T11:00:00Z", + "repository_url": "https://api.github.com/repos/org/two", + ], + ], + ] + ) + + for repo in ["one", "two"] { + MockURLProtocol.stub(urlContains: "/repos/org/\(repo)/pulls/42/reviews", json: []) + MockURLProtocol.stub( + urlContains: "/repos/org/\(repo)/commits/abc123def456/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub( + urlContains: "/repos/org/\(repo)/commits/abc123def456/status", + json: [ + "state": "success", + "statuses": [], + ] + ) + MockURLProtocol.stub( + urlContains: "/repos/org/\(repo)/pulls/42", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + } + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.count == 2) + #expect(Set(prs.map(\.id)).count == 2) + } + + @Test + func fetchAllPRStates_unknownMergeableState_mapsToPending() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "future_unknown_value") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .pending) + } + + @Test + func fetchAllPRStates_dirtyMergeableState_mapsToConflicts() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "dirty") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .conflicts) + } + + @Test + func fetchAllPRStates_blockedMergeableState_mapsToBlocked() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "blocked") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .blocked) + } + + @Test + func fetchAllPRStates_unstableMergeableState_mapsToReady() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, mergeableState: "unstable") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.mergeStatus == .ready) + } + + @Test + func fetchAllPRStates_failingCheckRuns_mapsToCIFailing() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + mergeableState: "clean", + checkRuns: [ + ["name": "unit-tests", "status": "completed", "conclusion": "failure"], + ["name": "lint", "status": "completed", "conclusion": "success"], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + let pr = try #require(prs.first) + + if case .failing(let checks, let total) = pr.ciStatus { + #expect(checks.map(\.name) == ["unit-tests"]) + #expect(checks[0].conclusion == "failure" && total == 2) + } else { + Issue.record("Expected .failing, got \(pr.ciStatus)") + } + } + + @Test + func fetchAllPRStates_checkRuns403_fallsBackToCommitStatuses() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + checkRunsStatusCode: 403, + statusState: "failure", + commitStatuses: [ + [ + "context": "legacy-ci", + "state": "failure", + ] + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + let pr = try #require(prs.first) + + if case .failing(let checks, _) = pr.ciStatus { + #expect(checks.map(\.name) == ["legacy-ci"]) + } else { + Issue.record("Expected .failing, got \(pr.ciStatus)") + } + } + + @Test + func fetchAllPRStates_checkRuns401_returnsPRWithNoCIStatus() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, checkRunsStatusCode: 401) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + let pr = try #require(prs.first) + #expect(pr.ciStatus == .none) + } + + @Test + func fetchAllPRStates_commitStatuses403_throwsAuthError() async { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, statusCodeForCommitStatuses: 403) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.fetchAllPRStates(username: "octocat") + Issue.record("Expected authError") + } catch let error as AppError { + #expect(error == .authError) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func fetchAllPRStates_commitStatusesRateLimited_throwsRateLimitExceeded() async { + let harness = Harness() + defer { harness.teardown() } + let resetTS = Int(Date().timeIntervalSince1970) + 120 + + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "Test PR", + "html_url": "https://github.com/org/app/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00.000Z", + "repository_url": "https://api.github.com/repos/org/app", + ] + ], + ] + ) + MockURLProtocol.stub(urlContains: "/pulls/1/reviews?per_page=100&page=1", json: []) + MockURLProtocol.stub(urlContains: "/pulls/1/reviews?per_page=100&page=2", json: []) + MockURLProtocol.stub(urlContains: "/check-runs", json: ["total_count": 0, "check_runs": []]) + MockURLProtocol.stub( + urlContains: "/status", + statusCode: 403, + headers: [ + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": "\(resetTS)", + ], + json: ["message": "API rate limit exceeded"] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.fetchAllPRStates(username: "octocat") + Issue.record("Expected rateLimitExceeded") + } catch AppError.rateLimitExceeded(let context) { + #expect(context.resetAt > Date()) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func fetchAllPRStates_failedCheckWinsOverPendingCheck() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + checkRuns: [ + ["name": "unit-tests", "status": "completed", "conclusion": "failure"], + ["name": "integration", "status": "in_progress"], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + let pr = try #require(prs.first) + + if case .failing(let checks, let total) = pr.ciStatus { + #expect(checks.map(\.name) == ["unit-tests"]) + #expect(total == 2) + } else { + Issue.record("Expected .failing, got \(pr.ciStatus)") + } + } + + @Test + func fetchAllPRStates_pendingCheckRuns_mapsToCIPending() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + checkRuns: [["name": "build", "status": "in_progress"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.ciStatus == .pending) + } + + @Test + func fetchAllPRStates_noCheckRuns_mapsToCINone() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, checkRuns: []) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.ciStatus == PRState.CIStatus.none) + } + + @Test + func fetchAllPRStates_commitStatusFailureWithoutCheckRuns_mapsToCIFailing() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + checkRuns: [], + statusState: "failure", + commitStatuses: [ + [ + "context": "legacy-ci", + "state": "failure", + ] + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + let pr = try #require(prs.first) + + if case .failing(let checks, _) = pr.ciStatus { + #expect(checks.map(\.name) == ["legacy-ci"]) + } else { + Issue.record("Expected .failing, got \(pr.ciStatus)") + } + } + + @Test + func fetchAllPRStates_reviewsApproved_mapsToApproved() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + reviews: [["user": ["login": "alice"], "state": "APPROVED"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .approved(let by) = prs.first?.reviewStatus { + #expect(by == ["alice"]) + } else { + Issue.record("Expected .approved, got \(String(describing: prs.first?.reviewStatus))") + } + } + + @Test + func fetchAllPRStates_reviewChangesRequested_mapsToChangesRequested() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + reviews: [["user": ["login": "bob"], "state": "CHANGES_REQUESTED"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .changesRequested(let by) = prs.first?.reviewStatus { + #expect(by == ["bob"]) + } else { + Issue.record("Expected .changesRequested") + } + } + + @Test + func fetchAllPRStates_requestedReviewers_mapsToRequested() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + requestedReviewers: [["login": "carol"]], + reviews: [] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .requested(let by) = prs.first?.reviewStatus { + #expect(by == ["carol"]) + } else { + Issue.record("Expected .requested, got \(String(describing: prs.first?.reviewStatus))") + } + } + + @Test + func fetchAllPRStates_requestedReviewers_overridePriorApproval() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + requestedReviewers: [["login": "carol"]], + reviews: [["user": ["login": "alice"], "state": "APPROVED"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .requested(let by) = prs.first?.reviewStatus { + #expect(by == ["carol"]) + } else { + Issue.record("Expected .requested when new reviewers are still outstanding") + } + } + + @Test + func fetchAllPRStates_changesRequested_overrideRequestedReviewers() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + requestedReviewers: [["login": "carol"]], + reviews: [["user": ["login": "alice"], "state": "CHANGES_REQUESTED"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .changesRequested(let by) = prs.first?.reviewStatus { + #expect(by == ["alice"]) + } else { + Issue.record("Expected .changesRequested to keep highest precedence") + } + } + + @Test + func fetchAllPRStates_paginatedReviews_useLatestPage() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": false, + "items": [ + [ + "number": 1, + "title": "Test PR", + "html_url": "https://github.com/owner/repo/pull/1", + "draft": false, + "updated_at": "2026-03-30T10:00:00Z", + "repository_url": "https://api.github.com/repos/owner/repo", + ] + ], + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1/reviews?per_page=100&page=1", + json: Array( + repeating: [ + "user": ["login": "alice"], + "state": "APPROVED", + ], + count: 100 + ) + ) + MockURLProtocol.stub( + urlContains: "/pulls/1/reviews?per_page=100&page=2", + json: [ + [ + "user": ["login": "alice"], + "state": "CHANGES_REQUESTED", + ] + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1/reviews", + json: Array( + repeating: [ + "user": ["login": "alice"], + "state": "APPROVED", + ], + count: 30 + ) + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/check-runs", + json: ["total_count": 0, "check_runs": []] + ) + MockURLProtocol.stub( + urlContains: "/commits/abc123def456/status", + json: [ + "state": "success", + "statuses": [], + ] + ) + MockURLProtocol.stub( + urlContains: "/pulls/1", + json: [ + "head": ["sha": "abc123def456"], + "state": "open", + "user": ["login": "octocat"], + "assignees": [], + "requested_reviewers": [], + "mergeable_state": "clean", + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + if case .changesRequested(let by) = prs.first?.reviewStatus { + #expect(by == ["alice"]) + } else { + Issue.record("Expected .changesRequested from later review page") + } + } + + @Test + func fetchAllPRStates_dismissedReview_clearsPriorApproval() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow( + number: 1, + reviews: [ + ["user": ["login": "alice"], "state": "APPROVED"], + ["user": ["login": "alice"], "state": "DISMISSED"], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + let pr = try #require(prs.first) + #expect(pr.reviewStatus == .none) + } + + @Test + func fetchAllPRStates_parsesUpdatedAtWithoutFractionalSeconds() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, updatedAt: "2026-03-30T10:00:00Z") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect( + prs.first?.updatedAt == ISO8601DateFormatter().date(from: "2026-03-30T10:00:00Z") + ) + } + + @Test + func fetchAllPRStates_invalidUpdatedAtFallsBackToDistantPast() async throws { + let harness = Harness() + defer { harness.teardown() } + stubFullPRFlow(number: 1, updatedAt: "definitely-not-a-date") + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let prs = try await client.fetchAllPRStates(username: "octocat") + + #expect(prs.first?.updatedAt == .distantPast) + } + + @Test + func fetchAllPRStates_etag304_returnsCachedData() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + headers: ["ETag": "\"abc\""], + json: ["login": "octocat"] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + _ = try await client.validateToken() + + MockURLProtocol.reset() + MockURLProtocol.stub(urlContains: "/user", statusCode: 304) + + let username = try await client.validateToken() + #expect(username == "octocat") + } + + @Test + func validateToken_cacheEvictionRetainsMostRecentlyUsedEntry() async throws { + let harness = Harness() + defer { harness.teardown() } + let expectedWarning = + "Token saved, but full PR status access could not be verified yet. " + + "The warning will clear after a successful poll loads PR status data." + MockURLProtocol.stub( + urlContains: "/user/repos?affiliation=owner,collaborator,organization_member&per_page=100&page=1", + headers: ["ETag": "\"repos-v1\""], + json: [] + ) + MockURLProtocol.stub( + urlContains: "/user", + headers: ["ETag": "\"user-v1\""], + json: ["login": "octocat"] + ) + MockURLProtocol.stub( + urlContains: "/user", + headers: ["ETag": "\"user-v1\""], + json: ["login": "octocat"] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session, maxCacheEntries: 2) + _ = try await client.validateToken() + _ = try await client.fetchTokenAccessDetails() + + MockURLProtocol.reset() + MockURLProtocol.stub(urlContains: "/user", statusCode: 304) + MockURLProtocol.stub( + urlContains: "/search/issues", + headers: ["ETag": "\"search-v1\""], + json: [ + "total_count": 0, + "incomplete_results": false, + "items": [], + ] + ) + + let validation = try await client.validateTokenForAppAccess() + #expect(validation == .warning(username: "octocat", message: expectedWarning)) + + MockURLProtocol.reset() + MockURLProtocol.stub(urlContains: "/user", statusCode: 304) + + let username = try await client.validateToken() + #expect(username == "octocat") + } +} +// swiftlint:enable file_length type_body_length diff --git a/GitHubCommandCenterTests/GitHubRESTClientTokenAccessTests.swift b/GitHubCommandCenterTests/GitHubRESTClientTokenAccessTests.swift new file mode 100644 index 0000000..02bcb51 --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientTokenAccessTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +extension GitHubRESTClientTests { + private func userReposPageMatcher(page: Int) -> @Sendable (URL) -> Bool { + { url in + guard url.path == "/user/repos" else { return false } + let queryItems = Set( + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .percentEncodedQuery? + .split(separator: "&") + .map(String.init) ?? [] + ) + return queryItems.contains("affiliation=owner,collaborator,organization_member") + && queryItems.contains("per_page=100") + && queryItems.contains("page=\(page)") + } + } + + @Test + func fetchTokenAccessDetails_classicTokenParsesReportedScopes() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + headers: ["X-OAuth-Scopes": "repo, workflow"], + json: ["login": "octocat"] + ) + MockURLProtocol.stub( + description: "user repos page 1", + matching: userReposPageMatcher(page: 1), + json: [] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let details = try await client.fetchTokenAccessDetails() + + #expect(details.username == "octocat") + #expect(details.oauthScopes == ["repo", "workflow"]) + } + + @Test + func fetchTokenAccessDetails_withoutReportedScopesReturnsEmptyScopeList() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + json: ["login": "octocat"] + ) + MockURLProtocol.stub( + description: "user repos page 1", + matching: userReposPageMatcher(page: 1), + json: [] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let details = try await client.fetchTokenAccessDetails() + + #expect(details.oauthScopes.isEmpty) + } + + @Test + func fetchTokenAccessDetails_accessibleRepositoriesPaginatesAndMapsAccessLevels() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + json: ["login": "octocat"] + ) + + let firstPage: [[String: Any]] = (1...100).map { number in + [ + "id": number, + "full_name": "org/repo-\(number)", + "permissions": [ + "admin": false, + "push": false, + "pull": true, + ], + ] + } + + MockURLProtocol.stub( + description: "user repos page 1", + matching: userReposPageMatcher(page: 1), + json: firstPage + ) + MockURLProtocol.stub( + description: "user repos page 2", + matching: userReposPageMatcher(page: 2), + json: [ + [ + "id": 101, + "full_name": "org/admin-repo", + "permissions": [ + "admin": true, + "push": true, + "pull": true, + ], + ], + [ + "id": 102, + "full_name": "org/write-repo", + "permissions": [ + "admin": false, + "push": true, + "pull": true, + ], + ], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let details = try await client.fetchTokenAccessDetails() + + #expect(details.accessibleRepositories.count == 102) + #expect(details.accessibleRepositories.first?.fullName == "org/repo-1") + #expect(details.accessibleRepositories.first?.accessLevel == .read) + #expect(details.accessibleRepositories[99].fullName == "org/repo-100") + #expect(details.accessibleRepositories[100].fullName == "org/admin-repo") + #expect(details.accessibleRepositories[100].accessLevel == .admin) + #expect(details.accessibleRepositories[101].fullName == "org/write-repo") + #expect(details.accessibleRepositories[101].accessLevel == .write) + } + + @Test + func fetchTokenAccessDetails_accessibleRepositoriesStopsAtConfiguredLimit() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub( + urlContains: "/user", + json: ["login": "octocat"] + ) + + for page in 1...10 { + let repositories: [[String: Any]] = (1...100).map { offset in + let id = ((page - 1) * 100) + offset + return [ + "id": id, + "full_name": "org/repo-\(id)", + "permissions": [ + "admin": false, + "push": false, + "pull": true, + ], + ] + } + MockURLProtocol.stub( + description: "user repos page \(page)", + matching: userReposPageMatcher(page: page), + json: repositories + ) + } + MockURLProtocol.stub( + description: "user repos page 11", + matching: userReposPageMatcher(page: 11), + json: [] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let details = try await client.fetchTokenAccessDetails() + + #expect(details.accessibleRepositories.count == 1000) + #expect(details.accessibleRepositories.last?.fullName == "org/repo-1000") + #expect( + MockURLProtocol.capturedRequests.contains { + $0.url?.absoluteString.contains("page=11") == true + } == false + ) + } +} diff --git a/GitHubCommandCenterTests/GitHubRESTClientValidationTests.swift b/GitHubCommandCenterTests/GitHubRESTClientValidationTests.swift new file mode 100644 index 0000000..fe5e7af --- /dev/null +++ b/GitHubCommandCenterTests/GitHubRESTClientValidationTests.swift @@ -0,0 +1,117 @@ +import Testing + +@testable import GitHubCommandCenter + +extension GitHubRESTClientTests { + @Test + func validateTokenForAppAccess_success_returnsUsernameWhenSearchEmpty() async throws { + let harness = Harness() + defer { harness.teardown() } + let expectedMessage = + "Token saved, but full PR status access could not be verified yet. " + + "The warning will clear after a successful poll loads PR status data." + + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 0, + "incomplete_results": false, + "items": [], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let result = try await client.validateTokenForAppAccess() + + #expect(result == .warning(username: "octocat", message: expectedMessage)) + #expect( + MockURLProtocol.capturedRequests.contains { + ($0.url?.absoluteString.contains("/search/issues") ?? false) + && ($0.url?.absoluteString.contains("per_page=1") ?? false) + } + ) + } + + @Test + func validateTokenForAppAccess_search403_throwsAuthError() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + MockURLProtocol.stub(urlContains: "/search/issues", statusCode: 403) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.validateTokenForAppAccess() + Issue.record("Expected authError") + } catch let error as AppError { + #expect(error == .authError) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateTokenForAppAccess_incompleteSearchResults_returnsWarning() async { + let harness = Harness() + defer { harness.teardown() } + let expectedMessage = + "GitHub search results are temporarily incomplete. " + + "The token was saved, but full PR status access could not be verified yet." + + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + MockURLProtocol.stub( + urlContains: "/search/issues", + json: [ + "total_count": 1, + "incomplete_results": true, + "items": [], + ] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + do { + let result = try await client.validateTokenForAppAccess() + #expect(result == .warning(username: "octocat", message: expectedMessage)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateTokenForAppAccess_nonEmptySearch_commitStatus403_throwsAuthError() async { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + stubFullPRFlow(checkRunsStatusCode: 403, statusCodeForCommitStatuses: 403) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + + do { + _ = try await client.validateTokenForAppAccess() + Issue.record("Expected authError") + } catch let error as AppError { + #expect(error == .authError) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func validateTokenForAppAccess_nonEmptySearch_checkRuns403_stillVerifiesWhenCommitStatusesReadable() async throws { + let harness = Harness() + defer { harness.teardown() } + MockURLProtocol.stub(urlContains: "/user", json: ["login": "octocat"]) + stubFullPRFlow( + checkRunsStatusCode: 403, + statusState: "success", + commitStatuses: [["context": "legacy-ci", "state": "success"]] + ) + + let client = GitHubRESTClient(token: "test-token", session: harness.session) + let result = try await client.validateTokenForAppAccess() + + #expect(result == .verified(username: "octocat")) + } +} diff --git a/GitHubCommandCenterTests/Helpers/MockGitHubDataSource.swift b/GitHubCommandCenterTests/Helpers/MockGitHubDataSource.swift new file mode 100644 index 0000000..c5302ce --- /dev/null +++ b/GitHubCommandCenterTests/Helpers/MockGitHubDataSource.swift @@ -0,0 +1,73 @@ +import Foundation + +@testable import GitHubCommandCenter + +final class MockGitHubDataSource: GitHubDataSource, @unchecked Sendable { + private let stateQueue = DispatchQueue(label: "MockGitHubDataSource.state") + + private var storedValidateTokenResult: Result = .success("testuser") + private var storedFetchResult: Result = .success(.init(prs: [])) + private var storedValidateTokenCallCount = 0 + private var storedFetchCallCount = 0 + private var storedResolvedDisappearedPRs: [PRState] = [] + private var storedLastResolvedDisappearedInput: [PRState] = [] + + var validateTokenResult: Result { + get { stateQueue.sync { storedValidateTokenResult } } + set { stateQueue.sync { storedValidateTokenResult = newValue } } + } + + var fetchResult: Result { + get { stateQueue.sync { storedFetchResult } } + set { stateQueue.sync { storedFetchResult = newValue } } + } + + var resolvedDisappearedPRs: [PRState] { + get { stateQueue.sync { storedResolvedDisappearedPRs } } + set { stateQueue.sync { storedResolvedDisappearedPRs = newValue } } + } + + var lastResolvedDisappearedInput: [PRState] { + get { stateQueue.sync { storedLastResolvedDisappearedInput } } + set { stateQueue.sync { storedLastResolvedDisappearedInput = newValue } } + } + + var validateTokenCallCount: Int { + stateQueue.sync { storedValidateTokenCallCount } + } + + var fetchCallCount: Int { + stateQueue.sync { storedFetchCallCount } + } + + func validateToken() async throws -> String { + let result = stateQueue.sync { + storedValidateTokenCallCount += 1 + return storedValidateTokenResult + } + + switch result { + case .success(let username): return username + case .failure(let error): throw error + } + } + + func fetchAllPRStates(username: String) async throws -> PRFetchResult { + let fetchResult = stateQueue.sync { + storedFetchCallCount += 1 + return storedFetchResult + } + + switch fetchResult { + case .success(let result): return result + case .failure(let error): throw error + } + } + + func resolveDisappearedPRs(_ prs: [PRState]) async -> [PRState] { + stateQueue.sync { + storedLastResolvedDisappearedInput = prs + return storedResolvedDisappearedPRs + } + } +} diff --git a/GitHubCommandCenterTests/Helpers/MockURLProtocol.swift b/GitHubCommandCenterTests/Helpers/MockURLProtocol.swift new file mode 100644 index 0000000..113b6d0 --- /dev/null +++ b/GitHubCommandCenterTests/Helpers/MockURLProtocol.swift @@ -0,0 +1,189 @@ +import Foundation +import XCTest + +/// Pattern-matched URLProtocol for unit testing URLSession-based code. +/// Register stubs before each test; call `reset()` in tearDown. +final class MockURLProtocol: URLProtocol { + enum MockError: Error { + case missingURL + case invalidResponse(url: URL) + } + + private struct Handler: Sendable { + let description: String + let matches: @Sendable (URL) -> Bool + let response: MockResponse + let persistent: Bool + } + + struct MockResponse: Sendable { + let data: Data + let statusCode: Int + let headers: [String: String] + } + + private static let stateQueue = DispatchQueue(label: "MockURLProtocol.state") + private static var handlers: [Handler] = [] + private static var storedCapturedRequests: [URLRequest] = [] + + static var capturedRequests: [URLRequest] { + stateQueue.sync { storedCapturedRequests } + } + + static func reset() { + stateQueue.sync { + handlers = [] + storedCapturedRequests = [] + } + } + + /// Register a JSON response for any URL that contains `pattern`. + static func stub( + urlContains pattern: String, + persistent: Bool = false, + statusCode: Int = 200, + headers: [String: String] = [:], + json: Any + ) { + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: json) + } catch { + XCTFail("Failed to serialize JSON stub for pattern \(pattern): \(error)") + return + } + let handler = Handler( + description: pattern, + matches: { $0.absoluteString.contains(pattern) }, + response: MockResponse(data: data, statusCode: statusCode, headers: headers), + persistent: persistent + ) + stateQueue.sync { + handlers.append(handler) + } + } + + /// Register an empty response (for 304, 401, etc.). + static func stub( + urlContains pattern: String, + persistent: Bool = false, + statusCode: Int, + headers: [String: String] = [:] + ) { + let handler = Handler( + description: pattern, + matches: { $0.absoluteString.contains(pattern) }, + response: MockResponse(data: Data(), statusCode: statusCode, headers: headers), + persistent: persistent + ) + stateQueue.sync { + handlers.append(handler) + } + } + + static func stub( + description: String, + persistent: Bool = false, + statusCode: Int = 200, + headers: [String: String] = [:], + matching matcher: @escaping @Sendable (URL) -> Bool, + json: Any + ) { + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: json) + } catch { + XCTFail("Failed to serialize JSON stub for matcher \(description): \(error)") + return + } + + let handler = Handler( + description: description, + matches: matcher, + response: MockResponse(data: data, statusCode: statusCode, headers: headers), + persistent: persistent + ) + stateQueue.sync { + handlers.append(handler) + } + } + + static func stub( + description: String, + persistent: Bool = false, + statusCode: Int, + headers: [String: String] = [:], + matching matcher: @escaping @Sendable (URL) -> Bool + ) { + let handler = Handler( + description: description, + matches: matcher, + response: MockResponse(data: Data(), statusCode: statusCode, headers: headers), + persistent: persistent + ) + stateQueue.sync { + handlers.append(handler) + } + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } + + // MARK: - URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: MockError.missingURL) + return + } + + let mock = Self.stateQueue.sync { () -> MockResponse? in + Self.storedCapturedRequests.append(request) + + guard let matchIndex = Self.handlers.firstIndex(where: { $0.matches(url) }) else { + return nil + } + + let handler = Self.handlers[matchIndex] + if !handler.persistent { + Self.handlers.remove(at: matchIndex) + } + + return handler.response + } + + guard let mock else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + + var headerFields = mock.headers + if headerFields["Content-Type"] == nil, mock.statusCode != 304 { + headerFields["Content-Type"] = "application/json" + } + + guard + let response = HTTPURLResponse( + url: url, + statusCode: mock.statusCode, + httpVersion: "HTTP/1.1", + headerFields: headerFields + ) + else { + client?.urlProtocol(self, didFailWithError: MockError.invalidResponse(url: url)) + return + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: mock.data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/GitHubCommandCenterTests/Helpers/PRStateFixtures.swift b/GitHubCommandCenterTests/Helpers/PRStateFixtures.swift new file mode 100644 index 0000000..2c53490 --- /dev/null +++ b/GitHubCommandCenterTests/Helpers/PRStateFixtures.swift @@ -0,0 +1,41 @@ +import Foundation + +@testable import GitHubCommandCenter + +extension PRState { + static func fixture( + number: Int = 1, + title: String = "Test PR", + repoFullName: String = "owner/repo", + draftStatus: DraftStatus = .ready, + ciStatus: CIStatus = .passing, + reviewStatus: ReviewStatus = .none, + mergeStatus: MergeStatus = .ready, + createdByMe: Bool = false, + reviewRequestedFromMe: Bool = false, + assignedToMe: Bool = false, + updatedAt: Date = Date(timeIntervalSince1970: 1_743_321_600) + ) -> PRState { + guard let pullRequestURL = URL(string: "https://github.com/\(repoFullName)/pull/\(number)") else { + preconditionFailure("Invalid PR fixture URL for \(repoFullName)#\(number)") + } + + return PRState( + number: number, + title: title, + repoFullName: repoFullName, + url: pullRequestURL, + headSHA: "abc123def456", + draftStatus: draftStatus, + ciStatus: ciStatus, + reviewStatus: reviewStatus, + mergeStatus: mergeStatus, + assignment: Assignment( + createdByMe: createdByMe, + reviewRequestedFromMe: reviewRequestedFromMe, + assignedToMe: assignedToMe + ), + updatedAt: updatedAt + ) + } +} diff --git a/GitHubCommandCenterTests/KeychainServiceTests.swift b/GitHubCommandCenterTests/KeychainServiceTests.swift new file mode 100644 index 0000000..28dd782 --- /dev/null +++ b/GitHubCommandCenterTests/KeychainServiceTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Security +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct KeychainServiceTests { + private enum RawTokenStoreError: LocalizedError { + case deleteFailed(status: OSStatus, serviceName: String) + case addFailed(status: OSStatus, serviceName: String) + + var errorDescription: String? { + switch self { + case .deleteFailed(let status, let serviceName): + return "Failed to clear seeded token data for \(serviceName) (OSStatus \(status))" + case .addFailed(let status, let serviceName): + return "Failed to seed raw token data for \(serviceName) (OSStatus \(status))" + } + } + } + + @Test + func saveAndLoad_returnsStoredToken() throws { + let service = makeService() + defer { try? service.deleteToken() } + try service.saveToken("ghp_test_token_123") + + let loaded = try service.loadToken() + + #expect(loaded == "ghp_test_token_123") + } + + @Test + func load_whenNoTokenStored_returnsNil() throws { + let service = makeService() + + let loaded = try service.loadToken() + + #expect(loaded == nil) + } + + @Test + func save_overwritesPreviousToken() throws { + let service = makeService() + defer { try? service.deleteToken() } + try service.saveToken("old_token") + try service.saveToken("new_token") + + let loaded = try service.loadToken() + + #expect(loaded == "new_token") + } + + @Test + func delete_removesToken() throws { + let service = makeService() + try service.saveToken("to_be_deleted") + try service.deleteToken() + + let loaded = try service.loadToken() + + #expect(loaded == nil) + } + + @Test + func delete_whenNoToken_doesNotThrow() throws { + let service = makeService() + try service.deleteToken() + } + + @Test + func save_emptyString_canBeLoadedBack() throws { + let service = makeService() + defer { try? service.deleteToken() } + try service.saveToken("") + + let loaded = try service.loadToken() + + #expect(loaded == "") + } + + @Test + func save_unicodeToken_roundtrips() throws { + let service = makeService() + defer { try? service.deleteToken() } + let token = "ghp_🔑_test_token" + try service.saveToken(token) + + let loaded = try service.loadToken() + + #expect(loaded == token) + } + + @Test + func load_whenStoredDataIsInvalidUTF8_throwsDecodingFailed() throws { + let serviceName = uniqueServiceName() + let service = KeychainService(serviceName: serviceName) + try storeRawTokenData(Data([0xFF, 0xFE, 0xFD]), serviceName: serviceName) + + do { + _ = try service.loadToken() + Issue.record("Expected a decodingFailed error") + } catch let error as KeychainService.KeychainError { + #expect(error == .decodingFailed) + } catch { + Issue.record("Unexpected error: \(error)") + } + + try? service.deleteToken() + } + + @Test + func baseQuery_includesBackgroundAccessibleAttribute() { + let service = makeService() + let query = service.baseQuery() + + #expect(query[kSecAttrAccessible] != nil) + #expect(KeychainService.tokenAccessibility == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + } + + private func makeService() -> KeychainService { + KeychainService(serviceName: uniqueServiceName()) + } + + private func uniqueServiceName() -> String { + "com.githubcommandcenter.tests.\(UUID().uuidString)" + } + + private func storeRawTokenData(_ data: Data, serviceName: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: serviceName, + kSecAttrAccount: "github-pat", + kSecAttrAccessible: KeychainService.tokenAccessibility, + ] + + let deleteStatus = SecItemDelete(query as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw RawTokenStoreError.deleteFailed(status: deleteStatus, serviceName: serviceName) + } + + let addQuery = query.merging([kSecValueData: data] as [CFString: Any]) { _, new in new } + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw RawTokenStoreError.addFailed(status: status, serviceName: serviceName) + } + } +} diff --git a/GitHubCommandCenterTests/NotificationServiceDuplicateIDTests.swift b/GitHubCommandCenterTests/NotificationServiceDuplicateIDTests.swift new file mode 100644 index 0000000..7ab1202 --- /dev/null +++ b/GitHubCommandCenterTests/NotificationServiceDuplicateIDTests.swift @@ -0,0 +1,20 @@ +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct NotificationServiceDuplicateIDTests { + @Test + func prsByID_duplicateIDs_keepLastValue() { + let earlier = PRState.fixture(ciStatus: .passing, createdByMe: true) + let later = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "ci", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + let prsByID = NotificationService.prsByID([earlier, later]) + + #expect(prsByID.count == 1) + #expect(prsByID[earlier.id]?.ciStatus == later.ciStatus) + } +} diff --git a/GitHubCommandCenterTests/NotificationServiceTests.swift b/GitHubCommandCenterTests/NotificationServiceTests.swift new file mode 100644 index 0000000..aa4ece0 --- /dev/null +++ b/GitHubCommandCenterTests/NotificationServiceTests.swift @@ -0,0 +1,303 @@ +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct NotificationServiceTests { + private enum MockDeliveryError: Error, Equatable { + case failed + } + + private enum MockAuthorizationError: Error { + case deniedBySystem + } + + private final class Harness { + let service = NotificationService() + var fired: [(String, String)] = [] + + init() { + service.notificationHandler = { [weak self] title, body in + self?.fired.append((title, body)) + } + } + } + + @Test + func notification_ciPassingToFailing_yourPR_fires() throws { + let harness = Harness() + let old = PRState.fixture(ciStatus: .passing, createdByMe: true) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "unit-tests", conclusion: "failure", url: nil)], totalChecks: 3), + createdByMe: true + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + try #require(harness.fired.count == 1) + #expect(harness.fired[0].1.contains("unit-tests")) + } + + @Test + func requestPermission_returnsGrantedAuthorizationResult() async { + let service = NotificationService() + service.authorizationRequester = { options in + #expect(options == [.alert, .sound, .badge]) + return true + } + + let granted = await service.requestPermission() + + #expect(granted) + } + + @Test + func requestPermission_returnsFalseWhenAuthorizationThrows() async { + let service = NotificationService() + service.authorizationRequester = { _ in + throw MockAuthorizationError.deniedBySystem + } + + let granted = await service.requestPermission() + + #expect(granted == false) + } + + @Test + func notification_ciPassingToFailing_notYourPR_silent() { + let harness = Harness() + let old = PRState.fixture(ciStatus: .passing, createdByMe: false) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: false + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_ciNoneToFailing_yourPR_fires() { + let harness = Harness() + let old = PRState.fixture(ciStatus: .none, createdByMe: true) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "lint", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.count == 1) + } + + @Test + func notification_ciPendingToFailing_yourPR_fires() throws { + let harness = Harness() + let old = PRState.fixture(ciStatus: .pending, createdByMe: true) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "build", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + try #require(harness.fired.count == 1) + #expect(harness.fired[0].1.contains("build")) + } + + @Test + func notification_ciFailingToFailing_noRepeat() { + let harness = Harness() + let old = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_changesRequested_yourPR_fires() throws { + let harness = Harness() + let old = PRState.fixture(reviewStatus: .none, createdByMe: true) + let new = PRState.fixture( + reviewStatus: .changesRequested(by: ["alice"]), + createdByMe: true + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + try #require(harness.fired.count == 1) + #expect(harness.fired[0].1.contains("@alice")) + } + + @Test + func notification_changesRequested_alreadySet_noRepeat() { + let harness = Harness() + let old = PRState.fixture(reviewStatus: .changesRequested(by: ["alice"]), createdByMe: true) + let new = PRState.fixture(reviewStatus: .changesRequested(by: ["alice"]), createdByMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_approved_yourPR_fires() throws { + let harness = Harness() + let old = PRState.fixture(reviewStatus: .requested(by: ["alice"]), createdByMe: true) + let new = PRState.fixture(reviewStatus: .approved(by: ["alice"]), createdByMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + try #require(harness.fired.count == 1) + #expect(harness.fired[0].1.contains("@alice")) + } + + @Test + func notification_approved_notYourPR_silent() { + let harness = Harness() + let old = PRState.fixture(reviewStatus: .requested(by: ["alice"]), createdByMe: false) + let new = PRState.fixture(reviewStatus: .approved(by: ["alice"]), createdByMe: false) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_reviewRequestedFromMe_newRequest_fires() { + let harness = Harness() + let old = PRState.fixture(reviewRequestedFromMe: false) + let new = PRState.fixture(reviewRequestedFromMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.count == 1) + } + + @Test + func notification_reviewRequestedFromMe_alreadyRequested_noRepeat() { + let harness = Harness() + let old = PRState.fixture(reviewRequestedFromMe: true) + let new = PRState.fixture(reviewRequestedFromMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_mergeConflictsAppear_yourPR_fires() { + let harness = Harness() + let old = PRState.fixture(mergeStatus: .ready, createdByMe: true) + let new = PRState.fixture(mergeStatus: .conflicts, createdByMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.count == 1) + } + + @Test + func notification_mergeConflictsAlreadyPresent_noRepeat() { + let harness = Harness() + let old = PRState.fixture(mergeStatus: .conflicts, createdByMe: true) + let new = PRState.fixture(mergeStatus: .conflicts, createdByMe: true) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_disappearedPR_createdByMe_fires() throws { + let harness = Harness() + let disappeared = PRState.fixture(number: 99, title: "Old PR", createdByMe: true) + + harness.service.checkTransitions(from: [disappeared], to: [], disappeared: [disappeared]) + + try #require(harness.fired.count == 1) + #expect(harness.fired[0].0.contains("99")) + } + + @Test + func notification_disappearedPR_notCreatedByMe_isSilent() { + let harness = Harness() + let disappeared = PRState.fixture(number: 99, title: "Old PR", createdByMe: false) + + harness.service.checkTransitions(from: [disappeared], to: [], disappeared: [disappeared]) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_disappearedMultiplePRs_firesForEachCreatedByMePR() { + let harness = Harness() + let prs = [ + PRState.fixture(number: 1, createdByMe: true), + PRState.fixture(number: 2, createdByMe: false), + PRState.fixture(number: 3, createdByMe: true), + ] + + harness.service.checkTransitions(from: prs, to: [], disappeared: prs) + + #expect(harness.fired.count == 2) + } + + @Test + func notification_newPRWithoutBaseline_isSilent() { + let harness = Harness() + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + reviewStatus: .changesRequested(by: ["alice"]), + createdByMe: true + ) + + harness.service.checkTransitions(from: [], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_ciPendingToFailing_notYourPR_silent() { + let harness = Harness() + let old = PRState.fixture(ciStatus: .pending, createdByMe: false) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "build", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: false + ) + + harness.service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(harness.fired.isEmpty) + } + + @Test + func notification_deliveryFailure_surfacesError() { + let service = NotificationService() + var capturedError: MockDeliveryError? + service.notificationScheduler = { _, completion in + completion(MockDeliveryError.failed) + } + service.notificationDeliveryErrorHandler = { error in + capturedError = error as? MockDeliveryError + } + + let old = PRState.fixture(ciStatus: .passing, createdByMe: true) + let new = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "unit-tests", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + service.checkTransitions(from: [old], to: [new], disappeared: []) + + #expect(capturedError == .failed) + } +} diff --git a/GitHubCommandCenterTests/PRPanelModeTests.swift b/GitHubCommandCenterTests/PRPanelModeTests.swift new file mode 100644 index 0000000..83ab5c2 --- /dev/null +++ b/GitHubCommandCenterTests/PRPanelModeTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct PRPanelModeTests { + @Test + func panelModeHasExpectedWidth() { + #expect(PanelMode.width == 480) + } + + @Test + func pullRequestsModeUsesPRPanelPresentation() { + #expect(PanelMode.pullRequests.headerTitle == "GitHub Command Center") + #expect(PanelMode.pullRequests.showsBackButton == false) + #expect(PanelMode.pullRequests.showsRefreshRow == true) + #expect(PanelMode.pullRequests.showsInlineAppControls == false) + } + + @Test + func settingsModeUsesCompactInlineSettingsPresentation() { + #expect(PanelMode.settings.headerTitle == "Settings") + #expect(PanelMode.settings.showsBackButton == true) + #expect(PanelMode.settings.showsRefreshRow == false) + #expect(PanelMode.settings.showsInlineAppControls == true) + } + + @Test + func settingsUsesPersonalAccessTokensURL() { + #expect(SettingsLinks.personalAccessTokens == URL(string: "https://github.com/settings/personal-access-tokens")) + } +} diff --git a/GitHubCommandCenterTests/PRRowPresentationTests.swift b/GitHubCommandCenterTests/PRRowPresentationTests.swift new file mode 100644 index 0000000..9d04412 --- /dev/null +++ b/GitHubCommandCenterTests/PRRowPresentationTests.swift @@ -0,0 +1,37 @@ +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct PRRowPresentationTests { + @Test + func accessibilityLabel_includesStatusSummary() { + let pr = PRState.fixture( + number: 17, + title: "Improve sync", + repoFullName: "owner/repo", + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 3), + reviewStatus: .changesRequested(by: ["alice"]), + mergeStatus: .behind + ) + + let label = PRRowPresentation.accessibilityLabel(for: pr) + + #expect(label.contains("PR 17")) + #expect(label.contains("Improve sync")) + #expect(label.contains("owner/repo")) + #expect(label.contains("CI failing")) + #expect(label.contains("review changes requested")) + #expect(label.contains("branch behind")) + } + + @Test + func openFailureMessage_includesPullRequestContext() { + let pr = PRState.fixture(number: 42, repoFullName: "owner/repo") + + let message = PRRowPresentation.openFailureMessage(for: pr) + + #expect(message.contains("#42")) + #expect(message.contains(pr.url.absoluteString)) + } +} diff --git a/GitHubCommandCenterTests/PRStateTests.swift b/GitHubCommandCenterTests/PRStateTests.swift new file mode 100644 index 0000000..b59535f --- /dev/null +++ b/GitHubCommandCenterTests/PRStateTests.swift @@ -0,0 +1,300 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct PRStateTests { + @Test + func id_sameNumberDifferentRepos_areDistinct() { + let first = PRState.fixture(number: 42, repoFullName: "org/one") + let second = PRState.fixture(number: 42, repoFullName: "org/two") + + #expect(first.id != second.id) + } + + @Test + func triageCategory_reviewRequestedFromMe_needsAction() { + let pr = PRState.fixture(reviewRequestedFromMe: true) + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_assignedToMe_needsAction() { + let pr = PRState.fixture(assignedToMe: true) + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_ciFailing_needsAction() { + let pr = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "unit-tests", conclusion: "failure", url: nil)], totalChecks: 3), + createdByMe: true + ) + + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_changesRequested_needsAction() { + let pr = PRState.fixture( + reviewStatus: .changesRequested(by: ["reviewer"]), + createdByMe: true + ) + + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_mergeConflicts_needsAction() { + let pr = PRState.fixture(mergeStatus: .conflicts, createdByMe: true) + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_branchBehind_needsAction() { + let pr = PRState.fixture(mergeStatus: .behind, createdByMe: true) + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_approvedAndClean_needsAction() { + let pr = PRState.fixture( + reviewStatus: .approved(by: ["reviewer"]), + mergeStatus: .ready, + createdByMe: true + ) + + #expect(pr.triageCategory == .needsYourAction) + } + + @Test + func triageCategory_createdByMe_allGreenWaitingReview_waitingOnOthers() { + let pr = PRState.fixture( + ciStatus: .passing, + reviewStatus: .none, + mergeStatus: .ready, + createdByMe: true + ) + + #expect(pr.triageCategory == .waitingOnOthers) + } + + @Test + func triageCategory_createdByMe_reviewRequested_waitingOnOthers() { + let pr = PRState.fixture( + reviewStatus: .requested(by: ["reviewer"]), + createdByMe: true + ) + + #expect(pr.triageCategory == .waitingOnOthers) + } + + @Test + func triageCategory_draft_reviewRequestedOnly_waitingOnOthers() { + let pr = PRState.fixture(draftStatus: .draft, reviewRequestedFromMe: true) + #expect(pr.triageCategory == .waitingOnOthers) + } + + @Test + func triageCategory_draft_createdByMe_yourDraft() { + let pr = PRState.fixture(draftStatus: .draft, createdByMe: true) + #expect(pr.triageCategory == .yourDraft) + } + + @Test + func triageCategory_draft_assignedToMe_yourDraft() { + let pr = PRState.fixture(draftStatus: .draft, assignedToMe: true) + #expect(pr.triageCategory == .yourDraft) + } + + @Test + func triageCategory_draft_createdByMeAndReviewRequested_yourDraft() { + let pr = PRState.fixture(draftStatus: .draft, createdByMe: true, reviewRequestedFromMe: true) + #expect(pr.triageCategory == .yourDraft) + } + + @Test + func triageCategory_draft_notMine_waitingOnOthers() { + let pr = PRState.fixture(draftStatus: .draft) + #expect(pr.triageCategory == .waitingOnOthers) + } + + @Test + func triageCategory_noFlags_waitingOnOthers() { + let pr = PRState.fixture() + #expect(pr.triageCategory == .waitingOnOthers) + } + + @Test + func urgencyScore_ciFailing_yourPR_adds3() { + let pr = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: true + ) + + #expect(pr.urgencyScore >= 3) + } + + @Test + func urgencyScore_ciFailing_notYourPR_doesNotAdd() { + let pr = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + createdByMe: false + ) + + #expect(pr.urgencyScore < 3) + } + + @Test + func urgencyScore_changesRequested_yourPR_adds3() { + let pr = PRState.fixture( + reviewStatus: .changesRequested(by: ["reviewer"]), + createdByMe: true + ) + + #expect(pr.urgencyScore >= 3) + } + + @Test + func urgencyScore_changesRequested_notYourPR_doesNotAdd3() { + let pr = PRState.fixture( + reviewStatus: .changesRequested(by: ["reviewer"]), + createdByMe: false + ) + + #expect(pr.urgencyScore == 0) + } + + @Test + func urgencyScore_mergeConflicts_adds2() { + let pr = PRState.fixture(mergeStatus: .conflicts) + #expect(pr.urgencyScore >= 2) + } + + @Test + func urgencyScore_branchBehind_adds2() { + let pr = PRState.fixture(mergeStatus: .behind) + #expect(pr.urgencyScore >= 2) + } + + @Test + func urgencyScore_reviewRequestedFromMe_adds2() { + let pr = PRState.fixture(reviewRequestedFromMe: true) + #expect(pr.urgencyScore >= 2) + } + + @Test + func urgencyScore_assignedToMe_notAuthor_adds1() { + let pr = PRState.fixture(createdByMe: false, assignedToMe: true) + #expect(pr.urgencyScore >= 1) + } + + @Test + func urgencyScore_approvedAndReadyToMerge_yourPR_adds1() { + let pr = PRState.fixture( + reviewStatus: .approved(by: ["reviewer"]), + mergeStatus: .ready, + createdByMe: true + ) + + #expect(pr.urgencyScore >= 1) + } + + @Test + func urgencyScore_draft_alwaysZero() { + let pr = PRState.fixture( + draftStatus: .draft, + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + reviewStatus: .changesRequested(by: ["reviewer"]), + mergeStatus: .conflicts, + createdByMe: true, + reviewRequestedFromMe: true, + assignedToMe: true + ) + + #expect(pr.urgencyScore == 0) + } + + @Test + func urgencyScore_multipleFlags_addsUp() { + let pr = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + reviewStatus: .changesRequested(by: ["reviewer"]), + mergeStatus: .conflicts, + createdByMe: true + ) + + #expect(pr.urgencyScore >= 8) + } + + @Test + func urgencyScore_noFlags_isZero() { + let pr = PRState.fixture() + #expect(pr.urgencyScore == 0) + } + + @Test + func needsActionSortOrder_higherUrgencyFirst() { + let low = PRState.fixture(number: 1, assignedToMe: true) + let high = PRState.fixture(number: 2, reviewRequestedFromMe: true) + let prs = [low, high].sorted(using: PRState.needsActionComparator) + + #expect(prs.first?.number == 2) + } + + @Test + func needsActionSortOrder_tiebreaker_moreRecentFirst() { + let older = PRState.fixture( + number: 1, + reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + let newer = PRState.fixture( + number: 2, + reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 2_000) + ) + let prs = [older, newer].sorted(using: PRState.needsActionComparator) + + #expect(prs.first?.number == 2) + } + + @Test + func displayRole_reviewRequestedFromMe_highestPrecedence() { + let pr = PRState.fixture(createdByMe: true, reviewRequestedFromMe: true, assignedToMe: true) + #expect(pr.displayRole == "Review requested") + } + + @Test + func displayRole_assignedButNotReviewRequested() { + let pr = PRState.fixture(createdByMe: true, assignedToMe: true) + #expect(pr.displayRole == "Assigned") + } + + @Test + func displayRole_onlyAuthor() { + let pr = PRState.fixture(createdByMe: true) + #expect(pr.displayRole == "Your PR") + } + + @Test + func displayRole_noFlags_empty() { + let pr = PRState.fixture() + #expect(pr.displayRole.isEmpty) + } + + @Test + func mergeStatus_blocked_isDistinctFromConflicts() { + let blocked = PRState.fixture(mergeStatus: .blocked) + let conflicts = PRState.fixture(mergeStatus: .conflicts) + + #expect(blocked.mergeStatus != conflicts.mergeStatus) + } + + @Test + func mergeStatus_pending_isNotReady() { + let pr = PRState.fixture(mergeStatus: .pending) + #expect(pr.mergeStatus != .ready) + } +} diff --git a/GitHubCommandCenterTests/PollingEngineTests.swift b/GitHubCommandCenterTests/PollingEngineTests.swift new file mode 100644 index 0000000..b895074 --- /dev/null +++ b/GitHubCommandCenterTests/PollingEngineTests.swift @@ -0,0 +1,554 @@ +import Foundation +import Security +import Testing + +@testable import GitHubCommandCenter + +@MainActor +@Suite(.serialized) +struct PollingEngineTests { + private actor SleepRecorder { + private var intervals: [TimeInterval] = [] + + func record(_ interval: TimeInterval) { + intervals.append(interval) + } + + func recordedIntervals() -> [TimeInterval] { + intervals + } + } + + @MainActor + private final class Harness { + let appState = AppState() + let mockSource = MockGitHubDataSource() + let engine: PollingEngine + + init( + recentlyClosedClearDelay: TimeInterval = 5, + dataSource: (any GitHubDataSource)? = nil, + useMockSource: Bool = true, + loadToken: @escaping @Sendable () throws -> String? = { + try KeychainService.shared.loadToken() + }, + sleep: (@Sendable (TimeInterval) async -> Void)? = nil + ) { + let resolvedDataSource: (any GitHubDataSource)? + if useMockSource { + resolvedDataSource = dataSource ?? mockSource + } else { + resolvedDataSource = dataSource + } + + engine = PollingEngine( + appState: appState, + dataSource: resolvedDataSource, + recentlyClosedClearDelay: recentlyClosedClearDelay, + loadToken: loadToken, + sleep: sleep ?? { interval in + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + ) + } + } + + private func successResult( + _ prs: [PRState], + warningMessage: String? = nil + ) -> Result { + .success(.init(prs: prs, warningMessage: warningMessage)) + } + + @Test + func pollInterval_fewerThan20PRs_is60s() { + let harness = Harness() + harness.appState.prs = Array(repeating: .fixture(), count: 5) + + #expect(harness.engine.pollInterval == 60) + } + + @Test + func pollInterval_20to39PRs_is120s() { + let harness = Harness() + harness.appState.prs = Array(repeating: .fixture(), count: 25) + + #expect(harness.engine.pollInterval == 120) + } + + @Test + func pollInterval_40orMorePRs_is300s() { + let harness = Harness() + harness.appState.prs = Array(repeating: .fixture(), count: 50) + + #expect(harness.engine.pollInterval == 300) + } + + @Test + func pollInterval_exactly20PRs_is120s() { + let harness = Harness() + harness.appState.prs = Array(repeating: .fixture(), count: 20) + + #expect(harness.engine.pollInterval == 120) + } + + @Test + func pollInterval_exactly40PRs_is300s() { + let harness = Harness() + harness.appState.prs = Array(repeating: .fixture(), count: 40) + + #expect(harness.engine.pollInterval == 300) + } + + @Test + func poll_updatesAppStatePRs() async { + let harness = Harness() + let pr = PRState.fixture(number: 1) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([pr]) + + await harness.engine.poll() + + #expect(harness.appState.prs.count == 1) + #expect(harness.appState.prs.first?.number == 1) + } + + @Test + func poll_setsLastUpdated() async { + let harness = Harness() + harness.mockSource.fetchResult = successResult([.fixture()]) + + #expect(harness.appState.lastUpdated == nil) + await harness.engine.poll() + #expect(harness.appState.lastUpdated != nil) + } + + @Test + func poll_clearsError_onSuccess() async { + let harness = Harness() + harness.appState.error = .networkError + harness.mockSource.fetchResult = successResult([]) + + await harness.engine.poll() + + #expect(harness.appState.error == nil) + } + + @Test + func poll_setsIsLoading_falseAfterSuccess() async { + let harness = Harness() + harness.appState.isLoading = true + harness.mockSource.fetchResult = successResult([]) + + await harness.engine.poll() + + #expect(harness.appState.isLoading == false) + } + + @Test + func poll_firstPoll_doesNotFireNotifications() async { + let harness = Harness() + var firedCount = 0 + NotificationService.shared.notificationHandler = { _, _ in firedCount += 1 } + defer { NotificationService.shared.notificationHandler = nil } + + let pr = PRState.fixture( + ciStatus: .failing(checks: [.init(name: "test", conclusion: "failure", url: nil)], totalChecks: 1), + reviewStatus: .changesRequested(by: ["alice"]), + createdByMe: true + ) + harness.mockSource.fetchResult = successResult([pr]) + + await harness.engine.poll() + + #expect(firedCount == 0) + } + + @Test + func poll_PRDisappears_addedToRecentlyClosed() async { + let harness = Harness() + let pr = PRState.fixture(number: 99) + harness.mockSource.resolvedDisappearedPRs = [pr] + harness.mockSource.fetchResult = successResult([pr]) + await harness.engine.poll() + + harness.mockSource.fetchResult = successResult([]) + await harness.engine.poll() + + #expect(harness.appState.recentlyClosedPRs.count == 1) + #expect(harness.appState.recentlyClosedPRs.first?.number == 99) + } + + @Test + func poll_PRDisappears_withoutConfirmedClosure_notMarkedRecentlyClosed() async { + let harness = Harness() + let pr = PRState.fixture(number: 99) + harness.mockSource.fetchResult = successResult([pr]) + await harness.engine.poll() + + harness.mockSource.resolvedDisappearedPRs = [] + harness.mockSource.fetchResult = successResult([]) + await harness.engine.poll() + + #expect(harness.appState.recentlyClosedPRs.isEmpty) + } + + @Test + func poll_consecutiveSuccesses_resetsFailureCount() async { + let harness = Harness(sleep: { _ in }) + harness.mockSource.fetchResult = .failure(AppError.networkError) + await harness.engine.poll() + + harness.mockSource.fetchResult = successResult([]) + await harness.engine.poll() + + #expect(harness.appState.error == nil) + } + + @Test + func poll_authError_stopsPollingAndSetsStatus() async { + let harness = Harness() + harness.mockSource.validateTokenResult = .failure(AppError.authError) + + await harness.engine.poll() + + if case .failed = harness.appState.authenticationStatus { + } else { + Issue.record("Expected .failed, got \(harness.appState.authenticationStatus)") + } + #expect(harness.appState.error == .authError) + } + + @Test + func poll_networkError_setsNetworkError() async { + let harness = Harness(sleep: { _ in }) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.networkError) + + await harness.engine.poll() + + #expect(harness.appState.error == .networkError) + } + + @Test + func poll_networkError_requestsImmediateRetryAfterBackoff() async { + let sleepRecorder = SleepRecorder() + let harness = Harness(sleep: { interval in + await sleepRecorder.record(interval) + }) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.networkError) + + let outcome = await harness.engine.poll() + + #expect(outcome == .continueImmediately) + #expect(await sleepRecorder.recordedIntervals() == [2]) + } + + @Test(.timeLimit(.minutes(1))) + func start_regularPollingUsesInjectedSleepClosure() async throws { + let sleepRecorder = SleepRecorder() + var stopPolling: (@MainActor () -> Void)? + let harness = Harness(sleep: { interval in + await sleepRecorder.record(interval) + await MainActor.run { + stopPolling?() + } + }) + stopPolling = { + harness.engine.stop() + } + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([]) + + harness.engine.start() + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(await sleepRecorder.recordedIntervals() == [60]) + } + + @Test + func poll_serverError_preservesErrorAndRequestsImmediateRetryAfterBackoff() async { + let harness = Harness(sleep: { _ in }) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.serverError(statusCode: 500)) + + let outcome = await harness.engine.poll() + + #expect(harness.appState.error == .serverError(statusCode: 500)) + #expect(outcome == .continueImmediately) + } + + @Test + func poll_paginationLimitExceeded_preservesErrorAndUsesRegularInterval() async { + let harness = Harness() + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.paginationLimitExceeded) + + let outcome = await harness.engine.poll() + + #expect(harness.appState.error == .paginationLimitExceeded) + #expect(outcome == .useRegularInterval) + } + + @Test + func poll_rateLimitExceeded_setsRateLimitState() async { + let harness = Harness() + let resetAt = Date().addingTimeInterval(3600) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.rateLimitExceeded(.init(resetAt: resetAt))) + + await harness.engine.poll() + + #expect(harness.appState.isRateLimited) + if case .rateLimitExceeded = harness.appState.error { + } else { + Issue.record("Expected rateLimitExceeded error") + } + } + + @Test + func poll_keychainLoadFailure_setsKeychainErrorWithoutPretendingAuthFailed() async { + let harness = Harness( + dataSource: nil, + useMockSource: false, + loadToken: { throw KeychainService.KeychainError.loadFailed(status: errSecInteractionNotAllowed) } + ) + + let outcome = await harness.engine.poll() + + #expect(outcome == .stopLoop) + #expect(harness.appState.error == .keychainError) + if case .unknown = harness.appState.authenticationStatus { + } else { + Issue.record("Expected authentication status to stay .unknown") + } + } + + @Test + func poll_setsAuthenticatedStatus_onFirstSuccess() async { + let harness = Harness() + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([]) + + await harness.engine.poll() + + if case .authenticated(let username) = harness.appState.authenticationStatus { + #expect(username == "octocat") + } else { + Issue.record("Expected .authenticated, got \(harness.appState.authenticationStatus)") + } + } + + @Test + func poll_reusesUsername_doesNotRevalidateToken() async { + let harness = Harness() + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.mockSource.fetchResult = successResult([]) + + await harness.engine.poll() + + #expect(harness.mockSource.fetchCallCount > 0) + #expect(harness.mockSource.validateTokenCallCount == 0) + } + + @Test + func poll_successWithNoPRs_keepsTokenValidationWarning() async { + let harness = Harness() + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.appState.tokenValidationWarningMessage = "Still verifying" + harness.mockSource.fetchResult = successResult([]) + + await harness.engine.poll() + + #expect(harness.appState.tokenValidationWarningMessage == "Still verifying") + } + + @Test + func poll_successWithPRs_clearsTokenValidationWarning() async { + let harness = Harness() + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.appState.tokenValidationWarningMessage = "Still verifying" + harness.mockSource.fetchResult = successResult([.fixture(number: 7)]) + + await harness.engine.poll() + + #expect(harness.appState.tokenValidationWarningMessage == nil) + } + + @Test + func poll_successWithWarning_storesPanelWarningMessage() async { + let harness = Harness() + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.mockSource.fetchResult = successResult( + [.fixture(number: 7)], + warningMessage: "Results truncated" + ) + + await harness.engine.poll() + + #expect(harness.appState.panel.warningMessage == "Results truncated") + } + + @Test + func poll_successWithoutWarning_clearsExistingPanelWarningMessage() async { + let harness = Harness() + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.appState.panel.warningMessage = "Results truncated" + harness.mockSource.fetchResult = successResult([.fixture(number: 7)]) + + await harness.engine.poll() + + #expect(harness.appState.panel.warningMessage == nil) + } + + @Test + func reset_clearsPreviousPRsBeforeNextPoll() async { + let harness = Harness() + let pr = PRState.fixture(number: 99) + harness.mockSource.fetchResult = successResult([pr]) + await harness.engine.poll() + + harness.engine.reset() + harness.mockSource.resolvedDisappearedPRs = [pr] + harness.mockSource.fetchResult = successResult([]) + await harness.engine.poll() + + #expect(harness.mockSource.lastResolvedDisappearedInput.isEmpty) + } + + @Test(.timeLimit(.minutes(1))) + func reset_cancelsRecentlyClosedClearTask() async { + let harness = Harness(recentlyClosedClearDelay: 0.05) + let pr = PRState.fixture(number: 99) + harness.mockSource.fetchResult = successResult([pr]) + await harness.engine.poll() + + harness.mockSource.resolvedDisappearedPRs = [pr] + harness.mockSource.fetchResult = successResult([]) + await harness.engine.poll() + #expect(harness.appState.recentlyClosedPRs.map(\.number) == [99]) + + harness.engine.reset() + harness.appState.recentlyClosedPRs = [.fixture(number: 100)] + var recentlyClosedWasCleared = false + + try? await Task.sleep(nanoseconds: 100_000_000) + + await confirmation("recently closed PRs stay visible after reset") { confirmation in + recentlyClosedWasCleared = harness.appState.recentlyClosedPRs.isEmpty + confirmation() + } + + #expect(recentlyClosedWasCleared == false) + #expect(harness.appState.recentlyClosedPRs.map(\.number) == [100]) + } + + @Test + func clearSessionStateForNewSession_clearsSessionScopedData() { + let harness = Harness() + harness.appState.prs = [.fixture(number: 1)] + harness.appState.recentlyClosedPRs = [.fixture(number: 2)] + harness.appState.lastUpdated = Date() + harness.appState.isLoading = false + harness.appState.error = .rateLimitExceeded(.init(resetAt: Date().addingTimeInterval(60))) + harness.appState.isStale = true + harness.appState.authenticationStatus = .authenticated(username: "octocat") + harness.appState.tokenValidationWarningMessage = "Still verifying" + + harness.appState.clearSessionStateForNewSession() + + #expect(harness.appState.prs.isEmpty) + #expect(harness.appState.recentlyClosedPRs.isEmpty) + #expect(harness.appState.lastUpdated == nil) + #expect(harness.appState.error == nil) + #expect(harness.appState.isRateLimited == false) + #expect(harness.appState.rateLimitResetDate == nil) + #expect(harness.appState.isStale == false) + #expect(harness.appState.isLoading) + #expect(harness.appState.tokenValidationWarningMessage == nil) + + if case .unknown = harness.appState.authenticationStatus { + } else { + Issue.record("Expected authentication status to reset to unknown") + } + } + + @Test + func panelContentState_noTokenWithNoPRs_prefersSetupState() { + let harness = Harness() + harness.appState.isLoading = false + harness.appState.authenticationStatus = .noToken + + #expect(harness.appState.panelContentState == .setupRequired) + } + + @Test + func panelContentState_authFailureWithNoPRs_prefersAuthErrorState() { + let harness = Harness() + harness.appState.isLoading = false + harness.appState.authenticationStatus = .failed + + #expect(harness.appState.panelContentState == .authError) + } + + @Test + func poll_networkError_withOldLastUpdated_marksStateStale() async { + let harness = Harness(sleep: { _ in }) + harness.appState.lastUpdated = Date().addingTimeInterval(-301) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = .failure(AppError.networkError) + + await harness.engine.poll() + + #expect(harness.appState.isStale) + } + + @Test + func poll_success_clearsExistingStaleState() async { + let harness = Harness() + harness.appState.lastUpdated = Date().addingTimeInterval(-301) + harness.appState.isStale = true + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([.fixture()]) + + await harness.engine.poll() + + #expect(harness.appState.isStale == false) + } + + @Test + func poll_success_populatesObservationSnapshots() async { + let harness = Harness() + let pr = PRState.fixture(number: 7, reviewRequestedFromMe: true) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([pr]) + + await harness.engine.poll() + + #expect(harness.appState.panel.triageSnapshot.prs.map(\.number) == [7]) + #expect(harness.appState.panel.triageSnapshot.needsActionPRs.map(\.number) == [7]) + #expect(harness.appState.panel.triageSnapshot.menuBarBadgeCount == 1) + + if case .authenticated(let username) = harness.appState.auth.authenticationStatus { + #expect(username == "octocat") + } else { + Issue.record("Expected authenticated auth state after polling") + } + } + + @Test + func poll_samePRs_keepsDerivedSnapshotStable() async { + let harness = Harness() + let pr = PRState.fixture(number: 7, reviewRequestedFromMe: true) + harness.mockSource.validateTokenResult = .success("octocat") + harness.mockSource.fetchResult = successResult([pr]) + + await harness.engine.poll() + let initialSnapshot = harness.appState.panel.triageSnapshot + + await harness.engine.poll() + + #expect(harness.appState.panel.triageSnapshot == initialSnapshot) + } +} diff --git a/GitHubCommandCenterTests/SettingsValidationTests.swift b/GitHubCommandCenterTests/SettingsValidationTests.swift new file mode 100644 index 0000000..a204b56 --- /dev/null +++ b/GitHubCommandCenterTests/SettingsValidationTests.swift @@ -0,0 +1,56 @@ +import Security +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct SettingsValidationTests { + @Test + func tokenSaveSuccess_verifiedResult_setsValidStateWithoutWarning() { + let outcome = SettingsContentView.tokenSaveSuccessOutcome( + for: .verified(username: "octocat") + ) + + #expect(outcome.tokenState == SettingsContentView.TokenState.valid(username: "octocat")) + #expect(outcome.warningMessage == nil) + } + + @Test + func tokenSaveSuccess_warningResult_setsValidStateAndWarning() { + let outcome = SettingsContentView.tokenSaveSuccessOutcome( + for: .warning( + username: "octocat", + message: "Status access could not be fully verified." + ) + ) + + #expect(outcome.tokenState == SettingsContentView.TokenState.valid(username: "octocat")) + #expect(outcome.warningMessage == "Status access could not be fully verified.") + } + + @Test + func tokenSaveFailure_authError_marksTokenInvalid() { + let outcome = SettingsContentView.tokenSaveFailureOutcome(for: AppError.authError) + + #expect(outcome.tokenState == SettingsContentView.TokenState.invalid) + #expect(outcome.message == nil) + } + + @Test + func tokenSaveFailure_retryableAppError_keepsTokenUnvalidated() { + let outcome = SettingsContentView.tokenSaveFailureOutcome(for: AppError.networkError) + + #expect(outcome.tokenState == SettingsContentView.TokenState.unvalidated) + #expect(outcome.message == AppError.networkError.errorDescription) + } + + @Test + func tokenSaveFailure_keychainError_keepsTokenUnvalidated() { + let outcome = SettingsContentView.tokenSaveFailureOutcome( + for: KeychainService.KeychainError.saveFailed(status: errSecInteractionNotAllowed) + ) + + #expect(outcome.tokenState == SettingsContentView.TokenState.unvalidated) + #expect(outcome.message?.contains("-25308") == true) + } +} diff --git a/GitHubCommandCenterTests/StatusDotTooltipTests.swift b/GitHubCommandCenterTests/StatusDotTooltipTests.swift new file mode 100644 index 0000000..8eedba1 --- /dev/null +++ b/GitHubCommandCenterTests/StatusDotTooltipTests.swift @@ -0,0 +1,187 @@ +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct StatusDotTooltipTests { + + // MARK: - Labels + + @Test + func ci_label_isCI() { + let tooltip = StatusDotTooltip(dimension: .ci, pr: .fixture()) + #expect(tooltip.label == "CI") + } + + @Test + func review_label_isReview() { + let tooltip = StatusDotTooltip(dimension: .review, pr: .fixture()) + #expect(tooltip.label == "REVIEW") + } + + @Test + func merge_label_isMerge() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture()) + #expect(tooltip.label == "MERGE") + } + + // MARK: - CI detail + + @Test + func ci_passing_detail() { + let tooltip = StatusDotTooltip(dimension: .ci, pr: .fixture(ciStatus: .passing)) + #expect(tooltip.detail == "All checks passing") + } + + @Test + func ci_failing_singleCheck_detail() { + let tooltip = StatusDotTooltip( + dimension: .ci, + pr: .fixture( + ciStatus: .failing( + checks: [.init(name: "unit-tests", conclusion: "failure", url: nil)], + totalChecks: 3 + ) + ) + ) + #expect(tooltip.detail == "unit-tests failing (3 total)") + } + + @Test + func ci_failing_manyChecks_detail() { + let tooltip = StatusDotTooltip( + dimension: .ci, + pr: .fixture( + ciStatus: .failing( + checks: [ + .init(name: "lint", conclusion: "failure", url: nil), + .init(name: "unit", conclusion: "failure", url: nil), + .init(name: "integration", conclusion: "failure", url: nil), + ], + totalChecks: 5 + ) + ) + ) + #expect(tooltip.detail == "lint, unit (+1 more) failing (5 total)") + } + + @Test + func ci_failing_noNamedChecks_detail() { + let tooltip = StatusDotTooltip( + dimension: .ci, + pr: .fixture(ciStatus: .failing(checks: [], totalChecks: 4)) + ) + + #expect(tooltip.detail == "Failing (4 total)") + } + + @Test + func ci_pending_detail() { + let tooltip = StatusDotTooltip(dimension: .ci, pr: .fixture(ciStatus: .pending)) + #expect(tooltip.detail == "Checks in progress") + } + + @Test + func ci_none_detail() { + let tooltip = StatusDotTooltip(dimension: .ci, pr: .fixture(ciStatus: .none)) + #expect(tooltip.detail == "No CI configured") + } + + // MARK: - Review detail + + @Test + func review_approved_detail() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .approved(by: ["alice"])) + ) + #expect(tooltip.detail == "Approved by @alice") + } + + @Test + func review_approved_manyReviewers_detailTruncatesAfterTwoNames() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .approved(by: ["alice", "bob", "carol"])) + ) + #expect(tooltip.detail == "Approved by @alice, @bob and 1 more") + } + + @Test + func review_changesRequested_detail() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .changesRequested(by: ["bob"])) + ) + #expect(tooltip.detail == "Changes requested by @bob") + } + + @Test + func review_changesRequested_manyReviewers_detailTruncatesAfterTwoNames() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .changesRequested(by: ["alice", "bob", "carol"])) + ) + #expect(tooltip.detail == "Changes requested by @alice, @bob and 1 more") + } + + @Test + func review_requested_detail() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .requested(by: ["carol"])) + ) + #expect(tooltip.detail == "Review requested from @carol") + } + + @Test + func review_requested_manyReviewers_detailTruncatesAfterTwoNames() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .requested(by: ["alice", "bob", "carol"])) + ) + #expect(tooltip.detail == "Review requested from @alice, @bob and 1 more") + } + + @Test + func review_none_detail() { + let tooltip = StatusDotTooltip( + dimension: .review, + pr: .fixture(reviewStatus: .none) + ) + #expect(tooltip.detail == "No reviews") + } + + // MARK: - Merge detail + + @Test + func merge_ready_detail() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture(mergeStatus: .ready)) + #expect(tooltip.detail == "Ready to merge") + } + + @Test + func merge_conflicts_detail() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture(mergeStatus: .conflicts)) + #expect(tooltip.detail == "Has merge conflicts") + } + + @Test + func merge_blocked_detail() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture(mergeStatus: .blocked)) + #expect(tooltip.detail == "Merge blocked by branch protection") + } + + @Test + func merge_behind_detail() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture(mergeStatus: .behind)) + #expect(tooltip.detail == "Branch is behind base and needs updating") + } + + @Test + func merge_pending_detail() { + let tooltip = StatusDotTooltip(dimension: .merge, pr: .fixture(mergeStatus: .pending)) + #expect(tooltip.detail == "Checking mergeability…") + } +} diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 0000000..ac53431 --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,212 @@ +# Development Setup + +## Prerequisites + +- **macOS 14.0+** +- **Xcode 26.3** (includes `swift format`) +- **[mise](https://mise.jdx.dev/)** for tool and task management +- A **GitHub account** with at least one open pull request you're involved in + +## First-Time Setup + +```bash +mise install +mise run sync +mise run hooks:install +``` + +`mise install` provisions the repo-managed tools defined in [`mise.toml`](mise.toml): + +| Tool | Purpose | +| --- | --- | +| `swiftlint` | Swift policy enforcement | +| `xcodegen` | Regenerate `GitHubCommandCenter.xcodeproj` from `project.yml` | +| `hk` | Git hook manager | +| `pkl` | Configuration runtime required by `hk` | + +`swift format` ships with Xcode, so there is no separate formatter install. + +## Mise Tasks + +Run any task with `mise run `: + +| Task | Description | +| --- | --- | +| `sync` | Install tools and regenerate the Xcode project | +| `open` | Open the generated Xcode project | +| `build` | Build the app with `xcodebuild` | +| `build-release` | Build the release app bundle into `build/DerivedData` | +| `test` | Run the Swift test suite with `xcodebuild` | +| `check` | Run `swift format` linting and `swiftlint` in read-only mode | +| `fix` | Regenerate the project and apply style fixes | +| `verify` | Run `fix`, then re-run style checks, then run tests | +| `hooks:install` | Install git hooks through `hk` | + +## Swift Formatting and Linting + +The repo uses a two-tool setup: + +### 1. `swift format` + +`swift format` handles formatting such as indentation, wrapping, spacing, and import ordering. Configuration lives in [`.swift-format`](.swift-format). + +Key settings: + +- Line length: `120` +- Indentation: `4` spaces +- Maximum blank lines: `1` +- Trailing commas: required in multiline collections +- File-scoped declarations: `private` +- Break before each argument when wrapping + +Manual commands: + +```bash +# Check only +swift format lint --strict --recursive GitHubCommandCenter GitHubCommandCenterTests + +# Fix in place +swift format --in-place --recursive GitHubCommandCenter GitHubCommandCenterTests +``` + +### 2. `SwiftLint` + +`SwiftLint` enforces policy rules such as size limits, force unwrap usage, and naming. Configuration lives in [`.swiftlint.yml`](.swiftlint.yml). + +Formatting-overlap rules are disabled so `swift format` remains the single source of truth for code layout. + +Notable thresholds: + +| Rule | Warning | Error | +| --- | --- | --- | +| `line_length` | 120 | 200 | +| `file_length` | 700 | 1000 | +| `function_body_length` | 110 | 140 | +| `type_body_length` | 600 | 700 | +| `cyclomatic_complexity` | 20 | 30 | +| `identifier_name` minimum | 2 chars | 1 char | + +Opt-in policy rules include `force_unwrapping`, `implicitly_unwrapped_optional`, `empty_count`, `first_where`, `toggle_bool`, and `modifier_order`. + +No global identifier exclusions are configured. For legitimate short names like `id`, use scoped `// swiftlint:disable:next identifier_name` inline disables. + +Manual commands: + +```bash +# Check +swiftlint lint --strict + +# Fix +swiftlint lint --fix +``` + +### Style task shortcuts + +```bash +mise run check +mise run fix +``` + +`mise run verify` is the broader local convenience workflow. It is intentionally mutating because it runs `mise run fix` before `mise run check` and `mise run test`. + +## Git Hooks + +Git hooks are managed by [`hk`](https://github.com/jdx/hk) using [`hk.pkl`](hk.pkl). + +Install hooks with: + +```bash +mise run hooks:install +``` + +The `pre-commit` hook: + +1. Stashes unstaged changes with `stash = "git"` +2. Runs `mise run fix` when staged Swift files are present +3. Re-runs `mise run check` to make sure the fixes are clean +4. Runs trailing-whitespace and merge-conflict checks +5. Re-stages files updated by the fix step + +## Build, Run, and Test + +### Command line + +```bash +mise run build +mise run check +mise run test +mise run verify +``` + +To open the project in Xcode: + +```bash +mise run open +``` + +### Xcode + +1. Run `mise run sync` if `project.yml` changed +2. Open `GitHubCommandCenter.xcodeproj` +3. Select the **GitHubCommandCenter** scheme +4. Set the destination to **My Mac** +5. Press **Cmd+B** to build or **Cmd+U** to run tests + +## Manual App Testing + +### 1. Create a GitHub personal access token + +1. Go to or +2. Generate either: + - A classic token with the `repo` scope, or + - A fine-grained token with **Pull requests: Read**, **Commit statuses: Read**, and **Checks: Read** +3. If you use a fine-grained token, grant repository access to every repo you want the app to track +4. Copy the token + +### 2. Launch the app + +1. Build with `mise run build` or run the app from Xcode with **Cmd+R** +2. Look for the menu bar icon at the top of the screen + +### Optional: preload the token for local development + +1. Add `GITHUB_TOKEN=...` to a local `.env.local` file at the repo root, or set `GITHUB_TOKEN` in your Xcode scheme environment +2. Launch the app with an empty keychain entry for GitHub Command Center +3. The app will import that token into Keychain on startup, then continue using Keychain as the source of truth +4. `.env.local` and `.env` are git-ignored in this repo; use `.env.example` as the template + +**Debug vs Release** + +- **Debug** builds set `DevEnvSearchRoot` in the app Info.plist to the repository root (`$(SRCROOT)` from XcodeGen). On first launch with an empty keychain, token bootstrap walks that directory (and parents) for `.env.local` / `.env`, so a repo-root file is found even when the app runs from DerivedData and the process working directory is not the repo. +- **Release** builds leave `DevEnvSearchRoot` empty; bootstrap still checks process environment variables and searches from the current working directory and the folder next to the app bundle, same as before. For distributed builds, configure the token in the app or rely on environment variables where appropriate. + +**Security note:** Only the repo *path* is baked into Debug Info.plist. The token itself is never embedded at build time; it is read from your local file at runtime. + +### 3. Configure the token + +1. Open the menu bar app +2. Open **Settings** +3. Paste the token into **Paste your GitHub token here** +4. Click **Save Token** +5. Confirm you see **Connected as @yourusername** + +### 4. Verify behavior + +1. Open the menu bar panel +2. Confirm PR titles, repo names, review state, draft state, and CI indicators match GitHub +3. Remember that the app currently tracks pull requests involving the authenticated user +4. Confirm the icon color matches repo health +5. Click a PR row and verify it opens in the browser + +## Troubleshooting + +| Problem | Solution | +| --- | --- | +| `mise run sync` fails because tools are missing | Run `mise install` first | +| `swiftlint` is not found | Run `mise install` so the repo-managed version is installed | +| Build fails with signing errors | Use the `mise run build` task, which disables code signing for local builds | +| `GitHubCommandCenter.xcodeproj` is stale | Run `mise run sync` after changing `project.yml` | +| Hooks do not run | Reinstall them with `mise run hooks:install` | +| Token validation fails | Ensure the token has the required `repo` scope or the fine-grained read permissions listed above | +| PR list is empty even though your team has open PRs | The app currently shows PRs involving the authenticated user, and fine-grained tokens need repository access to the repos you expect to see | +| PRs load but CI detail looks incomplete | Add **Checks: Read** to the fine-grained token; without it the app falls back to commit statuses only | diff --git a/TODOS.md b/TODOS.md index 18072bb..075462d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -12,3 +12,9 @@ After 1-2 weeks of using updated_at sort in MVP, add urgency scoring with weight informed by actual usage. The triage split (Needs Action vs Waiting) ships in MVP. Only the within-section sort order is deferred. Track which PR orderings feel wrong during personal use, then define weights based on those observations. + +## V1.1 — Team review request support +Handle GitHub team-based review requests in triage so CODEOWNERS and reviewer teams +surface as waiting-for-review/review-requested states instead of disappearing from +the app. This needs `requested_teams` support plus a way to map the authenticated +user to their GitHub teams. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docs/decisions/pr-triage-categories.md b/docs/decisions/pr-triage-categories.md new file mode 100644 index 0000000..62f0590 --- /dev/null +++ b/docs/decisions/pr-triage-categories.md @@ -0,0 +1,36 @@ +# PR Triage Categories + +PRs are sorted into three sections in the panel, from top to bottom: **Needs Your Action**, **Waiting on Others**, and **Your Draft PRs**. + +## Needs Your Action + +A non-draft PR lands here if **any** of the following are true: + +- **You've been asked to review it** (`reviewRequestedFromMe`) +- **You're assigned to it** (`assignedToMe`) +- **You created it** and at least one of these signals is present: + - CI is failing + - There are merge conflicts + - Changes have been requested by a reviewer + - It's approved **and** merge-ready (clean merge status) -- i.e. you can go merge it now + +## Waiting on Others + +Everything else. This covers: + +- PRs you created that are in a healthy state (CI passing, no conflicts, reviews pending or not yet requested) +- Draft PRs from other people that you're not assigned to +- Any PR where you have no ownership role and haven't been asked to act + +## Your Draft PRs + +Any PR with `draftStatus == .draft` where you created it or are assigned to it. Draft PRs from others that you didn't create and aren't assigned to fall through to Waiting on Others instead. + +## Sort order + +- **Needs Your Action** is sorted by urgency score (descending), then by most recently updated. The urgency score weights: CI failures on your PRs (+3), changes requested on your PRs (+3), merge conflicts (+2), review requested from you (+2), assigned to you (+1), approved and merge-ready (+1). +- **Waiting on Others** and **Your Draft PRs** are sorted by most recently updated. + +## Implementation + +The triage logic lives in `PRState.triageCategory` (`GitHubCommandCenter/Models/PRState.swift`). The urgency score is computed by `PRState.urgencyScore` in the same file. `AppState` caches the filtered/sorted lists and exposes them as `needsActionPRs`, `waitingOnOthersPRs`, and `yourDraftPRs`. diff --git a/docs/gstack-sketch.png b/docs/gstack-sketch.png new file mode 100644 index 0000000..1e03e55 Binary files /dev/null and b/docs/gstack-sketch.png differ diff --git a/docs/improvements-to-make.md b/docs/improvements-to-make.md new file mode 100644 index 0000000..4008859 --- /dev/null +++ b/docs/improvements-to-make.md @@ -0,0 +1,52 @@ +# UX Improvements + +- [X] Move the "Quit" and the "Launch at login" options into Settings. +- [X] User can't edit GitHub token after it has been entered + verified. then must remove it. +- [X] Show the user which repos are being tracked. +- [X] Make the "Accessible repos" section in Settings be collapsible. It's default state should be collapsed. +- [X] Displayed number in the menu bar should be the number of PRs that need your attention, not the number of PRs being tracked. +- [X] Only send the user to the GitHub link for the PR when the PR number is clicked. +- [X] It doesn't really make sense to have your draft PRs in the "Waiting on others" section when they're not waiting on others. Let's add a 3rd section: "Your draft PRs" containing any PRs that are drafts and that you either created or are assigned to. +- [X] We want it so that if we click on the indicators, the tool tips appear straight away. +- [X] Make the dropdown bigger. +- [X] On the Settings view, remove the text immediately under Settings that says "Authentication and app settings". +- [X] Provide all info for failing CI in the info popup. +- [X] Add a loading state for the menu bar icon when the app is first loading up. + + + +- [ ] Make the dropdown resizable lengthways by clicking and dragging on the bottom edge of the dropdown. The length of the dropdown should persist between dropdown opens and app restarts. +- [ ] Make it so that you can always see the title of the triage category at the top when you're scrolling. For example, at first as you scroll down the "NEEDS YOUR ACTION" should remain at the top, then when the "WAITING ON OTHERS" gets to the top it should push up the "NEEDS YOUR ACTION" and then "WAITING ON OTHERS" should take its place at the top, then when "YOUR DRAFT PRs" reaches the top it should push "WAITING ON OTHERS" up and then "YOUR DRAFT PRs" should be at the top. + + + + +# Bugs +- [ ] When a PR is in draft the CI indicator always shows "Checking mergeability" whatever is actually happening to the CI. Can we please make it so that the CI indicator info popup says the actual mergeability status. +- [ ] Consider the following scenario: user A has ownership of PR1, and then requests a review from user B, and then user B submits a "Changes requested" review on a PR, and then user A actions changes and then requests another review from user B. Currently, PR1's review indicator is red and the review indicator information dropdown gives the reason as "Changes requested by x". We want the behaviour to be that PR1's review indication indicates that a review has been requested from user B (this should also make the review indicator state for PR1 orange). +- [ ] A draft PR can show "Checks pending" when all the checks have passed. +- [ ] PRs that are in draft, that you didn't create and are not assigned to should not appear in the app. +- [ ] Failing CI checks can show repeated info in the info popup. + + +# Features +- [ ] in the CI checks info popup can we show for each action if it has failed (red x), is running (amber circle), or has passed (green tick). +- [ ] Filter repos. +- [ ] Set PR priorities – prioritised PRs will be put at the top of lists. +- [ ] Ability to reorder PRs. + + +# Efficiency +- [ ] Don't poll draft PRs ? + + +# Dev +- [X] GitHub actions for lint + check. + + +# Manual checks +- [ ] Test UX for adding a token. +- [ ] Efficiency check. +- [ ] How are the "Your PR", "Assigned" labels decided ? +- [ ] Notifications. +- [ ] Notification sounds. diff --git a/docs/initial-spec.md b/docs/initial-spec.md index 3a3d2ae..97b1b45 100644 --- a/docs/initial-spec.md +++ b/docs/initial-spec.md @@ -4,12 +4,13 @@ We want to build a macOS menu bar app, which provides a quick view of the status ### MVP -- Show state of all open PRs. +- [X] Show state of all open PRs. - Show PR number + start of title. - States: draft, open (no review submitted), waiting for review, review requested from me, reviewed: changes requested, reviewed: approved, assigned to PR (?), merged. - Show if a PR has merge conflicts, or failing GitHub actions or merge is blocked for another reason. - *Seems like there are multiple types of states: merge status (conflicts, failing actions, other blockers (e.g., conversation must be resolved), ready to merge, merged), reviews (waiting, requested, reviewed: changes requested, reviewed: approved), other (assigned to PR, unassigned from PR).* -- Button that opens the url of the PR in web browser. + - Review-state precedence for MVP triage: `changes requested` wins over everything, then any currently outstanding review requests, then prior approvals. If a PR was approved and later new reviewers are requested, the PR should display as waiting for review until those requests are satisfied. +- [X] Button that opens the url of the PR in web browser. - Notification (including sound) when a tracked PR enters a status. ### V2 diff --git a/docs/plans/2026-03-31-review-fixes.md b/docs/plans/2026-03-31-review-fixes.md new file mode 100644 index 0000000..e83590b --- /dev/null +++ b/docs/plans/2026-03-31-review-fixes.md @@ -0,0 +1,91 @@ +# Review Findings Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Verify each cited review finding against the current checkout and fix only the issues that still apply. + +**Architecture:** Keep changes local to the affected workflow, services, views, tests, and docs. Add focused regression coverage for behavior changes first, then implement the minimal production changes needed to make those tests pass. + +**Tech Stack:** Swift 5.9, Swift Testing, SwiftUI, GitHub Actions, `mise` + +### Task 1: Verify Findings and Capture Red Tests + +**Files:** +- Modify: `GitHubCommandCenterTests/GitHubRESTClientTests.swift` +- Modify: `GitHubCommandCenterTests/GitHubRESTClientTokenAccessTests.swift` +- Modify: `GitHubCommandCenterTests/AppStateTests.swift` +- Modify: `GitHubCommandCenterTests/NotificationServiceTests.swift` +- Modify: `GitHubCommandCenterTests/SettingsValidationTests.swift` + +**Step 1: Write failing tests for remaining behavior changes** + +- Add a cache-eviction test proving GitHub REST caching preserves recently used entries instead of clearing the full cache. +- Add an app-state/UI guard test proving recently closed PRs are only shown for successful load states. +- Add a notification scheduling test proving delivery errors are surfaced to an injected handler. +- Relax the settings validation assertion so it checks the stable part of the keychain error. +- Add the missing page-11 pagination stub in the token-access limit test. + +**Step 2: Run targeted tests to verify they fail for the expected reason** + +Run: + +```bash +xcodebuild test -project GitHubCommandCenter.xcodeproj -scheme GitHubCommandCenter -destination 'platform=macOS' -only-testing:GitHubCommandCenterTests/GitHubRESTClientTests -only-testing:GitHubCommandCenterTests/NotificationServiceTests -only-testing:GitHubCommandCenterTests/AppStateTests -only-testing:GitHubCommandCenterTests/SettingsValidationTests +``` + +Expected: failures around cache eviction, notification delivery error handling, and the recently-closed visibility helper until implementation lands. + +### Task 2: Implement the Minimal Fixes + +**Files:** +- Modify: `.github/workflows/release.yml` +- Modify: `GitHubCommandCenter/Services/GitHubRESTClient.swift` +- Modify: `GitHubCommandCenter/Services/KeychainService.swift` +- Modify: `GitHubCommandCenter/Services/NotificationService.swift` +- Modify: `GitHubCommandCenter/Views/PRListView.swift` +- Create: `GitHubCommandCenter/Logging.swift` +- Modify: `GitHubCommandCenter/Views/PRRowView.swift` +- Modify: `GitHubCommandCenter/Views/SettingsView.swift` +- Modify: `GitHubCommandCenterTests/GitHubRESTClientTestSupport.swift` +- Modify: `GitHubCommandCenterTests/Helpers/MockGitHubDataSource.swift` +- Modify: `TESTING_GUIDE.md` +- Modify: `mise.toml` + +**Step 1: Replace the broad Xcode action tag with a pinned release** + +- Update `maxim-lobanov/setup-xcode@v1` to a specific tested `v1.x.y` tag in `release.yml`. + +**Step 2: Replace whole-cache clearing with LRU eviction** + +- Store response data and ETags together in one LRU entry. +- Touch entries on read and write. +- Evict oldest entries until the cache is back under `maxCacheEntries`. + +**Step 3: Harden token storage and notification delivery** + +- Add `kSecAttrAccessible` to the shared keychain query. +- Route notification scheduling through `add(_:withCompletionHandler:)` and surface delivery errors via logging/test injection. + +**Step 4: Apply the smaller correctness fixes** + +- Gate recently closed rendering on successful load states only. +- Move the logging subsystem constant out of `Theme.swift`. +- Add explicit `Harness.teardown()` cleanup and call it deterministically in REST client tests. +- Synchronize all mock data-source mutable state behind one queue. +- Update docs and `mise` task metadata. + +### Task 3: Verify and Summarize + +**Files:** +- Verify only + +**Step 1: Run targeted tests and focused repo checks** + +Run: + +```bash +mise run check +xcodebuild test -project GitHubCommandCenter.xcodeproj -scheme GitHubCommandCenter -destination 'platform=macOS' -only-testing:GitHubCommandCenterTests/GitHubRESTClientTests -only-testing:GitHubCommandCenterTests/GitHubRESTClientTokenAccessTests -only-testing:GitHubCommandCenterTests/GitHubRESTClientValidationTests -only-testing:GitHubCommandCenterTests/AppStateTests -only-testing:GitHubCommandCenterTests/PollingEngineTests -only-testing:GitHubCommandCenterTests/NotificationServiceTests -only-testing:GitHubCommandCenterTests/SettingsValidationTests -only-testing:GitHubCommandCenterTests/KeychainServiceTests +``` + +Expected: targeted tests pass, style checks pass, and remaining changed files are the intended review-fix edits. diff --git a/docs/superpowers/plans/2026-03-31-pr-reorder-within-triage-groups.md b/docs/superpowers/plans/2026-03-31-pr-reorder-within-triage-groups.md new file mode 100644 index 0000000..8a8eddd --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-pr-reorder-within-triage-groups.md @@ -0,0 +1,1210 @@ +# PR Reorder Within Triage Groups — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users drag-and-drop PRs to reorder them within their triage sections (Needs Your Action, Waiting On Others, Your Draft PRs), with order persisted across app restarts and resilient to polling updates. + +**Architecture:** A `PROrderStore` backed by `UserDefaults` stores custom ordering as arrays of PR IDs keyed by triage category. When order is applied or mutated, the store normalizes persisted IDs against the current live PR IDs, removes duplicates and stale IDs, and clears persistence entirely when the effective order matches the section's default sort. `AppState.recomputePRCaches()` is extended to apply stored order when available, falling back to default sorting for unordered or new PRs. `PRListView` uses SwiftUI's `onDrag` + `onDrop(of:delegate:)` with `DropDelegate` row targets plus a trailing end-of-section target because that API exposes the lifecycle hooks and `.move` proposal needed for precise reordering. The insertion indicator is required feedback; dragged-row dimming is optional and should only ship if transient drag state can be cleared reliably in manual testing. + +**Tech Stack:** Swift 5.9, SwiftUI, macOS 14+, UserDefaults, UniformTypeIdentifiers, Swift Testing + +**Design direction:** Utilitarian and minimal — matching the existing dark panel aesthetic. Drag handles appear only on hover. A thin blue insertion indicator gives precise placement feedback. A reset-order icon appears in the section header only when the effective persisted order differs from that section's default sort. If dragged-row dimming is added, it must clear reliably on cancelled drags; otherwise ship without it. + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|---------------| +| Create | `GitHubCommandCenter/Services/PROrderStore.swift` | UserDefaults persistence for custom PR order per triage category | +| Modify | `GitHubCommandCenter/Models/AppState.swift` | Inject `PROrderStore`, use it in `recomputePRCaches()`, expose reorder/reset methods | +| Create | `GitHubCommandCenter/Views/PRReorderDropDelegate.swift` | Section-scoped drop delegate and trailing end-of-section drop target | +| Modify | `GitHubCommandCenter/Views/PRListView.swift` | Drag-and-drop wiring, drop indicator, section header reset button | +| Modify | `GitHubCommandCenter/Views/PRRowView.swift` | Add drag handle grip that appears on hover | +| Create | `GitHubCommandCenterTests/PROrderStoreTests.swift` | Unit tests for ordering persistence and merge logic | +| Create | `GitHubCommandCenterTests/AppStatePROrderTests.swift` | Integration tests: custom order applied to cached PR lists | + +--- + +### Task 1: PROrderStore — Persistence Layer + +**Files:** +- Create: `GitHubCommandCenter/Services/PROrderStore.swift` + +- [ ] **Step 1: Write the failing test — storing and retrieving order** + +Create `GitHubCommandCenterTests/PROrderStoreTests.swift`: + +```swift +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@Suite +struct PROrderStoreTests { + private func makeStore() -> PROrderStore { + let defaults = UserDefaults(suiteName: "PROrderStoreTests.\(UUID().uuidString)")! + return PROrderStore(defaults: defaults) + } + + @Test + func storedOrder_emptyByDefault() { + let store = makeStore() + #expect(store.storedOrder(for: .needsYourAction) == nil) + #expect(store.storedOrder(for: .waitingOnOthers) == nil) + #expect(store.storedOrder(for: .yourDraft) == nil) + } + + @Test + func setOrder_persistsAndRetrieves() { + let store = makeStore() + let ids = ["owner/repo#1", "owner/repo#3", "owner/repo#2"] + store.setOrder(ids, for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == ids) + } + + @Test + func setOrder_categoriesAreIndependent() { + let store = makeStore() + store.setOrder(["a"], for: .needsYourAction) + store.setOrder(["b"], for: .waitingOnOthers) + #expect(store.storedOrder(for: .needsYourAction) == ["a"]) + #expect(store.storedOrder(for: .waitingOnOthers) == ["b"]) + #expect(store.storedOrder(for: .yourDraft) == nil) + } + + @Test + func clearOrder_removesStoredOrder() { + let store = makeStore() + store.setOrder(["a", "b"], for: .needsYourAction) + store.clearOrder(for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == nil) + } + + @Test + func hasCustomOrder_reflectsStoredState() { + let store = makeStore() + #expect(store.hasCustomOrder(for: .needsYourAction) == false) + store.setOrder(["a"], for: .needsYourAction) + #expect(store.hasCustomOrder(for: .needsYourAction) == true) + store.clearOrder(for: .needsYourAction) + #expect(store.hasCustomOrder(for: .needsYourAction) == false) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mise run test` +Expected: Compilation failure — `PROrderStore` does not exist. + +- [ ] **Step 3: Implement PROrderStore** + +Create `GitHubCommandCenter/Services/PROrderStore.swift`: + +```swift +import Foundation + +struct PROrderStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func storedOrder(for category: PRState.TriageCategory) -> [String]? { + let key = defaultsKey(for: category) + let stored = defaults.stringArray(forKey: key) + if let stored, stored.isEmpty { return nil } + return stored + } + + func setOrder(_ ids: [String], for category: PRState.TriageCategory) { + let key = defaultsKey(for: category) + guard !ids.isEmpty else { + defaults.removeObject(forKey: key) + return + } + defaults.set(ids, forKey: key) + } + + func clearOrder(for category: PRState.TriageCategory) { + defaults.removeObject(forKey: defaultsKey(for: category)) + } + + func hasCustomOrder(for category: PRState.TriageCategory) -> Bool { + storedOrder(for: category) != nil + } + + private func defaultsKey(for category: PRState.TriageCategory) -> String { + switch category { + case .needsYourAction: return "prOrder.needsYourAction" + case .waitingOnOthers: return "prOrder.waitingOnOthers" + case .yourDraft: return "prOrder.yourDraft" + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mise run test` +Expected: All `PROrderStoreTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add GitHubCommandCenter/Services/PROrderStore.swift GitHubCommandCenterTests/PROrderStoreTests.swift +git commit -m "feat: add PROrderStore for persisting custom PR order per triage category" +``` + +--- + +### Task 2: PROrderStore — Merge Logic and Normalization Cleanup + +**Files:** +- Modify: `GitHubCommandCenter/Services/PROrderStore.swift` +- Modify: `GitHubCommandCenterTests/PROrderStoreTests.swift` + +- [ ] **Step 1: Write the failing test — applyOrder merges stored order with live PRs** + +Add to `PROrderStoreTests.swift`: + +```swift +@Test +func applyOrder_noStoredOrder_usesDefaultSort() { + let store = makeStore() + let prs = [ + PRState.fixture(number: 1, updatedAt: Date(timeIntervalSince1970: 100)), + PRState.fixture(number: 2, updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 3, updatedAt: Date(timeIntervalSince1970: 200)), + ] + let sorted = store.applyOrder( + to: prs, + category: .waitingOnOthers, + defaultSort: { $0.updatedAt > $1.updatedAt } + ) + #expect(sorted.map(\.number) == [2, 3, 1]) +} + +@Test +func applyOrder_withStoredOrder_respectsCustomOrder() { + let store = makeStore() + let prs = [ + PRState.fixture(number: 1, repoFullName: "o/r"), + PRState.fixture(number: 2, repoFullName: "o/r"), + PRState.fixture(number: 3, repoFullName: "o/r"), + ] + store.setOrder(["o/r#3", "o/r#1", "o/r#2"], for: .needsYourAction) + let sorted = store.applyOrder( + to: prs, + category: .needsYourAction, + defaultSort: { $0.number < $1.number } + ) + #expect(sorted.map(\.number) == [3, 1, 2]) +} + +@Test +func applyOrder_newPRsAppearAtEnd_inDefaultOrder() { + let store = makeStore() + let prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", updatedAt: Date(timeIntervalSince1970: 100)), + PRState.fixture(number: 2, repoFullName: "o/r", updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 3, repoFullName: "o/r", updatedAt: Date(timeIntervalSince1970: 200)), + ] + // Only PR 1 is in stored order — PRs 2 and 3 are "new" + store.setOrder(["o/r#1"], for: .waitingOnOthers) + let sorted = store.applyOrder( + to: prs, + category: .waitingOnOthers, + defaultSort: { $0.updatedAt > $1.updatedAt } + ) + // PR 1 first (stored), then 2, 3 in default sort (by updatedAt desc) + #expect(sorted.map(\.number) == [1, 2, 3]) + #expect(store.storedOrder(for: .waitingOnOthers) == ["o/r#1", "o/r#2", "o/r#3"]) +} + +@Test +func applyOrder_removedPRs_areIgnoredAndStorageFallsBackToDefault() { + let store = makeStore() + let prs = [ + PRState.fixture(number: 2, repoFullName: "o/r"), + ] + // PR 1 and 3 were in stored order but no longer exist + store.setOrder(["o/r#1", "o/r#2", "o/r#3"], for: .needsYourAction) + let sorted = store.applyOrder( + to: prs, + category: .needsYourAction, + defaultSort: { $0.number < $1.number } + ) + #expect(sorted.map(\.number) == [2]) + #expect(store.storedOrder(for: .needsYourAction) == nil) +} + +@Test +func applyOrder_duplicateStoredIDs_areDeduplicatedAndPersisted() { + let store = makeStore() + let prs = [ + PRState.fixture(number: 1, repoFullName: "o/r"), + PRState.fixture(number: 2, repoFullName: "o/r"), + PRState.fixture(number: 3, repoFullName: "o/r"), + ] + store.setOrder(["o/r#3", "o/r#1", "o/r#3"], for: .needsYourAction) + + let sorted = store.applyOrder( + to: prs, + category: .needsYourAction, + defaultSort: { $0.number < $1.number } + ) + + #expect(sorted.map(\.number) == [3, 1, 2]) + #expect(store.storedOrder(for: .needsYourAction) == ["o/r#3", "o/r#1", "o/r#2"]) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mise run test` +Expected: Compilation failure — `applyOrder(to:category:defaultSort:)` does not exist. + +- [ ] **Step 3: Implement applyOrder** + +Add to `PROrderStore.swift`: + +```swift +private func normalizedOrderIDs( + for category: PRState.TriageCategory, + currentIDs: [String] +) -> (ids: [String], hadStoredOrder: Bool, didNormalize: Bool) { + let storedIDs = storedOrder(for: category) + let rawStoredIDs = storedIDs ?? [] + let liveIDs = Set(currentIDs) + var normalized: [String] = [] + var seen = Set() + + for id in rawStoredIDs where liveIDs.contains(id) { + if seen.insert(id).inserted { + normalized.append(id) + } + } + + for id in currentIDs where seen.insert(id).inserted { + normalized.append(id) + } + + return ( + ids: normalized, + hadStoredOrder: storedIDs != nil, + didNormalize: normalized != rawStoredIDs + ) +} + +private func persistCustomOrder( + _ ids: [String], + defaultIDs: [String], + for category: PRState.TriageCategory +) { + if ids == defaultIDs { + clearOrder(for: category) + } else { + setOrder(ids, for: category) + } +} + +func applyOrder( + to prs: [PRState], + category: PRState.TriageCategory, + defaultSort: (PRState, PRState) -> Bool +) -> [PRState] { + let defaultSortedPRs = prs.sorted(by: defaultSort) + let defaultIDs = defaultSortedPRs.map(\.id) + let prsByID = Dictionary(uniqueKeysWithValues: defaultSortedPRs.map { ($0.id, $0) }) + let normalization = normalizedOrderIDs( + for: category, + currentIDs: defaultIDs + ) + if normalization.hadStoredOrder && normalization.didNormalize { + persistCustomOrder(normalization.ids, defaultIDs: defaultIDs, for: category) + } + return normalization.ids.compactMap { prsByID[$0] } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mise run test` +Expected: All `PROrderStoreTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add GitHubCommandCenter/Services/PROrderStore.swift GitHubCommandCenterTests/PROrderStoreTests.swift +git commit -m "feat: add applyOrder to merge custom order with live PR data" +``` + +--- + +### Task 3: PROrderStore — moveItem Helper + +**Files:** +- Modify: `GitHubCommandCenter/Services/PROrderStore.swift` +- Modify: `GitHubCommandCenterTests/PROrderStoreTests.swift` + +- [ ] **Step 1: Write the failing test — moveItem reorders within stored order** + +Add to `PROrderStoreTests.swift`: + +```swift +@Test +func moveItem_movesToBeforeTarget() { + let store = makeStore() + store.setOrder(["a", "b", "c", "d"], for: .needsYourAction) + store.moveItem("d", before: "b", using: ["a", "b", "c", "d"], for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == ["a", "d", "b", "c"]) +} + +@Test +func moveItem_movesToEnd_whenBeforeIDIsNil() { + let store = makeStore() + store.setOrder(["a", "b", "c"], for: .needsYourAction) + store.moveItem("a", before: nil, using: ["a", "b", "c"], for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == ["b", "c", "a"]) +} + +@Test +func moveItem_noStoredOrder_createsFromCurrentList() { + let store = makeStore() + store.moveItem("c", before: "a", using: ["a", "b", "c"], for: .waitingOnOthers) + #expect(store.storedOrder(for: .waitingOnOthers) == ["c", "a", "b"]) +} + +@Test +func moveItem_partialStoredOrder_canMoveNewlyPolledPR() { + let store = makeStore() + store.setOrder(["a"], for: .needsYourAction) + store.moveItem("c", before: "a", using: ["a", "b", "c"], for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == ["c", "a", "b"]) +} + +@Test +func moveItem_sameEffectivePosition_keepsCustomOrder() { + let store = makeStore() + store.setOrder(["c", "a", "b"], for: .needsYourAction) + store.moveItem("a", before: "b", using: ["a", "b", "c"], for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == ["c", "a", "b"]) +} + +@Test +func moveItem_resultMatchingDefaultOrder_clearsStoredOrder() { + let store = makeStore() + store.setOrder(["c", "a", "b"], for: .needsYourAction) + store.moveItem("c", before: nil, using: ["a", "b", "c"], for: .needsYourAction) + #expect(store.storedOrder(for: .needsYourAction) == nil) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mise run test` +Expected: Compilation failure — `moveItem` does not exist. + +- [ ] **Step 3: Implement moveItem** + +Add to `PROrderStore.swift`: + +```swift +func moveItem( + _ movedID: String, + before targetID: String?, + using currentIDs: [String], + for category: PRState.TriageCategory +) { + var order = normalizedOrderIDs(for: category, currentIDs: currentIDs).ids + guard let fromIndex = order.firstIndex(of: movedID) else { return } + order.remove(at: fromIndex) + + if let targetID, let toIndex = order.firstIndex(of: targetID) { + order.insert(movedID, at: toIndex) + } else { + order.append(movedID) + } + + persistCustomOrder(order, defaultIDs: currentIDs, for: category) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mise run test` +Expected: All `PROrderStoreTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add GitHubCommandCenter/Services/PROrderStore.swift GitHubCommandCenterTests/PROrderStoreTests.swift +git commit -m "feat: add moveItem to PROrderStore for drag-and-drop reordering" +``` + +--- + +### Task 4: AppState Integration — Apply Custom Ordering + +**Files:** +- Modify: `GitHubCommandCenter/Models/AppState.swift` + +- [ ] **Step 1: Write the failing test — AppState applies custom order to cached PRs** + +Create `GitHubCommandCenterTests/AppStatePROrderTests.swift`: + +```swift +import Foundation +import Testing + +@testable import GitHubCommandCenter + +@MainActor +@Suite +struct AppStatePROrderTests { + private func makeAppState(orderStore: PROrderStore? = nil) -> AppState { + AppState( + makePollingEngine: { _ in StubPollingEngine() }, + requestNotificationPermission: {}, + orderStore: orderStore ?? PROrderStore(defaults: UserDefaults(suiteName: "AppStatePROrderTests.\(UUID().uuidString)")!) + ) + } + + @Test + func needsActionPRs_withCustomOrder_respectsStoredOrder() { + let defaults = UserDefaults(suiteName: "AppStatePROrderTests.\(UUID().uuidString)")! + let store = PROrderStore(defaults: defaults) + + // Store custom order: PR 3 first, then PR 1 + store.setOrder(["o/r#3", "o/r#1"], for: .needsYourAction) + + let appState = makeAppState(orderStore: store) + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true), + PRState.fixture(number: 3, repoFullName: "o/r", reviewRequestedFromMe: true), + ] + + #expect(appState.needsActionPRs.map(\.number) == [3, 1]) + } + + @Test + func reorderPR_withPartialStoredOrder_canMoveNewlyPolledPR() { + let defaults = UserDefaults(suiteName: "AppStatePROrderTests.\(UUID().uuidString)")! + let store = PROrderStore(defaults: defaults) + store.setOrder(["o/r#1"], for: .needsYourAction) + + let appState = makeAppState(orderStore: store) + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 2, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 200)), + PRState.fixture(number: 3, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 100)), + ] + + #expect(appState.needsActionPRs.map(\.number) == [1, 2, 3]) + + appState.reorderPR(movedID: "o/r#3", beforeID: "o/r#1", inCategory: .needsYourAction) + + #expect(appState.needsActionPRs.map(\.number) == [3, 1, 2]) + } + + @Test + func reorderPR_updatesOrderAndCaches() { + let appState = makeAppState() + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 2, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 200)), + PRState.fixture(number: 3, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 100)), + ] + + // Default order by urgency/date: 1, 2, 3 + #expect(appState.needsActionPRs.map(\.number) == [1, 2, 3]) + + // Move PR 3 before PR 1 + appState.reorderPR(movedID: "o/r#3", beforeID: "o/r#1", inCategory: .needsYourAction) + + #expect(appState.needsActionPRs.map(\.number) == [3, 1, 2]) + } + + @Test + func resetOrder_restoresDefaultSort() { + let appState = makeAppState() + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 2, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 100)), + ] + + appState.reorderPR(movedID: "o/r#2", beforeID: "o/r#1", inCategory: .needsYourAction) + #expect(appState.needsActionPRs.map(\.number) == [2, 1]) + + appState.resetOrder(for: .needsYourAction) + #expect(appState.needsActionPRs.map(\.number) == [1, 2]) + } + + @Test + func hasCustomOrder_reflectsStoreState() { + let appState = makeAppState() + #expect(appState.hasCustomOrder(for: .needsYourAction) == false) + + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true), + PRState.fixture(number: 2, repoFullName: "o/r", reviewRequestedFromMe: true), + ] + + appState.reorderPR(movedID: "o/r#2", beforeID: "o/r#1", inCategory: .needsYourAction) + #expect(appState.hasCustomOrder(for: .needsYourAction) == true) + } + + @Test + func hasCustomOrder_returnsFalseWhenReorderReturnsToDefaultSort() { + let appState = makeAppState() + appState.prs = [ + PRState.fixture(number: 1, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 300)), + PRState.fixture(number: 2, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 200)), + PRState.fixture(number: 3, repoFullName: "o/r", reviewRequestedFromMe: true, + updatedAt: Date(timeIntervalSince1970: 100)), + ] + + appState.reorderPR(movedID: "o/r#3", beforeID: "o/r#1", inCategory: .needsYourAction) + #expect(appState.hasCustomOrder(for: .needsYourAction) == true) + + appState.reorderPR(movedID: "o/r#3", beforeID: nil, inCategory: .needsYourAction) + #expect(appState.needsActionPRs.map(\.number) == [1, 2, 3]) + #expect(appState.hasCustomOrder(for: .needsYourAction) == false) + } + + private final class StubPollingEngine: PollingControlling { + func start() {} + func stop() {} + func reset() {} + func forceRefresh() {} + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mise run test` +Expected: Compilation failure — `AppState` init doesn't accept `orderStore`, no `reorderPR`/`resetOrder`/`hasCustomOrder` methods. + +- [ ] **Step 3: Modify AppState to accept and use PROrderStore** + +In `GitHubCommandCenter/Models/AppState.swift`, make these changes: + +**Add `orderStore` property and update initializers** (after the existing `private var cached...` lines, around line 127): + +Replace the private stored properties section: + +```swift + private let makePollingEngine: (AppState) -> any PollingControlling + private let requestNotificationPermission: () async -> Void + private let preloadTokenIfNeeded: () -> Void + private var cachedNeedsActionPRs: [PRState] = [] + private var cachedWaitingOnOthersPRs: [PRState] = [] + private var cachedYourDraftPRs: [PRState] = [] +``` + +with: + +```swift + private let makePollingEngine: (AppState) -> any PollingControlling + private let requestNotificationPermission: () async -> Void + private let preloadTokenIfNeeded: () -> Void + private var orderStore: PROrderStore + private var cachedNeedsActionPRs: [PRState] = [] + private var cachedWaitingOnOthersPRs: [PRState] = [] + private var cachedYourDraftPRs: [PRState] = [] +``` + +**Update the default init** (around line 129): + +Replace: + +```swift + init() { + self.makePollingEngine = { PollingEngine(appState: $0) } + self.requestNotificationPermission = { await NotificationService.shared.requestPermission() } + self.preloadTokenIfNeeded = { + _ = EnvironmentTokenBootstrapper().preloadIfNeeded() + } + } +``` + +with: + +```swift + init() { + self.makePollingEngine = { PollingEngine(appState: $0) } + self.requestNotificationPermission = { await NotificationService.shared.requestPermission() } + self.preloadTokenIfNeeded = { + _ = EnvironmentTokenBootstrapper().preloadIfNeeded() + } + self.orderStore = PROrderStore() + } +``` + +**Update the test-friendly init** (around line 137): + +Replace: + +```swift + init( + makePollingEngine: @escaping (AppState) -> any PollingControlling, + requestNotificationPermission: @escaping () async -> Void, + preloadTokenIfNeeded: @escaping () -> Void = {} + ) { + self.makePollingEngine = makePollingEngine + self.requestNotificationPermission = requestNotificationPermission + self.preloadTokenIfNeeded = preloadTokenIfNeeded + } +``` + +with: + +```swift + init( + makePollingEngine: @escaping (AppState) -> any PollingControlling, + requestNotificationPermission: @escaping () async -> Void, + preloadTokenIfNeeded: @escaping () -> Void = {}, + orderStore: PROrderStore = PROrderStore() + ) { + self.makePollingEngine = makePollingEngine + self.requestNotificationPermission = requestNotificationPermission + self.preloadTokenIfNeeded = preloadTokenIfNeeded + self.orderStore = orderStore + } +``` + +**Update `recomputePRCaches`** (around line 157): + +Replace: + +```swift + private func recomputePRCaches() { + cachedNeedsActionPRs = + prs + .filter { $0.triageCategory == .needsYourAction } + .sorted(by: PRState.compareForNeedsAction) + cachedWaitingOnOthersPRs = + prs + .filter { $0.triageCategory == .waitingOnOthers } + .sorted { $0.updatedAt > $1.updatedAt } + cachedYourDraftPRs = + prs + .filter { $0.triageCategory == .yourDraft } + .sorted { $0.updatedAt > $1.updatedAt } + } +``` + +with: + +```swift + private func recomputePRCaches() { + cachedNeedsActionPRs = orderStore.applyOrder( + to: prs.filter { $0.triageCategory == .needsYourAction }, + category: .needsYourAction, + defaultSort: PRState.compareForNeedsAction + ) + cachedWaitingOnOthersPRs = orderStore.applyOrder( + to: prs.filter { $0.triageCategory == .waitingOnOthers }, + category: .waitingOnOthers, + defaultSort: { $0.updatedAt > $1.updatedAt } + ) + cachedYourDraftPRs = orderStore.applyOrder( + to: prs.filter { $0.triageCategory == .yourDraft }, + category: .yourDraft, + defaultSort: { $0.updatedAt > $1.updatedAt } + ) + } +``` + +**Add reorder and reset methods** (after `recomputePRCaches`, before `startPollingIfNeeded`): + +```swift + func reorderPR(movedID: String, beforeID: String?, inCategory category: PRState.TriageCategory) { + let currentIDs: [String] + switch category { + case .needsYourAction: currentIDs = cachedNeedsActionPRs.map(\.id) + case .waitingOnOthers: currentIDs = cachedWaitingOnOthersPRs.map(\.id) + case .yourDraft: currentIDs = cachedYourDraftPRs.map(\.id) + } + orderStore.moveItem(movedID, before: beforeID, using: currentIDs, for: category) + objectWillChange.send() + recomputePRCaches() + } + + func resetOrder(for category: PRState.TriageCategory) { + orderStore.clearOrder(for: category) + objectWillChange.send() + recomputePRCaches() + } + + func hasCustomOrder(for category: PRState.TriageCategory) -> Bool { + orderStore.hasCustomOrder(for: category) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mise run test` +Expected: All tests pass, including new `AppStatePROrderTests`. + +- [ ] **Step 5: Commit** + +```bash +git add GitHubCommandCenter/Models/AppState.swift GitHubCommandCenterTests/AppStatePROrderTests.swift +git commit -m "feat: integrate PROrderStore into AppState for custom PR ordering" +``` + +--- + +### Task 5: PRListView — Drop Delegate and Drag Wiring + +**Files:** +- Create: `GitHubCommandCenter/Views/PRReorderDropDelegate.swift` +- Modify: `GitHubCommandCenter/Views/PRListView.swift` + +- [ ] **Step 1: Re-read the SwiftUI docs, then create PRReorderDropDelegate** + +Before implementing, read the Apple SwiftUI docs via Context7 for: +- `View.onDrop(of:delegate:)` +- `DropDelegate` +- `View.draggable(_:)` +- `View.dropDestination(...)` + +Implementation note: use `onDrop(of:delegate:)` here, not `dropDestination`, because row-by-row reordering needs `DropDelegate` lifecycle callbacks (`validateDrop`, `dropEntered`, `dropExited`, `dropUpdated`, `performDrop`) plus `DropProposal(operation: .move)` for precise insertion behavior. + +Create `GitHubCommandCenter/Views/PRReorderDropDelegate.swift`: + +```swift +import SwiftUI +import UniformTypeIdentifiers + +enum PRDropTarget: Equatable { + case before(String) + case endOfSection(PRState.TriageCategory) +} + +struct PRReorderDropDelegate: DropDelegate { + let target: PRDropTarget + let category: PRState.TriageCategory + @Binding var draggedPRID: String? + @Binding var draggedPRCategory: PRState.TriageCategory? + @Binding var dropTarget: PRDropTarget? + let onReorder: (String, String?, PRState.TriageCategory) -> Void + let clearDragState: () -> Void + + func dropEntered(info: DropInfo) { + guard isValidDrop else { return } + withAnimation(.easeInOut(duration: 0.15)) { + dropTarget = target + } + } + + func dropExited(info: DropInfo) { + if dropTarget == target { + withAnimation(.easeInOut(duration: 0.15)) { + dropTarget = nil + } + } + } + + func performDrop(info: DropInfo) -> Bool { + guard isValidDrop, let draggedID = draggedPRID else { return false } + + let beforeID: String? + switch target { + case .before(let id): + beforeID = id + case .endOfSection: + beforeID = nil + } + + onReorder(draggedID, beforeID, category) + clearDragState() + return true + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + isValidDrop ? DropProposal(operation: .move) : nil + } + + func validateDrop(info: DropInfo) -> Bool { + isValidDrop + } + + private var isValidDrop: Bool { + guard let draggedID = draggedPRID, draggedPRCategory == category else { return false } + + switch target { + case .before(let id): + return draggedID != id + case .endOfSection: + return true + } + } +} +``` + +- [ ] **Step 2: Run `mise run build` to verify it compiles** + +Expected: Build succeeds. + +- [ ] **Step 3: Add drag state and update prSections in PRListView** + +In `GitHubCommandCenter/Views/PRListView.swift`, add state properties to `PRListView` (after the existing `@State private var panelMode` line): + +```swift + @State private var draggedPRID: String? + @State private var draggedPRCategory: PRState.TriageCategory? + @State private var dropTarget: PRDropTarget? +``` + +Add `import UniformTypeIdentifiers` at the top of the file (after `import SwiftUI`). + +Replace the `prSections` computed property (lines 182–211): + +```swift + @ViewBuilder + private var prSections: some View { + let needsAction = appState.needsActionPRs + let waiting = appState.waitingOnOthersPRs + let drafts = appState.yourDraftPRs + + if !needsAction.isEmpty { + sectionHeader("NEEDS YOUR ACTION", category: .needsYourAction) + draggableSection(prs: needsAction, category: .needsYourAction, opacity: 1.0) + } + + if !waiting.isEmpty { + sectionHeader("WAITING ON OTHERS", category: .waitingOnOthers) + draggableSection(prs: waiting, category: .waitingOnOthers, opacity: 0.55) + } + + if !drafts.isEmpty { + sectionHeader("YOUR DRAFT PRs", category: .yourDraft) + draggableSection(prs: drafts, category: .yourDraft, opacity: 0.5) + } + } + + private func draggableSection(prs: [PRState], category: PRState.TriageCategory, opacity: Double) -> some View { + VStack(spacing: 0) { + ForEach(prs) { pr in + VStack(spacing: 0) { + if dropTarget == .before(pr.id) { + dropIndicator() + } + PRRowView(pr: pr) + .opacity(opacity) + .onDrag { + draggedPRID = pr.id + draggedPRCategory = category + dropTarget = nil + return NSItemProvider(object: pr.id as NSString) + } + .onDrop( + of: [UTType.text], + delegate: PRReorderDropDelegate( + target: .before(pr.id), + category: category, + draggedPRID: $draggedPRID, + draggedPRCategory: $draggedPRCategory, + dropTarget: $dropTarget, + onReorder: { movedID, beforeID, cat in + appState.reorderPR(movedID: movedID, beforeID: beforeID, inCategory: cat) + }, + clearDragState: clearDragState + ) + ) + themedDivider() + } + } + + if dropTarget == .endOfSection(category) { + dropIndicator() + } + + Color.clear + .frame(height: 20) + .contentShape(Rectangle()) + .onDrop( + of: [UTType.text], + delegate: PRReorderDropDelegate( + target: .endOfSection(category), + category: category, + draggedPRID: $draggedPRID, + draggedPRCategory: $draggedPRCategory, + dropTarget: $dropTarget, + onReorder: { movedID, beforeID, cat in + appState.reorderPR(movedID: movedID, beforeID: beforeID, inCategory: cat) + }, + clearDragState: clearDragState + ) + ) + } + } + + private func clearDragState() { + draggedPRID = nil + draggedPRCategory = nil + dropTarget = nil + } + + private func dropIndicator() -> some View { + Rectangle() + .fill(Color.linkBlue) + .frame(height: 2) + .padding(.horizontal, Spacing.xl) + .transition(.opacity) + } +``` + +Important implementation note: +- Do not add dragged-row dimming in the first pass. +- The insertion indicator is the required feedback. +- After the smoke test, only add source-row dimming if it can be cleared reliably on cancelled drags. If it can stick, remove it and keep the insertion indicator only. + +- [ ] **Step 4: Update sectionHeader to accept category and show reset button** + +Replace the existing `sectionHeader` function (lines 213–222): + +```swift + private func sectionHeader(_ title: String, category: PRState.TriageCategory) -> some View { + HStack { + Text(title) + .font(.sectionLabel) + .tracking(1) + .foregroundColor(.textMuted) + Spacer() + if appState.hasCustomOrder(for: category) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + appState.resetOrder(for: category) + } + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 10)) + .foregroundColor(.textMuted) + } + .buttonStyle(.plain) + .help("Reset to default order") + .transition(.opacity) + } + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 4) + } +``` + +Also remove the old `sectionHeader` overload that only takes a `String`, and update the "RECENTLY CLOSED" call site (around line 166) to use a plain version: + +```swift + // Replace the old sectionHeader("RECENTLY CLOSED") call with inline styling: + if !appState.recentlyClosedPRs.isEmpty { + Text("RECENTLY CLOSED") + .font(.sectionLabel) + .tracking(1) + .foregroundColor(.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 4) + ForEach(appState.recentlyClosedPRs) { pr in + PRRowView(pr: pr).opacity(0.5) + themedDivider() + } + } +``` + +- [ ] **Step 5: Run `mise run build` to verify it compiles** + +Expected: Build succeeds. + +- [ ] **Step 6: Run `mise run test` to verify nothing is broken** + +Expected: All tests pass. + +- [ ] **Step 7: Run a manual drag-and-drop smoke test** + +Run: `mise run start` + +Expected: +- Dragging within a section shows the insertion indicator only in that same section. +- Dropping below the last row in a section moves the PR to the end of that section. +- Dragging over a different triage section shows no active drop indicator and does not reorder. +- Cancelling a drag or leaving valid drop targets clears all transient reorder UI. +- If optional source-row dimming is attempted and cannot be cleared reliably, remove it before merging. + +- [ ] **Step 8: Commit** + +```bash +git add GitHubCommandCenter/Views/PRReorderDropDelegate.swift GitHubCommandCenter/Views/PRListView.swift +git commit -m "feat: add drag-and-drop PR reordering with drop indicator and section reset" +``` + +--- + +### Task 6: PRRowView — Drag Handle on Hover + +**Files:** +- Modify: `GitHubCommandCenter/Views/PRRowView.swift` + +- [ ] **Step 1: Add a drag handle grip that appears on hover** + +In `GitHubCommandCenter/Views/PRRowView.swift`, add a grip icon to the left of the row content. Modify the `body` property. + +Replace the outer `HStack` (the current body content, lines 13–85): + +```swift + var body: some View { + HStack(spacing: Spacing.xs) { + Image(systemName: "line.3.horizontal") + .font(.system(size: 9)) + .foregroundColor(.textMuted) + .opacity(isHovered ? 0.7 : 0) + .frame(width: 10) + .animation(.easeInOut(duration: 0.15), value: isHovered) + + VStack(alignment: .leading, spacing: Spacing.hairline * 2) { + HStack(spacing: Spacing.xs) { + Button { + openPullRequest() + } label: { + Text("#\(pr.number)") + .font(.prNumber) + .foregroundColor(.linkBlue) + .underline() + } + .buttonStyle(.plain) + + if !pr.displayRole.isEmpty { + Text(pr.displayRole) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.textMuted) + .padding(.horizontal, Spacing.xxs) + .padding(.vertical, 1) + .background(Color.panelSurface) + .cornerRadius(Spacing.xxxs) + } + + Spacer() + + if pr.draftStatus == .draft { + Text("DRAFT") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.textMuted) + .padding(.horizontal, Spacing.xxxs) + .padding(.vertical, 1) + .overlay( + RoundedRectangle(cornerRadius: Spacing.xxxs) + .stroke(Color.textMuted.opacity(0.5), lineWidth: 0.5) + ) + } + } + + Text(pr.title) + .font(.prTitle) + .foregroundColor(.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: Spacing.xs) { + Text(pr.repoFullName) + .font(.prRepo) + .foregroundColor(.textMuted) + + Spacer() + + HStack(spacing: 5) { + StatusDotView(dimension: .ci, pr: pr) + StatusDotView(dimension: .review, pr: pr) + StatusDotView(dimension: .merge, pr: pr) + } + } + } + } + .padding(.leading, Spacing.lg) + .padding(.trailing, Spacing.xl) + .padding(.vertical, Spacing.sm) + .background(isHovered ? Color.panelSurface : Color.clear) + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { isHovered = hovering } + } + .accessibilityLabel(PRRowPresentation.accessibilityLabel(for: pr)) + .alert("Unable to Open Pull Request", isPresented: $isShowingOpenError) { + Button("OK", role: .cancel) {} + } message: { + Text(openErrorMessage) + } + } +``` + +Key changes: +- Added `Image(systemName: "line.3.horizontal")` grip icon that fades in on hover +- Changed `.padding(.horizontal, Spacing.xl)` to `.padding(.leading, Spacing.lg)` + `.padding(.trailing, Spacing.xl)` so the grip aligns closer to the left edge + +- [ ] **Step 2: Run `mise run build` to verify it compiles** + +Expected: Build succeeds. + +- [ ] **Step 3: Run `mise run test` to verify nothing is broken** + +Expected: All tests pass. (Existing `PRRowPresentationTests` should still pass since we only changed layout, not presentation logic.) + +- [ ] **Step 4: Commit** + +```bash +git add GitHubCommandCenter/Views/PRRowView.swift +git commit -m "feat: add drag handle grip icon that appears on PR row hover" +``` + +--- + +### Task 7: Style Check and Final Verification + +**Files:** All modified files + +- [ ] **Step 1: Run style checks** + +Run: `mise run check` +Expected: No style violations. + +- [ ] **Step 2: Run full verification** + +Run: `mise run verify` +Expected: Auto-fixes, style checks, and tests all pass. + +- [ ] **Step 3: Run persistence and polling smoke checks** + +Run: `mise run start` + +Expected: +- Reordering a PR within a populated triage section persists after closing and reopening the app. +- Reordering a newly appeared PR still works when a partial custom order already exists for that section. +- Triggering a refresh keeps the custom order for existing PRs while appending new PRs in default order. + +- [ ] **Step 4: Fix any issues found** + +If style or test issues arise, fix them and re-run `mise run verify`. + +- [ ] **Step 5: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: address style check issues in PR reorder implementation" +``` diff --git a/docs/version-plan.md b/docs/version-plan.md new file mode 100644 index 0000000..e3a2463 --- /dev/null +++ b/docs/version-plan.md @@ -0,0 +1,99 @@ +# Version Plan + +## MVP (v0.1) — Core PR Triage ✅ (in progress) + +**Goal:** Functional menu bar app that shows PR status at a glance. + +### Features shipped +- Menu bar icon showing number of PRs needing attention +- Dropdown with PRs split into three triage sections: Needs Your Action / Waiting on Others / Your Draft PRs +- Per-PR indicators: review state, CI status, merge status +- Click PR number → opens GitHub URL in browser +- Loading state on app start +- Settings: GitHub token entry/removal, tracked repos (collapsible), Launch at Login, Quit +- CI failure details in info popup +- Instant tooltip display on indicator click + +### Remaining MVP items +- [ ] Notifications (including sound) when a tracked PR changes status — *manual testing needed* +- [ ] Manual QA pass: token add/remove UX flow +- [ ] Manual QA pass: efficiency / API call count +- [ ] Clarify "Your PR" vs "Assigned" label logic + +--- + +## v0.2 — Bug Fixes + Polish + +**Goal:** Squash known bugs and add small UX improvements before building larger features. + +### Bugs +- [ ] Draft PR CI indicator always shows "Checking mergeability" — should show actual CI status +- [ ] After owner re-requests review following "Changes requested", indicator should flip to orange (review requested), not stay red (changes requested) +- [ ] Draft PR can incorrectly show "Checks pending" when all checks have passed +- [ ] Draft PRs you didn't create and aren't assigned to should not appear in the app +- [ ] Failing CI checks info popup can show repeated entries + +### UX improvements +- [ ] Sticky triage category headers: "NEEDS YOUR ACTION" / "WAITING ON OTHERS" / "YOUR DRAFT PRs" stay pinned as you scroll through their section (sticky-header style, each pushes the previous one up) +- [ ] CI checks info popup: show per-check status icon (green tick = passed, amber circle = running, red x = failed) + +### Efficiency +- [ ] Skip polling draft PRs (or poll at a much lower frequency) + +### UX / window +- [ ] **Resizable dropdown** — drag the bottom edge to resize the dropdown height; size persists across opens and app restarts + +### Dev +- [ ] GitHub Actions CI: lint + style checks on push/PR + +--- + +## v0.3 — Repo Management + +**Goal:** Give users control over which repos are tracked and what's visible. + +- [ ] **Subscribe / unsubscribe from repos** — user can add or remove repos from tracking entirely; unsubscribed repos are not fetched at all (UI: manage per-repo in Settings) +- [ ] **Filter repos** — dropdown filter in the main view so the user can select one or more repos and see only PRs from those repos + +--- + +## v1.1 — Smarter Triage + +**Goal:** Improve within-section PR ordering based on real-world usage data. + +- [ ] **Urgency scoring** — replace `updated_at` sort with weighted urgency score. Gather data from 1–2 weeks of real use to determine weights (age, review state, CI state, etc.). Track which orderings feel wrong during personal use first. +- [ ] **Team review request support** — handle `requested_teams` from the GitHub API so CODEOWNERS/reviewer-team requests surface correctly as "waiting for review" or "review requested from me" instead of disappearing +- [ ] **Set / reorder PR priorities** — mark a PR as high priority to pin it to the top of its triage section; allow manual drag-to-reorder within sections + +--- + +## v2 — Notifications + Collaboration + +**Goal:** Rich notifications and light social/collaboration features. + +### Architecture +- [ ] **Notification-driven hybrid polling** — replace pure polling with `/notifications` polling + targeted fetches for changed PRs. Dramatically reduces API calls for 50+ active PRs. Requires real-world API call data from v0.1/v1.x first. + +### Notifications +- [ ] Configurable notification triggers — choose which status changes fire a notification (all PRs / assigned only / relevant only) +- [ ] Notify when tagged in a PR comment +- [ ] Notify when tagged in a PR review comment +- [ ] Unsubscribe from notifications for a specific PR +- [ ] Subscribe to notifications for a specific PR +- [ ] Notifications for GitHub issues (tagged, assigned, activity on assigned issue) + +### Token management +- [ ] Guided token setup — walk user through selecting the right fine-grained token scopes during onboarding +- [ ] Token expiry reminder — user sets expiry date, app reminds them before it lapses +- [ ] Handle token scope conflicts when settings change + +### Collaboration features +- [ ] Post review reminder comment — tag reviewers + custom message directly from the app +- [ ] Request a review from someone via the app +- [ ] Escalate a PR — post an `@user ESCALATE` comment; app surfaces escalated PRs prominently +- [ ] Merge mergeable PRs from within the app + +### Quality of life +- [ ] Set working hours — suppress notifications outside working hours +- [ ] Catch-up view — summary of what happened while you were away +- [ ] Open worktree folder button per PR diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..38a8230 --- /dev/null +++ b/hk.pkl @@ -0,0 +1,27 @@ +amends "package://github.com/jdx/hk/releases/download/v1.39.0/hk@1.39.0#/Config.pkl" +import "package://github.com/jdx/hk/releases/download/v1.39.0/hk@1.39.0#/Builtins.pkl" + +local linters = new Mapping { + ["swift-style"] { + glob = "**/*.swift" + check = "mise run check" + fix = "mise run fix && mise run check" + } + ["trailing-whitespace"] = Builtins.trailing_whitespace + ["check-merge-conflict"] = Builtins.check_merge_conflict +} + +hooks { + ["pre-commit"] { + fix = true + stash = "git" + steps = linters + } + ["fix"] { + fix = true + steps = linters + } + ["check"] { + steps = linters + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..7516ec2 --- /dev/null +++ b/mise.toml @@ -0,0 +1,103 @@ +[tools] +swiftlint = "0.63.2" +xcodegen = "2.45.3" +hk = "1.39.0" +pkl = "0.31.1" # required by hk via hk.pkl + +[tasks.sync] +description = "Install all project dependencies" +run = ["mise install", "rm -rf GitHubCommandCenter.xcodeproj", "xcodegen generate"] + +[tasks.open] +description = "Open project in Xcode" +depends = ["sync"] +run = "open GitHubCommandCenter.xcodeproj" + +[tasks.build] +description = "Build with xcodebuild" +depends = ["sync"] +run = """ +xcodebuild \ + -project GitHubCommandCenter.xcodeproj \ + -scheme GitHubCommandCenter \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build +""" + +[tasks.build-release] +description = "Build release app bundle for packaging" +depends = ["sync"] +run = """ +xcodebuild \ + -project GitHubCommandCenter.xcodeproj \ + -scheme GitHubCommandCenter \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build +""" + +[tasks.test] +description = "Run Swift tests with xcodebuild" +depends = ["sync"] +run = """ +xcodebuild \ + -project GitHubCommandCenter.xcodeproj \ + -scheme GitHubCommandCenter \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + test +""" + +[tasks.check] +description = "Check Swift formatting + lint" +depends = ["sync"] +run = [ + "swift format lint --strict --recursive GitHubCommandCenter GitHubCommandCenterTests", + "swiftlint lint --strict", +] + +# `fix` intentionally only syncs and applies auto-fixes; it does not run tests or full checks. +[tasks.fix] +description = "Fix all formatting + lint issues" +depends = ["sync"] +run = [ + "swift format --in-place --recursive GitHubCommandCenter GitHubCommandCenterTests", + "swiftlint lint --fix", +] + +# `verify` intentionally mutates via `fix` before re-checking style and running tests. +[tasks.verify] +description = "Auto-fix style issues, then run style checks and tests" +run = ["mise run fix", "mise run check", "mise run test"] + +[tasks.start] +description = "Build and launch the app" +depends = ["sync"] +run = """ +xcodebuild \ + -project GitHubCommandCenter.xcodeproj \ + -scheme GitHubCommandCenter \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + build && \ +open build/DerivedData/Build/Products/Debug/GitHubCommandCenter.app +""" + +[tasks."hooks:install"] +description = "Install git pre-commit hooks via hk" +run = "hk install --mise" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..72c4628 --- /dev/null +++ b/project.yml @@ -0,0 +1,46 @@ +name: GitHubCommandCenter +options: + bundleIdPrefix: com.awjdean + xcodeVersion: "26.3" + createIntermediateGroups: true + +targets: + GitHubCommandCenter: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: GitHubCommandCenter + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.awjdean.GitHubCommandCenter + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_FILE: GitHubCommandCenter/Info.plist + CODE_SIGN_ENTITLEMENTS: GitHubCommandCenter/GitHubCommandCenter.entitlements + SWIFT_VERSION: "5.9" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Automatic + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + # Repo root for `.env.local` / `.env` bootstrap; only path is embedded (not the token). + DEV_ENV_SEARCH_ROOT: $(SRCROOT) + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" + DEV_ENV_SEARCH_ROOT: "" + + GitHubCommandCenterTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: GitHubCommandCenterTests + dependencies: + - target: GitHubCommandCenter + settings: + base: + BUNDLE_LOADER: "$(TEST_HOST)" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/GitHubCommandCenter.app/Contents/MacOS/GitHubCommandCenter" + SWIFT_VERSION: "5.9" + GENERATE_INFOPLIST_FILE: YES