diff --git a/Sources/Bloom/Intents/WorkspaceLookup.swift b/Sources/Bloom/Intents/WorkspaceLookup.swift index 0565ecbc9..1c3d54c7f 100644 --- a/Sources/Bloom/Intents/WorkspaceLookup.swift +++ b/Sources/Bloom/Intents/WorkspaceLookup.swift @@ -53,6 +53,7 @@ enum WorkspaceLookup { /// workspace falls back to what git alone can say about it. static func pullRequest(for workspace: Workspace) async -> PullRequest? { guard FileManager.default.fileExists(atPath: workspace.path) else { return nil } - return try? await GitHub.pullRequest(for: workspace, maxAge: .seconds(60)) + let forge = await ForgeResolver.client(for: workspace.path) + return try? await forge.pullRequest(for: workspace, maxAge: .seconds(60)) } } diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index 83eac602e..a490c8c3e 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -19,7 +19,7 @@ enum GitHubBridge { guard availability == .ready else { return .unavailable(GitHubReadFailure(reason: .authentication, message: "Connect GitHub to refresh pull requests.")) } - return await GitHub.readPullRequest(for: workspace, maxAge: maxAge) + return await ForgeResolver.client(for: workspace.path).readPullRequest(for: workspace, maxAge: maxAge) } /// - Parameter maxAge: how old an answer from the last `gh pr view` may be and still be used. @@ -42,7 +42,7 @@ enum GitHubBridge { } static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { - try await GitHub.markReadyForReview(pullRequest, worktree: worktree) + try await ForgeResolver.client(for: worktree).markReadyForReview(pullRequest, worktree: worktree) } static func mergePullRequest( @@ -55,7 +55,7 @@ enum GitHubBridge { /// as it always was, because `try?` would flatten it into that nil. static func checks(for workspace: Workspace) async -> [CheckRun]? { do { - return try await GitHub.checks(for: workspace) + return try await ForgeResolver.client(for: workspace.path).checks(for: workspace) } catch { return [] } diff --git a/Sources/Bloom/Views/Inspector/CheckFailureSender.swift b/Sources/Bloom/Views/Inspector/CheckFailureSender.swift index 69876fc90..201e52ffd 100644 --- a/Sources/Bloom/Views/Inspector/CheckFailureSender.swift +++ b/Sources/Bloom/Views/Inspector/CheckFailureSender.swift @@ -39,7 +39,8 @@ final class CheckFailureSender { if let target = CheckFailureHandoff.logTarget(detailsURL: run.detailsURL) { do { - let log = try await GitHub.checkRunLog(target, worktree: model.workspace.path) + let worktree = model.workspace.path + let log = try await ForgeResolver.client(for: worktree).checkRunLog(target, worktree: worktree) excerpt = CheckFailureHandoff.excerpt(log) } catch { // Not fatal, and deliberately so. A check with no log Bloom can reach is still diff --git a/Sources/BloomCore/Bridge/WorkspaceListTool.swift b/Sources/BloomCore/Bridge/WorkspaceListTool.swift index 8a9f68757..53d31b3c8 100644 --- a/Sources/BloomCore/Bridge/WorkspaceListTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceListTool.swift @@ -358,7 +358,8 @@ public struct WorkspaceListTool: BridgeToolHandling { /// over, so the workspace falls back to what Bloom's own database can say about it. private func pullRequest(for workspace: Workspace, store: Store) async -> PullRequest? { guard FileManager.default.fileExists(atPath: workspace.path) else { return nil } - let found = try? await GitHub.pullRequest(for: workspace, maxAge: .seconds(60)) + let forge = await ForgeResolver.client(for: workspace.path) + let found = try? await forge.pullRequest(for: workspace, maxAge: .seconds(60)) // Every lookup writes the number down, this one included. A listing an agent asked for is // as good a moment as a poll to learn which pull request a workspace is about, and the // column is what keeps the answer findable once the branch is deleted. See diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index e6ca8fe27..bf5ff0050 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -385,13 +385,14 @@ public struct WorkspaceMergeTool: BridgeToolHandling { /// cached "Ready to merge" from before somebody pushed a broken commit is the wrong kind of /// cheap. public static let ask: Reading = { workspace in + let forge = await ForgeResolver.client(for: workspace.path) do { - guard let pullRequest = try await GitHub.pullRequest( + guard let pullRequest = try await forge.pullRequest( forBranch: workspace.branch, worktree: workspace.path ) else { // gh answered and there is nothing, but only if gh could answer at all. Asked // second because it costs a subprocess and the common case never needs it. - let access = await GitHub.access() + let access = await forge.access() return access == .ready ? .noPullRequest : .unavailable(access) } let local = try? await Git.localWork(worktree: workspace.path) @@ -399,7 +400,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } catch { // gh threw. Which of the two it is decides the advice, so it is asked rather than // assumed: an unusable gh is permanent and a failed call usually is not. - let access = await GitHub.access() + let access = await forge.access() return access == .ready ? .failed(plainly(error)) : .unavailable(access) } } diff --git a/Sources/BloomCore/GitHub/ForgeClient.swift b/Sources/BloomCore/GitHub/ForgeClient.swift new file mode 100644 index 000000000..2d610276b --- /dev/null +++ b/Sources/BloomCore/GitHub/ForgeClient.swift @@ -0,0 +1,77 @@ +import Foundation + +/// The pull request operations more than one forge answers. Each requirement takes only what its +/// callers pass, so every default stays on `GitHub` rather than being restated here. +public protocol ForgeClient { + static func access() async -> GitHubAccess + static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead + static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? + static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? + static func checks(for workspace: Workspace) async throws -> [CheckRun]? + static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws + static func checkRunLog(_ target: CheckFailureHandoff.LogTarget, worktree: String) async throws -> String + static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] + static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing + static func repositorySlug(repoPath: String) async -> String? + static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws +} + +/// `GitHub` as a `ForgeClient`. It only forwards. +public enum GitHubForge: ForgeClient { + public static func access() async -> GitHubAccess { + await GitHub.access() + } + + public static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead { + await GitHub.readPullRequest(for: workspace, maxAge: maxAge) + } + + public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { + try await GitHub.pullRequest(for: workspace, maxAge: maxAge) + } + + public static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? { + try await GitHub.pullRequest(forBranch: branch, worktree: worktree) + } + + public static func checks(for workspace: Workspace) async throws -> [CheckRun]? { + try await GitHub.checks(for: workspace) + } + + public static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { + try await GitHub.markReadyForReview(pullRequest, worktree: worktree) + } + + public static func checkRunLog( + _ target: CheckFailureHandoff.LogTarget, worktree: String + ) async throws -> String { + try await GitHub.checkRunLog(target, worktree: worktree) + } + + public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] { + try await GitHub.openPullRequests(repoPath: repoPath) + } + + public static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing { + try await GitHub.pullRequestSummary(number: number, repoPath: repoPath) + } + + public static func repositorySlug(repoPath: String) async -> String? { + await GitHub.repositorySlug(repoPath: repoPath) + } + + public static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws { + try await GitHub.checkoutPullRequest(number: number, into: worktree, localBranch: localBranch) + } +} + +/// Which forge a repository's pull requests are asked of. +/// +/// Every repository answers GitHub, which is the whole of what Bloom speaks today. The directory +/// is taken now so that callers already say which repository they mean when a second answer +/// arrives. +public enum ForgeResolver { + public static func client(for directory: String) async -> any ForgeClient.Type { + GitHubForge.self + } +} diff --git a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift index 99f87dee6..852b7df2b 100644 --- a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift +++ b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift @@ -463,13 +463,14 @@ public enum WorkspaceCheckoutResolver { /// The whole of it, including the gh call. Lives here rather than in the sheet so the sheet is /// left drawing a text field and reading an answer. public static func resolve(_ text: String, repoPath: String) async -> WorkspaceCheckoutResolution { - let slug = await GitHub.repositorySlug(repoPath: repoPath) + let forge = await ForgeResolver.client(for: repoPath) + let slug = await forge.repositorySlug(repoPath: repoPath) if let problem = problem(with: text, in: slug) { return .failure(problem) } guard let reference = WorkspaceCheckoutPlan.parseReference(text) else { return .failure("That is not a pull request number or URL.") } do { - let summary = try await GitHub.pullRequestSummary( + let summary = try await forge.pullRequestSummary( number: reference.number, repoPath: repoPath ) return .checkout(.pullRequest(summary)) diff --git a/Sources/BloomCore/Workspace/WorkspaceManager.swift b/Sources/BloomCore/Workspace/WorkspaceManager.swift index 90466e9d6..3beea3310 100644 --- a/Sources/BloomCore/Workspace/WorkspaceManager.swift +++ b/Sources/BloomCore/Workspace/WorkspaceManager.swift @@ -354,7 +354,7 @@ public struct WorkspaceManager: Sendable { case .pullRequest(let request): try await Git.addDetachedWorktree(repo: repo.path, path: worktreePath) do { - try await GitHub.checkoutPullRequest( + try await ForgeResolver.client(for: repo.path).checkoutPullRequest( number: request.number, into: worktreePath, localBranch: branch ) } catch { diff --git a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift index 68a068593..149e6cf66 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift @@ -179,11 +179,12 @@ public struct WorkspaceCheckoutOptions: Sendable { ) } - let access = await GitHub.access() + let forge = await ForgeResolver.client(for: repoPath) + let access = await forge.access() guard access == .ready else { return options(access: access) } do { - return options(pullRequests: try await GitHub.openPullRequests(repoPath: repoPath)) + return options(pullRequests: try await forge.openPullRequests(repoPath: repoPath)) } catch { return options(failure: error.readableMessage) } diff --git a/Tests/BloomCoreTests/ForgeClientTests.swift b/Tests/BloomCoreTests/ForgeClientTests.swift new file mode 100644 index 000000000..f92f2ec3b --- /dev/null +++ b/Tests/BloomCoreTests/ForgeClientTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Forge client", .scratchDirectory) +struct ForgeClientTests { + @Test("every repository is asked through GitHub") + func resolvesGitHub() async { + let client = await ForgeResolver.client(for: TestScratch.unique("forge")) + #expect(ObjectIdentifier(client) == ObjectIdentifier(GitHubForge.self)) + } + + @Test("the GitHub client runs the gh command GitHub runs") + func forwards() async throws { + let worktree = TestScratch.unique("forge-forward") + let asked = ArgumentLog() + let client = await ForgeResolver.client(for: worktree) + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await asked.record(arguments) + return ShellResult(status: 0, stdout: #"{"number":1,"headRefName":"feature"}"#, stderr: "") + }) { + try await client.pullRequest(forBranch: "feature", worktree: worktree) + } + #expect(await asked.first.map { Array($0.prefix(3)) } == ["pr", "view", "feature"]) + } +} + +private actor ArgumentLog { + private(set) var first: [String]? + + func record(_ arguments: [String]) { + if first == nil { first = arguments } + } +}