From e5fc551ea4c5a36f4b8e6e7bf1f430568b353ebc Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Thu, 8 Oct 2026 06:10:28 +0200 Subject: [PATCH] Ask for pull requests through a forge client ForgeClient names the operations a project could need from a forge other than GitHub: reading its pull request and checks, a failed run's log, marking ready, listing, summarising and checking one out. GitHubForge forwards each to GitHub unchanged, and ForgeResolver answers it for every repository, so no command, default or answer moves. Creating a repository stays on GitHub alone. --- Sources/Bloom/Intents/WorkspaceLookup.swift | 3 +- Sources/Bloom/State/Bridges.swift | 6 +- .../Views/Inspector/CheckFailureSender.swift | 3 +- .../BloomCore/Bridge/WorkspaceListTool.swift | 3 +- .../BloomCore/Bridge/WorkspaceMergeTool.swift | 7 +- Sources/BloomCore/GitHub/ForgeClient.swift | 77 +++++++++++++++++++ .../Workspace/WorkspaceCheckout.swift | 5 +- .../Workspace/WorkspaceManager.swift | 2 +- .../Workspace/WorkspaceStartContext.swift | 5 +- Tests/BloomCoreTests/ForgeClientTests.swift | 34 ++++++++ 10 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 Sources/BloomCore/GitHub/ForgeClient.swift create mode 100644 Tests/BloomCoreTests/ForgeClientTests.swift 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 } + } +}