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
5 changes: 4 additions & 1 deletion Sources/Bloom/State/Bridges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ enum GitHubBridge {
try await DirectPullRequestCreation.create(for: workspace)
}
static func readPullRequest(for workspace: Workspace, maxAge: Duration = .zero) async -> PullRequestRead {
guard await ForgeResolver.forge(for: workspace.path) == .gitHub else {
return await GitLab.readPullRequest(for: workspace, maxAge: maxAge)
}
let availability = await GitHubAvailability.shared.check()
if availability == .notInstalled {
return .unavailable(GitHubReadFailure(reason: .unavailable, message: "Install the GitHub CLI to refresh pull requests."))
}
guard availability == .ready else {
return .unavailable(GitHubReadFailure(reason: .authentication, message: "Connect GitHub to refresh pull requests."))
}
return await ForgeResolver.client(for: workspace.path).readPullRequest(for: workspace, maxAge: maxAge)
return await GitHub.readPullRequest(for: workspace, maxAge: maxAge)
}

/// - Parameter maxAge: how old an answer from the last `gh pr view` may be and still be used.
Expand Down
5 changes: 4 additions & 1 deletion Sources/Bloom/State/WorkspaceModel+PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ extension WorkspaceModel {
await checkFailures.send(run, in: self)
}

var gitHubReadiness: GitHubAvailability.State { GitHubAvailability.shared.state }
/// GitLab reports its own access through the read, so gh's state says nothing about it.
var gitHubReadiness: GitHubAvailability.State {
forge == .gitLab ? .ready : GitHubAvailability.shared.state
}

func continueAfterMerge(_ pullRequest: PullRequest) async -> AppModel.ContinuationOutcome {
await app.continueAfterMerge(workspace, pullRequest: pullRequest)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Bloom/State/WorkspaceModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ final class WorkspaceModel {
}

var isLoadingPullRequest = false
/// Which forge this workspace's repository answered to on the last refresh.
var forge: Forge = .gitHub
/// Whether any refresh has come back for this workspace this launch, whatever it said.
///
/// Not the same question as `pullRequest != nil`, and that is the whole point: "this branch
Expand Down Expand Up @@ -2454,6 +2456,8 @@ final class WorkspaceModel {
) {
isLoadingPullRequest = true
}
// Before the read is awaited, so the supersession guard below still has nothing after it.
forge = await ForgeResolver.forge(for: asked.path)

let read = await task.value

Expand Down
5 changes: 5 additions & 0 deletions Sources/Bloom/Views/Inspector/GitHubSignIn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ final class GitHubSignIn {
/// cannot be undone may be handed to this.
func run(directory: String, action: @escaping @MainActor () -> Void) {
Task {
// A GitLab project never needs gh, and says what glab needs through its own reads.
guard await ForgeResolver.forge(for: directory) == .gitHub else {
action()
return
}
let state = await GitHubAvailability.shared.check()
guard state != .ready else {
action()
Expand Down
4 changes: 2 additions & 2 deletions Sources/BloomCore/Bridge/WorkspaceMergeTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,15 +392,15 @@ public struct WorkspaceMergeTool: BridgeToolHandling {
) 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 forge.access()
let access = await forge.access(in: workspace.path)
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 forge.access()
let access = await forge.access(in: workspace.path)
return access == .ready ? .failed(plainly(error)) : .unavailable(access)
}
}
Expand Down
26 changes: 22 additions & 4 deletions Sources/BloomCore/GitHub/CheckFailureHandoff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,22 @@ public enum CheckFailureHandoff {
public var runID: String
/// The job inside that run, when the URL named one.
public var jobID: String?
/// The GitLab project the job ran in, which for a merge request from a fork is the fork.
/// Nil on GitHub, whose run ids are found through the repository gh is pointed at.
public var project: String?

public init(runID: String, jobID: String? = nil) {
public init(runID: String, jobID: String? = nil, project: String? = nil) {
self.runID = runID
self.jobID = jobID
self.project = project
}
}

public static func logTarget(detailsURL: String?) -> LogTarget? {
guard let detailsURL, let url = URL(string: detailsURL),
let host = url.host?.lowercased(), host == "github.com" || host.hasSuffix(".github.com")
else { return nil }
guard let detailsURL, let url = URL(string: detailsURL), let host = url.host?.lowercased() else {
return nil
}
guard host == "github.com" || host.hasSuffix(".github.com") else { return gitLabJob(url) }

let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard let runs = parts.firstIndex(of: "runs"), runs > 0, parts[runs - 1] == "actions",
Expand All @@ -55,6 +60,19 @@ public enum CheckFailureHandoff {
return LogTarget(runID: runID, jobID: jobID)
}

/// A GitLab job page, `https://<host>/<project path>/-/jobs/<id>`. The job is its own run.
private static func gitLabJob(_ url: URL) -> LogTarget? {
let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard let separator = parts.firstIndex(of: "-"), separator >= 2,
parts.indices.contains(separator + 2), parts[separator + 1] == "jobs"
else { return nil }
let jobID = parts[separator + 2]
guard !jobID.isEmpty, jobID.allSatisfy(\.isNumber) else { return nil }
return LogTarget(
runID: jobID, jobID: jobID, project: parts[..<separator].joined(separator: "/")
)
}

// MARK: - How much of a log to keep

/// The head of the log, in lines.
Expand Down
31 changes: 31 additions & 0 deletions Sources/BloomCore/GitHub/Forge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// The server a project's pull requests live on, and the few words that differ between them.
/// The raw values are what a project writes as `git.forge`.
public enum Forge: String, Sendable, Hashable, Codable {
case gitHub = "github"
case gitLab = "gitlab"

/// The GitHub words are the old literals, held by the characterisation tests.
public var request: String {
switch self {
case .gitHub: "pull request"
case .gitLab: "merge request"
}
}

public var name: String {
switch self {
case .gitHub: "GitHub"
case .gitLab: "GitLab"
}
}

/// `#12` on GitHub, `!12` on GitLab, where `#12` is an issue.
public func reference(_ number: Int) -> String {
switch self {
case .gitHub: "#\(number)"
case .gitLab: "!\(number)"
}
}
}
25 changes: 6 additions & 19 deletions Sources/BloomCore/GitHub/ForgeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ 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 access(in directory: String) async -> GitHubAccess
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]?
Expand All @@ -18,14 +17,11 @@ public protocol ForgeClient {

/// `GitHub` as a `ForgeClient`. It only forwards.
public enum GitHubForge: ForgeClient {
public static func access() async -> GitHubAccess {
/// gh signs in once for every repository, so the directory changes nothing here.
public static func access(in directory: String) 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)
}
Expand All @@ -45,7 +41,9 @@ public enum GitHubForge: ForgeClient {
public static func checkRunLog(
_ target: CheckFailureHandoff.LogTarget, worktree: String
) async throws -> String {
try await GitHub.checkRunLog(target, worktree: worktree)
// A GitLab job's page, on a repository GitHub reports for: not a run gh can find.
guard target.project == nil else { throw GitHubError("This check's log is not on GitHub.") }
return try await GitHub.checkRunLog(target, worktree: worktree)
}

public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] {
Expand All @@ -64,14 +62,3 @@ public enum GitHubForge: ForgeClient {
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
}
}
143 changes: 143 additions & 0 deletions Sources/BloomCore/GitHub/ForgeRouting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Foundation

/// Which forge a repository's pull requests are asked of: GitLab only on positive evidence,
/// GitHub otherwise. A GitHub remote anywhere wins, and nobody is asked. `git.forge` in a
/// settings file overrules detection.
public enum ForgeRouting {
public enum Decision: Sendable, Equatable {
case forge(Forge)
/// Neither GitHub nor gitlab.com: GitLab only if glab is signed in to the host.
case askGlab(host: String)
}

public static func decide(declared: Forge?, baseRemoteURL: String?, remoteURLs: [String]) -> Decision {
if let declared { return .forge(declared) }
guard let base = GitHub.repositoryHost(baseRemoteURL) else { return .forge(.gitHub) }
if isGitHub(base) || remoteURLs.compactMap({ GitHub.repositoryHost($0) }).contains(where: isGitHub) {
return .forge(.gitHub)
}
if isGitLabCom(base) { return .forge(.gitLab) }
return .askGlab(host: base)
}

/// Any host naming GitHub, so Enterprise hosts and SSH aliases like `github-work` are never
/// probed with glab.
static func isGitHub(_ host: String) -> Bool {
host.contains("github")
}

static func isGitLabCom(_ host: String) -> Bool {
host == "gitlab.com" || host.hasSuffix(".gitlab.com")
}
}

/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes.
public enum ForgeResolver {
private static let resolutions = ForgeResolutions()

public static func client(for directory: String) async -> any ForgeClient.Type {
switch await forge(for: directory) {
case .gitHub: GitHubForge.self
case .gitLab: GitLab.self
}
}

public static func forge(for directory: String) async -> Forge {
let paths = Git.repositoryPaths(in: directory)
let checkout = mainCheckout(paths) ?? directory
let configPath = paths.map { ($0.commonDirectory as NSString).appendingPathComponent("config") }
let fingerprint = fingerprint(of: [configPath].compactMap { $0 } + SettingsLoader.candidatePaths(repo: checkout))
if let known = await resolutions.forge(for: directory, fingerprint: fingerprint) { return known }

let forge = await resolve(directory: directory, checkout: checkout, configPath: configPath)
await resolutions.store(forge, for: directory, fingerprint: fingerprint)
return forge
}

private static func resolve(directory: String, checkout: String, configPath: String?) async -> Forge {
if let declared = SettingsLoader.load(repo: checkout).forge { return declared }
// Settled from the config file's text where possible, so a GitHub user pays no process.
let written = configPath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) } ?? ""
let hosts = remoteURLs(in: written).compactMap { GitHub.repositoryHost($0) }
if hosts.contains(where: ForgeRouting.isGitHub) { return .gitHub }
if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return .gitHub }

let config = (try? await Git.repositoryConfiguration(in: directory)) ?? [:]
let context = try? await Git.repositoryContext(in: directory)
let remotes = config.filter { $0.key.hasPrefix("remote.") && $0.key.hasSuffix(".url") }.map(\.value)
switch ForgeRouting.decide(declared: nil, baseRemoteURL: context?.baseRemoteURL, remoteURLs: remotes) {
case .forge(let decided): return decided
case .askGlab(let host): return await GlabSignIn.shared.isSignedIn(to: host) ? .gitLab : .gitHub
}
}

/// The `url = ` values of a git config file, without git. Includes and `insteadOf` are not
/// followed; anything they hide reaches the full resolution instead.
static func remoteURLs(in config: String) -> [String] {
config.split(whereSeparator: \.isNewline).compactMap { line in
let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count == 2, parts[0].lowercased() == "url" else { return nil }
return parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}

/// The project's own checkout, where `settings.local.toml` lives.
static func mainCheckout(_ paths: GitRepositoryPaths?) -> String? {
guard let common = paths?.commonDirectory, (common as NSString).lastPathComponent == ".git" else { return nil }
return (common as NSString).deletingLastPathComponent
}

private static func fingerprint(of paths: [String]) -> [Date?] {
paths.map { (try? FileManager.default.attributesOfItem(atPath: $0))?[.modificationDate] as? Date }
}
}

private actor ForgeResolutions {
private var entries: [String: (fingerprint: [Date?], forge: Forge)] = [:]

func forge(for directory: String, fingerprint: [Date?]) -> Forge? {
guard let entry = entries[directory], entry.fingerprint == fingerprint else { return nil }
return entry.forge
}

func store(_ forge: Forge, for directory: String, fingerprint: [Date?]) {
entries[directory] = (fingerprint, forge)
}
}

/// Whether glab is signed in to a host. One probe per host at a time; a yes is kept for the
/// launch, a no only for a few minutes, because a VPN that was down comes back.
actor GlabSignIn {
static let shared = GlabSignIn()
private static let timeout = Duration.seconds(10)
private static let refusalLifetime = Duration.seconds(300)

private var answers: [String: (signedIn: Bool, at: ContinuousClock.Instant)] = [:]
private var probes: [String: Task<Bool, Never>] = [:]

static func isInstalled() -> Bool {
GitLab.commandOverride != nil || Shell.which("glab") != nil
}

func isSignedIn(to host: String) async -> Bool {
if let known = answers[host], known.signedIn || known.at.duration(to: .now) < Self.refusalLifetime {
return known.signedIn
}
if let running = probes[host] { return await running.value }
let probe = Task { await Self.probe(host) }
probes[host] = probe
let answer = await probe.value
probes[host] = nil
answers[host] = (answer, .now)
return answer
}

private static func probe(_ host: String) async -> Bool {
let arguments = ["auth", "status", "--hostname", host]
if let override = GitLab.commandOverride {
return (try? await override(arguments, nil))?.ok ?? false
}
guard Shell.which("glab") != nil else { return false }
return (try? await Shell.run("glab", arguments, timeout: timeout))?.ok ?? false
}
}
Loading
Loading