diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c180eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test and build + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Unit tests (SwiftPM) + run: swift test + + - name: Build app (Xcode, Release) + run: | + xcodebuild -project PRMonitor.xcodeproj -scheme PRMonitor -configuration Release \ + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b2eecb2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release + +# Push a tag like v1.0.0 to build, sign, notarize, and publish a GitHub release. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Build number is the commit count. + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Test + run: swift test + + - name: Import Developer ID certificate + env: + P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + run: | + KEYCHAIN="$RUNNER_TEMP/release.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/cert.p12" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm "$RUNNER_TEMP/cert.p12" + + - name: Write App Store Connect API key + env: + ASC_KEY_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + run: echo "$ASC_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" + + - name: Build, sign, and notarize + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: scripts/release.sh "${GITHUB_REF_NAME}" + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/PRMonitor.dmg dist/PRMonitor.zip dist/SHA256SUMS \ + --title "PR Monitor ${GITHUB_REF_NAME#v}" --generate-notes --verify-tag + + - name: Clean up signing material + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/release.keychain-db" || true + rm -f "$RUNNER_TEMP/AuthKey.p8" diff --git a/.gitignore b/.gitignore index 3cbea1b..2fb4027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,22 @@ -.firecrawl -.swiftpm -.DS_Store -DerivedData -.firecrawl/ +# macOS .DS_Store + +# Xcode / SwiftPM build products +build/ +.build/ +DerivedData/ +*.xcuserstate +xcuserdata/ PRMonitor.xcodeproj/project.xcworkspace/ -PRMonitor.xcodeproj/xcuserdata/ +.swiftpm/ + +# Release artifacts (attach to GitHub Releases instead) +*.app.zip +*.dmg + +# Local tooling +.firecrawl/ +.superset/ + +# Release output from scripts/release.sh +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6cd07e..3a90d12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,34 +1,45 @@ # Contributing -Thanks for your interest in PR Monitor. Contributions are welcome. +Thanks for your interest in PR Monitor. Issues and pull requests are welcome. -## Getting Started +## Getting started -1. Clone the repo. -2. Open `PRMonitor.xcodeproj` in Xcode. -3. Run the `PRMonitor` target. +1. Clone the repo and open `PRMonitor.xcodeproj` in Xcode 26 or later. +2. Run the **PRMonitor** scheme (⌘R). The app appears in the menu bar. +3. Run the tests with ⌘U, or `swift test` from the command line. -## Development Notes +Debug builds use their own keychain item, so they never touch the credentials of an installed release build. The quickest way to sign in while developing is **Continue with GitHub CLI**. -- The app uses GitHub Device Flow for authentication. -- Tokens are stored in the macOS Keychain. -- Polling frequency is configurable in Settings. +> **Tip:** if a command-line build fails at code signing with "resource fork, Finder information, or similar detritus not allowed", the checkout is inside a folder that adds extended attributes (such as an iCloud-synced `~/Documents`). Pass `-derivedDataPath` (Xcode) or `--scratch-path` (SwiftPM) pointing outside it. -## Code Style +## Where things live -- Prefer SwiftUI best practices and native macOS patterns. -- Avoid introducing new dependencies unless there is a clear benefit. -- Keep background work off the main thread; UI updates should be on the main actor. +| Folder | Contents | +| --- | --- | +| `Sources/PRMonitor/Core` | Pure domain logic: status engine, notification planner, polling policy. No I/O. | +| `Sources/PRMonitor/GitHub` | GraphQL client, queries, the two-phase fetcher, and auth (device flow, `gh`). | +| `Sources/PRMonitor/Services` | `@MainActor` models and system integration. | +| `Sources/PRMonitor/UI` | SwiftUI views and design tokens (`DesignSystem.swift`). | +| `Tests/PRMonitorTests` | Swift Testing suites and fixtures. | -## Testing +Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) before larger changes. -There is no automated test suite yet. If you add one, please include clear run instructions. +## Guidelines -## Submitting Changes +- **Status rules belong in `StatusEngine`**, with a test. If a PR is reported wrong, first reproduce it with `PRMonitor --diagnose owner/repo` (Debug builds), then write a failing test using the builders in `Fixtures.swift`. +- **Keep Core pure.** No networking or UI in Core, and time-dependent functions take `now` as a parameter so tests control the clock. +- **Measure GraphQL cost** when changing queries. Detail fields are paid for on every changed PR. +- **Follow the platform.** Use standard controls, macOS text styles and the tokens in `DesignSystem.swift`. Don't put glass on glass, and don't animate hover. +- **No new dependencies** without a strong reason. +- Refresh screenshots with the Debug-only tools in `Sources/PRMonitor/App/`. They always use fictional preview data: + - `PRMonitor --showcase hero-light` (also `hero-dark`, `settings-light`, `settings-dark`) puts the real UI, on real Liquid Glass, over a designed backdrop and prints the rectangle to capture with `screencapture -x -R out.png`. These are the README images. + - `PRMonitor --snapshot ` renders the panel, Settings panes and every menu bar icon state offscreen, for quick layout checks. + - `PRMonitor --diagnose owner/repo` checks status derivation against live repositories, using your `gh` token. -1. Create a branch from `main`. -2. Make your changes and verify the app runs. -3. Open a pull request with a clear description of the change. +## Submitting changes -If your change is significant, please open an issue first to discuss the approach. +1. Branch from `main`. +2. Make sure `swift test` passes and the app builds. +3. Open a pull request describing the change and how you verified it. Include before and after screenshots for UI changes. +For significant changes, please open an issue first to discuss the approach. diff --git a/PRMonitor.xcodeproj/project.pbxproj b/PRMonitor.xcodeproj/project.pbxproj index 44c130b..770dddd 100644 --- a/PRMonitor.xcodeproj/project.pbxproj +++ b/PRMonitor.xcodeproj/project.pbxproj @@ -1,249 +1,378 @@ // !$*UTF8*$! { archiveVersion = 1; - classes = {}; - objectVersion = 56; + classes = { + }; + objectVersion = 77; objects = { -/* Begin PBXBuildFile section */ - E78001FF98C94430914E3452 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644B97B8997741949754A68A /* AppState.swift */; }; - 15959AE2390540B99403139D /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E57E6F658EA45F4B979C3D9 /* AppContainer.swift */; }; - EDE587C69327413FBA9D38CF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24AA8850953C48EDAAD41B17 /* AppDelegate.swift */; }; - C81F744096B1432F83862A30 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = E41321BCAFB241DFB313FFB7 /* Models.swift */; }; - 13C7ECCDEF914D8DBF807163 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 413A2C3EDF1546099B4C72A2 /* AuthStore.swift */; }; - EBCC62C2C5B34586A521C976 /* PRMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD45E48815F141A6B513D4AB /* PRMonitorApp.swift */; }; - F00A6865B87A40E5B85807DC /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AC1C54FD8F744D7BC7724C4 /* SettingsStore.swift */; }; - A2F9F4DDB63541AB8ED0D49F /* GitHubAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8E907DD122457981820397 /* GitHubAuthService.swift */; }; - 7908E85144FF431C91A2C19D /* GitHubClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC78B421D3F245CD8C9084F2 /* GitHubClient.swift */; }; - 37C9B0CA60AC4DA2ABBEE840 /* PollingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B6023855E984BBB8E37A6B2 /* PollingService.swift */; }; - 26E46E9EFB13490389EE6EF0 /* AgentDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC9AFC7C7C074F23A5140BDC /* AgentDiscoveryService.swift */; }; - 1EFB51EBC5C14510AC627101 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958903B9354348848EBA9948 /* ContentView.swift */; }; - 2B2CF529A7BA41E5872AA6CA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6D4B45EC54442DB0A2651A /* SettingsView.swift */; }; - F955FE0C5D654AEAACD52716 /* StatusBarLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC0E16936A04FB38789F708 /* StatusBarLabel.swift */; }; - A59AA7B44EF04C259F3206C7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E35C5AFA7A4856A6D34417 /* Keychain.swift */; }; - 04EF92D31DD2400498021472 /* NotificationAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = E175404F860C44A2A03CEBD1 /* NotificationAvailability.swift */; }; - 21412E8064C44708B0B2572B /* AsyncSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAACAA854D574B38AA46BA8B /* AsyncSemaphore.swift */; }; - 18B26F6E773140D48D1FA579 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9B38857DE80F466587191E51 /* Assets.xcassets */; }; -/* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A1000000000000000000C001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A1000000000000000000F001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A1000000000000000000T001; + remoteInfo = PRMonitor; + }; +/* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 644B97B8997741949754A68A /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/AppState.swift; sourceTree = ""; }; - 9E57E6F658EA45F4B979C3D9 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/AppContainer.swift; sourceTree = ""; }; - 24AA8850953C48EDAAD41B17 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/AppDelegate.swift; sourceTree = ""; }; - E41321BCAFB241DFB313FFB7 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/Models.swift; sourceTree = ""; }; - 413A2C3EDF1546099B4C72A2 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/AuthStore.swift; sourceTree = ""; }; - BD45E48815F141A6B513D4AB /* PRMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/PRMonitorApp.swift; sourceTree = ""; }; - 5AC1C54FD8F744D7BC7724C4 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRMonitor/SettingsStore.swift; sourceTree = ""; }; - 4B8E907DD122457981820397 /* GitHubAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubAuthService.swift; sourceTree = ""; }; - BC78B421D3F245CD8C9084F2 /* GitHubClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubClient.swift; sourceTree = ""; }; - 9B6023855E984BBB8E37A6B2 /* PollingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollingService.swift; sourceTree = ""; }; - CC9AFC7C7C074F23A5140BDC /* AgentDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentDiscoveryService.swift; sourceTree = ""; }; - 958903B9354348848EBA9948 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - CE6D4B45EC54442DB0A2651A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 5FC0E16936A04FB38789F708 /* StatusBarLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarLabel.swift; sourceTree = ""; }; - 25E35C5AFA7A4856A6D34417 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; - E175404F860C44A2A03CEBD1 /* NotificationAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAvailability.swift; sourceTree = ""; }; - FAACAA854D574B38AA46BA8B /* AsyncSemaphore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSemaphore.swift; sourceTree = ""; }; - BE82754E576844E0A75D679A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9B38857DE80F466587191E51 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - F26C8741A0154CC9B8207228 /* PRMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = PRMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000000000000000000P001 /* PRMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PRMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1000000000000000000P002 /* PRMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PRMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A1000000000000000000E001 /* Exceptions for "PRMonitorApp" folder in "PRMonitor" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A1000000000000000000T001 /* PRMonitor */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A1000000000000000000S001 /* Sources/PRMonitor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Sources/PRMonitor; + sourceTree = ""; + }; + A1000000000000000000S002 /* PRMonitorApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A1000000000000000000E001 /* Exceptions for "PRMonitorApp" folder in "PRMonitor" target */, + ); + path = PRMonitorApp; + sourceTree = ""; + }; + A1000000000000000000S003 /* Tests/PRMonitorTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tests/PRMonitorTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ - E4BDE8E1FE6C42328C5F474D /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; + A1000000000000000000B001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1000000000000000000B011 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 2224E7031C304D109D5C94C2 = {isa = PBXGroup; children = ( - B6C4D3D82B3B4CB09464BC29 /* Sources */, - B1E89D8E49C549CBB940A2EF /* Supporting Files */, - 61B302BF2D9B4FEB9FF5612D /* Products */, - ); sourceTree = ""; }; - B6C4D3D82B3B4CB09464BC29 /* Sources */ = {isa = PBXGroup; children = ( - 644B97B8997741949754A68A /* AppState.swift */, - 9E57E6F658EA45F4B979C3D9 /* AppContainer.swift */, - 24AA8850953C48EDAAD41B17 /* AppDelegate.swift */, - E41321BCAFB241DFB313FFB7 /* Models.swift */, - 413A2C3EDF1546099B4C72A2 /* AuthStore.swift */, - BD45E48815F141A6B513D4AB /* PRMonitorApp.swift */, - 5AC1C54FD8F744D7BC7724C4 /* SettingsStore.swift */, - 17846063536D41B3A76A70B6 /* Services */, - D37FB8FDF77444F08B535242 /* Views */, - 3539254D397E4BD08F055DB0 /* Utils */, - ); path = Sources; sourceTree = ""; }; - 17846063536D41B3A76A70B6 /* Services */ = {isa = PBXGroup; children = ( - 4B8E907DD122457981820397 /* GitHubAuthService.swift */, - BC78B421D3F245CD8C9084F2 /* GitHubClient.swift */, - 9B6023855E984BBB8E37A6B2 /* PollingService.swift */, - CC9AFC7C7C074F23A5140BDC /* AgentDiscoveryService.swift */, - ); path = PRMonitor/Services; sourceTree = ""; }; - D37FB8FDF77444F08B535242 /* Views */ = {isa = PBXGroup; children = ( - 958903B9354348848EBA9948 /* ContentView.swift */, - CE6D4B45EC54442DB0A2651A /* SettingsView.swift */, - 5FC0E16936A04FB38789F708 /* StatusBarLabel.swift */, - ); path = PRMonitor/Views; sourceTree = ""; }; - 3539254D397E4BD08F055DB0 /* Utils */ = {isa = PBXGroup; children = ( - 25E35C5AFA7A4856A6D34417 /* Keychain.swift */, - E175404F860C44A2A03CEBD1 /* NotificationAvailability.swift */, - FAACAA854D574B38AA46BA8B /* AsyncSemaphore.swift */, - ); path = PRMonitor/Utils; sourceTree = ""; }; - B1E89D8E49C549CBB940A2EF /* Supporting Files */ = {isa = PBXGroup; children = ( - BE82754E576844E0A75D679A /* Info.plist */, - 9B38857DE80F466587191E51 /* Assets.xcassets */, - ); path = PRMonitorApp; sourceTree = ""; }; - 61B302BF2D9B4FEB9FF5612D /* Products */ = {isa = PBXGroup; children = ( - F26C8741A0154CC9B8207228 /* PRMonitor.app */, - ); name = Products; sourceTree = ""; }; + A1000000000000000000G001 = { + isa = PBXGroup; + children = ( + A1000000000000000000S001 /* Sources/PRMonitor */, + A1000000000000000000S002 /* PRMonitorApp */, + A1000000000000000000S003 /* Tests/PRMonitorTests */, + A1000000000000000000G002 /* Products */, + ); + sourceTree = ""; + }; + A1000000000000000000G002 /* Products */ = { + isa = PBXGroup; + children = ( + A1000000000000000000P001 /* PRMonitor.app */, + A1000000000000000000P002 /* PRMonitorTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 6442782EE46E4562B845764C /* PRMonitor */ = { + A1000000000000000000T001 /* PRMonitor */ = { isa = PBXNativeTarget; - buildConfigurationList = 9A86AFB97DAE466FA0011B01 /* Build configuration list for PBXNativeTarget "PRMonitor" */; + buildConfigurationList = A1000000000000000000L002 /* Build configuration list for PBXNativeTarget "PRMonitor" */; buildPhases = ( - C1E3CDF3C7594BDFBA83AFA5 /* Sources */, - E4BDE8E1FE6C42328C5F474D /* Frameworks */, - DEBA56F4DFE0448DA21C1C74 /* Resources */, + A1000000000000000000B002 /* Sources */, + A1000000000000000000B001 /* Frameworks */, + A1000000000000000000B003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A1000000000000000000S001 /* Sources/PRMonitor */, + A1000000000000000000S002 /* PRMonitorApp */, ); - buildRules = ( ); - dependencies = ( ); name = PRMonitor; productName = PRMonitor; - productReference = F26C8741A0154CC9B8207228 /* PRMonitor.app */; + productReference = A1000000000000000000P001 /* PRMonitor.app */; productType = "com.apple.product-type.application"; }; + A1000000000000000000T002 /* PRMonitorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1000000000000000000L003 /* Build configuration list for PBXNativeTarget "PRMonitorTests" */; + buildPhases = ( + A1000000000000000000B012 /* Sources */, + A1000000000000000000B011 /* Frameworks */, + A1000000000000000000B013 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A1000000000000000000D001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A1000000000000000000S003 /* Tests/PRMonitorTests */, + ); + name = PRMonitorTests; + productName = PRMonitorTests; + productReference = A1000000000000000000P002 /* PRMonitorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - B159222AAE91467BB5C40600 /* Project object */ = { + A1000000000000000000F001 /* Project object */ = { isa = PBXProject; - buildConfigurationList = 0F5F1E2EB3EA4FFFB0A495EA /* Build configuration list for PBXProject "PRMonitor" */; - compatibilityVersion = "Xcode 13.0"; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + A1000000000000000000T001 = { + CreatedOnToolsVersion = 27.0; + }; + A1000000000000000000T002 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = A1000000000000000000T001; + }; + }; + }; + buildConfigurationList = A1000000000000000000L001 /* Build configuration list for PBXProject "PRMonitor" */; developmentRegion = en; hasScannedForEncodings = 0; - mainGroup = 2224E7031C304D109D5C94C2; - productRefGroup = 61B302BF2D9B4FEB9FF5612D; + knownRegions = ( + en, + Base, + ); + mainGroup = A1000000000000000000G001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A1000000000000000000G002 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 6442782EE46E4562B845764C /* PRMonitor */, + A1000000000000000000T001 /* PRMonitor */, + A1000000000000000000T002 /* PRMonitorTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - DEBA56F4DFE0448DA21C1C74 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 18B26F6E773140D48D1FA579 /* Assets.xcassets in Resources */, - ); runOnlyForDeploymentPostprocessing = 0; }; + A1000000000000000000B003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1000000000000000000B013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - C1E3CDF3C7594BDFBA83AFA5 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E78001FF98C94430914E3452 /* AppState.swift in Sources */, - 15959AE2390540B99403139D /* AppContainer.swift in Sources */, - EDE587C69327413FBA9D38CF /* AppDelegate.swift in Sources */, - C81F744096B1432F83862A30 /* Models.swift in Sources */, - 13C7ECCDEF914D8DBF807163 /* AuthStore.swift in Sources */, - EBCC62C2C5B34586A521C976 /* PRMonitorApp.swift in Sources */, - F00A6865B87A40E5B85807DC /* SettingsStore.swift in Sources */, - A2F9F4DDB63541AB8ED0D49F /* GitHubAuthService.swift in Sources */, - 7908E85144FF431C91A2C19D /* GitHubClient.swift in Sources */, - 37C9B0CA60AC4DA2ABBEE840 /* PollingService.swift in Sources */, - 26E46E9EFB13490389EE6EF0 /* AgentDiscoveryService.swift in Sources */, - 1EFB51EBC5C14510AC627101 /* ContentView.swift in Sources */, - 2B2CF529A7BA41E5872AA6CA /* SettingsView.swift in Sources */, - F955FE0C5D654AEAACD52716 /* StatusBarLabel.swift in Sources */, - A59AA7B44EF04C259F3206C7 /* Keychain.swift in Sources */, - 04EF92D31DD2400498021472 /* NotificationAvailability.swift in Sources */, - 21412E8064C44708B0B2572B /* AsyncSemaphore.swift in Sources */, - ); runOnlyForDeploymentPostprocessing = 0; }; + A1000000000000000000B002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1000000000000000000B012 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A1000000000000000000D001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A1000000000000000000T001 /* PRMonitor */; + targetProxy = A1000000000000000000C001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ - 694531622C7247A0A73EC18D /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - DEVELOPMENT_TEAM = ""; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = "gnu11"; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - ONLY_ACTIVE_ARCH = YES; - SWIFT_VERSION = 5.9; - }; name = Debug; }; - B9C0175094F6499B9883ACCB /* Release */ = {isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - DEVELOPMENT_TEAM = ""; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = "gnu11"; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - SWIFT_VERSION = 5.9; - }; name = Release; }; + A1000000000000000000X001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + A1000000000000000000X002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + A1000000000000000000X003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + GITHUB_OAUTH_CLIENT_ID = ""; + INFOPLIST_FILE = PRMonitorApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor; + PRODUCT_NAME = PRMonitor; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + A1000000000000000000X004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + GITHUB_OAUTH_CLIENT_ID = ""; + INFOPLIST_FILE = PRMonitorApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor; + PRODUCT_NAME = PRMonitor; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; + A1000000000000000000X005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PRMonitor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PRMonitor"; + }; + name = Debug; + }; + A1000000000000000000X006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PRMonitor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PRMonitor"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0F5F1E2EB3EA4FFFB0A495EA /* Build configuration list for PBXProject "PRMonitor" */ = {isa = XCConfigurationList; buildConfigurations = ( - 694531622C7247A0A73EC18D /* Debug */, - B9C0175094F6499B9883ACCB /* Release */, - ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 058B0DAC786B4CEA8F9E63B9 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = PRMonitorApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor; - PRODUCT_NAME = PRMonitor; - SWIFT_VERSION = 5.9; - }; name = Debug; }; - 2AA8CDDE90EB40FDB6B4C070 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = PRMonitorApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.neat.prmonitor; - PRODUCT_NAME = PRMonitor; - SWIFT_VERSION = 5.9; - }; name = Release; }; - 9A86AFB97DAE466FA0011B01 /* Build configuration list for PBXNativeTarget "PRMonitor" */ = {isa = XCConfigurationList; buildConfigurations = ( - 058B0DAC786B4CEA8F9E63B9 /* Debug */, - 2AA8CDDE90EB40FDB6B4C070 /* Release */, - ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A1000000000000000000L001 /* Build configuration list for PBXProject "PRMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000X001 /* Debug */, + A1000000000000000000X002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000L002 /* Build configuration list for PBXNativeTarget "PRMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000X003 /* Debug */, + A1000000000000000000X004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000L003 /* Build configuration list for PBXNativeTarget "PRMonitorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000X005 /* Debug */, + A1000000000000000000X006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ - }; - rootObject = B159222AAE91467BB5C40600 /* Project object */; + rootObject = A1000000000000000000F001 /* Project object */; } diff --git a/PRMonitor.xcodeproj/xcshareddata/xcschemes/PRMonitor.xcscheme b/PRMonitor.xcodeproj/xcshareddata/xcschemes/PRMonitor.xcscheme new file mode 100644 index 0000000..436bd2e --- /dev/null +++ b/PRMonitor.xcodeproj/xcshareddata/xcschemes/PRMonitor.xcscheme @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PRMonitorApp/AppIcon.icon/Assets/AttentionDot.svg b/PRMonitorApp/AppIcon.icon/Assets/AttentionDot.svg new file mode 100644 index 0000000..de7359b --- /dev/null +++ b/PRMonitorApp/AppIcon.icon/Assets/AttentionDot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/PRMonitorApp/AppIcon.icon/Assets/Branches.svg b/PRMonitorApp/AppIcon.icon/Assets/Branches.svg new file mode 100644 index 0000000..7040c02 --- /dev/null +++ b/PRMonitorApp/AppIcon.icon/Assets/Branches.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/PRMonitorApp/AppIcon.icon/Assets/Nodes.svg b/PRMonitorApp/AppIcon.icon/Assets/Nodes.svg new file mode 100644 index 0000000..748a0ef --- /dev/null +++ b/PRMonitorApp/AppIcon.icon/Assets/Nodes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/PRMonitorApp/AppIcon.icon/icon.json b/PRMonitorApp/AppIcon.icon/icon.json new file mode 100644 index 0000000..19fbc6a --- /dev/null +++ b/PRMonitorApp/AppIcon.icon/icon.json @@ -0,0 +1,80 @@ +{ + "fill-specializations" : [ + { + "value" : { + "automatic-gradient" : "extended-srgb:0.10000,0.62000,0.36000,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "automatic-gradient" : "extended-srgb:0.03000,0.20000,0.12000,1.00000" + } + } + ], + "groups" : [ + { + "name" : "Attention dot", + "layers" : [ + { + "glass" : true, + "image-name" : "AttentionDot.svg", + "name" : "Attention dot" + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.1 + } + }, + { + "name" : "Nodes", + "layers" : [ + { + "glass" : true, + "image-name" : "Nodes.svg", + "name" : "Nodes" + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.45 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.15 + } + }, + { + "name" : "Branches", + "layers" : [ + { + "glass" : true, + "image-name" : "Branches.svg", + "name" : "Branches" + } + ], + "lighting" : "combined", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.3 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.35 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 3ba5775..0000000 --- a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images": [ - { - "idiom": "mac", - "size": "16x16", - "scale": "1x", - "filename": "icon_16.png" - }, - { - "idiom": "mac", - "size": "16x16", - "scale": "2x", - "filename": "icon_32.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "1x", - "filename": "icon_32.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "2x", - "filename": "icon_64.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "1x", - "filename": "icon_128.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "2x", - "filename": "icon_256.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "1x", - "filename": "icon_256.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "2x", - "filename": "icon_512.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "1x", - "filename": "icon_512.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "2x", - "filename": "icon_1024.png" - } - ], - "info": { - "version": 1, - "author": "xcode" - } -} \ No newline at end of file diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png deleted file mode 100644 index d746c6d..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_128.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_128.png deleted file mode 100644 index 40a1491..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_128.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_16.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_16.png deleted file mode 100644 index 432381a..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_16.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_256.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_256.png deleted file mode 100644 index 4ed5a68..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_256.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_32.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_32.png deleted file mode 100644 index 68d61b4..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_32.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_512.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_512.png deleted file mode 100644 index a649745..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_512.png and /dev/null differ diff --git a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_64.png b/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_64.png deleted file mode 100644 index 066684d..0000000 Binary files a/PRMonitorApp/Assets.xcassets/AppIcon.appiconset/icon_64.png and /dev/null differ diff --git a/PRMonitorApp/Info.plist b/PRMonitorApp/Info.plist index 9ae260f..930c3bf 100644 --- a/PRMonitorApp/Info.plist +++ b/PRMonitorApp/Info.plist @@ -2,27 +2,35 @@ - CFBundleDisplayName - PR Monitor - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - com.neat.prmonitor - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - PRMonitor - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 14.0 - LSUIElement - - NSPrincipalClass - NSApplication + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PR Monitor + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PR Monitor + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + GitHubOAuthClientID + $(GITHUB_OAUTH_CLIENT_ID) + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHumanReadableCopyright + © 2026 Hasaam. MIT License. + NSPrincipalClass + NSApplication diff --git a/Package.swift b/Package.swift index aa3e82e..d209102 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "PRMonitor", - platforms: [.macOS(.v14)], + platforms: [.macOS(.v26)], products: [ - .executable(name: "PRMonitor", targets: ["PRMonitor"]) + .executable(name: "PRMonitor", targets: ["PRMonitor"]), ], targets: [ .executableTarget( @@ -16,6 +16,7 @@ let package = Package( name: "PRMonitorTests", dependencies: ["PRMonitor"], path: "Tests/PRMonitorTests" - ) - ] + ), + ], + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index c3493ba..40d7851 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,161 @@ -# PR Monitor -Monitor multi-agent PR checks and review comments from your macOS menu bar — no tab switching. +

+ PR Monitor app icon: a layered Liquid Glass pull request mark with an amber attention dot +

-PR Monitor is built for teams using multiple PR-review agents and CI systems. It keeps track of check runs and agent comments across your repositories and tells you when everything is ready for review, so you can respond in one focused pass. +

PR Monitor

-## Why This Exists +

+ Know the moment your pull request is ready.
+ Every CI check and AI code reviewer on your pull requests, in one glance from the macOS menu bar. +

-With multiple agent reviewers running in parallel, it’s easy to get stuck context-switching: waiting on checks, checking PRs repeatedly, and tracking which agent finished. PR Monitor centralizes that status in the menu bar and notifies you when it’s time to act. +

+ Latest release + macOS 26 or later + Swift 6 + CI status + MIT license +

-## How It Works +

+ Install · + How it works · + FAQ · + Architecture +

-- Connect to GitHub via Device Flow -- Select repositories to track -- Map agents to checks and comment authors -- See live status for each PR and agent -- Get notified when reviews are ready +

+ + + + The PR Monitor panel open beneath its menu bar icon, listing pull requests with the status of each CI check and AI reviewer + +

-## Screenshots

-CleanShot 2026-02-04 at 14 43 13@2x + + + + Download for macOS + +

-## Quick Start +## Why -1. Open `PRMonitor.xcodeproj` in Xcode. -2. Run the `PRMonitor` app target. -3. Open **Settings** from the menu bar app. -4. Add tracked repositories (format: `owner/name`). -5. Configure agent mappings (check name pattern + comment author login). -6. Authenticate with GitHub via device flow. +Modern pull requests are reviewed by a crowd: CI, a preview deploy, and two or three AI reviewers that each finish on their own schedule. The usual way to keep track is to refresh tabs. -## Install (Recommended) +- **Wait once, not five times.** PR Monitor tells you when *every* agent has finished on your latest push, so you can read all the feedback in one focused pass. +- **See what needs you.** Failing checks and unresolved review threads stand out. Everything that's fine stays quiet. +- **Zero setup.** Point it at your repositories and it discovers your CI and review bots on its own. -The easiest way to install is from GitHub Releases (standard for menu bar apps): +## Highlights -1. Download the latest `PRMonitor.app.zip` from [Releases](https://github.com/BlockchainHB/PR-Monitor/releases). -2. Unzip and move `PRMonitor.app` to `/Applications`. -3. Launch it from Spotlight or `/Applications`. +- **Accurate.** Reads check runs *and* commit statuses, unresolved review threads, pending review requests, and which commit each review was on. +- **Quiet notifications.** Nothing on launch, one notification per push, and a follow-up only if new feedback arrives. Clicking one opens the PR. +- **Light on GitHub's API.** One batched GraphQL request per ten repositories. Full details load only for PRs that changed, which costs about 1 point per repository per poll. +- **Resilient.** Recovers after sleep and network changes, backs off on errors, and waits out rate limits. A repository you've lost access to never blanks the list. +- **Native.** SwiftUI and Liquid Glass, a template menu bar icon, skeleton loading, VoiceOver labels, and light and dark appearances. -If macOS Gatekeeper blocks the first launch, right-click the app → **Open** → **Open**. +## Works with -## GitHub OAuth Setup (Device Flow) +Every check and review bot is detected automatically. These are also available as one-click presets: -1. Create a GitHub OAuth App in your GitHub settings. -2. Copy the **Client ID** and paste it into Settings → Account. -3. Click **Sign In**, then enter the code at the GitHub verification page. +| Agent | Checks | Review threads | +| --- | :---: | :---: | +| GitHub Actions | ✓ | | +| Vercel · Netlify | ✓ | | +| Cursor Bugbot | ✓ | ✓ | +| CodeRabbit | ✓ | ✓ | +| Devin | ✓ | ✓ | +| Graphite · Greptile | ✓ | ✓ | +| Codex · Copilot · Gemini Code Assist · Sourcery | | ✓ | +| *Anything else that posts a check, status, or review* | ✓ | ✓ | -Scopes requested: `repo` (required for private repositories). This app currently always requests `repo`; change the scope in `Sources/PRMonitor/Services/GitHubAuthService.swift` if you want a public-only build. +## Install -## Notes +1. [Download **PRMonitor.dmg**](https://github.com/BlockchainHB/PR-Monitor/releases/latest/download/PRMonitor.dmg) and drag **PR Monitor** into Applications. +2. Open it and click the pull request icon in the menu bar. +3. Choose **Continue with GitHub CLI** if you use `gh`. Otherwise sign in with GitHub or a personal access token. +4. Pick the repositories to watch. That's it. -- Polling runs every 60 seconds while open PRs are detected, and backs off to 10 minutes when no open PRs exist. -- Notifications fire when all agents are complete, and optionally per agent if enabled. +Requires macOS 26 Tahoe or later. Releases are signed with a Developer ID and notarized by Apple. + +

+ PR Monitor settings with a sidebar: account, General, Notifications, Repositories and Agents +

+ +## How status is decided + +Each **agent** gets one state per pull request: + +| State | When | +| --- | --- | +| **Running** | A matching check is queued or in progress, the bot is still a requested reviewer, or a configured agent hasn't reported within 3 minutes of a push | +| **Failed** | A matching check failed, timed out, needs action, or failed to start, including workflows awaiting approval | +| **Needs review** | Finished, but left unresolved review threads on current code, or requested changes | +| **Passed** | Finished with nothing outstanding (skipped, neutral and cancelled checks aren't failures) | + +The pull request takes the most important of these: **Failing › Running › Needs review › Ready**. It's **settled** once nothing is running, which is when you're notified. If GitHub's own roll-up says checks are still pending but none are visible, PR Monitor trusts GitHub. + +The rules live in one pure function, [`StatusEngine`](Sources/PRMonitor/Core/StatusEngine.swift), with a unit test for each rule. + +
+Menu bar icon states +
+

Menu bar icon in each state: signed out, idle, running, needs review, ready, and with attention counts, as template and colored variants

+ +By default the icon is a template image that matches the other menu bar icons, as Apple recommends. The shape carries the state: a ring means agents are running, a dot means something needs you, and a number counts the pull requests that need you. Colored badges are available in Settings. +
## Privacy -Tokens are stored locally in the macOS Keychain. No data is sent anywhere other than GitHub’s API. +- Talks only to GitHub (`api.github.com` and `github.com`). No analytics, no telemetry, no servers of its own. +- Your token lives in your login keychain, and settings live in `UserDefaults`. +- Needs the `repo` scope to read checks on private repositories. A fine-grained token with read access to pull requests, checks and commit statuses also works. + +## FAQ + +
+I don't see the icon in the menu bar. +
+In macOS 26, open System Settings › Menu Bar and make sure PR Monitor is allowed. On a crowded menu bar, macOS may also hide icons that don't fit. +
+ +
+A repository shows a warning triangle. +
+GitHub couldn't load it: it was renamed, you lost access, or your organization restricts third-party OAuth apps. Hover the triangle for details. The GitHub CLI sign-in usually works where OAuth apps are restricted. +
+ +
+Will it use up my GitHub API rate limit? +
+No. A typical poll costs about 1 point per repository out of 5,000 per hour. PR Monitor slows down once everything has settled, and always leaves headroom for your other tools. +
+ +
+Does it support GitHub Enterprise? +
+Not yet. It talks to github.com only. +
-## Requirements +
+Why macOS 26? +
+It's built on the Liquid Glass design system and macOS 26 APIs such as `Observations`, with Swift 6 strict concurrency throughout. +
-- macOS 14+ -- Xcode 15 recommended +## Build from source -## Contributing +```bash +git clone https://github.com/BlockchainHB/PR-Monitor.git +cd PR-Monitor +open PRMonitor.xcodeproj # ⌘R to run, ⌘U to test +``` -See `CONTRIBUTING.md` for local setup and contribution guidelines. +Or run the 39 unit tests from the command line with `swift test`. Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how it's built, [docs/RELEASING.md](docs/RELEASING.md) for how releases are signed and notarized, and [CONTRIBUTING.md](CONTRIBUTING.md) to get involved. ## License -MIT — see `LICENSE`. +MIT © Hasaam Bhatti. Screenshots use fictional repositories. diff --git a/Sources/PRMonitor/App/Diagnostics.swift b/Sources/PRMonitor/App/Diagnostics.swift new file mode 100644 index 0000000..64b13e7 --- /dev/null +++ b/Sources/PRMonitor/App/Diagnostics.swift @@ -0,0 +1,67 @@ +#if DEBUG +import Foundation + +/// `PRMonitor --diagnose owner/repo [owner/repo …]` runs the real fetcher and status engine against +/// live GitHub data using the GitHub CLI's token, prints what the menu bar would show, and exits. +/// Useful for checking status derivation against your own repositories. Debug builds only. +enum Diagnostics { + static func runIfRequested() { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: "--diagnose") else { return } + let repositories = arguments[(index + 1)...].compactMap(RepositoryID.init(parsing:)) + + let done = DispatchSemaphore(value: 0) + Task.detached { + await run(repositories) + done.signal() + } + done.wait() + exit(0) + } + + private static func run(_ repositories: [RepositoryID]) async { + guard !repositories.isEmpty else { + print("usage: PRMonitor --diagnose owner/repo [owner/repo …]") + return + } + do { + let client = GitHubClient(token: try await GitHubCLI.token()) + let fetcher = PullRequestFetcher() + let clock = ContinuousClock() + + let start = clock.now + let result = try await fetcher.fetch(repositories, client: client) + let firstPoll = clock.now - start + + let secondStart = clock.now + _ = try await fetcher.fetch(repositories, client: client) + let secondPoll = clock.now - secondStart + + let engine = StatusEngine(mode: .automatic, agents: []) + print("Viewer: \(result.viewerLogin ?? "?") · rate limit remaining: \(result.rateLimit?.remaining ?? -1)") + print("First poll \(firstPoll.formatted(.units(allowed: [.milliseconds]))), cached poll \(secondPoll.formatted(.units(allowed: [.milliseconds])))\n") + + for repository in repositories { + switch result.repositories[repository] { + case let .loaded(pullRequests, totalOpen): + print("■ \(repository) (\(totalOpen) open)") + for pr in pullRequests { + let report = engine.report(for: pr) + let flags = [pr.isDraft ? "draft" : nil, pr.mergeable == .conflicting ? "conflicts" : nil].compactMap(\.self) + print(" #\(pr.number) [\(report.status.title)\(report.isSettled ? "" : ", running")] \(pr.title.prefix(60))\(flags.isEmpty ? "" : " (\(flags.joined(separator: ", ")))")") + for agent in report.agents { + print(" · \(agent.name): \(agent.state) — \(agent.summary)") + } + } + case let .failed(error): + print("■ \(repository) ✕ \(error.localizedDescription)") + case nil: + print("■ \(repository) (no result)") + } + } + } catch { + print("Diagnostics failed: \(error.localizedDescription)") + } + } +} +#endif diff --git a/Sources/PRMonitor/App/PRMonitorApp.swift b/Sources/PRMonitor/App/PRMonitorApp.swift new file mode 100644 index 0000000..b28c505 --- /dev/null +++ b/Sources/PRMonitor/App/PRMonitorApp.swift @@ -0,0 +1,72 @@ +import SwiftUI + +@main +struct PRMonitorApp: App { + @State private var settings: AppSettings + @State private var account: Account + @State private var monitor: Monitor + @State private var router = AppRouter() + + init() { + #if DEBUG + Diagnostics.runIfRequested() + Snapshots.runIfRequested() + #endif + let settings = AppSettings() + let account = Account() + let monitor = Monitor(settings: settings, account: account, notifier: Notifier()) + _settings = State(initialValue: settings) + _account = State(initialValue: account) + _monitor = State(initialValue: monitor) + monitor.start() + #if DEBUG + SettingsWindowPreview.runIfRequested() + Showcase.runIfRequested() + #endif + } + + var body: some Scene { + MenuBarExtra { + MenuBarPanel() + .environment(monitor) + .environment(settings) + .environment(account) + .environment(router) + } label: { + MenuBarLabel( + summary: monitor.summary, + count: settings.showsAttentionCount ? monitor.attentionCount : 0, + isColored: settings.usesColoredStatusIcon + ) + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView() + .environment(monitor) + .environment(settings) + .environment(account) + .environment(router) + } + .windowResizability(.contentMinSize) + } +} + +/// Lets any view send the user to a specific Settings tab. +@Observable +@MainActor +final class AppRouter { + enum SettingsTab: Hashable { + case account, general, notifications, repositories, agents + } + + var settingsTab: SettingsTab = .general + + /// Opens Settings on a given tab and brings it forward. A menu bar app has no Dock icon, so + /// without activating, the window would open behind whatever the user was doing. + func showSettings(_ tab: SettingsTab? = nil, using openSettings: OpenSettingsAction) { + if let tab { settingsTab = tab } + DockPresence.show() + openSettings() + } +} diff --git a/Sources/PRMonitor/App/PreviewData.swift b/Sources/PRMonitor/App/PreviewData.swift new file mode 100644 index 0000000..ef527df --- /dev/null +++ b/Sources/PRMonitor/App/PreviewData.swift @@ -0,0 +1,247 @@ +#if DEBUG +import AppKit +import SwiftUI + +/// Fictional pull requests for SwiftUI previews and README screenshots. Never real repository data. +enum PreviewData { + static let now = Date.now + + static let pullRequests: [PullRequest] = [ + pr(1, "acme/rocket", 482, "Add retry budget to the launch sequencer", author: "hasaam", minutesAgo: 3, + checks: [ + check("build", "GitHub Actions", "github-actions", .succeeded), + check("test", "GitHub Actions", "github-actions", .succeeded), + check("Cursor Bugbot", "Cursor", "cursor", .succeeded), + status("Vercel", "vercel", .succeeded), + ], + reviews: [review("cursor", .commented)], + threads: [thread("cursor"), thread("cursor"), thread("chatgpt-codex-connector")]), + pr(2, "acme/rocket", 479, "Migrate telemetry to structured events", author: "mei", minutesAgo: 12, + checks: [ + check("build", "GitHub Actions", "github-actions", .succeeded), + check("test", "GitHub Actions", "github-actions", .pending), + check("Cursor Bugbot", "Cursor", "cursor", .pending), + ], + requested: [ReviewRequestSignal(login: "copilot-pull-request-reviewer", isBot: true)]), + pr(3, "acme/rocket", 471, "Fix race in fuel gauge updates", author: "hasaam", minutesAgo: 45, + checks: [ + check("build", "GitHub Actions", "github-actions", .failed, raw: "FAILURE"), + check("lint", "GitHub Actions", "github-actions", .succeeded), + status("Vercel", "vercel", .succeeded), + ]), + pr(4, "acme/mission-control", 118, "Dark mode for the flight dashboard", author: "jordan", minutesAgo: 90, + checks: [ + check("CI", "GitHub Actions", "github-actions", .succeeded), + status("Vercel", "vercel", .succeeded), + check("Devin Review", "Devin", "devin-ai-integration", .succeeded), + ], + reviews: [review("devin-ai-integration", .approved)], decision: .approved), + pr(5, "acme/mission-control", 116, "Prototype orbit planner", author: "sam", minutesAgo: 60 * 26, + isDraft: true, mergeable: .conflicting, + checks: [check("CI", "GitHub Actions", "github-actions", .succeeded)]), + ] + + @MainActor + static func environment() -> (AppSettings, Account, Monitor, AppRouter) { + let settings = AppSettings(defaults: UserDefaults(suiteName: "PRMonitor.preview")!) + settings.repositories = [ + TrackedRepository(id: RepositoryID(owner: "acme", name: "rocket")), + TrackedRepository(id: RepositoryID(owner: "acme", name: "mission-control")), + ] + settings.scope = .all + settings.hidesDrafts = false + let account = Account(previewLogin: "hasaam") + let monitor = Monitor(settings: settings, account: account, notifier: Notifier()) + monitor.loadPreview(pullRequests) + return (settings, account, monitor, AppRouter()) + } + + // MARK: - Builders + + private static func pr( + _ id: Int, _ repo: String, _ number: Int, _ title: String, author: String, minutesAgo: Double, + isDraft: Bool = false, mergeable: Mergeable = .mergeable, checks: [CheckSignal] = [], + requested: [ReviewRequestSignal] = [], reviews: [ReviewSignal] = [], threads: [ThreadSignal] = [], + decision: ReviewDecision? = nil + ) -> PullRequest { + let repository = RepositoryID(parsing: repo)! + return PullRequest( + id: "PREVIEW_\(id)", repository: repository, number: number, title: title, + url: URL(string: "https://github.com/\(repo)/pull/\(number)")!, author: author, isDraft: isDraft, + createdAt: now.addingTimeInterval(-86_400), updatedAt: now.addingTimeInterval(-minutesAgo * 60), + headSHA: "head\(id)", headCommittedAt: now.addingTimeInterval(-max(minutesAgo, 20) * 60), rollupState: nil, + reviewDecision: decision, mergeable: mergeable, checks: checks, requestedReviewers: requested, + reviews: reviews, threads: threads, comments: [] + ) + } + + private static func check(_ name: String, _ app: String, _ slug: String, _ outcome: CheckSignal.Outcome, raw: String? = nil) -> CheckSignal { + CheckSignal(name: name, source: .checkRun(appName: app, appSlug: slug), outcome: outcome, + rawState: raw ?? (outcome == .pending ? "IN_PROGRESS" : "SUCCESS"), + startedAt: now.addingTimeInterval(-600), completedAt: outcome == .pending ? nil : now.addingTimeInterval(-240), url: nil) + } + + private static func status(_ context: String, _ creator: String, _ outcome: CheckSignal.Outcome) -> CheckSignal { + CheckSignal(name: context, source: .commitStatus(creator: creator), outcome: outcome, rawState: "SUCCESS", + startedAt: now.addingTimeInterval(-500), completedAt: now.addingTimeInterval(-420), url: nil) + } + + private static func review(_ login: String, _ state: ReviewSignal.State) -> ReviewSignal { + ReviewSignal(author: login, isBot: true, state: state, submittedAt: now.addingTimeInterval(-300), commitSHA: nil) + } + + private static func thread(_ login: String) -> ThreadSignal { + ThreadSignal(author: login, isBot: true, isResolved: false, isOutdated: false, createdAt: now.addingTimeInterval(-280)) + } +} + +/// `PRMonitor --settings-window ` shows the real Settings window (with its toolbar) on +/// preview data and prints its window number, so it can be captured with `screencapture -l`. +enum SettingsWindowPreview { + @MainActor private static var window: NSWindow? + + @MainActor + static func runIfRequested() { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: "--settings-window") else { return } + let name = arguments.indices.contains(index + 1) ? arguments[index + 1] : "account" + let tab: AppRouter.SettingsTab = switch name { + case "general": .general + case "notifications": .notifications + case "repositories": .repositories + case "agents": .agents + default: .account + } + Task { @MainActor in + let (settings, account, monitor, router) = PreviewData.environment() + router.settingsTab = tab + let host = NSHostingController(rootView: SettingsView() + .environment(monitor).environment(settings).environment(account).environment(router)) + let window = NSWindow(contentViewController: host) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.title = "Settings" + window.setContentSize(NSSize(width: 760, height: 540)) + window.center() + window.level = .floating // Visible for capture even when another app is frontmost. + window.makeKeyAndOrderFront(nil) + NSApp.activate() + self.window = window + try? await Task.sleep(for: .seconds(1)) + let frame = window.frame, screen = window.screen?.frame.height ?? 0 + print("WINDOW \(Int(frame.minX)),\(Int(screen - frame.maxY)),\(Int(frame.width)),\(Int(frame.height))") + fflush(stdout) + try? await Task.sleep(for: .seconds(4)) + exit(0) + } + } +} + +/// `PRMonitor --snapshot ` renders the panel with preview data to PNGs, in light and dark. +enum Snapshots { + @MainActor + static func runIfRequested() { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: "--snapshot"), arguments.indices.contains(index + 1) else { return } + let directory = URL(filePath: arguments[index + 1], directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let (settings, account, monitor, router) = PreviewData.environment() + let expandedID = PreviewData.pullRequests[0].id + for (name, appearance) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] { + render( + MenuBarPanel(initiallyExpanded: [expandedID]) + .environment(monitor).environment(settings).environment(account).environment(router), + appearance: appearance, + to: directory.appending(path: "panel-\(name).png") + ) + } + renderIcons(to: directory.appending(path: "menu-bar-icons.png")) + + let loadingMonitor = Monitor(settings: settings, account: account, notifier: Notifier()) + render( + MenuBarPanel().environment(loadingMonitor).environment(settings).environment(account).environment(router), + appearance: .aqua, to: directory.appending(path: "panel-loading.png") + ) + + let signedOut = Account(previewSignedOut: ()) + let onboardingMonitor = Monitor(settings: settings, account: signedOut, notifier: Notifier()) + render( + MenuBarPanel().environment(onboardingMonitor).environment(settings).environment(signedOut).environment(router), + appearance: .aqua, to: directory.appending(path: "panel-onboarding.png") + ) + + let tabs: [(String, AppRouter.SettingsTab)] = [ + ("account", .account), ("general", .general), ("notifications", .notifications), ("repositories", .repositories), ("agents", .agents), + ] + for (name, tab) in tabs { + router.settingsTab = tab + render( + SettingsView().environment(monitor).environment(settings).environment(account).environment(router), + appearance: .aqua, to: directory.appending(path: "settings-\(name).png") + ) + } + exit(0) + } + + /// Every menu bar state, template and colored, on light and dark bars, scaled up 4×. + @MainActor + private static func renderIcons(to url: URL) { + let states: [(Monitor.Summary, Int)] = [(.signedOut, 0), (.idle, 0), (.running, 0), (.needsReview, 0), (.ready, 0), (.needsReview, 2), (.failing, 12)] + let scale: CGFloat = 4, cell = NSSize(width: 40, height: 22) + let size = NSSize(width: cell.width * CGFloat(states.count) * scale, height: cell.height * 4 * scale) + let image = NSImage(size: size, flipped: true) { _ in + for (row, (dark, colored)) in [(false, false), (true, false), (false, true), (true, true)].enumerated() { + let background = dark ? NSColor(white: 0.12, alpha: 1) : NSColor(white: 0.93, alpha: 1) + background.setFill() + NSRect(x: 0, y: CGFloat(row) * cell.height * scale, width: size.width, height: cell.height * scale).fill() + NSAppearance(named: dark ? .darkAqua : .aqua)!.performAsCurrentDrawingAppearance { + for (column, (state, count)) in states.enumerated() { + let icon = StatusIcon.image(for: state, count: count, isColored: colored) + let rect = NSRect( + x: (CGFloat(column) * cell.width + (cell.width - icon.size.width) / 2) * scale, + y: (CGFloat(row) * cell.height + (cell.height - icon.size.height) / 2) * scale, + width: icon.size.width * scale, height: icon.size.height * scale + ) + let drawn = colored ? icon : tint(icon, dark ? .white : .black) + drawn.draw(in: rect) + } + } + } + return true + } + guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return } + try? rep.representation(using: .png, properties: [:])?.write(to: url) + } + + private static func tint(_ template: NSImage, _ color: NSColor) -> NSImage { + NSImage(size: template.size, flipped: false) { rect in + template.draw(in: rect) + color.set() + rect.fill(using: .sourceAtop) + return true + } + } + + @MainActor + private static func render(_ view: some View, appearance: NSAppearance.Name, to url: URL) { + let host = NSHostingView(rootView: view.background(Color(nsColor: .windowBackgroundColor))) + host.appearance = NSAppearance(named: appearance) + let size = host.fittingSize + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: appearance) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: .now.addingTimeInterval(0.3)) + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return } + host.cacheDisplay(in: host.bounds, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: url) + } +} + +#Preview("Panel") { + let (settings, account, monitor, router) = PreviewData.environment() + MenuBarPanel() + .environment(monitor).environment(settings).environment(account).environment(router) +} +#endif diff --git a/Sources/PRMonitor/App/Showcase.swift b/Sources/PRMonitor/App/Showcase.swift new file mode 100644 index 0000000..9850872 --- /dev/null +++ b/Sources/PRMonitor/App/Showcase.swift @@ -0,0 +1,199 @@ +#if DEBUG +import AppKit +import SwiftUI + +/// `PRMonitor --showcase ` puts marketing compositions on screen, built from the real views on +/// real Liquid Glass over a designed backdrop, then prints the rect to capture with `screencapture -R`. +/// Scenes: `hero-dark`, `hero-light`, `settings-dark`, `settings-light`. Debug builds only. +enum Showcase { + @MainActor private static var windows: [NSWindow] = [] + + @MainActor + static func runIfRequested() { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: "--showcase"), arguments.indices.contains(index + 1) else { return } + let scene = arguments[index + 1] + let isDark = scene.hasSuffix("dark") + + Task { @MainActor in + let (settings, account, monitor, router) = PreviewData.environment() + let appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + let canvas = NSSize(width: 1280, height: 800) + guard let screen = NSScreen.main else { return } + let origin = NSPoint(x: screen.frame.midX - canvas.width / 2, y: screen.frame.midY - canvas.height / 2) + let frame = NSRect(origin: origin, size: canvas) + + if scene.hasPrefix("hero") { + let hero = HeroComposition(isDark: isDark, expandedID: PreviewData.pullRequests[0].id) + .environment(monitor).environment(settings).environment(account).environment(router) + show(hero, frame: frame, appearance: appearance, level: .floating) + } else { + show(Backdrop(isDark: isDark), frame: frame, appearance: appearance, level: .floating) + router.settingsTab = .agents + let settingsWindow = AlwaysKeyWindow( + contentViewController: NSHostingController(rootView: SettingsView() + .environment(monitor).environment(settings).environment(account).environment(router)) + ) + settingsWindow.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + settingsWindow.title = "Agents" + settingsWindow.appearance = appearance + settingsWindow.setContentSize(NSSize(width: 760, height: 520)) + settingsWindow.setFrameOrigin(NSPoint(x: frame.midX - 380, y: frame.midY - 280)) + settingsWindow.level = NSWindow.Level(rawValue: NSWindow.Level.floating.rawValue + 1) + // Active, so traffic lights and controls render in their key-window colors. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + settingsWindow.makeKeyAndOrderFront(nil) + windows.append(settingsWindow) + } + + // macOS may decline the first activation request from a background launch; ask again. + for _ in 0..<4 { + try? await Task.sleep(for: .seconds(0.5)) + if !NSApp.isActive { + NSRunningApplication.current.activate(options: [.activateAllWindows]) + windows.last?.makeKeyAndOrderFront(nil) + } + } + try? await Task.sleep(for: .seconds(0.5)) + let top = screen.frame.height - frame.maxY + print("RECT \(Int(frame.minX)),\(Int(top)),\(Int(frame.width)),\(Int(frame.height))") + fflush(stdout) + try? await Task.sleep(for: .seconds(5)) + exit(0) + } + } + + @MainActor + private static func show(_ view: some View, frame: NSRect, appearance: NSAppearance?, level: NSWindow.Level) { + let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = NSHostingView(rootView: view) + window.appearance = appearance + window.level = level + window.hasShadow = false + window.orderFrontRegardless() + windows.append(window) + } +} + +/// Draws with the active (key) appearance even though a background-launched app can't take focus +/// from whatever the user is doing, so captures don't show gray traffic lights and controls. +private final class AlwaysKeyWindow: NSWindow { + override var isKeyWindow: Bool { true } + override var isMainWindow: Bool { true } +} + +// MARK: - Compositions + +/// A soft, Tahoe-style mesh-gradient wallpaper, so Liquid Glass has color to refract. +private struct Backdrop: View { + let isDark: Bool + + var body: some View { + MeshGradient( + width: 3, height: 3, + points: [ + [0, 0], [0.5, 0], [1, 0], + [0, 0.5], [0.62, 0.45], [1, 0.5], + [0, 1], [0.5, 1], [1, 1], + ], + colors: isDark ? [ + Color(red: 0.05, green: 0.07, blue: 0.16), Color(red: 0.10, green: 0.10, blue: 0.30), Color(red: 0.20, green: 0.10, blue: 0.34), + Color(red: 0.03, green: 0.20, blue: 0.22), Color(red: 0.08, green: 0.30, blue: 0.40), Color(red: 0.30, green: 0.14, blue: 0.40), + Color(red: 0.02, green: 0.12, blue: 0.10), Color(red: 0.04, green: 0.26, blue: 0.20), Color(red: 0.10, green: 0.14, blue: 0.30), + ] : [ + Color(red: 0.80, green: 0.87, blue: 1.00), Color(red: 0.88, green: 0.84, blue: 1.00), Color(red: 0.98, green: 0.86, blue: 0.94), + Color(red: 0.74, green: 0.93, blue: 0.92), Color(red: 0.80, green: 0.90, blue: 1.00), Color(red: 0.95, green: 0.84, blue: 0.96), + Color(red: 0.78, green: 0.95, blue: 0.86), Color(red: 0.72, green: 0.90, blue: 0.95), Color(red: 0.86, green: 0.86, blue: 1.00), + ] + ) + } +} + +/// The hero: the panel on real Liquid Glass hanging from a menu bar, with the icon and a headline. +private struct HeroComposition: View { + let isDark: Bool + let expandedID: String + @Environment(Monitor.self) private var monitor + + /// The default (light) rendition exported from Icon Composer, so marketing shots always show the + /// canonical icon regardless of the Mac's icon style setting. + static let marketingIcon: NSImage = { + let repository = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + return NSImage(contentsOf: repository.appending(path: "docs/screenshots/app-icon.png")) ?? NSApp.applicationIconImage + }() + + var body: some View { + ZStack(alignment: .top) { + Backdrop(isDark: isDark) + + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 18) { + Image(nsImage: Self.marketingIcon) + .resizable() + .frame(width: 112, height: 112) + .shadow(color: .black.opacity(0.25), radius: 18, y: 10) + Text("Know the moment\nyour pull request\nis ready.") + .font(.system(size: 50, weight: .bold, design: .default)) + .tracking(-0.8) + .foregroundStyle(.primary) + Text("Every CI check and AI reviewer on your PRs, in one glance from the menu bar.") + .font(.system(size: 19)) + .foregroundStyle(.secondary) + .frame(maxWidth: 420, alignment: .leading) + } + .padding(.leading, 80) + .padding(.top, 170) + Spacer() + MenuBarPanel(initiallyExpanded: [expandedID]) + .glassEffect(.regular, in: .rect(cornerRadius: Metrics.panelCornerRadius)) + .shadow(color: .black.opacity(isDark ? 0.45 : 0.18), radius: 30, y: 18) + .padding(.top, 44) + .padding(.trailing, 150) + } + + MockMenuBar(summary: monitor.summary, count: monitor.attentionCount, isDark: isDark) + } + .frame(width: 1280, height: 800) + .clipped() + } +} + +/// A slim stand-in for the macOS menu bar showing PR Monitor's real status icon, positioned so the +/// panel hangs beneath it. +private struct MockMenuBar: View { + let summary: Monitor.Summary + let count: Int + let isDark: Bool + + var body: some View { + HStack(spacing: 18) { + Image(systemName: "apple.logo").font(.system(size: 15, weight: .medium)) + Text("Finder").fontWeight(.bold) + Text("File") + Text("Edit") + Text("View") + Spacer() + Image(nsImage: StatusIcon.image(for: summary, count: count, isColored: false)) + .renderingMode(.template) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(.primary.opacity(0.14))) + .padding(.trailing, 268) + .overlay(alignment: .trailing) { + HStack(spacing: 18) { + Image(systemName: "wifi") + Image(systemName: "battery.75percent") + Image(systemName: "switch.2") + Text("Mon Sep 28 9:41 AM") + } + } + } + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 18) + .frame(height: 30) + } +} +#endif diff --git a/Sources/PRMonitor/AppContainer.swift b/Sources/PRMonitor/AppContainer.swift deleted file mode 100644 index ac03770..0000000 --- a/Sources/PRMonitor/AppContainer.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -@MainActor -final class AppContainer: ObservableObject { - let settingsStore: SettingsStore - let authStore: AuthStore - let appState: AppState - - init() { - let settings = SettingsStore() - let auth = AuthStore() - self.settingsStore = settings - self.authStore = auth - self.appState = AppState(settingsStore: settings, authStore: auth) - } -} diff --git a/Sources/PRMonitor/AppDelegate.swift b/Sources/PRMonitor/AppDelegate.swift deleted file mode 100644 index b5e1fc5..0000000 --- a/Sources/PRMonitor/AppDelegate.swift +++ /dev/null @@ -1,118 +0,0 @@ -import AppKit -import Combine -import SwiftUI -import UserNotifications - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { - var container: AppContainer? - - private let popover = NSPopover() - private var statusItem: NSStatusItem? - private var statusHostingView: NSHostingView? - private var cancellables = Set() - - private let preferredWidth: CGFloat = 520 - - func applicationDidFinishLaunching(_ notification: Notification) { - guard let container else { return } - - NSApp.setActivationPolicy(.accessory) - UNUserNotificationCenter.current().delegate = self - setupStatusItem() - setupPopover(container: container) - bindStatusUpdates(container: container) - } - - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { - [.banner, .sound, .list] - } - - @objc private func togglePopover(_ sender: Any?) { - guard let button = statusItem?.button else { return } - if popover.isShown { - popover.performClose(sender) - } else { - button.highlight(false) - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - NSApp.activate(ignoringOtherApps: true) - } - } - - private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - guard let button = statusItem?.button else { return } - button.target = self - button.action = #selector(togglePopover(_:)) - - let hosting = NSHostingView(rootView: StatusBarLabel(status: .idle)) - hosting.translatesAutoresizingMaskIntoConstraints = false - button.addSubview(hosting) - NSLayoutConstraint.activate([ - hosting.leadingAnchor.constraint(equalTo: button.leadingAnchor), - hosting.trailingAnchor.constraint(equalTo: button.trailingAnchor), - hosting.topAnchor.constraint(equalTo: button.topAnchor), - hosting.bottomAnchor.constraint(equalTo: button.bottomAnchor) - ]) - statusHostingView = hosting - } - - private func setupPopover(container: AppContainer) { - let rootView = ContentView( - maxPopoverHeight: currentMaxPopoverHeight(), - onSizeChange: { [weak self] size in - self?.updatePopoverSize(size: size) - } - ) - .environmentObject(container.appState) - .environmentObject(container.authStore) - - let controller = NSHostingController(rootView: rootView) - popover.contentViewController = controller - popover.behavior = .transient - popover.animates = true - popover.contentSize = NSSize(width: preferredWidth, height: 320) - } - - private func bindStatusUpdates(container: AppContainer) { - container.appState.objectWillChange - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.updateStatusItem(container: container) - } - .store(in: &cancellables) - - container.authStore.objectWillChange - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.updateStatusItem(container: container) - } - .store(in: &cancellables) - } - - private func updateStatusItem(container: AppContainer) { - statusHostingView?.rootView = StatusBarLabel(status: container.appState.overallStatus) - updatePopoverMaxHeight() - } - - private func updatePopoverSize(size: CGSize) { - let clampedHeight = min(currentMaxPopoverHeight(), max(260, size.height)) - if abs(popover.contentSize.height - clampedHeight) > 1 { - popover.contentSize = NSSize(width: preferredWidth, height: clampedHeight) - } - } - - private func updatePopoverMaxHeight() { - let clampedHeight = min(currentMaxPopoverHeight(), popover.contentSize.height) - if abs(popover.contentSize.height - clampedHeight) > 1 { - popover.contentSize = NSSize(width: preferredWidth, height: clampedHeight) - } - } - - private func currentMaxPopoverHeight() -> CGFloat { - let screenHeight = statusItem?.button?.window?.screen?.visibleFrame.height - ?? NSScreen.main?.visibleFrame.height - ?? 900 - return max(320, min(900, screenHeight - 120)) - } -} diff --git a/Sources/PRMonitor/AppState.swift b/Sources/PRMonitor/AppState.swift deleted file mode 100644 index 359af9d..0000000 --- a/Sources/PRMonitor/AppState.swift +++ /dev/null @@ -1,312 +0,0 @@ -import Foundation -import Combine -import UserNotifications - -@MainActor -final class AppState: ObservableObject { - enum OverallStatus { - case needsAuth - case idle - case running - case failed - case waiting - case done - } - - @Published var repoSections: [RepoSection] = [] - @Published var lastRefresh: Date? - @Published var isRefreshing = false - @Published var errorMessage: String? - @Published var viewerLogin: String? - - let settingsStore: SettingsStore - let authStore: AuthStore - - private let pollingService: PollingService - private var timer: Timer? - private var cancellables = Set() - private var lastAllDone = false - private var activeInterval: TimeInterval? - private let idleIntervalSeconds: TimeInterval = 600 - private var lastAgentStatus: [String: AgentRunStatus] = [:] - private var lastReviewSummaryToken: [String: String] = [:] - private var refreshTask: Task? - - init(settingsStore: SettingsStore, authStore: AuthStore) { - self.settingsStore = settingsStore - self.authStore = authStore - let client = GitHubClient(tokenProvider: { authStore.token }) - self.pollingService = PollingService(client: client) - - settingsStore.$pollingIntervalSeconds - .sink { [weak self] _ in self?.restartPolling() } - .store(in: &cancellables) - - settingsStore.$repos - .sink { [weak self] _ in self?.restartPolling() } - .store(in: &cancellables) - - authStore.$token - .sink { [weak self] _ in self?.restartPolling() } - .store(in: &cancellables) - - requestNotificationPermissions() - restartPolling() - } - - var overallStatus: OverallStatus { - guard authStore.isSignedIn else { return .needsAuth } - let agents = repoSections.flatMap { $0.prs.flatMap { $0.agents } } - if agents.isEmpty { return .idle } - if agents.contains(where: { $0.status == .running }) { return .running } - if agents.contains(where: { $0.status == .failed }) { return .failed } - if agents.contains(where: { $0.status == .waitingForComment || $0.status == .notFound }) { return .waiting } - return .done - } - - func refreshNow() { - guard authStore.isSignedIn else { - repoSections = [] - errorMessage = "Not signed in to GitHub" - updateTimerInterval(hasOpenPRs: false) - return - } - let repos = settingsStore.enabledRepos - guard !repos.isEmpty else { - repoSections = [] - errorMessage = "No repositories enabled. Add repos in Settings." - updateTimerInterval(hasOpenPRs: false) - return - } - - refreshTask?.cancel() - refreshTask = Task { @MainActor [weak self] in - guard let self else { return } - isRefreshing = true - errorMessage = nil - defer { isRefreshing = false } - - do { - let sections = try await pollingService.fetchRepoSections(repos: repos, agents: settingsStore.agents) - if Task.isCancelled { return } - let filtered = sections.filter { !$0.prs.isEmpty } - repoSections = filtered - lastRefresh = Date() - await updateViewerLogin() - handleNotificationsIfNeeded() - updateTimerInterval(hasOpenPRs: !repoSections.isEmpty) - } catch { - if Task.isCancelled { return } - if let clientError = error as? GitHubClientError { - if case .rateLimited(let reset) = clientError { - applyRateLimitBackoff(reset: reset) - } - } - errorMessage = error.localizedDescription - } - } - } - - func restartPolling() { - timer?.invalidate() - timer = nil - activeInterval = nil - refreshNow() - updateTimerInterval(hasOpenPRs: !repoSections.isEmpty) - } - - private func handleNotificationsIfNeeded() { - guard notificationsAvailable else { return } - let allDone = overallStatus == .done - if allDone && !lastAllDone { - sendAllDoneNotification() - } - lastAllDone = allDone - handlePerAgentNotifications() - handleReviewSummaryNotifications() - } - - private func sendAllDoneNotification() { - guard notificationsAvailable else { return } - let content = UNMutableNotificationContent() - content.title = "PR Monitor" - content.body = "All agents have completed their checks." - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) - } - - private func handleReviewSummaryNotifications() { - guard notificationsAvailable else { return } - guard settingsStore.notifySummary else { return } - - var nextTokens: [String: String] = [:] - - for section in repoSections { - for pr in section.prs { - let agents = pr.agents - guard !agents.isEmpty else { continue } - let allFinished = agents.allSatisfy { - $0.status == .done || $0.status == .waitingForComment || $0.status == .failed - } - guard allFinished else { continue } - - let token = reviewSummaryToken(for: pr) - let key = "\(section.fullName)#\(pr.number)" - nextTokens[key] = token - - if lastReviewSummaryToken[key] != token { - sendReviewSummaryNotification(pr: pr, agents: agents) - } - } - } - - lastReviewSummaryToken = nextTokens - } - - private func reviewSummaryToken(for pr: PRItem) -> String { - let commentCount = pr.agents.filter { $0.commentCount > 0 }.count - let total = pr.agents.count - let hasFailure = pr.agents.contains { isFailureConclusion($0.checkConclusion) } - return "\(pr.updatedAt.timeIntervalSince1970)-\(commentCount)-\(total)-\(hasFailure)" - } - - private func sendReviewSummaryNotification(pr: PRItem, agents: [AgentRun]) { - let total = agents.count - let commentAgents = agents.filter { $0.commentCount > 0 }.count - let hasFailure = agents.contains { isFailureConclusion($0.checkConclusion) } - - let title: String - let body: String - - if hasFailure { - title = "Build failed" - body = reviewBody(pr: pr, commentAgents: commentAgents, total: total) - } else if commentAgents == 0 { - title = "Review completed" - body = "#\(pr.number) \(pr.title) • No comments" - } else if commentAgents == total { - title = "Needs review" - body = "#\(pr.number) \(pr.title) • Comments from all agents" - } else { - title = "Needs review" - body = "#\(pr.number) \(pr.title) • Comments from \(commentAgents) of \(total) agents" - } - - let content = UNMutableNotificationContent() - content.title = "PR Monitor" - content.subtitle = title - content.body = body - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) - } - - private func isFailureConclusion(_ conclusion: String?) -> Bool { - guard let conclusion, !conclusion.isEmpty else { return false } - switch conclusion.lowercased() { - case "success", "neutral", "skipped": - return false - default: - return true - } - } - - private func reviewBody(pr: PRItem, commentAgents: Int, total: Int) -> String { - if commentAgents == 0 { - return "#\(pr.number) \(pr.title) • No comments" - } - if commentAgents == total { - return "#\(pr.number) \(pr.title) • Comments from all agents" - } - return "#\(pr.number) \(pr.title) • Comments from \(commentAgents) of \(total) agents" - } - - private func handlePerAgentNotifications() { - guard notificationsAvailable else { return } - guard settingsStore.notifyPerAgent else { return } - - var nextStatus: [String: AgentRunStatus] = [:] - for section in repoSections { - for pr in section.prs { - for agent in pr.agents { - let key = "\(section.fullName)#\(pr.number)#\(agent.id)" - nextStatus[key] = agent.status - if agent.status == .done && lastAgentStatus[key] != .done { - sendAgentDoneNotification(pr: pr, agent: agent) - } - } - } - } - lastAgentStatus = nextStatus - } - - private func sendAgentDoneNotification(pr: PRItem, agent: AgentRun) { - guard notificationsAvailable else { return } - let content = UNMutableNotificationContent() - content.title = "PR Monitor" - content.body = "\(agent.displayName) finished on #\(pr.number) \(pr.title)." - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) - } - - private func requestNotificationPermissions() { - guard notificationsAvailable else { return } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } - } - - private func updateViewerLogin() async { - do { - viewerLogin = try await pollingService.client.fetchViewerLogin() - } catch { - viewerLogin = nil - } - } - - private var notificationsAvailable: Bool { - NotificationAvailability.isAvailable - } - - private func updateTimerInterval(hasOpenPRs: Bool) { - guard authStore.isSignedIn else { - activeInterval = nil - timer?.invalidate() - timer = nil - return - } - guard !settingsStore.enabledRepos.isEmpty else { - activeInterval = nil - timer?.invalidate() - timer = nil - return - } - let targetInterval: TimeInterval - if hasOpenPRs { - targetInterval = TimeInterval(max(30, settingsStore.pollingIntervalSeconds)) - } else { - targetInterval = idleIntervalSeconds - } - - guard activeInterval != targetInterval else { return } - activeInterval = targetInterval - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: targetInterval, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.refreshNow() - } - } - } - - private func applyRateLimitBackoff(reset: Date?) { - guard authStore.isSignedIn else { return } - guard !settingsStore.enabledRepos.isEmpty else { return } - guard let reset else { return } - let delay = max(60, reset.timeIntervalSinceNow) - guard delay > 0 else { return } - activeInterval = delay - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.refreshNow() - } - } - } -} diff --git a/Sources/PRMonitor/AuthStore.swift b/Sources/PRMonitor/AuthStore.swift deleted file mode 100644 index dbf9c69..0000000 --- a/Sources/PRMonitor/AuthStore.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import AppKit - -struct DeviceFlowState: Hashable { - var userCode: String - var deviceCode: String - var verificationURL: URL - var expiresAt: Date - var intervalSeconds: Int -} - -@MainActor -final class AuthStore: ObservableObject { - @Published var token: String? - @Published var deviceFlow: DeviceFlowState? - @Published var statusMessage: String? - @Published var isSigningIn: Bool = false - - private let service = "PRMonitor" - private let account = "github_token" - private let authService = GitHubAuthService() - - init() { - token = Keychain.load(service: service, account: account) - } - - var isSignedIn: Bool { token != nil } - - func signIn(clientId: String) { - guard !clientId.isEmpty else { - statusMessage = "Add a GitHub OAuth client ID in Settings." - return - } - statusMessage = nil - isSigningIn = true - Task { - defer { isSigningIn = false } - do { - let flow = try await authService.startDeviceFlow(clientId: clientId) - deviceFlow = flow - NSWorkspace.shared.open(flow.verificationURL) - let tokenValue = try await authService.pollForToken(clientId: clientId, deviceCode: flow.deviceCode, intervalSeconds: flow.intervalSeconds, expiresAt: flow.expiresAt) - token = tokenValue - Keychain.save(service: service, account: account, value: tokenValue) - statusMessage = "Connected." - deviceFlow = nil - } catch { - deviceFlow = nil - statusMessage = "Sign-in failed: \(error.localizedDescription)" - } - } - } - - func signOut() { - token = nil - deviceFlow = nil - Keychain.delete(service: service, account: account) - } -} diff --git a/Sources/PRMonitor/Core/Agent.swift b/Sources/PRMonitor/Core/Agent.swift new file mode 100644 index 0000000..1a6d595 --- /dev/null +++ b/Sources/PRMonitor/Core/Agent.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Something that reports on a pull request: a CI system, a deploy integration, or an AI reviewer. +/// +/// An agent is matched against a PR by two independent keys. Either may be empty, but not both. +/// - `checkPattern` matches check runs and commit statuses (by name, app, or creator). +/// - `login` matches the bot account that requests reviews, leaves review threads, or comments. +struct Agent: Identifiable, Hashable, Codable, Sendable { + var id: UUID = UUID() + var name: String + var checkPattern: String = "" + var login: String = "" + + var isValid: Bool { + !name.trimmed.isEmpty && !(checkPattern.trimmed.isEmpty && login.trimmed.isEmpty) + } +} + +enum AgentMode: String, Codable, Sendable, CaseIterable { + /// Every check and review bot that reports on a PR becomes an agent. Zero configuration. + case automatic + /// Only the agents the user configured are tracked; everything else is ignored. + case custom +} + +extension Agent { + /// Well-known integrations. Logins are the GraphQL bot logins (without the `[bot]` suffix). + static let presets: [Agent] = [ + Agent(name: "CodeRabbit", checkPattern: "coderabbit", login: "coderabbitai"), + Agent(name: "Copilot", login: "copilot-pull-request-reviewer"), + Agent(name: "Cursor Bugbot", checkPattern: "cursor", login: "cursor"), + Agent(name: "Devin", checkPattern: "devin", login: "devin-ai-integration"), + Agent(name: "Codex", login: "chatgpt-codex-connector"), + Agent(name: "Gemini Code Assist", login: "gemini-code-assist"), + Agent(name: "Graphite", checkPattern: "graphite", login: "graphite-app"), + Agent(name: "Greptile", checkPattern: "greptile", login: "greptile-apps"), + Agent(name: "Sourcery", login: "sourcery-ai"), + Agent(name: "GitHub Actions", checkPattern: "github-actions"), + Agent(name: "Vercel", checkPattern: "vercel", login: "vercel"), + Agent(name: "Netlify", checkPattern: "netlify", login: "netlify"), + ] + + /// A friendly name for a bot login or app slug, if it belongs to a known integration. + static func presetName(forKey key: String) -> String? { + let key = Identity.normalize(key) + return presets.first { Identity.normalize($0.login) == key || Identity.normalize($0.checkPattern) == key }?.name + } +} + +/// Normalizes GitHub identities so that `cursor[bot]` (REST), `cursor` (GraphQL) and `Cursor` compare equal. +enum Identity { + static func normalize(_ value: String) -> String { + value.lowercased() + .replacing("[bot]", with: "") + .filter { $0.isLetter || $0.isNumber } + } +} + +extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} diff --git a/Sources/PRMonitor/Core/NotificationPlanner.swift b/Sources/PRMonitor/Core/NotificationPlanner.swift new file mode 100644 index 0000000..87f3624 --- /dev/null +++ b/Sources/PRMonitor/Core/NotificationPlanner.swift @@ -0,0 +1,164 @@ +import Foundation + +/// A notification the app should post. +struct PlannedNotification: Hashable, Sendable { + enum Kind: Hashable, Sendable { + /// Every agent finished on the head commit. + case settled(PullRequestStatus) + /// A settled PR got worse without a new push (e.g. a bot left threads after its check passed). + case regressed(PullRequestStatus) + case agentFinished(name: String, state: AgentReport.State) + } + + let kind: Kind + let key: PullRequestKey + let headSHA: String + let title: String + let subtitle: String + let body: String + let url: URL + + /// Deterministic, so re-planning the same event replaces rather than duplicates a banner. + var identifier: String { + let suffix = switch kind { + case let .settled(status): "settled-\(status)" + case let .regressed(status): "regressed-\(status)" + case let .agentFinished(name, _): "agent-\(name)" + } + return "\(key)@\(headSHA.prefix(12))/\(suffix)" + } +} + +struct NotificationPreferences: Hashable, Sendable { + var whenSettled = true + var whenFeedbackArrives = true + var perAgent = false +} + +/// Decides which notifications to post by diffing the previous poll against the current one. +/// +/// Rules that keep it quiet: +/// - The first poll only records a baseline, so launching the app never floods Notification Center. +/// - Transitions are tracked per head commit: a push starts a fresh lifecycle, and each lifecycle +/// announces "settled" at most once. +/// - PRs first seen after launch only notify if they were opened after the baseline was taken. +struct NotificationPlanner: Sendable { + struct Snapshot: Hashable, Sendable { + let headSHA: String + let status: PullRequestStatus + let isSettled: Bool + let agentStates: [String: AgentReport.State] + } + + private(set) var snapshots: [PullRequestKey: Snapshot] = [:] + private(set) var baselineDate: Date? + + mutating func plan( + for reports: [PullRequestReport], + preferences: NotificationPreferences, + now: Date = .now + ) -> [PlannedNotification] { + let next = Dictionary(reports.map { ($0.pullRequest.key, Self.snapshot(of: $0)) }, uniquingKeysWith: { a, _ in a }) + defer { snapshots = next } + + guard let baselineDate else { + self.baselineDate = now + return [] + } + + var planned: [PlannedNotification] = [] + for report in reports { + let pr = report.pullRequest + guard let current = next[pr.key] else { continue } + let previous = snapshots[pr.key] + + if previous == nil, pr.createdAt < baselineDate { + continue // Existed before launch but newly visible (e.g. repo just enabled). + } + let sameCommit = previous?.headSHA == current.headSHA + let wasSettled = sameCommit && (previous?.isSettled ?? false) + + if preferences.whenSettled, current.isSettled, !wasSettled, current.status != .quiet { + planned.append(Self.settledNotification(for: report)) + } else if preferences.whenFeedbackArrives, sameCommit, wasSettled, current.isSettled, + let previous, current.status.urgency > previous.status.urgency, current.status.needsAttention { + planned.append(Self.regressedNotification(for: report)) + } + + if preferences.perAgent { + // A new push restarts every agent's lifecycle, and an agent we haven't seen before + // on this commit was, by definition, not finished last time we looked. + for agent in report.agents where agent.state != .running { + let before = sameCommit ? (previous?.agentStates[agent.id] ?? .running) : .running + if before == .running { + planned.append(Self.agentNotification(agent, report: report)) + } + } + } + } + return planned + } + + private static func snapshot(of report: PullRequestReport) -> Snapshot { + Snapshot( + headSHA: report.pullRequest.headSHA, + status: report.status, + isSettled: report.isSettled, + agentStates: Dictionary(report.agents.map { ($0.id, $0.state) }, uniquingKeysWith: { a, _ in a }) + ) + } + + // MARK: - Copy + + private static func settledNotification(for report: PullRequestReport) -> PlannedNotification { + let pr = report.pullRequest + let (subtitle, body) = describe(report) + return PlannedNotification( + kind: .settled(report.status), key: pr.key, headSHA: pr.headSHA, + title: "\(pr.repository.name) #\(pr.number)", subtitle: subtitle, + body: "\(pr.title)\n\(body)", url: pr.url + ) + } + + private static func regressedNotification(for report: PullRequestReport) -> PlannedNotification { + let pr = report.pullRequest + let (subtitle, body) = describe(report) + return PlannedNotification( + kind: .regressed(report.status), key: pr.key, headSHA: pr.headSHA, + title: "\(pr.repository.name) #\(pr.number)", subtitle: subtitle, + body: "\(pr.title)\n\(body)", url: pr.url + ) + } + + private static func agentNotification(_ agent: AgentReport, report: PullRequestReport) -> PlannedNotification { + let pr = report.pullRequest + return PlannedNotification( + kind: .agentFinished(name: agent.name, state: agent.state), key: pr.key, headSHA: pr.headSHA, + title: "\(pr.repository.name) #\(pr.number)", subtitle: "\(agent.name): \(agent.summary)", + body: pr.title, url: agent.url ?? pr.url + ) + } + + static func describe(_ report: PullRequestReport) -> (subtitle: String, body: String) { + let names: (AgentReport.State) -> String = { state in + ListFormatter.localizedString(byJoining: report.agents.filter { $0.state == state }.map(\.name)) + } + switch report.status { + case .failing: + return ("Checks failed", "\(names(.failed)) failed.") + case .needsReview: + let threads = report.openThreadCount + let who = names(.needsReview) + if threads > 0 { + let count = threads == 1 ? "1 open thread" : "\(threads) open threads" + return ("Ready for your review", who.isEmpty ? "\(count)." : "\(count) from \(who).") + } + return ("Changes requested", who.isEmpty ? "A reviewer requested changes." : "\(who) requested changes.") + case .ready: + let count = report.agents.count + return ("All clear", count == 1 ? "\(report.agents[0].name) passed with no feedback." : "All \(count) agents passed with no feedback.") + case .running, .quiet: + return ("Updated", "") + } + } +} diff --git a/Sources/PRMonitor/Core/PollingPolicy.swift b/Sources/PRMonitor/Core/PollingPolicy.swift new file mode 100644 index 0000000..198a63b --- /dev/null +++ b/Sources/PRMonitor/Core/PollingPolicy.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Decides how long to wait before the next poll. +struct PollingPolicy: Sendable { + enum Outcome: Sendable { + case success(hasRunningPullRequests: Bool, hasOpenPullRequests: Bool) + case rateLimited(resetAt: Date?) + case failure(consecutive: Int) + } + + /// The user's chosen interval, used while any agent is still working. + var activeInterval: TimeInterval + /// Used when every open PR has settled; pushes and new feedback are still picked up. + var settledInterval: TimeInterval = 120 + /// Used when there are no open PRs at all. + var idleInterval: TimeInterval = 300 + var maximumBackoff: TimeInterval = 600 + + func delay(after outcome: Outcome, now: Date = .now) -> TimeInterval { + switch outcome { + case let .success(running, open): + if running { return activeInterval } + return open ? max(activeInterval, settledInterval) : max(activeInterval, idleInterval) + case let .rateLimited(resetAt): + // Wait out the window, plus a little slack so we don't land a hair early. + let wait = (resetAt?.timeIntervalSince(now) ?? 60) + 5 + return min(max(wait, 30), 3600) + case let .failure(consecutive): + // 30s, 60s, 120s … capped. Transient network blips recover quickly; outages don't hammer. + let exponent = Double(max(0, consecutive - 1)) + return min(30 * pow(2, exponent), maximumBackoff) + } + } +} diff --git a/Sources/PRMonitor/Core/PullRequest.swift b/Sources/PRMonitor/Core/PullRequest.swift new file mode 100644 index 0000000..87743dd --- /dev/null +++ b/Sources/PRMonitor/Core/PullRequest.swift @@ -0,0 +1,145 @@ +import Foundation + +/// Everything the app knows about one open pull request, as reported by GitHub. +/// +/// This is deliberately a plain value with no GitHub DTO types in it: the GitHub layer maps its +/// responses into this shape, and everything downstream (status derivation, notifications, UI) is +/// pure and testable against hand-built values. +struct PullRequest: Identifiable, Hashable, Sendable { + /// GraphQL node ID; stable across polls. + let id: String + let repository: RepositoryID + let number: Int + let title: String + let url: URL + let author: String? + let isDraft: Bool + let createdAt: Date + let updatedAt: Date + let headSHA: String + let headCommittedAt: Date? + /// GitHub's own roll-up of every check on the head commit. Used as a safety net: it also + /// counts checks the detail query can't see (more than 100, or suites without runs yet). + let rollupState: String? + let reviewDecision: ReviewDecision? + let mergeable: Mergeable + + let checks: [CheckSignal] + let requestedReviewers: [ReviewRequestSignal] + let reviews: [ReviewSignal] + let threads: [ThreadSignal] + let comments: [CommentSignal] + + var key: PullRequestKey { PullRequestKey(repository: repository, number: number) } + var checksURL: URL { url.appending(path: "checks") } +} + +/// A human-readable identity for a PR (`owner/name#123`), used for notification bookkeeping. +struct PullRequestKey: Hashable, Codable, Sendable, CustomStringConvertible { + let repository: RepositoryID + let number: Int + var description: String { "\(repository.fullName)#\(number)" } +} + +enum ReviewDecision: String, Sendable, Hashable { + case approved = "APPROVED" + case changesRequested = "CHANGES_REQUESTED" + case reviewRequired = "REVIEW_REQUIRED" +} + +enum Mergeable: String, Sendable, Hashable { + case mergeable = "MERGEABLE" + case conflicting = "CONFLICTING" + case unknown = "UNKNOWN" +} + +/// One check run or commit status on the head commit. +struct CheckSignal: Hashable, Sendable { + enum Source: Hashable, Sendable { + case checkRun(appName: String?, appSlug: String?) + case commitStatus(creator: String?) + } + + enum Outcome: Hashable, Sendable { + case pending + case succeeded + /// Completed without passing or failing: skipped, neutral, cancelled or stale. + case neutral + case failed + } + + let name: String + let source: Source + let outcome: Outcome + /// GitHub's raw status or conclusion (e.g. `TIMED_OUT`), kept for display. + let rawState: String + let startedAt: Date? + let completedAt: Date? + let url: URL? + + /// The key that groups related signals into one automatic agent: the app slug for check runs + /// (all GitHub Actions jobs become one agent) and the creator for statuses. This is also what + /// merges Vercel's commit status and its "Preview Comments" check run into a single row. + var groupKey: String { + switch source { + case let .checkRun(appName, appSlug): Identity.normalize(appSlug ?? appName ?? name) + case let .commitStatus(creator): Identity.normalize(creator ?? name) + } + } + + var groupDisplayName: String { + switch source { + case let .checkRun(appName, appSlug): Agent.presetName(forKey: appSlug ?? "") ?? appName ?? name + case let .commitStatus(creator): Agent.presetName(forKey: creator ?? "") ?? name + } + } + + /// Strings a user-entered check pattern is matched against. + var searchableFields: [String] { + switch source { + case let .checkRun(appName, appSlug): [name, appName, appSlug].compactMap(\.self) + case let .commitStatus(creator): [name, creator].compactMap(\.self) + } + } +} + +/// A pending review request. GitHub removes the request once the reviewer submits, so a request +/// that is still present means that reviewer hasn't finished. +struct ReviewRequestSignal: Hashable, Sendable { + let login: String + let isBot: Bool +} + +struct ReviewSignal: Hashable, Sendable { + enum State: String, Sendable { + case approved = "APPROVED" + case changesRequested = "CHANGES_REQUESTED" + case commented = "COMMENTED" + case dismissed = "DISMISSED" + case pending = "PENDING" + } + + let author: String + let isBot: Bool + let state: State + let submittedAt: Date? + /// The commit the review was left on, so a review of an older push can be told apart. + let commitSHA: String? +} + +struct ThreadSignal: Hashable, Sendable { + let author: String + let isBot: Bool + let isResolved: Bool + let isOutdated: Bool + let createdAt: Date? + + /// An unresolved thread on current code: the strongest signal that someone needs to act. + var isOpen: Bool { !isResolved && !isOutdated } +} + +struct CommentSignal: Hashable, Sendable { + let author: String + let isBot: Bool + let createdAt: Date +} diff --git a/Sources/PRMonitor/Core/Repository.swift b/Sources/PRMonitor/Core/Repository.swift new file mode 100644 index 0000000..ed44458 --- /dev/null +++ b/Sources/PRMonitor/Core/Repository.swift @@ -0,0 +1,52 @@ +import Foundation + +/// A GitHub repository coordinate (`owner/name`). +struct RepositoryID: Hashable, Codable, Sendable, Comparable, CustomStringConvertible { + let owner: String + let name: String + + init(owner: String, name: String) { + self.owner = owner + self.name = name + } + + /// Parses `owner/name`, `https://github.com/owner/name[.git][/…]`, or `git@github.com:owner/name.git`. + init?(parsing input: String) { + var text = input.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("git@github.com:") { + text.removeFirst("git@github.com:".count) + } else if let url = URL(string: text), let host = url.host(), host.hasSuffix("github.com") { + text = url.path() + } + if text.hasSuffix(".git") { text.removeLast(4) } + + let parts = text.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard parts.count >= 2 else { return nil } + // Anything beyond owner/name (e.g. `/pull/12`) is ignored so pasted URLs just work. + let owner = parts[0], name = parts[1] + guard Self.isValidOwner(owner), Self.isValidName(name) else { return nil } + self.init(owner: owner, name: name) + } + + var fullName: String { "\(owner)/\(name)" } + var description: String { fullName } + var url: URL { URL(string: "https://github.com/\(owner)/\(name)")! } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.fullName.localizedStandardCompare(rhs.fullName) == .orderedAscending + } + + private static func isValidOwner(_ owner: String) -> Bool { + owner.count <= 39 && owner.wholeMatch(of: /[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/) != nil + } + + private static func isValidName(_ name: String) -> Bool { + name.count <= 100 && name != "." && name != ".." && name.wholeMatch(of: /[A-Za-z0-9._-]+/) != nil + } +} + +/// A repository the user has chosen to monitor. +struct TrackedRepository: Identifiable, Hashable, Codable, Sendable { + var id: RepositoryID + var isEnabled: Bool = true +} diff --git a/Sources/PRMonitor/Core/StatusEngine.swift b/Sources/PRMonitor/Core/StatusEngine.swift new file mode 100644 index 0000000..f01857b --- /dev/null +++ b/Sources/PRMonitor/Core/StatusEngine.swift @@ -0,0 +1,268 @@ +import Foundation + +/// The derived state of one agent on one pull request. +struct AgentReport: Identifiable, Hashable, Sendable { + enum State: Hashable, Sendable { + case running + case failed + /// Finished, and left feedback someone has to address. + case needsReview + case passed + } + + let id: String + let name: String + let state: State + /// A short, human-readable explanation ("2 of 5 failed", "3 open threads"). + let summary: String + let openThreads: Int + let finishedAt: Date? + let url: URL? +} + +/// The overall state of a pull request, derived from its agents. +enum PullRequestStatus: Hashable, Sendable { + case failing + case running + case needsReview + case ready + /// No agent has reported anything on the head commit. + case quiet + + /// How urgently this status wants the user's attention when summarizing many PRs. + var urgency: Int { + switch self { + case .failing: 4 + case .needsReview: 3 + case .running: 2 + case .ready: 1 + case .quiet: 0 + } + } + + var needsAttention: Bool { self == .failing || self == .needsReview } +} + +struct PullRequestReport: Identifiable, Hashable, Sendable { + let pullRequest: PullRequest + let agents: [AgentReport] + let status: PullRequestStatus + + var id: String { pullRequest.id } + /// True once no agent is still working. This is the moment the app exists to announce. + var isSettled: Bool { !agents.contains { $0.state == .running } } + var finishedAgentCount: Int { agents.count { $0.state != .running } } + var openThreadCount: Int { pullRequest.threads.count(where: \.isOpen) } +} + +/// Reduces the raw signals on a pull request into per-agent and overall status. +/// +/// Pure and deterministic: the same inputs and `now` always produce the same report. +struct StatusEngine: Sendable { + var mode: AgentMode + var agents: [Agent] + /// How long after a push a configured agent that hasn't reported yet is assumed to be starting, + /// rather than not applicable to this PR. + var startupGracePeriod: TimeInterval = 180 + + func report(for pr: PullRequest, now: Date = .now) -> PullRequestReport { + var reports: [AgentReport] = switch mode { + case .automatic: automaticReports(for: pr, now: now) + case .custom: agents.filter(\.isValid).compactMap { configuredReport(for: $0, pr: pr, now: now) } + } + // Trust GitHub's roll-up over our own reading when they disagree: a pending roll-up with no + // pending check in view means something we can't see (>100 checks, a queued suite) is running. + if mode == .automatic, pr.rollupState == "PENDING" || pr.rollupState == "EXPECTED", + !pr.checks.contains(where: { $0.outcome == .pending }) { + reports.append(AgentReport(id: "rollup", name: "Other checks", state: .running, summary: "In progress", + openThreads: 0, finishedAt: nil, url: pr.checksURL)) + } + return PullRequestReport(pullRequest: pr, agents: reports, status: status(of: reports, pr: pr)) + } + + // MARK: - Overall status + + private func status(of agents: [AgentReport], pr: PullRequest) -> PullRequestStatus { + guard !agents.isEmpty else { return .quiet } + // A failure is actionable immediately, even while other agents are still working. + if agents.contains(where: { $0.state == .failed }) { return .failing } + if agents.contains(where: { $0.state == .running }) { return .running } + if agents.contains(where: { $0.state == .needsReview }) || pr.reviewDecision == .changesRequested { + return .needsReview + } + return .ready + } + + // MARK: - Automatic mode + + /// Every check integration and review bot on the PR becomes an agent. Signals from the same + /// integration are grouped: all GitHub Actions jobs form one agent, and a bot's commit status, + /// check run and review threads merge when its login matches the app slug. + private func automaticReports(for pr: PullRequest, now: Date) -> [AgentReport] { + var names: [String: String] = [:] + var order: [String] = [] + func register(_ key: String, name: @autoclosure () -> String) { + guard !key.isEmpty, names[key] == nil else { return } + names[key] = name() + order.append(key) + } + + for check in pr.checks { register(check.groupKey, name: check.groupDisplayName) } + // Bots that participate in review. Comment-only bots (changeset, codecov…) are left out: + // they have no lifecycle, so they would only add noise. + let reviewBots = pr.requestedReviewers.filter(\.isBot).map(\.login) + + pr.reviews.filter(\.isBot).map(\.author) + + pr.threads.filter(\.isBot).map(\.author) + for login in reviewBots { register(Identity.normalize(login), name: Self.displayName(forLogin: login)) } + + return order.compactMap { key in + evaluate( + id: key, + name: names[key] ?? key, + checks: pr.checks.filter { $0.groupKey == key }, + pr: pr, + login: key, + isConfigured: false, + now: now + ) + } + } + + // MARK: - Custom mode + + private func configuredReport(for agent: Agent, pr: PullRequest, now: Date) -> AgentReport? { + let pattern = Identity.normalize(agent.checkPattern) + let login = Identity.normalize(agent.login) + let checks = pr.checks.filter { check in + if !pattern.isEmpty, check.searchableFields.contains(where: { Identity.normalize($0).contains(pattern) }) { + return true + } + return !login.isEmpty && check.groupKey == login + } + return evaluate( + id: agent.id.uuidString, + name: agent.name, + checks: checks, + pr: pr, + login: login, + isConfigured: true, + now: now + ) + } + + // MARK: - Evaluation + + /// - Parameter login: normalized login used to attribute reviews, threads and comments. + private func evaluate( + id: String, + name: String, + checks: [CheckSignal], + pr: PullRequest, + login: String, + isConfigured: Bool, + now: Date + ) -> AgentReport? { + let byAgent: (String) -> Bool = { !login.isEmpty && Identity.normalize($0) == login } + let isRequested = pr.requestedReviewers.contains { byAgent($0.login) } + let latestReview = pr.reviews.filter { byAgent($0.author) } + .max { ($0.submittedAt ?? .distantPast) < ($1.submittedAt ?? .distantPast) } + let threads = pr.threads.filter { byAgent($0.author) } + let openThreads = threads.count(where: \.isOpen) + let headDate = pr.headCommittedAt ?? .distantPast + let recentComments = pr.comments.filter { byAgent($0.author) && $0.createdAt >= headDate } + let isWithinStartup = pr.headCommittedAt.map { now.timeIntervalSince($0) < startupGracePeriod } ?? false + + let hasActivity = !checks.isEmpty || isRequested || latestReview != nil || !threads.isEmpty || !recentComments.isEmpty + guard hasActivity else { + // A configured agent that hasn't reported right after a push is probably still starting. + // After the grace period it's treated as not applicable to this PR and hidden. + guard isConfigured, isWithinStartup else { return nil } + return AgentReport(id: id, name: name, state: .running, summary: "Waiting to start", + openThreads: 0, finishedAt: nil, url: nil) + } + + let failed = checks.filter { $0.outcome == .failed } + let pending = checks.filter { $0.outcome == .pending } + let finishedAt = (checks.compactMap(\.completedAt) + [latestReview?.submittedAt].compactMap(\.self)).max() + let primaryURL = (failed.first ?? pending.first ?? checks.first)?.url + + func report(_ state: AgentReport.State, _ summary: String, finished: Date? = nil) -> AgentReport { + AgentReport(id: id, name: name, state: state, summary: summary, openThreads: openThreads, + finishedAt: finished, url: primaryURL) + } + + if !failed.isEmpty { + let summary = checks.count == 1 ? Self.label(forRawState: failed[0].rawState) : "\(failed.count) of \(checks.count) failed" + return report(.failed, summary, finished: finishedAt) + } + if !pending.isEmpty { + let summary: String + if checks.count > 1 { + summary = "\(checks.count - pending.count) of \(checks.count) complete" + } else { + summary = pending[0].startedAt == nil ? "Queued" : "In progress" + } + return report(.running, summary) + } + if isRequested { + return report(.running, "Reviewing") + } + if openThreads > 0 { + return report(.needsReview, openThreads == 1 ? "1 open thread" : "\(openThreads) open threads", finished: finishedAt) + } + if latestReview?.state == .changesRequested { + return report(.needsReview, "Changes requested", finished: finishedAt) + } + + // Review-only agents (no checks) re-review on push without announcing it. A review of an + // older commit is stale: briefly assume a new one is coming. + if checks.isEmpty, let latestReview, Self.isStale(latestReview, on: pr) { + if isWithinStartup { return report(.running, "Waiting to review") } + return report(.passed, "Reviewed an earlier commit", finished: finishedAt) + } + + let summary: String + if checks.isEmpty { + summary = switch latestReview?.state { + case .approved: "Approved" + case .some: "Reviewed" + case nil: "Commented" + } + } else if checks.allSatisfy({ $0.outcome == .neutral }) { + summary = checks.count == 1 ? Self.label(forRawState: checks[0].rawState) : "Skipped" + } else { + summary = checks.count == 1 ? "Passed" : "\(checks.count) checks passed" + } + return report(.passed, summary, finished: finishedAt) + } + + private static func isStale(_ review: ReviewSignal, on pr: PullRequest) -> Bool { + if let sha = review.commitSHA { return sha != pr.headSHA } + guard let submitted = review.submittedAt, let committed = pr.headCommittedAt else { return false } + return submitted < committed + } + + // MARK: - Formatting + + static func label(forRawState raw: String) -> String { + switch raw.uppercased() { + case "SUCCESS": "Passed" + case "FAILURE": "Failed" + case "ERROR": "Errored" + case "TIMED_OUT": "Timed out" + case "ACTION_REQUIRED": "Action required" + case "STARTUP_FAILURE": "Failed to start" + case "CANCELLED": "Cancelled" + case "SKIPPED": "Skipped" + case "NEUTRAL": "Neutral" + case "STALE": "Stale" + default: raw.replacing("_", with: " ").capitalized + } + } + + static func displayName(forLogin login: String) -> String { + if let preset = Agent.presetName(forKey: login) { return preset } + let base = login.replacing("[bot]", with: "") + return base.split(separator: "-").map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: " ") + } +} diff --git a/Sources/PRMonitor/GitHub/DeviceFlow.swift b/Sources/PRMonitor/GitHub/DeviceFlow.swift new file mode 100644 index 0000000..4b07727 --- /dev/null +++ b/Sources/PRMonitor/GitHub/DeviceFlow.swift @@ -0,0 +1,109 @@ +import Foundation + +/// GitHub's OAuth device authorization flow (RFC 8628): no client secret, no redirect URI. +struct DeviceFlow: Sendable { + struct Authorization: Hashable, Sendable { + let userCode: String + let deviceCode: String + let verificationURL: URL + let expiresAt: Date + let interval: TimeInterval + } + + enum Failure: Error, Equatable, LocalizedError { + case expired + case denied + case misconfigured(String) + case transport(String) + + var errorDescription: String? { + switch self { + case .expired: "The sign-in code expired. Start again to get a new one." + case .denied: "Sign-in was cancelled on GitHub." + case let .misconfigured(detail): "GitHub rejected the OAuth app configuration: \(detail)" + case let .transport(detail): detail + } + } + } + + /// `repo` is required to read checks on private repositories. + static let scope = "repo" + + let clientID: String + var session: URLSession = .monitor + + func start() async throws(Failure) -> Authorization { + struct Payload: Decodable { + let device_code: String + let user_code: String + let verification_uri: URL + let expires_in: TimeInterval + let interval: TimeInterval? + } + let payload: Payload = try await post("https://github.com/login/device/code", ["client_id": clientID, "scope": Self.scope]) + return Authorization( + userCode: payload.user_code, + deviceCode: payload.device_code, + verificationURL: payload.verification_uri, + expiresAt: .now.addingTimeInterval(payload.expires_in), + interval: payload.interval ?? 5 + ) + } + + /// Polls until the user approves, denies, or the code expires. Cancelling the task stops polling. + func token(for authorization: Authorization) async throws(Failure) -> String { + struct Payload: Decodable { + let access_token: String? + let error: String? + let error_description: String? + let interval: TimeInterval? + } + + var interval = authorization.interval + while Date.now < authorization.expiresAt { + do { + try await Task.sleep(for: .seconds(interval)) + } catch { + throw .transport("Sign-in was cancelled.") + } + let payload: Payload = try await post("https://github.com/login/oauth/access_token", [ + "client_id": clientID, + "device_code": authorization.deviceCode, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + ]) + if let token = payload.access_token { return token } + switch payload.error { + case "authorization_pending": continue + case "slow_down": interval = payload.interval ?? interval + 5 + case "expired_token": throw .expired + case "access_denied": throw .denied + default: throw .misconfigured(payload.error_description ?? payload.error ?? "unknown error") + } + } + throw .expired + } + + private func post(_ url: String, _ form: [String: String]) async throws(Failure) -> Payload { + var components = URLComponents() + components.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) } + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + + do { + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw Failure.misconfigured("HTTP \(http.statusCode)") + } + return try JSONDecoder().decode(Payload.self, from: data) + } catch let failure as Failure { + throw failure + } catch is DecodingError { + throw .misconfigured("Unexpected response. Check the OAuth app's client ID and that device flow is enabled.") + } catch { + throw .transport(error.localizedDescription) + } + } +} diff --git a/Sources/PRMonitor/GitHub/GitHubCLI.swift b/Sources/PRMonitor/GitHub/GitHubCLI.swift new file mode 100644 index 0000000..cb5b84a --- /dev/null +++ b/Sources/PRMonitor/GitHub/GitHubCLI.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Reuses the credentials of the GitHub CLI (`gh`), which most developers already have signed in. +enum GitHubCLI { + /// GUI apps don't inherit the shell's PATH, so look in the usual install locations. + private static let candidatePaths = [ + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + "/usr/bin/gh", + "\(NSHomeDirectory())/.local/bin/gh", + ] + + static var executableURL: URL? { + candidatePaths.lazy + .filter { FileManager.default.isExecutableFile(atPath: $0) } + .map { URL(filePath: $0) } + .first + } + + static var isInstalled: Bool { executableURL != nil } + + enum Failure: Error, LocalizedError { + case notInstalled + case notSignedIn + + var errorDescription: String? { + switch self { + case .notInstalled: "The GitHub CLI isn't installed." + case .notSignedIn: "The GitHub CLI isn't signed in. Run `gh auth login` in Terminal, then try again." + } + } + } + + /// Runs `gh auth token` off the main thread. + static func token() async throws -> String { + guard let executableURL else { throw Failure.notInstalled } + return try await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = executableURL + process.arguments = ["auth", "token", "--hostname", "github.com"] + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let token = String(decoding: data, as: UTF8.self).trimmed + guard process.terminationStatus == 0, !token.isEmpty else { throw Failure.notSignedIn } + return token + }.value + } +} diff --git a/Sources/PRMonitor/GitHub/GitHubClient.swift b/Sources/PRMonitor/GitHub/GitHubClient.swift new file mode 100644 index 0000000..aa4f8c5 --- /dev/null +++ b/Sources/PRMonitor/GitHub/GitHubClient.swift @@ -0,0 +1,175 @@ +import Foundation + +/// A minimal GitHub GraphQL client. It knows about transport concerns only — auth, rate limits, +/// and error mapping — and nothing about pull requests. +struct GitHubClient: Sendable { + static let endpoint = URL(string: "https://api.github.com/graphql")! + + let token: String + var session: URLSession = .monitor + + struct Response: Sendable { + let data: Payload? + /// Errors scoped to part of the query (e.g. one repository alias), alongside partial data. + let errors: [GraphQLError] + } + + func query( + _ document: String, + variables: [String: GraphQLVariable] = [:], + as payload: Payload.Type = Payload.self + ) async throws(GitHubError) -> Response { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + do { + request.httpBody = try JSONEncoder().encode(Body(query: document, variables: variables)) + } catch { + throw .decoding("Couldn't encode the request.") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let error as URLError { + throw GitHubError(error) + } catch { + throw .network(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { throw .network("No response from GitHub.") } + try Self.validate(http, body: data) + + let envelope: Envelope + do { + envelope = try Self.decoder.decode(Envelope.self, from: data) + } catch { + throw .decoding(String(describing: error)) + } + + let errors = envelope.errors ?? [] + if errors.contains(where: { $0.type == "RATE_LIMITED" }) { + throw .rateLimited(resetAt: Self.rateLimitReset(http)) + } + guard envelope.data != nil else { + throw .graphQL(errors.map(\.message)) + } + return Response(data: envelope.data, errors: errors) + } + + // MARK: - HTTP validation + + private static func validate(_ http: HTTPURLResponse, body: Data) throws(GitHubError) { + switch http.statusCode { + case 200..<300: + return + case 401: + throw .unauthorized + case 403, 429: + // Primary limit: remaining hits zero. Secondary limit: a Retry-After header. + let remaining = http.value(forHTTPHeaderField: "x-ratelimit-remaining") + if remaining == "0" || http.value(forHTTPHeaderField: "retry-after") != nil || http.statusCode == 429 { + throw .rateLimited(resetAt: rateLimitReset(http)) + } + throw .forbidden(message(in: body) ?? "") + case 500...: + throw .server(status: http.statusCode) + default: + throw .network(message(in: body) ?? "GitHub returned HTTP \(http.statusCode).") + } + } + + private static func rateLimitReset(_ http: HTTPURLResponse) -> Date? { + if let retryAfter = http.value(forHTTPHeaderField: "retry-after").flatMap(TimeInterval.init) { + return .now.addingTimeInterval(retryAfter) + } + if let reset = http.value(forHTTPHeaderField: "x-ratelimit-reset").flatMap(TimeInterval.init) { + return Date(timeIntervalSince1970: reset) + } + return nil + } + + private static func message(in body: Data) -> String? { + struct Message: Decodable { let message: String } + return try? JSONDecoder().decode(Message.self, from: body).message + } + + // MARK: - Coding + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + static let userAgent: String = { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + return "PRMonitor/\(version)" + }() + + private struct Body: Encodable { + let query: String + let variables: [String: GraphQLVariable] + } + + private struct Envelope: Decodable { + let data: Payload? + let errors: [GraphQLError]? + } +} + +struct GraphQLError: Decodable, Sendable, Hashable { + let type: String? + let message: String + let path: [PathComponent]? + + /// The top-level field (alias) this error belongs to, e.g. `r3`. + var rootField: String? { + if case let .key(key) = path?.first { return key } + return nil + } + + enum PathComponent: Decodable, Sendable, Hashable { + case key(String) + case index(Int) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let index = try? container.decode(Int.self) { + self = .index(index) + } else { + self = .key(try container.decode(String.self)) + } + } + } +} + +enum GraphQLVariable: Encodable, Sendable, Hashable { + case string(String) + case strings([String]) + case null + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): try container.encode(value) + case let .strings(values): try container.encode(values) + case .null: try container.encodeNil() + } + } +} + +extension URLSession { + /// Short timeouts and no caching: a monitor wants fresh data or a fast failure it can retry. + static let monitor: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 60 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpAdditionalHeaders = ["X-Github-Next-Global-ID": "1"] + return URLSession(configuration: configuration) + }() +} diff --git a/Sources/PRMonitor/GitHub/GitHubError.swift b/Sources/PRMonitor/GitHub/GitHubError.swift new file mode 100644 index 0000000..df6776f --- /dev/null +++ b/Sources/PRMonitor/GitHub/GitHubError.swift @@ -0,0 +1,52 @@ +import Foundation + +enum GitHubError: Error, Hashable, Sendable, LocalizedError { + /// The token is missing, expired or revoked. + case unauthorized + case rateLimited(resetAt: Date?) + case forbidden(String) + case notFound(String) + case offline + case network(String) + case server(status: Int) + case graphQL([String]) + case decoding(String) + + var errorDescription: String? { + switch self { + case .unauthorized: + "Your GitHub session has expired. Sign in again to resume monitoring." + case let .rateLimited(resetAt): + if let resetAt { + "GitHub's rate limit was reached. Monitoring resumes \(resetAt.formatted(.relative(presentation: .named)))." + } else { + "GitHub's rate limit was reached. Monitoring resumes shortly." + } + case let .forbidden(message): + message.isEmpty ? "GitHub denied access." : message + case let .notFound(message): + message + case .offline: + "You're offline. Monitoring resumes when your connection is back." + case let .network(message): + message + case let .server(status): + "GitHub is having trouble (HTTP \(status)). Retrying automatically." + case let .graphQL(messages): + messages.first ?? "GitHub returned an error." + case let .decoding(detail): + "GitHub sent a response PR Monitor couldn't read. \(detail)" + } + } + + init(_ error: URLError) { + switch error.code { + case .notConnectedToInternet, .networkConnectionLost, .dataNotAllowed, .internationalRoamingOff: + self = .offline + case .userAuthenticationRequired: + self = .unauthorized + default: + self = .network(error.localizedDescription) + } + } +} diff --git a/Sources/PRMonitor/GitHub/PullRequestFetcher.swift b/Sources/PRMonitor/GitHub/PullRequestFetcher.swift new file mode 100644 index 0000000..3ed355e --- /dev/null +++ b/Sources/PRMonitor/GitHub/PullRequestFetcher.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Loads open pull requests for many repositories with as few GraphQL points as possible. +/// +/// Holds a cache of PR details between polls. Each poll lists every repository (cheap), then +/// re-fetches details only for PRs that changed, still have work in flight, or went stale. +actor PullRequestFetcher { + struct Result: Sendable { + var viewerLogin: String? + var repositories: [RepositoryID: RepositoryResult] + /// Remaining GraphQL points and when the window resets, for pacing the next poll. + var rateLimit: (remaining: Int, resetAt: Date)? + } + + enum RepositoryResult: Sendable { + case loaded(pullRequests: [PullRequest], totalOpen: Int) + case failed(GitHubError) + } + + private struct CacheEntry { + let fingerprint: String + let fetchedAt: Date + let pullRequest: PullRequest + } + + private var cache: [String: CacheEntry] = [:] + private let repositoriesPerListQuery = 10 + /// Details are refreshed at least this often even if nothing appears to have changed, which + /// catches events that don't bump `updatedAt` (e.g. a thread being resolved). + private let maximumDetailAge: TimeInterval = 300 + /// Right after a push, agents are still registering; keep details fresh during that window. + private let recentPushWindow: TimeInterval = 600 + + func reset() { + cache.removeAll() + } + + func fetch(_ repositories: [RepositoryID], client: GitHubClient, now: Date = .now) async throws(GitHubError) -> Result { + guard !repositories.isEmpty else { return Result(viewerLogin: nil, repositories: [:], rateLimit: nil) } + + // Phase 1: list every repository, batched with aliases. + let batches = stride(from: 0, to: repositories.count, by: repositoriesPerListQuery).map { + Array(repositories[$0..( + _ operation: () async throws(GitHubError) -> T + ) async throws(GitHubError) -> T { + do { + return try await operation() + } catch .server { + try? await Task.sleep(for: .seconds(1)) + return try await operation() + } + } + + private func needsDetail(_ item: PullRequestQueries.ListItemDTO, now: Date) -> Bool { + guard let entry = cache[item.id] else { return true } + if entry.fingerprint != item.fingerprint { return true } + if item.hasPendingChecks { return true } + if now.timeIntervalSince(entry.fetchedAt) > maximumDetailAge { return true } + let pr = entry.pullRequest + if !pr.requestedReviewers.filter(\.isBot).isEmpty { return true } + if let pushed = pr.headCommittedAt, now.timeIntervalSince(pushed) < recentPushWindow { return true } + return false + } +} diff --git a/Sources/PRMonitor/GitHub/PullRequestQueries.swift b/Sources/PRMonitor/GitHub/PullRequestQueries.swift new file mode 100644 index 0000000..143f23c --- /dev/null +++ b/Sources/PRMonitor/GitHub/PullRequestQueries.swift @@ -0,0 +1,371 @@ +import Foundation + +/// GraphQL documents and response types for monitoring pull requests. +/// +/// Fetching happens in two phases to stay far below GitHub's 5,000 points/hour budget: +/// 1. A **list** query batches many repositories into one request using aliases (`r0`, `r1`…) and +/// returns just enough to fingerprint each open PR (~1 point per repository). +/// 2. A **detail** query loads checks, reviews and threads by node ID, but only for PRs whose +/// fingerprint changed or that still have work in flight. +enum PullRequestQueries { + static let pullRequestsPerRepository = 50 + + // MARK: - List + + static func listDocument(repositoryCount: Int) -> String { + let parameters = (0.. [String: GraphQLVariable] { + var variables: [String: GraphQLVariable] = [:] + for (index, repository) in repositories.enumerated() { + variables["o\(index)"] = .string(repository.owner) + variables["n\(index)"] = .string(repository.name) + } + return variables + } + + struct ListPayload: Decodable, Sendable { + let viewerLogin: String? + let rateLimit: RateLimitDTO? + /// Keyed by alias. A `nil` value means GitHub couldn't resolve that repository. + let repositories: [String: RepositoryListDTO?] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + viewerLogin = try container.decodeIfPresent(ViewerDTO.self, forKey: DynamicKey("viewer"))?.login + rateLimit = try container.decodeIfPresent(RateLimitDTO.self, forKey: DynamicKey("rateLimit")) + var repositories: [String: RepositoryListDTO?] = [:] + for key in container.allKeys where key.stringValue.wholeMatch(of: /r\d+/) != nil { + repositories[key.stringValue] = try container.decodeIfPresent(RepositoryListDTO.self, forKey: key) + } + self.repositories = repositories + } + } + + struct RepositoryListDTO: Decodable, Sendable { + let pullRequests: Connection + } + + struct ListItemDTO: Decodable, Sendable { + let id: String + let updatedAt: Date + let headRefOid: String + let commits: Nodes> + + var rollupState: String? { commits.nodes.last?.commit.statusCheckRollup?.state } + /// Changes whenever the PR is pushed, commented on, reviewed, or its checks move. + var fingerprint: String { "\(updatedAt.timeIntervalSince1970)|\(headRefOid)|\(rollupState ?? "-")" } + /// A pending rollup means some check is still running, and per-check progress can change + /// without the fingerprint changing. + var hasPendingChecks: Bool { rollupState == "PENDING" || rollupState == "EXPECTED" } + } + + struct RollupStateCommit: Decodable, Sendable { + struct Rollup: Decodable, Sendable { let state: String } + let statusCheckRollup: Rollup? + } + + // MARK: - Detail + + static let detailBatchSize = 20 + + static let detailDocument = """ + query PRMonitorDetails($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest { + id number title url isDraft createdAt updatedAt reviewDecision mergeable headRefOid + repository { name owner { login } } + author { __typename login } + commits(last: 1) { + nodes { + commit { + committedDate + checkSuites(last: 20) { nodes { conclusion app { name slug } } } + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name status conclusion startedAt completedAt detailsUrl + checkSuite { app { name slug } } + } + ... on StatusContext { context state createdAt targetUrl creator { login } } + } + } + } + } + } + } + reviewRequests(first: 20) { + nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Mannequin { login } } } + } + latestReviews(first: 30) { nodes { author { __typename login } state submittedAt commit { oid } } } + reviewThreads(first: 100) { + nodes { isResolved isOutdated comments(first: 1) { nodes { author { __typename login } createdAt } } } + } + comments(last: 30) { nodes { author { __typename login } createdAt } } + } + } + } + """ + + struct DetailPayload: Decodable, Sendable { + /// `nil` entries are PRs that were closed or became inaccessible between the two phases. + let nodes: [PullRequestDTO?] + } + + struct PullRequestDTO: Decodable, Sendable { + struct Repository: Decodable, Sendable { + let name: String + let owner: ActorDTO + } + + struct DetailCommit: Decodable, Sendable { + struct Rollup: Decodable, Sendable { + let state: String? + let contexts: Nodes + } + struct Suite: Decodable, Sendable { + struct App: Decodable, Sendable { let name: String?; let slug: String? } + let conclusion: String? + let app: App? + } + let committedDate: Date? + let checkSuites: Nodes? + let statusCheckRollup: Rollup? + } + + struct ReviewRequest: Decodable, Sendable { let requestedReviewer: ActorDTO? } + struct Review: Decodable, Sendable { + struct Commit: Decodable, Sendable { let oid: String } + let author: ActorDTO? + let state: String + let submittedAt: Date? + let commit: Commit? + } + struct Thread: Decodable, Sendable { + let isResolved: Bool + let isOutdated: Bool + let comments: Nodes + } + struct Comment: Decodable, Sendable { + let author: ActorDTO? + let createdAt: Date + } + + let id: String + let number: Int + let title: String + let url: URL + let isDraft: Bool + let createdAt: Date + let updatedAt: Date + let reviewDecision: String? + let mergeable: String + let headRefOid: String + let repository: Repository + let author: ActorDTO? + let commits: Nodes> + let reviewRequests: Nodes + let latestReviews: Nodes + let reviewThreads: Nodes + let comments: Nodes + } + + enum ContextDTO: Decodable, Sendable { + case checkRun(name: String, status: String, conclusion: String?, startedAt: Date?, completedAt: Date?, + detailsUrl: URL?, appName: String?, appSlug: String?) + case status(context: String, state: String, createdAt: Date?, targetUrl: URL?, creator: String?) + case unknown + + private enum CodingKeys: String, CodingKey { + case __typename, name, status, conclusion, startedAt, completedAt, detailsUrl, checkSuite + case context, state, createdAt, targetUrl, creator + } + + private struct CheckSuite: Decodable { + struct App: Decodable { let name: String?; let slug: String? } + let app: App? + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(String.self, forKey: .__typename) { + case "CheckRun": + let app = try c.decodeIfPresent(CheckSuite.self, forKey: .checkSuite)?.app + self = .checkRun( + name: try c.decode(String.self, forKey: .name), + status: try c.decode(String.self, forKey: .status), + conclusion: try c.decodeIfPresent(String.self, forKey: .conclusion), + startedAt: try c.decodeIfPresent(Date.self, forKey: .startedAt), + completedAt: try c.decodeIfPresent(Date.self, forKey: .completedAt), + detailsUrl: try? c.decodeIfPresent(URL.self, forKey: .detailsUrl), + appName: app?.name, + appSlug: app?.slug + ) + case "StatusContext": + self = .status( + context: try c.decode(String.self, forKey: .context), + state: try c.decode(String.self, forKey: .state), + createdAt: try c.decodeIfPresent(Date.self, forKey: .createdAt), + targetUrl: try? c.decodeIfPresent(URL.self, forKey: .targetUrl), + creator: try c.decodeIfPresent(ActorDTO.self, forKey: .creator)?.login + ) + default: + self = .unknown + } + } + } + + // MARK: - Shared shapes + + struct ViewerDTO: Decodable, Sendable { let login: String } + + struct RateLimitDTO: Decodable, Sendable { + let remaining: Int + let resetAt: Date + } + + struct ActorDTO: Decodable, Sendable { + let __typename: String? + let login: String? + var isBot: Bool { __typename == "Bot" } + } + + struct Connection: Decodable, Sendable { + let totalCount: Int + let nodes: [Node] + } + + struct Nodes: Decodable, Sendable { + let nodes: [Node] + + init(from decoder: Decoder) throws { + // GitHub returns `null` for entries it can't resolve; drop them rather than fail the page. + let container = try decoder.container(keyedBy: DynamicKey.self) + nodes = try container.decodeIfPresent([Lossy].self, forKey: DynamicKey("nodes"))?.compactMap(\.value) ?? [] + } + } + + struct Lossy: Decodable { + let value: Value? + init(from decoder: Decoder) throws { value = try? Value(from: decoder) } + } + + struct CommitWrapper: Decodable, Sendable { let commit: Commit } + + struct DynamicKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + init(_ string: String) { stringValue = string } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { nil } + } +} + +// MARK: - Mapping into the domain + +extension PullRequestQueries.PullRequestDTO { + func toPullRequest() -> PullRequest { + let commit = commits.nodes.last?.commit + var checks: [CheckSignal] = (commit?.statusCheckRollup?.contexts.nodes ?? []).compactMap { $0.toSignal() } + // Workflows awaiting approval (e.g. from a first-time contributor's fork) have a check suite + // but no check runs, so they're invisible in the roll-up contexts. Surface them explicitly. + for suite in commit?.checkSuites?.nodes ?? [] where suite.conclusion == "ACTION_REQUIRED" { + let signal = CheckSignal( + name: suite.app?.name ?? "Workflow", source: .checkRun(appName: suite.app?.name, appSlug: suite.app?.slug), + outcome: .failed, rawState: "ACTION_REQUIRED", startedAt: nil, completedAt: nil, url: url.appending(path: "checks") + ) + if !checks.contains(where: { $0.groupKey == signal.groupKey }) { checks.append(signal) } + } + + return PullRequest( + id: id, + repository: RepositoryID(owner: repository.owner.login ?? "", name: repository.name), + number: number, + title: title, + url: url, + author: author?.login, + isDraft: isDraft, + createdAt: createdAt, + updatedAt: updatedAt, + headSHA: headRefOid, + headCommittedAt: commit?.committedDate, + rollupState: commit?.statusCheckRollup?.state, + reviewDecision: reviewDecision.flatMap(ReviewDecision.init(rawValue:)), + mergeable: Mergeable(rawValue: mergeable) ?? .unknown, + checks: checks, + requestedReviewers: reviewRequests.nodes.compactMap { request in + guard let reviewer = request.requestedReviewer, let login = reviewer.login else { return nil } + return ReviewRequestSignal(login: login, isBot: reviewer.isBot) + }, + reviews: latestReviews.nodes.compactMap { review in + guard let login = review.author?.login, let state = ReviewSignal.State(rawValue: review.state) else { return nil } + return ReviewSignal(author: login, isBot: review.author?.isBot ?? false, state: state, + submittedAt: review.submittedAt, commitSHA: review.commit?.oid) + }, + threads: reviewThreads.nodes.compactMap { thread in + guard let first = thread.comments.nodes.first, let login = first.author?.login else { return nil } + return ThreadSignal(author: login, isBot: first.author?.isBot ?? false, isResolved: thread.isResolved, + isOutdated: thread.isOutdated, createdAt: first.createdAt) + }, + comments: comments.nodes.compactMap { comment in + guard let login = comment.author?.login else { return nil } + return CommentSignal(author: login, isBot: comment.author?.isBot ?? false, createdAt: comment.createdAt) + } + ) + } +} + +extension PullRequestQueries.ContextDTO { + func toSignal() -> CheckSignal? { + switch self { + case let .checkRun(name, status, conclusion, startedAt, completedAt, detailsUrl, appName, appSlug): + let outcome: CheckSignal.Outcome = if status != "COMPLETED" { + .pending + } else { + switch conclusion { + case "SUCCESS": .succeeded + case "NEUTRAL", "SKIPPED", "CANCELLED", "STALE": .neutral + case nil: .pending + default: .failed // FAILURE, TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE + } + } + return CheckSignal(name: name, source: .checkRun(appName: appName, appSlug: appSlug), outcome: outcome, + rawState: conclusion ?? status, startedAt: startedAt, completedAt: completedAt, url: detailsUrl) + case let .status(context, state, createdAt, targetUrl, creator): + let outcome: CheckSignal.Outcome = switch state { + case "SUCCESS": .succeeded + case "PENDING", "EXPECTED": .pending + default: .failed // FAILURE, ERROR + } + // Statuses have no start/finish; `createdAt` is when this state was reported. + return CheckSignal(name: context, source: .commitStatus(creator: creator), outcome: outcome, rawState: state, + startedAt: createdAt, completedAt: outcome == .pending ? nil : createdAt, url: targetUrl) + case .unknown: + return nil + } + } +} diff --git a/Sources/PRMonitor/GitHub/ViewerQueries.swift b/Sources/PRMonitor/GitHub/ViewerQueries.swift new file mode 100644 index 0000000..e524962 --- /dev/null +++ b/Sources/PRMonitor/GitHub/ViewerQueries.swift @@ -0,0 +1,69 @@ +import Foundation + +/// A repository the signed-in user can access, for the repository picker. +struct RemoteRepository: Identifiable, Hashable, Sendable { + let id: RepositoryID + let isPrivate: Bool + let description: String? + let pushedAt: Date? +} + +extension GitHubClient { + func viewerLogin() async throws(GitHubError) -> String { + struct Payload: Decodable, Sendable { + struct Viewer: Decodable, Sendable { let login: String } + let viewer: Viewer + } + let response = try await query("query { viewer { login } }", as: Payload.self) + guard let login = response.data?.viewer.login else { throw .graphQL(["Couldn't read your GitHub account."]) } + return login + } + + /// Repositories the viewer owns, collaborates on, or can see through an organization, + /// most recently pushed first. Capped so a huge org can't stall the picker. + func viewerRepositories(limit: Int = 500) async throws(GitHubError) -> [RemoteRepository] { + struct Payload: Decodable, Sendable { + struct Viewer: Decodable, Sendable { let repositories: Page } + struct Page: Decodable, Sendable { + struct PageInfo: Decodable, Sendable { let hasNextPage: Bool; let endCursor: String? } + let pageInfo: PageInfo + let nodes: [Node] + } + struct Node: Decodable, Sendable { + let nameWithOwner: String + let isPrivate: Bool + let description: String? + let pushedAt: Date? + } + let viewer: Viewer + } + + let document = """ + query PRMonitorRepositories($cursor: String) { + viewer { + repositories(first: 100, after: $cursor, isArchived: false, + affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], + ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], + orderBy: {field: PUSHED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { nameWithOwner isPrivate description pushedAt } + } + } + } + """ + + var cursor: String? + var results: [RemoteRepository] = [] + repeat { + let response = try await query(document, variables: ["cursor": cursor.map(GraphQLVariable.string) ?? .null], as: Payload.self) + guard let page = response.data?.viewer.repositories else { break } + results += page.nodes.compactMap { node in + RepositoryID(parsing: node.nameWithOwner).map { + RemoteRepository(id: $0, isPrivate: node.isPrivate, description: node.description, pushedAt: node.pushedAt) + } + } + cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : nil + } while cursor != nil && results.count < limit + return results + } +} diff --git a/Sources/PRMonitor/Models.swift b/Sources/PRMonitor/Models.swift deleted file mode 100644 index eed1465..0000000 --- a/Sources/PRMonitor/Models.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -struct RepoConfig: Identifiable, Codable, Hashable { - var owner: String - var name: String - var isEnabled: Bool - - var id: String { "\(owner)/\(name)" } - var fullName: String { "\(owner)/\(name)" } -} - -struct AgentConfig: Identifiable, Codable, Hashable { - var id: UUID - var displayName: String - var checkNamePattern: String - var commentAuthor: String - - init(id: UUID = UUID(), displayName: String, checkNamePattern: String, commentAuthor: String) { - self.id = id - self.displayName = displayName - self.checkNamePattern = checkNamePattern - self.commentAuthor = commentAuthor - } -} - -enum AgentRunStatus: String { - case running - case waitingForComment - case failed - case done - case notFound -} - -struct AgentRun: Identifiable, Hashable { - var id: String - var displayName: String - var status: AgentRunStatus - var commentCount: Int - var checkConclusion: String? -} - -struct PRItem: Identifiable, Hashable { - var id: Int - var number: Int - var title: String - var author: String - var updatedAt: Date - var url: URL - var repoFullName: String - var agents: [AgentRun] -} - -struct RepoSection: Identifiable, Hashable { - var id: String { fullName } - var fullName: String - var prs: [PRItem] -} - -struct PRComment: Hashable { - var author: String - var createdAt: Date -} diff --git a/Sources/PRMonitor/PRMonitorApp.swift b/Sources/PRMonitor/PRMonitorApp.swift deleted file mode 100644 index 8588588..0000000 --- a/Sources/PRMonitor/PRMonitorApp.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -@main -struct PRMonitorApp: App { - @StateObject private var container: AppContainer - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - - init() { - let container = AppContainer() - _container = StateObject(wrappedValue: container) - appDelegate.container = container - } - - var body: some Scene { - Settings { - SettingsView() - .environmentObject(container.settingsStore) - .environmentObject(container.authStore) - } - } -} diff --git a/Sources/PRMonitor/Services/Account.swift b/Sources/PRMonitor/Services/Account.swift new file mode 100644 index 0000000..55f5641 --- /dev/null +++ b/Sources/PRMonitor/Services/Account.swift @@ -0,0 +1,185 @@ +import AppKit +import Foundation +import Observation + +/// The signed-in GitHub identity and the ways to obtain one. +@Observable +@MainActor +final class Account { + struct Credential: Codable, Equatable, Sendable { + enum Source: String, Codable, Sendable { + case deviceFlow, githubCLI, personalAccessToken + + var label: String { + switch self { + case .deviceFlow: "Signed in with GitHub" + case .githubCLI: "Using GitHub CLI credentials" + case .personalAccessToken: "Using a personal access token" + } + } + } + + let token: String + let source: Source + var login: String? + } + + enum State: Equatable { + case signedOut + /// Waiting for the user to enter `userCode` on github.com. + case authorizing(DeviceFlow.Authorization) + case verifying + case signedIn(Credential) + } + + private(set) var state: State = .signedOut + /// The most recent sign-in problem, shown inline in Settings. + private(set) var problem: String? + + var credential: Credential? { + if case let .signedIn(credential) = state { return credential } + return nil + } + + var isSignedIn: Bool { credential != nil } + + #if DEBUG + /// Development builds keep their own credential so they never prompt for, or overwrite, the + /// release app's keychain item (which is bound to a different code signature). + private let keychain = Keychain(service: "PRMonitor.debug") + #else + private let keychain = Keychain(service: "PRMonitor") + #endif + private let credentialAccount = "github_credential" + private let legacyTokenAccount = "github_token" + private var signInTask: Task? + + init() { + if let data = keychain.read(credentialAccount), + let credential = try? JSONDecoder().decode(Credential.self, from: data) { + state = .signedIn(credential) + } else if let legacy = keychain.read(legacyTokenAccount), let token = String(data: legacy, encoding: .utf8), !token.isEmpty { + // Migrate 0.x installs, which stored the bare device-flow token. The legacy item is + // removed on sign-out rather than here, so a downgrade keeps working. + let credential = Credential(token: token, source: .deviceFlow) + persist(credential) + state = .signedIn(credential) + } + } + + #if DEBUG + /// A signed-in account for previews and snapshots. Never touches the keychain. + init(previewLogin: String) { + state = .signedIn(Credential(token: "", source: .githubCLI, login: previewLogin)) + } + + init(previewSignedOut: Void) {} + #endif + + // MARK: - Sign in + + func signInWithDeviceFlow(clientID: String) { + guard !clientID.trimmed.isEmpty else { + problem = "Device sign-in needs a GitHub OAuth app client ID. Add one under Advanced, or use the GitHub CLI or a token." + return + } + begin { + let flow = DeviceFlow(clientID: clientID.trimmed) + let authorization = try await flow.start() + self.state = .authorizing(authorization) + // Put the code on the clipboard so the user can paste it straight into the browser. + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(authorization.userCode, forType: .string) + NSWorkspace.shared.open(authorization.verificationURL) + let token = try await flow.token(for: authorization) + try await self.complete(with: Credential(token: token, source: .deviceFlow)) + } + } + + func signInWithGitHubCLI() { + begin { + self.state = .verifying + let token = try await GitHubCLI.token() + try await self.complete(with: Credential(token: token, source: .githubCLI)) + } + } + + func signIn(personalAccessToken token: String) { + let token = token.trimmed + guard !token.isEmpty else { return } + begin { + self.state = .verifying + try await self.complete(with: Credential(token: token, source: .personalAccessToken)) + } + } + + func cancelSignIn() { + signInTask?.cancel() + signInTask = nil + if !isSignedIn { state = .signedOut } + } + + // MARK: - Sign out + + func signOut() { + cancelSignIn() + keychain.delete(credentialAccount) + keychain.delete(legacyTokenAccount) + state = .signedOut + problem = nil + } + + /// Called when GitHub rejects the stored token (revoked, expired, or scopes removed). + func sessionExpired() { + guard isSignedIn else { return } + keychain.delete(credentialAccount) + keychain.delete(legacyTokenAccount) + state = .signedOut + problem = GitHubError.unauthorized.errorDescription + } + + /// Records the viewer's login the first time the monitor learns it. + func update(login: String) { + guard case var .signedIn(credential) = state, credential.login != login else { return } + credential.login = login + persist(credential) + state = .signedIn(credential) + } + + // MARK: - Helpers + + private func begin(_ work: @escaping @MainActor () async throws -> Void) { + signInTask?.cancel() + problem = nil + signInTask = Task { + do { + try await work() + } catch is CancellationError { + // The user cancelled; nothing to report. + } catch { + guard !Task.isCancelled else { return } + self.problem = error.localizedDescription + if !self.isSignedIn { self.state = .signedOut } + } + } + } + + /// Verifies the token actually works before saving it, so a typo never looks like a sign-in. + private func complete(with credential: Credential) async throws { + var credential = credential + do { + credential.login = try await GitHubClient(token: credential.token).viewerLogin() + } catch .unauthorized { + throw GitHubError.forbidden("GitHub didn't accept that token.") + } + try Task.checkCancellation() + persist(credential) + state = .signedIn(credential) + } + + private func persist(_ credential: Credential) { + if let data = try? JSONEncoder().encode(credential) { + keychain.write(data, for: credentialAccount) + } + } +} diff --git a/Sources/PRMonitor/Services/AgentDiscoveryService.swift b/Sources/PRMonitor/Services/AgentDiscoveryService.swift deleted file mode 100644 index 10bf275..0000000 --- a/Sources/PRMonitor/Services/AgentDiscoveryService.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -final class AgentDiscoveryService { - private let client: GitHubClient - - init(client: GitHubClient) { - self.client = client - } - - func discoverAgents(repos: [RepoConfig]) async throws -> [AgentConfig] { - var candidates: [String: AgentCandidate] = [:] - - for repo in repos { - let pulls = try await client.fetchOpenPullRequests(repo: repo) - for pr in pulls { - async let checkRunsTask = client.fetchCheckRuns(owner: repo.owner, repo: repo.name, sha: pr.head.sha) - async let commentsTask = client.fetchAllPRComments(owner: repo.owner, repo: repo.name, prNumber: pr.number) - let checkRuns = try await checkRunsTask - let comments = try await commentsTask - - var localKeys: [String] = [] - - for run in checkRuns { - let displayName = run.app?.name ?? run.name - let pattern = run.app?.slug ?? run.name - let key = normalize(pattern) - guard !key.isEmpty else { continue } - localKeys.append(key) - if candidates[key] == nil { - candidates[key] = AgentCandidate(displayName: displayName, checkPattern: pattern, commentCounts: [:]) - } - } - - for comment in comments { - let commentKey = normalize(comment.author) - guard !commentKey.isEmpty else { continue } - for key in localKeys { - if matches(candidateKey: key, commentKey: commentKey) { - candidates[key, default: AgentCandidate(displayName: key, checkPattern: key, commentCounts: [:])] - .commentCounts[comment.author, default: 0] += 1 - } - } - } - } - } - - return candidates.values - .map { candidate in - let bestAuthor = candidate.commentCounts.max(by: { $0.value < $1.value })?.key ?? "" - return AgentConfig( - displayName: candidate.displayName, - checkNamePattern: candidate.checkPattern, - commentAuthor: bestAuthor - ) - } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - } - - private func normalize(_ value: String) -> String { - let lowered = value.lowercased() - let removedBot = lowered.replacingOccurrences(of: "[bot]", with: "") - let allowed = removedBot.filter { $0.isLetter || $0.isNumber } - return allowed - } - - private func matches(candidateKey: String, commentKey: String) -> Bool { - if candidateKey == commentKey { return true } - if commentKey.contains(candidateKey) { return true } - if candidateKey.contains(commentKey) { return true } - return false - } -} - -private struct AgentCandidate { - var displayName: String - var checkPattern: String - var commentCounts: [String: Int] -} diff --git a/Sources/PRMonitor/Services/AppSettings.swift b/Sources/PRMonitor/Services/AppSettings.swift new file mode 100644 index 0000000..2224969 --- /dev/null +++ b/Sources/PRMonitor/Services/AppSettings.swift @@ -0,0 +1,185 @@ +import Foundation +import Observation + +/// User preferences, persisted to `UserDefaults` as one versioned JSON document. +@Observable +@MainActor +final class AppSettings { + enum Scope: String, Codable, CaseIterable, Sendable { + case all, authored + + var title: String { + switch self { + case .all: "All" + case .authored: "Mine" + } + } + } + + static let refreshIntervals: [TimeInterval] = [30, 60, 120, 300] + + var repositories: [TrackedRepository] = [] { didSet { save() } } + var agentMode: AgentMode = .automatic { didSet { save() } } + var agents: [Agent] = [] { didSet { save() } } + var refreshInterval: TimeInterval = 60 { didSet { save() } } + var scope: Scope = .all { didSet { save() } } + var hidesDrafts = false { didSet { save() } } + var notifiesWhenSettled = true { didSet { save() } } + var notifiesOnNewFeedback = true { didSet { save() } } + var notifiesPerAgent = false { didSet { save() } } + var showsAttentionCount = true { didSet { save() } } + /// Off by default: the HIG asks menu bar extras to use template (monochrome) images. + var usesColoredStatusIcon = false { didSet { save() } } + /// Overrides the client ID bundled in Info.plist, for people who build from source. + var oauthClientIDOverride = "" { didSet { save() } } + + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private var isLoading = false + private static let storageKey = "PRMonitorSettings.v2" + private static let legacyStorageKey = "PRMonitorSettings" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + // MARK: - Derived + + var enabledRepositories: [RepositoryID] { + repositories.filter(\.isEnabled).map(\.id) + } + + var oauthClientID: String { + let override = oauthClientIDOverride.trimmed + if !override.isEmpty { return override } + return (Bundle.main.object(forInfoDictionaryKey: "GitHubOAuthClientID") as? String)?.trimmed ?? "" + } + + var statusEngine: StatusEngine { + StatusEngine(mode: agentMode, agents: agents) + } + + var notificationPreferences: NotificationPreferences { + NotificationPreferences( + whenSettled: notifiesWhenSettled, + whenFeedbackArrives: notifiesOnNewFeedback, + perAgent: notifiesPerAgent + ) + } + + // MARK: - Mutations + + @discardableResult + func track(_ repository: RepositoryID) -> Bool { + guard !repositories.contains(where: { $0.id == repository }) else { return false } + repositories.append(TrackedRepository(id: repository)) + repositories.sort { $0.id < $1.id } + return true + } + + func untrack(_ repository: RepositoryID) { + repositories.removeAll { $0.id == repository } + } + + func isTracking(_ repository: RepositoryID) -> Bool { + repositories.contains { $0.id == repository } + } + + // MARK: - Persistence + + private struct Document: Codable { + var repositories: [TrackedRepository] + var agentMode: AgentMode + var agents: [Agent] + var refreshInterval: TimeInterval + var scope: Scope + var hidesDrafts: Bool + var notifiesWhenSettled: Bool + var notifiesOnNewFeedback: Bool + var notifiesPerAgent: Bool + var showsAttentionCount: Bool + var usesColoredStatusIcon: Bool? + var oauthClientIDOverride: String + } + + private func load() { + isLoading = true + defer { isLoading = false } + + if let data = defaults.data(forKey: Self.storageKey), + let document = try? JSONDecoder().decode(Document.self, from: data) { + apply(document) + } else if let data = defaults.data(forKey: Self.legacyStorageKey), + let legacy = try? JSONDecoder().decode(LegacyDocument.self, from: data) { + migrate(legacy) + isLoading = false + save() + } + } + + private func apply(_ document: Document) { + repositories = document.repositories + agentMode = document.agentMode + agents = document.agents + refreshInterval = document.refreshInterval + scope = document.scope + hidesDrafts = document.hidesDrafts + notifiesWhenSettled = document.notifiesWhenSettled + notifiesOnNewFeedback = document.notifiesOnNewFeedback + notifiesPerAgent = document.notifiesPerAgent + showsAttentionCount = document.showsAttentionCount + usesColoredStatusIcon = document.usesColoredStatusIcon ?? false + oauthClientIDOverride = document.oauthClientIDOverride + } + + private func save() { + guard !isLoading else { return } + let document = Document( + repositories: repositories, agentMode: agentMode, agents: agents, refreshInterval: refreshInterval, + scope: scope, hidesDrafts: hidesDrafts, notifiesWhenSettled: notifiesWhenSettled, + notifiesOnNewFeedback: notifiesOnNewFeedback, notifiesPerAgent: notifiesPerAgent, + showsAttentionCount: showsAttentionCount, usesColoredStatusIcon: usesColoredStatusIcon, + oauthClientIDOverride: oauthClientIDOverride + ) + if let data = try? JSONEncoder().encode(document) { + defaults.set(data, forKey: Self.storageKey) + } + } + + // MARK: - Migration from 0.x + + private struct LegacyDocument: Decodable { + struct Repo: Decodable { var owner: String; var name: String; var isEnabled: Bool } + struct LegacyAgent: Decodable { var displayName: String; var checkNamePattern: String; var commentAuthor: String } + var repos: [Repo]? + var agents: [LegacyAgent]? + var pollingIntervalSeconds: Int? + var notifyPerAgent: Bool? + var notifySummary: Bool? + var githubClientId: String? + } + + private func migrate(_ legacy: LegacyDocument) { + repositories = (legacy.repos ?? []).map { + TrackedRepository(id: RepositoryID(owner: $0.owner, name: $0.name), isEnabled: $0.isEnabled) + } + refreshInterval = TimeInterval(legacy.pollingIntervalSeconds ?? 60) + notifiesPerAgent = legacy.notifyPerAgent ?? false + notifiesWhenSettled = legacy.notifySummary ?? true + oauthClientIDOverride = legacy.githubClientId ?? "" + + // 0.x seeded Vercel, Cursor and Devin for everyone. If the list is untouched, automatic + // mode tracks those and more; if it was customized, keep the user's list. + let seeded: Set = ["vercel", "cursor", "devin"] + let legacyAgents = legacy.agents ?? [] + let isUntouched = Set(legacyAgents.map { $0.checkNamePattern.lowercased() }) == seeded && legacyAgents.count == seeded.count + if legacyAgents.isEmpty || isUntouched { + agentMode = .automatic + } else { + agentMode = .custom + agents = legacyAgents.map { + Agent(name: $0.displayName, checkPattern: $0.checkNamePattern, login: $0.commentAuthor) + } + } + } +} diff --git a/Sources/PRMonitor/Services/GitHubAuthService.swift b/Sources/PRMonitor/Services/GitHubAuthService.swift deleted file mode 100644 index 6bb58af..0000000 --- a/Sources/PRMonitor/Services/GitHubAuthService.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation - -enum GitHubAuthError: Error, LocalizedError { - case invalidResponse - case expired - case accessDenied - case slowDown - - var errorDescription: String? { - switch self { - case .invalidResponse: - return "Invalid response from GitHub." - case .expired: - return "Device code expired." - case .accessDenied: - return "Access denied." - case .slowDown: - return "GitHub asked to slow down polling." - } - } -} - -final class GitHubAuthService { - private let session = URLSession.shared - - func startDeviceFlow(clientId: String) async throws -> DeviceFlowState { - guard let url = URL(string: "https://github.com/login/device/code") else { - throw GitHubAuthError.invalidResponse - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - let body = "client_id=\(clientId)&scope=repo" - request.httpBody = body.data(using: .utf8) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - let (data, _) = try await session.data(for: request) - let payload = try parseFormEncoded(data) - guard - let deviceCode = payload["device_code"], - let userCode = payload["user_code"], - let verificationURI = payload["verification_uri"], - let expiresIn = payload["expires_in"], - let interval = payload["interval"], - let verificationURL = URL(string: verificationURI), - let expiresSeconds = Double(expiresIn), - let intervalSeconds = Int(interval) - else { - throw GitHubAuthError.invalidResponse - } - - return DeviceFlowState( - userCode: userCode, - deviceCode: deviceCode, - verificationURL: verificationURL, - expiresAt: Date().addingTimeInterval(expiresSeconds), - intervalSeconds: intervalSeconds - ) - } - - func pollForToken(clientId: String, deviceCode: String, intervalSeconds: Int, expiresAt: Date) async throws -> String { - guard let url = URL(string: "https://github.com/login/oauth/access_token") else { - throw GitHubAuthError.invalidResponse - } - - var nextInterval = intervalSeconds - - while Date() < expiresAt { - try await Task.sleep(nanoseconds: UInt64(nextInterval) * 1_000_000_000) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - let body = "client_id=\(clientId)&device_code=\(deviceCode)&grant_type=urn:ietf:params:oauth:grant-type:device_code" - request.httpBody = body.data(using: .utf8) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - let (data, _) = try await session.data(for: request) - let payload = try parseFormEncoded(data) - - if let token = payload["access_token"] { - return token - } - - if let error = payload["error"] { - switch error { - case "authorization_pending": - continue - case "slow_down": - nextInterval += 5 - continue - case "access_denied": - throw GitHubAuthError.accessDenied - case "expired_token": - throw GitHubAuthError.expired - default: - throw GitHubAuthError.invalidResponse - } - } - } - - throw GitHubAuthError.expired - } - - private func parseFormEncoded(_ data: Data) throws -> [String: String] { - guard let text = String(data: data, encoding: .utf8) else { - throw GitHubAuthError.invalidResponse - } - var result: [String: String] = [:] - for pair in text.split(separator: "&") { - let parts = pair.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - let key = String(parts[0]) - let value = String(parts[1]) - result[key] = value.removingPercentEncoding ?? value - } - return result - } -} diff --git a/Sources/PRMonitor/Services/GitHubClient.swift b/Sources/PRMonitor/Services/GitHubClient.swift deleted file mode 100644 index 78ff634..0000000 --- a/Sources/PRMonitor/Services/GitHubClient.swift +++ /dev/null @@ -1,404 +0,0 @@ -import Foundation - -enum GitHubClientError: Error, LocalizedError { - case missingToken - case invalidResponse - case rateLimited(reset: Date?) - case httpError(status: Int) - - var errorDescription: String? { - switch self { - case .missingToken: - return "Missing GitHub token." - case .invalidResponse: - return "Unexpected GitHub response." - case .rateLimited(let reset): - if let reset { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - let relative = formatter.localizedString(for: reset, relativeTo: Date()) - return "GitHub API rate limit exceeded. Try again \(relative)." - } - return "GitHub API rate limit exceeded. Try again later." - case .httpError(let status): - return "GitHub API returned HTTP \(status)." - } - } -} - -final class GitHubClient { - private let session: URLSession - private let tokenProvider: () -> String? - private let decoder: JSONDecoder - private let pathSegmentAllowedCharacters: CharacterSet - - init(session: URLSession = .shared, tokenProvider: @escaping () -> String?) { - self.session = session - self.tokenProvider = tokenProvider - self.decoder = JSONDecoder() - self.decoder.dateDecodingStrategy = .iso8601 - var allowed = CharacterSet.urlPathAllowed - allowed.remove(charactersIn: "/") - self.pathSegmentAllowedCharacters = allowed - } - - func fetchOpenPullRequests(repo: RepoConfig) async throws -> [PullRequestDTO] { - var page = 1 - var pulls: [PullRequestDTO] = [] - while true { - let url = try apiURL( - pathSegments: ["repos", repo.owner, repo.name, "pulls"], - queryItems: [ - URLQueryItem(name: "state", value: "open"), - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)) - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let batch = try decoder.decode([PullRequestDTO].self, from: data) - pulls.append(contentsOf: batch) - if batch.count < 100 { break } - page += 1 - } - return pulls - } - - func fetchCheckRuns(owner: String, repo: String, sha: String) async throws -> [CheckRunDTO] { - var page = 1 - var runs: [CheckRunDTO] = [] - var totalCount: Int? - while true { - let url = try apiURL( - pathSegments: ["repos", owner, repo, "commits", sha, "check-runs"], - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)), - URLQueryItem(name: "filter", value: "latest") - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let payload = try decoder.decode(CheckRunsResponse.self, from: data) - totalCount = payload.totalCount - runs.append(contentsOf: payload.checkRuns) - if payload.checkRuns.count < 100 { break } - if let totalCount, runs.count >= totalCount { break } - page += 1 - } - return runs - } - - func fetchReviewComments(owner: String, repo: String, prNumber: Int) async throws -> [PRComment] { - var page = 1 - var comments: [PRComment] = [] - while true { - let url = try apiURL( - pathSegments: ["repos", owner, repo, "pulls", String(prNumber), "comments"], - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)) - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let batch = try decoder.decode([ReviewCommentDTO].self, from: data) - comments.append(contentsOf: batch.map { PRComment(author: $0.user.login, createdAt: $0.createdAt) }) - if batch.count < 100 { break } - page += 1 - } - return comments - } - - func fetchIssueComments(owner: String, repo: String, prNumber: Int) async throws -> [PRComment] { - var page = 1 - var comments: [PRComment] = [] - while true { - let url = try apiURL( - pathSegments: ["repos", owner, repo, "issues", String(prNumber), "comments"], - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)) - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let batch = try decoder.decode([IssueCommentDTO].self, from: data) - comments.append(contentsOf: batch.map { PRComment(author: $0.user.login, createdAt: $0.createdAt) }) - if batch.count < 100 { break } - page += 1 - } - return comments - } - - func fetchPullRequestReviews(owner: String, repo: String, prNumber: Int) async throws -> [PRComment] { - var page = 1 - var comments: [PRComment] = [] - while true { - let url = try apiURL( - pathSegments: ["repos", owner, repo, "pulls", String(prNumber), "reviews"], - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)) - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let batch = try decoder.decode([PullRequestReviewDTO].self, from: data) - comments.append(contentsOf: batch.compactMap { review in - guard let submittedAt = review.submittedAt else { return nil } - let body = review.body?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !body.isEmpty else { return nil } - return PRComment(author: review.user.login, createdAt: submittedAt) - }) - if batch.count < 100 { break } - page += 1 - } - return comments - } - - func fetchAllPRComments(owner: String, repo: String, prNumber: Int) async throws -> [PRComment] { - async let reviewComments = fetchReviewComments(owner: owner, repo: repo, prNumber: prNumber) - async let issueComments = fetchIssueComments(owner: owner, repo: repo, prNumber: prNumber) - async let reviews = fetchPullRequestReviews(owner: owner, repo: repo, prNumber: prNumber) - let resolvedReviewComments = try await reviewComments - let resolvedIssueComments = try await issueComments - let resolvedReviews = try await reviews - return resolvedReviewComments + resolvedIssueComments + resolvedReviews - } - - func fetchViewerLogin() async throws -> String { - let url = try apiURL(pathSegments: ["user"]) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - let payload = try decoder.decode(ViewerDTO.self, from: data) - return payload.login - } - - func fetchViewerRepos(page: Int) async throws -> [RepoDTO] { - let url = try apiURL( - pathSegments: ["user", "repos"], - queryItems: [ - URLQueryItem(name: "per_page", value: "100"), - URLQueryItem(name: "page", value: String(page)), - URLQueryItem(name: "affiliation", value: "owner,collaborator,organization_member"), - URLQueryItem(name: "sort", value: "updated") - ] - ) - let request = try makeRequest(url: url) - let (data, response) = try await session.data(for: request) - try validate(response: response) - return try decoder.decode([RepoDTO].self, from: data) - } - - func fetchAllViewerRepos() async throws -> [RepoDTO] { - var page = 1 - var repos: [RepoDTO] = [] - while true { - let batch = try await fetchViewerRepos(page: page) - if batch.isEmpty { break } - repos.append(contentsOf: batch) - if batch.count < 100 { break } - page += 1 - } - return repos - } - - private func makeRequest(url: URL) throws -> URLRequest { - guard let token = tokenProvider() else { - throw GitHubClientError.missingToken - } - 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") - request.setValue("PRMonitor", forHTTPHeaderField: "User-Agent") - return request - } - - private func apiURL(pathSegments: [String], queryItems: [URLQueryItem] = []) throws -> URL { - let encodedSegments = pathSegments.compactMap { - $0.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowedCharacters) - } - guard encodedSegments.count == pathSegments.count else { - throw GitHubClientError.invalidResponse - } - var components = URLComponents() - components.scheme = "https" - components.host = "api.github.com" - components.percentEncodedPath = "/" + encodedSegments.joined(separator: "/") - components.queryItems = queryItems.isEmpty ? nil : queryItems - guard let url = components.url else { - throw GitHubClientError.invalidResponse - } - return url - } - - private func validate(response: URLResponse) throws { - guard let http = response as? HTTPURLResponse else { - throw GitHubClientError.invalidResponse - } - guard (200...299).contains(http.statusCode) else { - if http.statusCode == 403 || http.statusCode == 429 { - let remaining = http.value(forHTTPHeaderField: "X-RateLimit-Remaining") - let retryAfter = http.value(forHTTPHeaderField: "Retry-After") - let reset = http.value(forHTTPHeaderField: "X-RateLimit-Reset") - - let resetDate: Date? = { - if let retryAfter, let seconds = TimeInterval(retryAfter) { - return Date().addingTimeInterval(seconds) - } - if let reset, let timestamp = TimeInterval(reset) { - return Date(timeIntervalSince1970: timestamp) - } - return nil - }() - - if remaining == "0" || http.statusCode == 429 { - throw GitHubClientError.rateLimited(reset: resetDate) - } - } - throw GitHubClientError.httpError(status: http.statusCode) - } - } -} - -struct PullRequestDTO: Decodable { - struct User: Decodable { - var login: String - } - struct Head: Decodable { - var sha: String - } - - var number: Int - var title: String - var user: User - var updatedAt: Date - var htmlURL: String - var head: Head - - enum CodingKeys: String, CodingKey { - case number - case title - case user - case updatedAt = "updated_at" - case htmlURL = "html_url" - case head - } -} - -struct CheckRunsResponse: Decodable { - var checkRuns: [CheckRunDTO] - var totalCount: Int - - enum CodingKeys: String, CodingKey { - case checkRuns = "check_runs" - case totalCount = "total_count" - } -} - -struct CheckRunDTO: Decodable { - struct App: Decodable { - var name: String? - var slug: String? - } - - var name: String - var status: String - var conclusion: String? - var createdAt: Date? - var startedAt: Date? - var completedAt: Date? - var app: App? - - enum CodingKeys: String, CodingKey { - case name - case status - case conclusion - case createdAt = "created_at" - case startedAt = "started_at" - case completedAt = "completed_at" - case app - } -} - -struct ReviewCommentDTO: Decodable { - struct User: Decodable { - var login: String - } - - var id: Int - var user: User - var createdAt: Date - - enum CodingKeys: String, CodingKey { - case id - case user - case createdAt = "created_at" - } -} - -struct IssueCommentDTO: Decodable { - struct User: Decodable { - var login: String - } - - var id: Int - var user: User - var createdAt: Date - - enum CodingKeys: String, CodingKey { - case id - case user - case createdAt = "created_at" - } -} - -struct PullRequestReviewDTO: Decodable { - struct User: Decodable { - var login: String - } - - var id: Int - var user: User - var submittedAt: Date? - var body: String? - - enum CodingKeys: String, CodingKey { - case id - case user - case submittedAt = "submitted_at" - case body - } -} - -struct ViewerDTO: Decodable { - var login: String -} - -struct RepoDTO: Decodable, Identifiable, Hashable { - struct Owner: Decodable, Hashable { - var login: String - } - - var name: String - var fullName: String - var owner: Owner - var isPrivate: Bool - - var id: String { fullName } - - enum CodingKeys: String, CodingKey { - case name - case fullName = "full_name" - case owner - case isPrivate = "private" - } -} diff --git a/Sources/PRMonitor/Services/Keychain.swift b/Sources/PRMonitor/Services/Keychain.swift new file mode 100644 index 0000000..c71b56a --- /dev/null +++ b/Sources/PRMonitor/Services/Keychain.swift @@ -0,0 +1,38 @@ +import Foundation +import Security + +/// Stores small secrets as generic passwords in the user's login keychain. +struct Keychain: Sendable { + let service: String + + func read(_ account: String) -> Data? { + var query = baseQuery(account) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + @discardableResult + func write(_ data: Data, for account: String) -> Bool { + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + ] + let status = SecItemUpdate(baseQuery(account) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + let insert = baseQuery(account).merging(attributes) { _, new in new } + return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess + } + return status == errSecSuccess + } + + func delete(_ account: String) { + SecItemDelete(baseQuery(account) as CFDictionary) + } + + private func baseQuery(_ account: String) -> [CFString: Any] { + [kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account] + } +} diff --git a/Sources/PRMonitor/Services/Monitor.swift b/Sources/PRMonitor/Services/Monitor.swift new file mode 100644 index 0000000..0af0aea --- /dev/null +++ b/Sources/PRMonitor/Services/Monitor.swift @@ -0,0 +1,340 @@ +import Foundation +import Observation + +/// Owns the polling loop and the current picture of every monitored pull request. +/// +/// Data flow, one direction only: +/// `PullRequestFetcher` (network) → raw `PullRequest`s → `StatusEngine` (pure) → `PullRequestReport`s +/// → views, and → `NotificationPlanner` (pure) → `Notifier`. +@Observable +@MainActor +final class Monitor { + struct Section: Identifiable, Hashable { + let repository: RepositoryID + var reports: [PullRequestReport] + var totalOpen: Int + /// Set when the last poll failed for this repository; `reports` then holds the last good data. + var error: GitHubError? + + var id: RepositoryID { repository } + } + + /// What the menu bar icon should convey, most urgent first. + enum Summary: Equatable { + case signedOut + case needsRepositories + case unavailable(GitHubError) + case failing + case needsReview + case running + case ready + case idle + } + + private(set) var sections: [Section] = [] + private(set) var lastUpdated: Date? + private(set) var isRefreshing = false + private(set) var lastError: GitHubError? + + let settings: AppSettings + let account: Account + private let notifier: Notifier + private let fetcher = PullRequestFetcher() + private let system = SystemEvents() + + @ObservationIgnored private var planner = NotificationPlanner() + @ObservationIgnored private var pullRequests: [RepositoryID: (items: [PullRequest], totalOpen: Int)] = [:] + @ObservationIgnored private var repositoryErrors: [RepositoryID: GitHubError] = [:] + @ObservationIgnored private var loopTask: Task? + @ObservationIgnored private var sleepTask: Task? + @ObservationIgnored private var observerTasks: [Task] = [] + @ObservationIgnored private var isRefreshQueued = false + @ObservationIgnored private var consecutiveFailures = 0 + + /// How long to wait when there's nothing to do until something changes (sign-in, settings, network). + private let dormantDelay: TimeInterval = 3600 + + init(settings: AppSettings, account: Account, notifier: Notifier) { + self.settings = settings + self.account = account + self.notifier = notifier + } + + // MARK: - Lifecycle + + func start() { + guard loopTask == nil else { return } + notifier.activate() + system.start { [weak self] in + // Give Wi-Fi a moment to reassociate after wake before hitting the network. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + self?.refresh() + } + } + observeConfiguration() + loopTask = Task { [weak self] in + while !Task.isCancelled { + guard let delay = await self?.poll() else { return } + await self?.sleep(for: delay) + } + } + } + + /// Polls now. Coalesces with a poll that's already in flight rather than starting a second one. + func refresh() { + if isRefreshing { + isRefreshQueued = true + } else { + sleepTask?.cancel() + } + } + + /// Called when the menu bar panel opens: cached data shows instantly, and is refreshed if old. + func refreshIfStale(maximumAge: TimeInterval = 30) { + guard let lastUpdated, Date.now.timeIntervalSince(lastUpdated) < maximumAge else { + refresh() + return + } + } + + // MARK: - Derived state + + /// Sections filtered by the user's display preferences, with empty repositories removed. + var visibleSections: [Section] { + sections.compactMap { section in + var section = section + section.reports = section.reports.filter(isVisible) + return section.reports.isEmpty && section.error == nil ? nil : section + } + } + + var visibleReports: [PullRequestReport] { + visibleSections.flatMap(\.reports) + } + + var attentionCount: Int { + visibleReports.count { $0.status.needsAttention } + } + + var summary: Summary { + guard account.isSignedIn else { return .signedOut } + guard !settings.enabledRepositories.isEmpty else { return .needsRepositories } + let reports = visibleReports + if let lastError, reports.isEmpty { return .unavailable(lastError) } + let top = reports.map(\.status).max { $0.urgency < $1.urgency } + return switch top { + case .failing: .failing + case .needsReview: .needsReview + case .running: .running + case .ready: .ready + case .quiet, nil: .idle + } + } + + /// Bots and integrations seen on current PRs, offered as suggestions when configuring agents. + /// Built from the raw signals so suggestions carry real logins and app slugs, not normalized keys. + var discoveredAgents: [Agent] { + var found: [String: Agent] = [:] + func add(key: String, name: String, checkPattern: String = "", login: String = "") { + var agent = found[key] ?? Agent(name: name) + if agent.checkPattern.isEmpty { agent.checkPattern = checkPattern } + if agent.login.isEmpty { agent.login = login } + found[key] = agent + } + for pr in pullRequests.values.flatMap(\.items) { + for check in pr.checks { + let pattern: String = switch check.source { + case let .checkRun(appName, appSlug): appSlug ?? appName ?? check.name + case let .commitStatus(creator): creator ?? check.name + } + add(key: check.groupKey, name: check.groupDisplayName, checkPattern: pattern) + } + let bots = pr.requestedReviewers.filter(\.isBot).map(\.login) + + pr.reviews.filter(\.isBot).map(\.author) + + pr.threads.filter(\.isBot).map(\.author) + for login in bots { + add(key: Identity.normalize(login), name: StatusEngine.displayName(forLogin: login), login: login) + } + } + return found.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + private func isVisible(_ report: PullRequestReport) -> Bool { + let pr = report.pullRequest + if settings.hidesDrafts, pr.isDraft { return false } + if settings.scope == .authored { + guard let viewer = account.credential?.login, let author = pr.author else { return false } + return Identity.normalize(viewer) == Identity.normalize(author) + } + return true + } + + // MARK: - Polling + + /// Performs one poll and returns how long to wait before the next. + private func poll() async -> TimeInterval { + guard let credential = account.credential else { + clear() + return dormantDelay + } + let repositories = settings.enabledRepositories + guard !repositories.isEmpty else { + clear() + return dormantDelay + } + guard system.isOnline else { + lastError = .offline + return dormantDelay // The network monitor wakes us when the connection returns. + } + + isRefreshing = true + defer { + isRefreshing = false + } + + var policy = PollingPolicy(activeInterval: settings.refreshInterval) + if system.isConstrained { policy.activeInterval = max(policy.activeInterval, 300) } + + let result: PullRequestFetcher.Result + do { + result = try await fetcher.fetch(repositories, client: GitHubClient(token: credential.token)) + } catch .unauthorized { + account.sessionExpired() + await fetcher.reset() + return dormantDelay + } catch let .rateLimited(resetAt) { + lastError = .rateLimited(resetAt: resetAt) + return policy.delay(after: .rateLimited(resetAt: resetAt)) + } catch { + consecutiveFailures += 1 + lastError = error + return nextDelay(policy.delay(after: .failure(consecutive: consecutiveFailures))) + } + + consecutiveFailures = 0 + lastError = nil + lastUpdated = .now + if let login = result.viewerLogin { account.update(login: login) } + + // Forget repositories that are no longer tracked; keep last good data for ones that failed. + let tracked = Set(repositories) + pullRequests = pullRequests.filter { tracked.contains($0.key) } + repositoryErrors.removeAll() + for (repository, outcome) in result.repositories { + switch outcome { + case let .loaded(items, totalOpen): pullRequests[repository] = (items, totalOpen) + case let .failed(error): repositoryErrors[repository] = error + } + } + rebuild() + + let planned = planner.plan(for: visibleReports, preferences: settings.notificationPreferences) + await notifier.post(planned) + + // Leave headroom in the hourly budget for the user's other tools (gh, IDEs, scripts). + if let limit = result.rateLimit, limit.remaining < 250 { + return nextDelay(policy.delay(after: .rateLimited(resetAt: limit.resetAt))) + } + let reports = sections.flatMap(\.reports) + return nextDelay(policy.delay(after: .success( + hasRunningPullRequests: reports.contains { !$0.isSettled }, + hasOpenPullRequests: !reports.isEmpty + ))) + } + + private func nextDelay(_ delay: TimeInterval) -> TimeInterval { + guard isRefreshQueued else { return delay } + isRefreshQueued = false + return 0 + } + + /// Re-derives every report from the raw data. Cheap, so it also runs when agent settings change. + private func rebuild() { + let engine = settings.statusEngine + let now = Date.now + let repositories = Set(pullRequests.keys).union(repositoryErrors.keys) + sections = repositories.sorted().map { repository in + let data = pullRequests[repository] + return Section( + repository: repository, + reports: (data?.items ?? []).map { engine.report(for: $0, now: now) }, + totalOpen: data?.totalOpen ?? 0, + error: repositoryErrors[repository] + ) + } + } + + private func clear() { + sections = [] + pullRequests = [:] + repositoryErrors = [:] + lastError = nil + lastUpdated = nil + } + + private func sleep(for seconds: TimeInterval) async { + guard seconds > 0 else { return } + let task = Task { _ = try? await Task.sleep(for: .seconds(seconds)) } + sleepTask = task + await task.value + sleepTask = nil + } + + // MARK: - Reacting to configuration + + private struct FetchConfiguration: Equatable, Sendable { + let token: String? + let repositories: [RepositoryID] + let interval: TimeInterval + } + + private struct EngineConfiguration: Equatable, Sendable { + let mode: AgentMode + let agents: [Agent] + } + + private func observeConfiguration() { + // Anything that changes *what* we fetch triggers an immediate poll. + observerTasks.append(Task { [weak self] in + let changes = Observations { [weak self] in + FetchConfiguration( + token: self?.account.credential?.token, + repositories: self?.settings.enabledRepositories ?? [], + interval: self?.settings.refreshInterval ?? 60 + ) + } + var previous: FetchConfiguration? + for await configuration in changes { + defer { previous = configuration } + guard let previous, previous != configuration else { continue } + if previous.token != configuration.token { await self?.fetcher.reset() } + self?.refresh() + } + }) + + // Changes to how agents are interpreted only need a re-derivation, not a network round trip. + observerTasks.append(Task { [weak self] in + let changes = Observations { [weak self] in + EngineConfiguration(mode: self?.settings.agentMode ?? .automatic, agents: self?.settings.agents ?? []) + } + var previous: EngineConfiguration? + for await configuration in changes { + defer { previous = configuration } + guard previous != nil, previous != configuration else { continue } + self?.rebuild() + } + }) + } +} + +#if DEBUG +extension Monitor { + /// Shows fixed data without polling, for previews and snapshots. + func loadPreview(_ items: [PullRequest]) { + pullRequests = Dictionary(grouping: items, by: \.repository).mapValues { ($0, $0.count) } + rebuild() + lastUpdated = .now.addingTimeInterval(-45) + } +} +#endif diff --git a/Sources/PRMonitor/Services/Notifier.swift b/Sources/PRMonitor/Services/Notifier.swift new file mode 100644 index 0000000..1f1667a --- /dev/null +++ b/Sources/PRMonitor/Services/Notifier.swift @@ -0,0 +1,97 @@ +import AppKit +@preconcurrency import UserNotifications + +/// Posts notifications and opens the pull request when one is clicked. +@MainActor +final class Notifier: NSObject, UNUserNotificationCenterDelegate { + nonisolated private static let urlKey = "url" + private static let categoryID = "pull-request" + private static let openActionID = "open" + + /// Notification Center only works inside an app bundle (not `swift run`, previews, or tests). + static var isAvailable: Bool { + let environment = ProcessInfo.processInfo.environment + guard environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil, environment["XCTestConfigurationFilePath"] == nil else { + return false + } + return Bundle.main.bundleURL.pathExtension == "app" + } + + private var center: UNUserNotificationCenter? { + Self.isAvailable ? .current() : nil + } + + func activate() { + guard let center else { return } + center.delegate = self + let open = UNNotificationAction(identifier: Self.openActionID, title: "Open Pull Request", options: [.foreground]) + center.setNotificationCategories([ + UNNotificationCategory(identifier: Self.categoryID, actions: [open], intentIdentifiers: []), + ]) + } + + /// Asks for permission in context: the first time there is something worth notifying about, + /// rather than on first launch. + func requestAuthorizationIfNeeded() async { + guard let center else { return } + let settings = await center.notificationSettings() + if settings.authorizationStatus == .notDetermined { + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + } + } + + func authorizationStatus() async -> UNAuthorizationStatus? { + await center?.notificationSettings().authorizationStatus + } + + func post(_ notifications: [PlannedNotification]) async { + guard let center, !notifications.isEmpty else { return } + await requestAuthorizationIfNeeded() + for planned in notifications { + let content = UNMutableNotificationContent() + content.title = planned.title + content.subtitle = planned.subtitle + content.body = planned.body + content.sound = .default + content.threadIdentifier = planned.key.description + content.categoryIdentifier = Self.categoryID + content.userInfo = [Self.urlKey: planned.url.absoluteString] + try? await center.add(UNNotificationRequest(identifier: planned.identifier, content: content, trigger: nil)) + } + } + + func postTest() async -> Bool { + guard let center else { return false } + await requestAuthorizationIfNeeded() + let content = UNMutableNotificationContent() + content.title = "PR Monitor" + content.subtitle = "Notifications are working" + content.body = "You'll hear from PR Monitor when your pull requests are ready for review." + content.sound = .default + do { + try await center.add(UNNotificationRequest(identifier: "test", content: content, trigger: nil)) + return true + } catch { + return false + } + } + + // MARK: - UNUserNotificationCenterDelegate + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + guard response.actionIdentifier != UNNotificationDismissActionIdentifier, + let string = response.notification.request.content.userInfo[Self.urlKey] as? String, + let url = URL(string: string) else { return } + await MainActor.run { _ = NSWorkspace.shared.open(url) } + } +} diff --git a/Sources/PRMonitor/Services/PollingService.swift b/Sources/PRMonitor/Services/PollingService.swift deleted file mode 100644 index c9892d5..0000000 --- a/Sources/PRMonitor/Services/PollingService.swift +++ /dev/null @@ -1,218 +0,0 @@ -import Foundation - -final class PollingService { - let client: GitHubClient - private let repoConcurrencyLimit = 4 - private let prConcurrencyLimit = 6 - - init(client: GitHubClient) { - self.client = client - } - - func fetchRepoSections(repos: [RepoConfig], agents: [AgentConfig]) async throws -> [RepoSection] { - guard !repos.isEmpty else { return [] } - - let repoSemaphore = AsyncSemaphore(value: repoConcurrencyLimit) - return try await withThrowingTaskGroup(of: RepoSection?.self) { group in - for repo in repos { - group.addTask { - await repoSemaphore.wait() - defer { repoSemaphore.signal() } - let pulls = try await self.client.fetchOpenPullRequests(repo: repo) - let prs = try await self.buildPRItems(repo: repo, pulls: pulls, agents: agents) - return RepoSection(fullName: repo.fullName, prs: prs) - } - } - - var sections: [RepoSection] = [] - for try await section in group { - if let section = section { - sections.append(section) - } - } - return sections.sorted { $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending } - } - } - - private func buildPRItems(repo: RepoConfig, pulls: [PullRequestDTO], agents: [AgentConfig]) async throws -> [PRItem] { - let prSemaphore = AsyncSemaphore(value: prConcurrencyLimit) - return try await withThrowingTaskGroup(of: PRItem?.self) { group in - for pr in pulls { - group.addTask { - await prSemaphore.wait() - defer { prSemaphore.signal() } - async let checkRuns = self.client.fetchCheckRuns(owner: repo.owner, repo: repo.name, sha: pr.head.sha) - async let comments = self.client.fetchAllPRComments(owner: repo.owner, repo: repo.name, prNumber: pr.number) - let resolvedCheckRuns = try await checkRuns - let resolvedComments = try await comments - let agentRuns = self.buildAgentRuns(checkRuns: resolvedCheckRuns, comments: resolvedComments, agents: agents) - return PRItem( - id: pr.number, - number: pr.number, - title: pr.title, - author: pr.user.login, - updatedAt: pr.updatedAt, - url: URL(string: pr.htmlURL) ?? URL(string: "https://github.com")!, - repoFullName: repo.fullName, - agents: agentRuns - ) - } - } - - var items: [PRItem] = [] - for try await item in group { - if let item = item { - items.append(item) - } - } - return items.sorted { $0.updatedAt > $1.updatedAt } - } - } - - private func buildAgentRuns(checkRuns: [CheckRunDTO], comments: [PRComment], agents: [AgentConfig]) -> [AgentRun] { - if agents.isEmpty { - return inferAgentRuns(checkRuns: checkRuns, comments: comments) - } - - return agents.map { agent in - let matchingRuns = checkRuns.filter { matches(checkRun: $0, agent: agent) } - let bestRun = latestRun(from: matchingRuns) - let since = bestRun?.startedAt ?? bestRun?.createdAt ?? bestRun?.completedAt - let commentCount = commentCountForAgent(agent: agent, comments: comments, since: since) - - guard let run = bestRun else { - let status: AgentRunStatus = commentCount > 0 ? .waitingForComment : .notFound - return AgentRun(id: agent.id.uuidString, displayName: agent.displayName, status: status, commentCount: commentCount, checkConclusion: nil) - } - - let status = agentStatus(run: run, commentCount: commentCount) - return AgentRun(id: agent.id.uuidString, displayName: agent.displayName, status: status, commentCount: commentCount, checkConclusion: run.conclusion) - } - } - - private func inferAgentRuns(checkRuns: [CheckRunDTO], comments: [PRComment]) -> [AgentRun] { - var seen: Set = [] - var runs: [AgentRun] = [] - - for run in checkRuns { - let key = runKey(run) - guard !seen.contains(key) else { continue } - seen.insert(key) - let display = runDisplayName(run) - - let since = run.startedAt ?? run.createdAt ?? run.completedAt - let commentCount = inferCommentCount(app: run.app, comments: comments, since: since) - let status = agentStatus(run: run, commentCount: commentCount) - runs.append(AgentRun(id: display, displayName: display, status: status, commentCount: commentCount, checkConclusion: run.conclusion)) - } - - return runs.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - } - - private func matches(checkRun: CheckRunDTO, agent: AgentConfig) -> Bool { - let pattern = agent.checkNamePattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if !pattern.isEmpty { - if checkRun.name.lowercased().contains(pattern) { - return true - } - if let appSlug = checkRun.app?.slug?.lowercased(), appSlug.contains(pattern) { - return true - } - if let appName = checkRun.app?.name?.lowercased(), appName.contains(pattern) { - return true - } - } - - let commentAuthor = agent.commentAuthor.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if !commentAuthor.isEmpty, let appSlug = checkRun.app?.slug?.lowercased() { - if commentAuthor.hasPrefix(appSlug) { - return true - } - } - - return false - } - - private func agentStatus(run: CheckRunDTO, commentCount: Int) -> AgentRunStatus { - if run.status != "completed" { - return .running - } - if isFailureConclusion(run.conclusion) { - return .failed - } - if commentCount > 0 { - return .waitingForComment - } - return .done - } - - private func isFailureConclusion(_ conclusion: String?) -> Bool { - guard let conclusion, !conclusion.isEmpty else { return false } - switch conclusion.lowercased() { - case "success", "neutral", "skipped": - return false - default: - return true - } - } - - private func commentCountForAgent(agent: AgentConfig, comments: [PRComment], since: Date?) -> Int { - let filtered = filterComments(comments, since: since) - let author = agent.commentAuthor.trimmingCharacters(in: .whitespacesAndNewlines) - if !author.isEmpty { - let normalizedAuthor = normalize(author) - return filtered.filter { normalize($0.author) == normalizedAuthor }.count - } - - let normalized = normalize(agent.displayName) - if normalized.isEmpty { - return 0 - } - return filtered.filter { normalize($0.author) == normalized }.count - } - - private func inferCommentCount(app: CheckRunDTO.App?, comments: [PRComment], since: Date?) -> Int { - guard let slug = app?.slug?.lowercased() else { return 0 } - let filtered = filterComments(comments, since: since) - return filtered.filter { normalize($0.author).hasPrefix(slug) }.count - } - - private func latestRun(from runs: [CheckRunDTO]) -> CheckRunDTO? { - runs.max(by: { runDate($0) < runDate($1) }) - } - - private func runDate(_ run: CheckRunDTO) -> Date { - let candidates = [run.startedAt, run.completedAt, run.createdAt].compactMap { $0 } - return candidates.max() ?? .distantPast - } - - private func filterComments(_ comments: [PRComment], since: Date?) -> [PRComment] { - guard let since else { return comments } - return comments.filter { $0.createdAt >= since } - } - - private func normalize(_ value: String) -> String { - let lowered = value.lowercased() - let removedBot = lowered.replacingOccurrences(of: "[bot]", with: "") - let allowed = removedBot.filter { $0.isLetter || $0.isNumber } - return allowed - } - - private func runKey(_ run: CheckRunDTO) -> String { - let appKey = run.app?.slug ?? run.app?.name ?? "" - return "\(appKey.lowercased())::\(run.name.lowercased())" - } - - private func runDisplayName(_ run: CheckRunDTO) -> String { - let appName = run.app?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let runName = run.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !appName.isEmpty else { return runName } - if appName.caseInsensitiveCompare(runName) == .orderedSame { - return appName - } - if runName.isEmpty { - return appName - } - return "\(appName) — \(runName)" - } -} diff --git a/Sources/PRMonitor/Services/SystemEvents.swift b/Sources/PRMonitor/Services/SystemEvents.swift new file mode 100644 index 0000000..baaa41f --- /dev/null +++ b/Sources/PRMonitor/Services/SystemEvents.swift @@ -0,0 +1,53 @@ +import AppKit +import Network +import ServiceManagement + +/// Emits when the Mac wakes from sleep or the network comes back, so the monitor can refresh +/// immediately instead of waiting out its timer with stale data. +@MainActor +final class SystemEvents { + private let pathMonitor = NWPathMonitor() + private var observers: [NSObjectProtocol] = [] + private(set) var isOnline = true + /// Low Data Mode or a metered connection (e.g. a phone hotspot): poll less often. + private(set) var isConstrained = false + + func start(onResume: @escaping @MainActor () -> Void) { + let center = NSWorkspace.shared.notificationCenter + observers.append(center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { _ in + MainActor.assumeIsolated { onResume() } + }) + + pathMonitor.pathUpdateHandler = { [weak self] path in + let online = path.status == .satisfied + let constrained = path.isConstrained || path.isExpensive + Task { @MainActor in + guard let self else { return } + self.isConstrained = constrained + let cameBack = online && !self.isOnline + self.isOnline = online + if cameBack { onResume() } + } + } + pathMonitor.start(queue: DispatchQueue(label: "PRMonitor.network")) + } +} + +/// Launch at login via `SMAppService`, which shows up in System Settings › General › Login Items. +enum LoginItem { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static var requiresApproval: Bool { + SMAppService.mainApp.status == .requiresApproval + } + + static func setEnabled(_ enabled: Bool) throws { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } +} diff --git a/Sources/PRMonitor/SettingsStore.swift b/Sources/PRMonitor/SettingsStore.swift deleted file mode 100644 index a46db75..0000000 --- a/Sources/PRMonitor/SettingsStore.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -final class SettingsStore: ObservableObject { - @Published var repos: [RepoConfig] = [] { - didSet { save() } - } - @Published var agents: [AgentConfig] = [] { - didSet { save() } - } - @Published var pollingIntervalSeconds: Int = 60 { - didSet { save() } - } - @Published var notifyPerAgent: Bool = false { - didSet { save() } - } - @Published var notifySummary: Bool = true { - didSet { save() } - } - @Published var githubClientId: String = "" { - didSet { save() } - } - - private let storageKey = "PRMonitorSettings" - - init() { - load() - if repos.isEmpty { - repos = [] - } - if agents.isEmpty { - agents = [ - AgentConfig(displayName: "Vercel", checkNamePattern: "vercel", commentAuthor: "vercel"), - AgentConfig(displayName: "Cursor Bugbot", checkNamePattern: "cursor", commentAuthor: "cursor"), - AgentConfig(displayName: "Devin Review", checkNamePattern: "devin", commentAuthor: "devin-ai-integration") - ] - } - } - - var enabledRepos: [RepoConfig] { - repos.filter { $0.isEnabled } - } - - func addRepo(from text: String) -> RepoConfig? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let parts = trimmed.split(separator: "/").map(String.init) - guard parts.count == 2 else { return nil } - let owner = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let name = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard isValidOwner(owner), isValidRepoName(name) else { return nil } - let repo = RepoConfig(owner: owner, name: name, isEnabled: true) - if !repos.contains(where: { $0.id == repo.id }) { - repos.append(repo) - } - return repo - } - - func setRepoEnabled(owner: String, name: String, enabled: Bool) { - let repoId = "\(owner)/\(name)" - if let index = repos.firstIndex(where: { $0.id == repoId }) { - repos[index].isEnabled = enabled - } else if enabled { - repos.append(RepoConfig(owner: owner, name: name, isEnabled: true)) - } - } - - private func load() { - guard let data = UserDefaults.standard.data(forKey: storageKey) else { return } - do { - let decoded = try JSONDecoder().decode(PersistedSettings.self, from: data) - repos = decoded.repos - agents = decoded.agents - pollingIntervalSeconds = decoded.pollingIntervalSeconds - notifyPerAgent = decoded.notifyPerAgent - notifySummary = decoded.notifySummary - githubClientId = decoded.githubClientId - } catch { - repos = [] - agents = [] - pollingIntervalSeconds = 60 - notifyPerAgent = false - notifySummary = true - githubClientId = "" - } - } - - private func save() { - let payload = PersistedSettings( - repos: repos, - agents: agents, - pollingIntervalSeconds: pollingIntervalSeconds, - notifyPerAgent: notifyPerAgent, - notifySummary: notifySummary, - githubClientId: githubClientId - ) - guard let data = try? JSONEncoder().encode(payload) else { return } - UserDefaults.standard.set(data, forKey: storageKey) - } - - private func isValidOwner(_ owner: String) -> Bool { - guard !owner.isEmpty, owner.count <= 39 else { return false } - return owner.range(of: "^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$", options: .regularExpression) != nil - } - - private func isValidRepoName(_ name: String) -> Bool { - guard !name.isEmpty else { return false } - return name.range(of: "^[A-Za-z0-9._-]+$", options: .regularExpression) != nil - } -} - -private struct PersistedSettings: Codable { - var repos: [RepoConfig] - var agents: [AgentConfig] - var pollingIntervalSeconds: Int - var notifyPerAgent: Bool - var notifySummary: Bool - var githubClientId: String - - enum CodingKeys: String, CodingKey { - case repos - case agents - case pollingIntervalSeconds - case notifyPerAgent - case notifySummary - case githubClientId - } - - init(repos: [RepoConfig], agents: [AgentConfig], pollingIntervalSeconds: Int, notifyPerAgent: Bool, notifySummary: Bool, githubClientId: String) { - self.repos = repos - self.agents = agents - self.pollingIntervalSeconds = pollingIntervalSeconds - self.notifyPerAgent = notifyPerAgent - self.notifySummary = notifySummary - self.githubClientId = githubClientId - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - repos = try container.decodeIfPresent([RepoConfig].self, forKey: .repos) ?? [] - agents = try container.decodeIfPresent([AgentConfig].self, forKey: .agents) ?? [] - pollingIntervalSeconds = try container.decodeIfPresent(Int.self, forKey: .pollingIntervalSeconds) ?? 60 - notifyPerAgent = try container.decodeIfPresent(Bool.self, forKey: .notifyPerAgent) ?? false - notifySummary = try container.decodeIfPresent(Bool.self, forKey: .notifySummary) ?? true - githubClientId = try container.decodeIfPresent(String.self, forKey: .githubClientId) ?? "" - } -} diff --git a/Sources/PRMonitor/UI/Components/Avatar.swift b/Sources/PRMonitor/UI/Components/Avatar.swift new file mode 100644 index 0000000..b76e81b --- /dev/null +++ b/Sources/PRMonitor/UI/Components/Avatar.swift @@ -0,0 +1,75 @@ +import AppKit +import SwiftUI + +/// A GitHub avatar that never flashes blank: a pulsing bone while the photo loads, then a +/// crossfade to it. The user's initial is the fallback if there's no photo. Loaded images are +/// cached for the session, so the sidebar and the Account pane show the same avatar instantly. +struct Avatar: View { + let login: String? + var size: CGFloat = 36 + + private enum Phase { case loading, loaded(NSImage), fallback } + @State private var phase: Phase = .loading + + var body: some View { + ZStack { + switch phase { + case .loading: + BoneCircle(size: size).skeletonPulse().transition(.opacity) + case let .loaded(image): + Image(nsImage: image).resizable().scaledToFill().transition(.opacity) + case .fallback: + monogram.transition(.opacity) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(.primary.opacity(0.08), lineWidth: 0.5)) + .accessibilityHidden(true) + .task(id: login) { await load() } + } + + private var monogram: some View { + Circle() + .fill(Color.accentColor.gradient) + .overlay { + if let initial = login?.first { + Text(String(initial).uppercased()) + .font(.system(size: size * 0.42, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } else { + Image(systemName: "person.fill") + .font(.system(size: size * 0.45)) + .foregroundStyle(.white.opacity(0.9)) + } + } + } + + private func load() async { + guard let login else { phase = .fallback; return } + if let cached = AvatarCache.shared[login] { + phase = .loaded(cached) + return + } + phase = .loading + guard let url = URL(string: "https://github.com/\(login).png?size=\(Int(size * 3))"), + let (data, _) = try? await URLSession.shared.data(from: url), + let loaded = NSImage(data: data) else { + withAnimation(Motion.crossfade) { phase = .fallback } + return + } + AvatarCache.shared[login] = loaded + withAnimation(Motion.crossfade) { phase = .loaded(loaded) } + } +} + +@MainActor +private final class AvatarCache { + static let shared = AvatarCache() + private var images: [String: NSImage] = [:] + + subscript(login: String) -> NSImage? { + get { images[login.lowercased()] } + set { images[login.lowercased()] = newValue } + } +} diff --git a/Sources/PRMonitor/UI/Components/DesignSystem.swift b/Sources/PRMonitor/UI/Components/DesignSystem.swift new file mode 100644 index 0000000..cfe0f74 --- /dev/null +++ b/Sources/PRMonitor/UI/Components/DesignSystem.swift @@ -0,0 +1,227 @@ +import SwiftUI + +/// Layout metrics shared by the menu bar panel. Values follow macOS menu geometry: highlights +/// inset 5pt from the panel edge, text aligned 14pt in, so rows read like native menu items. +enum Metrics { + static let panelWidth: CGFloat = 360 + /// The panel's corner radius, published as a container shape so inner highlights can be + /// concentric with it (inner radius = outer radius − inset). + static let panelCornerRadius: CGFloat = 16 + static let edgeInset: CGFloat = 14 + static let highlightInset: CGFloat = 5 + static var rowPadding: CGFloat { edgeInset - highlightInset } + static let minimumHighlightRadius: CGFloat = 8 + static let iconButtonSize: CGFloat = 28 +} + +/// Motion tokens. High-frequency feedback (hover) is instant; state changes are short and never bounce. +enum Motion { + /// Expanding and collapsing rows: an interruptible spring with no bounce. + static let disclosure = Animation.smooth(duration: 0.25) + /// Rows moving or appearing after a refresh. Kept under 300ms, like all UI motion. + static let content = Animation.smooth(duration: 0.25) + /// Exits are shorter and softer than enters, so they don't compete for attention. + static let exit = Animation.easeOut(duration: 0.15) + /// Skeleton → content, image → image. + static let crossfade = Animation.easeOut(duration: 0.2) + static let press = Animation.snappy(duration: 0.12) + static let pressedScale: CGFloat = 0.96 +} + +extension AnyTransition { + /// Enter: fade in with a small drop. Exit: a quicker fade with a slight blur, and no travel. + static var disclosure: AnyTransition { + .asymmetric( + insertion: .opacity.combined(with: .offset(y: -4)), + removal: .opacity.combined(with: .blur(radius: 4)).animation(Motion.exit) + ) + } +} + +extension AnyTransition { + static func blur(radius: CGFloat) -> AnyTransition { + .modifier(active: BlurModifier(radius: radius), identity: BlurModifier(radius: 0)) + } +} + +private struct BlurModifier: ViewModifier { + let radius: CGFloat + func body(content: Content) -> some View { content.blur(radius: radius) } +} + +// MARK: - Status semantics + +extension PullRequestStatus { + var tint: Color { + switch self { + case .failing: .red + case .running: .blue + case .needsReview: .orange + case .ready: .green + case .quiet: .gray + } + } + + /// Each status has a distinct glyph so it never relies on color alone. + var symbol: String { + switch self { + case .failing: "xmark.circle.fill" + case .running: "arrow.trianglehead.2.clockwise.rotate.90.circle.fill" + case .needsReview: "exclamationmark.bubble.circle.fill" + case .ready: "checkmark.circle.fill" + case .quiet: "minus.circle.fill" + } + } + + var title: String { + switch self { + case .failing: "Failing" + case .running: "In progress" + case .needsReview: "Needs review" + case .ready: "Ready" + case .quiet: "No checks" + } + } +} + +extension AgentReport.State { + var tint: Color { + switch self { + case .running: .blue + case .failed: .red + case .needsReview: .orange + case .passed: .green + } + } + + var symbol: String { + switch self { + case .running: "circle.dotted" + case .failed: "xmark.circle.fill" + case .needsReview: "exclamationmark.bubble.circle.fill" + case .passed: "checkmark.circle.fill" + } + } +} + +// MARK: - Shapes + +/// A highlight shape that stays concentric with the panel's corners and falls back to a fixed +/// minimum radius away from them. +struct HighlightShape: Shape { + func path(in rect: CGRect) -> Path { + ConcentricRectangle(corners: .concentric(minimum: .fixed(Metrics.minimumHighlightRadius)), isUniform: true) + .path(in: rect) + } +} + +// MARK: - Button styles + +/// A menu-item-like row: an instant hover highlight, a slightly deeper fill while pressed. +struct RowButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + RowBody(configuration: configuration) + } + + private struct RowBody: View { + let configuration: Configuration + @State private var isHovered = false + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + configuration.label + .contentShape(HighlightShape()) + .background { + HighlightShape() + .fill(.primary.opacity(fillOpacity)) + } + .onHover { isHovered = $0 && isEnabled } + } + + private var fillOpacity: Double { + if configuration.isPressed { return 0.12 } + return isHovered ? 0.07 : 0 + } + } +} + +/// A borderless icon button for use inside the (already glass) panel. Glass-on-glass is avoided per +/// Apple's guidance, so this uses a circular hover fill and a subtle press scale instead. +struct IconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + IconBody(configuration: configuration) + } + + private struct IconBody: View { + let configuration: Configuration + @State private var isHovered = false + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + configuration.label + .font(.body) + .foregroundStyle(isEnabled ? .secondary : .tertiary) + .frame(width: Metrics.iconButtonSize, height: Metrics.iconButtonSize) + .background(Circle().fill(.primary.opacity(isHovered && isEnabled ? 0.08 : 0))) + .contentShape(Circle()) + .scaleEffect(configuration.isPressed ? Motion.pressedScale : 1) + .animation(Motion.press, value: configuration.isPressed) + .onHover { isHovered = $0 } + } + } +} + +extension ButtonStyle where Self == IconButtonStyle { + static var icon: IconButtonStyle { IconButtonStyle() } +} + +// MARK: - Small components + +/// A pull request's status as a soft, two-tone symbol: the glyph in the status color on a tinted +/// disc of the same hue, in the current SF Symbols style. +struct StatusGlyph: View { + let status: PullRequestStatus + var isDraft = false + + var body: some View { + let tint: Color = isDraft ? .gray : status.tint + Image(systemName: isDraft ? "pencil.circle.fill" : status.symbol) + .font(.system(size: 19, weight: .medium)) + .symbolRenderingMode(.palette) + .foregroundStyle(tint, tint.opacity(0.18)) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 20, height: 20) + .animation(.easeOut(duration: 0.2), value: status) + .accessibilityHidden(true) + } +} + +/// The smaller companion used for individual agents. +struct AgentGlyph: View { + let state: AgentReport.State + + var body: some View { + Image(systemName: state.symbol) + .font(.system(size: 12, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(state.tint, state.tint.opacity(0.18)) + .frame(width: 14) + .accessibilityHidden(true) + } +} + +// MARK: - Formatting + +extension Date { + /// A compact age like "now", "4m", "2h" or "3d", sized for dense rows. + func compactAge(relativeTo now: Date = .now) -> String { + let minutes = Int(max(0, now.timeIntervalSince(self)) / 60) + switch minutes { + case ..<1: return "now" + case ..<60: return "\(minutes)m" + case ..<1440: return "\(minutes / 60)h" + case ..<10080: return "\(minutes / 1440)d" + default: return "\(minutes / 10080)w" + } + } +} diff --git a/Sources/PRMonitor/UI/Components/PullRequestMark.swift b/Sources/PRMonitor/UI/Components/PullRequestMark.swift new file mode 100644 index 0000000..497c6fa --- /dev/null +++ b/Sources/PRMonitor/UI/Components/PullRequestMark.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// The familiar pull request mark: a branch with two nodes, and a second branch arcing back with an +/// arrow. One definition serves the menu bar icon (via `cgPath`) and in-app artwork (as a `Shape`). +/// +/// Geometry is authored on a 16 × 16 grid with a 1.5pt stroke, matching the optical weight of +/// system menu bar symbols, and scales uniformly to any size. +struct PullRequestMark: Shape { + func path(in rect: CGRect) -> Path { + let scale = min(rect.width, rect.height) / 16 + var path = Path() + let r: CGFloat = 1.9 + + // Left branch: two nodes joined by a line. + path.addEllipse(in: CGRect(x: 3.5 - r, y: 3.4 - r, width: r * 2, height: r * 2)) + path.addEllipse(in: CGRect(x: 3.5 - r, y: 12.8 - r, width: r * 2, height: r * 2)) + path.move(to: CGPoint(x: 3.5, y: 5.3)) + path.addLine(to: CGPoint(x: 3.5, y: 10.9)) + + // Right branch: a node rising and curving back toward the left branch, ending in an arrow. + path.addEllipse(in: CGRect(x: 11.5 - r, y: 12.8 - r, width: r * 2, height: r * 2)) + path.move(to: CGPoint(x: 11.5, y: 10.9)) + path.addLine(to: CGPoint(x: 11.5, y: 5.8)) + path.addCurve(to: CGPoint(x: 9.2, y: 3.4), control1: CGPoint(x: 11.5, y: 4.4), control2: CGPoint(x: 10.6, y: 3.4)) + path.addLine(to: CGPoint(x: 7.2, y: 3.4)) + path.move(to: CGPoint(x: 8.9, y: 1.7)) + path.addLine(to: CGPoint(x: 7.2, y: 3.4)) + path.addLine(to: CGPoint(x: 8.9, y: 5.1)) + + let offset = CGPoint(x: rect.midX - 8 * scale, y: rect.midY - 8 * scale) + return path + .applying(CGAffineTransform(scaleX: scale, y: scale).concatenating(CGAffineTransform(translationX: offset.x, y: offset.y))) + .strokedPath(StrokeStyle(lineWidth: 1.5 * scale, lineCap: .round, lineJoin: .round)) + } +} diff --git a/Sources/PRMonitor/UI/Components/Skeleton.swift b/Sources/PRMonitor/UI/Components/Skeleton.swift new file mode 100644 index 0000000..d4fe651 --- /dev/null +++ b/Sources/PRMonitor/UI/Components/Skeleton.swift @@ -0,0 +1,104 @@ +import SwiftUI + +/// Loading placeholders ("bones") that mirror the geometry of the content they stand in for, so +/// nothing shifts when real data arrives, and the wait reads as "content is coming" rather than +/// an indeterminate spinner. +struct Bone: View { + var width: CGFloat? = nil + var height: CGFloat = 9 + + var body: some View { + Capsule() + .fill(.primary.opacity(0.09)) + .frame(width: width, height: height) + } +} + +struct BoneCircle: View { + var size: CGFloat + + var body: some View { + Circle() + .fill(.primary.opacity(0.09)) + .frame(width: size, height: size) + } +} + +extension View { + /// A slow, gentle breathing pulse for skeletons. Static when Reduce Motion is on. + func skeletonPulse() -> some View { + modifier(SkeletonPulse()) + } +} + +private struct SkeletonPulse: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isDimmed = false + + func body(content: Content) -> some View { + content + .opacity(isDimmed ? 0.5 : 1) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { isDimmed = true } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading") + } +} + +// MARK: - Panel + +/// Stands in for the pull request list on first load. Uses the same insets, glyph size, and line +/// heights as `PullRequestRow`. +struct PullRequestListSkeleton: View { + private let rows: [(title: CGFloat, detail: CGFloat)] = [(210, 150), (170, 180), (230, 130), (150, 160)] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Bone(width: 110, height: 8) + .padding(.horizontal, Metrics.edgeInset) + .padding(.top, 13) + .padding(.bottom, 7) + ForEach(rows.indices, id: \.self) { index in + HStack(alignment: .top, spacing: 10) { + BoneCircle(size: 20) + VStack(alignment: .leading, spacing: 7) { + Bone(width: rows[index].title, height: 10) + Bone(width: rows[index].detail, height: 8) + } + .padding(.top, 3) + } + .padding(.leading, Metrics.edgeInset) + .padding(.vertical, 8) + } + } + .padding(.bottom, Metrics.highlightInset) + .frame(maxWidth: .infinity, alignment: .leading) + .skeletonPulse() + } +} + +// MARK: - Settings + +/// Stands in for a list of repositories or accounts: a circle and two lines per row. +struct ListRowSkeleton: View { + var count = 6 + var circleSize: CGFloat = 18 + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(0.. some View { + self + .opacity(isVisible ? 1 : 0) + .blur(radius: isVisible || reduceMotion ? 0 : 4) + .offset(y: isVisible || reduceMotion ? 0 : 8) + .animation(.smooth(duration: 0.4).delay(Double(index) * 0.08), value: isVisible) + } +} diff --git a/Sources/PRMonitor/UI/MenuBar/MenuBarLabel.swift b/Sources/PRMonitor/UI/MenuBar/MenuBarLabel.swift new file mode 100644 index 0000000..76a8b59 --- /dev/null +++ b/Sources/PRMonitor/UI/MenuBar/MenuBarLabel.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI + +/// The status item: a pull-request glyph with a small state badge, or an attention count. +/// +/// By default the image is a template, so macOS tints it for light/dark menu bars and the selected +/// state, as the HIG asks. The badge's *shape* carries the state (filled dot = needs attention, +/// ring = in progress, dimmed glyph = not connected), so it reads without color. When there's a +/// count it replaces the dot, since the number already says "these need you". Everything, count +/// included, is drawn into one image so the spacing is exact. +struct MenuBarLabel: View { + let summary: Monitor.Summary + let count: Int + let isColored: Bool + + var body: some View { + Image(nsImage: StatusIcon.image(for: summary, count: count, isColored: isColored)) + .accessibilityLabel("PR Monitor") + .accessibilityValue(StatusIcon.accessibilityValue(for: summary, count: count)) + } +} + +enum StatusIcon { + enum Badge { + case none, dot(NSColor), ring(NSColor) + } + + private static let glyphSize: CGFloat = 16 + private static let countSpacing: CGFloat = 4 + + static func image(for summary: Monitor.Summary, count: Int = 0, isColored: Bool) -> NSImage { + var (badge, isDimmed) = style(for: summary, isColored: isColored) + let countText = count > 0 ? NSAttributedString(string: count.formatted(), attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium), + ]) : nil + if countText != nil, case .dot = badge { badge = .none } + + let countWidth = countText.map { ceil($0.size().width) + countSpacing } ?? 0 + let badgeWidth: CGFloat = if case .none = badge { 0 } else { 5 } + let size = NSSize(width: glyphSize + badgeWidth + countWidth, height: glyphSize) + + // Flipped so the mark's top-left-origin geometry draws upright. + let image = NSImage(size: size, flipped: true) { _ in + let ink: NSColor = isColored ? .labelColor : .black + ink.withAlphaComponent(isDimmed ? 0.45 : 1).setFill() + NSBezierPath(cgPath: PullRequestMark().path(in: CGRect(x: 0, y: 0, width: glyphSize, height: glyphSize)).cgPath).fill() + + switch badge { + case .none: + break + case let .dot(color), let .ring(color): + let rect = NSRect(x: glyphSize - 1.5, y: 5, width: 6, height: 6) + // Knock out a gap around the badge so it separates cleanly from the glyph. + NSGraphicsContext.current?.compositingOperation = .destinationOut + NSBezierPath(ovalIn: rect.insetBy(dx: -1.5, dy: -1.5)).fill() + NSGraphicsContext.current?.compositingOperation = .sourceOver + let fill = isColored ? color : .black + if case .ring = badge { + let ring = NSBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) + ring.lineWidth = 1.5 + fill.setStroke() + ring.stroke() + } else { + fill.setFill() + NSBezierPath(ovalIn: rect).fill() + } + } + + if let countText { + let tint: NSColor = isColored ? countColor(for: summary) : .black + let tinted = NSMutableAttributedString(attributedString: countText) + tinted.addAttribute(.foregroundColor, value: tint, range: NSRange(location: 0, length: tinted.length)) + let textSize = tinted.size() + tinted.draw(at: NSPoint(x: glyphSize + badgeWidth + countSpacing, y: (glyphSize - textSize.height) / 2)) + } + return true + } + image.isTemplate = !isColored + return image + } + + private static func countColor(for summary: Monitor.Summary) -> NSColor { + switch summary { + case .failing: .systemRed + case .needsReview: .systemOrange + default: .labelColor + } + } + + /// In template mode a filled dot always means "act on this", so "ready" gets no badge there. + private static func style(for summary: Monitor.Summary, isColored: Bool) -> (Badge, dimmed: Bool) { + switch summary { + case .signedOut, .needsRepositories, .unavailable: (.none, true) + case .failing: (.dot(.systemRed), false) + case .needsReview: (.dot(.systemOrange), false) + case .running: (.ring(.systemBlue), false) + case .ready: (isColored ? .dot(.systemGreen) : .none, false) + case .idle: (.none, false) + } + } + + static func accessibilityValue(for summary: Monitor.Summary, count: Int) -> String { + let state = switch summary { + case .signedOut: "Not signed in" + case .needsRepositories: "No repositories selected" + case let .unavailable(error): error.localizedDescription + case .failing: "Checks failing" + case .needsReview: "Ready for review" + case .running: "Agents running" + case .ready: "All clear" + case .idle: "No open pull requests" + } + return count > 0 ? "\(state), \(count) need attention" : state + } +} diff --git a/Sources/PRMonitor/UI/MenuBar/MenuBarPanel.swift b/Sources/PRMonitor/UI/MenuBar/MenuBarPanel.swift new file mode 100644 index 0000000..5712656 --- /dev/null +++ b/Sources/PRMonitor/UI/MenuBar/MenuBarPanel.swift @@ -0,0 +1,324 @@ +import SwiftUI + +/// The window shown from the menu bar. The system renders it on Liquid Glass; everything inside is +/// content, so it uses plain fills and hover highlights rather than more glass. +struct MenuBarPanel: View { + @Environment(Monitor.self) private var monitor + @Environment(AppSettings.self) private var settings + @Environment(Account.self) private var account + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var expanded: Set + @State private var listHeight: CGFloat = 0 + + init(initiallyExpanded: Set = []) { + _expanded = State(initialValue: initiallyExpanded) + } + + var body: some View { + VStack(spacing: 0) { + PanelHeader() + content + // Skeleton → list and empty states crossfade instead of popping. + .animation(Motion.crossfade, value: contentPhase) + Divider() + .padding(.horizontal, Metrics.edgeInset) + PanelFooter() + } + .frame(width: Metrics.panelWidth) + .containerShape(.rect(cornerRadius: Metrics.panelCornerRadius)) + // The panel's window is reused between openings, so refresh whenever it becomes key. + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + monitor.refreshIfStale() + } + } + + @ViewBuilder + private var content: some View { + switch monitor.summary { + case .signedOut: + ConnectGitHubView() + case .needsRepositories: + EmptyStateView( + symbol: "book.closed", + title: "Choose Repositories", + message: "Pick the repositories whose pull requests you want PR Monitor to watch.", + action: ("Choose Repositories…", .repositories) + ) + case let .unavailable(error) where monitor.visibleSections.isEmpty: + EmptyStateView(symbol: symbol(for: error), title: title(for: error), message: error.localizedDescription) + default: + if monitor.visibleSections.isEmpty { + if monitor.lastUpdated == nil { + PullRequestListSkeleton() + .transition(.opacity) + } else { + EmptyStateView( + symbol: "checkmark.circle", + title: "No Open Pull Requests", + message: settings.scope == .authored + ? "You don't have any open pull requests in the repositories you monitor." + : "There are no open pull requests in the repositories you monitor." + ) + } + } else { + list + .transition(.opacity) + } + } + } + + /// Identifies which kind of content is showing, so switching between them crossfades. + private var contentPhase: Int { + switch monitor.summary { + case .signedOut: 0 + case .needsRepositories: 1 + default: monitor.lastUpdated == nil ? 2 : (monitor.visibleSections.isEmpty ? 3 : 4) + } + } + + private var list: some View { + ScrollView { + // A plain VStack (not lazy) so the measured height is exact on the first frame; the + // list is at most a few dozen rows. + VStack(alignment: .leading, spacing: 0) { + ForEach(monitor.visibleSections) { section in + Section { + ForEach(section.reports) { report in + PullRequestRow(report: report, isExpanded: expansionBinding(for: report.id)) + } + } header: { + SectionHeader(section: section) + } + } + } + .padding(.bottom, Metrics.highlightInset) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { listHeight = $0 } + .animation(reduceMotion ? nil : Motion.content, value: monitor.visibleReports.map(\.id)) + } + .scrollBounceBehavior(.basedOnSize) + .scrollEdgeEffectStyle(.soft, for: .top) + // Grow with the content up to most of the screen, then scroll. + .frame(height: min(max(listHeight, 1), maximumListHeight)) + } + + private var maximumListHeight: CGFloat { + let screen = NSScreen.main?.visibleFrame.height ?? 900 + return min(screen - 180, 620) + } + + private func expansionBinding(for id: String) -> Binding { + Binding( + get: { expanded.contains(id) }, + set: { isExpanded in + if isExpanded { expanded.insert(id) } else { expanded.remove(id) } + } + ) + } + + private func symbol(for error: GitHubError) -> String { + switch error { + case .offline: "wifi.slash" + case .rateLimited: "hourglass" + default: "exclamationmark.triangle" + } + } + + private func title(for error: GitHubError) -> String { + switch error { + case .offline: "You're Offline" + case .rateLimited: "Taking a Short Break" + default: "Couldn't Reach GitHub" + } + } +} + +// MARK: - Header + +private struct PanelHeader: View { + @Environment(Monitor.self) private var monitor + @Environment(AppSettings.self) private var settings + + var body: some View { + @Bindable var settings = settings + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 1) { + Text("Pull Requests") + .font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .contentTransition(.numericText()) + .animation(Motion.content, value: subtitle) + } + Spacer(minLength: 0) + if monitor.summary.showsContent { + Menu { + Picker("Show", selection: $settings.scope) { + Label("All Pull Requests", systemImage: "tray.full").tag(AppSettings.Scope.all) + Label("My Pull Requests", systemImage: "person").tag(AppSettings.Scope.authored) + } + .pickerStyle(.inline) + Divider() + Toggle("Hide Drafts", isOn: $settings.hidesDrafts) + } label: { + Label("Filter", systemImage: isFiltered ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .labelStyle(.iconOnly) + .foregroundStyle(isFiltered ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .contentTransition(.symbolEffect(.replace)) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.icon) + .help("Filter pull requests") + } + } + .padding(.leading, Metrics.edgeInset) + .padding(.trailing, 6) + .padding(.top, 12) + .padding(.bottom, 8) + } + + private var isFiltered: Bool { + settings.scope == .authored || settings.hidesDrafts + } + + private var subtitle: String { + let reports = monitor.visibleReports + switch monitor.summary { + case .signedOut: return "Not connected" + case .needsRepositories: return "No repositories selected" + case .unavailable where reports.isEmpty: return "Unavailable" + default: break + } + guard monitor.lastUpdated != nil else { return "Loading…" } + if reports.isEmpty { return "Nothing open" } + + let attention = reports.count { $0.status.needsAttention } + let running = reports.count { $0.status == .running } + var parts: [String] = [] + if attention > 0 { parts.append("\(attention) need\(attention == 1 ? "s" : "") attention") } + if running > 0 { parts.append("\(running) in progress") } + if parts.isEmpty { parts.append(reports.count == 1 ? "1 open, all clear" : "\(reports.count) open, all clear") } + if settings.scope == .authored { parts.insert("Yours", at: 0) } + return parts.joined(separator: " · ") + } +} + +private struct SectionHeader: View { + let section: Monitor.Section + + var body: some View { + HStack(spacing: 6) { + Text("\(Text(section.repository.owner + "/").foregroundStyle(.tertiary))\(section.repository.name)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if let error = section.error { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .help(error.localizedDescription) + .accessibilityLabel("Couldn't refresh: \(error.localizedDescription)") + } + } + .padding(.horizontal, Metrics.edgeInset) + .padding(.top, 10) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + } +} + +// MARK: - Footer + +private struct PanelFooter: View { + @Environment(Monitor.self) private var monitor + @Environment(AppRouter.self) private var router + @Environment(\.openSettings) private var openSettings + + var body: some View { + HStack(spacing: 2) { + status + .padding(.leading, Metrics.edgeInset - 4) + Spacer(minLength: 8) + + Button("Refresh", systemImage: "arrow.clockwise") { monitor.refresh() } + .symbolEffect(.rotate, options: .repeat(.continuous), isActive: monitor.isRefreshing) + .keyboardShortcut("r") + .help("Refresh (⌘R)") + .disabled(!monitor.summary.canRefresh) + + Button("Settings", systemImage: "gearshape") { router.showSettings(using: openSettings) } + .keyboardShortcut(",") + .help("Settings… (⌘,)") + + Menu { + Button("Open GitHub") { NSWorkspace.shared.open(URL(string: "https://github.com/pulls")!) } + Divider() + Button("About PR Monitor") { + NSApp.activate() + NSApp.orderFrontStandardAboutPanel() + } + Button("Quit PR Monitor") { NSApp.terminate(nil) } + .keyboardShortcut("q") + } label: { + Label("More", systemImage: "ellipsis") + } + .menuIndicator(.hidden) + .help("More") + } + .buttonStyle(.icon) + .menuStyle(.button) + .labelStyle(.iconOnly) + .padding(.trailing, 6) + .padding(.vertical, 6) + } + + @ViewBuilder + private var status: some View { + TimelineView(.periodic(from: .now, by: 30)) { _ in + Group { + if let error = monitor.lastError, monitor.summary.showsContent { + Label(error.shortDescription, systemImage: "exclamationmark.triangle.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(.orange) + .help(error.localizedDescription) + } else if let updated = monitor.lastUpdated { + Text("Updated \(updated, format: .relative(presentation: .named, unitsStyle: .wide))") + .foregroundStyle(.secondary) + } else { + Text(" ") + } + } + .font(.subheadline) + .lineLimit(1) + } + } +} + +extension Monitor.Summary { + var showsContent: Bool { + switch self { + case .signedOut, .needsRepositories: false + default: true + } + } + + var canRefresh: Bool { showsContent } +} + +extension GitHubError { + /// A few words for the footer; the full explanation is in the tooltip. + var shortDescription: String { + switch self { + case .offline: "Offline" + case .rateLimited: "Rate limited" + case .unauthorized: "Signed out" + case .server: "GitHub unavailable" + default: "Couldn't refresh" + } + } +} diff --git a/Sources/PRMonitor/UI/MenuBar/PullRequestRow.swift b/Sources/PRMonitor/UI/MenuBar/PullRequestRow.swift new file mode 100644 index 0000000..02a70e9 --- /dev/null +++ b/Sources/PRMonitor/UI/MenuBar/PullRequestRow.swift @@ -0,0 +1,197 @@ +import SwiftUI + +/// One pull request, in two light lines: the title, then a colored status phrase with the PR's +/// details. Clicking opens it on GitHub, like choosing a menu item; the chevron reveals each agent. +struct PullRequestRow: View { + let report: PullRequestReport + @Binding var isExpanded: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.openURL) private var openURL + + private var pr: PullRequest { report.pullRequest } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { openURL(pr.url) } label: { summary } + .buttonStyle(RowButtonStyle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Opens the pull request on GitHub") + + disclosureButton + } + + if isExpanded { + AgentList(report: report) + .transition(reduceMotion ? .opacity : .disclosure) + } + } + .padding(.horizontal, Metrics.highlightInset) + .contextMenu { contextMenu } + } + + // MARK: - Summary + + private var summary: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + StatusGlyph(status: report.status, isDraft: pr.isDraft && report.status == .quiet) + .alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + 4 } + + VStack(alignment: .leading, spacing: 2) { + Text(pr.title) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + detailLine + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.leading, Metrics.rowPadding) + .padding(.trailing, 2) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// "3 threads · #482 · hasaam · 3m". The glyph already says ready/failed/running, so the line + /// only leads with what the glyph can't: which agent failed, how many threads, how far along. + private var detailLine: some View { + var details = ["#\(pr.number)"] + if let author = pr.author { details.append(author) } + details.append(pr.updatedAt.compactAge()) + if pr.isDraft { details.append("Draft") } + + return HStack(spacing: 0) { + if let statusDetail { + Text(statusDetail + " · ").foregroundStyle(report.status.tint) + } + Text(details.joined(separator: " · ")) + .foregroundStyle(.secondary) + if pr.mergeable == .conflicting { + Text(" · Conflicts").foregroundStyle(.red) + } + } + .font(.subheadline) + .lineLimit(1) + } + + /// The part of the status the glyph can't express, or nil when the glyph says it all. + private var statusDetail: String? { + switch report.status { + case .failing: + let failed = report.agents.filter { $0.state == .failed }.map(\.name) + guard let first = failed.first else { return nil } + return failed.count == 1 ? first : "\(first) +\(failed.count - 1)" + case .needsReview: + let threads = report.openThreadCount + if threads > 0 { return threads == 1 ? "1 thread" : "\(threads) threads" } + return "Changes requested" + case .running: + return "\(report.finishedAgentCount) of \(report.agents.count)" + case .ready: + return pr.reviewDecision == .approved ? "Approved" : nil + case .quiet: + return nil + } + } + + /// Full wording for VoiceOver, which can't see the glyph. + private var spokenStatus: String { + switch report.status { + case .failing: statusDetail.map { "\($0) failed" } ?? "Failing" + case .needsReview: statusDetail.map { "\($0) to review" } ?? "Needs review" + case .running: "Running, \(statusDetail ?? "")" + case .ready: pr.reviewDecision == .approved ? "Ready, approved" : "Ready" + case .quiet: "No checks yet" + } + } + + // MARK: - Disclosure + + private var disclosureButton: some View { + Button { + withAnimation(reduceMotion ? nil : Motion.disclosure) { isExpanded.toggle() } + } label: { + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .buttonStyle(.icon) + .disabled(report.agents.isEmpty) + .opacity(report.agents.isEmpty ? 0 : 1) + .help(isExpanded ? "Hide agents" : "Show agents") + .accessibilityLabel(isExpanded ? "Hide agents" : "Show agents") + } + + // MARK: - Context menu + + @ViewBuilder + private var contextMenu: some View { + Button("Open Pull Request", systemImage: "arrow.up.right.square") { openURL(pr.url) } + Button("Open Checks", systemImage: "checklist") { openURL(pr.checksURL) } + Divider() + Button("Copy Link", systemImage: "link") { copy(pr.url.absoluteString) } + Button("Copy Title", systemImage: "doc.on.doc") { copy("#\(pr.number) \(pr.title)") } + } + + private func copy(_ string: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + + private var accessibilityLabel: String { + var parts = ["\(pr.title), pull request \(pr.number)", spokenStatus] + if pr.isDraft { parts.append("Draft") } + if pr.mergeable == .conflicting { parts.append("Has conflicts") } + parts += report.agents.map { "\($0.name): \($0.summary)" } + return parts.joined(separator: ", ") + } +} + +// MARK: - Agent list + +/// The expanded per-agent breakdown: quiet, compact rows that link to each check or review. +private struct AgentList: View { + let report: PullRequestReport + @Environment(\.openURL) private var openURL + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(report.agents) { agent in + Button { + openURL(agent.url ?? report.pullRequest.url) + } label: { + HStack(spacing: 7) { + AgentGlyph(state: agent.state) + Text(agent.name) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 8) + Text(detail(for: agent)) + .foregroundStyle(.tertiary) + .monospacedDigit() + .lineLimit(1) + } + .font(.subheadline) + .padding(.vertical, 3) + // Align with the title column: glyph (20) + spacing (10). + .padding(.leading, Metrics.rowPadding + 30) + .padding(.trailing, Metrics.rowPadding) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(RowButtonStyle()) + .help(agent.url == nil ? "Open pull request" : "Open details") + .accessibilityLabel("\(agent.name), \(agent.summary)") + } + } + .padding(.bottom, 6) + } + + private func detail(for agent: AgentReport) -> String { + guard let finished = agent.finishedAt, agent.state != .running else { return agent.summary } + return "\(agent.summary) · \(finished.compactAge())" + } +} diff --git a/Sources/PRMonitor/UI/Settings/AccountPane.swift b/Sources/PRMonitor/UI/Settings/AccountPane.swift new file mode 100644 index 0000000..ddf80bd --- /dev/null +++ b/Sources/PRMonitor/UI/Settings/AccountPane.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct AccountPane: View { + @Environment(Account.self) private var account + @Environment(AppSettings.self) private var settings + @State private var token = "" + + var body: some View { + @Bindable var settings = settings + Form { + switch account.state { + case let .signedIn(credential): + signedIn(credential) + case let .authorizing(authorization): + authorizing(authorization) + case .verifying: + Section { + HStack(spacing: 12) { + BoneCircle(size: 44) + VStack(alignment: .leading, spacing: 7) { + Bone(width: 120, height: 11) + Bone(width: 170, height: 8) + } + } + .padding(.vertical, 2) + .skeletonPulse() + } header: { + Text("GitHub") + } footer: { + Text("Verifying with GitHub…") + } + case .signedOut: + signInOptions + } + + Section { + TextField("OAuth app client ID", text: $settings.oauthClientIDOverride, prompt: Text(bundledClientIDPrompt)) + .autocorrectionDisabled() + } header: { + Text("Advanced") + } footer: { + Text("Only needed for “Sign in with GitHub” when you build PR Monitor yourself. Create an OAuth app with device flow enabled at github.com/settings/developers.") + } + } + .formStyle(.grouped) + } + + // MARK: - Signed in + + private func signedIn(_ credential: Account.Credential) -> some View { + Section { + HStack(spacing: 12) { + Avatar(login: credential.login, size: 44) + VStack(alignment: .leading, spacing: 2) { + Text(credential.login ?? "GitHub account") + .font(.headline) + Text(credential.source.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Button("Sign Out") { account.signOut() } + } + .padding(.vertical, 2) + } header: { + Text("GitHub") + } footer: { + Text("Your token is stored in your login keychain and is only ever sent to api.github.com.") + } + } + + // MARK: - Device flow in progress + + private func authorizing(_ authorization: DeviceFlow.Authorization) -> some View { + Section { + VStack(spacing: 10) { + Text("Enter this code on GitHub") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(authorization.userCode) + .font(.system(.largeTitle, design: .monospaced).weight(.semibold)) + .tracking(2) + .textSelection(.enabled) + .accessibilityLabel("Code: \(authorization.userCode.map(String.init).joined(separator: " "))") + Text("It's been copied to your clipboard.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Button("Cancel", role: .cancel) { account.cancelSignIn() } + Button("Copy Code") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(authorization.userCode, forType: .string) + } + Button("Open GitHub") { NSWorkspace.shared.open(authorization.verificationURL) } + .buttonStyle(.borderedProminent) + } + .padding(.top, 4) + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Waiting for you to approve…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } header: { + Text("Sign in with GitHub") + } + } + + // MARK: - Signed out + + @ViewBuilder + private var signInOptions: some View { + Section { + option( + title: "GitHub CLI", + detail: GitHubCLI.isInstalled + ? "Use the account you're signed in to with gh." + : "Install with `brew install gh`, then run `gh auth login`.", + button: "Use GitHub CLI", + isEnabled: GitHubCLI.isInstalled + ) { account.signInWithGitHubCLI() } + + option( + title: "Sign in with GitHub", + detail: settings.oauthClientID.isEmpty + ? "Requires an OAuth app client ID (see Advanced)." + : "Approve PR Monitor in your browser with a one-time code.", + button: "Sign In…", + isEnabled: !settings.oauthClientID.isEmpty + ) { account.signInWithDeviceFlow(clientID: settings.oauthClientID) } + + VStack(alignment: .leading, spacing: 8) { + Text("Personal access token") + Text("A classic token with the repo scope, or a fine-grained token with read access to pull requests, checks and commit statuses.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + SecureField("Token", text: $token, prompt: Text("ghp_… or github_pat_…")) + .labelsHidden() + .onSubmit(useToken) + Button("Use Token", action: useToken) + .disabled(token.trimmed.isEmpty) + } + } + .padding(.vertical, 2) + } header: { + Text("Connect to GitHub") + } footer: { + if let problem = account.problem { + Text(problem).foregroundStyle(.red) + } + } + } + + private func option(title: String, detail: LocalizedStringKey, button: String, isEnabled: Bool, action: @escaping () -> Void) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button(button, action: action) + .disabled(!isEnabled) + } + .padding(.vertical, 2) + } + + private func useToken() { + account.signIn(personalAccessToken: token) + token = "" + } + + private var bundledClientIDPrompt: String { + let bundled = (Bundle.main.object(forInfoDictionaryKey: "GitHubOAuthClientID") as? String)?.trimmed ?? "" + return bundled.isEmpty ? "Not set" : "Using built-in \(bundled.prefix(8))…" + } +} diff --git a/Sources/PRMonitor/UI/Settings/AgentsPane.swift b/Sources/PRMonitor/UI/Settings/AgentsPane.swift new file mode 100644 index 0000000..49cac04 --- /dev/null +++ b/Sources/PRMonitor/UI/Settings/AgentsPane.swift @@ -0,0 +1,221 @@ +import SwiftUI + +struct AgentsPane: View { + @Environment(AppSettings.self) private var settings + @Environment(Monitor.self) private var monitor + @State private var editing: Agent? + + var body: some View { + @Bindable var settings = settings + Form { + Section("Track") { + ModeOption( + title: "Automatic", + detail: "Every CI system, deployment, and AI reviewer that reports on a pull request. Jobs from the same integration are grouped.", + isSelected: settings.agentMode == .automatic + ) { settings.agentMode = .automatic } + ModeOption( + title: "Only agents I choose", + detail: "Only the agents you add count toward a pull request's status.", + isSelected: settings.agentMode == .custom + ) { settings.agentMode = .custom } + } + + switch settings.agentMode { + case .automatic: + detectedSection + case .custom: + configuredSection + } + } + .formStyle(.grouped) + .sheet(item: $editing) { agent in + AgentEditor(agent: agent) { saved in + if let index = settings.agents.firstIndex(where: { $0.id == saved.id }) { + settings.agents[index] = saved + } else { + settings.agents.append(saved) + } + } + } + } + + // MARK: - Automatic + + @ViewBuilder + private var detectedSection: some View { + let detected = monitor.discoveredAgents + Section { + if detected.isEmpty { + Text("Agents appear here once they report on an open pull request.") + .foregroundStyle(.secondary) + } else { + ForEach(detected) { agent in + AgentSummaryRow(agent: agent) + } + } + } header: { + Text("Seen on Your Pull Requests") + } + } + + // MARK: - Custom + + @ViewBuilder + private var configuredSection: some View { + Section { + if settings.agents.isEmpty { + Text("Add the checks and reviewers you care about.") + .foregroundStyle(.secondary) + } + ForEach(settings.agents) { agent in + HStack { + AgentSummaryRow(agent: agent) + Spacer() + Button("Edit") { editing = agent } + .buttonStyle(.borderless) + Button(role: .destructive) { + settings.agents.removeAll { $0.id == agent.id } + } label: { + Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .accessibilityLabel("Remove \(agent.name)") + } + } + } header: { + HStack { + Text("Agents") + Spacer() + addMenu + } + } + } + + private var addMenu: some View { + let configured = Set(settings.agents.map { Identity.normalize($0.login + $0.checkPattern) }) + let isNew: (Agent) -> Bool = { !configured.contains(Identity.normalize($0.login + $0.checkPattern)) } + let suggestions = monitor.discoveredAgents.filter(isNew) + let presets = Agent.presets.filter(isNew) + + return Menu { + if !suggestions.isEmpty { + Section("Seen on Your Pull Requests") { + ForEach(suggestions) { agent in + Button(agent.name) { settings.agents.append(Agent(name: agent.name, checkPattern: agent.checkPattern, login: agent.login)) } + } + } + } + Section("Popular") { + ForEach(presets, id: \.name) { agent in + Button(agent.name) { settings.agents.append(Agent(name: agent.name, checkPattern: agent.checkPattern, login: agent.login)) } + } + } + Divider() + Button("Custom Agent…") { editing = Agent(name: "") } + } label: { + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 22, height: 22) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .fixedSize() + .help("Add an agent") + .accessibilityLabel("Add an agent") + } +} + +/// One choice in an iOS-style checkmark list: title and description, checkmark when selected. +private struct ModeOption: View { + let title: String + let detail: String + let isSelected: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title).foregroundStyle(.primary) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(.tint) + .opacity(isSelected ? 1 : 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } +} + +private struct AgentSummaryRow: View { + let agent: Agent + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(agent.name) + Text(matchDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var matchDescription: String { + var parts: [String] = [] + if !agent.checkPattern.trimmed.isEmpty { parts.append("Checks matching “\(agent.checkPattern)”") } + if !agent.login.trimmed.isEmpty { parts.append("Reviews by @\(agent.login)") } + return parts.joined(separator: " · ") + } +} + +private struct AgentEditor: View { + @State var agent: Agent + let save: (Agent) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + TextField("Name", text: $agent.name, prompt: Text("Cursor Bugbot")) + } header: { + Text(agent.name.isEmpty ? "New Agent" : agent.name) + } + Section { + TextField("Check name contains", text: $agent.checkPattern, prompt: Text("cursor")) + .autocorrectionDisabled() + TextField("Bot account", text: $agent.login, prompt: Text("cursor")) + .autocorrectionDisabled() + } footer: { + Text("Checks match by name, app, or status creator. The bot account matches review requests, reviews, and review threads. Fill in at least one.") + } + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + save(agent) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!agent.isValid) + } + .padding([.horizontal, .bottom], 20) + } + .frame(width: 420, height: 340) + } +} diff --git a/Sources/PRMonitor/UI/Settings/RepositoriesPane.swift b/Sources/PRMonitor/UI/Settings/RepositoriesPane.swift new file mode 100644 index 0000000..7b9261e --- /dev/null +++ b/Sources/PRMonitor/UI/Settings/RepositoriesPane.swift @@ -0,0 +1,246 @@ +import SwiftUI + +struct RepositoriesPane: View { + @Environment(AppSettings.self) private var settings + @State private var isPickerPresented = false + + var body: some View { + @Bindable var settings = settings + Group { + if settings.repositories.isEmpty { + ContentUnavailableView { + Label("No Repositories", systemImage: "book.closed") + } description: { + Text("Choose the repositories whose pull requests you want to monitor.") + } actions: { + Button("Add Repositories…") { isPickerPresented = true } + .buttonStyle(.borderedProminent) + } + } else { + Form { + Section { + ForEach($settings.repositories) { $repository in + RepositoryRow(repository: $repository) { settings.untrack(repository.id) } + } + } header: { + HStack { + Text("Monitored Repositories") + Spacer() + AddButton(help: "Add repositories") { isPickerPresented = true } + } + } footer: { + Text("Turn a repository off to pause it without losing it.") + } + } + .formStyle(.grouped) + } + } + .sheet(isPresented: $isPickerPresented) { + RepositoryPicker() + } + } +} + +private struct RepositoryRow: View { + @Binding var repository: TrackedRepository + let remove: () -> Void + + var body: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(repository.id.name) + Text(repository.id.owner) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .opacity(repository.isEnabled ? 1 : 0.5) + Spacer() + Toggle("Monitor \(repository.id.fullName)", isOn: $repository.isEnabled) + .labelsHidden() + Button(role: .destructive, action: remove) { + Image(systemName: "minus.circle.fill") + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Stop monitoring \(repository.id.fullName)") + .accessibilityLabel("Remove \(repository.id.fullName)") + } + .contextMenu { + Button("Open on GitHub") { NSWorkspace.shared.open(repository.id.url) } + Button("Remove", role: .destructive, action: remove) + } + } +} + +/// Search the repositories you can access, or paste any `owner/name` or GitHub URL. +private struct RepositoryPicker: View { + @Environment(AppSettings.self) private var settings + @Environment(Account.self) private var account + @Environment(\.dismiss) private var dismiss + + @State private var query = "" + @State private var repositories: [RemoteRepository] = [] + @State private var isLoading = false + @State private var loadError: String? + @FocusState private var isSearchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 10) { + Text("Add Repositories") + .font(.headline) + TextField("Search, or paste owner/name or a GitHub URL", text: $query) + .textFieldStyle(.roundedBorder) + .focused($isSearchFocused) + .onSubmit(addTypedRepository) + } + .padding(16) + + Divider() + + List { + if let typed = typedRepository { + Button { + addTypedRepository() + } label: { + Label("Add \(typed.fullName)", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderless) + } + ForEach(filtered) { repository in + RemoteRepositoryRow(repository: repository, isTracked: settings.isTracking(repository.id)) { + if settings.isTracking(repository.id) { + settings.untrack(repository.id) + } else { + settings.track(repository.id) + } + } + } + } + .listStyle(.inset) + .overlay { overlay } + .animation(Motion.crossfade, value: repositories.isEmpty) + + Divider() + + HStack { + Text("\(settings.repositories.count) monitored") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(width: 460, height: 520) + .task { await load() } + .onAppear { isSearchFocused = true } + } + + @ViewBuilder + private var overlay: some View { + if isLoading && repositories.isEmpty { + ListRowSkeleton(count: 7) + .padding(.horizontal, 22) + .padding(.top, 14) + .frame(maxHeight: .infinity, alignment: .top) + .transition(.opacity) + } else if let loadError, repositories.isEmpty { + ContentUnavailableView("Couldn't Load Repositories", systemImage: "exclamationmark.triangle", description: Text(loadError)) + } else if !isLoading, filtered.isEmpty, typedRepository == nil { + ContentUnavailableView.search(text: query) + } + } + + private var filtered: [RemoteRepository] { + let text = query.trimmed + guard !text.isEmpty else { return repositories } + return repositories.filter { $0.id.fullName.localizedStandardContains(text) } + } + + /// A parsable repository that isn't in the list (e.g. from an org the token can see but doesn't belong to). + private var typedRepository: RepositoryID? { + guard let id = RepositoryID(parsing: query), !settings.isTracking(id) else { return nil } + return repositories.contains { $0.id == id } ? nil : id + } + + private func addTypedRepository() { + guard let id = RepositoryID(parsing: query) else { return } + settings.track(id) + query = "" + } + + private func load() async { + guard let token = account.credential?.token else { + loadError = "Sign in to see your repositories." + return + } + isLoading = true + defer { isLoading = false } + do { + repositories = try await GitHubClient(token: token).viewerRepositories() + } catch { + loadError = error.localizedDescription + } + } +} + +private struct RemoteRepositoryRow: View { + let repository: RemoteRepository + let isTracked: Bool + let toggle: () -> Void + + var body: some View { + Button(action: toggle) { + HStack(spacing: 10) { + Image(systemName: isTracked ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isTracked ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) + .contentTransition(.symbolEffect(.replace)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(repository.id.fullName) + .foregroundStyle(.primary) + if repository.isPrivate { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel("Private") + } + } + if let description = repository.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isTracked ? .isSelected : []) + } +} + +/// The small "+" that sits at the trailing edge of a section header. +struct AddButton: View { + let help: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 22, height: 22) + .contentShape(Circle()) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help(help) + .accessibilityLabel(help) + } +} diff --git a/Sources/PRMonitor/UI/Settings/SettingsView.swift b/Sources/PRMonitor/UI/Settings/SettingsView.swift new file mode 100644 index 0000000..0a9704c --- /dev/null +++ b/Sources/PRMonitor/UI/Settings/SettingsView.swift @@ -0,0 +1,340 @@ +import SwiftUI +import UserNotifications + +/// Settings in the style of System Settings: a sidebar with the account at the top and colored +/// icon tiles, and a detail pane whose toolbar holds that pane's actions. +struct SettingsView: View { + @Environment(AppRouter.self) private var router + + var body: some View { + NavigationSplitView { + SettingsSidebar() + .toolbar(removing: .sidebarToggle) + } detail: { + detail + .navigationTitle(router.settingsTab.title) + } + .frame(minWidth: 720, idealWidth: 760, minHeight: 500, idealHeight: 560) + // A menu bar app has no Dock icon; while Settings is open it behaves like a regular app + // (Dock, ⌘-Tab, app menu), then steps back when the window closes. + .onAppear { DockPresence.show() } + .onDisappear { DockPresence.hide() } + } + + @ViewBuilder + private var detail: some View { + switch router.settingsTab { + case .account: AccountPane() + case .general: GeneralPane() + case .notifications: NotificationsPane() + case .repositories: RepositoriesPane() + case .agents: AgentsPane() + } + } +} + +enum DockPresence { + @MainActor static func show() { + NSApp.setActivationPolicy(.regular) + NSApp.activate() + } + + @MainActor static func hide() { + NSApp.setActivationPolicy(.accessory) + } +} + +extension AppRouter.SettingsTab { + var title: String { + switch self { + case .account: "Account" + case .general: "General" + case .notifications: "Notifications" + case .repositories: "Repositories" + case .agents: "Agents" + } + } + + var symbol: String { + switch self { + case .account: "person.crop.circle.fill" + case .general: "gearshape" + case .notifications: "bell.badge" + case .repositories: "book.closed" + case .agents: "sparkles" + } + } +} + +// MARK: - Sidebar + +/// A neutral sidebar in the current iOS/iPadOS style. A system `List` always paints selection in the +/// accent color, so rows are drawn here: +/// - selection is a soft gray pill with primary-color text, and the selected glyph goes to full strength; +/// - hover is a barely-there wash, shown instantly, so it never competes with the selection; +/// - glyphs take the label's font, in a fixed-width column so titles align; +/// - everything scales with the user's Sidebar Icon Size preference, and arrow keys move the selection. +private struct SettingsSidebar: View { + @Environment(AppRouter.self) private var router + @Environment(Account.self) private var account + @FocusState private var isFocused: Bool + + private let order: [AppRouter.SettingsTab] = [.account, .general, .notifications, .repositories, .agents] + + var body: some View { + ScrollView { + // One even rhythm between rows; only the account row gets breathing room below it. + VStack(alignment: .leading, spacing: 2) { + ForEach(order, id: \.self) { tab in + SidebarRow(tab: tab, isSelected: router.settingsTab == tab) { + router.settingsTab = tab + } + .padding(.bottom, tab == .account ? 8 : 0) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .scrollBounceBehavior(.basedOnSize) + .focusable() + .focused($isFocused) + .focusEffectDisabled() + .onKeyPress(.upArrow) { move(by: -1) } + .onKeyPress(.downArrow) { move(by: 1) } + // Wide enough for "Notifications" at the Large sidebar size, like System Settings. + .navigationSplitViewColumnWidth(min: 215, ideal: 230, max: 300) + .frame(minWidth: 215) + } + + private func move(by offset: Int) -> KeyPress.Result { + guard let index = order.firstIndex(of: router.settingsTab) else { return .ignored } + router.settingsTab = order[max(0, min(order.count - 1, index + offset))] + return .handled + } +} + +private struct SidebarRow: View { + let tab: AppRouter.SettingsTab + let isSelected: Bool + let select: () -> Void + + @Environment(Account.self) private var account + @Environment(\.sidebarRowSize) private var rowSize + @State private var isHovered = false + + var body: some View { + Button(action: select) { + Group { + if tab == .account { + AccountSidebarRow(credential: account.credential) + } else { + HStack(spacing: 8) { + Image(systemName: tab.symbol) + .foregroundStyle(isSelected ? .primary : .secondary) + .frame(width: iconColumn) + Text(tab.title) + .foregroundStyle(.primary) + .lineLimit(1) + } + .font(font) + .frame(minHeight: rowHeight) + } + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect(cornerRadius: 8)) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + // Hover is a whisper; only the selection reads as a solid pill. + .fill(.primary.opacity(isSelected ? 0.1 : (isHovered ? 0.035 : 0))) + } + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .accessibilityLabel(tab == .account ? (account.credential?.login ?? "Account") : tab.title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var font: Font { + switch rowSize { + case .small: .subheadline + case .large: .title3 + default: .body + } + } + + private var rowHeight: CGFloat { + switch rowSize { + case .small: 24 + case .large: 34 + default: 28 + } + } + + private var iconColumn: CGFloat { + switch rowSize { + case .small: 16 + case .large: 22 + default: 18 + } + } +} + +/// The account row at the top of the sidebar, like System Settings' Apple Account row. It's the +/// only custom row, so it follows the user's Sidebar Icon Size preference the way system rows do. +private struct AccountSidebarRow: View { + let credential: Account.Credential? + @Environment(\.sidebarRowSize) private var rowSize + + var body: some View { + HStack(spacing: 8) { + Avatar(login: credential?.login, size: avatarSize) + VStack(alignment: .leading, spacing: 0) { + Text(credential?.login ?? "Connect GitHub") + .fontWeight(.semibold) + .lineLimit(1) + Text(credential == nil ? "Not signed in" : "GitHub") + .font(subtitleFont) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 3) + } + + private var avatarSize: CGFloat { + switch rowSize { + case .small: 24 + case .large: 34 + default: 28 + } + } + + private var subtitleFont: Font { + switch rowSize { + case .small: .caption + case .large: .callout + default: .subheadline + } + } +} + +// MARK: - General + +private struct GeneralPane: View { + @Environment(AppSettings.self) private var settings + @State private var launchesAtLogin = LoginItem.isEnabled + @State private var loginItemError: String? + + var body: some View { + @Bindable var settings = settings + Form { + Section { + Picker("Check for changes", selection: $settings.refreshInterval) { + ForEach(AppSettings.refreshIntervals, id: \.self) { interval in + Text(Duration.seconds(interval).formatted(.units(allowed: [.minutes, .seconds], width: .wide))) + .tag(interval) + } + } + } footer: { + Text("How often to check while agents are running. PR Monitor checks less often once everything has settled, and waits out GitHub rate limits on its own.") + } + + Section("Menu Bar") { + Picker("Show pull requests", selection: $settings.scope) { + Text("All open pull requests").tag(AppSettings.Scope.all) + Text("Only ones I opened").tag(AppSettings.Scope.authored) + } + Toggle("Hide drafts", isOn: $settings.hidesDrafts) + Toggle(isOn: $settings.showsAttentionCount) { + Text("Show attention count") + Text("The number of pull requests that are failing or ready for your review.") + } + Toggle(isOn: $settings.usesColoredStatusIcon) { + Text("Use colored status icon") + Text("Otherwise the icon matches the other menu bar icons.") + } + } + + Section { + Toggle("Open at login", isOn: $launchesAtLogin) + .onChange(of: launchesAtLogin) { _, enabled in setLaunchAtLogin(enabled) } + } footer: { + if let loginItemError { + Text(loginItemError).foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + } + + private func setLaunchAtLogin(_ enabled: Bool) { + do { + try LoginItem.setEnabled(enabled) + loginItemError = LoginItem.requiresApproval ? "Approve PR Monitor in System Settings › General › Login Items." : nil + } catch { + loginItemError = error.localizedDescription + launchesAtLogin = LoginItem.isEnabled + } + } +} + +// MARK: - Notifications + +private struct NotificationsPane: View { + @Environment(AppSettings.self) private var settings + @State private var status: UNAuthorizationStatus? + @State private var testResult: String? + + var body: some View { + @Bindable var settings = settings + Form { + Section { + Toggle(isOn: $settings.notifiesWhenSettled) { + Text("When agents finish") + Text("Once every check and review bot has reported on the latest push.") + } + Toggle(isOn: $settings.notifiesOnNewFeedback) { + Text("When new feedback arrives") + Text("If a bot leaves review threads after a pull request has settled.") + } + Toggle(isOn: $settings.notifiesPerAgent) { + Text("When each agent finishes") + Text("A separate notification for every check and reviewer.") + } + } + + Section { + LabeledContent { + if status == .denied { + Button("Open System Settings…") { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension")!) + } + } else { + Button("Send Test") { + Task { + testResult = await Notifier().postTest() ? "Sent" : "Couldn't send" + status = await Notifier().authorizationStatus() + } + } + .disabled(!Notifier.isAvailable) + } + } label: { + Text("Permission") + Text(testResult ?? permissionDescription) + } + } + } + .formStyle(.grouped) + .task { status = await Notifier().authorizationStatus() } + } + + private var permissionDescription: String { + guard Notifier.isAvailable else { return "Unavailable when running outside the app bundle." } + return switch status { + case .authorized, .provisional, .ephemeral: "Allowed" + case .denied: "Turned off in System Settings" + default: "Not requested yet" + } + } +} diff --git a/Sources/PRMonitor/Utils/AsyncSemaphore.swift b/Sources/PRMonitor/Utils/AsyncSemaphore.swift deleted file mode 100644 index 0deb612..0000000 --- a/Sources/PRMonitor/Utils/AsyncSemaphore.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -final class AsyncSemaphore: @unchecked Sendable { - private let lock = NSLock() - private var permits: Int - private var waiters: [Waiter] = [] - - private struct Waiter { - let id: UUID - let continuation: CheckedContinuation - } - - init(value: Int) { - self.permits = max(0, value) - } - - func wait() async { - if Task.isCancelled { return } - let waiterId = UUID() - await withTaskCancellationHandler(operation: { - await withCheckedContinuation { continuation in - lock.lock() - if Task.isCancelled { - lock.unlock() - continuation.resume() - return - } - if permits > 0 { - permits -= 1 - lock.unlock() - continuation.resume() - } else { - waiters.append(Waiter(id: waiterId, continuation: continuation)) - lock.unlock() - } - } - }, onCancel: { - lock.lock() - if let index = waiters.firstIndex(where: { $0.id == waiterId }) { - let waiter = waiters.remove(at: index) - lock.unlock() - waiter.continuation.resume() - } else { - lock.unlock() - } - }) - } - - func signal() { - lock.lock() - if !waiters.isEmpty { - let continuation = waiters.removeFirst().continuation - lock.unlock() - continuation.resume() - } else { - permits += 1 - lock.unlock() - } - } -} diff --git a/Sources/PRMonitor/Utils/Keychain.swift b/Sources/PRMonitor/Utils/Keychain.swift deleted file mode 100644 index e7fe9cd..0000000 --- a/Sources/PRMonitor/Utils/Keychain.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Security - -enum Keychain { - static func save(service: String, account: String, value: String) { - let data = Data(value.utf8) - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account - ] - SecItemDelete(query as CFDictionary) - let attributes: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - kSecValueData: data - ] - SecItemAdd(attributes as CFDictionary, nil) - } - - static func load(service: String, account: String) -> String? { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne - ] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - static func delete(service: String, account: String) { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/Sources/PRMonitor/Utils/NotificationAvailability.swift b/Sources/PRMonitor/Utils/NotificationAvailability.swift deleted file mode 100644 index c576ede..0000000 --- a/Sources/PRMonitor/Utils/NotificationAvailability.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -enum NotificationAvailability { - static var isAvailable: Bool { - let env = ProcessInfo.processInfo.environment - if env["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { - return false - } - if env["XCTestConfigurationFilePath"] != nil { - return false - } - if let bundleType = Bundle.main.object(forInfoDictionaryKey: "CFBundlePackageType") as? String, - bundleType == "APPL" { - return true - } - return Bundle.main.bundleURL.pathExtension == "app" - } -} diff --git a/Sources/PRMonitor/Views/ContentView.swift b/Sources/PRMonitor/Views/ContentView.swift deleted file mode 100644 index e4ee73b..0000000 --- a/Sources/PRMonitor/Views/ContentView.swift +++ /dev/null @@ -1,582 +0,0 @@ -import SwiftUI -import AppKit -import Foundation - -struct ContentView: View { - @EnvironmentObject private var appState: AppState - @State private var expandedPRs: Set = [] - @State private var hoveredPRs: Set = [] - @State private var hoveredMenuItem: String? = nil - @State private var listContentHeight: CGFloat = 0 - @State private var headerHeight: CGFloat = 48 - @State private var footerHeight: CGFloat = 96 - @State private var errorHeight: CGFloat = 0 - @Environment(\.openSettings) private var openSettings - - private let maxPopoverHeight: CGFloat - private let onSizeChange: ((CGSize) -> Void)? - private let preferredWidth: CGFloat = 520 - - init(maxPopoverHeight: CGFloat = 760, onSizeChange: ((CGSize) -> Void)? = nil) { - self.maxPopoverHeight = maxPopoverHeight - self.onSizeChange = onSizeChange - } - - var body: some View { - VStack(spacing: 12) { - header - if let error = appState.errorMessage { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - .background( - GeometryReader { proxy in - Color.clear.preference(key: ErrorHeightKey.self, value: proxy.size.height) - } - ) - } - Divider() - content - footer - } - .padding(12) - .frame(width: preferredWidth, height: windowHeight) - .background( - GeometryReader { proxy in - Color.clear.preference(key: RootSizeKey.self, value: proxy.size) - } - ) - .onPreferenceChange(RootSizeKey.self) { size in - onSizeChange?(size) - } - .onPreferenceChange(ErrorHeightKey.self) { height in - errorHeight = height - } - .onChange(of: appState.errorMessage) { _, value in - if value == nil { errorHeight = 0 } - } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("PR Monitor") - .font(.headline) - HStack(spacing: 6) { - Text(statusText) - if let detail = statusDetailText { - Text("• \(detail)") - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - .background( - GeometryReader { proxy in - Color.clear.preference(key: HeaderHeightKey.self, value: proxy.size.height) - } - ) - .onPreferenceChange(HeaderHeightKey.self) { height in - headerHeight = height - } - } - - @ViewBuilder - private var content: some View { - if !appState.authStore.isSignedIn { - unavailableView( - title: "Sign in to GitHub", - systemImage: "person.crop.circle.badge.xmark", - message: "Connect GitHub to start monitoring." - ) - } else if appState.repoSections.isEmpty { - unavailableView( - title: "No Open Pull Requests", - systemImage: "tray", - message: "No open PRs in tracked repos." - ) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(appState.repoSections) { section in - repoSection(section) - } - } - .background( - GeometryReader { proxy in - Color.clear.preference(key: ListContentHeightKey.self, value: proxy.size.height) - } - ) - } - .onPreferenceChange(ListContentHeightKey.self) { height in - listContentHeight = height - } - .frame(height: listHeight) - } - } - - private func repoSection(_ section: RepoSection) -> some View { - GroupBox { - VStack(alignment: .leading, spacing: 8) { - ForEach(section.prs) { pr in - prRow(pr) - } - } - } label: { - HStack(spacing: 8) { - Text(section.fullName) - .font(.caption.weight(.semibold)) - Text("• \(section.prs.count) PR\(section.prs.count == 1 ? "" : "s")") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - - private func prRow(_ pr: PRItem) -> some View { - let isExpanded = expandedPRs.contains(prKey(pr)) - let isHovered = hoveredPRs.contains(prKey(pr)) - return VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .center, spacing: 8) { - Button { - toggleExpanded(pr) - } label: { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 18, height: 18) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(isExpanded ? "Hide checks" : "Show checks") - - Button { - openPR(pr.url) - } label: { - HStack(spacing: 8) { - Text("#\(pr.number)") - .font(.callout.weight(.semibold)) - .monospacedDigit() - Text(pr.title) - .font(.callout) - .lineLimit(1) - - Spacer(minLength: 12) - - StatusPill(status: prStatus(pr)) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - Text("by \(pr.author)") - .font(.caption) - .foregroundStyle(.secondary) - } - if isExpanded { - Divider() - VStack(alignment: .leading, spacing: 6) { - if pr.agents.isEmpty { - Text("No checks configured for this repo.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(pr.agents) { agent in - agentRow(agent) - } - } - } - .padding(.leading, 4) - } - } - .padding(12) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(isHovered ? Color.primary.opacity(0.12) : Color.clear, lineWidth: 1) - ) - .animation(.easeInOut(duration: 0.15), value: isHovered) - .onHover { hovering in - let key = prKey(pr) - if hovering { - hoveredPRs.insert(key) - NSCursor.pointingHand.push() - } else { - hoveredPRs.remove(key) - NSCursor.pop() - } - } - .contextMenu { - Button("Open Pull Request") { - openPR(pr.url) - } - Button("Open Checks") { - openPR(checksURL(for: pr)) - } - Button("Copy URL") { - copyToPasteboard(pr.url.absoluteString) - } - Button("Copy Title") { - copyToPasteboard("#\(pr.number) \(pr.title)") - } - } - } - - private func agentRow(_ agent: AgentRun) -> some View { - HStack(spacing: 8) { - Circle() - .fill(agent.status.color) - .frame(width: 8, height: 8) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(agent.displayName) - .font(.caption) - } - Spacer() - Text(agentStatusText(agent)) - .font(.caption) - .foregroundStyle(.secondary) - } - .accessibilityLabel("\(agent.displayName), \(agentStatusText(agent))") - } - - private func prStatus(_ pr: PRItem) -> AgentRunStatus { - if pr.agents.contains(where: { $0.status == .running }) { return .running } - if pr.agents.contains(where: { $0.status == .failed }) { return .failed } - if pr.agents.contains(where: { $0.status == .waitingForComment || $0.status == .notFound }) { return .waitingForComment } - return .done - } - - private func agentStatusText(_ agent: AgentRun) -> String { - switch agent.status { - case .running: - return "Running" - case .waitingForComment: - return agent.commentCount > 0 ? "Needs review" : "Waiting" - case .failed: - return conclusionText(agent) ?? "Failed" - case .done: - return conclusionText(agent) ?? "Done" - case .notFound: - return "No check yet" - } - } - - private func emptyState(text: String) -> some View { - VStack(spacing: 8) { - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: 80) - } - - private var settingsAction: some View { - Group { - if #available(macOS 14.0, *) { - SettingsLink(label: { - Text("Open Settings") - }) - } else { - Button("Open Settings") { - openSettingsWindow() - } - } - } - } - - private var footer: some View { - VStack(spacing: 6) { - Divider() - VStack(spacing: 0) { - Button { - appState.refreshNow() - } label: { - menuRowLabel( - id: "refresh", - title: appState.isRefreshing ? "Refreshing" : "Refresh", - systemImage: "arrow.clockwise", - trailing: appState.isRefreshing ? .progress : nil - ) - } - .buttonStyle(.plain) - .disabled(appState.isRefreshing) - .keyboardShortcut("r") - .help("Refresh now") - - settingsFooterAction - - Button { - NSApp.terminate(nil) - } label: { - menuRowLabel(id: "quit", title: "Quit PR Monitor", systemImage: "power") - } - .buttonStyle(.plain) - .keyboardShortcut("q") - } - } - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 6) - .background( - GeometryReader { proxy in - Color.clear.preference(key: FooterHeightKey.self, value: proxy.size.height) - } - ) - .onPreferenceChange(FooterHeightKey.self) { height in - footerHeight = height - } - } - - private var settingsFooterAction: some View { - Group { - if #available(macOS 14.0, *) { - SettingsLink(label: { - menuRowLabel(id: "settings", title: "Settings…", systemImage: "gearshape") - }) - } else { - Button { - openSettingsWindow() - } label: { - menuRowLabel(id: "settings", title: "Settings…", systemImage: "gearshape") - } - } - } - .buttonStyle(.plain) - .help("Open Settings") - } - - private enum MenuRowTrailing { - case progress - } - - @ViewBuilder - private func menuRowLabel( - id: String, - title: String, - systemImage: String? = nil, - isEnabled: Bool = true, - trailing: MenuRowTrailing? = nil - ) -> some View { - HStack(spacing: 8) { - if let systemImage { - Image(systemName: systemImage) - .frame(width: 16, height: 16) - .foregroundStyle(.secondary) - } - Text(title) - Spacer() - if trailing == .progress { - ProgressView() - .controlSize(.mini) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background( - hoveredMenuItem == id ? Color.primary.opacity(0.08) : Color.clear, - in: RoundedRectangle(cornerRadius: 5, style: .continuous) - ) - .opacity(isEnabled ? 1 : 0.5) - .contentShape(Rectangle()) - .animation(.easeInOut(duration: 0.15), value: hoveredMenuItem == id) - .onHover { hovering in - guard isEnabled else { return } - hoveredMenuItem = hovering ? id : nil - } - } - - private func prKey(_ pr: PRItem) -> String { - "\(pr.repoFullName)#\(pr.number)" - } - - private func toggleExpanded(_ pr: PRItem) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - let key = prKey(pr) - if expandedPRs.contains(key) { - expandedPRs.remove(key) - } else { - expandedPRs.insert(key) - } - } - } - - - private func checksURL(for pr: PRItem) -> URL { - pr.url.appendingPathComponent("checks") - } - - private func conclusionText(_ agent: AgentRun) -> String? { - guard agent.status == .done || agent.status == .failed else { return nil } - guard let conclusion = agent.checkConclusion, !conclusion.isEmpty else { return nil } - return conclusion.replacingOccurrences(of: "_", with: " ").capitalized - } - - - private func openSettingsWindow() { - openSettings() - } - - private func openPR(_ url: URL) { - NSWorkspace.shared.open(url) - } - - private func copyToPasteboard(_ value: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(value, forType: .string) - } - - private var statusText: String { - if let login = appState.viewerLogin { - return "Signed in as \(login)" - } - return appState.authStore.isSignedIn ? "Connected" : "Sign in required" - } - - private var statusDetailText: String? { - if appState.isRefreshing { - return "Refreshing…" - } - guard let last = appState.lastRefresh else { return nil } - return "Updated \(relativeTime(from: last))" - } - - private var totalPRCount: Int { - appState.repoSections.reduce(0) { $0 + $1.prs.count } - } - - private var listHeight: CGFloat { - guard appState.authStore.isSignedIn, totalPRCount > 0 else { return 0 } - let maxList = maxListHeight - let content = listContentHeight > 0 ? listContentHeight : maxList - return min(content, maxList) - } - - private var windowHeight: CGFloat { - guard appState.authStore.isSignedIn, totalPRCount > 0 else { return 260 } - return min(maxPopoverHeight, chromeHeight + listHeight) - } - - private var chromeHeight: CGFloat { - let padding: CGFloat = 24 - let dividerHeight: CGFloat = 1 - let elements = 3 + (appState.errorMessage == nil ? 0 : 1) - let gaps = max(0, elements - 1) - let spacing = CGFloat(gaps) * 12 - return headerHeight + footerHeight + errorHeight + padding + dividerHeight + spacing - } - - private var maxListHeight: CGFloat { - let available = maxPopoverHeight - chromeHeight - return max(120, available) - } - - private func relativeTime(from date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } - - @ViewBuilder - private func unavailableView(title: String, systemImage: String, message: String) -> some View { - if #available(macOS 14.0, *) { - ContentUnavailableView(label: { - Label(title, systemImage: systemImage) - }, description: { - Text(message) - }, actions: { - settingsAction - }) - } else { - emptyState(text: message) - } - } -} - -private struct StatusPill: View { - let status: AgentRunStatus - - private var text: String { - switch status { - case .running: - return "Running" - case .failed: - return "Failed" - case .waitingForComment: - return "Waiting" - case .done: - return "Ready" - case .notFound: - return "Pending" - } - } - - var body: some View { - Text(text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(status.color.opacity(0.2), in: Capsule()) - .foregroundStyle(status.color) - .accessibilityLabel("Status") - .accessibilityValue(text) - } -} - -extension AgentRunStatus { - var color: Color { - switch self { - case .running: return .blue - case .waitingForComment: return .orange - case .notFound: return .gray - case .failed: return .red - case .done: return .green - } - } -} - -private struct RootSizeKey: PreferenceKey { - static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - let next = nextValue() - if next != .zero { value = next } - } -} - -private struct HeaderHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} - -private struct FooterHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} - -private struct ErrorHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} - -private struct ListContentHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} diff --git a/Sources/PRMonitor/Views/SettingsView.swift b/Sources/PRMonitor/Views/SettingsView.swift deleted file mode 100644 index b87e919..0000000 --- a/Sources/PRMonitor/Views/SettingsView.swift +++ /dev/null @@ -1,639 +0,0 @@ -import SwiftUI -import UserNotifications -import AppKit - -struct SettingsView: View { - var body: some View { - TabView { - GeneralSettingsTab() - .tabItem { - Label("General", systemImage: "gearshape") - } - RepositoriesSettingsTab() - .tabItem { - Label("Repositories", systemImage: "folder") - } - AgentsSettingsTab() - .tabItem { - Label("Agents", systemImage: "cpu") - } - } - .padding(20) - .frame(width: 600, height: 500) - } -} - -// MARK: - General Settings Tab - -private struct GeneralSettingsTab: View { - @EnvironmentObject private var settings: SettingsStore - @EnvironmentObject private var authStore: AuthStore - - @State private var notificationStatus: UNAuthorizationStatus = .notDetermined - @State private var notificationStatusDetail = "" - - var body: some View { - Form { - accountSection - pollingSection - notificationsSection - } - .formStyle(.grouped) - .onAppear { - loadNotificationStatus() - } - } - - // MARK: Account - - @ViewBuilder - private var accountSection: some View { - Section { - HStack(spacing: 8) { - if authStore.isSignedIn { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityHidden(true) - Text("Signed in") - .accessibilityValue("Connected to GitHub") - } else { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.yellow) - .accessibilityHidden(true) - Text("Not signed in") - .accessibilityValue("Not connected to GitHub") - } - Spacer() - if authStore.isSignedIn { - Button("Sign Out") { - authStore.signOut() - } - } else { - Button(authStore.isSigningIn ? "Signing In..." : "Sign In") { - authStore.signIn(clientId: settings.githubClientId) - } - .disabled(authStore.isSigningIn || settings.githubClientId.isEmpty) - } - } - - if let flow = authStore.deviceFlow { - VStack(alignment: .leading, spacing: 8) { - Text("Enter this code at GitHub:") - .font(.caption) - .foregroundStyle(.secondary) - Text(flow.userCode) - .font(.system(size: 20, weight: .semibold, design: .monospaced)) - .textSelection(.enabled) - Button { - NSWorkspace.shared.open(flow.verificationURL) - } label: { - Label("Open GitHub", systemImage: "arrow.up.right.square") - } - } - } - - TextField("GitHub OAuth Client ID", text: $settings.githubClientId) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - .accessibilityLabel("GitHub OAuth Client ID") - - if let message = authStore.statusMessage { - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - } - } header: { - Text("Account") - } footer: { - Text("Create an OAuth app at github.com/settings/developers") - } - } - - // MARK: Polling - - private var pollingSection: some View { - Section { - Picker("Polling Interval", selection: $settings.pollingIntervalSeconds) { - Text("30 seconds").tag(30) - Text("1 minute").tag(60) - Text("2 minutes").tag(120) - Text("5 minutes").tag(300) - } - .pickerStyle(.segmented) - } header: { - Text("Polling") - } footer: { - Text("When no open PRs are found, refreshes every 10 minutes.") - } - } - - // MARK: Notifications - - private var notificationsSection: some View { - Section { - Toggle("All agents complete", isOn: .constant(true)) - .disabled(true) - .accessibilityValue("Always enabled") - Toggle("Review summary", isOn: $settings.notifySummary) - Toggle("Per-agent completion", isOn: $settings.notifyPerAgent) - Button("Send Test Notification") { - sendTestNotification() - } - .accessibilityLabel("Send a test notification") - } header: { - Text("Notifications") - } footer: { - VStack(alignment: .leading, spacing: 6) { - Text("Receive notifications when agent checks complete on your PRs") - Text(notificationStatusDetail) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityValue(notificationStatusDetail) - } - } - } - - // MARK: Helpers - - private func loadNotificationStatus() { - guard notificationsAvailable else { - notificationStatus = .notDetermined - notificationStatusDetail = "Notifications require running the app bundle (not previews or `swift run`)." - return - } - UNUserNotificationCenter.current().getNotificationSettings { notiSettings in - Task { @MainActor in - notificationStatus = notiSettings.authorizationStatus - switch notiSettings.authorizationStatus { - case .authorized: - notificationStatusDetail = "Notifications are allowed in System Settings." - case .denied: - notificationStatusDetail = "Notifications are blocked. Enable PR Monitor in System Settings > Notifications." - case .notDetermined: - notificationStatusDetail = "Notifications haven't been requested yet." - case .provisional: - notificationStatusDetail = "Notifications are in provisional mode." - case .ephemeral: - notificationStatusDetail = "Notifications are temporary (ephemeral)." - @unknown default: - notificationStatusDetail = "Notification permission status unknown." - } - } - } - } - - private func sendTestNotification() { - guard notificationsAvailable else { - notificationStatusDetail = "Notifications require running the app bundle (not previews or `swift run`)." - return - } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in - let content = UNMutableNotificationContent() - content.title = "PR Monitor" - content.body = "Test notification." - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) { addError in - Task { @MainActor in - if let error { - self.notificationStatusDetail = "Notification authorization failed: \(error.localizedDescription)" - } else if let addError { - self.notificationStatusDetail = "Notification delivery failed: \(addError.localizedDescription)" - } else if !granted { - self.notificationStatusDetail = "Notifications are blocked. Enable PR Monitor in System Settings > Notifications." - } else { - self.notificationStatusDetail = "Test notification queued." - } - loadNotificationStatus() - } - } - } - } - - private var notificationsAvailable: Bool { - NotificationAvailability.isAvailable - } -} - -// MARK: - Repositories Settings Tab - -private struct RepositoriesSettingsTab: View { - @EnvironmentObject private var settings: SettingsStore - @EnvironmentObject private var authStore: AuthStore - - @State private var newRepoText = "" - @State private var repoSearch = "" - @State private var availableRepos: [RepoDTO] = [] - @State private var isLoadingRepos = false - @State private var repoLoadError: String? - - private var filteredAvailableRepos: [RepoDTO] { - let trimmed = repoSearch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return availableRepos } - return availableRepos.filter { $0.fullName.localizedCaseInsensitiveContains(trimmed) } - } - - private var canAddRepo: Bool { - let trimmed = newRepoText.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split(separator: "/") - return parts.count == 2 && !parts[0].isEmpty && !parts[1].isEmpty - } - - var body: some View { - Form { - trackedReposSection - addRepoSection - browseGitHubSection - if !availableRepos.isEmpty { - filterSection - availableReposSection - } - } - .formStyle(.grouped) - } - - // MARK: Tracked Repos - - private var trackedReposSection: some View { - Section { - if settings.repos.isEmpty { - Text("No repositories added yet.") - .foregroundStyle(.secondary) - } else { - ForEach($settings.repos) { $repo in - Toggle(repo.fullName, isOn: $repo.isEnabled) - .contextMenu { - Button("Remove", role: .destructive) { - settings.repos.removeAll { $0.id == repo.id } - } - } - } - .onDelete { indexSet in - settings.repos.remove(atOffsets: indexSet) - } - } - } header: { - Text("Tracked Repositories") - } - } - - // MARK: Add Repo - - private var addRepoSection: some View { - Section { - HStack { - TextField("owner/repo", text: $newRepoText) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - .onSubmit { addRepo() } - .accessibilityLabel("Repository name in owner/repo format") - Button("Add") { addRepo() } - .disabled(!canAddRepo) - } - } header: { - Text("Add Repository") - } - } - - // MARK: Browse GitHub - - private var browseGitHubSection: some View { - Section { - Button { - loadRepos() - } label: { - if isLoadingRepos { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Fetching repositories...") - } - } else { - Label("Fetch from GitHub", systemImage: "arrow.down.circle") - } - } - .disabled(isLoadingRepos || !authStore.isSignedIn) - - if !authStore.isSignedIn { - Text("Sign in required to fetch repositories.") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let error = repoLoadError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } header: { - Text("Browse GitHub Repositories") - } footer: { - if !availableRepos.isEmpty { - Text("\(availableRepos.count) repositories available") - } else { - Text("Fetch your repositories to add them to tracking") - } - } - } - - // MARK: Filter & Available - - private var filterSection: some View { - Section { - HStack { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - TextField("Search repositories", text: $repoSearch) - .textFieldStyle(.roundedBorder) - if !repoSearch.isEmpty { - Button { - repoSearch = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .help("Clear search") - .accessibilityLabel("Clear search") - } - } - } header: { - Text("Filter") - } - } - - private var availableReposSection: some View { - Section { - ForEach(filteredAvailableRepos) { repo in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(repo.fullName) - if repo.isPrivate { - Label("Private", systemImage: "lock.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer() - Toggle("", isOn: bindingForRepo(repo)) - .labelsHidden() - .accessibilityLabel("Track \(repo.fullName)") - } - } - } header: { - Text("Available Repositories") - } footer: { - if !repoSearch.isEmpty { - Text("Showing \(filteredAvailableRepos.count) of \(availableRepos.count) repositories") - } - } - } - - // MARK: Helpers - - private func bindingForRepo(_ repo: RepoDTO) -> Binding { - Binding( - get: { - settings.repos.first(where: { $0.fullName == repo.fullName })?.isEnabled ?? false - }, - set: { enabled in - settings.setRepoEnabled(owner: repo.owner.login, name: repo.name, enabled: enabled) - } - ) - } - - private func addRepo() { - if settings.addRepo(from: newRepoText) != nil { - newRepoText = "" - } - } - - private func loadRepos() { - guard authStore.isSignedIn else { - repoLoadError = "Sign in to fetch repos." - return - } - repoLoadError = nil - isLoadingRepos = true - - let client = GitHubClient(tokenProvider: { authStore.token }) - Task { - do { - let repos = try await client.fetchAllViewerRepos() - let sorted = repos.sorted { $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending } - await MainActor.run { - availableRepos = sorted - isLoadingRepos = false - } - } catch { - await MainActor.run { - repoLoadError = error.localizedDescription - isLoadingRepos = false - } - } - } - } -} - -// MARK: - Agents Settings Tab - -private struct AgentsSettingsTab: View { - @EnvironmentObject private var settings: SettingsStore - @EnvironmentObject private var authStore: AuthStore - - @State private var newAgentName = "" - @State private var newAgentCheckPattern = "" - @State private var newAgentCommentAuthor = "" - @State private var isDetectingAgents = false - @State private var agentDetectError: String? - - var body: some View { - Form { - quickSetupSection - configuredAgentsSection - addAgentSection - } - .formStyle(.grouped) - } - - // MARK: Quick Setup - - private var quickSetupSection: some View { - Section { - Button { - detectAgents() - } label: { - if isDetectingAgents { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Detecting agents...") - } - } else { - Label("Auto-detect from Open PRs", systemImage: "sparkles") - } - } - .disabled(isDetectingAgents || !authStore.isSignedIn || settings.enabledRepos.isEmpty) - - if let error = agentDetectError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } else if settings.enabledRepos.isEmpty { - Text("Enable repositories first") - .font(.caption) - .foregroundStyle(.secondary) - } - } header: { - Text("Quick Setup") - } footer: { - Text("Automatically find agents from your pull requests") - } - } - - // MARK: Configured Agents - - @ViewBuilder - private var configuredAgentsSection: some View { - Section { - if settings.agents.isEmpty { - Text("No agents configured yet.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) - } else { - ForEach($settings.agents) { $agent in - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(agent.displayName.isEmpty ? "Unnamed Agent" : agent.displayName) - .font(.headline) - if !agent.checkNamePattern.isEmpty { - Text("Check: \(agent.checkNamePattern)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - Button(role: .destructive) { - settings.agents.removeAll { $0.id == agent.id } - } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - } - .buttonStyle(.borderless) - .help("Remove agent") - .accessibilityLabel("Remove \(agent.displayName)") - } - - DisclosureGroup("Edit") { - VStack(alignment: .leading, spacing: 8) { - TextField("Display Name", text: $agent.displayName) - .textFieldStyle(.roundedBorder) - TextField("Check Name Pattern", text: $agent.checkNamePattern) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - TextField("Comment Author Login", text: $agent.commentAuthor) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - } - } - .font(.caption) - } - } - .onDelete { indexSet in - settings.agents.remove(atOffsets: indexSet) - } - } - } header: { - Text("Configured Agents") - } footer: { - Text("Each agent monitors specific checks and comments on PRs") - } - } - - // MARK: Add Agent - - private var addAgentSection: some View { - Section { - TextField("Display Name", text: $newAgentName) - .textFieldStyle(.roundedBorder) - TextField("Check Name Pattern", text: $newAgentCheckPattern) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - TextField("Comment Author Login", text: $newAgentCommentAuthor) - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - Button("Add Agent") { - let agent = AgentConfig( - displayName: newAgentName, - checkNamePattern: newAgentCheckPattern, - commentAuthor: newAgentCommentAuthor - ) - guard !agent.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - settings.agents.append(agent) - newAgentName = "" - newAgentCheckPattern = "" - newAgentCommentAuthor = "" - } - .disabled(newAgentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } header: { - Text("Add New Agent") - } footer: { - Text("Agents match GitHub check runs and comment authors") - } - } - - // MARK: Helpers - - private func detectAgents() { - guard authStore.isSignedIn else { - agentDetectError = "Sign in to detect agents." - return - } - guard !settings.enabledRepos.isEmpty else { - agentDetectError = "Enable at least one repo." - return - } - agentDetectError = nil - isDetectingAgents = true - - let client = GitHubClient(tokenProvider: { authStore.token }) - let service = AgentDiscoveryService(client: client) - - Task { - do { - let discovered = try await service.discoverAgents(repos: settings.enabledRepos) - await MainActor.run { - mergeAgents(discovered) - isDetectingAgents = false - } - } catch { - await MainActor.run { - agentDetectError = error.localizedDescription - isDetectingAgents = false - } - } - } - } - - private func mergeAgents(_ discovered: [AgentConfig]) { - for agent in discovered { - let exists = settings.agents.contains { existing in - normalize(existing.displayName) == normalize(agent.displayName) - || normalize(existing.checkNamePattern) == normalize(agent.checkNamePattern) - || (!agent.commentAuthor.isEmpty && normalize(existing.commentAuthor) == normalize(agent.commentAuthor)) - } - if !exists { - settings.agents.append(agent) - } - } - } - - private func normalize(_ value: String) -> String { - let lowered = value.lowercased() - let removedBot = lowered.replacingOccurrences(of: "[bot]", with: "") - let allowed = removedBot.filter { $0.isLetter || $0.isNumber } - return allowed - } -} diff --git a/Sources/PRMonitor/Views/StatusBarLabel.swift b/Sources/PRMonitor/Views/StatusBarLabel.swift deleted file mode 100644 index c24ae46..0000000 --- a/Sources/PRMonitor/Views/StatusBarLabel.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -struct StatusBarLabel: View { - let status: AppState.OverallStatus - - private var color: Color { - switch status { - case .needsAuth: - return .gray - case .idle: - return .gray - case .running: - return .blue - case .failed: - return .red - case .waiting: - return .orange - case .done: - return .green - } - } - - private var statusText: String { - switch status { - case .needsAuth: - return "Sign in required" - case .idle: - return "Idle" - case .running: - return "Running" - case .failed: - return "Failed" - case .waiting: - return "Waiting" - case .done: - return "Ready" - } - } - - var body: some View { - ZStack(alignment: .bottomTrailing) { - Image(systemName: "waveform.path.ecg") - .font(.system(size: 14, weight: .medium)) - Circle() - .fill(color) - .frame(width: 6, height: 6) - .offset(x: 3, y: 3) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("PR Monitor") - .accessibilityValue(statusText) - } -} diff --git a/Tests/PRMonitorTests/Fixtures.swift b/Tests/PRMonitorTests/Fixtures.swift new file mode 100644 index 0000000..38eb286 --- /dev/null +++ b/Tests/PRMonitorTests/Fixtures.swift @@ -0,0 +1,95 @@ +import Foundation +@testable import PRMonitor + +/// Builders for hand-written pull request states. Every test reads as "given this PR…". +enum Fixture { + static let now = Date(timeIntervalSince1970: 1_800_000_000) + static let repository = RepositoryID(owner: "acme", name: "rocket") + + static func pullRequest( + number: Int = 1, + headSHA: String = "aaa111", + committedMinutesAgo: Double = 30, + createdMinutesAgo: Double = 120, + author: String = "hasaam", + isDraft: Bool = false, + rollupState: String? = nil, + reviewDecision: ReviewDecision? = nil, + checks: [CheckSignal] = [], + requested: [ReviewRequestSignal] = [], + reviews: [ReviewSignal] = [], + threads: [ThreadSignal] = [], + comments: [CommentSignal] = [] + ) -> PullRequest { + PullRequest( + id: "PR_\(number)", + repository: repository, + number: number, + title: "Add launch sequence", + url: URL(string: "https://github.com/acme/rocket/pull/\(number)")!, + author: author, + isDraft: isDraft, + createdAt: now.addingTimeInterval(-createdMinutesAgo * 60), + updatedAt: now.addingTimeInterval(-60), + headSHA: headSHA, + headCommittedAt: now.addingTimeInterval(-committedMinutesAgo * 60), + rollupState: rollupState, + reviewDecision: reviewDecision, + mergeable: .mergeable, + checks: checks, + requestedReviewers: requested, + reviews: reviews, + threads: threads, + comments: comments + ) + } + + static func checkRun(_ name: String, app: String, slug: String, _ outcome: CheckSignal.Outcome, raw: String? = nil) -> CheckSignal { + CheckSignal( + name: name, + source: .checkRun(appName: app, appSlug: slug), + outcome: outcome, + rawState: raw ?? defaultRaw(outcome), + startedAt: now.addingTimeInterval(-600), + completedAt: outcome == .pending ? nil : now.addingTimeInterval(-300), + url: URL(string: "https://example.com/\(slug)/\(name)") + ) + } + + static func status(_ context: String, creator: String, _ outcome: CheckSignal.Outcome) -> CheckSignal { + CheckSignal( + name: context, + source: .commitStatus(creator: creator), + outcome: outcome, + rawState: outcome == .pending ? "PENDING" : outcome == .failed ? "FAILURE" : "SUCCESS", + startedAt: now.addingTimeInterval(-500), + completedAt: outcome == .pending ? nil : now.addingTimeInterval(-400), + url: nil + ) + } + + static func thread(by author: String, bot: Bool = true, resolved: Bool = false, outdated: Bool = false) -> ThreadSignal { + ThreadSignal(author: author, isBot: bot, isResolved: resolved, isOutdated: outdated, createdAt: now.addingTimeInterval(-200)) + } + + static func review(by author: String, _ state: ReviewSignal.State, sha: String? = "aaa111", bot: Bool = true) -> ReviewSignal { + ReviewSignal(author: author, isBot: bot, state: state, submittedAt: now.addingTimeInterval(-250), commitSHA: sha) + } + + private static func defaultRaw(_ outcome: CheckSignal.Outcome) -> String { + switch outcome { + case .pending: "IN_PROGRESS" + case .succeeded: "SUCCESS" + case .neutral: "SKIPPED" + case .failed: "FAILURE" + } + } +} + +extension StatusEngine { + static let automatic = StatusEngine(mode: .automatic, agents: []) + + func callAsFunction(_ pr: PullRequest) -> PullRequestReport { + report(for: pr, now: Fixture.now) + } +} diff --git a/Tests/PRMonitorTests/GraphQLDecodingTests.swift b/Tests/PRMonitorTests/GraphQLDecodingTests.swift new file mode 100644 index 0000000..cbd194f --- /dev/null +++ b/Tests/PRMonitorTests/GraphQLDecodingTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import PRMonitor + +/// Decodes synthetic payloads shaped exactly like GitHub's GraphQL responses (captured against the +/// live API, then anonymized) and checks the mapping into the domain model. +@Suite("GraphQL decoding") +struct GraphQLDecodingTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try GitHubClient.decoder.decode(T.self, from: Data(json.utf8)) + } + + @Test func listPayloadKeepsGoodRepositoriesWhenOneFails() throws { + let payload = try decode(PullRequestQueries.ListPayload.self, """ + { + "viewer": { "login": "hasaam" }, + "rateLimit": { "remaining": 4812, "resetAt": "2026-09-29T00:06:55Z" }, + "r0": { + "pullRequests": { + "totalCount": 1, + "nodes": [{ + "id": "PR_1", "updatedAt": "2026-09-25T20:33:43Z", "headRefOid": "abc", + "commits": { "nodes": [{ "commit": { "statusCheckRollup": { "state": "PENDING" } } }] } + }] + } + }, + "r1": null + } + """) + #expect(payload.viewerLogin == "hasaam") + #expect(payload.rateLimit?.remaining == 4812) + #expect(payload.repositories.count == 2) + let item = try #require(payload.repositories["r0"] ?? nil).pullRequests.nodes.first + #expect(item?.hasPendingChecks == true) + #expect((payload.repositories["r1"] ?? nil) == nil) + } + + @Test func graphQLErrorsMapBackToTheirAlias() throws { + let error = try decode(GraphQLError.self, """ + { "type": "NOT_FOUND", "path": ["r2"], "message": "Could not resolve to a Repository with the name 'acme/gone'." } + """) + #expect(error.rootField == "r2") + #expect(error.type == "NOT_FOUND") + } + + @Test func detailPayloadMapsIntoAPullRequest() throws { + let payload = try decode(PullRequestQueries.DetailPayload.self, Self.detailJSON) + let pr = try #require(payload.nodes.first ?? nil).toPullRequest() + + #expect(pr.repository == RepositoryID(owner: "acme", name: "rocket")) + #expect(pr.number == 857) + #expect(pr.isDraft) + #expect(pr.mergeable == .conflicting) + #expect(pr.reviewDecision == .changesRequested) + #expect(pr.rollupState == "FAILURE") + + // Check runs, a commit status, and a workflow awaiting approval. + #expect(pr.checks.map(\.name) == ["Semgrep", "Deploy Preview", "Vercel", "Netlify"]) + #expect(pr.checks.map(\.outcome) == [.failed, .pending, .succeeded, .failed]) + #expect(pr.checks[3].rawState == "ACTION_REQUIRED") + #expect(pr.checks[2].groupKey == "vercel") + + #expect(pr.requestedReviewers == [ReviewRequestSignal(login: "copilot-pull-request-reviewer", isBot: true)]) + #expect(pr.reviews.first?.commitSHA == "old999") + #expect(pr.reviews.first?.isBot == true) + #expect(pr.threads.count == 2, "The thread whose author was deleted is dropped") + #expect(pr.threads.filter(\.isOpen).count == 1) + #expect(pr.comments.map(\.author) == ["cursor"]) + } + + @Test func nullNodesAreSkippedRatherThanFailingTheBatch() throws { + let payload = try decode(PullRequestQueries.DetailPayload.self, #"{ "nodes": [null] }"#) + #expect(payload.nodes.count == 1) + #expect(payload.nodes[0] == nil) + } + + static let detailJSON = """ + { + "nodes": [{ + "id": "PR_857", "number": 857, "title": "Market detail page", "url": "https://github.com/acme/rocket/pull/857", + "isDraft": true, "createdAt": "2026-08-10T10:00:00Z", "updatedAt": "2026-08-17T19:45:41Z", + "reviewDecision": "CHANGES_REQUESTED", "mergeable": "CONFLICTING", "headRefOid": "head123", + "repository": { "name": "rocket", "owner": { "login": "acme" } }, + "author": { "__typename": "User", "login": "hasaam" }, + "commits": { "nodes": [{ "commit": { + "committedDate": "2026-08-17T19:45:33Z", + "checkSuites": { "nodes": [ + { "conclusion": "ACTION_REQUIRED", "app": { "name": "Netlify", "slug": "netlify" } }, + { "conclusion": "SUCCESS", "app": { "name": "GitHub Actions", "slug": "github-actions" } } + ] }, + "statusCheckRollup": { "state": "FAILURE", "contexts": { "nodes": [ + { "__typename": "CheckRun", "name": "Semgrep", "status": "COMPLETED", "conclusion": "FAILURE", + "startedAt": "2026-08-17T19:45:44Z", "completedAt": "2026-08-17T19:47:00Z", + "detailsUrl": "https://github.com/acme/rocket/actions/runs/1", "checkSuite": { "app": { "name": "GitHub Actions", "slug": "github-actions" } } }, + { "__typename": "CheckRun", "name": "Deploy Preview", "status": "QUEUED", "conclusion": null, + "startedAt": null, "completedAt": null, "detailsUrl": null, "checkSuite": { "app": { "name": "Supabase", "slug": "supabase" } } }, + { "__typename": "StatusContext", "context": "Vercel", "state": "SUCCESS", "createdAt": "2026-08-17T19:46:10Z", + "targetUrl": "https://vercel.com/acme/rocket/1", "creator": { "login": "vercel" } } + ] } } + } }] }, + "reviewRequests": { "nodes": [ + { "requestedReviewer": { "__typename": "Bot", "login": "copilot-pull-request-reviewer" } }, + { "requestedReviewer": { "__typename": "Team" } } + ] }, + "latestReviews": { "nodes": [ + { "author": { "__typename": "Bot", "login": "chatgpt-codex-connector" }, "state": "COMMENTED", + "submittedAt": "2026-08-16T12:00:00Z", "commit": { "oid": "old999" } } + ] }, + "reviewThreads": { "nodes": [ + { "isResolved": false, "isOutdated": false, "comments": { "nodes": [{ "author": { "__typename": "Bot", "login": "chatgpt-codex-connector" }, "createdAt": "2026-08-16T12:00:00Z" }] } }, + { "isResolved": false, "isOutdated": true, "comments": { "nodes": [{ "author": { "__typename": "Bot", "login": "chatgpt-codex-connector" }, "createdAt": "2026-08-15T12:00:00Z" }] } }, + { "isResolved": true, "isOutdated": false, "comments": { "nodes": [{ "author": null, "createdAt": "2026-08-14T12:00:00Z" }] } } + ] }, + "comments": { "nodes": [ { "author": { "__typename": "Bot", "login": "cursor" }, "createdAt": "2026-08-17T19:50:00Z" } ] } + }] + } + """ +} + +@Suite("Settings migration") +@MainActor +struct SettingsMigrationTests { + private func defaults(with legacy: String) -> UserDefaults { + let suite = "PRMonitorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.set(Data(legacy.utf8), forKey: "PRMonitorSettings") + return defaults + } + + @Test func untouchedSeededAgentsMigrateToAutomaticMode() { + let settings = AppSettings(defaults: defaults(with: """ + { "repos": [{ "owner": "acme", "name": "rocket", "isEnabled": true }, { "owner": "acme", "name": "old", "isEnabled": false }], + "agents": [ + { "id": "\(UUID())", "displayName": "Vercel", "checkNamePattern": "vercel", "commentAuthor": "vercel" }, + { "id": "\(UUID())", "displayName": "Cursor Bugbot", "checkNamePattern": "cursor", "commentAuthor": "cursor" }, + { "id": "\(UUID())", "displayName": "Devin Review", "checkNamePattern": "devin", "commentAuthor": "devin-ai-integration" } + ], + "pollingIntervalSeconds": 120, "notifyPerAgent": true, "notifySummary": false, "githubClientId": "Iv1.abc" } + """)) + #expect(settings.enabledRepositories == [RepositoryID(owner: "acme", name: "rocket")]) + #expect(settings.repositories.count == 2) + #expect(settings.agentMode == .automatic) + #expect(settings.refreshInterval == 120) + #expect(settings.notifiesPerAgent) + #expect(!settings.notifiesWhenSettled) + #expect(settings.oauthClientIDOverride == "Iv1.abc") + } + + @Test func customizedAgentsAreKept() { + let settings = AppSettings(defaults: defaults(with: """ + { "repos": [], "agents": [{ "id": "\(UUID())", "displayName": "CodeRabbit", "checkNamePattern": "coderabbit", "commentAuthor": "coderabbitai" }] } + """)) + #expect(settings.agentMode == .custom) + #expect(settings.agents.map(\.login) == ["coderabbitai"]) + } + + @Test func settingsRoundTripThroughStorage() { + let defaults = UserDefaults(suiteName: "PRMonitorTests.\(UUID().uuidString)")! + let settings = AppSettings(defaults: defaults) + settings.track(RepositoryID(owner: "acme", name: "rocket")) + settings.scope = .authored + settings.agentMode = .custom + + let reloaded = AppSettings(defaults: defaults) + #expect(reloaded.repositories.map(\.id.fullName) == ["acme/rocket"]) + #expect(reloaded.scope == .authored) + #expect(reloaded.agentMode == .custom) + } +} diff --git a/Tests/PRMonitorTests/NotificationPlannerTests.swift b/Tests/PRMonitorTests/NotificationPlannerTests.swift new file mode 100644 index 0000000..27aa534 --- /dev/null +++ b/Tests/PRMonitorTests/NotificationPlannerTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import PRMonitor + +@Suite("Notification planner") +struct NotificationPlannerTests { + typealias F = Fixture + let engine = StatusEngine.automatic + let preferences = NotificationPreferences() + + private func running(sha: String = "aaa111", number: Int = 1) -> PullRequestReport { + engine(F.pullRequest(number: number, headSHA: sha, checks: [F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .pending)])) + } + + private func passed(sha: String = "aaa111", number: Int = 1, createdMinutesAgo: Double = 120) -> PullRequestReport { + engine(F.pullRequest(number: number, headSHA: sha, createdMinutesAgo: createdMinutesAgo, + checks: [F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded)])) + } + + private func withFeedback(sha: String = "aaa111") -> PullRequestReport { + engine(F.pullRequest(headSHA: sha, checks: [F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded)], + threads: [F.thread(by: "cursor")])) + } + + @Test func theFirstPollOnlyRecordsABaseline() { + var planner = NotificationPlanner() + #expect(planner.plan(for: [passed()], preferences: preferences, now: F.now).isEmpty) + } + + @Test func announcesOnceWhenAPullRequestSettles() { + var planner = NotificationPlanner() + _ = planner.plan(for: [running()], preferences: preferences, now: F.now) + + let settled = planner.plan(for: [passed()], preferences: preferences, now: F.now) + #expect(settled.count == 1) + #expect(settled.first?.kind == .settled(.ready)) + #expect(settled.first?.subtitle == "All clear") + + #expect(planner.plan(for: [passed()], preferences: preferences, now: F.now).isEmpty, "No repeat on the next poll") + } + + @Test func aNewPushStartsAFreshLifecycle() { + var planner = NotificationPlanner() + _ = planner.plan(for: [running()], preferences: preferences, now: F.now) + _ = planner.plan(for: [passed()], preferences: preferences, now: F.now) + + // Pushed and finished between two polls: still announced, because it's a new commit. + let next = planner.plan(for: [passed(sha: "bbb222")], preferences: preferences, now: F.now) + #expect(next.count == 1) + #expect(next.first?.headSHA == "bbb222") + } + + @Test func feedbackAfterSettlingIsAnnouncedAsARegression() { + var planner = NotificationPlanner() + _ = planner.plan(for: [running()], preferences: preferences, now: F.now) + _ = planner.plan(for: [passed()], preferences: preferences, now: F.now) + + let regressed = planner.plan(for: [withFeedback()], preferences: preferences, now: F.now) + #expect(regressed.map(\.kind) == [.regressed(.needsReview)]) + #expect(regressed.first?.body.contains("1 open thread") == true) + } + + @Test func pullRequestsThatExistedBeforeLaunchAreNotAnnouncedWhenTheyAppear() { + var planner = NotificationPlanner() + _ = planner.plan(for: [], preferences: preferences, now: F.now) + // e.g. the user just enabled a repository with an old, already-green PR. + #expect(planner.plan(for: [passed(number: 7)], preferences: preferences, now: F.now).isEmpty) + } + + @Test func pullRequestsOpenedAfterLaunchAreAnnounced() { + var planner = NotificationPlanner() + let launch = F.now.addingTimeInterval(-3600) + _ = planner.plan(for: [], preferences: preferences, now: launch) + let planned = planner.plan(for: [passed(number: 8, createdMinutesAgo: 5)], preferences: preferences, now: F.now) + #expect(planned.count == 1) + } + + @Test func respectsPreferences() { + var planner = NotificationPlanner() + let off = NotificationPreferences(whenSettled: false, whenFeedbackArrives: false, perAgent: false) + _ = planner.plan(for: [running()], preferences: off, now: F.now) + #expect(planner.plan(for: [passed()], preferences: off, now: F.now).isEmpty) + } + + @Test func perAgentNotificationsFireAsEachAgentFinishes() { + var planner = NotificationPlanner() + let perAgent = NotificationPreferences(whenSettled: false, whenFeedbackArrives: false, perAgent: true) + let twoRunning = engine(F.pullRequest(checks: [ + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .pending), + F.checkRun("Bugbot", app: "Cursor", slug: "cursor", .pending), + ])) + let oneDone = engine(F.pullRequest(checks: [ + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded), + F.checkRun("Bugbot", app: "Cursor", slug: "cursor", .pending), + ])) + _ = planner.plan(for: [twoRunning], preferences: perAgent, now: F.now) + let planned = planner.plan(for: [oneDone], preferences: perAgent, now: F.now) + #expect(planned.map(\.kind) == [.agentFinished(name: "GitHub Actions", state: .passed)]) + #expect(planner.plan(for: [oneDone], preferences: perAgent, now: F.now).isEmpty) + } + + @Test func identifiersAreStableSoRepeatsReplaceRatherThanStack() { + var a = NotificationPlanner(), b = NotificationPlanner() + _ = a.plan(for: [running()], preferences: preferences, now: F.now) + _ = b.plan(for: [running()], preferences: preferences, now: F.now) + let first = a.plan(for: [passed()], preferences: preferences, now: F.now) + let second = b.plan(for: [passed()], preferences: preferences, now: F.now) + #expect(first.map(\.identifier) == second.map(\.identifier)) + #expect(first.first?.identifier == "acme/rocket#1@aaa111/settled-ready") + } +} + +@Suite("Polling policy") +struct PollingPolicyTests { + let policy = PollingPolicy(activeInterval: 60) + + @Test func pollsAtTheUsersIntervalWhileAgentsRun() { + #expect(policy.delay(after: .success(hasRunningPullRequests: true, hasOpenPullRequests: true)) == 60) + } + + @Test func slowsDownOnceEverythingSettles() { + #expect(policy.delay(after: .success(hasRunningPullRequests: false, hasOpenPullRequests: true)) == 120) + #expect(policy.delay(after: .success(hasRunningPullRequests: false, hasOpenPullRequests: false)) == 300) + } + + @Test func backsOffExponentiallyOnFailure() { + let delays = (1...6).map { policy.delay(after: .failure(consecutive: $0)) } + #expect(delays == [30, 60, 120, 240, 480, 600]) + } + + @Test func waitsOutARateLimit() { + let now = Date(timeIntervalSince1970: 0) + #expect(policy.delay(after: .rateLimited(resetAt: now.addingTimeInterval(900)), now: now) == 905) + #expect(policy.delay(after: .rateLimited(resetAt: now.addingTimeInterval(-10)), now: now) == 30, "Never returns a negative or tiny wait") + } +} diff --git a/Tests/PRMonitorTests/PRMonitorSmokeTests.swift b/Tests/PRMonitorTests/PRMonitorSmokeTests.swift deleted file mode 100644 index 2bfa99f..0000000 --- a/Tests/PRMonitorTests/PRMonitorSmokeTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import XCTest -@testable import PRMonitor - -final class PRMonitorSmokeTests: XCTestCase { - func testRepoConfigFullName() { - let repo = RepoConfig(owner: "apple", name: "swift", isEnabled: true) - XCTAssertEqual(repo.id, "apple/swift") - XCTAssertEqual(repo.fullName, "apple/swift") - } - - func testAgentRunStatusRawValuesStable() { - XCTAssertEqual(AgentRunStatus.running.rawValue, "running") - XCTAssertEqual(AgentRunStatus.failed.rawValue, "failed") - XCTAssertEqual(AgentRunStatus.done.rawValue, "done") - } -} diff --git a/Tests/PRMonitorTests/RepositoryIDTests.swift b/Tests/PRMonitorTests/RepositoryIDTests.swift new file mode 100644 index 0000000..36fff66 --- /dev/null +++ b/Tests/PRMonitorTests/RepositoryIDTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import PRMonitor + +@Suite("Repository parsing") +struct RepositoryIDTests { + @Test("Accepts every common way of writing a repository", arguments: [ + "apple/swift", + " apple/swift ", + "https://github.com/apple/swift", + "https://github.com/apple/swift.git", + "https://github.com/apple/swift/pull/42", + "git@github.com:apple/swift.git", + ]) + func parses(_ input: String) { + #expect(RepositoryID(parsing: input) == RepositoryID(owner: "apple", name: "swift")) + } + + @Test("Rejects malformed input", arguments: [ + "", "swift", "/swift", "apple/", "-apple/swift", "apple/sw ift", "https://gitlab.com/apple/swift", "apple/..", + ]) + func rejects(_ input: String) { + #expect(RepositoryID(parsing: input) == nil) + } + + @Test func sortsNaturally() { + let ids = ["acme/rocket10", "acme/rocket2", "Acme/alpha"].compactMap(RepositoryID.init(parsing:)) + #expect(ids.sorted().map(\.name) == ["alpha", "rocket2", "rocket10"]) + } +} diff --git a/Tests/PRMonitorTests/StatusEngineTests.swift b/Tests/PRMonitorTests/StatusEngineTests.swift new file mode 100644 index 0000000..4b948c4 --- /dev/null +++ b/Tests/PRMonitorTests/StatusEngineTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +@testable import PRMonitor + +@Suite("Status engine") +struct StatusEngineTests { + typealias F = Fixture + + // MARK: - Automatic mode + + @Test func groupsJobsFromTheSameIntegrationIntoOneAgent() { + let report = StatusEngine.automatic(F.pullRequest(checks: [ + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded), + F.checkRun("test", app: "GitHub Actions", slug: "github-actions", .succeeded), + F.checkRun("lint", app: "GitHub Actions", slug: "github-actions", .succeeded), + ])) + #expect(report.agents.map(\.name) == ["GitHub Actions"]) + #expect(report.agents[0].summary == "3 checks passed") + #expect(report.status == .ready) + } + + @Test func mergesACommitStatusAndCheckRunFromTheSameBot() { + // Vercel reports both a commit status and a "Preview Comments" check run. + let report = StatusEngine.automatic(F.pullRequest(checks: [ + F.status("Vercel", creator: "vercel", .succeeded), + F.checkRun("Vercel Preview Comments", app: "Vercel", slug: "vercel", .succeeded), + ])) + #expect(report.agents.count == 1) + #expect(report.agents[0].name == "Vercel") + } + + @Test func oneFailedJobFailsTheWholeAgentEvenWhileOthersRun() { + let report = StatusEngine.automatic(F.pullRequest(checks: [ + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .failed), + F.checkRun("test", app: "GitHub Actions", slug: "github-actions", .pending), + F.checkRun("lint", app: "GitHub Actions", slug: "github-actions", .succeeded), + ])) + #expect(report.agents[0].state == .failed) + #expect(report.agents[0].summary == "1 of 3 failed") + #expect(report.status == .failing) + #expect(report.isSettled, "A failed agent counts as finished: the failure is already actionable") + } + + @Test func failureOutranksRunningAtThePullRequestLevel() { + let report = StatusEngine.automatic(F.pullRequest(checks: [ + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .failed), + F.checkRun("Bugbot", app: "Cursor", slug: "cursor", .pending), + ])) + #expect(report.status == .failing) + #expect(!report.isSettled, "Cursor is still running, so the PR hasn't settled") + } + + @Test func reviewOnlyBotsBecomeAgentsButCommentOnlyBotsDoNot() { + let report = StatusEngine.automatic(F.pullRequest( + threads: [F.thread(by: "chatgpt-codex-connector")], + comments: [CommentSignal(author: "changeset-bot", isBot: true, createdAt: F.now)] + )) + #expect(report.agents.map(\.name) == ["Codex"]) + #expect(report.agents[0].state == .needsReview) + #expect(report.agents[0].summary == "1 open thread") + } + + @Test func humanThreadsDoNotCreateAgents() { + let report = StatusEngine.automatic(F.pullRequest(threads: [F.thread(by: "octocat", bot: false)])) + #expect(report.agents.isEmpty) + #expect(report.status == .quiet) + #expect(report.openThreadCount == 1) + } + + @Test func resolvedAndOutdatedThreadsDoNotNeedReview() { + let report = StatusEngine.automatic(F.pullRequest( + checks: [F.checkRun("Bugbot", app: "Cursor", slug: "cursor", .succeeded)], + reviews: [F.review(by: "cursor", .commented)], + threads: [F.thread(by: "cursor", resolved: true), F.thread(by: "cursor", outdated: true)] + )) + #expect(report.agents.map(\.state) == [.passed]) + #expect(report.status == .ready) + } + + @Test func aPendingReviewRequestMeansTheBotIsStillReviewing() { + let report = StatusEngine.automatic(F.pullRequest( + requested: [ReviewRequestSignal(login: "copilot-pull-request-reviewer", isBot: true)] + )) + #expect(report.agents.map(\.name) == ["Copilot"]) + #expect(report.agents[0].state == .running) + #expect(report.agents[0].summary == "Reviewing") + } + + @Test func skippedAndCancelledChecksAreNotFailures() { + let report = StatusEngine.automatic(F.pullRequest(checks: [ + F.checkRun("Supabase Preview", app: "Supabase", slug: "supabase", .neutral, raw: "SKIPPED"), + F.checkRun("deploy", app: "Netlify", slug: "netlify", .neutral, raw: "CANCELLED"), + ])) + #expect(report.agents.map(\.summary) == ["Skipped", "Cancelled"]) + #expect(report.status == .ready) + } + + @Test func trustsAPendingRollupWhenNoVisibleCheckIsPending() { + let report = StatusEngine.automatic(F.pullRequest( + rollupState: "PENDING", + checks: [F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded)] + )) + #expect(report.status == .running) + #expect(report.agents.contains { $0.id == "rollup" }) + } + + @Test func aHumanRequestingChangesKeepsThePullRequestInReview() { + let report = StatusEngine.automatic(F.pullRequest( + reviewDecision: .changesRequested, + checks: [F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .succeeded)] + )) + #expect(report.status == .needsReview) + } + + // MARK: - Stale reviews + + @Test func aReviewOfAnOlderCommitIsAssumedToBeRedoneShortlyAfterAPush() { + let pr = F.pullRequest(headSHA: "new222", committedMinutesAgo: 1, reviews: [F.review(by: "chatgpt-codex-connector", .commented, sha: "old111")]) + let report = StatusEngine.automatic(pr) + #expect(report.agents[0].state == .running) + #expect(report.agents[0].summary == "Waiting to review") + } + + @Test func aReviewOfAnOlderCommitSettlesAfterTheGracePeriod() { + let pr = F.pullRequest(headSHA: "new222", committedMinutesAgo: 20, reviews: [F.review(by: "chatgpt-codex-connector", .commented, sha: "old111")]) + let report = StatusEngine.automatic(pr) + #expect(report.agents[0].state == .passed) + #expect(report.agents[0].summary == "Reviewed an earlier commit") + } + + // MARK: - Custom mode + + @Test func customAgentsMatchChecksByPatternAndReviewsByLogin() { + let engine = StatusEngine(mode: .custom, agents: [ + Agent(name: "Bugbot", checkPattern: "cursor", login: "cursor"), + ]) + let report = engine(F.pullRequest( + checks: [ + F.checkRun("Cursor Bugbot", app: "Cursor", slug: "cursor", .succeeded), + F.checkRun("build", app: "GitHub Actions", slug: "github-actions", .failed), + ], + threads: [F.thread(by: "cursor[bot]"), F.thread(by: "cursor"), F.thread(by: "someone-else")] + )) + #expect(report.agents.map(\.name) == ["Bugbot"]) + #expect(report.agents[0].openThreads == 2, "REST-style `cursor[bot]` and GraphQL-style `cursor` are the same bot") + #expect(report.status == .needsReview, "The failing Actions job isn't a configured agent, so it's ignored") + } + + @Test func aConfiguredAgentThatHasNotReportedIsExpectedRightAfterAPush() { + let engine = StatusEngine(mode: .custom, agents: [Agent(name: "Devin", login: "devin-ai-integration")]) + let justPushed = engine(F.pullRequest(committedMinutesAgo: 1)) + #expect(justPushed.agents.map(\.summary) == ["Waiting to start"]) + #expect(justPushed.status == .running) + + let later = engine(F.pullRequest(committedMinutesAgo: 30)) + #expect(later.agents.isEmpty, "After the grace period it's treated as not applicable") + #expect(later.status == .quiet) + } + + @Test func invalidAgentsAreIgnored() { + let engine = StatusEngine(mode: .custom, agents: [Agent(name: "Nothing")]) + #expect(engine(F.pullRequest(checks: [F.checkRun("x", app: "X", slug: "x", .failed)])).agents.isEmpty) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..425c10b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,148 @@ +# Architecture + +PR Monitor has one job: turn a noisy stream of GitHub signals into a small number of trustworthy answers. Is this pull request done? Does it need me? This document explains how the code is organized around that job, and the decisions behind it. + +## Layers + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ UI (SwiftUI) MenuBarPanel · PullRequestRow · Settings panes │ +├──────────────────────────────────────────────────────────────────────────┤ +│ Services (@MainActor) Monitor · Account · AppSettings · Notifier · │ +│ SystemEvents · LoginItem │ +├──────────────────────────────────────────────────────────────────────────┤ +│ GitHub GitHubClient · PullRequestFetcher (actor) · │ +│ PullRequestQueries · DeviceFlow · GitHubCLI │ +├──────────────────────────────────────────────────────────────────────────┤ +│ Core (pure) PullRequest · StatusEngine · NotificationPlanner · │ +│ PollingPolicy · RepositoryID · Agent │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Dependencies only point downward. **Core** has no I/O and imports nothing but Foundation, which is why almost every behavioral rule is unit-tested without mocks: tests build a `PullRequest` value, run it through a pure function, and assert on the result. + +## Data flow + +1. `Monitor` runs one structured-concurrency loop: `poll()`, then `sleep(delay)`, repeated. +2. `PullRequestFetcher` returns plain `PullRequest` values (no DTOs leak past the GitHub layer). +3. `StatusEngine` reduces each PR to a `PullRequestReport`: per-agent states plus an overall status. +4. SwiftUI observes `Monitor`. At the same time `NotificationPlanner` diffs the new reports against the previous poll, and `Notifier` posts whatever it decides. + +Settings changes flow back through the macOS 26 `Observations` async sequence. A change to *what* is fetched (token, repositories, interval) wakes the loop immediately. A change to *how* agents are interpreted re-runs the engine on cached data with no network round trip. + +## Fetching: two phases, one request per ten repositories + +GitHub's GraphQL API charges points for the connections you ask for, not the data you get back. Asking for full details on every open PR every minute costs about 26 points per repository, which exhausts the 5,000 point hourly budget with five repositories. + +So the fetch is split: + +| Phase | Query | Cost | When | +| --- | --- | --- | --- | +| **List** | Up to 10 repositories per request via aliases (`r0: repository(…)`), 50 open PRs each, only `id`, `updatedAt`, `headRefOid` and the roll-up `state` | ~1 point per repository | Every poll | +| **Detail** | `nodes(ids:)` for up to 20 PRs: check runs, commit statuses, check suites, review requests, latest reviews, review threads, comments | ~1 point per PR | Only for PRs that changed, are still running, were pushed in the last 10 minutes, have a bot review pending, or haven't been refreshed in 5 minutes | + +The fingerprint (`updatedAt | headRefOid | rollupState`) catches pushes, comments, reviews and check transitions. The five-minute ceiling catches the few events that bump none of those, such as a resolved thread. + +**Partial failures stay partial.** GraphQL returns `data` and `errors` together. Each error's `path[0]` maps back to its alias, so an inaccessible repository shows a warning in its section header while the rest of the list stays live. Stale data for that repository is kept rather than dropped, so no one sees a "new" PR notification when access returns. + +Real-world validation: `PRMonitor --diagnose` runs this pipeline against live repositories. On a sample of five repositories and 17 PRs, a cold poll took about 5s and a cached poll about 1.1s. + +## Status derivation + +`StatusEngine` is deliberately boring: one pure function per concern, and no hidden state. + +- **Automatic mode** groups signals by integration. Check runs group by app slug and commit statuses by creator, so twelve GitHub Actions jobs become one row. A bot whose login matches an app slug merges with it: Vercel's status, its "Preview Comments" check run and its account are one agent. Review-only bots (Codex, Copilot) become agents through review requests, reviews and threads. Comment-only bots (changeset, codecov) are ignored because they have no lifecycle. +- **Custom mode** matches user-defined agents by check pattern and/or bot login. Logins are normalized, so `cursor[bot]` (REST), `cursor` (GraphQL) and `Cursor` compare equal. +- **Precedence**: Failing › Running › Needs review › Ready. A failure is actionable even while other agents run. +- **Accuracy edge cases**, each covered by a test: + - A pending review request means the bot is still reviewing (GitHub removes the request on submit). + - A review attached to an older commit SHA is treated as "about to re-review" for three minutes after a push, then as passed. + - A configured agent that hasn't reported within three minutes of a push is treated as not applicable, rather than "waiting" forever. The 0.x version did the latter. + - Workflows awaiting approval have a check suite but no check runs, so they're surfaced from `checkSuites`. + - If the roll-up says `PENDING` but no visible check is pending, the engine trusts GitHub. + - Skipped, neutral, cancelled and stale checks are not failures. + +## Notifications + +`NotificationPlanner` is a pure value type that diffs snapshots: + +- The **first poll records a baseline**, so launching the app never floods Notification Center. +- Lifecycles are keyed by **head commit SHA**. Each push can announce "settled" at most once. None of the similar open-source projects surveyed does this. They key by PR or by status text, and re-announce when unrelated activity bumps `updatedAt`. +- **Regressions** (a bot leaves threads after its check passed) produce one follow-up. +- PRs that existed before launch aren't announced when they first appear, for example after enabling a repository. +- Identifiers are deterministic (`owner/repo#12@/settled-ready`), so a re-plan replaces a banner instead of stacking a duplicate. +- Permission is requested **in context**, the first time there's something worth saying, not on first launch. + +## Polling + +`PollingPolicy` is pure: + +| Situation | Delay | +| --- | --- | +| An agent is running | The user's interval (30s to 5m) | +| Everything has settled | max(interval, 2m) | +| No open PRs | max(interval, 5m) | +| Low Data Mode or an expensive network | At least 5m | +| Failure | 30s, 60s, 120s … capped at 10m | +| Rate limited, or fewer than 250 points left | Until the reset, plus 5s | + +`Monitor` also refreshes immediately 2s after wake, when the network returns, when the panel opens with data older than 30s, and on ⌘R. Refresh requests during a poll are coalesced into one follow-up poll. + +## Concurrency + +Swift 6 language mode with complete checking and no `@unchecked Sendable`: + +- UI-facing models are `@MainActor @Observable`. +- `PullRequestFetcher` is an `actor` that owns the detail cache, so JSON decoding happens off the main thread. +- `GitHubClient` is a `Sendable` value holding its token. It's rebuilt when the token changes, which replaces the 0.x pattern of a closure reaching into main-actor state from background tasks. +- Networking uses typed throws (`throws(GitHubError)`), so every failure mode is an exhaustive `switch` for the caller. + +## UI and design decisions + +**MenuBarExtra (`.window`) over NSStatusItem + NSPopover.** The system provides the Liquid Glass panel, positioning, dismissal and accessibility. What's given up, programmatic open/close and status-item access, isn't needed: notifications open the PR directly. This removed about 150 lines of AppKit sizing code, including five `PreferenceKey`s and manual height arithmetic. + +**No glass on glass.** Apple's guidance is that Liquid Glass is the functional layer and content should sit beneath it, without layering glass on glass. The panel is already glass, so everything inside uses plain fills: rows behave like menu items with an instant hover highlight, and icon buttons use a circular hover fill with a 0.96 press scale. + +**Template menu bar icon by default.** The HIG asks menu bar extras to use template images that the system tints. State is carried by the badge's *shape*: a filled dot means "act on this", a ring means "in progress", and a dimmed glyph means "not connected". It never relies on color. Colored badges are an opt-in setting. The glyph is a custom-drawn pull request mark (`PullRequestMark`), because the closest SF Symbol reads as a generic merge arrow at 16pt. + +**Rows are two light lines.** The title, then the number, author and age, led in color only by what the status glyph can't express: which agent failed, how many threads, progress ("2 of 4"), or "Approved". A ready PR shows no status words at all, because the glyph already says it. VoiceOver still gets the full wording. Status glyphs are palette-rendered SF Symbols (the glyph in the status color on a tinted disc), not solid badges. Per-agent detail lives behind the disclosure chevron. + +**Filters, not segmented controls.** Scope and draft filtering sit in a menu behind a filter button that fills in when a filter is active, the pattern used by Mail and Files. + +**Skeletons, not spinners.** First load, the repository picker, sign-in verification and avatars all show "bones" that mirror the geometry of the real content, so nothing shifts when data arrives. They pulse gently (static under Reduce Motion) and crossfade to content in 200ms. + +**Settings uses a sidebar.** The window is a `NavigationSplitView`: the account on top (like System Settings' Apple Account row), then evenly spaced rows. Add actions are a small "+" at the trailing edge of their section header, and the agent mode is an iOS-style checkmark list. Rows are drawn by hand only to get a neutral selection: a soft gray pill with primary text, where a system `List` would use the accent color. Glyphs take their label's font in a fixed-width column, hover is an instant faint wash, rows scale with the user's Sidebar Icon Size setting, and the arrow keys move the selection. While Settings is open, the app joins the Dock and ⌘-Tab, and leaves when the window closes. + +**The app icon is a layered Liquid Glass icon.** `PRMonitorApp/AppIcon.icon` is an Icon Composer document with three glass groups (branches, nodes, and an attention dot) at different depths, individual specular lighting, and separate light and dark gradient fills. It targets shared "squares", so the same source compiles for iOS, iPadOS and macOS; `actool` emits Light, Dark and Tintable renditions. + +**Hierarchy through restraint.** Color is reserved for the status phrase and glyph, so in a list of 20 PRs the eye lands on the red and orange lines that need action. + +**Typography and geometry** follow the macOS text styles: Headline 13pt bold for the title, Body 13pt for PR titles, Subheadline 11pt for metadata. Highlights sit 5pt inside the panel edge and text 14pt in, matching native menus. Corner radii are concentric (`ConcentricRectangle` against the panel's container shape). + +**Motion** is short and never bounces: +- Disclosure is an interruptible `.smooth(duration: 0.25)` spring. The expanded list enters with a fade and a 4pt drop, and exits in 150ms with a fade and a 4px blur, because exits should be quicker and softer than enters. +- Hover never animates, since it happens dozens of times a day. Icon buttons scale to 0.96 when pressed. +- Status glyphs change with `.contentTransition(.symbolEffect(.replace))` and counts with `.numericText()`. +- Only first-run onboarding uses a staggered entrance (80ms per chunk), because it's rare. +- With Reduce Motion enabled, movement is removed and opacity fades remain. + +## Testing + +`swift test` runs 39 Swift Testing tests in about 10ms, with no network access: + +- `StatusEngineTests`: grouping, precedence and each accuracy edge case above. +- `NotificationPlannerTests`: baseline, once per SHA, new pushes, regressions, preferences, identifier stability. +- `PollingPolicyTests`: intervals, backoff and rate-limit waits. +- `GraphQLDecodingTests`: payloads shaped like live responses (anonymized), partial errors, `null` nodes, domain mapping. +- `SettingsMigrationTests`: 0.x to 1.0 migration and round-tripping. +- `RepositoryIDTests`: every common way of writing a repository, including pasted URLs. + +## Prior art + +Before the rewrite I studied Trailer, Gitify, CCMenu2, gh-dash, supacode and Pullfather. From them PR Monitor borrows: +- gh-dash's cheap-list / rich-detail split; +- Pullfather's single async scheduling loop and handling of `gh` paths; +- CCMenu2's first-poll-is-a-baseline rule; +- supacode's lesson that small alias chunks avoid 504s on busy repositories. + +It avoids Trailer's missing `CheckRun.status`, which renders running checks as neutral, and Gitify's discard-the-batch-on-any-error behavior. diff --git a/docs/AUDIT.md b/docs/AUDIT.md new file mode 100644 index 0000000..2d80f8d --- /dev/null +++ b/docs/AUDIT.md @@ -0,0 +1,65 @@ +# 0.x Audit + +This audit of PR Monitor 0.1 (commit `baf7fdb`) motivated the 1.0 rewrite. Findings are grouped by area and ordered by user impact. Each links to where 1.0 addresses it. + +## Accuracy + +| Severity | Finding | Impact | Fix in 1.0 | +| --- | --- | --- | --- | +| **High** | Configured agents with no check on a PR stayed `notFound`, which counted as **Waiting forever**. Every user started with Vercel, Cursor and Devin seeded. | Any repo without all three showed "Waiting" permanently, and "all agents complete" never fired | Agents that haven't reported are hidden after a 3-minute startup grace period (`StatusEngine`) | +| **High** | Only check runs were read. **Commit statuses** (the Vercel, Netlify and CodeRabbit style) were ignored. | Deploy integrations were invisible or permanently "No check yet" | The GraphQL `statusCheckRollup` covers both check runs and statuses | +| **High** | When a pattern matched several checks, only the **latest** was considered | A failed `build` job could be hidden by a later passing `lint` job | All matching checks are aggregated: any failure fails, any pending is pending | +| **Medium** | "Needs review" meant *any comment since the check started*, including the bot's own status comments | Vercel's deploy comment made PRs look like they needed review | Feedback is **unresolved, non-outdated review threads** or *changes requested* | +| **Medium** | The PR pill said "Waiting" while its agent row said "Needs review" for the same state | Contradictory UI | One state model (`AgentReport.State`) drives both | +| **Medium** | Review-only bots (Copilot, Codex) were never detected as running | PRs looked finished while a bot was still reviewing | Pending review requests mean "reviewing", and reviews are tied to commit SHAs | +| **Low** | Workflows awaiting approval were invisible | A fork PR looked green while blocked | Surfaced from `checkSuites` as "Action required" | + +## Notifications + +| Severity | Finding | Fix in 1.0 | +| --- | --- | --- | +| **High** | On **every launch**, each finished PR and each done agent fired a notification, because the "last seen" dictionaries started empty | The first poll records a baseline and posts nothing | +| **High** | The review-summary dedupe key included `updatedAt`, so any comment, label or reviewer change re-notified | Lifecycles are keyed by head commit SHA | +| **Medium** | "All agents complete" was global across every PR, and its settings toggle was a disabled switch that did nothing | A per-PR "settled" notification, with real toggles | +| **Medium** | Clicking a notification did nothing | Clicking opens the PR, and notifications are grouped by PR | +| **Low** | Permission was requested at launch, before the user had seen any value | Requested in context | + +## Architecture and correctness + +| Severity | Finding | Fix in 1.0 | +| --- | --- | --- | +| **High** | `@Published` publishes on `willSet`, so the Combine sinks restarted polling with the **old** value. After sign-in, `isSignedIn` was still `false`, so the timer was torn down and **polling never started** until a manual refresh. Toggling a repo refreshed with the previous repo list. | The `Observations` sequence delivers post-change values, and there's one scheduling loop | +| **High** | About 5 REST calls per PR per poll (PR list, check runs, 3 comment endpoints), plus a `/user` call every poll | Two-phase GraphQL at ~1 point per repository ([ARCHITECTURE.md](ARCHITECTURE.md#fetching-two-phases-one-request-per-ten-repositories)) | +| **High** | One inaccessible repository failed the **whole refresh**, because the throwing task group failed on the first error | Per-repository results, keeping last good data | +| **Medium** | Init triggered 4 refreshes, since each sink fires on subscribe, each cancelling the last | One loop with coalesced refreshes | +| **Medium** | A revoked token showed "HTTP 401" forever | 401 signs out with a clear "session expired" message | +| **Medium** | Main-actor `AuthStore.token` was read from background tasks through a closure (a data race hidden by Swift 5 mode) | Swift 6 strict concurrency, with a `Sendable` client holding its token | +| **Medium** | No handling for sleep/wake, network loss, or GraphQL secondary rate limits | `SystemEvents`, `PollingPolicy`, and `Retry-After` support | +| **Low** | Device-flow form bodies weren't percent-encoded, and sign-in couldn't be cancelled | `URLComponents` encoding, and a cancellable task | +| **Low** | Settings saved on every property set during load, and a decode failure silently wrote defaults | Versioned document, no saves while loading, migration from 0.x | +| **Low** | The Xcode project listed files by hand and was already out of sync with SwiftPM | Xcode 16+ synchronized folders, plus a unit-test target | + +## Onboarding + +| Severity | Finding | Fix in 1.0 | +| --- | --- | --- | +| **High** | Signing in required creating your own GitHub OAuth app and pasting its client ID, before anything worked | One-click **GitHub CLI** sign-in, a personal access token, or device flow with a build-time client ID | +| **Medium** | Adding repositories meant typing `owner/name`, with a separate five-section browse form | A searchable picker that also accepts pasted URLs | +| **Medium** | Agents had to be configured by hand, with inline edit forms | Automatic mode by default; custom mode with presets and "seen on your PRs" suggestions | + +## Design (HIG) + +| Severity | Before | After | Why | +| --- | --- | --- | --- | +| **Medium** | Material cards inside `GroupBox`es inside a popover | Flat, menu-like rows inside the system glass panel | Nested containers; content shouldn't carry its own glass or material | +| **Medium** | 520pt-wide popover with manual height math (5 `PreferenceKey`s) | 360pt `MenuBarExtra` window sized to content | Menu bar panels are narrow; let the system size and place them | +| **Medium** | Colored dot rendered by an `NSHostingView` inside the status button | Template image, with state carried by badge shape | The HIG asks for template images, and the hosted view broke button semantics | +| **Medium** | Every status shown with equal visual weight | Solid wells only for failing and needs review | Hierarchy: attention goes to what needs action | +| **Low** | A pointing-hand cursor pushed on hover over whole cards | Standard arrow cursor with a hover highlight | macOS uses the pointing hand for links only | +| **Low** | "2 hrs ago", "Signed in as …" and a wordy footer | Compact ages ("2h"); secondary information moved to tooltips | Density suited to a glanceable surface | +| **Low** | Section headers in caption size and weight, mixed type sizes | macOS text styles: Headline, Body, Subheadline | Consistent type ramp | +| **Low** | 150ms animation on every hover | Instant hover; 0.25s smooth disclosure; opacity-only with Reduce Motion | High-frequency interactions shouldn't animate | + +## Testing + +0.x had two smoke tests (a string concatenation and enum raw values). 1.0 has 39 behavioral tests covering status derivation, notification planning, polling, decoding and migration. It also has a `--diagnose` tool for checking results against live repositories. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..40c4202 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,88 @@ +# Releasing + +PR Monitor ships as a **Developer ID–signed, notarized** disk image on GitHub Releases. That's how Ice, Rectangle, Loop, Stats and AltTab distribute too. Since macOS 15, Gatekeeper no longer lets people right-click › Open an unsigned app, so notarization isn't optional for a download people can actually open. + +`scripts/release.sh` does everything: archive, export with Developer ID, notarize and staple the app, build an APFS disk image with an Applications shortcut, sign, notarize and staple the image, and write `dist/SHA256SUMS`. CI runs the same script when a tag is pushed. + +## One-time setup + +### On your Mac + +You need the **Developer ID Application** certificate in your login keychain (check with `security find-identity -v -p codesigning`). You also need notarization credentials, stored once in the keychain: + +1. Create an app-specific password at [account.apple.com](https://account.apple.com) › Sign-In and Security › App-Specific Passwords. +2. Store the credentials under a profile name. The command prompts for the password: + + ```bash + xcrun notarytool store-credentials PRMonitor --apple-id YOUR_APPLE_ID --team-id G2537ZRGNU + ``` + +### In GitHub (for automated releases) + +Add these under **Settings › Secrets and variables › Actions**: + +| Secret | How to get it | +| --- | --- | +| `DEVELOPER_ID_P12_BASE64` | In Keychain Access, export the Developer ID Application certificate *with its private key* as a .p12, then `base64 -i cert.p12 \| pbcopy` | +| `DEVELOPER_ID_P12_PASSWORD` | The password you set when exporting the .p12 | +| `APPLE_TEAM_ID` | `G2537ZRGNU` | +| `ASC_API_KEY_P8_BASE64` | App Store Connect › Users and Access › Integrations › Team Keys: create a key with the Developer role and download `AuthKey_XXXX.p8`, then `base64 -i AuthKey_XXXX.p8 \| pbcopy` | +| `ASC_KEY_ID` | The key's ID, shown next to it | +| `ASC_ISSUER_ID` | The Issuer ID at the top of the Team Keys page | + +## Cutting a release + +1. Make sure `main` is green and `swift test` passes. +2. Tag and push: + + ```bash + git tag v1.0.0 && git push origin v1.0.0 + ``` + + The **Release** workflow builds, signs, notarizes, and publishes the release with `PRMonitor.dmg`, `PRMonitor.zip`, and `SHA256SUMS`. The README's Download button always points at the latest `PRMonitor.dmg`. + + To release from your Mac instead: + + ```bash + NOTARY_PROFILE=PRMonitor scripts/release.sh 1.0.0 + gh release create v1.0.0 dist/PRMonitor.dmg dist/PRMonitor.zip dist/SHA256SUMS --generate-notes + ``` + +3. Verify before announcing. Download the DMG from the release page so it's quarantined like a user's copy, then check: + + ```bash + spctl --assess --type open --context context:primary-signature -vv PRMonitor.dmg # "Notarized Developer ID" + ``` + + Then open it and make sure the app launches with no Gatekeeper warning. + +**Versions:** `MARKETING_VERSION` comes from the tag (SemVer). `CFBundleVersion` is the commit count, so it always increases, which update checks rely on. + +**Testing the pipeline without signing:** `SIGN_IDENTITY=- scripts/release.sh 0.0.0-test` builds an ad-hoc DMG and skips notarization. + +## Homebrew + +`packaging/homebrew/pr-monitor.rb` is a ready-made cask. + +- **Now:** create a `BlockchainHB/homebrew-tap` repository and copy the cask to `Casks/pr-monitor.rb`, with the release's version and DMG `sha256`. Users install with: + + ```bash + brew install --cask blockchainhb/tap/pr-monitor + ``` + +- **Later:** the official `homebrew/cask` requires the app to be notarized, which it is. It also requires notability: about 75 stars, or 225 if you submit your own project. At that point the cask can move there and BrewTestBot bumps it automatically. + +## Why not the Mac App Store (yet) + +A Mac App Store build is possible, but only as a reduced variant, and it isn't worth doing first: + +- **Sandbox required (guideline 2.4.5).** Networking, the keychain, notifications and launch at login all work sandboxed, but **"Continue with GitHub CLI" can't**. Child processes inherit the sandbox, so `gh` couldn't read its own credentials, and running a tool from outside the app bundle is disallowed (2.5.2 and 4.2.3). A Mac App Store build would offer the OAuth sign-in and pasting a token instead. +- **No self-updater** (2.4.5(vii)), and launch at login must be off until the user turns it on. +- **Review needs a working account.** That means a dedicated GitHub test account with seeded pull requests and a token in the review notes, kept valid for every update. Every fix also waits on review. +- **Precedent:** Ice, Rectangle, Loop, AltTab and Stats all skip the store. Maccy ships both. + +If it's ever wanted, add an `APPSTORE` build configuration that enables the sandbox with `com.apple.security.network.client`, and compiles out the GitHub CLI option and any updater. + +## Next: automatic updates + +Add [Sparkle 2](https://sparkle-project.org): generate EdDSA keys, set `SUFeedURL` and `SUPublicEDKey` in `Info.plist`, and have the release workflow run `sign_update` and `generate_appcast`, attaching `appcast.xml` to the release. Until then, users update by downloading the new DMG, and a Developer ID signature keeps their keychain access working across versions. diff --git a/docs/assets/download-dark.svg b/docs/assets/download-dark.svg new file mode 100644 index 0000000..5efae92 --- /dev/null +++ b/docs/assets/download-dark.svg @@ -0,0 +1,8 @@ + + + + + + Download for macOS + Free · macOS 26 Tahoe or later + diff --git a/docs/assets/download-light.svg b/docs/assets/download-light.svg new file mode 100644 index 0000000..9168973 --- /dev/null +++ b/docs/assets/download-light.svg @@ -0,0 +1,8 @@ + + + + + + Download for macOS + Free · macOS 26 Tahoe or later + diff --git a/docs/screenshots/app-icon.png b/docs/screenshots/app-icon.png new file mode 100644 index 0000000..d9b08a1 Binary files /dev/null and b/docs/screenshots/app-icon.png differ diff --git a/docs/screenshots/hero-dark.jpg b/docs/screenshots/hero-dark.jpg new file mode 100644 index 0000000..2b3639c Binary files /dev/null and b/docs/screenshots/hero-dark.jpg differ diff --git a/docs/screenshots/hero-light.jpg b/docs/screenshots/hero-light.jpg new file mode 100644 index 0000000..56473b5 Binary files /dev/null and b/docs/screenshots/hero-light.jpg differ diff --git a/docs/screenshots/menu-bar-icons.png b/docs/screenshots/menu-bar-icons.png new file mode 100644 index 0000000..cafe10c Binary files /dev/null and b/docs/screenshots/menu-bar-icons.png differ diff --git a/docs/screenshots/settings-light.jpg b/docs/screenshots/settings-light.jpg new file mode 100644 index 0000000..71cda74 Binary files /dev/null and b/docs/screenshots/settings-light.jpg differ diff --git a/packaging/homebrew/pr-monitor.rb b/packaging/homebrew/pr-monitor.rb new file mode 100644 index 0000000..c1967fe --- /dev/null +++ b/packaging/homebrew/pr-monitor.rb @@ -0,0 +1,27 @@ +# Homebrew cask for PR Monitor. Lives in the BlockchainHB/homebrew-tap repository as +# Casks/pr-monitor.rb; update `version` and `sha256` (from dist/SHA256SUMS) on each release. +cask "pr-monitor" do + version "1.0.0" + sha256 "REPLACE_WITH_PRMonitor.dmg_SHA256" + + url "https://github.com/BlockchainHB/PR-Monitor/releases/download/v#{version}/PRMonitor.dmg" + name "PR Monitor" + desc "Menu bar app that watches CI checks and AI code reviewers on your pull requests" + homepage "https://github.com/BlockchainHB/PR-Monitor" + + livecheck do + url :url + strategy :github_latest + end + + depends_on macos: ">= :tahoe" + + app "PRMonitor.app" + + uninstall quit: "com.neat.prmonitor" + + zap trash: [ + "~/Library/Caches/com.neat.prmonitor", + "~/Library/Preferences/com.neat.prmonitor.plist", + ] +end diff --git a/scripts/ExportOptions.plist b/scripts/ExportOptions.plist new file mode 100644 index 0000000..e599d20 --- /dev/null +++ b/scripts/ExportOptions.plist @@ -0,0 +1,14 @@ + + + + + method + developer-id + signingStyle + manual + signingCertificate + Developer ID Application + teamID + G2537ZRGNU + + diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..ed53fc3 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Builds a signed, notarized, stapled PR Monitor release: dist/PRMonitor.dmg and dist/PRMonitor.zip. +# +# scripts/release.sh 1.0.0 +# +# Environment: +# TEAM_ID Apple Developer team (default: G2537ZRGNU) +# SIGN_IDENTITY codesign identity (default: "Developer ID Application"). Use "-" for an ad-hoc +# local test build, which skips export and notarization. +# NOTARY_PROFILE notarytool keychain profile (local; see docs/RELEASING.md), or +# ASC_KEY_PATH, ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key (CI). +# BUILD_NUMBER CFBundleVersion (default: commit count, which always increases) +set -euo pipefail + +cd "$(dirname "$0")/.." +VERSION="${1:?usage: scripts/release.sh }" +VERSION="${VERSION#v}" +TEAM_ID="${TEAM_ID:-G2537ZRGNU}" +SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application}" +BUILD_NUMBER="${BUILD_NUMBER:-$(git rev-list --count HEAD)}" +DIST="$PWD/dist" +# Build outside the checkout: iCloud-synced folders add extended attributes that break codesign. +WORK="$(mktemp -d "${TMPDIR:-/tmp}/prmonitor-release.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } + +notarize() { + if [[ -n "${NOTARY_PROFILE:-}" ]]; then + xcrun notarytool submit "$1" --keychain-profile "$NOTARY_PROFILE" --wait + elif [[ -n "${ASC_KEY_PATH:-}" ]]; then + xcrun notarytool submit "$1" --key "$ASC_KEY_PATH" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" --wait + else + echo "error: set NOTARY_PROFILE or ASC_KEY_PATH/ASC_KEY_ID/ASC_ISSUER_ID to notarize" >&2 + exit 1 + fi +} + +step "Archiving PR Monitor $VERSION ($BUILD_NUMBER)" +SIGNING_ARGS=(CODE_SIGN_STYLE=Manual "CODE_SIGN_IDENTITY=$SIGN_IDENTITY") +[[ "$SIGN_IDENTITY" != "-" ]] && SIGNING_ARGS+=("DEVELOPMENT_TEAM=$TEAM_ID" "OTHER_CODE_SIGN_FLAGS=--timestamp") +xcodebuild archive \ + -project PRMonitor.xcodeproj -scheme PRMonitor -configuration Release \ + -archivePath "$WORK/PRMonitor.xcarchive" -derivedDataPath "$WORK/DerivedData" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + "${SIGNING_ARGS[@]}" -quiet + +if [[ "$SIGN_IDENTITY" == "-" ]]; then + step "Ad-hoc build: skipping export and notarization" + APP="$WORK/PRMonitor.xcarchive/Products/Applications/PRMonitor.app" +else + step "Exporting with Developer ID" + cp scripts/ExportOptions.plist "$WORK/ExportOptions.plist" + plutil -replace teamID -string "$TEAM_ID" "$WORK/ExportOptions.plist" + xcodebuild -exportArchive -archivePath "$WORK/PRMonitor.xcarchive" \ + -exportOptionsPlist "$WORK/ExportOptions.plist" -exportPath "$WORK/export" -quiet + APP="$WORK/export/PRMonitor.app" + + step "Notarizing the app" + ditto -c -k --keepParent "$APP" "$WORK/notarize.zip" + notarize "$WORK/notarize.zip" + xcrun stapler staple "$APP" +fi +codesign --verify --deep --strict --verbose=1 "$APP" + +step "Building the disk image" +rm -rf "$DIST" && mkdir -p "$DIST" "$WORK/dmg" +ditto "$APP" "$WORK/dmg/PRMonitor.app" +ln -s /Applications "$WORK/dmg/Applications" +hdiutil create -volname "PR Monitor" -srcfolder "$WORK/dmg" -fs APFS -format ULMO -ov "$DIST/PRMonitor.dmg" -quiet + +if [[ "$SIGN_IDENTITY" != "-" ]]; then + codesign --sign "$SIGN_IDENTITY" --timestamp "$DIST/PRMonitor.dmg" + step "Notarizing the disk image" + notarize "$DIST/PRMonitor.dmg" + xcrun stapler staple "$DIST/PRMonitor.dmg" + spctl --assess --type open --context context:primary-signature --verbose "$DIST/PRMonitor.dmg" +fi + +ditto -c -k --keepParent "$APP" "$DIST/PRMonitor.zip" + +step "Done" +( cd "$DIST" && LC_ALL=C shasum -a 256 PRMonitor.dmg PRMonitor.zip | tee SHA256SUMS )