diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index aa252ab69..773a92125 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -510,6 +510,15 @@ final class AppModel { private(set) var lastQuotaAskAt: Date? private(set) var isAskingForQuotas = false private var identityTask: Task? + + /// GitLab accounts once the launch's identity lookup has finished, which can be after a + /// settings pane has already opened. + func gitLabAccounts() async -> [String: String] { + await identityTask?.value + // Read again on every visit: a sign in may have happened in a terminal since launch. + if GitLab.isInstalled { await GitLabIdentity.resolve(force: true) } + return GitLabIdentity.usernames + } /// The launch sweep for project icons. Not private, because the work it does is in /// `AppModel+ProjectIcons.swift`, and outside observation because nothing draws from it. @ObservationIgnored var iconSearchTask: Task? @@ -624,7 +633,10 @@ final class AppModel { // Held so quitting takes it with us: it is a `gh` subprocess with a ten second timeout, // and `Shell.run` terminates the child when its task is cancelled. - identityTask = Task { await GitHubIdentity.resolve() } + identityTask = Task { + await GitHubIdentity.resolve() + await GitLabIdentity.resolve() + } startBackgroundRefresh() startObservingStore() startObservingSessions() diff --git a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift index a029eb477..7bac35303 100644 --- a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift +++ b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift @@ -20,6 +20,17 @@ extension WorkspaceModel { forge == .gitLab ? .ready : GitHubAvailability.shared.state } + /// Raises the GitLab sign in sheet for this workspace's host, installing glab first if needed. + func signInToGitLab() async { + let access = GitHubAvailability.State(await GitLab.access(in: workspace.path)) + guard access != .ready else { + await refreshPullRequest() + return + } + let host = await GitLab.host(in: workspace.path) + GitHubSignIn.shared.presentGitLab(directory: workspace.path, host: host, access: access) + } + /// gh's readiness, asked afresh, or ready on GitLab. For the Checks tab's poll. func checksReadiness() async -> GitHubAvailability.State { forge = await ForgeResolver.forge(for: workspace.path) diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index a72d393f8..f2adad02c 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -2719,6 +2719,22 @@ final class WorkspaceModel { return nil } + /// Asks the agent to have GitLab rebase the merge request onto its target, and nothing more. + func requestRebase(_ pullRequest: PullRequest) async -> String? { + guard let session = await sessionForPullRequest(titledIfNew: "Rebase") else { + return "Could not open a session in \(workspace.name) to send the request to." + } + let reference = pullRequest.forge.reference(pullRequest.number) + let text = "GitLab will not merge \(reference) until it is rebased onto \(workspace.baseBranch). " + + "Run `glab mr rebase \(pullRequest.number)` from this worktree and wait for it to finish, " + + "then bring this worktree up to date with `git pull --rebase origin \(workspace.branch)`, so it does not hold the " + + "commits from before the rebase. If GitLab or git reports a conflict, stop and say so. " + + "Do not merge \(reference) and do not force push." + activeSessionID = session.id + await transcript(for: session).submit(text) + return nil + } + /// The turn that goes down the wire, with the instructions named in it. /// /// The path goes in the sentence that asks for it, which is where every other file Bloom sends diff --git a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift index a4a9c7087..446d7de9e 100644 --- a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift @@ -29,6 +29,7 @@ struct AgentsSettingsView: View { @State private var accountToRemove: AgentAccountProfile? @State private var showsRemoveConfirmation = false @State private var pathDraft = "" + @State private var gitLabAccounts: [String: String] = [:] /// Which agent `pathDraft` belongs to. `selection` has already moved on by the time the /// change handler runs, so committing against it would file one agent's path under another. @State private var draftKind: AgentKind = .claudeCode @@ -92,6 +93,25 @@ struct AgentsSettingsView: View { } } + if GitLab.isInstalled { + Section("GitLab") { + let accounts = gitLabAccounts.sorted { $0.key < $1.key } + if accounts.isEmpty { + SettingsRow("Account") { + Text("Not signed in with glab") + .foregroundStyle(Palette.textSecondary) + } + } + ForEach(accounts, id: \.key) { host, username in + SettingsRow(host) { + Text(username) + .foregroundStyle(Palette.textSecondary) + .textSelection(.enabled) + } + } + } + } + Section { DisclosureGroup("Advanced configuration") { executableSection(status) @@ -102,6 +122,7 @@ struct AgentsSettingsView: View { } .settingsForm() .task { await bootstrap() } + .task { gitLabAccounts = await app.gitLabAccounts() } .sheet(item: $loginRequest, onDismiss: { Task { await refresh() } }) { request in AgentSignInSheet(request: request) { Task { await refresh() } diff --git a/Sources/Bloom/Views/Chrome/Settings/PromptEditor.swift b/Sources/Bloom/Views/Chrome/Settings/PromptEditor.swift index d0e5ea040..a449c323d 100644 --- a/Sources/Bloom/Views/Chrome/Settings/PromptEditor.swift +++ b/Sources/Bloom/Views/Chrome/Settings/PromptEditor.swift @@ -9,8 +9,11 @@ import BloomCore /// it still falls back to the built-in on the way out. struct PromptEditor: View { let definition: PromptDefinition + var forge: Forge = .gitHub var onSave: () -> Void = {} + private var defaultTemplate: String { PromptOverrides.defaultTemplate(for: definition.id, forge: forge) } + /// Tall enough that the built-in prompts are readable without scrolling on a default window, /// and short enough that the variable reference below stays visible. private static let editorHeight: CGFloat = 220 @@ -148,7 +151,7 @@ struct PromptEditor: View { // MARK: - State private var isCustomised: Bool { - text != definition.defaultTemplate + text != defaultTemplate } private var isEmptyOverride: Bool { @@ -165,7 +168,7 @@ struct PromptEditor: View { // MARK: - Storage private func load() { - text = overrides.stored(for: definition.id) ?? definition.defaultTemplate + text = overrides.stored(for: definition.id, forge: forge) ?? defaultTemplate isLoaded = true } @@ -174,12 +177,12 @@ struct PromptEditor: View { /// have on screen once. private func save(_ value: String) { guard isLoaded else { return } - overrides.set(value == definition.defaultTemplate ? nil : value, for: definition.id) + overrides.set(value == defaultTemplate ? nil : value, for: definition.id, forge: forge) onSave() } private func restoreDefault() { - text = definition.defaultTemplate + text = defaultTemplate save(text) } } diff --git a/Sources/Bloom/Views/Chrome/Settings/PromptSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/PromptSettingsView.swift index 9b07ebb82..2a77b6cbc 100644 --- a/Sources/Bloom/Views/Chrome/Settings/PromptSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/PromptSettingsView.swift @@ -4,6 +4,8 @@ import BloomCore struct PromptSettingsView: View { @State private var selection: PromptID? = .createPullRequest @State private var customised: Set = [] + @State private var forge: Forge = .gitHub + @State private var showsGitLab = false private var definition: PromptDefinition { PromptRegistry.definition(for: selection ?? .createPullRequest) @@ -18,8 +20,22 @@ struct PromptSettingsView: View { .padding(.horizontal, Metrics.inset) .padding(.top, Metrics.inset) + if showsGitLab { + Picker("Forge", selection: $forge) { + Text("GitHub").tag(Forge.gitHub) + Text("GitLab").tag(Forge.gitLab) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, Metrics.inset) + .onChange(of: forge) { + if let selection, !PromptOverrides.hasVariant(selection, for: forge) { self.selection = .createPullRequest } + refreshStatuses() + } + } + List(selection: $selection) { - ForEach(PromptRegistry.all) { prompt in + ForEach(PromptRegistry.all.filter { PromptOverrides.hasVariant($0.id, for: forge) }) { prompt in HStack(spacing: Metrics.spacingSmall) { VStack(alignment: .leading, spacing: Metrics.spacingTight) { Text(prompt.title) @@ -56,11 +72,11 @@ struct PromptSettingsView: View { .foregroundStyle(Palette.textSecondary) } - PromptEditor(definition: definition) { + PromptEditor(definition: definition, forge: forge) { refreshStatuses() } // A different prompt must never inherit the previous editor's draft or focus. - .id(definition.id) + .id("\(forge.rawValue).\(definition.id.rawValue)") Text("Changes save automatically and apply the next time Bloom uses this prompt.") .settingsFootnote() @@ -71,11 +87,14 @@ struct PromptSettingsView: View { } .frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity) } - .onAppear(perform: refreshStatuses) + .onAppear { + showsGitLab = GitLab.isInstalled + refreshStatuses() + } } private func refreshStatuses() { let overrides = PromptOverrides() - customised = Set(PromptRegistry.all.filter { overrides.isCustomised(for: $0.id) }.map(\.id)) + customised = Set(PromptRegistry.all.filter { overrides.stored(for: $0.id, forge: forge) != nil }.map(\.id)) } } diff --git a/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift b/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift index 843070b0f..93613ba64 100644 --- a/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift +++ b/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift @@ -372,7 +372,8 @@ enum SetupRehearsal { claude: SetupOutcome = .ready(detail: "you@example.com"), codex: SetupOutcome = .ready(detail: "you@example.com"), grok: SetupOutcome = .missing, - gitHub: SetupOutcome = .ready(detail: "Signed in") + gitHub: SetupOutcome = .ready(detail: "Signed in"), + gitLab: SetupOutcome = .missing ) -> SetupReport { // Every row the probe has, Grok included. It was written before Grok joined, and a row // the rehearsal did not name stayed pending, so no rehearsal ever settled. @@ -382,6 +383,7 @@ enum SetupRehearsal { SetupCheck(tool: .codex, outcome: codex), SetupCheck(tool: .grok, outcome: grok), SetupCheck(tool: .gitHub, outcome: gitHub), + SetupCheck(tool: .gitLab, outcome: gitLab), ]) } #endif diff --git a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift index 60d886114..833a3aad1 100644 --- a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift +++ b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift @@ -23,6 +23,9 @@ final class GitHubSignIn { /// Where the login runs. Any directory would do; the worktree the user was looking at /// keeps the shell's idea of "here" the same as theirs. var directory: String + var forge: Forge = .gitHub + /// The GitLab host to sign in to. Nil on GitHub. + var host: String? static func == (lhs: Request, rhs: Request) -> Bool { lhs.id == rhs.id } } @@ -71,6 +74,20 @@ final class GitHubSignIn { run(directory: directory) {} } + /// glab's state for this directory's host, with the accounts read again once it is ready, so a + /// sign in shows in Agents and in `gitlab_username` without a relaunch. + func gitLabAccess(in directory: String) async -> GitHubAvailability.State { + let state = GitHubAvailability.State(await GitLab.access(in: directory)) + if state == .ready { await GitLabIdentity.resolve(force: true) } + return state + } + + /// The GitLab sheet, for a read that failed because glab is missing or signed out. + func presentGitLab(directory: String, host: String?, access: GitHubAvailability.State) { + pending = nil + request = Request(access: access, directory: directory, forge: .gitLab, host: host) + } + /// Closes the sheet. On success the remembered action runs, on the same turn of the run loop /// the sheet goes away, so the thing the user asked for happens rather than being announced. func finish(connected: Bool) { diff --git a/Sources/Bloom/Views/Inspector/GitHubSignInSheet.swift b/Sources/Bloom/Views/Inspector/GitHubSignInSheet.swift index bf266eba4..e645a6dc3 100644 --- a/Sources/Bloom/Views/Inspector/GitHubSignInSheet.swift +++ b/Sources/Bloom/Views/Inspector/GitHubSignInSheet.swift @@ -39,8 +39,6 @@ struct GitHubSignInSheet: View { /// Long enough to see that it worked, short enough not to be a step of its own. private static let successPause = Duration.milliseconds(700) - private static let manualURL = "https://cli.github.com/manual/gh_auth_login" - private static let downloadURL = "https://cli.github.com" init(request: GitHubSignIn.Request, onFinish: @escaping (Bool) -> Void) { self.request = request @@ -127,12 +125,12 @@ struct GitHubSignInSheet: View { case .checking: HStack(spacing: InspectorLayout.gap) { ProgressView().controlSize(.small) - Text("Checking with the GitHub CLI") + Text("Checking with the \(words.cliName)") .font(Typo.label) .foregroundStyle(Palette.textSecondary) } case .connected: - Label("Connected to GitHub", systemImage: "checkmark.circle.fill") + Label("Connected to \(words.service)", systemImage: "checkmark.circle.fill") .font(Typo.label) .foregroundStyle(Palette.positive) case .failed(let message): @@ -170,7 +168,7 @@ struct GitHubSignInSheet: View { private var options: some View { VStack(alignment: .leading, spacing: Metrics.spacingWide) { Text( - "You can sign in anywhere: run gh auth login in your own terminal, or use a " + "You can sign in anywhere: run \(words.login) in your own terminal, or use a " + "token you already have. Bloom re-checks on its own, and Check again asks " + "straight away." ) @@ -179,9 +177,9 @@ struct GitHubSignInSheet: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: InspectorLayout.gap) { - Button("Copy gh auth login") { Clipboard.copy("gh auth login") } + Button("Copy \(words.login)") { Clipboard.copy(words.login) } Button("Check again") { recheck() } - Button("GitHub CLI manual") { GitHubBridge.open(Self.manualURL) } + Button("\(words.cliName) manual") { GitHubBridge.open(words.manualURL) } } .buttonStyle(.bordered) .controlSize(.small) @@ -197,7 +195,7 @@ struct GitHubSignInSheet: View { switch phase { case .idle: if access == .notInstalled, !canBrew { - Button("Open cli.github.com") { GitHubBridge.open(Self.downloadURL) } + Button("Open \(words.downloadName)") { GitHubBridge.open(words.downloadURL) } .buttonStyle(.borderedProminent) .tint(Palette.controlAccent) .keyboardShortcut(.defaultAction) @@ -228,24 +226,24 @@ struct GitHubSignInSheet: View { // MARK: - Copy private var title: String { - access == .notInstalled ? "Install the GitHub CLI" : "Connect GitHub" + access == .notInstalled ? "Install the \(words.cliName)" : "Connect \(words.service)" } private var sentence: String { switch access { case .notInstalled where canBrew: - "This action needs the gh command, and it is not installed on this Mac. " + "This action needs the \(words.command) command, and it is not installed on this Mac. " + "Homebrew can install it here." case .notInstalled: - "This action needs the gh command, and it is not installed on this Mac. " - + "Install it from cli.github.com, then come back." + "This action needs the \(words.command) command, and it is not installed on this Mac. " + + "Install it from \(words.downloadName), then come back." default: - "This action needs GitHub access. Sign in with the GitHub CLI to continue." + "This action needs \(words.service) access. Sign in with the \(words.cliName) to continue." } } private var primaryTitle: String { - access == .notInstalled ? "Run brew install gh" : "Run gh auth login" + access == .notInstalled ? "Run brew install \(words.command)" : "Run \(words.login)" } /// Resolved once when the sheet opens rather than on every render: `which` walks the PATH, @@ -258,8 +256,8 @@ struct GitHubSignInSheet: View { private func start() { session?.stop() - let executable = access == .notInstalled ? "brew" : "gh" - let arguments = access == .notInstalled ? ["install", "gh"] : ["auth", "login"] + let executable = access == .notInstalled ? "brew" : words.command + let arguments = access == .notInstalled ? ["install", words.command] : words.loginArguments guard let session = LoginTerminalSession( executable: executable, @@ -280,7 +278,7 @@ struct GitHubSignInSheet: View { private func finished() { phase = .checking Task { - let state = await GitHubAvailability.shared.check(force: true) + let state = await currentAccess() switch state { case .ready: phase = .connected @@ -291,9 +289,9 @@ struct GitHubSignInSheet: View { access = .signedOut phase = .idle case .signedOut: - phase = .failed("The GitHub CLI is still signed out. You can run it again.") + phase = .failed("The \(words.cliName) is still signed out. You can run it again.") case .notInstalled: - phase = .failed("The gh command is still not on this Mac.") + phase = .failed("The \(words.command) command is still not on this Mac.") case .unknown: phase = .failed("Bloom could not tell whether that worked.") } @@ -303,8 +301,8 @@ struct GitHubSignInSheet: View { private func recheck() { phase = .checking Task { - guard await GitHubAvailability.shared.check(force: true) == .ready else { - phase = .failed("Still no GitHub access.") + guard await currentAccess() == .ready else { + phase = .failed("Still no \(words.service) access.") return } phase = .connected @@ -317,4 +315,46 @@ struct GitHubSignInSheet: View { session?.stop() onFinish(false) } + + private var words: SignInWords { SignInWords(request.forge, host: request.host) } + + private func currentAccess() async -> GitHubAvailability.State { + guard request.forge == .gitLab else { return await GitHubAvailability.shared.check(force: true) } + return await GitHubSignIn.shared.gitLabAccess(in: request.directory) + } +} + +/// The words and commands that differ between signing in to GitHub and to GitLab. GitHub's are +/// the sheet's original literals. +private struct SignInWords { + let command: String + let cliName: String + let service: String + let loginArguments: [String] + let manualURL: String + let downloadURL: String + let downloadName: String + + var login: String { ([command] + loginArguments).joined(separator: " ") } + + init(_ forge: Forge, host: String?) { + switch forge { + case .gitHub: + command = "gh" + cliName = "GitHub CLI" + service = "GitHub" + loginArguments = ["auth", "login"] + manualURL = "https://cli.github.com/manual/gh_auth_login" + downloadURL = "https://cli.github.com" + downloadName = "cli.github.com" + case .gitLab: + command = "glab" + cliName = "GitLab CLI" + service = host ?? "GitLab" + loginArguments = ["auth", "login"] + (host.map { ["--hostname", $0] } ?? []) + manualURL = "https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/login.md" + downloadURL = "https://gitlab.com/gitlab-org/cli" + downloadName = "gitlab.com/gitlab-org/cli" + } + } } diff --git a/Sources/Bloom/Views/Inspector/InspectorView.swift b/Sources/Bloom/Views/Inspector/InspectorView.swift index 6fa93a5b2..0cdfb3b8e 100644 --- a/Sources/Bloom/Views/Inspector/InspectorView.swift +++ b/Sources/Bloom/Views/Inspector/InspectorView.swift @@ -34,12 +34,18 @@ struct InspectorView: View { if let failure = model.pullRequestRefreshFailure { HStack(alignment: .top, spacing: InspectorLayout.gap) { VStack(alignment: .leading, spacing: InspectorLayout.tight) { - Text(model.pullRequest == nil ? "GitHub could not refresh" : "Showing the last GitHub update") + Text(model.pullRequest == nil + ? "\(model.forge.name) could not refresh" : "Showing the last \(model.forge.name) update") .font(Typo.captionEmphasis) .foregroundStyle(Palette.textPrimary) Text(failure.message).font(Typo.micro).foregroundStyle(Palette.textSecondary) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) + if model.forge == .gitLab, failure.reason != .rateLimited { + Button("Connect GitLab") { Task { await model.signInToGitLab() } } + .linkButton() + .font(Typo.micro) + } if let retryAt = failure.retryAt { Text("Next refresh after \(retryAt.formatted(date: .omitted, time: .shortened))") .font(Typo.micro).foregroundStyle(Palette.textSecondary) diff --git a/Sources/Bloom/Views/Inspector/PullRequestBar.swift b/Sources/Bloom/Views/Inspector/PullRequestBar.swift index a585eacbc..4c0c8b233 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestBar.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestBar.swift @@ -109,6 +109,7 @@ struct PullRequestBar: View { onMarkReadyForReview: { markReadyForReview(pullRequest) }, onPush: push, onFixConflicts: { fixConflicts(on: pullRequest) }, + onRebase: { rebase(pullRequest) }, onContinue: { carryOn(after: pullRequest) }, onArchive: archive, archiveRequest: $pendingArchive, @@ -268,6 +269,19 @@ struct PullRequestBar: View { } } + /// Hands GitLab's rebase to the agent, the way Fix merge conflicts hands over a conflict. + private func rebase(_ pullRequest: PullRequest) { + isWorking = true + report = nil + + Task { + defer { isWorking = false } + if let refusal = await model.requestRebase(pullRequest) { + report = PullRequestNotice(tone: .info, title: "Nothing was sent", message: refusal) + } + } + } + /// Cuts a fresh branch in this worktree and hands the session on to it. /// /// Reported when it does not work, and silent when it does. A refusal names the condition that diff --git a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift index be26071d3..1801d1626 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift @@ -43,6 +43,7 @@ struct PullRequestSummary: View { var onPush: () -> Void /// Asks the workspace's agent to bring the base branch in and resolve the conflicts with it. var onFixConflicts: () -> Void + var onRebase: () -> Void = {} /// Carries a merged workspace on to a fresh branch, in place. See `continueButton`. var onContinue: () -> Void /// Archives, through the app's ordinary archive with all its checks intact. @@ -281,6 +282,7 @@ struct PullRequestSummary: View { case .merge: mergeControl case .markReadyForReview: markReadyForReviewButton case .fixConflicts: fixConflictsButton + case .rebase: rebaseButton case .commitAndPush, .push: pushButton } } @@ -505,6 +507,21 @@ struct PullRequestSummary: View { ) } + private var rebaseButton: some View { + Button("Rebase", systemImage: "arrow.triangle.branch", action: onRebase) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: Metrics.corner)) + .tint(status.tone.fill) + .controlSize(.regular) + .labelStyle(.titleAndIcon) + .fixedSize() + .help( + branchActions.reason + ?? "Ask this workspace's agent to have GitLab rebase \(pullRequest.forge.reference(pullRequest.number)) " + + "onto \(baseBranch). Nothing is merged." + ) + } + /// Merge, and the chevron that chooses which merge, as one control. /// /// This replaces a pair: a prominent `Merge` button that always proposed a squash, and a small diff --git a/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift b/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift index d71a2497d..b3f86c360 100644 --- a/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift +++ b/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift @@ -600,6 +600,15 @@ struct RepoSettingsView: View { Text("Delete the branch when a workspace is archived") Text("Archiving always removes the worktree. Turn this on to remove its branch too.") } + + // Only where it can matter: glab on this Mac, or a project that already says. + if GitLab.isInstalled || model.draft.forge != nil { + Picker("Hosted on", selection: $model.draft.forge) { + Text("Detect from the remote").tag(Forge?.none) + Text("GitHub").tag(Forge?.some(.gitHub)) + Text("GitLab").tag(Forge?.some(.gitLab)) + } + } } header: { Text("Branches") } footer: { diff --git a/Sources/BloomCore/Bridge/WorkspaceListTool.swift b/Sources/BloomCore/Bridge/WorkspaceListTool.swift index 53d31b3c8..87bfe1f9c 100644 --- a/Sources/BloomCore/Bridge/WorkspaceListTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceListTool.swift @@ -419,6 +419,7 @@ public struct WorkspaceListTool: BridgeToolHandling { case .push: "push" case .commitAndPush: "commitAndPush" case .fixConflicts: "fixConflicts" + case .rebase: "rebase" } } } diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift index d08888260..9eb9c9f5a 100644 --- a/Sources/BloomCore/GitHub/ForgeRouting.swift +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -120,7 +120,7 @@ actor GlabSignIn { private var probes: [String: Task] = [:] static func isInstalled() -> Bool { - GitLab.commandOverride != nil || Shell.which("glab") != nil + GitLab.isInstalled } func isSignedIn(to host: String) async -> Bool { diff --git a/Sources/BloomCore/GitHub/PullRequestStatus.swift b/Sources/BloomCore/GitHub/PullRequestStatus.swift index 8cdc9665f..64d3cf67f 100644 --- a/Sources/BloomCore/GitHub/PullRequestStatus.swift +++ b/Sources/BloomCore/GitHub/PullRequestStatus.swift @@ -60,6 +60,8 @@ public struct PullRequestStatus: Sendable, Hashable { /// cannot do. The strip used to draw Merge here, beside a red band saying the branch /// conflicts, and pressing it asked the agent to run a command GitHub had already refused. case fixConflicts + /// GitLab requires the branch rebased onto its target before it will merge. + case rebase } // A running agent is not one of the reasons in here: everything in this type is what GitHub @@ -246,7 +248,8 @@ public extension PullRequest { text: blocker.text, detail: checksDetail, canMerge: false, - blockedReason: blocker.reason + blockedReason: blocker.reason, + remedy: blocker == .needsRebase ? .rebase : .merge ) } diff --git a/Sources/BloomCore/GitLab/GitLab.swift b/Sources/BloomCore/GitLab/GitLab.swift index 2f729e683..03c75e2a7 100644 --- a/Sources/BloomCore/GitLab/GitLab.swift +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -308,6 +308,17 @@ public enum GitLab: ForgeClient { // MARK: Running glab + /// Whether glab is on this Mac: a walk of the PATH, no process. Settings show GitLab's rows only + /// then, so somebody without it sees what they saw before. + public static var isInstalled: Bool { + commandOverride != nil || Shell.which("glab") != nil + } + + /// The GitLab host this repository's base remote is on, for signing in to it. + public static func host(in directory: String) async -> String? { + await project(in: directory)?.host + } + static func project(in directory: String) async -> GitLabProject? { let context = try? await Git.repositoryContext(in: directory) return GitLabProject(remote: context?.baseRemoteURL) diff --git a/Sources/BloomCore/GitLab/GitLabIdentity.swift b/Sources/BloomCore/GitLab/GitLabIdentity.swift new file mode 100644 index 000000000..3f11a8f8f --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabIdentity.swift @@ -0,0 +1,120 @@ +import Foundation +import Synchronization + +/// The GitLab user glab is signed in as, per host, for `gitlab_username` branch prefixes, the +/// Agents pane and onboarding. Read once per launch, and only when glab is installed. +/// +/// Host by host rather than one `glab auth status`: that one asks every configured host in turn +/// and took 43 seconds on a machine with one unreachable instance among four. +public enum GitLabIdentity { + private struct Identity { + var usernames: [String: String] = [:] + var resolved = false + } + + private static let state = Mutex(Identity()) + + /// Host to username, for every host glab is signed in to. + public static var usernames: [String: String] { + state.withLock(\.usernames) + } + + /// The username on the first host of this repository's remotes that glab is signed in to. + public static func username(forRepo repo: String) -> String? { + let known = usernames + guard !known.isEmpty, + let common = Git.repositoryPaths(in: repo)?.commonDirectory, + let config = try? String(contentsOfFile: (common as NSString).appendingPathComponent("config"), encoding: .utf8) + else { return nil } + return ForgeResolver.remoteURLs(in: config).lazy + .compactMap { GitHub.repositoryHost($0) } + .compactMap { known[$0] } + .first + } + + /// - Parameter force: read again, after a sign in that may have changed the answer. + public static func resolve(force: Bool = false) async { + guard force || !state.withLock(\.resolved) else { return } + let answers = await signedIn() + guard !Task.isCancelled else { return } + state.withLock { + // A host that did not answer, offline or timed out, keeps what it had. + let kept = $0.usernames.filter { answers.unanswered.contains($0.key) } + $0.usernames = answers.usernames.merging(kept) { found, _ in found } + $0.resolved = true + } + } + + /// Every configured host glab is signed in to, asked at once, each given a few seconds. + static func signedIn() async -> (usernames: [String: String], unanswered: Set) { + guard GitLab.isInstalled else { return ([:], []) } + let replies = await withTaskGroup(of: (String, [String: String]?).self) { group in + for host in configuredHosts() { + group.addTask { + let arguments = ["auth", "status", "--hostname", host] + let result: ShellResult? = if let override = GitLab.commandOverride { + try? await override(arguments, nil) + } else { + try? await Shell.run("glab", arguments, timeout: .seconds(5)) + } + return (host, result.map { parse($0.stdout + $0.stderr) }) + } + } + return await group.reduce(into: [(String, [String: String]?)]()) { $0.append($1) } + } + let usernames = replies.compactMap(\.1).reduce(into: [String: String]()) { $0.merge($1) { first, _ in first } } + return (usernames, Set(replies.filter { $0.1 == nil }.map(\.0))) + } + + /// The hosts in glab's config, where glab itself looks for it. + static func configuredHosts( + environment: [String: String] = ProcessInfo.processInfo.environment, home: String = NSHomeDirectory() + ) -> [String] { + var candidates: [String] = [] + if let directory = environment["GLAB_CONFIG_DIR"] { candidates.append(directory) } + if let xdg = environment["XDG_CONFIG_HOME"] { candidates.append("\(xdg)/glab-cli") } + candidates += ["\(home)/Library/Application Support/glab-cli", "\(home)/.config/glab-cli"] + for directory in candidates { + if let text = try? String(contentsOfFile: "\(directory)/config.yml", encoding: .utf8) { + return hosts(inConfig: text) + } + } + return [] + } + + /// The keys of the top level `hosts:` map, read without a YAML parser: one indent deeper than + /// `hosts:`, ending in a colon. + static func hosts(inConfig text: String) -> [String] { + var hosts: [String] = [] + var indent: Int? + var inHosts = false + for line in text.components(separatedBy: .newlines) { + let uncommented = line.range(of: " #").map { String(line[..<$0.lowerBound]) } ?? line + let trimmed = uncommented.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression) + let content = trimmed.drop { $0 == " " } + let depth = trimmed.count - content.count + if content.isEmpty || content.hasPrefix("#") { continue } + if depth == 0 { + inHosts = content.hasPrefix("hosts:") + continue + } + guard inHosts else { continue } + if indent == nil { indent = depth } + guard depth == indent, content.hasSuffix(":") else { continue } + hosts.append(String(content.dropLast()).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))) + } + return hosts + } + + /// `✓ Logged in to as ()` lines, which glab prints per host. + static func parse(_ status: String) -> [String: String] { + var usernames: [String: String] = [:] + for line in status.components(separatedBy: .newlines) { + guard let range = line.range(of: "Logged in to ") else { continue } + let words = line[range.upperBound...].split(separator: " ").map(String.init) + guard words.count >= 3, words[1] == "as" else { continue } + usernames[words[0]] = words[2] + } + return usernames + } +} diff --git a/Sources/BloomCore/Persistence/Settings.swift b/Sources/BloomCore/Persistence/Settings.swift index 2b6da2bc0..dabcbdd0d 100644 --- a/Sources/BloomCore/Persistence/Settings.swift +++ b/Sources/BloomCore/Persistence/Settings.swift @@ -39,6 +39,7 @@ public enum SettingsKey: String, Sendable, Hashable, CaseIterable { case filesToCopy = "file_include_globs" case branchPrefix = "git.branch_prefix" case deleteBranchOnArchive = "git.delete_branch_on_archive" + case forge = "git.forge" case mergeInstructions = "instructions.merge" case conflictInstructions = "instructions.fix_conflicts" case browserURL = "browser.url" @@ -318,6 +319,9 @@ public enum SettingsLoader { case "github_username": settings.branchPrefix = GitHubIdentity.cachedUsername note(.branchPrefix) + case "gitlab_username": + settings.branchPrefix = GitLabIdentity.username(forRepo: repo) + note(.branchPrefix) case "none": settings.branchPrefix = nil note(.branchPrefix) @@ -327,6 +331,7 @@ public enum SettingsLoader { } if let forge = toml["git.forge"]?.stringValue.flatMap(Forge.init(rawValue:)) { settings.forge = forge + note(.forge) } if let delete = toml["git.delete_branch_on_archive"]?.boolValue { settings.deleteBranchOnArchive = delete diff --git a/Sources/BloomCore/Persistence/SettingsWriter.swift b/Sources/BloomCore/Persistence/SettingsWriter.swift index 5f6878a6b..0c4411290 100644 --- a/Sources/BloomCore/Persistence/SettingsWriter.swift +++ b/Sources/BloomCore/Persistence/SettingsWriter.swift @@ -13,6 +13,7 @@ public enum SettingsEdit: Sendable, Hashable { case mergeInstructions(String?) case conflictInstructions(String?) case browserURL(String?) + case forge(Forge?) public var key: SettingsKey { switch self { @@ -26,6 +27,7 @@ public enum SettingsEdit: Sendable, Hashable { case .mergeInstructions: .mergeInstructions case .conflictInstructions: .conflictInstructions case .browserURL: .browserURL + case .forge: .forge } } } @@ -370,6 +372,8 @@ public enum SettingsWriter { set(prefix, at: SettingsKey.branchPrefix.path, in: &document, overriding: overriding) case .browserURL(let url): set(url, at: SettingsKey.browserURL.path, in: &document, overriding: overriding) + case .forge(let forge): + set(forge?.rawValue, at: SettingsKey.forge.path, in: &document, overriding: overriding) case .deleteBranchOnArchive(let flag): document.set(.boolean(flag), at: SettingsKey.deleteBranchOnArchive.path) case .filesToCopy(let globs): diff --git a/Sources/BloomCore/Transcript/PromptOverrides.swift b/Sources/BloomCore/Transcript/PromptOverrides.swift index 16fd097d7..fa59b49c5 100644 --- a/Sources/BloomCore/Transcript/PromptOverrides.swift +++ b/Sources/BloomCore/Transcript/PromptOverrides.swift @@ -61,11 +61,32 @@ public struct PromptOverrides: @unchecked Sendable { return override } + /// The built-in a forge starts from. Prompts with no GitLab variant are shared, overrides too. + public static func defaultTemplate(for id: PromptID, forge: Forge) -> String { + let shared = PromptRegistry.definition(for: id).defaultTemplate + return forge == .gitLab ? GitLabInstructions.defaultTemplate(for: id) ?? shared : shared + } + + public static func hasVariant(_ id: PromptID, for forge: Forge) -> Bool { + forge == .gitHub || GitLabInstructions.defaultTemplate(for: id) != nil + } + + public func stored(for id: PromptID, forge: Forge) -> String? { + guard Self.hasVariant(id, for: forge), forge == .gitLab else { return stored(for: id) } + return defaults.string(forKey: Self.key(for: id, forge: forge)) + } + + public func set(_ text: String?, for id: PromptID, forge: Forge) { + guard Self.hasVariant(id, for: forge), forge == .gitLab else { return set(text, for: id) } + let key = Self.key(for: id, forge: forge) + if let text { defaults.set(text, forKey: key) } else { defaults.removeObject(forKey: key) } + } + public func template(for id: PromptID, forge: Forge) -> String { - guard forge == .gitLab else { return template(for: id) } - let override = defaults.string(forKey: Self.key(for: id, forge: forge)) ?? "" + guard forge == .gitLab, Self.hasVariant(id, for: .gitLab) else { return template(for: id) } + let override = stored(for: id, forge: forge) ?? "" guard !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return GitLabInstructions.defaultTemplate(for: id) ?? PromptRegistry.definition(for: id).defaultTemplate + return Self.defaultTemplate(for: id, forge: forge) } return override } diff --git a/Sources/BloomCore/Workspace/RepoSettingsDraft.swift b/Sources/BloomCore/Workspace/RepoSettingsDraft.swift index cf09b3ea0..bc5b9b63b 100644 --- a/Sources/BloomCore/Workspace/RepoSettingsDraft.swift +++ b/Sources/BloomCore/Workspace/RepoSettingsDraft.swift @@ -56,6 +56,8 @@ public struct RepoSettingsDraft: Sendable, Hashable { /// What a browser pane opens on, as typed, with the variables left unexpanded. Empty for the /// port Bloom allocated, which is what most projects want. public var browserURL = "" + /// Nil is detection, which is what a project that never said is using. + public var forge: Forge? /// Table names under `scripts.run` that the file holds and the loader skipped as broken. A new /// row must not be given one of them, or its keys would land in the broken table the writer /// deliberately leaves alone. See `SettingsWriter.skippedRunScripts`. @@ -82,6 +84,7 @@ public struct RepoSettingsDraft: Sendable, Hashable { mergeInstructions = settings.mergeInstructions ?? "" conflictInstructions = settings.conflictInstructions ?? "" browserURL = settings.browserURL ?? "" + forge = settings.forge } // MARK: - One run script, by identity @@ -197,6 +200,9 @@ public struct RepoSettingsDraft: Sendable, Hashable { if url != (settings.browserURL ?? "") { edits.append(.browserURL(url)) } + if forge != settings.forge { + edits.append(.forge(forge)) + } return edits } } diff --git a/Sources/BloomCore/Workspace/SetupCheck.swift b/Sources/BloomCore/Workspace/SetupCheck.swift index b964cbde6..539232677 100644 --- a/Sources/BloomCore/Workspace/SetupCheck.swift +++ b/Sources/BloomCore/Workspace/SetupCheck.swift @@ -17,6 +17,8 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C case codex case grok case gitHub + /// Shown so GitLab is visibly supported, and optional: never in the verdict or its sentence. + case gitLab public var id: String { rawValue } @@ -29,7 +31,7 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C case .claudeCode: .claudeCode case .codex: .codex case .grok: .grok - case .git, .gitHub: nil + case .git, .gitHub, .gitLab: nil } } @@ -41,6 +43,7 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C case .codex: "Codex" case .grok: "Grok" case .gitHub: "GitHub CLI" + case .gitLab: "GitLab CLI" } } @@ -50,6 +53,7 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C public var sentenceName: String { switch self { case .gitHub: "the GitHub CLI" + case .gitLab: "the GitLab CLI" case .git, .claudeCode, .codex, .grok: title } } @@ -71,6 +75,8 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C "xAI's agent. Bloom can drive a workspace with it instead of Claude Code or Codex." case .gitHub: "Pull requests, checks and merges. Everything else in Bloom works without it." + case .gitLab: + "Merge requests and pipelines, for projects on GitLab. Only those projects need it." } } @@ -80,12 +86,16 @@ public enum SetupTool: String, Sendable, Hashable, CaseIterable, Identifiable, C case .git: "git" case .claudeCode, .codex, .grok: agentKind?.executableName ?? rawValue case .gitHub: "gh" + case .gitLab: "glab" } } /// The order the window lists them in: the flat requirement, then the agents, then the one /// that is optional. Reading down the column is reading down the strength of the ask. - public static let displayOrder: [SetupTool] = [.git, .claudeCode, .codex, .grok, .gitHub] + public static let displayOrder: [SetupTool] = [.git, .claudeCode, .codex, .grok, .gitHub, .gitLab] + + /// A row the verdict ignores whatever it says. + public var isOptional: Bool { self == .gitLab } } // MARK: - How a tool turned out @@ -254,6 +264,21 @@ public extension SetupCheck { url: nil, isInteractive: true ) + + case (.gitLab, .missing): + return SetupFix( + summary: "Install the GitLab CLI", + command: "brew install glab", + url: URL(string: "https://gitlab.com/gitlab-org/cli") + ) + + case (.gitLab, .needsSignIn): + return SetupFix( + summary: "Sign in to GitLab", + command: "glab auth login", + url: nil, + isInteractive: true + ) } } } @@ -338,7 +363,7 @@ public struct SetupReport: Sendable, Hashable { guard isSettled else { return .checking } if !outcome(for: .git).isReady { return .blocked } if !hasRunnableAgent { return .blocked } - let everythingIsReady = checks.allSatisfy { $0.outcome.isReady } + let everythingIsReady = checks.allSatisfy { $0.outcome.isReady || $0.tool.isOptional } return everythingIsReady ? .ready : .readyWithNotes } @@ -357,7 +382,7 @@ public struct SetupReport: Sendable, Hashable { // quiet again half a second later. if hasRunnableAgent || agentsAreStillChecking { return .note } return .problem - case .gitHub: + case .gitHub, .gitLab: return .note } } @@ -403,7 +428,7 @@ public extension SetupReport { /// sentence that has not finished its job. private var readyWithNotesSentence: String { let quiet = checks - .filter { !$0.outcome.isReady && severity(for: $0.tool) == .note } + .filter { !$0.outcome.isReady && !$0.tool.isOptional && severity(for: $0.tool) == .note } .map(\.tool.sentenceName) guard !quiet.isEmpty else { diff --git a/Sources/BloomCore/Workspace/SetupProbe.swift b/Sources/BloomCore/Workspace/SetupProbe.swift index 33b3b50ff..1c63b58ed 100644 --- a/Sources/BloomCore/Workspace/SetupProbe.swift +++ b/Sources/BloomCore/Workspace/SetupProbe.swift @@ -59,6 +59,7 @@ public struct SetupProbe: Sendable { case .git: await SetupCheck(tool: tool, outcome: gitOutcome()) case .claudeCode, .codex, .grok: await SetupCheck(tool: tool, outcome: agentOutcome(tool)) case .gitHub: await SetupCheck(tool: tool, outcome: gitHubOutcome()) + case .gitLab: await SetupCheck(tool: tool, outcome: gitLabOutcome()) } } @@ -133,4 +134,17 @@ public struct SetupProbe: Sendable { case .ready: return .ready(detail: "Signed in") } } + + // MARK: - GitLab + + /// Signed in to any host is ready. See `GitLabIdentity.signedIn` for why host by host. + private func gitLabOutcome() async -> SetupOutcome { + guard Shell.which("glab") != nil else { return .missing } + return Self.gitLabOutcome(signedIn: await GitLabIdentity.signedIn().usernames) + } + + static func gitLabOutcome(signedIn: [String: String]) -> SetupOutcome { + let hosts = signedIn.keys.sorted() + return hosts.isEmpty ? .needsSignIn(detail: nil) : .ready(detail: "Signed in to \(hosts.joined(separator: ", "))") + } } diff --git a/Tests/BloomCoreTests/GitLabRemedyTests.swift b/Tests/BloomCoreTests/GitLabRemedyTests.swift new file mode 100644 index 000000000..4e3517de8 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabRemedyTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab remedies") +struct GitLabRemedyTests { + @Test("a merge request that needs a rebase offers one, and no other blocker does") + func rebase() { + let needsRebase = PullRequest( + number: 3, title: "", url: "", state: "OPEN", forge: .gitLab, blockers: [.needsRebase] + ) + #expect(needsRebase.status.remedy == .rebase) + #expect(needsRebase.status.canMerge == false) + + let unapproved = PullRequest( + number: 3, title: "", url: "", state: "OPEN", forge: .gitLab, blockers: [.approvalRequired] + ) + #expect(unapproved.status.remedy == .merge) + #expect(unapproved.status.canMerge == false) + } +} diff --git a/Tests/BloomCoreTests/GitLabSettingsTests.swift b/Tests/BloomCoreTests/GitLabSettingsTests.swift new file mode 100644 index 000000000..7d1388fb1 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabSettingsTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab settings", .scratchDirectory) +struct GitLabSettingsTests { + @Test("a prompt with no GitLab variant is shared, override included") + func sharedPrompts() throws { + let defaults = try #require(UserDefaults(suiteName: "gitlab-shared-\(UUID().uuidString)")) + let overrides = PromptOverrides(defaults: defaults) + overrides.set("Review it my way.", for: .review) + #expect(overrides.template(for: .review, forge: .gitLab) == "Review it my way.") + #expect(!PromptOverrides.hasVariant(.review, for: .gitLab)) + #expect(PromptOverrides.hasVariant(.mergePullRequest, for: .gitLab)) + + overrides.set("Merge with glab.", for: .mergePullRequest, forge: .gitLab) + #expect(overrides.stored(for: .mergePullRequest) == nil) + #expect(overrides.template(for: .mergePullRequest, forge: .gitLab) == "Merge with glab.") + } + + @Test("the forge chosen in settings is written to the file and read back") + func forgeRoundTrip() throws { + let repo = TestScratch.unique("forge-roundtrip") + try FileManager.default.createDirectory(atPath: repo, withIntermediateDirectories: true) + let settings = SettingsLoader.load(repo: repo) + var draft = RepoSettingsDraft(settings) + #expect(draft.forge == nil) + draft.forge = .gitLab + try SettingsWriter.write(draft.edits(comparedTo: settings), repo: repo, settings: settings) + #expect(SettingsLoader.load(repo: repo).forge == .gitLab) + } +} diff --git a/Tests/BloomCoreTests/SetupCheckTests.swift b/Tests/BloomCoreTests/SetupCheckTests.swift index 37e60c593..6e44e10f6 100644 --- a/Tests/BloomCoreTests/SetupCheckTests.swift +++ b/Tests/BloomCoreTests/SetupCheckTests.swift @@ -9,7 +9,8 @@ private func report( claude: SetupOutcome = .ready(detail: "freek@example.com"), codex: SetupOutcome = .ready(detail: "freek@example.com"), grok: SetupOutcome = .ready(detail: "ada@example.com"), - gitHub: SetupOutcome = .ready(detail: "Signed in") + gitHub: SetupOutcome = .ready(detail: "Signed in"), + gitLab: SetupOutcome = .ready(detail: "Signed in to gitlab.com") ) -> SetupReport { SetupReport(checks: [ SetupCheck(tool: .git, outcome: git), @@ -17,6 +18,7 @@ private func report( SetupCheck(tool: .codex, outcome: codex), SetupCheck(tool: .grok, outcome: grok), SetupCheck(tool: .gitHub, outcome: gitHub), + SetupCheck(tool: .gitLab, outcome: gitLab), ]) } diff --git a/Tests/BloomCoreTests/SetupGitLabTests.swift b/Tests/BloomCoreTests/SetupGitLabTests.swift new file mode 100644 index 000000000..2c5061895 --- /dev/null +++ b/Tests/BloomCoreTests/SetupGitLabTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab in onboarding") +struct SetupGitLabTests { + private func report(gitHub: SetupOutcome, gitLab: SetupOutcome) -> SetupReport { + SetupReport(checks: [ + SetupCheck(tool: .git, outcome: .ready(detail: "2.51.0")), + SetupCheck(tool: .claudeCode, outcome: .ready(detail: nil)), + SetupCheck(tool: .codex, outcome: .missing), + SetupCheck(tool: .grok, outcome: .missing), + SetupCheck(tool: .gitHub, outcome: gitHub), + SetupCheck(tool: .gitLab, outcome: gitLab), + ]) + } + + @Test("a missing glab changes neither the verdict nor its sentence") + func optional() { + let without = report(gitHub: .missing, gitLab: .missing) + let with = report(gitHub: .missing, gitLab: .ready(detail: nil)) + #expect(without.verdict == with.verdict) + #expect(without.sentence == with.sentence) + #expect(!without.sentence.contains("GitLab")) + #expect(without.severity(for: .gitLab) == .note) + #expect(without.blocking.isEmpty) + } + + @Test("a GitHub user with everything but glab is still all set") + func allSetWithoutGlab() { + let report = SetupReport(checks: [ + SetupCheck(tool: .git, outcome: .ready(detail: "2.51.0")), + SetupCheck(tool: .claudeCode, outcome: .ready(detail: nil)), + SetupCheck(tool: .codex, outcome: .ready(detail: nil)), + SetupCheck(tool: .grok, outcome: .ready(detail: nil)), + SetupCheck(tool: .gitHub, outcome: .ready(detail: "Signed in")), + SetupCheck(tool: .gitLab, outcome: .missing), + ]) + #expect(report.verdict == .ready) + #expect(report.headline == "You are all set") + } + + @Test("installing and signing in go the way gh's do") + func fixes() { + #expect(SetupCheck(tool: .gitLab, outcome: .missing).fix?.command == "brew install glab") + #expect(SetupCheck(tool: .gitLab, outcome: .missing).fix?.isInteractive == false) + let signIn = SetupCheck(tool: .gitLab, outcome: .needsSignIn(detail: nil)).fix + #expect(signIn?.command == "glab auth login") + #expect(signIn?.isInteractive == true) + } + + @Test("signed in to one host is ready, even when another host fails") + func status() { + let output = """ + gitlab.com + x gitlab.com: API call failed: 401 Unauthorized + git.example.com + ✓ Logged in to git.example.com as someone (/Users/someone/.config/glab-cli/config.yml) + """ + #expect(SetupProbe.gitLabOutcome(signedIn: GitLabIdentity.parse(output)) == .ready(detail: "Signed in to git.example.com")) + #expect(SetupProbe.gitLabOutcome(signedIn: [:]) == .needsSignIn(detail: nil)) + } +} + +@Suite("GitLab identity") +struct GitLabIdentityTests { + @Test("each signed in host gives its username, and a failing host gives none") + func parse() { + let output = """ + gitlab.com + x gitlab.com: API call failed: 401 Unauthorized + git.example.com + ✓ Logged in to git.example.com as someone (/Users/someone/.config/glab-cli/config.yml) + """ + #expect(GitLabIdentity.parse(output) == ["git.example.com": "someone"]) + } +} + +@Suite("glab's configured hosts") +struct GlabHostsTests { + @Test("the keys of the hosts map, and nothing nested under them") + func hosts() { + let config = """ + # glab config + git_protocol: ssh + hosts: + gitlab.com: # work + api_protocol: https + token: secret + git.example.com: + user: someone + editor: vim + """ + #expect(GitLabIdentity.hosts(inConfig: config) == ["gitlab.com", "git.example.com"]) + } + + @Test("GLAB_CONFIG_DIR wins over the default place, as it does for glab") + func configDirectory() throws { + let directory = TestScratch.unique("glab-config") + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try "hosts:\n only.example:\n user: a\n".write(toFile: "\(directory)/config.yml", atomically: true, encoding: .utf8) + #expect(GitLabIdentity.configuredHosts(environment: ["GLAB_CONFIG_DIR": directory], home: "/nonexistent") == ["only.example"]) + } +}