diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 5d9e7d84..e740e6ba 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -42,11 +42,12 @@ extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String + var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] - var mergePullRequest: @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void - var closePullRequest: @Sendable (URL, Int) async throws -> Void - var markPullRequestReady: @Sendable (URL, Int) async throws -> Void + var mergePullRequest: @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void + var closePullRequest: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void + var markPullRequestReady: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void var failedRunLogs: @Sendable (URL, Int) async throws -> String var runLogs: @Sendable (URL, Int) async throws -> String @@ -61,6 +62,7 @@ extension GithubCLIClient: DependencyKey { let resolver = GithubCLIExecutableResolver() return GithubCLIClient( defaultBranch: defaultBranchFetcher(shell: shell, resolver: resolver), + resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver), latestRun: latestRunFetcher(shell: shell, resolver: resolver), batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver), mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver), @@ -76,11 +78,12 @@ extension GithubCLIClient: DependencyKey { static let testValue = GithubCLIClient( defaultBranch: { _ in "main" }, + resolveRemoteInfo: { _ in nil }, latestRun: { _, _ in nil }, batchPullRequests: { _, _, _, _ in [:] }, - mergePullRequest: { _, _, _ in }, - closePullRequest: { _, _ in }, - markPullRequestReady: { _, _ in }, + mergePullRequest: { _, _, _, _ in }, + closePullRequest: { _, _, _ in }, + markPullRequestReady: { _, _, _ in }, rerunFailedJobs: { _, _ in }, failedRunLogs: { _, _ in "" }, runLogs: { _, _ in "" }, @@ -102,6 +105,23 @@ private struct GithubPullRequestsRequest: Sendable { let repo: String } +nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { + let owner: Owner + let name: String + let url: String + + nonisolated var remoteInfo: GithubRemoteInfo? { + guard let host = URL(string: url)?.host else { + return nil + } + return GithubRemoteInfo(host: host, owner: owner.login, repo: name) + } + + nonisolated struct Owner: Decodable, Sendable { + let login: String + } +} + private actor GithubCLIExecutableResolver { private var cachedExecutableURL: URL? private var inFlightResolution: Task? @@ -197,6 +217,27 @@ nonisolated private func defaultBranchFetcher( } } +nonisolated private func resolveRemoteInfoFetcher( + shell: ShellClient, + resolver: GithubCLIExecutableResolver +) -> @Sendable (URL) async -> GithubRemoteInfo? { + { repoRoot in + do { + let output = try await runGh( + shell: shell, + resolver: resolver, + arguments: ["repo", "view", "--json", "owner,name,url"], + repoRoot: repoRoot + ) + let data = Data(output.utf8) + let response = try JSONDecoder().decode(GithubRepoViewRemoteInfoResponse.self, from: data) + return response.remoteInfo + } catch { + return nil + } + } +} + nonisolated private func latestRunFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver @@ -258,8 +299,8 @@ nonisolated private func batchPullRequestsFetcher( nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void { - { repoRoot, pullRequestNumber, strategy in +) -> @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber, strategy in _ = try await runGh( shell: shell, resolver: resolver, @@ -268,7 +309,7 @@ nonisolated private func mergePullRequestFetcher( "merge", "\(pullRequestNumber)", "--\(strategy.ghArgument)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } @@ -277,8 +318,8 @@ nonisolated private func mergePullRequestFetcher( nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> Void { - { repoRoot, pullRequestNumber in +) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, resolver: resolver, @@ -286,7 +327,7 @@ nonisolated private func closePullRequestFetcher( "pr", "close", "\(pullRequestNumber)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } @@ -295,8 +336,8 @@ nonisolated private func closePullRequestFetcher( nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> Void { - { repoRoot, pullRequestNumber in +) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, resolver: resolver, @@ -304,12 +345,19 @@ nonisolated private func markPullRequestReadyFetcher( "pr", "ready", "\(pullRequestNumber)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } } +nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo?) -> [String] { + guard let remoteInfo else { + return [] + } + return ["--repo", "\(remoteInfo.host)/\(remoteInfo.owner)/\(remoteInfo.repo)"] +} + nonisolated private func rerunFailedJobsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver diff --git a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift index 3f87e833..af4ad74e 100644 --- a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift +++ b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift @@ -16,10 +16,22 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { continue } let upstreamCandidates = connection.nodes.filter { $0.matches(owner: normalizedOwner, repo: normalizedRepo) } - let candidates = - upstreamCandidates.isEmpty - ? connection.nodes.filter { $0.headRepository != nil } - : upstreamCandidates + let candidates: [PullRequestNode] + if !upstreamCandidates.isEmpty { + candidates = upstreamCandidates + } else { + let forkCandidates = connection.nodes.filter { + $0.headRepository != nil && $0.doesNotTargetSameBranch(branch) + } + candidates = + if !forkCandidates.isEmpty { + forkCandidates + } else { + connection.nodes.filter { + $0.headRepository == nil && $0.doesNotTargetSameBranch(branch) + } + } + } if let node = candidates.max(by: { left, right in let leftRank = left.stateRank let rightRank = right.stateRank @@ -133,6 +145,13 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { return headRepository.owner.login.lowercased() == owner && headRepository.name.lowercased() == repo } + + func doesNotTargetSameBranch(_ branch: String) -> Bool { + guard let baseRefName else { + return true + } + return baseRefName != branch + } } nonisolated struct CommitConnection: Decodable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 0c9d88f8..a1f13f0b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -2,6 +2,19 @@ import AppKit import ComposableArchitecture import Foundation +extension RepositoriesFeature { + func resolveGithubRemoteInfo( + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> GithubRemoteInfo? { + if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { + return remoteInfo + } + return await gitClient.remoteInfo(repositoryRootURL) + } +} + extension RepositoriesFeature { // swiftlint:disable:next cyclomatic_complexity function_body_length func reduceGithubIntegration( @@ -312,6 +325,7 @@ extension RepositoriesFeature { case .markReadyForReview: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -324,7 +338,12 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Marking PR ready…"))) do { - try await githubCLI.markPullRequestReady(worktreeRoot, pullRequest.number) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.markPullRequestReady(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request marked ready"))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { @@ -341,6 +360,7 @@ extension RepositoriesFeature { case .merge: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -356,7 +376,12 @@ extension RepositoriesFeature { let strategy = repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy await send(.showToast(.inProgress("Merging pull request…"))) do { - try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.mergePullRequest(worktreeRoot, remoteInfo, pullRequest.number, strategy) await send(.showToast(.success("Pull request merged"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) @@ -374,6 +399,7 @@ extension RepositoriesFeature { case .close: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -386,7 +412,12 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Closing pull request…"))) do { - try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.closePullRequest(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request closed"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 18be5ba1..d2381eae 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1152,7 +1152,13 @@ struct RepositoriesFeature { let gitClient = gitClient let githubCLI = githubCLI return .run { send in - guard let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) else { + guard + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + else { await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } diff --git a/supacodeTests/GithubBatchPullRequestsTests.swift b/supacodeTests/GithubBatchPullRequestsTests.swift index 2545e0a8..5b450ada 100644 --- a/supacodeTests/GithubBatchPullRequestsTests.swift +++ b/supacodeTests/GithubBatchPullRequestsTests.swift @@ -108,7 +108,7 @@ struct GithubBatchPullRequestsTests { #expect(prs["feature-a"]?.title == "Fork PR") } - @Test func ignoresNilHeadRepositoryInFallback() throws { + @Test func fallsBackToMergedPullRequestWithDeletedFork() throws { let json = """ { "data": { @@ -117,8 +117,8 @@ struct GithubBatchPullRequestsTests { "nodes": [ { "number": 7, - "title": "Head Missing", - "state": "OPEN", + "title": "Deleted Fork", + "state": "MERGED", "additions": 1, "deletions": 0, "isDraft": false, @@ -127,18 +127,62 @@ struct GithubBatchPullRequestsTests { "url": "https://github.com/octo/repo/pull/7", "headRefName": "feature-a", "headRepository": null + } + ] + } + } + } + } + """ + let data = Data(json.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) + let prs = response.pullRequestsByBranch( + aliasMap: ["branch0": "feature-a"], + owner: "octo", + repo: "repo" + ) + #expect(prs["feature-a"]?.number == 7) + #expect(prs["feature-a"]?.title == "Deleted Fork") + } + + @Test func forkFallbackIgnoresSameBaseBranchMatches() throws { + let json = """ + { + "data": { + "repository": { + "branch0": { + "nodes": [ + { + "number": 12, + "title": "Same Branch Candidate", + "state": "OPEN", + "additions": 1, + "deletions": 0, + "isDraft": false, + "reviewDecision": null, + "updatedAt": "2025-01-03T00:00:00Z", + "url": "https://github.com/fork/repo/pull/12", + "headRefName": "feature-a", + "baseRefName": "feature-a", + "headRepository": { + "name": "repo", + "owner": { "login": "fork" } + } }, { - "number": 8, - "title": "Fork PR", + "number": 13, + "title": "Fork PR From Feature", "state": "OPEN", "additions": 1, "deletions": 0, "isDraft": false, "reviewDecision": null, "updatedAt": "2025-01-01T00:00:00Z", - "url": "https://github.com/fork/repo/pull/8", + "url": "https://github.com/fork/repo/pull/13", "headRefName": "feature-a", + "baseRefName": "main", "headRepository": { "name": "repo", "owner": { "login": "fork" } @@ -159,7 +203,8 @@ struct GithubBatchPullRequestsTests { owner: "octo", repo: "repo" ) - #expect(prs["feature-a"]?.number == 8) + #expect(prs["feature-a"]?.number == 13) + #expect(prs["feature-a"]?.title == "Fork PR From Feature") } @Test func prefersOpenOverMergedEvenIfOlder() throws { diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index 8eec680e..083ca5b0 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -48,7 +48,96 @@ actor GithubBatchShellProbe { } } +actor GithubCommandProbe { + struct Call: Equatable { + let arguments: [String] + let currentDirectoryURL: URL? + } + + private var calls: [Call] = [] + + func record(arguments: [String], currentDirectoryURL: URL?) { + calls.append(Call(arguments: arguments, currentDirectoryURL: currentDirectoryURL)) + } + + func snapshot() -> [Call] { + calls + } +} + struct GithubCLIClientTests { + @Test func resolveRemoteInfoUsesGhRepoView() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/fork") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + return ShellOutput( + stdout: #"{"owner":{"login":"supabitapp"},"name":"supacode","url":"https://github.com/supabitapp/supacode"}"#, + stderr: "", + exitCode: 0 + ) + } + ) + let client = GithubCLIClient.live(shell: shell) + + let remoteInfo = try await client.resolveRemoteInfo(repoRoot) + + #expect(remoteInfo == GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode")) + let calls = await probe.snapshot() + #expect( + calls == [ + GithubCommandProbe.Call( + arguments: ["repo", "view", "--json", "owner,name,url"], + currentDirectoryURL: repoRoot + ) + ]) + } + + @Test func pullRequestMutationsUseResolvedRemoteInfo() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/fork") + let remoteInfo = GithubRemoteInfo(host: "github.enterprise.test", owner: "octo", repo: "repo") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + let client = GithubCLIClient.live(shell: shell) + + try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash) + try await client.closePullRequest(repoRoot, remoteInfo, 13) + try await client.markPullRequestReady(repoRoot, remoteInfo, 14) + + let calls = await probe.snapshot() + #expect( + calls.map(\.arguments) == [ + ["pr", "merge", "12", "--squash", "--repo", "github.enterprise.test/octo/repo"], + ["pr", "close", "13", "--repo", "github.enterprise.test/octo/repo"], + ["pr", "ready", "14", "--repo", "github.enterprise.test/octo/repo"], + ]) + #expect(calls.allSatisfy { $0.currentDirectoryURL == repoRoot }) + } + @Test func batchPullRequestsCapsConcurrencyAtThree() async throws { let probe = GithubBatchShellProbe() let shell = ShellClient( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2545f92e..7501d737 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3306,7 +3306,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.mergePullRequest = { _, number, _ in + $0.githubCLI.mergePullRequest = { _, _, number, _ in mergedNumbers.withValue { $0.append(number) } } } @@ -3356,7 +3356,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.mergePullRequest = { _, _, strategy in + $0.githubCLI.mergePullRequest = { _, _, _, strategy in mergedStrategies.withValue { $0.append(strategy) } } } @@ -3374,6 +3374,57 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func pullRequestActionMergeUsesResolvedRemoteInfo() async { + let repoRoot = "/tmp/repo" + 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 openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .disabled + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: openPullRequest + ) + let mutationRemoteInfos = LockIsolated<[GithubRemoteInfo?]>([]) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { root in + #expect(root == URL(fileURLWithPath: repoRoot)) + return upstreamRemoteInfo + } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not be used when gh repo view succeeds") + return nil + } + $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _ in + #expect(root == featureWorktree.workingDirectory) + #expect(number == 12) + mutationRemoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(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(mutationRemoteInfos.value == [upstreamRemoteInfo]) + await store.finish() + } + @Test func pullRequestActionCloseRefreshesImmediately() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -3396,7 +3447,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.closePullRequest = { _, number in + $0.githubCLI.closePullRequest = { _, _, number in closedNumbers.withValue { $0.append(number) } } } @@ -3589,6 +3640,58 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func worktreeInfoEventRepositoryPullRequestRefreshPrefersResolvedRemoteInfo() async { + let repoRoot = "/tmp/repo" + 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 upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let requestedRemoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { root in + #expect(root == URL(fileURLWithPath: repoRoot)) + return upstreamRemoteInfo + } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not be used when gh repo view succeeds") + return nil + } + $0.githubCLI.batchPullRequests = { host, owner, repo, branches in + #expect(branches == ["main", "feature"]) + requestedRemoteInfos.withValue { + $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) + } + return [:] + } + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + #expect(requestedRemoteInfos.value == [upstreamRemoteInfo]) + await store.finish() + } + @Test func worktreeInfoEventRepositoryPullRequestRefreshQueuesWhileAvailabilityUnknown() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot)