From b663797b4d27b6fa1a5eb456ed2d284f143d2f0a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 15 Jun 2026 22:14:27 +0900 Subject: [PATCH 1/4] Fix PR refresh across fork remotes --- docs/components/github-pull-requests.md | 5 + supacode/Clients/Git/GitClient.swift | 83 +++++- .../Clients/Github/GithubRemoteInfo.swift | 6 +- .../Repositories/GitClientDependency.swift | 4 + .../PullRequestRefreshCoordinator.swift | 237 +++++++++++++----- ...epositoriesFeature+GithubIntegration.swift | 87 ++++--- ...atchedPullRequestRefreshReducerTests.swift | 24 +- supacodeTests/GitRemoteInfoTests.swift | 21 ++ .../PullRequestRefreshCoordinatorTests.swift | 41 +++ 9 files changed, 396 insertions(+), 112 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index 4387e8ae..b2e209fa 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -14,6 +14,11 @@ with a worktree's branch and exposes its status and actions. It works through th **`gh` CLI**, so it uses your existing `gh auth` — Prowl never handles tokens itself. +If a repository has multiple GitHub remotes, Prowl checks each remote for a PR on +the worktree branch. `upstream` is preferred, other named remotes come next, and +`origin` is used as the fallback, so fork-based worktrees can show upstream PRs +without changing `origin` or restarting the app. + ## What it shows - PR number, title, state (open/closed/merged), draft status. diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 98b735e6..96a418c2 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -462,6 +462,20 @@ struct GitClient { await remoteWebInfo(for: repositoryRoot)?.repositoryURL } + nonisolated func githubRemoteInfos(for repositoryRoot: URL) async -> [GithubRemoteInfo] { + let candidates = await remoteWebCandidates(for: repositoryRoot).compactMap { + candidate -> ( + name: String, + info: GithubRemoteInfo + )? in + guard let info = Self.parseGithubRemoteInfo(candidate.info) else { + return nil + } + return (name: candidate.name, info: info) + } + return Self.prioritizedGithubRemoteInfos(candidates) + } + nonisolated func remoteInfo(for repositoryRoot: URL) async -> GithubRemoteInfo? { guard let remoteWebInfo = await remoteWebInfo(for: repositoryRoot) else { return nil @@ -470,6 +484,13 @@ struct GitClient { } nonisolated private func remoteWebInfo(for repositoryRoot: URL) async -> GitRemoteWebInfo? { + let candidates = await remoteWebCandidates(for: repositoryRoot) + return Self.originFirstRemoteWebCandidates(candidates).first?.info + } + + nonisolated private func remoteWebCandidates( + for repositoryRoot: URL + ) async -> [(name: String, info: GitRemoteWebInfo)] { let path = repositoryRoot.path(percentEncoded: false) guard let remotesOutput = try? await runGit( @@ -477,20 +498,15 @@ struct GitClient { arguments: ["-C", path, "remote"] ) else { - return nil + return [] } let remotes = remotesOutput .split(whereSeparator: \.isNewline) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - let orderedRemotes: [String] - if remotes.contains("origin") { - orderedRemotes = ["origin"] + remotes.filter { $0 != "origin" } - } else { - orderedRemotes = remotes - } - for remote in orderedRemotes { + var candidates: [(name: String, info: GitRemoteWebInfo)] = [] + for remote in remotes { guard let remoteURL = try? await runGit( operation: .remoteInfo, @@ -500,10 +516,59 @@ struct GitClient { continue } if let info = Self.parseRepositoryWebInfo(remoteURL) { + candidates.append((name: remote, info: info)) + } + } + return candidates + } + + nonisolated private static func originFirstRemoteWebCandidates( + _ candidates: [(name: String, info: GitRemoteWebInfo)] + ) -> [(name: String, info: GitRemoteWebInfo)] { + guard candidates.contains(where: { $0.name == "origin" }) else { + return candidates + } + return candidates.filter { $0.name == "origin" } + candidates.filter { $0.name != "origin" } + } + + nonisolated static func prioritizedGithubRemoteInfos( + _ candidates: [(name: String, info: GithubRemoteInfo)] + ) -> [GithubRemoteInfo] { + var seen = Set() + return + candidates + .enumerated() + .sorted { lhs, rhs in + let lhsPriority = githubPullRequestRemotePriority(lhs.element.name) + let rhsPriority = githubPullRequestRemotePriority(rhs.element.name) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs.offset < rhs.offset + } + .compactMap { entry in + let info = entry.element.info + let key = [ + info.host.lowercased(), + info.owner.lowercased(), + info.repo.lowercased(), + ].joined(separator: "/") + guard seen.insert(key).inserted else { + return nil + } return info } + } + + nonisolated private static func githubPullRequestRemotePriority(_ name: String) -> Int { + switch name.lowercased() { + case "upstream": + 0 + case "origin": + 2 + default: + 1 } - return nil } nonisolated func remoteNames(for repoRoot: URL) async throws -> [String] { diff --git a/supacode/Clients/Github/GithubRemoteInfo.swift b/supacode/Clients/Github/GithubRemoteInfo.swift index 5d855049..e8f7dd75 100644 --- a/supacode/Clients/Github/GithubRemoteInfo.swift +++ b/supacode/Clients/Github/GithubRemoteInfo.swift @@ -1,7 +1,11 @@ import Foundation -struct GithubRemoteInfo: Equatable, Sendable { +nonisolated struct GithubRemoteInfo: Equatable, Sendable { let host: String let owner: String let repo: String + + nonisolated var key: RepoKey { + RepoKey(owner: owner, repo: repo) + } } diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index e52ab0ff..ecf17ee7 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -32,6 +32,7 @@ struct GitClientDependency: Sendable { var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? var renameBranch: @Sendable (_ worktreeURL: URL, _ branchName: String) async throws -> Void var repositoryWebURL: @Sendable (_ repositoryRoot: URL) async -> URL? + var githubRemoteInfos: @Sendable (_ repositoryRoot: URL) async -> [GithubRemoteInfo] var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void @@ -80,6 +81,9 @@ extension GitClientDependency: DependencyKey { repositoryWebURL: { repositoryRoot in await GitClient().repositoryWebURL(for: repositoryRoot) }, + githubRemoteInfos: { repositoryRoot in + await GitClient().githubRemoteInfos(for: repositoryRoot) + }, remoteInfo: { repositoryRoot in await GitClient().remoteInfo(for: repositoryRoot) }, diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index 824463ed..ba7a1ce6 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -7,11 +7,62 @@ final class PullRequestRefreshCoordinator { let repositoryID: Repository.ID let repositoryRootURL: URL let host: String - let owner: String - let repo: String + let repositories: [GithubRemoteInfo] let accountOverride: GithubAccountOverride? let branches: [String] let worktreeIDs: [Worktree.ID] + + var owner: String { + repositories.first?.owner ?? "" + } + + var repo: String { + repositories.first?.repo ?? "" + } + + init( + repositoryID: Repository.ID, + repositoryRootURL: URL, + host: String, + owner: String, + repo: String, + accountOverride: GithubAccountOverride?, + branches: [String], + worktreeIDs: [Worktree.ID] + ) { + self.init( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + host: host, + repositories: [GithubRemoteInfo(host: host, owner: owner, repo: repo)], + accountOverride: accountOverride, + branches: branches, + worktreeIDs: worktreeIDs + ) + } + + init( + repositoryID: Repository.ID, + repositoryRootURL: URL, + host: String, + repositories: [GithubRemoteInfo], + accountOverride: GithubAccountOverride?, + branches: [String], + worktreeIDs: [Worktree.ID] + ) { + self.repositoryID = repositoryID + self.repositoryRootURL = repositoryRootURL + self.host = host + self.repositories = Self.deduplicateRepositories(repositories) + self.accountOverride = accountOverride + self.branches = branches + self.worktreeIDs = worktreeIDs + } + + private static func deduplicateRepositories(_ repositories: [GithubRemoteInfo]) -> [GithubRemoteInfo] { + var seen = Set() + return repositories.filter { seen.insert($0.key).inserted } + } } nonisolated enum Outcome: Sendable, Equatable { @@ -65,19 +116,21 @@ final class PullRequestRefreshCoordinator { let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } - guard !cleanedBranches.isEmpty else { + guard !cleanedBranches.isEmpty, !request.repositories.isEmpty else { return } let normalized = Request( repositoryID: request.repositoryID, repositoryRootURL: request.repositoryRootURL, host: request.host, - owner: request.owner, - repo: request.repo, + repositories: request.repositories.filter { $0.host == request.host }, accountOverride: request.accountOverride, branches: cleanedBranches, worktreeIDs: request.worktreeIDs ) + guard !normalized.repositories.isEmpty else { + return + } let key = BatchKey(host: normalized.host, accountOverride: normalized.accountOverride) if inflightHosts.contains(key) { @@ -125,12 +178,16 @@ final class PullRequestRefreshCoordinator { for worktreeID in request.worktreeIDs where workseen.insert(worktreeID).inserted { workCombined.append(worktreeID) } + var seenRepositories = Set(existing.repositories.map(\.key)) + var combinedRepositories = existing.repositories + for repository in request.repositories where seenRepositories.insert(repository.key).inserted { + combinedRepositories.append(repository) + } hostBucket[request.repositoryID] = Request( repositoryID: request.repositoryID, repositoryRootURL: request.repositoryRootURL, host: request.host, - owner: request.owner, - repo: request.repo, + repositories: combinedRepositories, accountOverride: request.accountOverride, branches: combined, worktreeIDs: workCombined @@ -170,7 +227,7 @@ final class PullRequestRefreshCoordinator { } private func processBatch(key: BatchKey, requests: [Request]) async { - let groupsByKey = groupRequestsByRepo(requests) + let groupsByKey = groupBranchesByRepo(requests) let crossRepoRequests = groupsByKey.values.map { group in CrossRepoPullRequestRequest( owner: group.key.owner, @@ -184,42 +241,66 @@ final class PullRequestRefreshCoordinator { requests: crossRepoRequests, accountOverride: key.accountOverride ) - for (key, prsByBranch) in result.successByRepo { - guard let group = groupsByKey[key] else { - continue - } - for request in group.requests { - resultHandler( - .refreshed( - repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, - worktreeIDs: request.worktreeIDs, - prsByBranch: prsByBranch.filter { request.branches.contains($0.key) } - ) - ) + var prsByRepo = result.successByRepo + var failedMessagesByRepo = result.failedRepos.mapValues { String(describing: $0) } + if !result.failedRepos.isEmpty { + let failedGroups = result.failedRepos.keys.compactMap { groupsByKey[$0] } + let fallback = await fetchFallbackResults(key: key, groups: failedGroups) + for (repoKey, prsByBranch) in fallback.successByRepo { + prsByRepo[repoKey] = prsByBranch + failedMessagesByRepo.removeValue(forKey: repoKey) } + failedMessagesByRepo.merge(fallback.failedMessagesByRepo) { _, new in new } } - let failedRequests = result.failedRepos.keys.flatMap { key in - groupsByKey[key]?.requests ?? [] - } - if !failedRequests.isEmpty { - await fanOutFallback(failedRequests) - } + emitOutcomes( + requests, + prsByRepo: prsByRepo, + failedMessagesByRepo: failedMessagesByRepo + ) } catch { - await fanOutFallback(requests) + let fallback = await fetchFallbackResults(key: key, groups: Array(groupsByKey.values)) + emitOutcomes( + requests, + prsByRepo: fallback.successByRepo, + failedMessagesByRepo: fallback.failedMessagesByRepo + ) } } - private func fanOutFallback(_ requests: [Request]) async { - let groups = Array(groupRequestsByRepo(requests).values) + private func fetchFallbackResults( + key: BatchKey, + groups: [RepoRequestGroup] + ) async -> RepoFetchResults { // Run per-repo fallback requests concurrently; serial awaits here would multiply // a slow recovery path by the number of repos in the batch. - await withTaskGroup(of: Void.self) { group in - for requestGroup in groups { - group.addTask { [weak self] in - await self?.fallbackPerRepo(requestGroup) + await withTaskGroup(of: RepoFetchOutcome.self) { taskGroup in + let githubCLI = self.githubCLI + for repoGroup in groups { + taskGroup.addTask { + do { + let prs = try await githubCLI.batchPullRequests( + key.host, + repoGroup.key.owner, + repoGroup.key.repo, + repoGroup.branches, + key.accountOverride + ) + return .success(repoGroup.key, prs) + } catch { + return .failed(repoGroup.key, String(describing: error)) + } + } + } + var results = RepoFetchResults() + for await outcome in taskGroup { + switch outcome { + case .success(let repoKey, let prsByBranch): + results.successByRepo[repoKey] = prsByBranch + case .failed(let repoKey, let message): + results.failedMessagesByRepo[repoKey] = message } } + return results } } @@ -253,50 +334,79 @@ final class PullRequestRefreshCoordinator { } } - private func fallbackPerRepo(_ group: RepoRequestGroup) async { - do { - let prs = try await githubCLI.batchPullRequests( - group.requests[0].host, - group.key.owner, - group.key.repo, - group.branches, - group.requests[0].accountOverride - ) - for request in group.requests { + private func emitOutcomes( + _ requests: [Request], + prsByRepo: [RepoKey: [String: GithubPullRequest]], + failedMessagesByRepo: [RepoKey: String] + ) { + for request in requests { + let prsByBranch = mergedPullRequests(for: request, prsByRepo: prsByRepo) + let candidateKeys = request.repositories.map(\.key) + let allCandidatesFailed = + !candidateKeys.isEmpty + && candidateKeys.allSatisfy { prsByRepo[$0] == nil && failedMessagesByRepo[$0] != nil } + if allCandidatesFailed { resultHandler( - .refreshed( + .failed( repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, worktreeIDs: request.worktreeIDs, - prsByBranch: prs.filter { request.branches.contains($0.key) } + message: failureMessage(for: candidateKeys, failedMessagesByRepo: failedMessagesByRepo) ) ) - } - } catch { - for request in group.requests { + } else { resultHandler( - .failed( + .refreshed( repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, worktreeIDs: request.worktreeIDs, - message: String(describing: error) + prsByBranch: prsByBranch ) ) } } } - private func groupRequestsByRepo(_ requests: [Request]) -> [RepoKey: RepoRequestGroup] { + private func mergedPullRequests( + for request: Request, + prsByRepo: [RepoKey: [String: GithubPullRequest]] + ) -> [String: GithubPullRequest] { + var prsByBranch: [String: GithubPullRequest] = [:] + for branch in request.branches { + for repository in request.repositories { + if let pullRequest = prsByRepo[repository.key]?[branch] { + prsByBranch[branch] = pullRequest + break + } + } + } + return prsByBranch + } + + private func failureMessage( + for repoKeys: [RepoKey], + failedMessagesByRepo: [RepoKey: String] + ) -> String { + let messages = repoKeys.compactMap { repoKey -> String? in + guard let message = failedMessagesByRepo[repoKey] else { + return nil + } + return "\(repoKey.owner)/\(repoKey.repo): \(message)" + } + return messages.isEmpty ? "GitHub pull request refresh failed." : messages.joined(separator: "; ") + } + + private func groupBranchesByRepo(_ requests: [Request]) -> [RepoKey: RepoRequestGroup] { var groupsByKey: [RepoKey: RepoRequestGroup] = [:] for request in requests { - let key = RepoKey(owner: request.owner, repo: request.repo) - groupsByKey[key, default: RepoRequestGroup(key: key)].append(request) + for repository in request.repositories { + groupsByKey[repository.key, default: RepoRequestGroup(key: repository.key)].append(branches: request.branches) + } } return groupsByKey } private struct RepoRequestGroup: Sendable { let key: RepoKey - private(set) var requests: [Request] = [] private(set) var branches: [String] = [] private var seenBranches: Set = [] @@ -304,9 +414,8 @@ final class PullRequestRefreshCoordinator { self.key = key } - mutating func append(_ request: Request) { - requests.append(request) - for branch in request.branches where seenBranches.insert(branch).inserted { + mutating func append(branches newBranches: [String]) { + for branch in newBranches where seenBranches.insert(branch).inserted { branches.append(branch) } } @@ -316,6 +425,16 @@ final class PullRequestRefreshCoordinator { case completed(CrossRepoPullRequestResult) case timedOut } + + private struct RepoFetchResults: Sendable { + var successByRepo: [RepoKey: [String: GithubPullRequest]] = [:] + var failedMessagesByRepo: [RepoKey: String] = [:] + } + + private enum RepoFetchOutcome: Sendable { + case success(RepoKey, [String: GithubPullRequest]) + case failed(RepoKey, String) + } } enum PullRequestRefreshCoordinatorError: Error, Equatable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 38245aab..d0c31f9e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -21,6 +21,37 @@ extension RepositoriesFeature { } return await githubCLI.resolveRemoteInfo(repositoryRootURL) } + + static func resolveGithubRemoteInfos( + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> [GithubRemoteInfo] { + let remoteInfos = await gitClient.githubRemoteInfos(repositoryRootURL) + if !remoteInfos.isEmpty { + return remoteInfos + } + if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { + return [remoteInfo] + } + return [] + } + + static func resolveGithubRemoteInfo( + for pullRequest: GithubPullRequest, + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> GithubRemoteInfo? { + if let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) { + return remoteInfo + } + return await resolveGithubRemoteInfo( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + } } extension RepositoriesFeature { @@ -87,8 +118,7 @@ extension RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktrees: worktrees, - branches: branches, - cachedRemoteInfo: state.remoteInfoByRepositoryID[repositoryID] + branches: branches ) case .unknown: queuePullRequestRefresh( @@ -366,6 +396,7 @@ extension RepositoriesFeature { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -416,6 +447,7 @@ extension RepositoriesFeature { do { guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -466,6 +498,7 @@ extension RepositoriesFeature { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -713,47 +746,37 @@ extension RepositoriesFeature { repositoryID: Repository.ID, repositoryRootURL: URL, worktrees: [Worktree], - branches: [String], - cachedRemoteInfo: GithubRemoteInfo? + branches: [String] ) -> Effect { let worktreeIDs = worktrees.map(\.id) let coordinatorClient = pullRequestRefreshCoordinator let githubCLI = self.githubCLI let gitClient = self.gitClient return .run { send in - let resolvedRemoteInfo: GithubRemoteInfo? - if let cachedRemoteInfo { - resolvedRemoteInfo = cachedRemoteInfo - } else { - let info = await RepositoriesFeature.resolveGithubRemoteInfo( - repositoryRootURL: repositoryRootURL, - githubCLI: githubCLI, - gitClient: gitClient - ) - if let info { - await send( - .githubIntegration(.cacheRemoteInfo(repositoryID: repositoryID, remoteInfo: info)) - ) - } - resolvedRemoteInfo = info - } - guard let info = resolvedRemoteInfo else { + let remoteInfos = await RepositoriesFeature.resolveGithubRemoteInfos( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + guard !remoteInfos.isEmpty else { await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings - coordinatorClient.enqueue( - PullRequestRefreshCoordinator.Request( - repositoryID: repositoryID, - repositoryRootURL: repositoryRootURL, - host: info.host, - owner: info.owner, - repo: info.repo, - accountOverride: repositorySettings.githubAccountOverride, - branches: branches, - worktreeIDs: worktreeIDs + let remoteInfosByHost = Dictionary(grouping: remoteInfos, by: \.host) + for (host, hostRemoteInfos) in remoteInfosByHost { + coordinatorClient.enqueue( + PullRequestRefreshCoordinator.Request( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + host: host, + repositories: hostRemoteInfos, + accountOverride: repositorySettings.githubAccountOverride, + branches: branches, + worktreeIDs: worktreeIDs + ) ) - ) + } } } diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index e4386281..a44306cf 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -8,17 +8,23 @@ import Testing @MainActor struct BatchedPullRequestRefreshReducerTests { - @Test func refreshDispatchesViaCoordinatorWhenRemoteInfoCached() async { + @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfosWhenCacheExists() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + initialState.remoteInfoByRepositoryID[context.repository.id] = GithubRemoteInfo( + host: "github.com", + owner: "stale", + repo: "cached" + ) + let upstreamInfo = GithubRemoteInfo(host: "github.com", owner: "khoi", repo: "upstream") let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, upstreamInfo] } $0.githubCLI.resolveRemoteInfo = { _ in - Issue.record("Should not resolve when cache hit") + Issue.record("gh resolveRemoteInfo should not run when git remotes resolve") return nil } $0.githubCLI.batchPullRequests = { _, _, _, _, _ in @@ -51,12 +57,11 @@ struct BatchedPullRequestRefreshReducerTests { #expect(snapshot.count == 1) let request = snapshot[0] #expect(request.host == "github.com") - #expect(request.owner == "khoi") - #expect(request.repo == "alpha") + #expect(request.repositories == [context.remoteInfo, upstreamInfo]) #expect(request.branches == ["main", "feature"]) } - @Test func refreshResolvesAndCachesRemoteInfoOnFirstRun() async { + @Test func refreshResolvesRemoteInfosOnFirstRun() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) let initialState = context.state @@ -64,9 +69,9 @@ struct BatchedPullRequestRefreshReducerTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in context.remoteInfo } + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo] } $0.githubCLI.resolveRemoteInfo = { _ in - Issue.record("gh resolveRemoteInfo should not run when git remote resolves") + Issue.record("gh resolveRemoteInfo should not run when git remotes resolve") return nil } $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( @@ -89,9 +94,6 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } - await store.receive(\.githubIntegration.cacheRemoteInfo) { - $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo - } await store.finish() #expect(enqueued.value.count == 1) diff --git a/supacodeTests/GitRemoteInfoTests.swift b/supacodeTests/GitRemoteInfoTests.swift index b592e504..be87cbc7 100644 --- a/supacodeTests/GitRemoteInfoTests.swift +++ b/supacodeTests/GitRemoteInfoTests.swift @@ -42,6 +42,11 @@ struct GitRemoteInfoTests { #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) } + @Test func parsePullRequestURLRemote() { + let info = GitClient.parseGithubRemoteInfo("https://github.com/octo/repo/pull/123") + #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) + } + @Test func parseEnterpriseRemote() { let info = GitClient.parseGithubRemoteInfo("git@github.acme.com:team/repo.git") #expect(info == GithubRemoteInfo(host: "github.acme.com", owner: "team", repo: "repo")) @@ -51,4 +56,20 @@ struct GitRemoteInfoTests { let info = GitClient.parseGithubRemoteInfo("https://gitlab.com/group/repo.git") #expect(info == nil) } + + @Test func prioritizesGithubRemotesForPullRequestLookup() { + let fork = GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") + let upstream = GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + let team = GithubRemoteInfo(host: "github.com", owner: "team", repo: "project") + let duplicateTeam = GithubRemoteInfo(host: "github.com", owner: "TEAM", repo: "project") + + let infos = GitClient.prioritizedGithubRemoteInfos([ + (name: "origin", info: fork), + (name: "team", info: team), + (name: "upstream", info: upstream), + (name: "backup", info: duplicateTeam), + ]) + + #expect(infos == [upstream, team, fork]) + } } diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 16835954..5ebb5f33 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -299,6 +299,47 @@ struct PullRequestRefreshCoordinatorTests { #expect(refreshed.first { $0.0 == "alpha-b" }?.1 == ["feat-2"]) } + @Test func sameLocalRepositoryWithDifferentRemoteReposQueriesAllCandidatesBeforeEmitting() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var dict: [RepoKey: [String: GithubPullRequest]] = [:] + for request in requests { + if request.repo == "upstream" { + dict[request.key] = ["feat-1": makeFixturePullRequest(repo: "upstream")] + } else { + dict[request.key] = [:] + } + } + return CrossRepoPullRequestResult(successByRepo: dict) + } + ) + + coordinator.enqueue(request(repo: "fork", repositoryID: "local")) + coordinator.enqueue(request(repo: "upstream", repositoryID: "local")) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 1) + #expect(Set(calls.first?.requests.map(\.repo) ?? []) == ["fork", "upstream"]) + + let refreshed = await outcomes.snapshot().compactMap { outcome -> [String: GithubPullRequest]? in + if case .refreshed("local", _, _, let prsByBranch) = outcome { + return prsByBranch + } + return nil + } + #expect(refreshed.count == 1) + #expect(refreshed.first?["feat-1"]?.title == "PR-upstream") + } + @Test func duplicateRepoKeysFallbackOnceAndFanOutToEachRepository() async throws { let clock = TestClock() let probe = CoordinatorProbe() -- 2.51.2 From 01da613fb0bda6ed6e2d5e9ce1bab3e1c7cb9de9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 15 Jun 2026 22:40:03 +0900 Subject: [PATCH 2/4] Prefer origin for PR remote lookup --- docs/components/github-pull-requests.md | 6 +++--- supacode/Clients/Git/GitClient.swift | 12 ++++++++---- supacodeTests/GitRemoteInfoTests.swift | 8 +++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index b2e209fa..166c03b8 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -15,9 +15,9 @@ with a worktree's branch and exposes its status and actions. It works through th itself. If a repository has multiple GitHub remotes, Prowl checks each remote for a PR on -the worktree branch. `upstream` is preferred, other named remotes come next, and -`origin` is used as the fallback, so fork-based worktrees can show upstream PRs -without changing `origin` or restarting the app. +the worktree branch. `origin` is preferred, `upstream` comes next, and other +named remotes are used alphabetically, so fork-based worktrees can show upstream +PRs without changing `origin` or restarting the app. ## What it shows diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 96a418c2..62ee7def 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -544,6 +544,10 @@ struct GitClient { if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + let nameComparison = lhs.element.name.localizedStandardCompare(rhs.element.name) + if nameComparison != .orderedSame { + return nameComparison == .orderedAscending + } return lhs.offset < rhs.offset } .compactMap { entry in @@ -562,12 +566,12 @@ struct GitClient { nonisolated private static func githubPullRequestRemotePriority(_ name: String) -> Int { switch name.lowercased() { - case "upstream": - 0 case "origin": - 2 - default: + 0 + case "upstream": 1 + default: + 2 } } diff --git a/supacodeTests/GitRemoteInfoTests.swift b/supacodeTests/GitRemoteInfoTests.swift index be87cbc7..576b7430 100644 --- a/supacodeTests/GitRemoteInfoTests.swift +++ b/supacodeTests/GitRemoteInfoTests.swift @@ -61,15 +61,17 @@ struct GitRemoteInfoTests { let fork = GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") let upstream = GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") let team = GithubRemoteInfo(host: "github.com", owner: "team", repo: "project") + let zed = GithubRemoteInfo(host: "github.com", owner: "zed", repo: "project") let duplicateTeam = GithubRemoteInfo(host: "github.com", owner: "TEAM", repo: "project") let infos = GitClient.prioritizedGithubRemoteInfos([ + (name: "zed", info: zed), + (name: "upstream", info: upstream), (name: "origin", info: fork), (name: "team", info: team), - (name: "upstream", info: upstream), - (name: "backup", info: duplicateTeam), + (name: "zz-team", info: duplicateTeam), ]) - #expect(infos == [upstream, team, fork]) + #expect(infos == [fork, upstream, team, zed]) } } -- 2.51.2 From 0e6e4caa6f9f78a91f28140789a53a99d0f78bb8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 01:04:07 +0900 Subject: [PATCH 3/4] Harden multi-remote PR refresh --- ...epositoriesFeature+GithubIntegration.swift | 122 ++++++++++++++-- .../Reducer/RepositoriesFeature.swift | 5 +- ...atchedPullRequestRefreshReducerTests.swift | 117 ++++++++++++---- supacodeTests/RepositoriesFeatureTests.swift | 132 ++++++++++++++++++ 4 files changed, 333 insertions(+), 43 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index d0c31f9e..41b9b0d7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -178,6 +178,8 @@ extension RepositoriesFeature { } state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() return .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) @@ -212,6 +214,8 @@ extension RepositoriesFeature { case .repositoryPullRequestRefreshCompleted(let repositoryID): state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) guard state.githubIntegrationAvailability == .available, let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( forKey: repositoryID @@ -228,6 +232,13 @@ extension RepositoriesFeature { ) ) + case .pullRequestRefreshBatchCountResolved(let repositoryID, let count): + guard state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) else { + return .none + } + state.prRefreshBatchCountsByRepositoryID[repositoryID] = max(1, count) + return .none + case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): guard let repository = state.repositories[id: repositoryID] else { return .none @@ -675,6 +686,8 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .send(.githubIntegration(.refreshGithubIntegrationAvailability)) @@ -684,6 +697,8 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() let worktreeIDs = Array(state.worktreeInfoByID.keys) for worktreeID in worktreeIDs { updateWorktreePullRequest( @@ -701,10 +716,6 @@ extension RepositoriesFeature { state.mergedWorktreeAction = action return .none - case .cacheRemoteInfo(let repositoryID, let remoteInfo): - state.remoteInfoByRepositoryID[repositoryID] = remoteInfo - return .none - case .pullRequestRefreshBatchOutcome(let outcome): return reduceBatchOutcome(state: &state, outcome: outcome) } @@ -718,14 +729,27 @@ extension RepositoriesFeature { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) return .none } - var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] - for worktreeID in worktreeIDs { - if let worktree = repository.worktrees[id: worktreeID] { - prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] - } + mergePullRequestRefreshResults( + repositoryID: repositoryID, + prsByBranch: prsByBranch, + state: &state + ) + guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { + return .none } + let mergedPRsByBranch = + state.prRefreshResultsByRepositoryID.removeValue( + forKey: repositoryID + ) ?? [:] + let prsByWorktreeID = pullRequestsByWorktreeID( + repository: repository, + worktreeIDs: worktreeIDs, + prsByBranch: mergedPRsByBranch + ) return .merge( .send( .githubIntegration( @@ -737,11 +761,79 @@ extension RepositoriesFeature { ), .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) - case .failed(let repositoryID, _, _): - return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + case .failed(let repositoryID, let worktreeIDs, _): + guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { + return .none + } + let mergedPRsByBranch = + state.prRefreshResultsByRepositoryID.removeValue( + forKey: repositoryID + ) ?? [:] + guard !mergedPRsByBranch.isEmpty, + let repository = state.repositories[id: repositoryID] + else { + return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + } + return .merge( + .send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: pullRequestsByWorktreeID( + repository: repository, + worktreeIDs: worktreeIDs, + prsByBranch: mergedPRsByBranch + ) + ) + ) + ), + .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + ) } } + private func mergePullRequestRefreshResults( + repositoryID: Repository.ID, + prsByBranch: [String: GithubPullRequest], + state: inout State + ) { + guard !prsByBranch.isEmpty else { + return + } + var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] + for (branch, pullRequest) in prsByBranch where merged[branch] == nil { + merged[branch] = pullRequest + } + state.prRefreshResultsByRepositoryID[repositoryID] = merged + } + + private func consumePullRequestRefreshBatch( + repositoryID: Repository.ID, + state: inout State + ) -> Bool { + let remaining = (state.prRefreshBatchCountsByRepositoryID[repositoryID] ?? 1) - 1 + guard remaining > 0 else { + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + return true + } + state.prRefreshBatchCountsByRepositoryID[repositoryID] = remaining + return false + } + + private func pullRequestsByWorktreeID( + repository: Repository, + worktreeIDs: [Worktree.ID], + prsByBranch: [String: GithubPullRequest] + ) -> [Worktree.ID: GithubPullRequest?] { + var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] + for worktreeID in worktreeIDs { + if let worktree = repository.worktrees[id: worktreeID] { + prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] + } + } + return prsByWorktreeID + } + func enqueueBatchedPullRequestRefresh( repositoryID: Repository.ID, repositoryRootURL: URL, @@ -764,6 +856,14 @@ extension RepositoriesFeature { } @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings let remoteInfosByHost = Dictionary(grouping: remoteInfos, by: \.host) + await send( + .githubIntegration( + .pullRequestRefreshBatchCountResolved( + repositoryID: repositoryID, + count: remoteInfosByHost.count + ) + ) + ) for (host, hostRemoteInfos) in remoteInfosByHost { coordinatorClient.enqueue( PullRequestRefreshCoordinator.Request( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index cc6f3eab..f9276e64 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -185,6 +185,7 @@ struct RepositoriesFeature { case refreshGithubIntegrationAvailability case githubIntegrationAvailabilityUpdated(Bool) case repositoryPullRequestRefreshCompleted(Repository.ID) + case pullRequestRefreshBatchCountResolved(repositoryID: Repository.ID, count: Int) case repositoryPullRequestsLoaded( repositoryID: Repository.ID, pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] @@ -192,7 +193,6 @@ struct RepositoriesFeature { case setGithubIntegrationEnabled(Bool) case setMergedWorktreeAction(MergedWorktreeAction?) case pullRequestAction(Worktree.ID, PullRequestAction) - case cacheRemoteInfo(repositoryID: Repository.ID, remoteInfo: GithubRemoteInfo) case pullRequestRefreshBatchOutcome(PullRequestRefreshCoordinator.Outcome) } @@ -263,8 +263,9 @@ struct RepositoriesFeature { var githubIntegrationAvailability: GithubIntegrationAvailability = .unknown var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var inFlightPullRequestRefreshRepositoryIDs: Set = [] + var prRefreshBatchCountsByRepositoryID: [Repository.ID: Int] = [:] + var prRefreshResultsByRepositoryID: [Repository.ID: [String: GithubPullRequest]] = [:] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] - var remoteInfoByRepositoryID: [Repository.ID: GithubRemoteInfo] = [:] var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] @Shared(.appStorage("prowlCreatedWorktreeIDs")) var prowlCreatedWorktreeIDs: [Worktree.ID] = [] diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index a44306cf..e8d78f69 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -8,18 +8,12 @@ import Testing @MainActor struct BatchedPullRequestRefreshReducerTests { - @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfosWhenCacheExists() async { + @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfos() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) - var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = GithubRemoteInfo( - host: "github.com", - owner: "stale", - repo: "cached" - ) let upstreamInfo = GithubRemoteInfo(host: "github.com", owner: "khoi", repo: "upstream") - let store = TestStore(initialState: initialState) { + let store = TestStore(initialState: context.state) { RepositoriesFeature() } withDependencies: { $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, upstreamInfo] } @@ -51,6 +45,9 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + } await store.finish() let snapshot = enqueued.value @@ -61,6 +58,83 @@ struct BatchedPullRequestRefreshReducerTests { #expect(request.branches == ["main", "feature"]) } + @Test func refreshWaitsForAllHostBatchesBeforeCompleting() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + let githubPullRequest = makePullRequestFixture() + let enterpriseInfo = GithubRemoteInfo(host: "ghe.example", owner: "khoi", repo: "alpha") + + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, enterpriseInfo] } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + } + + #expect(Set(enqueued.value.map(\.host)) == ["github.com", "ghe.example"]) + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": githubPullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": githubPullRequest] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = githubPullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + $0.prRefreshBatchCountsByRepositoryID = [:] + } + await store.finish() + } + @Test func refreshResolvesRemoteInfosOnFirstRun() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) @@ -94,6 +168,9 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + } await store.finish() #expect(enqueued.value.count == 1) @@ -154,34 +231,14 @@ struct BatchedPullRequestRefreshReducerTests { await store.finish() } - @Test func cacheRemoteInfoStoresMappingInState() async { - let context = makeContext() - let store = TestStore(initialState: context.state) { - RepositoriesFeature() - } withDependencies: { - $0.pullRequestRefreshCoordinator = .unimplemented - } - - await store.send( - .githubIntegration( - .cacheRemoteInfo(repositoryID: context.repository.id, remoteInfo: context.remoteInfo) - ) - ) { - $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo - } - await store.finish() - } - @Test(.dependencies) func refreshSkippedWhenPullRequestStateFetchDisabled() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) - var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo @Shared(.repositorySettings(context.repoRootURL)) var repositorySettings $repositorySettings.withLock { $0.fetchPullRequestState = false } - let store = TestStore(initialState: initialState) { + let store = TestStore(initialState: context.state) { RepositoriesFeature() } withDependencies: { $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( @@ -212,7 +269,7 @@ struct BatchedPullRequestRefreshReducerTests { @MainActor private func makeContext() -> RefreshTestContext { - let repoRoot = "/tmp/coord-repo" + let repoRoot = "/tmp/coord-repo-\(UUID().uuidString)" let mainWorktree = Worktree( id: repoRoot, name: "main", diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index c8d4f90f..640f6b49 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -4065,6 +4065,104 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func pullRequestMergeUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-merge") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.mergePullRequest = { _, remoteInfo, _, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .merge))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Merging pull request…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request merged") + } + await store.receive(\.worktreeInfoEvent) + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + + @Test func pullRequestCloseUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-close") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.closePullRequest = { _, remoteInfo, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .close))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Closing pull request…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request closed") + } + await store.receive(\.worktreeInfoEvent) + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + + @Test func pullRequestMarkReadyUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-ready") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.markPullRequestReady = { _, remoteInfo, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .markReadyForReview))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Marking PR ready…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request marked ready") + } + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + @Test func pullRequestActionMergeRequiresResolvedRemoteInfo() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -5322,6 +5420,40 @@ struct RepositoriesFeatureTests { return state } + private func makePullRequestURLRemoteInfoFixture(repoRoot: String) -> PullRequestURLRemoteInfoFixture { + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let pullRequest = makePullRequest( + state: "OPEN", + headRefName: featureWorktree.name, + number: 456, + url: "https://github.com/onevcat/Prowl/pull/456" + ) + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .disabled + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: pullRequest + ) + return PullRequestURLRemoteInfoFixture( + featureWorktree: featureWorktree, + state: state, + expectedRemoteInfo: GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "Prowl") + ) + } + + private struct PullRequestURLRemoteInfoFixture { + let featureWorktree: Worktree + let state: RepositoriesFeature.State + let expectedRemoteInfo: GithubRemoteInfo + } + @Test func loadPersistedRepositoriesStartsFetchesConcurrentlyAndPreservesRootOrder() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" -- 2.51.2 From 725bd3290cd90736f0e5f8705b8a1533fd45fef3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 02:09:15 +0900 Subject: [PATCH 4/4] Stabilize cross-host PR refresh priority --- ...epositoriesFeature+GithubIntegration.swift | 87 ++++++++++--- .../Reducer/RepositoriesFeature.swift | 10 +- ...atchedPullRequestRefreshReducerTests.swift | 114 +++++++++++++++++- 3 files changed, 193 insertions(+), 18 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 41b9b0d7..01fe76f7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -178,8 +178,7 @@ extension RepositoriesFeature { } state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) return .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) @@ -214,8 +213,7 @@ extension RepositoriesFeature { case .repositoryPullRequestRefreshCompleted(let repositoryID): state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) - state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) - state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) guard state.githubIntegrationAvailability == .available, let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( forKey: repositoryID @@ -232,11 +230,13 @@ extension RepositoriesFeature { ) ) - case .pullRequestRefreshBatchCountResolved(let repositoryID, let count): + case .pullRequestRefreshBatchCountResolved(let repositoryID, let count, let remotePriorities): guard state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) else { return .none } state.prRefreshBatchCountsByRepositoryID[repositoryID] = max(1, count) + state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] = remotePriorities + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) return .none case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): @@ -686,8 +686,7 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .send(.githubIntegration(.refreshGithubIntegrationAvailability)) @@ -697,8 +696,7 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) let worktreeIDs = Array(state.worktreeInfoByID.keys) for worktreeID in worktreeIDs { updateWorktreePullRequest( @@ -729,8 +727,7 @@ extension RepositoriesFeature { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) - state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) - state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) return .none } mergePullRequestRefreshResults( @@ -745,6 +742,7 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) let prsByWorktreeID = pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, @@ -769,6 +767,7 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) guard !mergedPRsByBranch.isEmpty, let repository = state.repositories[id: repositoryID] else { @@ -801,10 +800,46 @@ extension RepositoriesFeature { return } var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] - for (branch, pullRequest) in prsByBranch where merged[branch] == nil { - merged[branch] = pullRequest + var resultPriorities = state.prRefreshResultPrioritiesByRepositoryID[repositoryID] ?? [:] + let remotePriorities = state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] ?? [:] + // Host batches race independently. Use the returned PR URL to recover its + // source repo, then compare against the original remote order before replacing. + for (branch, pullRequest) in prsByBranch { + let priority = remotePriority(for: pullRequest, remotePriorities: remotePriorities) + if merged[branch] == nil || priority < (resultPriorities[branch] ?? .max) { + merged[branch] = pullRequest + resultPriorities[branch] = priority + } } state.prRefreshResultsByRepositoryID[repositoryID] = merged + state.prRefreshResultPrioritiesByRepositoryID[repositoryID] = resultPriorities + } + + private func remotePriority( + for pullRequest: GithubPullRequest, + remotePriorities: [String: Int] + ) -> Int { + guard let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) else { + return .max + } + return remotePriorities[Self.pullRequestRefreshRemotePriorityKey(remoteInfo)] ?? .max + } + + private func clearPullRequestRefreshTracking( + repositoryID: Repository.ID, + state: inout State + ) { + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshRemotePrioritiesByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) + } + + private func clearAllPullRequestRefreshTracking(state: inout State) { + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() + state.prRefreshRemotePrioritiesByRepositoryID.removeAll() + state.prRefreshResultPrioritiesByRepositoryID.removeAll() } private func consumePullRequestRefreshBatch( @@ -860,7 +895,8 @@ extension RepositoriesFeature { .githubIntegration( .pullRequestRefreshBatchCountResolved( repositoryID: repositoryID, - count: remoteInfosByHost.count + count: remoteInfosByHost.count, + remotePriorities: Self.pullRequestRefreshRemotePriorities(remoteInfos) ) ) ) @@ -890,6 +926,29 @@ extension RepositoriesFeature { return pullRequest } + nonisolated private static func pullRequestRefreshRemotePriorities( + _ remoteInfos: [GithubRemoteInfo] + ) -> [String: Int] { + var priorities: [String: Int] = [:] + for (index, remoteInfo) in remoteInfos.enumerated() { + let key = pullRequestRefreshRemotePriorityKey(remoteInfo) + if priorities[key] == nil { + priorities[key] = index + } + } + return priorities + } + + nonisolated private static func pullRequestRefreshRemotePriorityKey( + _ remoteInfo: GithubRemoteInfo + ) -> String { + [ + remoteInfo.host.lowercased(), + remoteInfo.owner.lowercased(), + remoteInfo.repo.lowercased(), + ].joined(separator: "/") + } + nonisolated private static func validWebURL(_ raw: String) -> URL? { guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f9276e64..f59a99fc 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -185,7 +185,11 @@ struct RepositoriesFeature { case refreshGithubIntegrationAvailability case githubIntegrationAvailabilityUpdated(Bool) case repositoryPullRequestRefreshCompleted(Repository.ID) - case pullRequestRefreshBatchCountResolved(repositoryID: Repository.ID, count: Int) + case pullRequestRefreshBatchCountResolved( + repositoryID: Repository.ID, + count: Int, + remotePriorities: [String: Int] + ) case repositoryPullRequestsLoaded( repositoryID: Repository.ID, pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] @@ -265,6 +269,10 @@ struct RepositoriesFeature { var inFlightPullRequestRefreshRepositoryIDs: Set = [] var prRefreshBatchCountsByRepositoryID: [Repository.ID: Int] = [:] var prRefreshResultsByRepositoryID: [Repository.ID: [String: GithubPullRequest]] = [:] + /// Cross-host PR refresh batches complete independently; keep the intended remote + /// order so same-branch collisions are resolved by priority, not arrival time. + var prRefreshRemotePrioritiesByRepositoryID: [Repository.ID: [String: Int]] = [:] + var prRefreshResultPrioritiesByRepositoryID: [Repository.ID: [String: Int]] = [:] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index e8d78f69..2a1d5da2 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -47,6 +47,10 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0, + "github.com/khoi/upstream": 1, + ] } await store.finish() @@ -90,6 +94,10 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "ghe.example/khoi/alpha": 1, + "github.com/khoi/alpha": 0, + ] } #expect(Set(enqueued.value.map(\.host)) == ["github.com", "ghe.example"]) @@ -107,6 +115,7 @@ struct BatchedPullRequestRefreshReducerTests { ) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": githubPullRequest] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": .max] } await store.send( @@ -122,6 +131,7 @@ struct BatchedPullRequestRefreshReducerTests { ) { $0.prRefreshBatchCountsByRepositoryID = [:] $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] } await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { var entry = WorktreeInfoEntry() @@ -131,6 +141,98 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshRemotePrioritiesByRepositoryID = [:] + } + await store.finish() + } + + @Test func refreshPrefersHigherPriorityRemoteWhenHostBatchResultsRace() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + let enterpriseInfo = GithubRemoteInfo(host: "github.enterprise.test", owner: "khoi", repo: "alpha") + let enterprisePullRequest = makePullRequestFixture( + title: "Enterprise PR", + url: "https://github.enterprise.test/khoi/alpha/pull/8" + ) + let originPullRequest = makePullRequestFixture( + title: "Origin PR", + url: "https://github.com/khoi/alpha/pull/7" + ) + + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, enterpriseInfo] } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0, + "github.enterprise.test/khoi/alpha": 1, + ] + } + + #expect(Set(enqueued.value.map(\.host)) == ["github.com", "github.enterprise.test"]) + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": enterprisePullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": enterprisePullRequest] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": 1] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": originPullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = originPullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshRemotePrioritiesByRepositoryID = [:] } await store.finish() } @@ -170,6 +272,9 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0 + ] } await store.finish() @@ -317,10 +422,13 @@ private struct RefreshTestContext { var worktreeIDs: [Worktree.ID] { [mainWorktree.id, featureWorktree.id] } } -nonisolated private func makePullRequestFixture() -> GithubPullRequest { +nonisolated private func makePullRequestFixture( + title: String = "Coord PR", + url: String = "https://example.com/coord-pr/7" +) -> GithubPullRequest { GithubPullRequest( number: 7, - title: "Coord PR", + title: title, state: "OPEN", additions: 0, deletions: 0, @@ -329,7 +437,7 @@ nonisolated private func makePullRequestFixture() -> GithubPullRequest { mergeable: nil, mergeStateStatus: nil, updatedAt: nil, - url: "https://example.com/coord-pr/7", + url: url, headRefName: "feature", baseRefName: "main", commitsCount: 1, -- 2.51.2