Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/Bloom/Intents/WorkspaceLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
6 changes: 3 additions & 3 deletions Sources/Bloom/State/Bridges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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 []
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/Bloom/Views/Inspector/CheckFailureSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/BloomCore/Bridge/WorkspaceListTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Sources/BloomCore/Bridge/WorkspaceMergeTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -385,21 +385,22 @@ 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)
return .found(pullRequest, local: local)
} 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)
}
}
Expand Down
77 changes: 77 additions & 0 deletions Sources/BloomCore/GitHub/ForgeClient.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 3 additions & 2 deletions Sources/BloomCore/Workspace/WorkspaceCheckout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion Sources/BloomCore/Workspace/WorkspaceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions Sources/BloomCore/Workspace/WorkspaceStartContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
34 changes: 34 additions & 0 deletions Tests/BloomCoreTests/ForgeClientTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading