Phase 1: GitHub APIクライアント層 + 認証 - #1
Merged
Merged
Conversation
Codable structs for Repository, Release, ReleaseAsset, and GitHubUser matching the GitHub REST API response shape, split into Models/ per file (Repository/Release/GitHubUser) as documented in CLAUDE.md. Only fields needed by the search/releases/readme/user endpoints are included. Decode tests cover realistic API response JSON for each type. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Actor tracking GitHub REST API rate-limit state from X-RateLimit-* response headers rather than a self-maintained counter, since GitHub returns the authoritative remaining count on every response. On 429, records a backoff deadline from Retry-After (falling back to X-RateLimit-Reset), and waitIfNeeded() suspends the caller until that deadline via an injectable sleep function for deterministic testing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHubClientProtocol + GitHubClient covering search/releases/readme/user via URLSession, protocol-abstracted for test mocking. No Authorization header is sent yet (browsing works unauthenticated); accessTokenProvider is wired to a nil-returning default and will be connected to AuthenticationState in a later task. Maps HTTP status to GitHubClientError (401 -> tokenInvalid, 403/429 -> rateLimited via GitHubRateLimiter, other non-2xx -> httpError). readme() treats 404 as the normal not-yet-published case and returns nil rather than throwing. Tests stub URLSession via a custom URLProtocol. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the GitHub OAuth Device Flow: requestDeviceCode() gets the user_code, pollForAccessToken() polls /login/oauth/access_token handling authorization_pending (retry), slow_down (+5s interval), access_denied, and expired_token per GitHub's spec. Sleep is injectable for deterministic polling tests. Uses a GitHub App (not a classic OAuth App) as the Client ID source, switched after empirically verifying via curl that a GitHub App's user-to-server token can read public repositories the user neither owns nor has installed the app on (required for Cairn's discovery use case) and that it correctly returns expires_in/refresh_token, unlike classic OAuth Apps which never expire tokens and would have made the plan's refresh-token task dead code. Client ID is embedded as a public constant in GitHubOAuthConfig (Public Client, no secret). GitHubClientTests' StubURLProtocol was extracted to its own file; DeviceFlowAuthenticatorTests uses a separate DeviceFlowStubURLProtocol class so the two test suites' shared static stub state doesn't race when swift test runs suites in parallel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persists the GitHub OAuth access/refresh tokens (StoredToken) to the macOS Keychain via SecItem calls, isolated behind a KeychainStoring protocol so tests use an in-memory fake instead of touching the real Keychain. SystemKeychain sets kSecAttrAccessibleAfterFirstUnlock and explicitly kSecAttrSynchronizable = false (device-local only, matching GitHub Desktop/gh CLI convention rather than iCloud Keychain sync, per the implementation plan). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@observable class orchestrating Device Flow sign-in, restoring auth status from Keychain at launch, and exposing status (.unauthenticated/.authenticating/.authenticated/.tokenInvalid) for the UI to react to. handleUnauthorizedResponse() lets GitHubClient push a 401 into a .tokenInvalid transition; signOut() clears the Keychain. signIn() and the underlying pollForAccessToken() both take an injectable sleep function so tests don't block on real Device Flow polling intervals. InMemoryKeychain was promoted out of KeychainTokenStoreTests into its own file so AuthenticationStateTests can reuse it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DeviceFlowAuthenticator.refreshAccessToken(refreshToken:) posts a grant_type=refresh_token request, sharing the POST/decode plumbing with pollOnce via a new postToTokenEndpoint helper. AuthenticationState.validAccessToken() is the refresh-aware accessor: returns the stored token as-is if unexpired, transparently refreshes and persists a new one if the access token expired but the refresh token hasn't, and falls back to .tokenInvalid (returning nil) when the refresh token is also expired or the refresh call itself fails. GitHub Apps' user-to-server tokens expire in 8 hours by design (confirmed empirically in task 4), so this keeps the user signed in without repeating the Device Flow every 8 hours. validAccessToken() is what GitHubClient's accessTokenProvider will call once wired up in the next task. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHubClient gains an onUnauthorized hook, called on every 401 before throwing .tokenInvalid. Composition-root wiring (accessTokenProvider: { await authState.validAccessToken() }, onUnauthorized: { authState.handleUnauthorizedResponse() }) is left for the feature phase that actually instantiates GitHubClient in the UI, since CairnApp has no consumer of it yet.
GitHubClient stays decoupled from AuthenticationState by design (closures only, no direct reference), keeping the protocol-mockable GitHubClientProtocol usable independent of the auth layer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SwiftUI view rendering the Device Flow sign-in UI: a sign-in button when unauthenticated, the user_code plus a progress indicator while authenticating, a success state, and a retry prompt on .tokenInvalid. Opening the browser (NSWorkspace) and copying the user_code to the clipboard happen via .onChange(of: authState.status) rather than a completion closure passed into signIn(). AuthenticationState is now @mainactor: Swift 6's strict concurrency checker flagged passing a MainActor-isolated UI closure into signIn() (a nonisolated async method) as a data-race risk, and since AuthenticationState only ever gets driven from SwiftUI anyway, isolating the whole class to MainActor is the correct fix rather than working around the check. AuthenticationStateTests is marked @mainactor to match; GitHubClient's onUnauthorized/accessTokenProvider closures already cross the boundary safely since they're async and awaited. No automated test targets this view directly (no snapshot/UI-testing framework in the project's minimal-dependency stack per docs/dependencies.md) - manual verification against the running app is the plan's stated way to confirm the Device Flow completes end-to-end once a Client ID is available, which it now is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates docs/progress.md to reflect the completed GitHub App setup, the OAuth App -> GitHub App switch rationale, and the 9 implementation tasks finished on this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wraps the sign-in card in .glassEffect(.regular, in: .rect(cornerRadius:)) and renders the user_code as an interactive glass capsule, following macOS 26's Liquid Glass HIG guidance instead of a flat background. Standard controls (.borderedProminent, .bordered buttons) already adopt Liquid Glass automatically on macOS 26. Verified end-to-end on-device: swift run launched the app, the sign-in button triggered the Device Flow, the browser opened with the user_code pre-copied to the clipboard, and completing authorization in the browser transitioned the view to the 'signed in' state, confirming the token round-tripped through Keychain via AuthenticationState. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GitHubClientProtocol/GitHubClientとして実装。未認証でも動作し、レート制限(GitHubRateLimiter)をX-RateLimit-*ヘッダから実測補正。DeviceFlowAuthenticator(device_code取得・ポーリング)、KeychainTokenStore(Keychain永続化、端末ローカルのみ)、AuthenticationState(@MainActor@Observable、状態遷移・自動refresh token更新)、DeviceFlowSignInView(Liquid Glassスタイルのサインイン画面)。GitHubClientはonUnauthorizedフック経由で401受信をAuthenticationStateの.tokenInvalid遷移に接続できるよう疎結合設計。実際のDI配線は次のUI機能フェーズで行う。設計判断
Ov23liプレフィックス)を作成したが、Device Flowで発行されるトークンが失効せずrefresh_tokenも付与されないことが判明(refresh_token自動更新タスクが死んだコードになる)。GitHub App(Iv23liプレフィックス)に作り直し、curlでの実機検証により「未インストールの他人の公開リポジトリにも問題なくアクセスできる」ことを確認した上で採用。GitHubOAuthConfig.swift、Client Secret不要のPublic Client方式)。Test plan
swift test— 41テスト全パス(GitHubModels/GitHubRateLimiter/GitHubClient/DeviceFlowAuthenticator/KeychainTokenStore/AuthenticationStateの各スイート)just lint相当(swift-format + swiftlint、警告のみ・エラーなし)swift runでアプリを起動し、DeviceFlowSignInViewからサインインボタン押下→ブラウザ起動→user_code自動コピー→GitHub側で認可→アプリ側が「サインイン済み」状態に遷移することを確認Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com