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
14 changes: 13 additions & 1 deletion Sources/Bloom/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,15 @@ final class AppModel {
private(set) var lastQuotaAskAt: Date?
private(set) var isAskingForQuotas = false
private var identityTask: Task<Void, Never>?

/// 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<Void, Never>?
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions Sources/Bloom/State/WorkspaceModel+PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions Sources/Bloom/State/WorkspaceModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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() }
Expand Down
11 changes: 7 additions & 4 deletions Sources/Bloom/Views/Chrome/Settings/PromptEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -148,7 +151,7 @@ struct PromptEditor: View {
// MARK: - State

private var isCustomised: Bool {
text != definition.defaultTemplate
text != defaultTemplate
}

private var isEmptyOverride: Bool {
Expand All @@ -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
}

Expand All @@ -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)
}
}
29 changes: 24 additions & 5 deletions Sources/Bloom/Views/Chrome/Settings/PromptSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import BloomCore
struct PromptSettingsView: View {
@State private var selection: PromptID? = .createPullRequest
@State private var customised: Set<PromptID> = []
@State private var forge: Forge = .gitHub
@State private var showsGitLab = false

private var definition: PromptDefinition {
PromptRegistry.definition(for: selection ?? .createPullRequest)
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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))
}
}
4 changes: 3 additions & 1 deletion Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Sources/Bloom/Views/Inspector/GitHubSignIn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading