diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index ca5708308..6446388fb 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -70,7 +70,8 @@ final class PullRequestRefreshCoordinator { repositoryID: Repository.ID, repositoryRootURL: URL, worktreeIDs: [Worktree.ID], - prsByBranch: [String: GithubPullRequest] + prsByBranch: [String: GithubPullRequest], + confirmedNoPrBranches: Set ) case failed( repositoryID: Repository.ID, @@ -355,12 +356,21 @@ final class PullRequestRefreshCoordinator { ) ) } else { + // Only mark branches as "confirmed no PR" when all candidate repos + // succeeded — if any repo failed, the branch status is unknown and + // the reducer should preserve existing PR state. + let allCandidatesSucceeded = + !candidateKeys.isEmpty + && candidateKeys.allSatisfy { prsByRepo[$0] != nil && failedMessagesByRepo[$0] == nil } + let confirmedNoPrBranches: Set = + allCandidatesSucceeded ? Set(request.branches).subtracting(prsByBranch.keys) : [] resultHandler( .refreshed( repositoryID: request.repositoryID, repositoryRootURL: request.repositoryRootURL, worktreeIDs: request.worktreeIDs, - prsByBranch: prsByBranch + prsByBranch: prsByBranch, + confirmedNoPrBranches: confirmedNoPrBranches ) ) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index a29cedf58..a5705bbb3 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -724,7 +724,7 @@ extension RepositoriesFeature { outcome: PullRequestRefreshCoordinator.Outcome ) -> Effect { switch outcome { - case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): + case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch, let confirmedNoPrBranches): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) @@ -733,6 +733,7 @@ extension RepositoriesFeature { mergePullRequestRefreshResults( repositoryID: repositoryID, prsByBranch: prsByBranch, + confirmedNoPrBranches: confirmedNoPrBranches, state: &state ) guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { @@ -742,11 +743,20 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + let hadFailedBatch = state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) != nil + let accumulatedConfirmedNoPrBranches = + state.prRefreshNoPrBranchesByID.removeValue( + forKey: repositoryID + ) ?? [] + // A failed host batch means branch status on that host is unknown, even when + // it arrived before this final refreshed outcome — suppress confirmed clears. + let confirmedNoPrBranches = hadFailedBatch ? [] : accumulatedConfirmedNoPrBranches state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) let prsByWorktreeID = pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, - prsByBranch: mergedPRsByBranch + prsByBranch: mergedPRsByBranch, + confirmedNoPrBranches: confirmedNoPrBranches ) return .merge( .send( @@ -760,6 +770,7 @@ extension RepositoriesFeature { .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) case .failed(let repositoryID, let worktreeIDs, _): + state.prRefreshFailedBatchRepositoryIDs.insert(repositoryID) guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { return .none } @@ -767,6 +778,8 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) + _ = state.prRefreshNoPrBranchesByID.removeValue(forKey: repositoryID) state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) guard !mergedPRsByBranch.isEmpty, let repository = state.repositories[id: repositoryID] @@ -781,7 +794,8 @@ extension RepositoriesFeature { pullRequestsByWorktreeID: pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, - prsByBranch: mergedPRsByBranch + prsByBranch: mergedPRsByBranch, + confirmedNoPrBranches: [] ) ) ) @@ -794,11 +808,9 @@ extension RepositoriesFeature { private func mergePullRequestRefreshResults( repositoryID: Repository.ID, prsByBranch: [String: GithubPullRequest], + confirmedNoPrBranches: Set, state: inout State ) { - guard !prsByBranch.isEmpty else { - return - } var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] var resultPriorities = state.prRefreshResultPrioritiesByRepositoryID[repositoryID] ?? [:] let remotePriorities = state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] ?? [:] @@ -811,6 +823,15 @@ extension RepositoriesFeature { resultPriorities[branch] = priority } } + // Accumulate confirmed-no-PR branches. Only clear when all repos for a + // branch succeeded and none returned a PR — partial failures leave the + // branch out of confirmedNoPrBranches so existing state is preserved. + var existingConfirmed = state.prRefreshNoPrBranchesByID[repositoryID] ?? [] + existingConfirmed.formUnion(confirmedNoPrBranches) + // Remove any confirmed-no-PR entries that now have a PR (priority-based + // merge may have resolved a later host's PR over an earlier "no PR"). + existingConfirmed.subtract(merged.keys) + state.prRefreshNoPrBranchesByID[repositoryID] = existingConfirmed state.prRefreshResultsByRepositoryID[repositoryID] = merged state.prRefreshResultPrioritiesByRepositoryID[repositoryID] = resultPriorities } @@ -831,6 +852,8 @@ extension RepositoriesFeature { ) { state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshNoPrBranchesByID.removeValue(forKey: repositoryID) + state.prRefreshFailedBatchRepositoryIDs.remove(repositoryID) state.prRefreshRemotePrioritiesByRepositoryID.removeValue(forKey: repositoryID) state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) } @@ -838,6 +861,8 @@ extension RepositoriesFeature { private func clearAllPullRequestRefreshTracking(state: inout State) { state.prRefreshBatchCountsByRepositoryID.removeAll() state.prRefreshResultsByRepositoryID.removeAll() + state.prRefreshNoPrBranchesByID.removeAll() + state.prRefreshFailedBatchRepositoryIDs.removeAll() state.prRefreshRemotePrioritiesByRepositoryID.removeAll() state.prRefreshResultPrioritiesByRepositoryID.removeAll() } @@ -858,13 +883,21 @@ extension RepositoriesFeature { private func pullRequestsByWorktreeID( repository: Repository, worktreeIDs: [Worktree.ID], - prsByBranch: [String: GithubPullRequest] + prsByBranch: [String: GithubPullRequest], + confirmedNoPrBranches: Set ) -> [Worktree.ID: GithubPullRequest?] { var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] for worktreeID in worktreeIDs { - if let worktree = repository.worktrees[id: worktreeID] { - prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] - } + guard let worktree = repository.worktrees[id: worktreeID] else { continue } + if let pullRequest = prsByBranch[worktree.name] { + prsByWorktreeID[worktreeID] = pullRequest + } else if confirmedNoPrBranches.contains(worktree.name) { + // All repos confirmed no PR for this branch — explicitly clear. A nil + // literal through the subscript would remove the key instead of storing + // an explicit nil, so downstream would never see the clear. + prsByWorktreeID.updateValue(nil, forKey: worktreeID) + } + // Otherwise: unknown status (partial failure) — omit to preserve existing. } return prsByWorktreeID } @@ -886,18 +919,9 @@ extension RepositoriesFeature { gitClient: gitClient ) guard !remoteInfos.isEmpty else { - let clearedPullRequestsByWorktreeID = Dictionary( - worktreeIDs.map { ($0, Optional.none) }, - uniquingKeysWith: { first, _ in first } - ) - await send( - .githubIntegration( - .repositoryPullRequestsLoaded( - repositoryID: repositoryID, - pullRequestsByWorktreeID: clearedPullRequestsByWorktreeID - ) - ) - ) + // No GitHub remote configured for this repository — preserve existing PR + // values rather than clearing them, which would cause a flicker during + // refresh cycles. await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 718a7c2d3..b913f5915 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -337,6 +337,13 @@ struct RepositoriesFeature { var inFlightPullRequestRefreshRepositoryIDs: Set = [] var prRefreshBatchCountsByRepositoryID: [Repository.ID: Int] = [:] var prRefreshResultsByRepositoryID: [Repository.ID: [String: GithubPullRequest]] = [:] + /// Branches confirmed as having no PR (all repos succeeded, none returned a PR). + /// Used to clear stale PR state without flashing when only some repos succeed. + var prRefreshNoPrBranchesByID: [Repository.ID: Set] = [:] + /// Repositories with at least one failed host batch in the current refresh cycle. + /// A failure means branch status on that host is unknown, so confirmed-no-PR + /// clears from the healthy hosts must be suppressed regardless of arrival order. + var prRefreshFailedBatchRepositoryIDs: Set = [] /// 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]] = [:] diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 30607aab9..c957fc823 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -109,12 +109,14 @@ struct BatchedPullRequestRefreshReducerTests { repositoryID: context.repository.id, repositoryRootURL: context.repoRootURL, worktreeIDs: context.worktreeIDs, - prsByBranch: ["feature": githubPullRequest] + prsByBranch: ["feature": githubPullRequest], + confirmedNoPrBranches: [] ) )) ) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": githubPullRequest] + $0.prRefreshNoPrBranchesByID[context.repository.id] = [] $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": .max] } @@ -125,12 +127,14 @@ struct BatchedPullRequestRefreshReducerTests { repositoryID: context.repository.id, repositoryRootURL: context.repoRootURL, worktreeIDs: context.worktreeIDs, - prsByBranch: [:] + prsByBranch: [:], + confirmedNoPrBranches: [] ) )) ) { $0.prRefreshBatchCountsByRepositoryID = [:] $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshNoPrBranchesByID = [:] $0.prRefreshResultPrioritiesByRepositoryID = [:] } await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { @@ -200,12 +204,14 @@ struct BatchedPullRequestRefreshReducerTests { repositoryID: context.repository.id, repositoryRootURL: context.repoRootURL, worktreeIDs: context.worktreeIDs, - prsByBranch: ["feature": enterprisePullRequest] + prsByBranch: ["feature": enterprisePullRequest], + confirmedNoPrBranches: [] ) )) ) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": enterprisePullRequest] + $0.prRefreshNoPrBranchesByID[context.repository.id] = [] $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": 1] } @@ -216,12 +222,14 @@ struct BatchedPullRequestRefreshReducerTests { repositoryID: context.repository.id, repositoryRootURL: context.repoRootURL, worktreeIDs: context.worktreeIDs, - prsByBranch: ["feature": originPullRequest] + prsByBranch: ["feature": originPullRequest], + confirmedNoPrBranches: [] ) )) ) { $0.prRefreshBatchCountsByRepositoryID = [:] $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshNoPrBranchesByID = [:] $0.prRefreshResultPrioritiesByRepositoryID = [:] } await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { @@ -281,7 +289,7 @@ struct BatchedPullRequestRefreshReducerTests { #expect(enqueued.value.count == 1) } - @Test func refreshClearsStalePullRequestsWhenGithubRemotesDisappear() async { + @Test func refreshPreservesPullRequestsWhenGithubRemotesUnavailable() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) let stalePullRequest = makePullRequestFixture(url: "https://github.com/khoi/alpha/pull/7") @@ -315,9 +323,6 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { - $0.worktreeInfoByID.removeValue(forKey: context.featureWorktree.id) - } await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -342,7 +347,8 @@ struct BatchedPullRequestRefreshReducerTests { repositoryID: context.repository.id, repositoryRootURL: context.repoRootURL, worktreeIDs: context.worktreeIDs, - prsByBranch: ["feature": pullRequest] + prsByBranch: ["feature": pullRequest], + confirmedNoPrBranches: [] ) await store.send(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) @@ -381,6 +387,242 @@ struct BatchedPullRequestRefreshReducerTests { await store.finish() } + @Test func coordinatorOutcomeConfirmedNoPrClearsStalePullRequest() async { + let context = makeContext() + let stalePullRequest = makePullRequestFixture(url: "https://github.com/khoi/alpha/pull/7") + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + var staleEntry = WorktreeInfoEntry() + staleEntry.pullRequest = stalePullRequest + initialState.worktreeInfoByID[context.featureWorktree.id] = staleEntry + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + let outcome = PullRequestRefreshCoordinator.Outcome.refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:], + confirmedNoPrBranches: ["feature"] + ) + + await store.send(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + $0.worktreeInfoByID.removeValue(forKey: context.featureWorktree.id) + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + } + + @Test func coordinatorOutcomeUnknownBranchStatusPreservesPullRequest() async { + let context = makeContext() + let stalePullRequest = makePullRequestFixture(url: "https://github.com/khoi/alpha/pull/7") + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + var staleEntry = WorktreeInfoEntry() + staleEntry.pullRequest = stalePullRequest + initialState.worktreeInfoByID[context.featureWorktree.id] = staleEntry + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + let outcome = PullRequestRefreshCoordinator.Outcome.refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:], + confirmedNoPrBranches: [] + ) + + await store.send(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + + #expect(store.state.worktreeInfoByID[context.featureWorktree.id]?.pullRequest == stalePullRequest) + } + + @Test func confirmedNoPrClearIsSuppressedWhenAnotherHostBatchFailedFirst() async { + let context = makeContext() + let stalePullRequest = makePullRequestFixture(url: "https://ghe.example/khoi/alpha/pull/9") + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + initialState.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + var staleEntry = WorktreeInfoEntry() + staleEntry.pullRequest = stalePullRequest + initialState.worktreeInfoByID[context.featureWorktree.id] = staleEntry + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .failed( + repositoryID: context.repository.id, + worktreeIDs: context.worktreeIDs, + message: "enterprise host down" + ) + )) + ) { + $0.prRefreshFailedBatchRepositoryIDs = [context.repository.id] + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:], + confirmedNoPrBranches: ["feature"] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshFailedBatchRepositoryIDs = [] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + + #expect(store.state.worktreeInfoByID[context.featureWorktree.id]?.pullRequest == stalePullRequest) + } + + @Test func confirmedNoPrClearIsDiscardedWhenFinalHostBatchFails() async { + let context = makeContext() + let stalePullRequest = makePullRequestFixture(url: "https://ghe.example/khoi/alpha/pull/9") + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + initialState.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + var staleEntry = WorktreeInfoEntry() + staleEntry.pullRequest = stalePullRequest + initialState.worktreeInfoByID[context.featureWorktree.id] = staleEntry + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:], + confirmedNoPrBranches: ["feature"] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = [:] + $0.prRefreshNoPrBranchesByID[context.repository.id] = ["feature"] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = [:] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .failed( + repositoryID: context.repository.id, + worktreeIDs: context.worktreeIDs, + message: "enterprise host down" + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshNoPrBranchesByID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + + #expect(store.state.worktreeInfoByID[context.featureWorktree.id]?.pullRequest == stalePullRequest) + } + + @Test func pullRequestFromLaterHostBatchOverridesEarlierConfirmedNoPr() async { + let context = makeContext() + let pullRequest = makePullRequestFixture() + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + initialState.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:], + confirmedNoPrBranches: ["feature"] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = [:] + $0.prRefreshNoPrBranchesByID[context.repository.id] = ["feature"] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = [:] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": pullRequest], + confirmedNoPrBranches: [] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshNoPrBranchesByID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = pullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + } + @Test(.dependencies) func refreshSkippedWhenPullRequestStateFetchDisabled() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 2017e0762..3a786186a 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -289,7 +289,7 @@ struct PullRequestRefreshCoordinatorTests { let snapshots = await outcomes.snapshot() let refreshed = snapshots.compactMap { outcome -> (Repository.ID, [String])? in - if case .refreshed(let id, _, _, let prs) = outcome { + if case .refreshed(let id, _, _, let prs, _) = outcome { return (id, Array(prs.keys)) } return nil @@ -336,7 +336,7 @@ struct PullRequestRefreshCoordinatorTests { #expect(calls.first?.requests.allSatisfy { $0.allowedHeadRepositories == expectedAllowedHeadRepositories } == true) let refreshed = await outcomes.snapshot().compactMap { outcome -> [String: GithubPullRequest]? in - if case .refreshed("local", _, _, let prsByBranch) = outcome { + if case .refreshed("local", _, _, let prsByBranch, _) = outcome { return prsByBranch } return nil @@ -345,6 +345,90 @@ struct PullRequestRefreshCoordinatorTests { #expect(refreshed.first?["feat-1"]?.title == "PR-upstream") } + @Test func allCandidateReposSucceedingConfirmsBranchesWithoutPullRequests() 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", branches: ["feat-1", "feat-2"])) + coordinator.enqueue(request(repo: "upstream", repositoryID: "local", branches: ["feat-1", "feat-2"])) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let refreshed = await outcomes.snapshot().compactMap { + outcome -> ([String: GithubPullRequest], Set)? in + if case .refreshed("local", _, _, let prsByBranch, let confirmedNoPrBranches) = outcome { + return (prsByBranch, confirmedNoPrBranches) + } + return nil + } + let result = try #require(refreshed.first) + #expect(refreshed.count == 1) + #expect(result.0["feat-1"]?.title == "PR-upstream") + #expect(result.1 == ["feat-2"]) + } + + @Test func partialCandidateRepoFailureLeavesBranchesUnconfirmed() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var success: [RepoKey: [String: GithubPullRequest]] = [:] + var failed: [RepoKey: GithubCLIError] = [:] + for request in requests { + if request.repo == "upstream" { + failed[request.key] = .commandFailed("boom") + } else { + success[request.key] = [:] + } + } + return CrossRepoPullRequestResult(successByRepo: success, failedRepos: failed) + }, + legacy: { _, _, _, _ in + throw GithubCLIError.commandFailed("fallback down too") + } + ) + + coordinator.enqueue(request(repo: "fork", repositoryID: "local", branches: ["feat-1", "feat-2"])) + coordinator.enqueue(request(repo: "upstream", repositoryID: "local", branches: ["feat-1", "feat-2"])) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let refreshed = await outcomes.snapshot().compactMap { + outcome -> ([String: GithubPullRequest], Set)? in + if case .refreshed("local", _, _, let prsByBranch, let confirmedNoPrBranches) = outcome { + return (prsByBranch, confirmedNoPrBranches) + } + return nil + } + let result = try #require(refreshed.first) + #expect(refreshed.count == 1) + #expect(result.0.isEmpty) + #expect(result.1.isEmpty) + } + @Test func duplicateRepoKeysFallbackOnceAndFanOutToEachRepository() async throws { let clock = TestClock() let probe = CoordinatorProbe() @@ -476,7 +560,7 @@ struct PullRequestRefreshCoordinatorTests { let snapshots = await outcomes.snapshot() let refresh = try #require( snapshots.compactMap { snapshot -> (String, [String: GithubPullRequest])? in - if case .refreshed(let id, _, _, let prs) = snapshot { + if case .refreshed(let id, _, _, let prs, _) = snapshot { return (id, prs) } return nil @@ -708,7 +792,7 @@ actor OutcomeCollector { func refreshedRepositories() -> [String] { outcomes.compactMap { - if case .refreshed(let id, _, _, _) = $0 { + if case .refreshed(let id, _, _, _, _) = $0 { return id } return nil diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 04f230f6b..ae7b4b2dd 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -5933,7 +5933,6 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -6062,7 +6061,6 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -6166,7 +6164,6 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] }