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
2 changes: 1 addition & 1 deletion Sources/Bloom/State/AppModel+Workspaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ extension AppModel {
pullRequest: PullRequest,
in model: WorkspaceModel
) async {
let template = PromptOverrides().template(for: .continueAfterMerge)
let template = PromptOverrides().template(for: .continueAfterMerge, forge: pullRequest.forge)
let render = continuation.render(template: template, pullRequest: pullRequest.number)

guard let session = await continuationSession(in: model) else { return }
Expand Down
12 changes: 8 additions & 4 deletions Sources/Bloom/State/Bridges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import BloomCore
/// The one place the app layer touches `GitHub`. Keeping it behind a single adapter means a
/// change to the gh wrapper's signature is a one-file fix rather than a sweep through views.
///
/// Pull requests can be prepared by an agent or directly through git and gh. Direct merges and
/// marking a draft ready also use the GitHub CLI without involving an agent.
/// Pull requests can be prepared by an agent or directly through git and gh, or glab on GitLab.
/// Direct merges and marking a draft ready also use the forge's CLI without involving an agent.
enum GitHubBridge {
static func createPullRequestDirectly(for workspace: Workspace) async throws -> PullRequest {
try await DirectPullRequestCreation.create(for: workspace)
try await DirectPullRequestCreation.create(
for: workspace, forge: await ForgeResolver.forge(for: workspace.path)
)
}
static func readPullRequest(for workspace: Workspace, maxAge: Duration = .zero) async -> PullRequestRead {
guard await ForgeResolver.forge(for: workspace.path) == .gitHub else {
Expand Down Expand Up @@ -51,7 +53,9 @@ enum GitHubBridge {
static func mergePullRequest(
_ pullRequest: PullRequest, base: String, method: GitHub.MergeMethod, worktree: String
) async throws -> DirectMergeOutcome {
try await GitHub.mergePullRequest(pullRequest, into: base, method: method, worktree: worktree)
try await ForgeResolver.client(of: pullRequest.forge).mergePullRequest(
pullRequest, into: base, method: method, worktree: worktree
)
}

/// Nil when GitHub refused this token the check runs. A failed read is still an empty list,
Expand Down
6 changes: 6 additions & 0 deletions Sources/Bloom/State/WorkspaceModel+PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ extension WorkspaceModel {
forge == .gitLab ? .ready : GitHubAvailability.shared.state
}

/// 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)
return forge == .gitLab ? .ready : await GitHubAvailability.shared.check()
}

func continueAfterMerge(_ pullRequest: PullRequest) async -> AppModel.ContinuationOutcome {
await app.continueAfterMerge(workspace, pullRequest: pullRequest)
}
Expand Down
37 changes: 23 additions & 14 deletions Sources/Bloom/State/WorkspaceModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2503,7 +2503,8 @@ final class WorkspaceModel {
///
/// Returns nil on success, or the sentence to put in front of the user.
func requestPullRequest(overrides: PromptOverrides = PromptOverrides()) async -> String? {
let template = overrides.template(for: .createPullRequest)
let forge = await ForgeResolver.forge(for: workspace.path)
let template = overrides.template(for: .createPullRequest, forge: forge)
let wanted = Set(PromptTemplate.variableNames(in: template))

// Only what this template actually asks for. The built-in one names the target branch and
Expand Down Expand Up @@ -2532,7 +2533,7 @@ final class WorkspaceModel {
// pressed a button in the inspector should be looking at the answer to it.
activeSessionID = session.id
isExpectingPullRequest = true
await transcript(for: session).submit(await pullRequestTurn(text: render.text))
await transcript(for: session).submit(await pullRequestTurn(text: render.text, forge: forge))
return nil
}

Expand All @@ -2559,7 +2560,7 @@ final class WorkspaceModel {
///
/// Returns nil on success, or the sentence to put in front of the user.
func requestPush(overrides: PromptOverrides = PromptOverrides()) async -> String? {
let template = overrides.template(for: .pushLocalWork)
let template = overrides.template(for: .pushLocalWork, forge: await ForgeResolver.forge(for: workspace.path))
let wanted = Set(PromptTemplate.variableNames(in: template))

if wanted.contains(PromptRegistry.PushLocalWork.changes) {
Expand Down Expand Up @@ -2622,11 +2623,12 @@ final class WorkspaceModel {
title: pullRequest.title,
branch: pullRequest.branch,
baseBranch: workspace.baseBranch,
method: method
method: method,
forge: pullRequest.forge
)
let render = context.render(template: overrides.template(for: .mergePullRequest))
let render = context.render(template: overrides.template(for: .mergePullRequest, forge: pullRequest.forge))

let text = await turn(render.text, for: .merge)
let text = await turn(render.text, for: .merge, forge: pullRequest.forge)
activeSessionID = session.id
await transcript(for: session).submit(text)
return nil
Expand All @@ -2653,7 +2655,9 @@ final class WorkspaceModel {
/// refreshed when the workspace is selected, and the sequence that has to work is typing an
/// instruction in the project settings window and pressing Merge in the window behind it
/// without touching the sidebar in between.
private func turn(_ text: String, for subject: ProjectInstructions.Subject) async -> String {
private func turn(
_ text: String, for subject: ProjectInstructions.Subject, forge: Forge
) async -> String {
await reloadSettings()
let stated = ProjectInstructions.stated(subject, in: settings)
let path = workspace.path
Expand All @@ -2662,7 +2666,7 @@ final class WorkspaceModel {
let extra = await Task.detached(priority: .userInitiated) {
ProjectInstructions.resolve(subject, in: path, stated: stated)
}.value
return ProjectInstructions.turn(text, for: subject, adding: extra)
return ProjectInstructions.turn(text, for: subject, adding: extra, forge: forge)
}

/// Asks the workspace's agent to bring the base branch in and resolve the conflicts.
Expand Down Expand Up @@ -2699,15 +2703,17 @@ final class WorkspaceModel {
branch: workspace.branch,
baseBranch: workspace.baseBranch
)
let render = context.render(template: overrides.template(for: .fixConflicts))
let forge = pullRequest.forge
let render = context.render(template: overrides.template(for: .fixConflicts, forge: forge))

let path = workspace.path
let rendered = render.text
let contents = forge == .gitLab ? GitLabInstructions.conflictMarkdown : ConflictInstructions.defaultMarkdown
// Off the main actor: it writes a file into the worktree, and this runs on a button press.
let asked = await Task.detached(priority: .userInitiated) {
ConflictInstructions.asking(rendered, in: path)
ConflictInstructions.asking(rendered, in: path, contents: contents)
}.value
let text = await turn(asked, for: .fixConflicts)
let text = await turn(asked, for: .fixConflicts, forge: forge)
activeSessionID = session.id
await transcript(for: session).submit(text)
return nil
Expand All @@ -2722,11 +2728,14 @@ final class WorkspaceModel {
///
/// When the file cannot be written, the instructions go into the message itself. A read-only
/// checkout is a reason to say it differently, not a reason for the button to stop working.
private func pullRequestTurn(text: String) async -> String {
if let path = await PullRequestInstructions.ensure(in: workspace.path) {
private func pullRequestTurn(text: String, forge: Forge) async -> String {
let (contents, scratch) = forge == .gitLab
? (GitLabInstructions.mergeRequestMarkdown, GitLabInstructions.mergeRequestScratchPath)
: (PullRequestInstructions.defaultMarkdown, PullRequestInstructions.scratchPath)
if let path = await PullRequestInstructions.ensure(in: workspace.path, contents: contents, scratch: scratch) {
return PullRequestInstructions.asking(text, toFollow: path)
}
return text + "\n\n" + PullRequestInstructions.defaultMarkdown
return text + "\n\n" + contents
}

/// A workspace whose agent was never started still has a button to press. Rather than doing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,8 @@ struct CreateWorkspaceView: View {
pullRequests: checkoutOptions.pullRequests,
branches: checkoutOptions.branches,
baseBranches: branchOptions,
defaultBranch: repo?.defaultBranch ?? ""
defaultBranch: repo?.defaultBranch ?? "",
forge: checkoutOptions.forge
)
}

Expand Down
18 changes: 9 additions & 9 deletions Sources/Bloom/Views/Inspector/BranchActionsPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ enum PullRequestCreationRoute: String, CaseIterable {
case agent
case direct

var label: String {
func label(on forge: Forge) -> String {
switch self {
case .agent: "Ask agent to create PR"
case .direct: "Commit and create PR with GitHub CLI"
case .agent: "Ask agent to create \(forge.shortRequest)"
case .direct: "Commit and create \(forge.shortRequest) with \(forge.name) CLI"
}
}
}
Expand All @@ -23,8 +23,8 @@ struct BranchActionsPopover: View {

var body: some View {
ConfirmationPopover(
title: "Create pull request?",
confirmLabel: "Create PR",
title: "Create \(model.forge.request)?",
confirmLabel: "Create \(model.forge.shortRequest)",
tint: Palette.controlAccent,
canConfirm: route == .agent || (model.gitHubReadiness.isUsable && !model.isRunning),
onConfirm: {
Expand All @@ -36,23 +36,23 @@ struct BranchActionsPopover: View {
) {
Picker("Create using", selection: $route) {
ForEach(PullRequestCreationRoute.allCases, id: \.self) { choice in
Text(choice.label).tag(choice)
Text(choice.label(on: model.forge)).tag(choice)
}
}
.pickerStyle(.radioGroup)

if route == .direct {
Text("Bloom stages and commits all local changes as “\(model.workspace.name)”, pushes the branch, then opens a pull request with gh.")
Text("Bloom stages and commits all local changes as “\(model.workspace.name)”, pushes the branch, then opens a \(model.forge.request) with \(model.forge.cli).")
.foregroundStyle(.secondary)
if !model.gitHubReadiness.isUsable {
Text("Connect GitHub to use the direct route.")
Text("Connect \(model.forge.name) to use the direct route.")
.foregroundStyle(Palette.negative)
} else if model.isRunning {
Text("Wait for the agent to finish before committing directly.")
.foregroundStyle(Palette.negative)
}
} else {
Text("The agent will prepare the commits, push, and create the pull request using this project's instructions.")
Text("The agent will prepare the commits, push, and create the \(model.forge.request) using this project's instructions.")
.foregroundStyle(.secondary)
}
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/Bloom/Views/Inspector/ChecksView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ struct ChecksView: View {
EmptyStateView(
glyph: "checkmark.seal",
title: "No checks",
message: "GitHub has not reported a check run for this branch."
message: "\(model.forge.name) has not reported a check run for this branch."
)
} else {
LoadingView("Asking GitHub")
LoadingView("Asking \(model.forge.name)")
}
}

Expand Down Expand Up @@ -220,7 +220,7 @@ struct ChecksView: View {
// Asked every pass rather than once: gh can be signed in from a terminal while this
// tab is open, and the answer is cached, so this costs a subprocess only when it
// has actually expired.
let state = await GitHubAvailability.shared.check()
let state = await model.checksReadiness()
github = state

if state == .ready {
Expand Down
13 changes: 9 additions & 4 deletions Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct MergeConfirmationPopover: View {
) {
Picker("Merge using", selection: executionBinding) {
ForEach(MergeExecution.allCases, id: \.self) { choice in
Text(choice.label).tag(choice)
Text(choice.label(on: pullRequest.forge)).tag(choice)
}
}
.pickerStyle(.radioGroup)
Expand All @@ -41,11 +41,16 @@ struct MergeConfirmationPopover: View {
}

if execution == .direct {
Text("Bloom will run gh pr merge with the selected \(method.label.lowercased()) method. GitHub may queue the merge if this repository requires it.")
Text("Bloom will not delete the branch or change this checkout. GitHub may delete the remote branch if the repository is configured to do so.")
let forge = pullRequest.forge
Text("Bloom will run \(forge.command("merge")) with the selected \(method.label.lowercased()) method. \(forge.name) may queue the merge if this repository requires it.")
if forge == .gitLab, method == .rebase {
Text("GitLab rebases the branch first, which starts a new pipeline. A project that waits for it will not merge yet.")
.foregroundStyle(.secondary)
}
Text("Bloom will not delete the branch or change this checkout. \(forge.name) may delete the remote branch if the repository is configured to do so.")
.foregroundStyle(.secondary)
if !canMergeDirectly {
Text("Refresh this pull request before merging directly.")
Text("Refresh this \(forge.request) before merging directly.")
.foregroundStyle(Palette.negative)
}
} else {
Expand Down
7 changes: 4 additions & 3 deletions Sources/Bloom/Views/Inspector/PullRequestBadge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct PullRequestBadge: View {
var url: String
/// The state's colour, or nil for the states that carry none.
var tint: Color?
var forge: Forge = .gitHub

private var ink: Color { tint ?? Palette.textSecondary }

Expand All @@ -37,7 +38,7 @@ struct PullRequestBadge: View {
// a machine set to Dutch #2631 came out as "#2.631": a pull request number with a
// thousands separator in it, which is not a number GitHub has. The chip this
// replaces was handed a `String` and never had the problem.
Text(verbatim: "#\(number)")
Text(verbatim: forge.reference(number))
// Monospaced digits, so a four digit number and a five digit one are the same
// shape and the badge cannot appear to jitter as a poll comes back.
.font(Typo.label)
Expand Down Expand Up @@ -71,8 +72,8 @@ struct PullRequestBadge: View {
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Pull request \(number), \(title)")
.accessibilityHint("Opens on GitHub")
.help("Open #\(number) on GitHub: \(title)")
.accessibilityHint("Opens on \(forge.name)")
.help("Open \(forge.reference(number)) on \(forge.name): \(title)")
}

/// The arrow's own route, unchanged and not duplicated: there is one way this app opens a
Expand Down
22 changes: 13 additions & 9 deletions Sources/Bloom/Views/Inspector/PullRequestBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ struct PullRequestBar: View {
branchActions: branchActions,
worktree: model.workspace.path,
github: model.gitHubReadiness,
forge: model.forge,
hasChanges: hasChanges,
hasReadChanges: model.hasReadChanges,
continued: model.continued,
Expand Down Expand Up @@ -190,13 +191,14 @@ struct PullRequestBar: View {
case .direct:
do {
let created = try await model.createPullRequestDirectly()
let forge = created.forge
report = PullRequestNotice(
tone: .info, title: "Pull request created",
message: "Opened #\(created.number) on GitHub."
tone: .info, title: "\(forge.titledRequest) created",
message: "Opened \(forge.reference(created.number)) on \(forge.name)."
)
} catch {
report = PullRequestNotice(
tone: .failure, title: "Could not create the pull request",
tone: .failure, title: "Could not create the \(model.forge.request)",
message: String(describing: error)
)
}
Expand Down Expand Up @@ -324,7 +326,7 @@ struct PullRequestBar: View {
}

/// The confirmation chose how to execute the merge. The direct path stays in the model,
/// keeping subprocesses out of the view and reporting gh's answer beside the inspector.
/// keeping subprocesses out of the view and reporting gh's or glab's answer beside the inspector.
private func merge(_ method: GitHub.MergeMethod, execution: MergeExecution) {
guard let pullRequest = model.pullRequest else { return }
isWorking = true
Expand All @@ -342,23 +344,25 @@ struct PullRequestBar: View {
case .direct:
do {
let outcome = try await model.mergePullRequestDirectly(pullRequest, method: method)
let forge = pullRequest.forge
let reference = forge.reference(pullRequest.number)
let message: String
switch outcome {
case .merged:
message = "#\(pullRequest.number) merged. Bloom did not delete the branch or change this checkout."
message = "\(reference) merged. Bloom did not delete the branch or change this checkout."
case .acceptedButOpen:
message = "#\(pullRequest.number) is still open, possibly waiting in a merge queue."
message = "\(reference) is still open, possibly waiting in a merge queue."
case .acceptedUnverified:
message = "gh accepted the command, but Bloom could not verify #\(pullRequest.number)'s final status."
message = "\(forge.cli) accepted the command, but Bloom could not verify \(reference)'s final status."
}
report = PullRequestNotice(
tone: .info,
title: outcome == .merged ? "Merged on GitHub" : "GitHub accepted the merge",
title: outcome == .merged ? "Merged on \(forge.name)" : "\(forge.name) accepted the merge",
message: message
)
} catch {
report = PullRequestNotice(
tone: .failure, title: "Could not merge on GitHub",
tone: .failure, title: "Could not merge on \(pullRequest.forge.name)",
message: String(describing: error)
)
}
Expand Down
Loading
Loading