From e8c1ed2107e18a6d6efccf7ac1f964e207daf55c Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 01/16] Pin today's GitHub behaviour before adding GitLab --- Tests/BloomCoreTests/Characterisation.swift | 22 ++ Tests/BloomCoreTests/GitHubCommandTests.swift | 207 ++++++++++++++++++ .../GitHubDirectRouteTests.swift | 157 +++++++++++++ .../GitHubRepositoryResolutionTests.swift | 130 +++++++++++ .../GitHubStoredValueTests.swift | 70 ++++++ .../GitHubTextCharacterisationTests.swift | 73 ++++++ Tests/BloomCoreTests/TestSupport.swift | 16 +- .../check-failure-sentence.txt | 3 + .../characterisation/conflict-instructions.md | 39 ++++ .../characterisation/merge-instructions.md | 30 +++ .../pr-instructions-retired-1.md | 23 ++ .../characterisation/pr-instructions.md | 24 ++ .../project-instruction-sentences.txt | 11 + .../prompt-carryOnArchived.txt | 19 ++ .../prompt-continueAfterMerge.txt | 18 ++ .../prompt-createPullRequest.txt | 14 ++ .../characterisation/prompt-fixConflicts.txt | 15 ++ .../prompt-mergePullRequest.txt | 16 ++ .../characterisation/prompt-nameWorkspace.txt | 22 ++ .../characterisation/prompt-pushLocalWork.txt | 13 ++ .../characterisation/prompt-review.txt | 18 ++ .../characterisation/tool-workspace_list.json | 24 ++ .../tool-workspace_merge.json | 25 +++ .../tool-workspace_start.json | 57 +++++ 24 files changed, 1039 insertions(+), 7 deletions(-) create mode 100644 Tests/BloomCoreTests/Characterisation.swift create mode 100644 Tests/BloomCoreTests/GitHubCommandTests.swift create mode 100644 Tests/BloomCoreTests/GitHubDirectRouteTests.swift create mode 100644 Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift create mode 100644 Tests/BloomCoreTests/GitHubStoredValueTests.swift create mode 100644 Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift create mode 100644 Tests/fixtures/characterisation/check-failure-sentence.txt create mode 100644 Tests/fixtures/characterisation/conflict-instructions.md create mode 100644 Tests/fixtures/characterisation/merge-instructions.md create mode 100644 Tests/fixtures/characterisation/pr-instructions-retired-1.md create mode 100644 Tests/fixtures/characterisation/pr-instructions.md create mode 100644 Tests/fixtures/characterisation/project-instruction-sentences.txt create mode 100644 Tests/fixtures/characterisation/prompt-carryOnArchived.txt create mode 100644 Tests/fixtures/characterisation/prompt-continueAfterMerge.txt create mode 100644 Tests/fixtures/characterisation/prompt-createPullRequest.txt create mode 100644 Tests/fixtures/characterisation/prompt-fixConflicts.txt create mode 100644 Tests/fixtures/characterisation/prompt-mergePullRequest.txt create mode 100644 Tests/fixtures/characterisation/prompt-nameWorkspace.txt create mode 100644 Tests/fixtures/characterisation/prompt-pushLocalWork.txt create mode 100644 Tests/fixtures/characterisation/prompt-review.txt create mode 100644 Tests/fixtures/characterisation/tool-workspace_list.json create mode 100644 Tests/fixtures/characterisation/tool-workspace_merge.json create mode 100644 Tests/fixtures/characterisation/tool-workspace_start.json diff --git a/Tests/BloomCoreTests/Characterisation.swift b/Tests/BloomCoreTests/Characterisation.swift new file mode 100644 index 000000000..bd966e3cf --- /dev/null +++ b/Tests/BloomCoreTests/Characterisation.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing + +/// Holds `actual` against `Tests/fixtures/characterisation/`, byte for byte. The suite never +/// writes a fixture: a mismatch prints the text JSON encoded on a `BLOOM-FIXTURE` line instead, +/// so whitespace survives a CI log. +func expectCharacterised( + _ actual: String, _ name: String, sourceLocation: SourceLocation = #_sourceLocation +) { + let path = "characterisation/\(name)" + let expected = bloomFixtureURL(path).flatMap { try? String(contentsOf: $0, encoding: .utf8) } + guard actual != expected else { return } + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let encoded = (try? encoder.encode(actual)).map { String(decoding: $0, as: UTF8.self) } ?? "?" + let problem = expected == nil ? "is missing" : "differs" + Issue.record( + Comment(rawValue: "Fixture \(path) \(problem).\nBLOOM-FIXTURE \(path) \(encoded)"), + sourceLocation: sourceLocation + ) +} diff --git a/Tests/BloomCoreTests/GitHubCommandTests.swift b/Tests/BloomCoreTests/GitHubCommandTests.swift new file mode 100644 index 000000000..7398066e9 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubCommandTests.swift @@ -0,0 +1,207 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Every `gh` command run through `GitHub.run`, pinned argument by argument, before +/// `repositoryArguments` adds `--repo` (see `GitHubRepositoryResolutionTests`). `gh auth status` +/// and `gh api user` call `Shell` directly and cannot be seen from here. +@Suite("GitHub commands", .scratchDirectory) +struct GitHubCommandTests { + private static let fields = "number,title,url,state,isDraft,mergeable,baseRefName,headRefOid," + + "mergeStateStatus,reviewDecision,headRefName,statusCheckRollup,closedAt" + private static let fieldsWithoutChecks = "number,title,url,state,isDraft,mergeable,baseRefName,headRefOid," + + "mergeStateStatus,reviewDecision,headRefName,closedAt" + private static let listingFields = "number,title,author,headRefName,baseRefName," + + "isDraft,state,isCrossRepository,headRepositoryOwner" + private static let pullRequest = #"{"number":42,"title":"Work","url":"https://github.com/acme/app/pull/42","state":"OPEN","isDraft":false,"headRefName":"feature","statusCheckRollup":[]}"# + private static let listing = #"{"number":7,"title":"Fix","author":{"login":"someone"},"headRefName":"fix","baseRefName":"main","isDraft":false,"state":"OPEN","isCrossRepository":false}"# + private static let noPullRequest = ShellResult(status: 1, stdout: "", stderr: "no pull requests found for branch \"feature\"") + + @Test("a pull request is read by branch") + func viewByBranch() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.snapshot(forBranch: "feature", worktree: worktree, maxAge: .zero) + } + #expect(commands == [["pr", "view", "feature", "--json", Self.fields]]) + } + + @Test("a branch with no pull request is asked again without naming it") + func viewOfCheckedOutBranch() async throws { + let commands = try await record(result: Self.noPullRequest) { worktree in + _ = try await GitHub.snapshot(forBranch: "feature", worktree: worktree, maxAge: .zero) + } + #expect(commands == [ + ["pr", "view", "feature", "--json", Self.fields], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a pull request is read by number") + func viewByNumber() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.snapshot(forNumber: 42, worktree: worktree, maxAge: .zero) + } + #expect(commands == [["pr", "view", "42", "--json", Self.fields]]) + } + + @Test("refused check runs are asked for again without the rollup") + func viewWithoutChecks() async throws { + let refusal = ShellResult( + status: 1, stdout: "", + stderr: "GraphQL: Resource not accessible by personal access token (repository.pullRequest.statusCheckRollup)" + ) + let commands = try await record { arguments in + arguments.contains(Self.fields) ? refusal : ShellResult(status: 0, stdout: Self.pullRequest, stderr: "") + } operation: { worktree in + _ = try await GitHub.snapshot(forNumber: 42, worktree: worktree, maxAge: .zero) + } + #expect(commands == [ + ["pr", "view", "42", "--json", Self.fields], + ["pr", "view", "42", "--json", Self.fieldsWithoutChecks], + ]) + } + + @Test("the pull requests a head branch ever had are listed") + func listByHead() async throws { + let commands = try await record(answer: "[]") { worktree in + _ = try await GitHub.pullRequestsWithHead("feature", worktree: worktree) + } + #expect(commands == [[ + "pr", "list", "--head", "feature", "--state", "all", "--limit", "20", "--json", "number,closedAt", + ]]) + } + + @Test("a draft is marked ready by its address") + func markReady() async throws { + let draft = PullRequest( + number: 42, title: "Work", url: "https://github.com/acme/app/pull/42", state: "OPEN", isDraft: true + ) + let commands = try await record(answer: "") { worktree in + try await GitHub.markReadyForReview(draft, worktree: worktree) + } + #expect(commands == [["pr", "ready", "https://github.com/acme/app/pull/42"]]) + } + + @Test("a pull request is created, then read back") + func create() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.createPullRequest( + worktree: worktree, base: "main", title: "Title", body: "Body", draft: true + ) + } + #expect(commands == [ + ["pr", "create", "--base", "main", "--title", "Title", "--body", "Body", "--draft"], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a pull request is created from its pushed commits, then read back") + func createFromCommits() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.createPullRequestFromCommits(worktree: worktree, base: "main", title: "Title") + } + #expect(commands == [ + ["pr", "create", "--base", "main", "--title", "Title", "--fill"], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a failed run's log is read for its failed steps first, then whole", arguments: [ + (target: CheckFailureHandoff.LogTarget(runID: "123"), selector: ["123"]), + (target: CheckFailureHandoff.LogTarget(runID: "123", jobID: "456"), selector: ["--job", "456"]), + ]) + func runLog(target: CheckFailureHandoff.LogTarget, selector: [String]) async throws { + let commands = try await record { arguments in + ShellResult(status: 0, stdout: arguments.contains("--log") ? "the log" : "", stderr: "") + } operation: { worktree in + _ = try await GitHub.checkRunLog(target, worktree: worktree) + } + #expect(commands == [ + ["run", "view"] + selector + ["--log-failed"], + ["run", "view"] + selector + ["--log"], + ]) + } + + @Test("a pull request is summarised by number for the create window") + func summary() async throws { + let commands = try await record(answer: Self.listing) { repository in + _ = try await GitHub.pullRequestSummary(number: 7, repoPath: repository) + } + #expect(commands == [["pr", "view", "7", "--json", Self.listingFields]]) + } + + @Test("a repository with no readable remote asks gh for its name") + func slug() async throws { + let commands = try await record(answer: "acme/app\n") { repository in + _ = await GitHub.repositorySlug(repoPath: repository) + } + #expect(commands == [["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]]) + } + + @Test("a pull request is checked out onto a named branch") + func checkout() async throws { + let commands = try await record(answer: "") { worktree in + try await GitHub.checkoutPullRequest(number: 7, into: worktree, localBranch: "review/7") + } + #expect(commands == [["pr", "checkout", "7", "--branch", "review/7"]]) + } + + @Test("the accounts a repository could belong to") + func owners() async throws { + let commands = try await record { arguments in + ShellResult(status: 0, stdout: arguments == ["api", "user"] ? #"{"login":"someone"}"# : "[]", stderr: "") + } operation: { _ in + _ = try await GitHub.owners() + } + #expect(commands == [["api", "user"], ["api", "user/orgs", "--paginate"]]) + } + + @Test("a repository name is checked, then created and given an address") + func repositoryCreation() async throws { + let commands = try await record(answer: "") { _ in + _ = await GitHub.repositoryAvailability(owner: "acme", name: "app") + _ = try await GitHub.createRepository(owner: "acme", name: "app", isPrivate: true) + } + #expect(commands == [ + ["api", "--silent", "repos/acme/app"], + ["repo", "create", "acme/app", "--private"], + ["config", "get", "git_protocol"], + ]) + } + + private func record( + answer stdout: String, operation: (String) async throws -> Void + ) async throws -> [[String]] { + try await record(result: ShellResult(status: 0, stdout: stdout, stderr: ""), operation: operation) + } + + private func record( + result: ShellResult, operation: (String) async throws -> Void + ) async throws -> [[String]] { + try await record({ _ in result }, operation: operation) + } + + /// The gh commands `operation` ran, in order, each answered by `respond`. + private func record( + _ respond: @escaping @Sendable ([String]) -> ShellResult, + operation: (String) async throws -> Void + ) async throws -> [[String]] { + let worktree = TestScratch.unique("gh-command") + let log = CommandLog() + try await GitHub.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return respond(arguments) + }) { + try await operation(worktree) + } + return await log.commands + } +} + +private actor CommandLog { + private(set) var commands: [[String]] = [] + + func append(_ arguments: [String]) { + commands.append(arguments) + } +} diff --git a/Tests/BloomCoreTests/GitHubDirectRouteTests.swift b/Tests/BloomCoreTests/GitHubDirectRouteTests.swift new file mode 100644 index 000000000..8285703ba --- /dev/null +++ b/Tests/BloomCoreTests/GitHubDirectRouteTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import BloomCore + +/// What the direct routes say when Bloom runs gh itself, word for word: every refusal before +/// `gh pr merge` runs, the outcomes after it, and a create refused because one exists. +@Suite("GitHub direct routes", .tags(.git), .scratchDirectory) +struct GitHubDirectRouteTests { + enum Refusal: String, CaseIterable, Sendable { + case unrefreshed, otherBranch, localWork, otherHead, otherBase, changed, closed + } + + @Test("each refusal before gh merges", arguments: Refusal.allCases) + func refusal(_ refusal: Refusal) async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let other = String(repeating: "a", count: 40) + if refusal == .localWork { try repo.write("untracked.txt", "keep\n") } + let gh = DirectGH( + head: refusal == .changed ? other : head, state: refusal == .closed ? "CLOSED" : "OPEN" + ) + let expected = pullRequest( + branch: refusal == .otherBranch ? "other" : "feature", + base: refusal == .otherBase ? "develop" : "main", + head: refusal == .unrefreshed ? nil : refusal == .otherHead ? other : head + ) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest(expected, into: "main", method: .squash, worktree: repo.path) + } + Issue.record("\(refusal) was merged") + } catch let error as GitHubError { + #expect(error.message == Self.messages[refusal]) + } + #expect(await gh.merges == 0) + } + + private static let messages: [Refusal: String] = [ + .unrefreshed: "Refresh the pull request before merging directly.", + .otherBranch: "This worktree is no longer on the pull request's branch.", + .localWork: "This worktree has changes or commits GitHub has not got. Commit and push them, or choose the agent route after reviewing the warning.", + .otherHead: "This checkout is not at the pull request's head commit. Refresh or update it before merging directly.", + .otherBase: "The pull request targets develop, not main. Refresh it before merging.", + .changed: "The pull request changed since it was shown. Refresh it before merging.", + .closed: "This pull request was closed without merging.", + ] + + @Test("a merge gh accepted is merged, still open, or unverified, and a refused one that landed is merged") + func outcomes() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let cases: [(gh: DirectGH, outcome: DirectMergeOutcome)] = [ + (DirectGH(head: head, after: .merged), .merged), + (DirectGH(head: head, after: .stillOpen), .acceptedButOpen), + (DirectGH(head: head, after: .unreadable), .acceptedUnverified), + (DirectGH(head: head, after: .merged, mergeFails: true), .merged), + ] + for (gh, outcome) in cases { + let merged = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest( + pullRequest(branch: "feature", base: "main", head: head), + into: "main", method: .merge, worktree: repo.path + ) + } + #expect(merged == outcome) + } + } + + @Test("a create stops before committing when the branch already has a pull request") + func createRefusedWhenOneExists() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + try repo.write("work.txt", "work\n") + let head = try await Git.headSHA(of: repo.path) + let gh = DirectGH(head: head) + let workspace = Workspace( + repoID: RepoID("repo"), name: "work", branch: "feature", path: repo.path, baseBranch: "main" + ) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await DirectPullRequestCreation.create(for: workspace) + } + Issue.record("A second pull request was created") + } catch let error as GitHubError { + #expect(error.message == "Pull request #42 already exists for this branch.") + } + #expect(await gh.creates == 0) + #expect(try await Git.headSHA(of: repo.path) == head) + } + + private func featureRepo() async throws -> TempRepo { + let repo = try await TempRepo() + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + return repo + } + + private func pullRequest(branch: String, base: String, head: String?) -> PullRequest { + PullRequest( + number: 42, title: "Feature", url: "https://github.com/org/repo/pull/42", + state: "OPEN", branch: branch, baseBranch: base, headRevision: head + ) + } +} + +/// gh for one pull request, #42 on `feature`, and what it says once a merge has been sent. +private actor DirectGH { + enum After: Sendable { case merged, stillOpen, unreadable } + + private let head: String + private let state: String + private let after: After + private let mergeFails: Bool + private var sent = false + private(set) var merges = 0 + private(set) var creates = 0 + + init(head: String, state: String = "OPEN", after: After = .merged, mergeFails: Bool = false) { + self.head = head + self.state = state + self.after = after + self.mergeFails = mergeFails + } + + func execute(_ arguments: [String]) -> ShellResult { + if arguments.starts(with: ["pr", "merge"]) { + merges += 1 + sent = true + return ShellResult(status: mergeFails ? 1 : 0, stdout: "", stderr: mergeFails ? "refused" : "") + } + if arguments.starts(with: ["pr", "create"]) { + creates += 1 + return ShellResult(status: 0, stdout: "", stderr: "") + } + if arguments.starts(with: ["pr", "list"]) { + return ShellResult(status: 0, stdout: "[]", stderr: "") + } + if sent, after == .unreadable { + return ShellResult(status: 1, stdout: "", stderr: "HTTP 502") + } + let current = sent && after == .merged ? "MERGED" : state + return ShellResult(status: 0, stdout: """ + {"number":42,"title":"Feature","url":"https://github.com/org/repo/pull/42",\ + "state":"\(current)","isDraft":false,"mergeable":"MERGEABLE",\ + "headRefName":"feature","baseRefName":"main","headRefOid":"\(head)"} + """, stderr: "") + } +} diff --git a/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift b/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift new file mode 100644 index 000000000..d8409db67 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import BloomCore + +/// How a remote becomes the host and repository `gh` is pointed at, as the code does it today, +/// accidents included (a two segment gitlab.com remote is handed to gh). +@Suite("GitHub repository resolution") +struct GitHubRepositoryResolutionTests { + @Test("the host of a remote", arguments: [ + (remote: "https://github.com/acme/app.git", host: "github.com"), + (remote: "https://GitHub.com/acme/app", host: "github.com"), + (remote: "git@github.com:acme/app.git", host: "github.com"), + (remote: "ssh://git@github.com/acme/app.git", host: "github.com"), + (remote: "git@GHE.corp.example:acme/app.git", host: "ghe.corp.example"), + (remote: "git@gitlab.com:group/subgroup/app.git", host: "gitlab.com"), + (remote: "https://gitlab.example.com/group/subgroup/app.git", host: "gitlab.example.com"), + ]) + func host(remote: String, host: String) { + #expect(GitHub.repositoryHost(remote) == host) + } + + @Test("a remote with no host has none", arguments: ["/Users/someone/project", "../project"]) + func noHost(remote: String) { + #expect(GitHub.repositoryHost(remote) == nil) + } + + @Test("no remote has no host") + func missingRemote() { + #expect(GitHub.repositoryHost(nil) == nil) + } + + @Test("the repository gh is told to use", arguments: [ + (remote: "https://github.com/acme/app.git", specifier: "github.com/acme/app"), + (remote: "https://github.com/acme/app", specifier: "github.com/acme/app"), + (remote: "https://github.com/acme/app/", specifier: "github.com/acme/app"), + (remote: "git@github.com:acme/app.git", specifier: "github.com/acme/app"), + (remote: "ssh://git@github.com/acme/app.git", specifier: "github.com/acme/app"), + (remote: "git@ghe.corp.example:acme/app.git", specifier: "ghe.corp.example/acme/app"), + (remote: "git@gitlab.com:group/app.git", specifier: "gitlab.com/group/app"), + ]) + func specifier(remote: String, specifier: String) { + #expect(GitHub.repositorySpecifier(remote) == specifier) + } + + @Test("a remote that is not two segments on a host names no repository", arguments: [ + "git@gitlab.com:group/subgroup/app.git", + "https://gitlab.example.com/group/subgroup/app.git", + "https://github.com/acme", + "https://github.com/-acme/app", + "/Users/someone/project", + ]) + func noSpecifier(remote: String) { + #expect(GitHub.repositorySpecifier(remote) == nil) + } + + @Test("the host a request is rate limited under", arguments: [ + (arguments: ["pr", "view", "1", "--repo", "ghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "view", "1", "--repo=ghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "view", "1", "-Rghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "ready", "https://GHE.corp.example/acme/app/pull/1"], host: "ghe.corp.example"), + ]) + func requestHost(arguments: [String], host: String) { + #expect(GitHub.requestHost(arguments: arguments, context: nil) == host) + } + + @Test("a request with no host of its own takes the base remote's") + func requestHostFromContext() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@ghe.corp.example:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.requestHost(arguments: ["pr", "view", "feature"], context: context) == "ghe.corp.example") + #expect(GitHub.requestHost(arguments: ["pr", "view", "1", "--repo", "acme/app"], context: context) + == "ghe.corp.example") + } + + @Test("a request with no host anywhere falls back to GH_HOST, then github.com") + func requestHostFallback() { + let expected = ProcessInfo.processInfo.environment["GH_HOST"] ?? "github.com" + #expect(GitHub.requestHost(arguments: ["pr", "list"], context: nil) == expected) + } + + @Test("a pull request created from a fork names its head in the fork") + func forkCreate() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:fork/project.git", + "remote.upstream.url": "https://github.com/upstream/project.git", + "branch.main.remote": "upstream", "remote.pushdefault": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments( + ["pr", "create", "--base", "main", "--title", "Title", "--body", "Body"], context: context + ) == [ + "pr", "create", "--base", "main", "--title", "Title", "--body", "Body", + "--head", "fork:feature", "--repo", "github.com/upstream/project", + ]) + } + + @Test("a pull request created in its own repository names no head") + func ownCreate() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(["pr", "create", "--base", "main", "--draft"], context: context) + == ["pr", "create", "--base", "main", "--draft", "--repo", "github.com/acme/app"]) + } + + @Test("commands outside pull requests and run logs are left alone", arguments: [ + ["api", "user", "--jq", ".login"], + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], + ["run", "list"], + ["auth", "status"], + ]) + func untouchedFamilies(arguments: [String]) { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(arguments, context: context) == arguments) + } + + @Test("a remote that names no repository adds no --repo") + func subgroupRemote() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@gitlab.com:group/subgroup/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(["pr", "view", "1"], context: context) == ["pr", "view", "1"]) + } +} diff --git a/Tests/BloomCoreTests/GitHubStoredValueTests.swift b/Tests/BloomCoreTests/GitHubStoredValueTests.swift new file mode 100644 index 000000000..52d81cc69 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubStoredValueTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Names and values that outlive a launch or leave the app. Renaming one is a migration. +@Suite("GitHub stored values") +struct GitHubStoredValueTests { + @Test("the keys a pull request's settings and state are stored under") + func keys() { + let workspace = WorkspaceID("workspace") + #expect(PromptOverrides.key(for: .createPullRequest) == "prompts.createPullRequest") + #expect(MergeMethodChoice.key(repoID: RepoID("repo")) == "repo.repo.mergeMethod") + #expect(MergeExecutionChoice.key(repoID: RepoID("repo")) == "repo.repo.mergeExecution") + #expect(ChecksWatch.lastSeenKey(workspaceID: workspace) == "checks.lastSeen.workspace") + #expect(OnboardingGate.completedKey == "onboarding.completed") + #expect(NotificationPreferences.key(for: .checksFinished) == "notifications.event.checksFinished") + } + + @Test("the instruction files a project commits") + func instructionPaths() { + #expect(PullRequestInstructions.projectPath == ".bloom/pr-instructions.md") + #expect(PullRequestInstructions.scratchPath == ".bloom/scratch/pr-instructions.md") + #expect(ProjectInstructions.projectPath(for: .merge) == ".bloom/merge-instructions.md") + #expect(ProjectInstructions.projectPath(for: .fixConflicts) == ".bloom/conflict-instructions.md") + #expect(ConflictInstructions.scratchPath == ".bloom/scratch/resolving-conflicts.md") + } + + @Test("the values those keys and answers hold") + func rawValues() { + #expect(PromptID.allCases.map(\.rawValue) == [ + "createPullRequest", "pushLocalWork", "mergePullRequest", "fixConflicts", + "continueAfterMerge", "carryOnArchived", "review", "nameWorkspace", + ]) + #expect(GitHub.MergeMethod.allCases.map(\.rawValue) == ["merge", "squash", "rebase"]) + #expect(MergeExecution.allCases.map(\.rawValue) == ["direct", "agent"]) + #expect(MergeExecution.allCases.map(\.label) == ["Merge with GitHub CLI", "Ask agent to merge"]) + let checks: [PullRequest.Checks] = [.none, .pending, .passing, .failing, .unavailable] + #expect(checks.map(\.rawValue) == ["none", "pending", "passing", "failing", "unavailable"]) + } + + @Test("a workspace's status, as a tool answers it and a legend names it") + func statuses() { + #expect(WorkspaceStatus.allCases.map { "\($0.rawValue): \($0.label)" } == [ + "settingUp: Setting up", + "awaitingPermission: Waiting on you", + "running: Agent running", + "setupFailed: Setup failed", + "unread: Unread", + "merged: Merged", + "closed: Pull request closed", + "conflicted: Merge conflicts", + "checksFailing: Checks failing", + "checksRunning: Checks running", + "checksPassed: Checks passed", + "draft: Draft pull request", + "pullRequestOpen: Pull request open", + "changed: Has changes", + "clean: No changes", + ]) + } + + @Test("a pull request state's summary names it by number") + func statusSummary() { + let pullRequest = PullRequest( + number: 42, title: "Work", url: "https://github.com/acme/app/pull/42", state: "OPEN" + ) + #expect(WorkspaceStatus.pullRequestOpen.summary(pullRequest: pullRequest) + == "Pull request open, pull request #42") + } +} diff --git a/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift b/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift new file mode 100644 index 000000000..d1bae7b6e --- /dev/null +++ b/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import BloomCore + +/// What Bloom tells an agent about pull requests, word for word. `isUnedited` compares defaults +/// byte for byte, so a reworded default makes every untouched copy look edited. +@Suite("GitHub texts, as they stand") +struct GitHubTextCharacterisationTests { + @Test("every default prompt", arguments: PromptRegistry.all) + func prompt(_ definition: PromptDefinition) { + let variables = definition.variables.map { "- \($0.name): \($0.summary)" }.joined(separator: "\n") + let text = """ + title: \(definition.title) + + summary: + \(definition.summary) + + variables: + \(variables) + + template: + \(definition.defaultTemplate) + """ + expectCharacterised(text, "prompt-\(definition.id.rawValue).txt") + } + + @Test("the instruction files Bloom writes") + func instructionFiles() { + expectCharacterised(PullRequestInstructions.defaultMarkdown, "pr-instructions.md") + expectCharacterised(MergeInstructions.canonical, "merge-instructions.md") + expectCharacterised(ConflictInstructions.defaultMarkdown, "conflict-instructions.md") + for (index, retired) in PullRequestInstructions.retiredDefaults.enumerated() { + expectCharacterised(retired, "pr-instructions-retired-\(index + 1).md") + } + } + + @Test("the sentences that carry a project's own instructions") + func projectInstructionSentences() { + let subjects: [ProjectInstructions.Subject] = [.merge, .fixConflicts] + let text = subjects.flatMap { subject in + [ + ProjectInstructions.sentence(for: subject, adding: .file(".bloom/scratch/instructions.md")) ?? "", + ProjectInstructions.sentence(for: subject, adding: .inline("Squash it.")) ?? "", + ] + }.joined(separator: "\n---\n") + expectCharacterised(text, "project-instruction-sentences.txt") + } + + @Test("the sentence a failed check is handed to the agent with") + func checkFailureSentence() { + let text = [ + CheckFailureHandoff.sentence( + name: "Tests", workflow: "CI", state: .failed, + detailsURL: "https://github.com/acme/app/actions/runs/1/job/2", + logPath: ".bloom/scratch/checks/tests.log" + ), + CheckFailureHandoff.sentence(name: "Lint", state: .failed), + ].joined(separator: "\n---\n") + expectCharacterised(text, "check-failure-sentence.txt") + } + + @Test("the tools an agent sees for pull requests") + func toolListings() { + let tools = [ + WorkspaceListTool().tool, + WorkspaceMergeTool(read: { _ in .noPullRequest }) { _, _, _ in .turnBegun(chat: "Merge") }.tool, + WorkspaceStartTool(start: { _, _, _, _ in throw CancellationError() }).tool, + ] + for tool in tools { + expectCharacterised(BridgeToolResult.json(tool.listing).text, "tool-\(tool.name).json") + } + } +} diff --git a/Tests/BloomCoreTests/TestSupport.swift b/Tests/BloomCoreTests/TestSupport.swift index b86f0f408..d54fc85ae 100644 --- a/Tests/BloomCoreTests/TestSupport.swift +++ b/Tests/BloomCoreTests/TestSupport.swift @@ -369,6 +369,13 @@ struct TempRepo { /// walking up from this file. Symlinks are resolved too, because the core suite is run from a mirrored /// package that has no app target (see Tools/test-core.sh). func bloomFixtureLines(_ name: String) throws -> [String] { + guard let fixture = bloomFixtureURL(name) else { throw CocoaError(.fileNoSuchFile) } + return try String(contentsOf: fixture, encoding: .utf8) + .components(separatedBy: "\n") + .filter { $0.isEmpty == false } +} + +func bloomFixtureURL(_ name: String) -> URL? { let starts = [ URL(fileURLWithPath: #filePath), URL(fileURLWithPath: #filePath).resolvingSymlinksInPath(), @@ -378,16 +385,11 @@ func bloomFixtureLines(_ name: String) throws -> [String] { var directory = start.deletingLastPathComponent() for _ in 0..<8 { let candidate = directory.appendingPathComponent("fixtures").appendingPathComponent(name) - if FileManager.default.fileExists(atPath: candidate.path) { - return try String(contentsOf: candidate, encoding: .utf8) - .components(separatedBy: "\n") - .filter { $0.isEmpty == false } - } + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } directory = directory.deletingLastPathComponent() } } - - throw CocoaError(.fileNoSuchFile) + return nil } // MARK: - Output collection diff --git a/Tests/fixtures/characterisation/check-failure-sentence.txt b/Tests/fixtures/characterisation/check-failure-sentence.txt new file mode 100644 index 000000000..bfeec90b4 --- /dev/null +++ b/Tests/fixtures/characterisation/check-failure-sentence.txt @@ -0,0 +1,3 @@ +The GitHub check "Tests" in CI failed. Its log is in `.bloom/scratch/checks/tests.log`. The run is at https://github.com/acme/app/actions/runs/1/job/2. +--- +The GitHub check "Lint" failed. Bloom could not fetch its log. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/conflict-instructions.md b/Tests/fixtures/characterisation/conflict-instructions.md new file mode 100644 index 000000000..3d48bb94a --- /dev/null +++ b/Tests/fixtures/characterisation/conflict-instructions.md @@ -0,0 +1,39 @@ +# Resolving merge conflicts + +Bloom attaches this file when someone presses Fix merge conflicts. This copy is Bloom's own, is +rewritten on every press and is invisible to git, so an edit made here does not last. To change +what this project does about conflicts, write it in `.bloom/conflict-instructions.md` and commit +it: Bloom attaches that as well, and where the two disagree yours wins. The message these steps +arrived with is a prompt you can reword in Bloom's settings. + +That message names the pull request, the branch this worktree is on, and the branch that +conflicts with it. Call the second one this branch and the third the base branch below. + +- If this project has a skill or an instruction file about resolving conflicts, follow that + first. It outranks everything here. +- Fetch the base branch first, so you are working against what is on the server rather than a + stale copy of it. +- Bring the base branch into this branch the way this project brings it in: merge it unless + the project's own conventions say to rebase onto it. + It goes into this branch and never the other way round. +- Work through every conflicted file. Keep what this branch changed and what the base branch + changed, and where the two genuinely disagree, read enough of the code around them to work out + which is right instead of taking a side. +- Follow this project's conventions, and run whatever it uses to check itself before you call + anything resolved. +- Commit the resolution, with a message worded the way this project words one. +- Then push it. A conflict resolved only in this worktree is still a conflict to everybody else, + and the pull request goes on refusing to merge until the branch on the server carries the + resolution. If bringing the base branch in rewrote this branch's commits, which a rebase does, + the push needs `--force-with-lease`, and it may only ever go to this branch, never to the base + branch and never to any other branch. +- **Do not push if you are not sure.** Genuine uncertainty about what a resolution should be, a + check that fails for a reason neither branch explains, or anything you had to guess at: leave + the commit here, say what you are unsure about, and let a person look. A resolution nobody + believes in is worse on the server than in a worktree. +- Do not merge the pull request whatever happens. Whether the work is good is the reader's + decision, and they are the one who pressed the button. +- Finish by saying which files conflicted, what you decided in each of them, whether you pushed, + and anything you are not sure about. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/merge-instructions.md b/Tests/fixtures/characterisation/merge-instructions.md new file mode 100644 index 000000000..2ef7b0518 --- /dev/null +++ b/Tests/fixtures/characterisation/merge-instructions.md @@ -0,0 +1,30 @@ +This message names the pull request, the branch it is on, and the merge method to use. Nothing +below chooses any of those, and nothing below may change them. + +- If this project has a skill or an instruction file about merging, follow that first. It + outranks everything here. +- Merge with `gh pr merge `, run from this worktree, where the method + flag is the one the message names: `--squash`, `--merge` or `--rebase`. +- Pass no other flags. + Not `--admin`, which overrides the repository's own rules. + Not `--auto`, which merges later, when nobody is watching. + Not `--delete-branch`, which makes gh reach for this checkout and fail, because a worktree + cannot check out the branch the main copy is standing on. +- **If GitHub refuses the merge, stop.** Say what it said, in its own words. Do not retry it, + do not force it, and do not change a branch protection rule, a required check, a review or + any other repository setting to get round it. A refusal is an answer, and the person who + pressed the button is reading this. +- Only once the merge has actually succeeded, delete the branch on the server with + `git push --delete -- origin refs/heads/`, using the branch the message names. If + git answers that the remote ref does not exist, the repository deleted it on merge and there + is nothing left to do. This is a separate command from the merge and it runs second, never + instead. +- Change nothing on this machine. Do not delete the local branch, do not remove or move the + worktree, and do not check out anything else. This worktree stays where it is, on the branch + it is on, and Bloom archives it separately when the person asks. +- Do not commit and do not push. If the worktree is holding work GitHub has not got, that was + the reader's decision before pressing the button, not a thing to fix. Say in one line that it + was left behind. +- Finish by saying what happened: that it merged, and whether the branch on the server is gone. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/pr-instructions-retired-1.md b/Tests/fixtures/characterisation/pr-instructions-retired-1.md new file mode 100644 index 000000000..cab6d3b69 --- /dev/null +++ b/Tests/fixtures/characterisation/pr-instructions-retired-1.md @@ -0,0 +1,23 @@ +# Opening a pull request + +Bloom attaches this file when someone presses Create pull request. It is a normal file in this +repository: edit it to say how this project opens pull requests, and everybody working here +gets the change. + +The message this file came with names the branch to target. Call it the target branch below. + +- If this project has a skill or an instruction file about opening pull requests, follow that + first. It outranks everything here. +- Run `git status`. If anything is uncommitted, review it and commit it, following whatever + this project says about commit messages. +- Push the branch with `git push -u origin HEAD`. If it already tracks a different upstream, + push to that one instead. +- Read the whole branch with `git diff ...` before writing anything. The + description has to cover every change on the branch, not only what was done in this session. +- Open the pull request with `gh pr create --base --title --body + <description>`. If the repository has a pull request template, fill that in instead of + writing your own structure. Keep the title under 80 characters and the description under + five sentences. +- Say what the pull request URL is once it exists. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/pr-instructions.md b/Tests/fixtures/characterisation/pr-instructions.md new file mode 100644 index 000000000..8e1158d14 --- /dev/null +++ b/Tests/fixtures/characterisation/pr-instructions.md @@ -0,0 +1,24 @@ +# Opening a pull request + +Bloom attaches this file when someone presses Create pull request. This copy is Bloom's own and +is invisible to git. To make it this project's, move it to `.bloom/pr-instructions.md`, edit it +to say how this project opens pull requests, and commit it. Bloom then uses that copy instead +and never writes over it, so everybody working here gets the change. + +The message this file came with names the branch to target. Call it the target branch below. + +- If this project has a skill or an instruction file about opening pull requests, follow that + first. It outranks everything here. +- Run `git status`. If anything is uncommitted, review it and commit it, following whatever + this project says about commit messages. +- Push the branch with `git push -u origin HEAD`. If it already tracks a different upstream, + push to that one instead. +- Read the whole branch with `git diff <target branch>...` before writing anything. The + description has to cover every change on the branch, not only what was done in this session. +- Open the pull request with `gh pr create --base <target branch> --title <title> --body + <description>`. If the repository has a pull request template, fill that in instead of + writing your own structure. Keep the title under 80 characters and the description under + five sentences. +- Say what the pull request URL is once it exists. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/project-instruction-sentences.txt b/Tests/fixtures/characterisation/project-instruction-sentences.txt new file mode 100644 index 000000000..e368b0134 --- /dev/null +++ b/Tests/fixtures/characterisation/project-instruction-sentences.txt @@ -0,0 +1,11 @@ +This project has its own instructions for merging. Follow the instructions in `.bloom/scratch/instructions.md` as well, and where they disagree with anything above, they win. +--- +This project has its own instructions for merging. They could not be written to a file in this worktree, so they are below. Where they disagree with anything above, they win. + +Squash it. +--- +This project has its own instructions for resolving merge conflicts. Follow the instructions in `.bloom/scratch/instructions.md` as well, and where they disagree with anything above, they win. +--- +This project has its own instructions for resolving merge conflicts. They could not be written to a file in this worktree, so they are below. Where they disagree with anything above, they win. + +Squash it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-carryOnArchived.txt b/Tests/fixtures/characterisation/prompt-carryOnArchived.txt new file mode 100644 index 000000000..dc84cbdc5 --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-carryOnArchived.txt @@ -0,0 +1,19 @@ +title: Carry on from an archive + +summary: +Sent when you press Carry On in an archived workspace, into a new worktree, resuming that workspace's conversation. It only says what moved. + +variables: +- workspace: The archived workspace's name, which the new one keeps. +- project: The project it belongs to. +- previous_branch: The branch the archived workspace was on, which no longer exists. +- previous_path: The worktree the archive removed. +- branch: The branch the new worktree is on. +- base_branch: The branch the new one was cut from. + +template: +We are carrying on somewhere else. The workspace this conversation was in, {{workspace}}, has been archived: its worktree at {{previous_path}} was deleted, and {{previous_branch}} is gone from this Mac and from the remote, so there was nothing left to rebuild it from. + +You are in a new worktree now, on a fresh branch {{branch}} cut from an up to date {{base_branch}} in {{project}}. Everything we said to each other is still yours, and none of the files are: every path you remember is stale, so read anything here before you rely on it, and expect work that landed on {{base_branch}} to already be underneath you. + +Do not redo what the old branch held, and do not start anything new yet. Say in one or two lines where we had got to and what was left, which is the only record of it this chat has, then wait for what I ask next. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-continueAfterMerge.txt b/Tests/fixtures/characterisation/prompt-continueAfterMerge.txt new file mode 100644 index 000000000..15168dcb2 --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-continueAfterMerge.txt @@ -0,0 +1,18 @@ +title: Continue after a merge + +summary: +Sent when you press Continue on a merged pull request, after the worktree has already moved to a new branch. It only says what moved. + +variables: +- workspace: The workspace's name. +- branch: The new branch this worktree is on now. +- previous_branch: The branch that was merged. +- base_branch: The branch the new one was cut from. +- pull_request: The number of the pull request that landed. + +template: +Pull request #{{pull_request}} is merged, so {{previous_branch}} is finished with. + +This worktree is now on a new branch, {{branch}}, cut from an up to date {{base_branch}}, so everything that just landed is already underneath you. Nothing else moved: same directory, same session, and anything that was uncommitted is still here. + +Do not redo what the pull request landed, and do not start anything new yet. Say in one line that you are ready, then wait for what I ask next. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-createPullRequest.txt b/Tests/fixtures/characterisation/prompt-createPullRequest.txt new file mode 100644 index 000000000..717b3d0f6 --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-createPullRequest.txt @@ -0,0 +1,14 @@ +title: Create pull request + +summary: +Sent when you press Create pull request, with the project's `.bloom/pr-instructions.md` attached. + +variables: +- workspace: The workspace's name. +- branch: The branch the work is on. +- base_branch: The branch the pull request targets. +- task: The first thing you asked this workspace for. +- changes: The changed files, with their added and removed line counts. + +template: +Create a pull request for this workspace against {{base_branch}}. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-fixConflicts.txt b/Tests/fixtures/characterisation/prompt-fixConflicts.txt new file mode 100644 index 000000000..4a244dc8f --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-fixConflicts.txt @@ -0,0 +1,15 @@ +title: Fix merge conflicts + +summary: +Sent when you press Fix merge conflicts, with Bloom's own steps attached as a file. It resolves against the base branch here and pushes the result; it never merges the pull request. + +variables: +- workspace: The workspace's name. +- number: The pull request's number. +- branch: The branch the conflicts are resolved on, which is this worktree's. +- base_branch: The branch that conflicts with it, and that is brought in. + +template: +Pull request #{{number}} conflicts with {{base_branch}}, so GitHub will not merge it as it stands. Resolve that here, in this worktree, on {{branch}}: bring {{base_branch}} into this branch, work through the conflicts, commit, and push {{branch}}, so the conflict is gone for everybody rather than only here. + +Do not merge the pull request. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-mergePullRequest.txt b/Tests/fixtures/characterisation/prompt-mergePullRequest.txt new file mode 100644 index 000000000..a6c558a5e --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-mergePullRequest.txt @@ -0,0 +1,16 @@ +title: Merge a pull request + +summary: +Sent when you confirm Merge, with Bloom's own merge steps under it and the project's own instructions attached when it has any. The agent runs `gh pr merge` in front of you, not Bloom. + +variables: +- workspace: The workspace's name. +- number: The pull request's number. +- title: The pull request's title. +- branch: The branch it is on. +- base_branch: The branch it is merged into. +- method: The method you chose, in words: squash merge, merge commit, rebase merge. +- method_flag: The same method as the gh flag that performs it, such as --squash. + +template: +Merge pull request #{{number}} into {{base_branch}} as a {{method}}, which is `{{method_flag}}`. It is on the branch {{branch}}. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-nameWorkspace.txt b/Tests/fixtures/characterisation/prompt-nameWorkspace.txt new file mode 100644 index 000000000..23dbb78fe --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-nameWorkspace.txt @@ -0,0 +1,22 @@ +title: Name a new workspace + +summary: +Sent a moment after a workspace is created, to turn what you asked for into a name and a branch. + +variables: +- task: The first thing you asked this workspace for. +- project: The project it was created in. + +template: +Name this coding task, for a workspace sitting in a list beside twenty others. + +Project: {{project}} + +Answer at once, without deliberating. + +- name: at most five words, sentence case, no full stop, no quotes. Say what the work is, in the words someone would use out loud. Never start it with "Task" or "Workspace". +- branch: lowercase, words joined by hyphens, at most four words, letters, digits and hyphens only, no slashes and no prefix. + +## Task + +{{task}} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-pushLocalWork.txt b/Tests/fixtures/characterisation/prompt-pushLocalWork.txt new file mode 100644 index 000000000..c7c3f5ba0 --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-pushLocalWork.txt @@ -0,0 +1,13 @@ +title: Commit and push + +summary: +Sent when you press Commit and push in the pull request strip. The agent writes the commit message, not Bloom. + +variables: +- workspace: The workspace's name. +- branch: The branch the work is on. +- base_branch: The branch the pull request targets. +- changes: The changed files, with their added and removed line counts. + +template: +Commit everything outstanding in this worktree, with a message that describes the change the way this project words one, then push {{branch}} so its pull request reflects what is here. If there is nothing to commit, just push. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/prompt-review.txt b/Tests/fixtures/characterisation/prompt-review.txt new file mode 100644 index 000000000..aed2ff626 --- /dev/null +++ b/Tests/fixtures/characterisation/prompt-review.txt @@ -0,0 +1,18 @@ +title: Send review comments + +summary: +Sent when you send the inline comments you left on the diff. + +variables: +- message: What you typed in the composer alongside the comments. +- comments: Every attached comment, with its file, line and surrounding code. +- count: How many comments are attached. + +template: +{{message}} + +I reviewed the diff and left {{count}} inline comment(s). Each one below names the file it is about, the line it points at, and the code around that line, with the commented line marked `>`. + +Work through them in order. Read the surrounding code before you change anything, because a comment is about the line it points at and not about the whole file. Where a comment says the line has moved or is gone, find what it is actually about before acting on it, and tell me if you cannot. If you disagree with one, say so instead of changing the code. + +{{comments}} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/tool-workspace_list.json b/Tests/fixtures/characterisation/tool-workspace_list.json new file mode 100644 index 000000000..74ecc0f98 --- /dev/null +++ b/Tests/fixtures/characterisation/tool-workspace_list.json @@ -0,0 +1,24 @@ +{ + "description" : "The workspaces Bloom has: what each one is, what state it is in, and where its worktree is on disk. This is how you find out what became of a workspace you started, since workspace_start answers before any work happens and nothing waits for it.\n\nEach one carries its id, name, branch, base branch and worktree path, its project, whether it is active or archived, how far its setup script got, whether an agent is running in it, whether one has stopped on a permission question and what it asked, whether there is output nobody has read, the size of its diff, who asked for it, its chats with their cost and context size, and any messages queued for those chats with the reason the queue is not moving.\n\nDo not call it in a loop to wait for a workspace you started: pass notify_when_done to workspace_start instead, and Bloom tells your chat when that work comes to rest.\n\nThe worktree path is the most useful thing here. It is an ordinary git checkout, so read the diff, the log and the files in it with your own tools rather than asking Bloom for them.\n\nagent_running is about right now, and a workspace with nothing running is still a workspace: most of them sit idle most of the time, waiting to be read and merged. project_list counts these same rows, so its workspaces for a project is how many appear here for it and its agents_running is how many of those are marked agent_running. If the two ever look as though they disagree, one of them is being read as the other's number.\n\nBy default it reads Bloom's database and nothing else, and at that price GitHub is not consulted at all: there is no pull request, no checks, and the status can never say merged, closed, draft or anything about checks. A default call has not looked, so do not report that a workspace has no pull request on the strength of one. Pass include_github to ask, which costs one gh call and one git call per workspace and is slow on a long list.\n\nThe diff counts and the states come from Bloom's database, which a running Bloom refreshes every few seconds. With Bloom closed they are as old as the last time it was open. Read only. It changes nothing and starts nothing.\n", + "inputSchema" : { + "properties" : { + "include_archived" : { + "description" : "Include workspaces that have been archived. They have no worktree left on disk. Off by default.", + "type" : "boolean" + }, + "include_github" : { + "description" : "Include pull request and check state. Costs one gh call and one git call per workspace, so it is slow on a long list. Off by default.", + "type" : "boolean" + }, + "project" : { + "description" : "Narrow it to one project, by the name or the path project_list reports. Leave it out for every project.", + "type" : "string" + } + }, + "required" : [ + + ], + "type" : "object" + }, + "name" : "workspace_list" +} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/tool-workspace_merge.json b/Tests/fixtures/characterisation/tool-workspace_merge.json new file mode 100644 index 000000000..7043babd4 --- /dev/null +++ b/Tests/fixtures/characterisation/tool-workspace_merge.json @@ -0,0 +1,25 @@ +{ + "description" : "Ask a workspace's own agent to merge its pull request.\n\nThis does not merge anything. It composes the request Bloom's agent-guided merge choice composes, with the project's merge instructions attached, and sends it into that workspace's chat as an ordinary message. The agent runs `gh pr merge` there, in front of the owner, under whatever permission mode they set, and can say what GitHub answered if it refuses.\n\nSo it returns once the turn has begun, and the merging happens inside that turn, after this call is over. Nothing has landed on GitHub when this answers, and the merge may still not happen. Do not report a pull request as merged on the strength of this call. Call workspace_list with include_github afterwards and read the state.\n\nIt refuses a pull request GitHub will not take, a worktree holding work GitHub has not got, and a workspace whose agent is busy. When it refuses, do not run `gh pr merge` yourself and do not ask another agent to. Merging publishes to a server other people share, and this tool only asks the workspace's own agent to do it.\n\nName the workspace by the id workspace_list reports, not by its name. It squash merges unless you say otherwise.", + "inputSchema" : { + "properties" : { + "method" : { + "description" : "How to merge it. Leave it out for squash, which is what Bloom's own button proposes.", + "enum" : [ + "merge", + "squash", + "rebase" + ], + "type" : "string" + }, + "workspace" : { + "description" : "The workspace whose pull request to merge, by the id workspace_list reports. Not its name: two workspaces may share one.", + "type" : "string" + } + }, + "required" : [ + "workspace" + ], + "type" : "object" + }, + "name" : "workspace_merge" +} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/tool-workspace_start.json b/Tests/fixtures/characterisation/tool-workspace_start.json new file mode 100644 index 000000000..f0930a147 --- /dev/null +++ b/Tests/fixtures/characterisation/tool-workspace_start.json @@ -0,0 +1,57 @@ +{ + "description" : "Start a workspace and give it a task. It is a real git worktree on its own branch with its own agent, and it appears in Bloom's sidebar for the owner to watch and review.\n\nName the project to start it in with 'project', giving the name or the path that project_list reports. Bloom only starts workspaces in repositories it already has, so a project that is not on that list has to be registered with project_add first. If you are yourself running inside a Bloom workspace, leave 'project' out to start it in the project you are already in, or name another project to hand the work to that repository instead. The new workspace is still counted as one you started.\n\nUse it when a task splits into parts that do not need to see each other's edits, and you want them worked on at the same time rather than one after another.\n\nThere are two ways to start it, the two Bloom's own create window offers, and picking the wrong one is the mistake worth avoiding here.\n\n'New branch' is the default and needs nothing said. Commits land on a new branch, and merge into the branch you pick here. Name the branch to cut from with 'base_branch', or leave it out for the project's default branch.\n\n'Existing branch' is 'existing_branch'. Commits land on the branch you pick here, and merge when it does. Use it to carry on, review or fix work that is already on a branch: cutting a new branch off that branch instead gives you a workspace whose diff is empty, because it starts out identical to the branch you named. The branch has to exist already, and git allows one worktree per branch, so a branch another workspace is sitting on is refused rather than opened twice.\n\nTo review an existing GitHub pull request, use 'pull_request' with its number, '#123', or its GitHub URL. Bloom checks out the pull request itself, preserving its base and identity so the Changes, Checks and Merge controls refer to that pull request. Do not create a review branch with base_branch for this purpose.\n\nName only one of base_branch, existing_branch or pull_request.\n\n'model' and 'effort' are optional. Leave either out to inherit the calling chat's setting, or Bloom's default when the caller has no chat. An explicit effort must be one the selected model supports.\n\nIt returns as soon as the workspace exists, not when its work is done. The new agent starts on its own and keeps running while you carry on. There is no way to wait for it from here, so do not ask for one and then sit idle: say what you started and get on with your own work.\n\nPass notify_when_done: true to have Bloom tell this chat once, by itself, when the new agent's first turn comes to rest: finished (with its last message), failed (with the reason), or blocked waiting on the owner for a permission prompt or a question. With it, there is no need to tell the new agent to report back when it is done.\n\nThe task you give it is all it gets. It cannot see this conversation, so write the prompt as if to someone who has just opened the project for the first time.\n\nThis costs real money and real disk. Start one because the work genuinely divides, not to parallelise something you could do in a single pass. A workspace that was itself started this way cannot start others.", + "inputSchema" : { + "properties" : { + "agent" : { + "description" : "Which agent runs it. Leave it out for the one you are running on, or for Bloom's own default if you are not running in Bloom.", + "enum" : [ + "claudeCode", + "codex", + "grok" + ], + "type" : "string" + }, + "base_branch" : { + "description" : "Cut a new branch from this one. Your commits land on the new branch and merge back into this one. Leave it out for the project's default branch. Do not name it together with existing_branch.", + "type" : "string" + }, + "effort" : { + "description" : "The exact reasoning effort id, such as low, medium or high. Leave it out to inherit the calling chat's effort, or Bloom's default when the caller has no chat.", + "type" : "string" + }, + "existing_branch" : { + "description" : "Work on this branch itself, instead of cutting a new one. Your commits land on it. It has to exist already, locally or on the remote, and it must not be open in another workspace. Do not name it together with base_branch.", + "type" : "string" + }, + "model" : { + "description" : "The exact model id. Claude Code models are opus, sonnet, fable and haiku. Codex model ids come from the signed-in Codex account, for example gpt-5.6-sol. Do not use a Claude Code model with codex or a Codex model with claudeCode. Leave this out to use the selected agent's default model.", + "type" : "string" + }, + "name" : { + "description" : "What to call it in the sidebar. Leave it out and Bloom names it from the task.", + "type" : "string" + }, + "notify_when_done" : { + "description" : "Have Bloom tell this chat once when the new agent's first turn comes to rest: finished, failed, or waiting on the owner. Defaults to false.", + "type" : "boolean" + }, + "project" : { + "description" : "Which project to start it in, by the name or the path project_list reports. Inside a Bloom workspace, leave it out for the project you are in, or name another to start the work there.", + "type" : "string" + }, + "prompt" : { + "description" : "The task, written for someone with no context beyond the project itself.", + "type" : "string" + }, + "pull_request" : { + "description" : "Open this GitHub pull request itself for review, by number, #number or GitHub URL. This preserves the PR connection so Bloom can show checks and merge it. Do not combine it with base_branch or existing_branch.", + "type" : "string" + } + }, + "required" : [ + "prompt" + ], + "type" : "object" + }, + "name" : "workspace_start" +} \ No newline at end of file From fd93a6e7b6812d7c36a2cfc869d9511d475c5f3f Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 02/16] Ask for pull requests through a forge client --- Sources/Bloom/Intents/WorkspaceLookup.swift | 3 +- Sources/Bloom/State/Bridges.swift | 6 +- .../Views/Inspector/CheckFailureSender.swift | 3 +- .../BloomCore/Bridge/WorkspaceListTool.swift | 3 +- .../BloomCore/Bridge/WorkspaceMergeTool.swift | 7 +- Sources/BloomCore/GitHub/ForgeClient.swift | 77 +++++++++++++++++++ .../Workspace/WorkspaceCheckout.swift | 5 +- .../Workspace/WorkspaceManager.swift | 2 +- .../Workspace/WorkspaceStartContext.swift | 5 +- Tests/BloomCoreTests/ForgeClientTests.swift | 34 ++++++++ 10 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 Sources/BloomCore/GitHub/ForgeClient.swift create mode 100644 Tests/BloomCoreTests/ForgeClientTests.swift diff --git a/Sources/Bloom/Intents/WorkspaceLookup.swift b/Sources/Bloom/Intents/WorkspaceLookup.swift index 0565ecbc9..1c3d54c7f 100644 --- a/Sources/Bloom/Intents/WorkspaceLookup.swift +++ b/Sources/Bloom/Intents/WorkspaceLookup.swift @@ -53,6 +53,7 @@ enum WorkspaceLookup { /// workspace falls back to what git alone can say about it. static func pullRequest(for workspace: Workspace) async -> PullRequest? { guard FileManager.default.fileExists(atPath: workspace.path) else { return nil } - return try? await GitHub.pullRequest(for: workspace, maxAge: .seconds(60)) + let forge = await ForgeResolver.client(for: workspace.path) + return try? await forge.pullRequest(for: workspace, maxAge: .seconds(60)) } } diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index 193dd3110..cc64620d2 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -19,7 +19,7 @@ enum GitHubBridge { guard availability == .ready else { return .unavailable(GitHubReadFailure(reason: .authentication, message: "Connect GitHub to refresh pull requests.")) } - return await GitHub.readPullRequest(for: workspace, maxAge: maxAge) + return await ForgeResolver.client(for: workspace.path).readPullRequest(for: workspace, maxAge: maxAge) } /// - Parameter maxAge: how old an answer from the last `gh pr view` may be and still be used. @@ -42,7 +42,7 @@ enum GitHubBridge { } static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { - try await GitHub.markReadyForReview(pullRequest, worktree: worktree) + try await ForgeResolver.client(for: worktree).markReadyForReview(pullRequest, worktree: worktree) } static func mergePullRequest( @@ -58,7 +58,7 @@ enum GitHubBridge { /// as it always was, because `try?` would flatten it into that nil. static func checks(for workspace: Workspace) async -> [CheckRun]? { do { - return try await GitHub.checks(for: workspace) + return try await ForgeResolver.client(for: workspace.path).checks(for: workspace) } catch { return [] } diff --git a/Sources/Bloom/Views/Inspector/CheckFailureSender.swift b/Sources/Bloom/Views/Inspector/CheckFailureSender.swift index 69876fc90..201e52ffd 100644 --- a/Sources/Bloom/Views/Inspector/CheckFailureSender.swift +++ b/Sources/Bloom/Views/Inspector/CheckFailureSender.swift @@ -39,7 +39,8 @@ final class CheckFailureSender { if let target = CheckFailureHandoff.logTarget(detailsURL: run.detailsURL) { do { - let log = try await GitHub.checkRunLog(target, worktree: model.workspace.path) + let worktree = model.workspace.path + let log = try await ForgeResolver.client(for: worktree).checkRunLog(target, worktree: worktree) excerpt = CheckFailureHandoff.excerpt(log) } catch { // Not fatal, and deliberately so. A check with no log Bloom can reach is still diff --git a/Sources/BloomCore/Bridge/WorkspaceListTool.swift b/Sources/BloomCore/Bridge/WorkspaceListTool.swift index 8a9f68757..53d31b3c8 100644 --- a/Sources/BloomCore/Bridge/WorkspaceListTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceListTool.swift @@ -358,7 +358,8 @@ public struct WorkspaceListTool: BridgeToolHandling { /// over, so the workspace falls back to what Bloom's own database can say about it. private func pullRequest(for workspace: Workspace, store: Store) async -> PullRequest? { guard FileManager.default.fileExists(atPath: workspace.path) else { return nil } - let found = try? await GitHub.pullRequest(for: workspace, maxAge: .seconds(60)) + let forge = await ForgeResolver.client(for: workspace.path) + let found = try? await forge.pullRequest(for: workspace, maxAge: .seconds(60)) // Every lookup writes the number down, this one included. A listing an agent asked for is // as good a moment as a poll to learn which pull request a workspace is about, and the // column is what keeps the answer findable once the branch is deleted. See diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index e6ca8fe27..bf5ff0050 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -385,13 +385,14 @@ public struct WorkspaceMergeTool: BridgeToolHandling { /// cached "Ready to merge" from before somebody pushed a broken commit is the wrong kind of /// cheap. public static let ask: Reading = { workspace in + let forge = await ForgeResolver.client(for: workspace.path) do { - guard let pullRequest = try await GitHub.pullRequest( + guard let pullRequest = try await forge.pullRequest( forBranch: workspace.branch, worktree: workspace.path ) else { // gh answered and there is nothing, but only if gh could answer at all. Asked // second because it costs a subprocess and the common case never needs it. - let access = await GitHub.access() + let access = await forge.access() return access == .ready ? .noPullRequest : .unavailable(access) } let local = try? await Git.localWork(worktree: workspace.path) @@ -399,7 +400,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } catch { // gh threw. Which of the two it is decides the advice, so it is asked rather than // assumed: an unusable gh is permanent and a failed call usually is not. - let access = await GitHub.access() + let access = await forge.access() return access == .ready ? .failed(plainly(error)) : .unavailable(access) } } diff --git a/Sources/BloomCore/GitHub/ForgeClient.swift b/Sources/BloomCore/GitHub/ForgeClient.swift new file mode 100644 index 000000000..2d610276b --- /dev/null +++ b/Sources/BloomCore/GitHub/ForgeClient.swift @@ -0,0 +1,77 @@ +import Foundation + +/// The pull request operations more than one forge answers. Each requirement takes only what its +/// callers pass, so every default stays on `GitHub` rather than being restated here. +public protocol ForgeClient { + static func access() async -> GitHubAccess + static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead + static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? + static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? + static func checks(for workspace: Workspace) async throws -> [CheckRun]? + static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws + static func checkRunLog(_ target: CheckFailureHandoff.LogTarget, worktree: String) async throws -> String + static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] + static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing + static func repositorySlug(repoPath: String) async -> String? + static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws +} + +/// `GitHub` as a `ForgeClient`. It only forwards. +public enum GitHubForge: ForgeClient { + public static func access() async -> GitHubAccess { + await GitHub.access() + } + + public static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead { + await GitHub.readPullRequest(for: workspace, maxAge: maxAge) + } + + public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { + try await GitHub.pullRequest(for: workspace, maxAge: maxAge) + } + + public static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? { + try await GitHub.pullRequest(forBranch: branch, worktree: worktree) + } + + public static func checks(for workspace: Workspace) async throws -> [CheckRun]? { + try await GitHub.checks(for: workspace) + } + + public static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { + try await GitHub.markReadyForReview(pullRequest, worktree: worktree) + } + + public static func checkRunLog( + _ target: CheckFailureHandoff.LogTarget, worktree: String + ) async throws -> String { + try await GitHub.checkRunLog(target, worktree: worktree) + } + + public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] { + try await GitHub.openPullRequests(repoPath: repoPath) + } + + public static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing { + try await GitHub.pullRequestSummary(number: number, repoPath: repoPath) + } + + public static func repositorySlug(repoPath: String) async -> String? { + await GitHub.repositorySlug(repoPath: repoPath) + } + + public static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws { + try await GitHub.checkoutPullRequest(number: number, into: worktree, localBranch: localBranch) + } +} + +/// Which forge a repository's pull requests are asked of. +/// +/// Every repository answers GitHub, which is the whole of what Bloom speaks today. The directory +/// is taken now so that callers already say which repository they mean when a second answer +/// arrives. +public enum ForgeResolver { + public static func client(for directory: String) async -> any ForgeClient.Type { + GitHubForge.self + } +} diff --git a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift index 99f87dee6..852b7df2b 100644 --- a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift +++ b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift @@ -463,13 +463,14 @@ public enum WorkspaceCheckoutResolver { /// The whole of it, including the gh call. Lives here rather than in the sheet so the sheet is /// left drawing a text field and reading an answer. public static func resolve(_ text: String, repoPath: String) async -> WorkspaceCheckoutResolution { - let slug = await GitHub.repositorySlug(repoPath: repoPath) + let forge = await ForgeResolver.client(for: repoPath) + let slug = await forge.repositorySlug(repoPath: repoPath) if let problem = problem(with: text, in: slug) { return .failure(problem) } guard let reference = WorkspaceCheckoutPlan.parseReference(text) else { return .failure("That is not a pull request number or URL.") } do { - let summary = try await GitHub.pullRequestSummary( + let summary = try await forge.pullRequestSummary( number: reference.number, repoPath: repoPath ) return .checkout(.pullRequest(summary)) diff --git a/Sources/BloomCore/Workspace/WorkspaceManager.swift b/Sources/BloomCore/Workspace/WorkspaceManager.swift index 90466e9d6..3beea3310 100644 --- a/Sources/BloomCore/Workspace/WorkspaceManager.swift +++ b/Sources/BloomCore/Workspace/WorkspaceManager.swift @@ -354,7 +354,7 @@ public struct WorkspaceManager: Sendable { case .pullRequest(let request): try await Git.addDetachedWorktree(repo: repo.path, path: worktreePath) do { - try await GitHub.checkoutPullRequest( + try await ForgeResolver.client(for: repo.path).checkoutPullRequest( number: request.number, into: worktreePath, localBranch: branch ) } catch { diff --git a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift index 68a068593..149e6cf66 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift @@ -179,11 +179,12 @@ public struct WorkspaceCheckoutOptions: Sendable { ) } - let access = await GitHub.access() + let forge = await ForgeResolver.client(for: repoPath) + let access = await forge.access() guard access == .ready else { return options(access: access) } do { - return options(pullRequests: try await GitHub.openPullRequests(repoPath: repoPath)) + return options(pullRequests: try await forge.openPullRequests(repoPath: repoPath)) } catch { return options(failure: error.readableMessage) } diff --git a/Tests/BloomCoreTests/ForgeClientTests.swift b/Tests/BloomCoreTests/ForgeClientTests.swift new file mode 100644 index 000000000..f92f2ec3b --- /dev/null +++ b/Tests/BloomCoreTests/ForgeClientTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Forge client", .scratchDirectory) +struct ForgeClientTests { + @Test("every repository is asked through GitHub") + func resolvesGitHub() async { + let client = await ForgeResolver.client(for: TestScratch.unique("forge")) + #expect(ObjectIdentifier(client) == ObjectIdentifier(GitHubForge.self)) + } + + @Test("the GitHub client runs the gh command GitHub runs") + func forwards() async throws { + let worktree = TestScratch.unique("forge-forward") + let asked = ArgumentLog() + let client = await ForgeResolver.client(for: worktree) + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await asked.record(arguments) + return ShellResult(status: 0, stdout: #"{"number":1,"headRefName":"feature"}"#, stderr: "") + }) { + try await client.pullRequest(forBranch: "feature", worktree: worktree) + } + #expect(await asked.first.map { Array($0.prefix(3)) } == ["pr", "view", "feature"]) + } +} + +private actor ArgumentLog { + private(set) var first: [String]? + + func record(_ arguments: [String]) { + if first == nil { first = arguments } + } +} From e2dca450c02eded1cfdf9429993da4433c1ee203 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 03/16] Read merge requests and pipelines from GitLab --- Sources/Bloom/State/Bridges.swift | 5 +- .../State/WorkspaceModel+PullRequest.swift | 5 +- Sources/Bloom/State/WorkspaceModel.swift | 4 + .../Bloom/Views/Inspector/GitHubSignIn.swift | 5 + .../BloomCore/Bridge/WorkspaceMergeTool.swift | 4 +- .../GitHub/CheckFailureHandoff.swift | 26 +- Sources/BloomCore/GitHub/Forge.swift | 31 +++ Sources/BloomCore/GitHub/ForgeClient.swift | 25 +- Sources/BloomCore/GitHub/ForgeRouting.swift | 143 +++++++++++ Sources/BloomCore/GitHub/MergeBlocker.swift | 68 ++++++ .../BloomCore/GitHub/PullRequestNumber.swift | 3 +- .../BloomCore/GitHub/PullRequestStatus.swift | 25 +- Sources/BloomCore/GitLab/GitLab.swift | 227 ++++++++++++++++++ Sources/BloomCore/GitLab/GitLabDecoding.swift | 159 ++++++++++++ Sources/BloomCore/GitLab/GitLabProject.swift | 31 +++ Sources/BloomCore/Model/Models.swift | 10 +- Sources/BloomCore/Model/WorkspaceStatus.swift | 3 +- Sources/BloomCore/Persistence/Settings.swift | 5 + .../Workspace/WorkspaceStartContext.swift | 2 +- Tests/BloomCoreTests/ForgeClientTests.swift | 15 +- Tests/BloomCoreTests/ForgeRoutingTests.swift | 93 +++++++ Tests/BloomCoreTests/GitLabCommandTests.swift | 107 +++++++++ .../BloomCoreTests/GitLabDecodingTests.swift | 137 +++++++++++ .../fixtures/gitlab/jobs-failed-pipeline.json | 200 +++++++++++++++ .../gitlab/mr-draft-conflicting-fork.json | 31 +++ Tests/fixtures/gitlab/mr-list-open.json | 87 +++++++ Tests/fixtures/gitlab/mr-merged.json | 31 +++ Tests/fixtures/gitlab/mr-not-approved.json | 31 +++ .../gitlab/mr-unresolved-discussions.json | 31 +++ 29 files changed, 1504 insertions(+), 40 deletions(-) create mode 100644 Sources/BloomCore/GitHub/Forge.swift create mode 100644 Sources/BloomCore/GitHub/ForgeRouting.swift create mode 100644 Sources/BloomCore/GitHub/MergeBlocker.swift create mode 100644 Sources/BloomCore/GitLab/GitLab.swift create mode 100644 Sources/BloomCore/GitLab/GitLabDecoding.swift create mode 100644 Sources/BloomCore/GitLab/GitLabProject.swift create mode 100644 Tests/BloomCoreTests/ForgeRoutingTests.swift create mode 100644 Tests/BloomCoreTests/GitLabCommandTests.swift create mode 100644 Tests/BloomCoreTests/GitLabDecodingTests.swift create mode 100644 Tests/fixtures/gitlab/jobs-failed-pipeline.json create mode 100644 Tests/fixtures/gitlab/mr-draft-conflicting-fork.json create mode 100644 Tests/fixtures/gitlab/mr-list-open.json create mode 100644 Tests/fixtures/gitlab/mr-merged.json create mode 100644 Tests/fixtures/gitlab/mr-not-approved.json create mode 100644 Tests/fixtures/gitlab/mr-unresolved-discussions.json diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index cc64620d2..fe385ff0a 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -12,6 +12,9 @@ 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.")) @@ -19,7 +22,7 @@ enum GitHubBridge { 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. diff --git a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift index 97878e273..6f7a28318 100644 --- a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift +++ b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift @@ -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) diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index 7365fc1d2..edcfa41da 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -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 @@ -2474,6 +2476,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 diff --git a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift index b4499faf7..60d886114 100644 --- a/Sources/Bloom/Views/Inspector/GitHubSignIn.swift +++ b/Sources/Bloom/Views/Inspector/GitHubSignIn.swift @@ -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() diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index bf5ff0050..03905d191 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -392,7 +392,7 @@ 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) @@ -400,7 +400,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } 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) } } diff --git a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift index 1ee9829ba..ae366a1ef 100644 --- a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift +++ b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift @@ -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", @@ -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. diff --git a/Sources/BloomCore/GitHub/Forge.swift b/Sources/BloomCore/GitHub/Forge.swift new file mode 100644 index 000000000..9b0e43454 --- /dev/null +++ b/Sources/BloomCore/GitHub/Forge.swift @@ -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)" + } + } +} diff --git a/Sources/BloomCore/GitHub/ForgeClient.swift b/Sources/BloomCore/GitHub/ForgeClient.swift index 2d610276b..79dcae209 100644 --- a/Sources/BloomCore/GitHub/ForgeClient.swift +++ b/Sources/BloomCore/GitHub/ForgeClient.swift @@ -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]? @@ -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) } @@ -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] { @@ -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 - } -} diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift new file mode 100644 index 000000000..3a5b3c552 --- /dev/null +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -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 + } +} diff --git a/Sources/BloomCore/GitHub/MergeBlocker.swift b/Sources/BloomCore/GitHub/MergeBlocker.swift new file mode 100644 index 000000000..b8aff7018 --- /dev/null +++ b/Sources/BloomCore/GitHub/MergeBlocker.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Why GitLab will refuse a merge request that is open, not a draft and not conflicting, read +/// from `detailed_merge_status`. The GitHub decoder never fills it. +public enum MergeBlocker: Sendable, Hashable, Codable { + case approvalRequired + case changesRequested + case unresolvedDiscussions + case needsRebase + case pipelineMustPass + case pipelineRunning + case blockedByMergeRequest + case notBefore + case checking + /// A project rule in GitLab's words. An unknown status lands here: a refusal, never mergeable. + case policy(String) + + /// Nil for `mergeable` and for what `PullRequest` already says (conflict, draft, not open). + public init?(detailedMergeStatus status: String) { + switch status { + case "mergeable", "conflict", "draft_status", "not_open", "": return nil + case "not_approved": self = .approvalRequired + case "requested_changes": self = .changesRequested + case "discussions_not_resolved": self = .unresolvedDiscussions + case "need_rebase": self = .needsRebase + case "ci_must_pass", "status_checks_must_pass", "security_policy_pipeline_check": + self = .pipelineMustPass + case "ci_still_running": self = .pipelineRunning + case "merge_request_blocked": self = .blockedByMergeRequest + case "merge_time": self = .notBefore + case "checking", "unchecked", "preparing", "approvals_syncing": self = .checking + default: self = .policy(status) + } + } + + /// The strip's headline. + public var text: String { + switch self { + case .approvalRequired: "Waiting for approval" + case .changesRequested: "Changes requested" + case .unresolvedDiscussions: "Unresolved threads" + case .needsRebase: "Needs rebase" + case .pipelineMustPass: "Pipeline must pass" + case .pipelineRunning: "Pipeline running" + case .blockedByMergeRequest: "Blocked by another merge request" + case .notBefore: "Scheduled" + case .checking: "Checking mergeability" + case .policy: "Blocked by GitLab" + } + } + + /// Why the merge button is off, for its tooltip. + public var reason: String { + switch self { + case .approvalRequired: "This merge request needs an approval first." + case .changesRequested: "A reviewer has requested changes." + case .unresolvedDiscussions: "Resolve the open threads first." + case .needsRebase: "Rebase this branch onto its target first." + case .pipelineMustPass: "The pipeline has to succeed before this can merge." + case .pipelineRunning: "Wait for the pipeline to finish." + case .blockedByMergeRequest: "Another merge request has to merge first." + case .notBefore: "This merge request is set to merge later." + case .checking: "GitLab is still working out whether this can merge." + case .policy(let status): + "GitLab will not merge this yet: \(status.replacingOccurrences(of: "_", with: " "))." + } + } +} diff --git a/Sources/BloomCore/GitHub/PullRequestNumber.swift b/Sources/BloomCore/GitHub/PullRequestNumber.swift index 7da6e5a38..6eea3be57 100644 --- a/Sources/BloomCore/GitHub/PullRequestNumber.swift +++ b/Sources/BloomCore/GitHub/PullRequestNumber.swift @@ -28,7 +28,8 @@ public enum PullRequestNumber { // Zero is a real value rather than a hypothetical: `GitHub.decodePullRequest` reads a // payload with no `number` in it as 0, which an older gh produces, and a 0 written here // would be asked about by `gh pr view 0` on every poll for the rest of the row's life. - guard let found, found.number > 0, found.number != recorded else { return nil } + // GitHub's only: GitLab needs no recorded number, and an iid here would be read as one. + guard let found, found.forge == .gitHub, found.number > 0, found.number != recorded else { return nil } return found.number } diff --git a/Sources/BloomCore/GitHub/PullRequestStatus.swift b/Sources/BloomCore/GitHub/PullRequestStatus.swift index 741bb1ec2..8cdc9665f 100644 --- a/Sources/BloomCore/GitHub/PullRequestStatus.swift +++ b/Sources/BloomCore/GitHub/PullRequestStatus.swift @@ -202,7 +202,7 @@ public extension PullRequest { tone: .merged, text: "Merged", canMerge: false, - blockedReason: "This pull request is already merged." + blockedReason: "This \(forge.request) is already merged." ) } if isClosed { @@ -210,7 +210,7 @@ public extension PullRequest { tone: .neutral, text: "Closed", canMerge: false, - blockedReason: "This pull request was closed without merging." + blockedReason: "This \(forge.request) was closed without merging." ) } // Conflicts outrank the check rollup: green checks on a branch that cannot be applied to @@ -236,10 +236,19 @@ public extension PullRequest { text: "Draft", detail: checksDetail, canMerge: false, - blockedReason: "This pull request is still a draft.", + blockedReason: "This \(forge.request) is still a draft.", remedy: .markReadyForReview ) } + if let blocker = blockers.first { + return PullRequestStatus( + tone: blocker == .checking ? .neutral : .warning, + text: blocker.text, + detail: checksDetail, + canMerge: false, + blockedReason: blocker.reason + ) + } return PullRequestStatus( tone: openTone, text: openHeadline, detail: checksDetail, canMerge: true @@ -247,16 +256,16 @@ public extension PullRequest { } func mergeConfirmationTitle(base: String) -> String { - "Merge #\(number) into \(base)?" + "Merge \(forge.reference(number)) into \(base)?" } var mergeConfirmationMessage: String { - "Your agent will merge this pull request in the chat, where you can follow its progress." + "Your agent will merge this \(forge.request) in the chat, where you can follow its progress." } func mergeBranchDeletionMessage(deletesBranch: Bool) -> String? { guard deletesBranch, !branch.isEmpty else { return nil } - return "After merging, \(branch) will be deleted on GitHub. Your local branch stays." + return "After merging, \(branch) will be deleted on \(forge.name). Your local branch stays." } /// These warnings precede the explanation so a merge of older work or a failing check @@ -265,7 +274,7 @@ public extension PullRequest { var warnings: [String] = [] if let local, local.isAhead { warnings.append( - "GitHub does not have everything in this worktree: " + "\(forge.name) does not have everything in this worktree: " + Self.localDetail(local) + ". None of that is part of what is merged, and the agent is told to leave it" + " alone rather than commit it first." @@ -273,7 +282,7 @@ public extension PullRequest { } if checks == .failing { warnings.append(checksSummary) } if checks == .unavailable { - warnings.append("Bloom could not read this pull request's checks, so it cannot say whether they passed.") + warnings.append("Bloom could not read this \(forge.request)'s checks, so it cannot say whether they passed.") } if hasConflicts { warnings.append("This branch conflicts with \(base).") } return warnings diff --git a/Sources/BloomCore/GitLab/GitLab.swift b/Sources/BloomCore/GitLab/GitLab.swift new file mode 100644 index 000000000..e9f1d0419 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -0,0 +1,227 @@ +import Foundation + +/// The glab boundary, read as `PullRequest` and `CheckRun`. +/// +/// Reads use `glab api` on the versioned REST API with the host and project named, rather than +/// glab's own JSON and remote guessing. No cache yet: a read is two or three small calls. +public enum GitLab: ForgeClient { + @TaskLocal static var commandOverride: (@Sendable ([String], String?) async throws -> ShellResult)? + + private static let readTimeout = Duration.seconds(20) + /// Checking out fetches objects, so it gets what `gh pr checkout` gets. + private static let checkoutTimeout = Duration.seconds(120) + private static let logTimeout = Duration.seconds(90) + private static let candidateLimit = 20 + private static let jobLimit = 100 + private static let listLimit = 30 + + public static func access(in directory: String) async -> GitHubAccess { + guard commandOverride != nil || Shell.which("glab") != nil else { return .notInstalled } + guard let project = await project(in: directory), + let result = try? await run(["auth", "status", "--hostname", project.host], cwd: nil) + else { return .signedOut } + return result.ok ? .ready : .signedOut + } + + public static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead { + guard commandOverride != nil || Shell.which("glab") != nil else { + return .unavailable(GitHubReadFailure( + reason: .unavailable, message: "Install the GitLab CLI to refresh merge requests." + )) + } + do { return .current(try await pullRequest(for: workspace, maxAge: maxAge)) } catch { + let failure = GitHubReadFailure.classify(error) + let refused = failure.reason == .authentication || ((error as? ShellError)?.stderr.contains("401") ?? false) + return .unavailable(refused ? GitHubReadFailure( + reason: .authentication, message: "Connect GitLab again to refresh merge requests." + ) : failure) + } + } + + public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { + try await snapshot(for: workspace)?.pullRequest + } + + public static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? { + guard let project = await project(in: worktree) else { return nil } + let candidates = try await mergeRequests(withSource: branch, in: project, worktree: worktree) + .filter { $0.sourceProjectId == $0.targetProjectId } + let chosen = candidates.first { $0.state == "opened" } ?? candidates.first + guard let iid = chosen?.iid else { return nil } + return try await snapshot(number: iid, in: project, worktree: worktree).pullRequest + } + + public static func checks(for workspace: Workspace) async throws -> [CheckRun]? { + try await snapshot(for: workspace)?.runs ?? [] + } + + public static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { + guard pullRequest.isOpen, pullRequest.isDraft else { + throw GitHubError("This merge request is no longer an open draft.") + } + guard let project = await project(in: worktree) else { throw notAProject } + try await check(["mr", "update", String(pullRequest.number), "--ready", "-R", project.webURL], cwd: worktree) + } + + public static func checkRunLog(_ target: CheckFailureHandoff.LogTarget, worktree: String) async throws -> String { + guard let jobID = target.jobID, let projectPath = target.project else { + throw GitHubError("Bloom could not tell which GitLab job this is.") + } + guard let project = await project(in: worktree) else { throw notAProject } + let jobProject = GitLabProject(remote: "https://\(project.host)/\(projectPath)") ?? project + let result = try await run( + api(project.host, "projects/\(jobProject.apiID)/jobs/\(jobID)/trace"), cwd: worktree, timeout: logTimeout + ) + guard result.ok, !result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GitHubError(result.stderr.isEmpty ? "glab returned no log for this job" : result.stderr) + } + return result.stdout + } + + public static func openPullRequests(repoPath: String) async throws -> [PullRequestListing] { + guard let project = await project(in: repoPath) else { return [] } + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests?state=opened&per_page=\(listLimit)"), + cwd: repoPath + ) + let listings = try GitLabDecoding.mergeRequests(from: Data(result.stdout.utf8)).map(GitLabDecoding.listing) + return WorkspaceCheckoutPlan.offered(listings) + } + + public static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing { + guard number > 0 else { throw GitHubError("\(number) is not a merge request number") } + guard let project = await project(in: repoPath) else { throw notAProject } + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests/\(number)"), cwd: repoPath + ) + return GitLabDecoding.listing(try GitLabDecoding.mergeRequest(from: Data(result.stdout.utf8))) + } + + public static func repositorySlug(repoPath: String) async -> String? { + await project(in: repoPath)?.path + } + + public static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws { + guard number > 0 else { throw GitHubError("\(number) is not a merge request number") } + guard Git.isValidBranchName(localBranch) else { + throw GitHubError("'\(localBranch)' is not a valid branch name") + } + guard let project = await project(in: worktree) else { throw notAProject } + try await check( + ["mr", "checkout", String(number), "--branch", localBranch, "-R", project.webURL], + cwd: worktree, timeout: checkoutTimeout + ) + } + + // MARK: Reading one merge request + + struct Snapshot { + let pullRequest: PullRequest + let runs: [CheckRun] + } + + /// The newest merge request from the workspace's head that had not ended before it existed. + /// GitLab filters on the recorded source branch, so this survives the branch being deleted. + static func snapshot(for workspace: Workspace) async throws -> Snapshot? { + guard let project = await project(in: workspace.path) else { return nil } + let head = await GitHub.headBranch(of: workspace) + let checkedOut = await checkedOutMergeRequest(branch: head, worktree: workspace.path) + let candidates = try await mergeRequests(withSource: head, in: project, worktree: workspace.path) + // A fork's merge request with the same branch name is somebody else's, unless this + // worktree was checked out from it. + let matches = candidates.compactMap { candidate -> PullRequestHeadMatch? in + guard let iid = candidate.iid, + candidate.sourceProjectId == candidate.targetProjectId || iid == checkedOut + else { return nil } + let ended = GitLabDecoding.pullRequest(candidate, runs: []).closedAt + return PullRequestHeadMatch(number: iid, closedAt: ended) + } + guard let chosen = PullRequestOwnership.choose( + from: matches, startedAt: workspace.createdAt, checkedOutAs: checkedOut + ) else { return nil } + return try await snapshot(number: chosen, in: project, worktree: workspace.path) + } + + private static func snapshot(number: Int, in project: GitLabProject, worktree: String) async throws -> Snapshot { + let detail = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests/\(number)"), cwd: worktree + ) + let payload = try GitLabDecoding.mergeRequest(from: Data(detail.stdout.utf8)) + guard let pipeline = payload.headPipeline else { + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: []), runs: []) + } + let pipelineProject = pipeline.projectId.map(String.init) ?? project.apiID + // An unreadable pipeline (a private fork) means unknown checks, not no merge request. + guard let jobs = try? await check( + api(project.host, "projects/\(pipelineProject)/pipelines/\(pipeline.id)/jobs?per_page=\(jobLimit)"), + cwd: worktree + ), let runs = try? GitLabDecoding.jobs(from: Data(jobs.stdout.utf8)) else { + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: nil), runs: []) + } + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: runs), runs: runs) + } + + private static func mergeRequests( + withSource branch: String, in project: GitLabProject, worktree: String + ) async throws -> [GitLabDecoding.MergeRequestPayload] { + guard Git.isValidBranchName(branch) else { return [] } + let query = "source_branch=\(queryValue(branch))&state=all&order_by=created_at&sort=desc" + + "&per_page=\(candidateLimit)" + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests?\(query)"), cwd: worktree + ) + return try GitLabDecoding.mergeRequests(from: Data(result.stdout.utf8)) + } + + /// The merge request `glab mr checkout` wrote into the branch's config, if it did. + private static func checkedOutMergeRequest(branch: String, worktree: String) async -> Int? { + guard Git.isValidBranchName(branch), + let result = try? await Shell.run("git", ["config", "--get", "branch.\(branch).merge"], cwd: worktree), + result.ok + else { return nil } + let reference = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "refs/merge-requests/" + let suffix = "/head" + guard reference.hasPrefix(prefix), reference.hasSuffix(suffix) else { return nil } + return Int(reference.dropFirst(prefix.count).dropLast(suffix.count)) + } + + // MARK: Running glab + + static func project(in directory: String) async -> GitLabProject? { + let context = try? await Git.repositoryContext(in: directory) + return GitLabProject(remote: context?.baseRemoteURL) + } + + private static let notAProject = GitHubError("This repository has no GitLab remote Bloom can read.") + + private static func api(_ host: String, _ endpoint: String) -> [String] { + ["api", "--hostname", host, endpoint] + } + + private static func queryValue(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))) ?? value + } + + @discardableResult + private static func check( + _ arguments: [String], cwd: String?, timeout: Duration = readTimeout + ) async throws -> ShellResult { + let result = try await run(arguments, cwd: cwd, timeout: timeout) + guard result.ok else { + throw ShellError( + command: "glab " + arguments.joined(separator: " "), + status: result.status, + stderr: result.stderr.isEmpty ? result.stdout : result.stderr + ) + } + return result + } + + private static func run( + _ arguments: [String], cwd: String?, timeout: Duration = readTimeout + ) async throws -> ShellResult { + if let commandOverride { return try await commandOverride(arguments, cwd) } + return try await Shell.run("glab", arguments, cwd: cwd, timeout: timeout) + } +} diff --git a/Sources/BloomCore/GitLab/GitLabDecoding.swift b/Sources/BloomCore/GitLab/GitLabDecoding.swift new file mode 100644 index 000000000..2762505d1 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabDecoding.swift @@ -0,0 +1,159 @@ +import Foundation + +/// GitLab's REST answers in GitHub's vocabulary (`opened` is `OPEN`, a job is a check run), so +/// everything downstream is shared. What GitHub has no word for goes in `blockers`. +enum GitLabDecoding { + struct MergeRequestPayload: Decodable { + struct Pipeline: Decodable { + let id: Int + /// The project the pipeline ran in, which for a merge request from a fork is the fork. + let projectId: Int? + } + + struct Author: Decodable { + let username: String? + } + + let iid: Int? + let title: String? + let webUrl: String? + let state: String? + let draft: Bool? + let hasConflicts: Bool? + let detailedMergeStatus: String? + let sourceBranch: String? + let targetBranch: String? + /// The source branch's head commit, which a direct merge passes as `--sha`. + let sha: String? + let mergedAt: String? + let closedAt: String? + let sourceProjectId: Int? + let targetProjectId: Int? + let headPipeline: Pipeline? + let author: Author? + } + + struct JobPayload: Decodable { + let id: Int? + let name: String? + let stage: String? + let status: String? + let allowFailure: Bool? + let webUrl: String? + let startedAt: String? + let finishedAt: String? + } + + static func mergeRequest(from data: Data) throws -> MergeRequestPayload { + try decode(MergeRequestPayload.self, from: data) + } + + static func mergeRequests(from data: Data) throws -> [MergeRequestPayload] { + try decode([MergeRequestPayload].self, from: data) + } + + static func jobs(from data: Data) throws -> [CheckRun] { + try decode([JobPayload].self, from: data).map(checkRun) + } + + /// `runs` is nil when the pipeline's jobs could not be read. + static func pullRequest(_ payload: MergeRequestPayload, runs: [CheckRun]?) -> PullRequest { + let detailed = payload.detailedMergeStatus ?? "" + let state = state(payload.state) + let (checks, summary) = runs.map(GitHub.rollup) ?? (.unavailable, GitHub.checksUnavailableSummary) + return PullRequest( + number: payload.iid ?? 0, + title: payload.title ?? "", + url: payload.webUrl ?? "", + state: state, + isDraft: payload.draft ?? false, + mergeable: payload.hasConflicts == true || detailed == "conflict" ? "CONFLICTING" : nil, + checks: checks, + checksSummary: summary, + reviewDecision: reviewDecision(detailed), + branch: payload.sourceBranch ?? "", + baseBranch: payload.targetBranch, + headRevision: payload.sha, + // GitLab leaves `closed_at` null on a merge; ownership needs it set, as GitHub does. + closedAt: date(payload.mergedAt) ?? date(payload.closedAt), + forge: .gitLab, + blockers: state == "OPEN" ? MergeBlocker(detailedMergeStatus: detailed).map { [$0] } ?? [] : [] + ) + } + + static func listing(_ payload: MergeRequestPayload) -> PullRequestListing { + let isFork = payload.sourceProjectId != nil && payload.sourceProjectId != payload.targetProjectId + let author = payload.author?.username ?? "" + return PullRequestListing( + number: payload.iid ?? 0, + title: payload.title ?? "", + author: author, + headRefName: payload.sourceBranch ?? "", + baseRefName: payload.targetBranch ?? "", + isDraft: payload.draft ?? false, + state: state(payload.state), + isCrossRepository: isFork, + // Usually the fork's namespace; only the local branch name uses it. + headRepositoryOwner: isFork ? author : nil + ) + } + + static func state(_ value: String?) -> String { + switch value { + case "opened": "OPEN" + case "merged": "MERGED" + case "closed", "locked": "CLOSED" + default: value?.uppercased() ?? "UNKNOWN" + } + } + + private static func reviewDecision(_ detailed: String) -> String? { + switch detailed { + case "not_approved": "REVIEW_REQUIRED" + case "requested_changes": "CHANGES_REQUESTED" + default: nil + } + } + + private static func checkRun(_ job: JobPayload) -> CheckRun { + let (status, conclusion) = outcome(job.status) + return CheckRun( + name: job.name ?? "Job", + status: status, + conclusion: conclusion, + detailsURL: job.webUrl, + startedAt: date(job.startedAt), + completedAt: date(job.finishedAt), + workflowName: job.stage, + isRequired: !(job.allowFailure ?? false) + ) + } + + private static func outcome(_ status: String?) -> (status: String, conclusion: String?) { + switch status { + case "success": ("COMPLETED", "SUCCESS") + case "failed": ("COMPLETED", "FAILURE") + case "canceled", "canceling": ("COMPLETED", "CANCELLED") + case "skipped", "manual": ("COMPLETED", "SKIPPED") + case "running": ("IN_PROGRESS", nil) + default: ("QUEUED", nil) + } + } + + private static func date(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(value)) + ?? (try? Date.ISO8601FormatStyle(includingFractionalSeconds: false).parse(value)) + } + + private static func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + return try decoder.decode(type, from: data) + } catch { + let raw = String(decoding: data.suffix(1_024), as: UTF8.self) + throw GitHubError("Could not decode GitLab JSON: \(error). Raw JSON tail: \(raw)") + } + } +} diff --git a/Sources/BloomCore/GitLab/GitLabProject.swift b/Sources/BloomCore/GitLab/GitLabProject.swift new file mode 100644 index 000000000..882e5d646 --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabProject.swift @@ -0,0 +1,31 @@ +import Foundation + +/// A GitLab project from a remote: the host and the whole path, since groups nest. +struct GitLabProject: Sendable, Hashable { + let host: String + let path: String + + init?(remote: String?) { + guard let remote, let host = GitHub.repositoryHost(remote) else { return nil } + let rawPath: String + if let url = URL(string: remote), url.host != nil { + rawPath = url.path + } else if let colon = remote.firstIndex(of: ":") { + rawPath = String(remote[remote.index(after: colon)...]) + } else { return nil } + + var segments = rawPath.split(separator: "/").map(String.init) + if let last = segments.last, last.hasSuffix(".git") { segments[segments.count - 1] = String(last.dropLast(4)) } + guard segments.count >= 2, segments.allSatisfy({ !$0.isEmpty && !$0.hasPrefix("-") }) else { return nil } + self.host = host + self.path = segments.joined(separator: "/") + } + + /// The project as the REST API takes it in a path: `group%2Fsubgroup%2Fapp`. + var apiID: String { + path.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))) ?? path + } + + /// What `glab -R` is given, so glab never has to guess the project from the remotes. + var webURL: String { "https://\(host)/\(path)" } +} diff --git a/Sources/BloomCore/Model/Models.swift b/Sources/BloomCore/Model/Models.swift index bce6939d8..c7d1ce1aa 100644 --- a/Sources/BloomCore/Model/Models.swift +++ b/Sources/BloomCore/Model/Models.swift @@ -671,6 +671,10 @@ public struct PullRequest: Sendable, Hashable, Codable { /// A pull request that ended before the workspace asking about it was created belongs to an /// earlier life of that name. See `PullRequestOwnership`. public var closedAt: Date? + /// Which server answered. Every value built before there was a second forge is GitHub's. + public var forge: Forge + /// Why the server will refuse a merge that nothing else here explains. Always empty on GitHub. + public var blockers: [MergeBlocker] public init( number: Int, @@ -685,7 +689,9 @@ public struct PullRequest: Sendable, Hashable, Codable { branch: String = "", baseBranch: String? = nil, headRevision: String? = nil, - closedAt: Date? = nil + closedAt: Date? = nil, + forge: Forge = .gitHub, + blockers: [MergeBlocker] = [] ) { self.number = number self.title = title @@ -700,6 +706,8 @@ public struct PullRequest: Sendable, Hashable, Codable { self.baseBranch = baseBranch self.headRevision = headRevision self.closedAt = closedAt + self.forge = forge + self.blockers = blockers } } diff --git a/Sources/BloomCore/Model/WorkspaceStatus.swift b/Sources/BloomCore/Model/WorkspaceStatus.swift index ff0c66416..f157cddb7 100644 --- a/Sources/BloomCore/Model/WorkspaceStatus.swift +++ b/Sources/BloomCore/Model/WorkspaceStatus.swift @@ -186,7 +186,8 @@ public enum WorkspaceStatus: String, Sendable, Hashable, CaseIterable, Codable { } guard describesPullRequest, let pullRequest else { return label } - var text = "\(label), pull request #\(pullRequest.number)" + let forge = pullRequest.forge + var text = "\(label), \(forge.request) \(forge.reference(pullRequest.number))" if let detail = detail(pullRequest: pullRequest) { text += ": \(detail)" } return text } diff --git a/Sources/BloomCore/Persistence/Settings.swift b/Sources/BloomCore/Persistence/Settings.swift index 4cef0b6ff..2b6da2bc0 100644 --- a/Sources/BloomCore/Persistence/Settings.swift +++ b/Sources/BloomCore/Persistence/Settings.swift @@ -100,6 +100,8 @@ public struct RepoSettings: Sendable, Hashable { /// `http://localhost:$BLOOM_PORT/admin`. A workspace whose setup script wrote an address of /// its own beats this. See `WorkspaceBrowserURL`, which holds both and the order between them. public var browserURL: String? + /// `git.forge`, overruling detection. See `ForgeRouting`. + public var forge: Forge? /// Set by a file inside the repository. Ranks ABOVE the app-level defaults, because pinning /// a model in a project's own settings is a deliberate statement about that project. public var defaultModel: String? @@ -323,6 +325,9 @@ public enum SettingsLoader { break } } + if let forge = toml["git.forge"]?.stringValue.flatMap(Forge.init(rawValue:)) { + settings.forge = forge + } if let delete = toml["git.delete_branch_on_archive"]?.boolValue { settings.deleteBranchOnArchive = delete note(.deleteBranchOnArchive) diff --git a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift index 149e6cf66..4386f3003 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift @@ -180,7 +180,7 @@ public struct WorkspaceCheckoutOptions: Sendable { } let forge = await ForgeResolver.client(for: repoPath) - let access = await forge.access() + let access = await forge.access(in: repoPath) guard access == .ready else { return options(access: access) } do { diff --git a/Tests/BloomCoreTests/ForgeClientTests.swift b/Tests/BloomCoreTests/ForgeClientTests.swift index f92f2ec3b..5cdecdc62 100644 --- a/Tests/BloomCoreTests/ForgeClientTests.swift +++ b/Tests/BloomCoreTests/ForgeClientTests.swift @@ -4,12 +4,25 @@ import Testing @Suite("Forge client", .scratchDirectory) struct ForgeClientTests { - @Test("every repository is asked through GitHub") + @Test("a directory that is not a repository is asked through GitHub") func resolvesGitHub() async { let client = await ForgeResolver.client(for: TestScratch.unique("forge")) #expect(ObjectIdentifier(client) == ObjectIdentifier(GitHubForge.self)) } + @Test("a GitLab job's page is never handed to gh") + func gitLabJobOnGitHub() async { + let target = CheckFailureHandoff.LogTarget(runID: "1", jobID: "1", project: "group/app") + await #expect(throws: GitHubError.self) { + try await GitHub.$commandOverride.withValue({ _, _ in + Issue.record("gh should not be asked for a GitLab job") + return ShellResult(status: 0, stdout: "log", stderr: "") + }) { + try await GitHubForge.checkRunLog(target, worktree: TestScratch.unique("forge-log")) + } + } + } + @Test("the GitHub client runs the gh command GitHub runs") func forwards() async throws { let worktree = TestScratch.unique("forge-forward") diff --git a/Tests/BloomCoreTests/ForgeRoutingTests.swift b/Tests/BloomCoreTests/ForgeRoutingTests.swift new file mode 100644 index 000000000..bbc5f9327 --- /dev/null +++ b/Tests/BloomCoreTests/ForgeRoutingTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Forge routing", .scratchDirectory) +struct ForgeRoutingTests { + private static let cases: [(declared: Forge?, base: String?, remotes: [String], decision: ForgeRouting.Decision)] = [ + (nil, "git@github.com:acme/app.git", [], .forge(.gitHub)), + (nil, "git@gitlab.com:group/app.git", ["git@github.com:acme/app.git"], .forge(.gitHub)), + (nil, "git@gitlab.com:group/sub/app.git", [], .forge(.gitLab)), + (nil, "https://gitlab.example.com/group/app.git", [], .askGlab(host: "gitlab.example.com")), + (nil, "git@ghe.corp.example:acme/app.git", [], .askGlab(host: "ghe.corp.example")), + (nil, "git@github-work:acme/app.git", [], .forge(.gitHub)), + (nil, "https://github.corp.example/acme/app.git", [], .forge(.gitHub)), + (nil, nil, [], .forge(.gitHub)), + (.gitLab, "git@github.com:acme/app.git", [], .forge(.gitLab)), + (.gitHub, "git@gitlab.com:group/app.git", [], .forge(.gitHub)), + ] + + @Test("GitLab only on positive evidence, GitHub everywhere else", arguments: cases) + func decide(declared: Forge?, base: String?, remotes: [String], decision: ForgeRouting.Decision) { + #expect(ForgeRouting.decide(declared: declared, baseRemoteURL: base, remoteURLs: remotes) == decision) + } + + @Test("a host glab is not signed in to stays on GitHub") + func unsignedHost() async { + let host = "gitlab-\(UUID().uuidString).example" + let signedIn = await GitLab.$commandOverride.withValue({ _, _ in + ShellResult(status: 1, stdout: "", stderr: "not logged in") + }) { + await GlabSignIn.shared.isSignedIn(to: host) + } + #expect(signedIn == false) + } + + @Test("a host glab is signed in to is GitLab's") + func signedHost() async { + let host = "gitlab-\(UUID().uuidString).example" + let signedIn = await GitLab.$commandOverride.withValue({ arguments, _ in + ShellResult(status: arguments == ["auth", "status", "--hostname", host] ? 0 : 1, stdout: "", stderr: "") + }) { + await GlabSignIn.shared.isSignedIn(to: host) + } + #expect(signedIn) + } + + @Test("a repository resolves from its remotes, and again when they change", .tags(.git)) + func resolvesRepository() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/sub/app.git"], cwd: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + + try await Shell.check("git", ["remote", "add", "mirror", "git@github.com:acme/app.git"], cwd: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitHub) + + try repo.write(".bloom/settings.toml", "[git]\nforge = \"gitlab\"\n") + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + } + + @Test("remote addresses are read from git's config file without git") + func remoteURLs() { + let config = "[remote \"origin\"]\n\turl = git@github.com:acme/app.git\n\tpushurl = x\n[remote \"up\"]\n\turl=\"https://gitlab.com/g/a\"\n" + #expect(ForgeResolver.remoteURLs(in: config) == ["git@github.com:acme/app.git", "https://gitlab.com/g/a"]) + } + + @Test("a project's settings can name its forge") + func declared() throws { + let repo = TestScratch.unique("forge-settings") + try FileManager.default.createDirectory(atPath: "\(repo)/.bloom", withIntermediateDirectories: true) + try "[git]\nforge = \"gitlab\"\n".write(toFile: "\(repo)/.bloom/settings.toml", atomically: true, encoding: .utf8) + #expect(SettingsLoader.load(repo: repo).forge == .gitLab) + } + + @Test("a GitLab remote names its whole namespace", arguments: [ + (remote: "git@gitlab.com:group/sub/app.git", host: "gitlab.com", path: "group/sub/app", apiID: "group%2Fsub%2Fapp"), + (remote: "https://gitlab.example.com/group/app", host: "gitlab.example.com", path: "group/app", apiID: "group%2Fapp"), + (remote: "ssh://git@gitlab.example.com:2222/group/app.git", host: "gitlab.example.com", path: "group/app", apiID: "group%2Fapp"), + ]) + func project(remote: String, host: String, path: String, apiID: String) throws { + let project = try #require(GitLabProject(remote: remote)) + #expect(project.host == host) + #expect(project.path == path) + #expect(project.apiID == apiID) + #expect(project.webURL == "https://\(host)/\(path)") + } + + @Test("a remote with one segment is no project") + func noProject() { + #expect(GitLabProject(remote: "https://gitlab.com/group") == nil) + #expect(GitLabProject(remote: nil) == nil) + } +} diff --git a/Tests/BloomCoreTests/GitLabCommandTests.swift b/Tests/BloomCoreTests/GitLabCommandTests.swift new file mode 100644 index 000000000..72f287313 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabCommandTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab commands", .tags(.git), .scratchDirectory) +struct GitLabCommandTests { + @Test("a branch's merge request is found by source branch, then read with its pipeline's jobs") + func readsByBranch() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/sub/app.git"], cwd: repo.path) + let detail = try fixture("mr-not-approved.json") + let jobs = try fixture("jobs-failed-pipeline.json") + let log = GlabLog() + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + let endpoint = arguments.last ?? "" + if endpoint.contains("/jobs") { return ShellResult(status: 0, stdout: jobs, stderr: "") } + if endpoint.contains("source_branch=") { return ShellResult(status: 0, stdout: "[\(detail)]", stderr: "") } + return ShellResult(status: 0, stdout: detail, stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "feature/x", worktree: repo.path) + } + + #expect(found?.number == 4021) + #expect(found?.checks == .failing) + #expect(await log.commands == [ + ["api", "--hostname", "gitlab.com", + "projects/group%2Fsub%2Fapp/merge_requests?source_branch=feature%2Fx&state=all&order_by=created_at&sort=desc&per_page=20"], + ["api", "--hostname", "gitlab.com", "projects/group%2Fsub%2Fapp/merge_requests/4021"], + ["api", "--hostname", "gitlab.com", "projects/34675721/pipelines/2923303483/jobs?per_page=100"], + ]) + } + + @Test("a fork's merge request on the same branch name is not this branch's") + func forkIsNotOurs() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:gitlab-org/cli.git"], cwd: repo.path) + let fork = try fixture("mr-draft-conflicting-fork.json") + let log = GlabLog() + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return ShellResult(status: 0, stdout: "[\(fork)]", stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "8591-iteration-list-total", worktree: repo.path) + } + #expect(found == nil) + #expect(await log.commands.count == 1) + } + + @Test("a pipeline whose jobs cannot be read leaves the checks unknown, not the merge request") + func unreadableJobs() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:gitlab-org/cli.git"], cwd: repo.path) + let detail = try fixture("mr-not-approved.json") + + let found = try await GitLab.$commandOverride.withValue({ arguments, _ in + let endpoint = arguments.last ?? "" + if endpoint.contains("/jobs") { return ShellResult(status: 1, stdout: "", stderr: "403 Forbidden") } + if endpoint.contains("source_branch=") { return ShellResult(status: 0, stdout: "[\(detail)]", stderr: "") } + return ShellResult(status: 0, stdout: detail, stderr: "") + }) { + try await GitLab.pullRequest(forBranch: "feature", worktree: repo.path) + } + #expect(found?.number == 4021) + #expect(found?.checks == .unavailable) + } + + @Test("marking ready and checking out name the project rather than letting glab guess") + func actionsNameTheProject() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "https://gitlab.example.com/group/app.git"], cwd: repo.path) + let draft = PullRequest(number: 7, title: "", url: "", state: "OPEN", isDraft: true, forge: .gitLab) + let log = GlabLog() + + try await GitLab.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return ShellResult(status: 0, stdout: "", stderr: "") + }) { + try await GitLab.markReadyForReview(draft, worktree: repo.path) + try await GitLab.checkoutPullRequest(number: 7, into: repo.path, localBranch: "review/7") + } + + #expect(await log.commands == [ + ["mr", "update", "7", "--ready", "-R", "https://gitlab.example.com/group/app"], + ["mr", "checkout", "7", "--branch", "review/7", "-R", "https://gitlab.example.com/group/app"], + ]) + } + + private func fixture(_ name: String) throws -> String { + let url = try #require(bloomFixtureURL("gitlab/\(name)")) + return try String(contentsOf: url, encoding: .utf8) + } +} + +private actor GlabLog { + private(set) var commands: [[String]] = [] + + func append(_ arguments: [String]) { + commands.append(arguments) + } +} diff --git a/Tests/BloomCoreTests/GitLabDecodingTests.swift b/Tests/BloomCoreTests/GitLabDecodingTests.swift new file mode 100644 index 000000000..61d6e031a --- /dev/null +++ b/Tests/BloomCoreTests/GitLabDecodingTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Against real REST answers from gitlab.com (`gitlab-org/cli`, 2026-10-08), trimmed to the +/// fields Bloom reads and with authors anonymised. See `Tests/fixtures/gitlab`. +@Suite("GitLab decoding") +struct GitLabDecodingTests { + @Test("an open merge request waiting for approval, with a failed pipeline") + func waitingForApproval() throws { + let pullRequest = try decode("mr-not-approved.json", jobs: "jobs-failed-pipeline.json") + #expect(pullRequest.number == 4021) + #expect(pullRequest.state == "OPEN") + #expect(pullRequest.forge == .gitLab) + #expect(pullRequest.branch == "phikai/8524-validate-api-repo-urls") + #expect(pullRequest.baseBranch == "main") + #expect(pullRequest.headRevision == "d7be98ed462c3011d7fd47a92f0dd1257ec6fdbb") + #expect(pullRequest.reviewDecision == "REVIEW_REQUIRED") + #expect(pullRequest.blockers == [.approvalRequired]) + #expect(pullRequest.checks == .failing) + #expect(pullRequest.checksSummary == "1 required check failed") + #expect(pullRequest.status.text == "Waiting for approval") + #expect(pullRequest.status.canMerge == false) + #expect(pullRequest.status.blockedReason == "This merge request needs an approval first.") + } + + @Test("a conflict outranks the draft, as it does on GitHub") + func draftAndConflicting() throws { + let pullRequest = try decode("mr-draft-conflicting-fork.json") + #expect(pullRequest.isDraft) + #expect(pullRequest.hasConflicts) + #expect(pullRequest.blockers.isEmpty) + #expect(pullRequest.status.text == "Merge conflicts") + } + + @Test("a merged merge request ended when it merged") + func merged() throws { + let pullRequest = try decode("mr-merged.json") + #expect(pullRequest.isMerged) + #expect(pullRequest.closedAt != nil) + #expect(pullRequest.blockers.isEmpty) + #expect(pullRequest.status.blockedReason == "This merge request is already merged.") + #expect(WorkspaceStatus.merged.summary(pullRequest: pullRequest) == "Merged, merge request !4015: No checks") + } + + @Test("unresolved threads block the merge") + func unresolvedThreads() throws { + let pullRequest = try decode("mr-unresolved-discussions.json") + #expect(pullRequest.blockers == [.unresolvedDiscussions]) + #expect(pullRequest.status.text == "Unresolved threads") + } + + @Test("jobs become check runs, required unless allowed to fail") + func jobs() throws { + let runs = try GitLabDecoding.jobs(from: fixture("jobs-failed-pipeline.json")) + #expect(runs.count == 18) + let failed = try #require(runs.first { $0.name == "tests:unit" }) + #expect(failed.status == "COMPLETED") + #expect(failed.conclusion == "FAILURE") + #expect(failed.isRequired) + #expect(failed.workflowName == "test") + let manual = try #require(runs.first { $0.name == "review-docs-deploy" }) + #expect(manual.conclusion == "SKIPPED") + #expect(manual.isRequired == false) + } + + @Test("a merge request from a fork is listed as one") + func listing() throws { + let payloads = try GitLabDecoding.mergeRequests(from: fixture("mr-list-open.json")) + let first = GitLabDecoding.listing(try #require(payloads.first)) + #expect(first.number == 4038) + #expect(first.headRefName == "anishisbusy/repo-create-topics") + #expect(first.baseRefName == "main") + #expect(first.isCrossRepository) + #expect(first.headRepositoryOwner == "contributor-1") + #expect(first.state == "OPEN") + } + + private static let statuses: [(status: String, blocker: MergeBlocker?)] = [ + (status: "mergeable", blocker: nil), + (status: "conflict", blocker: nil), + (status: "draft_status", blocker: nil), + (status: "not_open", blocker: nil), + (status: "not_approved", blocker: .approvalRequired), + (status: "requested_changes", blocker: .changesRequested), + (status: "need_rebase", blocker: .needsRebase), + (status: "ci_must_pass", blocker: .pipelineMustPass), + (status: "ci_still_running", blocker: .pipelineRunning), + (status: "merge_request_blocked", blocker: .blockedByMergeRequest), + (status: "merge_time", blocker: .notBefore), + (status: "unchecked", blocker: .checking), + (status: "jira_association_missing", blocker: .policy("jira_association_missing")), + (status: "something_new", blocker: .policy("something_new")), + ] + + @Test("every detailed merge status has a meaning, and an unknown one refuses", arguments: statuses) + func blockers(status: String, blocker: MergeBlocker?) { + #expect(MergeBlocker(detailedMergeStatus: status) == blocker) + } + + @Test("a GitHub pull request never carries a blocker") + func gitHubHasNone() throws { + let pullRequest = try GitHub.decodePullRequest(from: Data( + #"{"number":1,"title":"PR","url":"https://github.com/a/b/pull/1","state":"OPEN","mergeStateStatus":"BLOCKED"}"#.utf8 + )) + #expect(pullRequest.forge == .gitHub) + #expect(pullRequest.blockers.isEmpty) + } + + @Test("a GitLab number is never written into the GitHub column") + func numberNotRecorded() { + let gitLab = PullRequest(number: 12, title: "", url: "", state: "OPEN", forge: .gitLab) + let gitHub = PullRequest(number: 12, title: "", url: "", state: "OPEN") + #expect(PullRequestNumber.toRecord(found: gitLab, recorded: nil) == nil) + #expect(PullRequestNumber.toRecord(found: gitHub, recorded: nil) == 12) + } + + @Test("a GitLab job page names its job and the project it ran in") + func jobLogTarget() { + let target = CheckFailureHandoff.logTarget(detailsURL: "https://gitlab.com/group/sub/app/-/jobs/17015172335") + #expect(target == CheckFailureHandoff.LogTarget( + runID: "17015172335", jobID: "17015172335", project: "group/sub/app" + )) + #expect(CheckFailureHandoff.logTarget(detailsURL: "https://gitlab.com/group/app/-/pipelines/1") == nil) + } + + private func decode(_ name: String, jobs: String? = nil) throws -> PullRequest { + let payload = try GitLabDecoding.mergeRequest(from: fixture(name)) + let runs = try jobs.map { try GitLabDecoding.jobs(from: fixture($0)) } ?? [] + return GitLabDecoding.pullRequest(payload, runs: runs) + } + + private func fixture(_ name: String) throws -> Data { + let url = try #require(bloomFixtureURL("gitlab/\(name)")) + return try Data(contentsOf: url) + } +} diff --git a/Tests/fixtures/gitlab/jobs-failed-pipeline.json b/Tests/fixtures/gitlab/jobs-failed-pipeline.json new file mode 100644 index 000000000..7339f456c --- /dev/null +++ b/Tests/fixtures/gitlab/jobs-failed-pipeline.json @@ -0,0 +1,200 @@ +[ + { + "id": 17015172335, + "name": "lint_commit", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015172335", + "created_at": "2026-10-07T22:35:58.281Z", + "started_at": "2026-10-07T22:35:58.546Z", + "finished_at": "2026-10-07T22:36:24.924Z" + }, + { + "id": 17015105576, + "name": "review-docs-cleanup", + "stage": "review", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105576", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105575, + "name": "review-docs-deploy", + "stage": "review", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105575", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105574, + "name": "tests:coverage", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105574", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:35:49.786Z", + "finished_at": "2026-10-07T22:36:28.692Z" + }, + { + "id": 17015105573, + "name": "tests:integration", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105573", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.278Z", + "finished_at": "2026-10-07T22:33:47.991Z" + }, + { + "id": 17015105572, + "name": "tests:unit", + "stage": "test", + "status": "failed", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105572", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.178Z", + "finished_at": "2026-10-07T22:35:47.931Z" + }, + { + "id": 17015105571, + "name": "lint", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105571", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.151Z", + "finished_at": "2026-10-07T22:38:57.151Z" + }, + { + "id": 17015105570, + "name": "lint:comments", + "stage": "test", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105570", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:37.081Z", + "finished_at": "2026-10-07T22:32:05.621Z" + }, + { + "id": 17015105569, + "name": "docs hugo_build", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105569", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.971Z", + "finished_at": "2026-10-07T22:32:04.282Z" + }, + { + "id": 17015105568, + "name": "check_docs_markdown", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105568", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.952Z", + "finished_at": "2026-10-07T22:31:55.658Z" + }, + { + "id": 17015105567, + "name": "check_docs_update", + "stage": "documentation", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105567", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.905Z", + "finished_at": "2026-10-07T22:33:35.901Z" + }, + { + "id": 17015105566, + "name": "check_args", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105566", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.837Z", + "finished_at": "2026-10-07T22:31:48.038Z" + }, + { + "id": 17015105565, + "name": "check_embed", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105565", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.752Z", + "finished_at": "2026-10-07T22:31:48.027Z" + }, + { + "id": 17015105563, + "name": "check_go_generated_code", + "stage": "checks", + "status": "success", + "allow_failure": false, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105563", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.719Z", + "finished_at": "2026-10-07T22:32:31.164Z" + }, + { + "id": 17015105562, + "name": "dependency-scanning-profile-0", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105562", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.650Z", + "finished_at": "2026-10-07T22:31:55.534Z" + }, + { + "id": 17015105561, + "name": "govulncheck", + "stage": "application-security-testing", + "status": "manual", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105561", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": null, + "finished_at": null + }, + { + "id": 17015105560, + "name": "secret_detection", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105560", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.466Z", + "finished_at": "2026-10-07T22:31:51.248Z" + }, + { + "id": 17015105559, + "name": "gitlab-advanced-sast", + "stage": "application-security-testing", + "status": "success", + "allow_failure": true, + "web_url": "https://gitlab.com/gitlab-org/cli/-/jobs/17015105559", + "created_at": "2026-10-07T22:31:35.849Z", + "started_at": "2026-10-07T22:31:36.476Z", + "finished_at": "2026-10-07T22:33:35.126Z" + } +] diff --git a/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json b/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json new file mode 100644 index 000000000..57fec2c03 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-draft-conflicting-fork.json @@ -0,0 +1,31 @@ +{ + "iid": 4020, + "title": "Draft: fix(iteration): list header shows the total count, not the page count", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4020", + "state": "opened", + "draft": true, + "has_conflicts": true, + "detailed_merge_status": "draft_status", + "merge_status": "cannot_be_merged", + "source_branch": "8591-iteration-list-total", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "f144edffb4240d739d84a54a66bfb78d0cfa54c6", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923072371, + "iid": 1567, + "project_id": 45049979, + "status": "success", + "sha": "f144edffb4240d739d84a54a66bfb78d0cfa54c6", + "ref": "8591-iteration-list-total", + "web_url": "https://gitlab.com/gitlab-community/gitlab-org/cli/-/pipelines/2923072371" + }, + "references": { + "short": "!4020", + "relative": "!4020", + "full": "gitlab-org/cli!4020" + } +} diff --git a/Tests/fixtures/gitlab/mr-list-open.json b/Tests/fixtures/gitlab/mr-list-open.json new file mode 100644 index 000000000..57faf0a74 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-list-open.json @@ -0,0 +1,87 @@ +[ + { + "iid": 4038, + "title": "fix(repo): send topics instead of deprecated tag_list", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4038", + "state": "opened", + "draft": false, + "source_branch": "anishisbusy/repo-create-topics", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "discussions_not_resolved", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-1" + } + }, + { + "iid": 4037, + "title": "fix(repo): correct default visibility in repo create help", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4037", + "state": "opened", + "draft": false, + "source_branch": "anishisbusy/repo-create-visibility-help", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "discussions_not_resolved", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-2" + } + }, + { + "iid": 4035, + "title": "Draft: refactor(issue): reuse cmdutils issue URL parsing", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4035", + "state": "opened", + "draft": true, + "source_branch": "8398-unify-issue-url-parsing", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-3" + } + }, + { + "iid": 4034, + "title": "Draft: feat(ssh-key): add --replace flag to ssh-key add", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4034", + "state": "opened", + "draft": true, + "source_branch": "8577-ssh-key-add-replace", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-4" + } + }, + { + "iid": 4033, + "title": "Draft: fix(stack): track the current stack per worktree", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4033", + "state": "opened", + "draft": true, + "source_branch": "8329-isolate-current-stack-per-worktree", + "target_branch": "main", + "source_project_id": 45049979, + "target_project_id": 34675721, + "detailed_merge_status": "draft_status", + "merged_at": null, + "closed_at": null, + "author": { + "username": "contributor-5" + } + } +] diff --git a/Tests/fixtures/gitlab/mr-merged.json b/Tests/fixtures/gitlab/mr-merged.json new file mode 100644 index 000000000..bb0ead3cb --- /dev/null +++ b/Tests/fixtures/gitlab/mr-merged.json @@ -0,0 +1,31 @@ +{ + "iid": 4015, + "title": "fix(iteration): show the real total in list header", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4015", + "state": "merged", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "not_open", + "merge_status": "can_be_merged", + "source_branch": "unstable-code/fix-iteration-list-total", + "target_branch": "main", + "merged_at": "2026-10-07T16:41:00.770Z", + "closed_at": null, + "sha": "a4f0330b1b8cfb0a3392696524005025d3342099", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923247089, + "iid": 14865, + "project_id": 34675721, + "status": "success", + "sha": "d5f45439bd471880c6bfe4dd391a24ea2bbcf777", + "ref": "refs/merge-requests/4015/train", + "web_url": "https://gitlab.com/gitlab-org/cli/-/pipelines/2923247089" + }, + "references": { + "short": "!4015", + "relative": "!4015", + "full": "gitlab-org/cli!4015" + } +} diff --git a/Tests/fixtures/gitlab/mr-not-approved.json b/Tests/fixtures/gitlab/mr-not-approved.json new file mode 100644 index 000000000..f6e34f296 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-not-approved.json @@ -0,0 +1,31 @@ +{ + "iid": 4021, + "title": "fix(git): validate API-supplied repository URLs before running git", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4021", + "state": "opened", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "not_approved", + "merge_status": "can_be_merged", + "source_branch": "phikai/8524-validate-api-repo-urls", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "d7be98ed462c3011d7fd47a92f0dd1257ec6fdbb", + "source_project_id": 34675721, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2923303483, + "iid": 14872, + "project_id": 34675721, + "status": "success", + "sha": "bd1f12c63234365990db79745a8ba4022fb306ea", + "ref": "refs/merge-requests/4021/merge", + "web_url": "https://gitlab.com/gitlab-org/cli/-/pipelines/2923303483" + }, + "references": { + "short": "!4021", + "relative": "!4021", + "full": "gitlab-org/cli!4021" + } +} diff --git a/Tests/fixtures/gitlab/mr-unresolved-discussions.json b/Tests/fixtures/gitlab/mr-unresolved-discussions.json new file mode 100644 index 000000000..495e0f285 --- /dev/null +++ b/Tests/fixtures/gitlab/mr-unresolved-discussions.json @@ -0,0 +1,31 @@ +{ + "iid": 4038, + "title": "fix(repo): send topics instead of deprecated tag_list", + "web_url": "https://gitlab.com/gitlab-org/cli/-/merge_requests/4038", + "state": "opened", + "draft": false, + "has_conflicts": false, + "detailed_merge_status": "discussions_not_resolved", + "merge_status": "can_be_merged", + "source_branch": "anishisbusy/repo-create-topics", + "target_branch": "main", + "merged_at": null, + "closed_at": null, + "sha": "b4feaccdd217d199ed18d15d3acd4447bbf677b3", + "source_project_id": 45049979, + "target_project_id": 34675721, + "head_pipeline": { + "id": 2924840836, + "iid": 1584, + "project_id": 45049979, + "status": "success", + "sha": "b4feaccdd217d199ed18d15d3acd4447bbf677b3", + "ref": "anishisbusy/repo-create-topics", + "web_url": "https://gitlab.com/gitlab-community/gitlab-org/cli/-/pipelines/2924840836" + }, + "references": { + "short": "!4038", + "relative": "!4038", + "full": "gitlab-org/cli!4038" + } +} From ba8d3eb17e1ca07ba53c3fd3e84205b08a13a9c9 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 04/16] Create, merge and fix merge requests through the agent on GitLab --- Sources/Bloom/State/AppModel+Workspaces.swift | 2 +- Sources/Bloom/State/Bridges.swift | 10 +- .../State/WorkspaceModel+PullRequest.swift | 6 + Sources/Bloom/State/WorkspaceModel.swift | 41 ++-- .../CreateWorkspace/CreateWorkspaceView.swift | 3 +- .../Inspector/BranchActionsPopover.swift | 18 +- .../Bloom/Views/Inspector/ChecksView.swift | 6 +- .../Inspector/MergeConfirmationPopover.swift | 13 +- .../Views/Inspector/PullRequestBadge.swift | 7 +- .../Views/Inspector/PullRequestBar.swift | 22 +- .../Views/Inspector/PullRequestCreator.swift | 7 +- .../Views/Inspector/PullRequestSummary.swift | 9 +- .../BloomCore/GitHub/DirectMergeCheck.swift | 56 +++++ .../GitHub/DirectPullRequestCreation.swift | 13 +- Sources/BloomCore/GitHub/Forge.swift | 26 +++ Sources/BloomCore/GitHub/ForgeClient.swift | 25 +++ Sources/BloomCore/GitHub/ForgeRouting.swift | 6 +- Sources/BloomCore/GitHub/GitHub.swift | 45 +--- .../GitHub/MergeExecutionChoice.swift | 6 +- Sources/BloomCore/GitHub/MergePrompt.swift | 7 +- .../GitHub/ProjectInstructions.swift | 10 +- .../GitHub/PullRequestInstructions.swift | 13 +- .../GitHub/SquashCommitMessage.swift | 17 +- Sources/BloomCore/GitLab/GitLab.swift | 124 +++++++++++ Sources/BloomCore/GitLab/GitLabDecoding.swift | 14 ++ .../BloomCore/GitLab/GitLabInstructions.swift | 80 +++++++ .../Transcript/PromptOverrides.swift | 15 ++ Sources/BloomCore/Transcript/SentTurn.swift | 8 + .../Workspace/WorkspaceCheckout.swift | 30 ++- .../BloomCore/Workspace/WorkspaceSource.swift | 8 +- .../Workspace/WorkspaceStartContext.swift | 9 +- .../GitHubStoredValueTests.swift | 2 +- Tests/BloomCoreTests/GitLabDirectTests.swift | 197 ++++++++++++++++++ .../GitLabInstructionsTests.swift | 109 ++++++++++ .../SquashCommitMessageTests.swift | 13 ++ .../gitlab-conflict-instructions.md | 39 ++++ .../gitlab-merge-instructions.md | 29 +++ .../gitlab-mr-instructions.md | 24 +++ .../gitlab-prompt-continueAfterMerge.txt | 5 + .../gitlab-prompt-createPullRequest.txt | 1 + .../gitlab-prompt-fixConflicts.txt | 3 + .../gitlab-prompt-mergePullRequest.txt | 1 + .../gitlab-prompt-pushLocalWork.txt | 1 + 43 files changed, 950 insertions(+), 130 deletions(-) create mode 100644 Sources/BloomCore/GitHub/DirectMergeCheck.swift create mode 100644 Sources/BloomCore/GitLab/GitLabInstructions.swift create mode 100644 Tests/BloomCoreTests/GitLabDirectTests.swift create mode 100644 Tests/BloomCoreTests/GitLabInstructionsTests.swift create mode 100644 Tests/fixtures/characterisation/gitlab-conflict-instructions.md create mode 100644 Tests/fixtures/characterisation/gitlab-merge-instructions.md create mode 100644 Tests/fixtures/characterisation/gitlab-mr-instructions.md create mode 100644 Tests/fixtures/characterisation/gitlab-prompt-continueAfterMerge.txt create mode 100644 Tests/fixtures/characterisation/gitlab-prompt-createPullRequest.txt create mode 100644 Tests/fixtures/characterisation/gitlab-prompt-fixConflicts.txt create mode 100644 Tests/fixtures/characterisation/gitlab-prompt-mergePullRequest.txt create mode 100644 Tests/fixtures/characterisation/gitlab-prompt-pushLocalWork.txt diff --git a/Sources/Bloom/State/AppModel+Workspaces.swift b/Sources/Bloom/State/AppModel+Workspaces.swift index 29ed40d80..564b67cdd 100644 --- a/Sources/Bloom/State/AppModel+Workspaces.swift +++ b/Sources/Bloom/State/AppModel+Workspaces.swift @@ -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 } diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index fe385ff0a..9f0dc5e8c 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -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 { @@ -52,7 +54,7 @@ enum GitHubBridge { _ pullRequest: PullRequest, base: String, method: GitHub.MergeMethod, squashMessage: SquashCommitMessage?, worktree: String ) async throws -> DirectMergeOutcome { - try await GitHub.mergePullRequest( + try await ForgeResolver.client(of: pullRequest.forge).mergePullRequest( pullRequest, into: base, method: method, squashMessage: squashMessage, worktree: worktree ) } diff --git a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift index 6f7a28318..a029eb477 100644 --- a/Sources/Bloom/State/WorkspaceModel+PullRequest.swift +++ b/Sources/Bloom/State/WorkspaceModel+PullRequest.swift @@ -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) } diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index edcfa41da..cc81617b8 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -2523,7 +2523,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 @@ -2552,7 +2553,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 } @@ -2579,7 +2580,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) { @@ -2643,12 +2644,15 @@ 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 asked = squashMessage?.instruction(proposed: .proposed(for: pullRequest)) - let text = await turn([render.text, asked].compactMap { $0 }.joined(separator: "\n\n"), for: .merge) + let asked = squashMessage?.instruction(proposed: .proposed(for: pullRequest), forge: pullRequest.forge) + let text = await turn( + [render.text, asked].compactMap { $0 }.joined(separator: "\n\n"), for: .merge, forge: pullRequest.forge + ) activeSessionID = session.id await transcript(for: session).submit(text) return nil @@ -2676,7 +2680,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 @@ -2685,7 +2691,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. @@ -2722,15 +2728,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 @@ -2745,11 +2753,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 diff --git a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift index 7693c4987..ada71edc3 100644 --- a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift +++ b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift @@ -927,7 +927,8 @@ struct CreateWorkspaceView: View { pullRequests: checkoutOptions.pullRequests, branches: checkoutOptions.branches, baseBranches: branchOptions, - defaultBranch: repo?.defaultBranch ?? "" + defaultBranch: repo?.defaultBranch ?? "", + forge: checkoutOptions.forge ) } diff --git a/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift b/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift index a53d4f5f7..456dbe3c7 100644 --- a/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift +++ b/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift @@ -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" } } } @@ -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: { @@ -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) } } diff --git a/Sources/Bloom/Views/Inspector/ChecksView.swift b/Sources/Bloom/Views/Inspector/ChecksView.swift index 3db9942ad..4e020d733 100644 --- a/Sources/Bloom/Views/Inspector/ChecksView.swift +++ b/Sources/Bloom/Views/Inspector/ChecksView.swift @@ -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)") } } @@ -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 { diff --git a/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift b/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift index 01776c2d4..ff9df893a 100644 --- a/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift +++ b/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift @@ -39,7 +39,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) @@ -61,11 +61,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 { diff --git a/Sources/Bloom/Views/Inspector/PullRequestBadge.swift b/Sources/Bloom/Views/Inspector/PullRequestBadge.swift index 2b9a845e1..13167a0e3 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestBadge.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestBadge.swift @@ -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 } @@ -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) @@ -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 diff --git a/Sources/Bloom/Views/Inspector/PullRequestBar.swift b/Sources/Bloom/Views/Inspector/PullRequestBar.swift index 241f76c3f..6f3f8af41 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestBar.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestBar.swift @@ -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, @@ -190,13 +191,14 @@ struct PullRequestBar: View { case .direct: do { let created = try await model.createPullRequestDirectly() + let forge = created.forge report = PaneNoticeContent( - 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 = PaneNoticeContent( - tone: .failure, title: "Could not create the pull request", + tone: .failure, title: "Could not create the \(model.forge.request)", message: String(describing: error) ) } @@ -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, squashMessage: SquashCommitMessage?) { guard let pullRequest = model.pullRequest else { return } isWorking = true @@ -344,23 +346,25 @@ struct PullRequestBar: View { let outcome = try await model.mergePullRequestDirectly( pullRequest, method: method, squashMessage: squashMessage ) + 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 = PaneNoticeContent( 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 = PaneNoticeContent( - tone: .failure, title: "Could not merge on GitHub", + tone: .failure, title: "Could not merge on \(pullRequest.forge.name)", message: String(describing: error) ) } diff --git a/Sources/Bloom/Views/Inspector/PullRequestCreator.swift b/Sources/Bloom/Views/Inspector/PullRequestCreator.swift index 0e48ff362..a6a88e7fe 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestCreator.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestCreator.swift @@ -32,6 +32,7 @@ struct PullRequestCreator: View { /// workspace with no worktree on this Mac, which is also one that is never in the dark. var worktree: String var github: GitHubAvailability.State + var forge: Forge = .gitHub /// Whether this branch has anything on it at all. A worktree identical to its base has nothing /// to open a pull request for, and Bloom knows that for free, so it says so here rather than /// spending a whole agent turn on the agent finding out. @@ -152,18 +153,18 @@ struct PullRequestCreator: View { if let note = branchActions.note { return note } if !hasChanges, !hasReadChanges { return "Comparing with \(baseBranch)…" } guard hasChanges else { return ContinuedBranch.line(on: branch, continued: continued) } - return "No pull request yet. Target \(baseBranch)." + return "No \(forge.request) yet. Target \(baseBranch)." } /// A single title-bar control. Its popover chooses how to create the PR now. private var branchActionsButton: some View { - Button("Create PR") { isShowingBranchActions = true } + Button("Create \(forge.shortRequest)") { isShowingBranchActions = true } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: Metrics.corner)) .tint(Palette.controlAccent) .controlSize(.regular) .disabled(!branchActions.isAllowed) - .help("Create a pull request for \(branch)") + .help("Create a \(forge.request) for \(branch)") .popover(isPresented: $isShowingBranchActions, arrowEdge: .top) { BranchActionsPopover(model: model, onCreatePullRequest: action) } diff --git a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift index 26d6f7907..71062d584 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift @@ -80,7 +80,7 @@ struct PullRequestSummary: View { // the part the reader is trying to read. A right click costs no width at all, and it is // where a link is asked for everywhere else on this system. .contextMenu { - Button("Open on GitHub") { GitHubBridge.open(pullRequest.url) } + Button("Open on \(pullRequest.forge.name)") { GitHubBridge.open(pullRequest.url) } Button("Copy link", action: copyLink) if let url = URL(string: pullRequest.url) { // A submenu of this menu rather than a picker that replaces it, which is where @@ -111,7 +111,8 @@ struct PullRequestSummary: View { number: pullRequest.number, title: pullRequest.title, url: pullRequest.url, - tint: tint + tint: tint, + forge: pullRequest.forge ) if pullRequest.isDraft { draftChip } } @@ -128,7 +129,7 @@ struct PullRequestSummary: View { /// is the other thing about this pull request that is simply true. private var draftChip: some View { Chip(text: "Draft", tint: Palette.textSecondary, background: Palette.hover) - .help("This pull request is still a draft, so it cannot be merged.") + .help("This \(pullRequest.forge.request) is still a draft, so it cannot be merged.") .accessibilityLabel("Draft") } @@ -448,7 +449,7 @@ struct PullRequestSummary: View { .fixedSize() .help( branchActions.reason - ?? "Mark #\(pullRequest.number) ready for review on GitHub." + ?? "Mark \(pullRequest.forge.reference(pullRequest.number)) ready for review on \(pullRequest.forge.name)." ) } diff --git a/Sources/BloomCore/GitHub/DirectMergeCheck.swift b/Sources/BloomCore/GitHub/DirectMergeCheck.swift new file mode 100644 index 000000000..c8e7279cd --- /dev/null +++ b/Sources/BloomCore/GitHub/DirectMergeCheck.swift @@ -0,0 +1,56 @@ +import Foundation + +/// What a direct merge checks on either forge before running the CLI: the request as shown, this +/// checkout at its head, and a fresh read that still says the same. GitHub's words stay as they were. +enum DirectMergeCheck { + /// The confirmed head commit and base branch, once this checkout is known to be exactly that. + static func confirmed( + _ expected: PullRequest, base: String, worktree: String + ) async throws -> (head: String, base: String) { + let forge = expected.forge + guard expected.number > 0, !expected.url.isEmpty, + let head = expected.headRevision, isCommitID(head), + let expectedBase = expected.baseBranch, !expectedBase.isEmpty else { + throw GitHubError("Refresh the \(forge.request) before merging directly.") + } + if !expected.branch.isEmpty, + try await Git.currentBranch(of: worktree) != expected.branch { + throw GitHubError("This worktree is no longer on the \(forge.request)'s branch.") + } + + let local = try await Git.localWork(worktree: worktree) + guard !local.isAhead else { + throw GitHubError("This worktree has changes or commits \(forge.name) has not got. Commit and push them, or choose the agent route after reviewing the warning.") + } + guard try await Git.headSHA(of: worktree) == head else { + throw GitHubError("This checkout is not at the \(forge.request)'s head commit. Refresh or update it before merging directly.") + } + + let context = try await Git.repositoryContext(in: worktree, baseBranch: base) + guard expectedBase == context.baseBranch else { + throw GitHubError("The \(forge.request) targets \(expectedBase), not \(context.baseBranch). Refresh it before merging.") + } + return (head, expectedBase) + } + + /// A fresh read of the same request: unchanged since it was shown, and still mergeable. + static func verify(_ current: PullRequest?, against expected: PullRequest, head: String, base: String) throws { + let forge = expected.forge + guard let current else { + throw GitHubError("\(forge.name) no longer has \(forge.request) \(forge.reference(expected.number)).") + } + guard current.url == expected.url, current.branch == expected.branch, + current.baseBranch == base, current.headRevision == head else { + throw GitHubError("The \(forge.request) changed since it was shown. Refresh it before merging.") + } + guard current.isOpen, current.status.canMerge else { + throw GitHubError(current.status.blockedReason ?? "This \(forge.request) can no longer be merged.") + } + } + + static func isCommitID(_ value: String) -> Bool { + (value.count == 40 || value.count == 64) && value.utf8.allSatisfy { + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + } + } +} diff --git a/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift b/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift index a5355e4dd..31ba86a08 100644 --- a/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift +++ b/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift @@ -1,9 +1,10 @@ import Foundation -/// Commits and publishes a workspace branch before asking GitHub to open its pull request. +/// Commits and publishes a workspace branch before asking the forge to open its pull request. /// Each step is explicit so a failure leaves the completed git work in place and reports it. public enum DirectPullRequestCreation { - public static func create(for workspace: Workspace) async throws -> PullRequest { + public static func create(for workspace: Workspace, forge: Forge = .gitHub) async throws -> PullRequest { + let client = ForgeResolver.client(of: forge) let worktree = workspace.path guard try await Git.currentBranch(of: worktree) == workspace.branch else { throw GitHubError("This worktree is no longer on \(workspace.branch).") @@ -12,9 +13,9 @@ public enum DirectPullRequestCreation { throw GitHubError("The target branch is not a valid Git branch name.") } - switch await GitHub.readPullRequest(for: workspace, maxAge: .zero) { + switch await client.readPullRequest(for: workspace, maxAge: .zero) { case .current(.some(let existing)): - throw GitHubError("Pull request #\(existing.number) already exists for this branch.") + throw GitHubError("\(forge.titledRequest) \(forge.reference(existing.number)) already exists for this branch.") case .current(nil): break case .unavailable(let failure): @@ -48,11 +49,11 @@ public enum DirectPullRequestCreation { throw GitHubError(prefix + String(describing: error)) } do { - return try await GitHub.createPullRequestFromCommits( + return try await client.createPullRequestFromCommits( worktree: worktree, base: workspace.baseBranch, title: title ) } catch { - throw GitHubError("The branch was pushed, but GitHub did not create the pull request: \(error)") + throw GitHubError("The branch was pushed, but \(forge.name) did not create the \(forge.request): \(error)") } } } diff --git a/Sources/BloomCore/GitHub/Forge.swift b/Sources/BloomCore/GitHub/Forge.swift index 9b0e43454..8a89c39d1 100644 --- a/Sources/BloomCore/GitHub/Forge.swift +++ b/Sources/BloomCore/GitHub/Forge.swift @@ -14,6 +14,32 @@ public enum Forge: String, Sendable, Hashable, Codable { } } + /// `request` at the start of a sentence. + public var titledRequest: String { + request.prefix(1).uppercased() + request.dropFirst() + } + + /// `PR` or `MR`, where a button has room for little else. + public var shortRequest: String { + switch self { + case .gitHub: "PR" + case .gitLab: "MR" + } + } + + /// The command line tool Bloom runs for this forge. + public var cli: String { + switch self { + case .gitHub: "gh" + case .gitLab: "glab" + } + } + + /// `gh pr merge`, `glab mr merge`. + public func command(_ verb: String) -> String { + "\(cli) \(self == .gitHub ? "pr" : "mr") \(verb)" + } + public var name: String { switch self { case .gitHub: "GitHub" diff --git a/Sources/BloomCore/GitHub/ForgeClient.swift b/Sources/BloomCore/GitHub/ForgeClient.swift index 79dcae209..ac30e124c 100644 --- a/Sources/BloomCore/GitHub/ForgeClient.swift +++ b/Sources/BloomCore/GitHub/ForgeClient.swift @@ -4,6 +4,7 @@ import Foundation /// callers pass, so every default stays on `GitHub` rather than being restated here. public protocol ForgeClient { static func access(in directory: String) async -> GitHubAccess + static func readPullRequest(for workspace: Workspace, maxAge: Duration) async -> PullRequestRead static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? static func checks(for workspace: Workspace) async throws -> [CheckRun]? @@ -13,6 +14,11 @@ public protocol ForgeClient { static func pullRequestSummary(number: Int, repoPath: String) async throws -> PullRequestListing static func repositorySlug(repoPath: String) async -> String? static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws + static func createPullRequestFromCommits(worktree: String, base: String, title: String) async throws -> PullRequest + static func mergePullRequest( + _ expected: PullRequest, into base: String, method: GitHub.MergeMethod, + squashMessage: SquashCommitMessage?, worktree: String + ) async throws -> DirectMergeOutcome } /// `GitHub` as a `ForgeClient`. It only forwards. @@ -22,6 +28,10 @@ public enum GitHubForge: ForgeClient { 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) } @@ -61,4 +71,19 @@ public enum GitHubForge: ForgeClient { public static func checkoutPullRequest(number: Int, into worktree: String, localBranch: String) async throws { try await GitHub.checkoutPullRequest(number: number, into: worktree, localBranch: localBranch) } + + public static func createPullRequestFromCommits( + worktree: String, base: String, title: String + ) async throws -> PullRequest { + try await GitHub.createPullRequestFromCommits(worktree: worktree, base: base, title: title) + } + + public static func mergePullRequest( + _ expected: PullRequest, into base: String, method: GitHub.MergeMethod, + squashMessage: SquashCommitMessage?, worktree: String + ) async throws -> DirectMergeOutcome { + try await GitHub.mergePullRequest( + expected, into: base, method: method, squashMessage: squashMessage, worktree: worktree + ) + } } diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift index 3a5b3c552..d08888260 100644 --- a/Sources/BloomCore/GitHub/ForgeRouting.swift +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -36,7 +36,11 @@ public enum ForgeResolver { private static let resolutions = ForgeResolutions() public static func client(for directory: String) async -> any ForgeClient.Type { - switch await forge(for: directory) { + client(of: await forge(for: directory)) + } + + public static func client(of forge: Forge) -> any ForgeClient.Type { + switch forge { case .gitHub: GitHubForge.self case .gitLab: GitLab.self } diff --git a/Sources/BloomCore/GitHub/GitHub.swift b/Sources/BloomCore/GitHub/GitHub.swift index 00d6ef0b0..3465c116b 100644 --- a/Sources/BloomCore/GitHub/GitHub.swift +++ b/Sources/BloomCore/GitHub/GitHub.swift @@ -395,40 +395,13 @@ public enum GitHub { _ expected: PullRequest, into base: String, method: MergeMethod, squashMessage: SquashCommitMessage? = nil, worktree: String ) async throws -> DirectMergeOutcome { - guard expected.number > 0, !expected.url.isEmpty, - let expectedHead = expected.headRevision, Self.isCommitID(expectedHead), - let expectedBase = expected.baseBranch, !expectedBase.isEmpty else { - throw GitHubError("Refresh the pull request before merging directly.") - } - if !expected.branch.isEmpty, - try await Git.currentBranch(of: worktree) != expected.branch { - throw GitHubError("This worktree is no longer on the pull request's branch.") - } - - let local = try await Git.localWork(worktree: worktree) - guard !local.isAhead else { - throw GitHubError("This worktree has changes or commits GitHub has not got. Commit and push them, or choose the agent route after reviewing the warning.") - } - guard try await Git.headSHA(of: worktree) == expectedHead else { - throw GitHubError("This checkout is not at the pull request's head commit. Refresh or update it before merging directly.") - } - - let context = try await Git.repositoryContext(in: worktree, baseBranch: base) - guard expectedBase == context.baseBranch else { - throw GitHubError("The pull request targets \(expectedBase), not \(context.baseBranch). Refresh it before merging.") - } - guard let current = try await snapshot( + let (expectedHead, expectedBase) = try await DirectMergeCheck.confirmed( + expected, base: base, worktree: worktree + ) + let current = try await snapshot( forNumber: expected.number, worktree: worktree, maxAge: .zero - )?.pullRequest else { - throw GitHubError("GitHub no longer has pull request #\(expected.number).") - } - guard current.url == expected.url, current.branch == expected.branch, - current.baseBranch == expectedBase, current.headRevision == expectedHead else { - throw GitHubError("The pull request changed since it was shown. Refresh it before merging.") - } - guard current.isOpen, current.status.canMerge else { - throw GitHubError(current.status.blockedReason ?? "This pull request can no longer be merged.") - } + )?.pullRequest + try DirectMergeCheck.verify(current, against: expected, head: expectedHead, base: expectedBase) let arguments = [ "pr", "merge", String(expected.number), method.flag, @@ -448,12 +421,6 @@ public enum GitHub { return after.isMerged ? .merged : .acceptedButOpen } - private static func isCommitID(_ value: String) -> Bool { - (value.count == 40 || value.count == 64) && value.utf8.allSatisfy { - (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) - } - } - /// Pushes the current HEAD to `branch` on its configured publication remote. /// /// The branch never travels as a bare argument. Git will happily create a branch called diff --git a/Sources/BloomCore/GitHub/MergeExecutionChoice.swift b/Sources/BloomCore/GitHub/MergeExecutionChoice.swift index e0a0989b0..6bcef3d5f 100644 --- a/Sources/BloomCore/GitHub/MergeExecutionChoice.swift +++ b/Sources/BloomCore/GitHub/MergeExecutionChoice.swift @@ -1,13 +1,13 @@ import Foundation -/// Whether Bloom sends a merge turn or executes the chosen gh command itself. +/// Whether Bloom sends a merge turn or executes the chosen gh or glab command itself. public enum MergeExecution: String, CaseIterable, Sendable { case direct case agent - public var label: String { + public func label(on forge: Forge) -> String { switch self { - case .direct: "Merge with GitHub CLI" + case .direct: "Merge with \(forge.name) CLI" case .agent: "Ask agent to merge" } } diff --git a/Sources/BloomCore/GitHub/MergePrompt.swift b/Sources/BloomCore/GitHub/MergePrompt.swift index e2e6efd08..b2c12d1b9 100644 --- a/Sources/BloomCore/GitHub/MergePrompt.swift +++ b/Sources/BloomCore/GitHub/MergePrompt.swift @@ -23,6 +23,7 @@ public struct MergePromptContext: Sendable, Hashable { public var branch: String public var baseBranch: String public var method: GitHub.MergeMethod + public var forge: Forge public init( workspaceName: String, @@ -30,7 +31,8 @@ public struct MergePromptContext: Sendable, Hashable { title: String, branch: String, baseBranch: String, - method: GitHub.MergeMethod + method: GitHub.MergeMethod, + forge: Forge = .gitHub ) { self.workspaceName = workspaceName self.number = number @@ -38,6 +40,7 @@ public struct MergePromptContext: Sendable, Hashable { self.branch = branch self.baseBranch = baseBranch self.method = method + self.forge = forge } public var values: [String: String] { @@ -48,7 +51,7 @@ public struct MergePromptContext: Sendable, Hashable { PromptRegistry.MergePullRequest.branch: branch.isEmpty ? Self.noBranch : branch, PromptRegistry.MergePullRequest.baseBranch: baseBranch, PromptRegistry.MergePullRequest.method: method.phrase, - PromptRegistry.MergePullRequest.methodFlag: method.flag, + PromptRegistry.MergePullRequest.methodFlag: forge == .gitLab ? GitLabInstructions.flag(for: method) : method.flag, ] } diff --git a/Sources/BloomCore/GitHub/ProjectInstructions.swift b/Sources/BloomCore/GitHub/ProjectInstructions.swift index 93710bc7f..de91766fa 100644 --- a/Sources/BloomCore/GitHub/ProjectInstructions.swift +++ b/Sources/BloomCore/GitHub/ProjectInstructions.swift @@ -167,9 +167,11 @@ public enum ProjectInstructions { /// Pure, and the only place the three are put in an order, so what an agent is about to be /// told can be asserted without a worktree, a store or GitHub. That is the same reason /// `MergePromptContext` is a value rather than a lookup. - public static func turn(_ rendered: String, for subject: Subject, adding extra: Extra) -> String { + public static func turn( + _ rendered: String, for subject: Subject, adding extra: Extra, forge: Forge = .gitHub + ) -> String { var parts = [rendered.trimmingCharacters(in: .whitespacesAndNewlines)] - if let canonical = canonical(for: subject) { parts.append(canonical) } + if let canonical = canonical(for: subject, forge: forge) { parts.append(canonical) } if let closing = sentence(for: subject, adding: extra) { parts.append(closing) } return parts.filter { !$0.isEmpty }.joined(separator: "\n\n") } @@ -188,9 +190,9 @@ public enum ProjectInstructions { /// bubble is what stopped anybody reading them. They are added before this call rather than /// inside it, by `WorkspaceModel.requestFixConflicts`, because the path they are at is a fact /// about one worktree and everything in this switch is a constant. - public static func canonical(for subject: Subject) -> String? { + public static func canonical(for subject: Subject, forge: Forge = .gitHub) -> String? { switch subject { - case .merge: MergeInstructions.canonical + case .merge: forge == .gitLab ? GitLabInstructions.merge : MergeInstructions.canonical case .fixConflicts: nil } } diff --git a/Sources/BloomCore/GitHub/PullRequestInstructions.swift b/Sources/BloomCore/GitHub/PullRequestInstructions.swift index 1dc0027ec..2554a55d1 100644 --- a/Sources/BloomCore/GitHub/PullRequestInstructions.swift +++ b/Sources/BloomCore/GitHub/PullRequestInstructions.swift @@ -50,8 +50,10 @@ public enum PullRequestInstructions { /// path in a turn is a promise to the agent that it can read what it names. `ComposerView` /// takes the same last look before it sends a prompt somebody typed, and this is the same /// look taken for the one attachment Bloom makes for itself. - public static func ensure(in worktree: String, contents: String = defaultMarkdown) async -> String? { - guard let path = await choose(in: worktree, contents: contents) else { return nil } + public static func ensure( + in worktree: String, contents: String = defaultMarkdown, scratch: String = scratchPath + ) async -> String? { + guard let path = await choose(in: worktree, contents: contents, scratch: scratch) else { return nil } // Deliberately after every branch below rather than inside them, so a case added later // cannot answer with a path nobody looked at. return InstructionFile.isFile(path, in: worktree) ? path : nil @@ -64,12 +66,13 @@ public enum PullRequestInstructions { } /// Which of the two files this worktree is going to use, writing Bloom's own if it has to. - private static func choose(in worktree: String, contents: String) async -> String? { + private static func choose(in worktree: String, contents: String, scratch scratchPath: String) async -> String? { let project = (worktree as NSString).appendingPathComponent(projectPath) if InstructionFile.isFile(projectPath, in: worktree) { - if let moved = await reclaimStrayDefault(at: project, in: worktree) { return moved } - return projectPath + guard let moved = await reclaimStrayDefault(at: project, in: worktree) else { return projectPath } + // A stray GitHub default is GitHub's scratch copy now; GitLab writes its own below. + if scratchPath == Self.scratchPath { return moved } } WorktreeScratch.shield(WorktreeScratch.generated, in: worktree) diff --git a/Sources/BloomCore/GitHub/SquashCommitMessage.swift b/Sources/BloomCore/GitHub/SquashCommitMessage.swift index 8abf35e84..480dae02d 100644 --- a/Sources/BloomCore/GitHub/SquashCommitMessage.swift +++ b/Sources/BloomCore/GitHub/SquashCommitMessage.swift @@ -13,8 +13,12 @@ public struct SquashCommitMessage: Sendable, Hashable { self.body = body } + /// `Title (#12)` on GitHub. GitLab's own default for a squash is just the title. public static func proposed(for pullRequest: PullRequest) -> SquashCommitMessage { - SquashCommitMessage(subject: "\(pullRequest.title) (#\(pullRequest.number))") + switch pullRequest.forge { + case .gitHub: SquashCommitMessage(subject: "\(pullRequest.title) (#\(pullRequest.number))") + case .gitLab: SquashCommitMessage(subject: pullRequest.title) + } } /// This message trimmed, or nil when there is nothing to ask for: left as proposed, or a @@ -34,13 +38,22 @@ public struct SquashCommitMessage: Sendable, Hashable { ["--subject=\(subject)"] + (body.isEmpty ? [] : ["--body=\(body)"]) } + /// The whole message as `glab mr merge --squash-message` takes it: subject, blank line, body. + public var commitMessage: String { + body.isEmpty ? subject : "\(subject)\n\n\(body)" + } + /// The paragraph that asks the agent for this message, or nil when there is nothing to ask. - public func instruction(proposed: SquashCommitMessage) -> String? { + public func instruction(proposed: SquashCommitMessage, forge: Forge = .gitHub) -> String? { guard let edited = edited(from: proposed) else { return nil } let subject = edited.subject let body = edited.body let fence = Self.fence(longerThanAnyIn: subject + "\n" + body) + if forge == .gitLab { + return "Squash with this commit message, word for word, as `--squash-message` to `glab mr merge`:\n\n" + + "\(fence)\n\(edited.commitMessage)\n\(fence)" + } guard !body.isEmpty else { return "Squash with this commit subject, word for word, as `--subject` to `gh pr merge`:\n\n" + "\(fence)\n\(subject)\n\(fence)" diff --git a/Sources/BloomCore/GitLab/GitLab.swift b/Sources/BloomCore/GitLab/GitLab.swift index e9f1d0419..bb067776a 100644 --- a/Sources/BloomCore/GitLab/GitLab.swift +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -11,8 +11,13 @@ public enum GitLab: ForgeClient { /// Checking out fetches objects, so it gets what `gh pr checkout` gets. private static let checkoutTimeout = Duration.seconds(120) private static let logTimeout = Duration.seconds(90) + /// What `gh pr merge` and `gh pr create` get. + private static let mergeTimeout = Duration.seconds(60) + /// `glab mr rebase` polls until GitLab is done; this caps a rebase that never is. + private static let rebaseTimeout = Duration.seconds(120) private static let candidateLimit = 20 private static let jobLimit = 100 + private static let commitLimit = 100 private static let listLimit = 30 public static func access(in directory: String) async -> GitHubAccess { @@ -113,6 +118,125 @@ public enum GitLab: ForgeClient { ) } + // MARK: Creating and merging directly + + /// The workspace's title, and the pushed commits for the description, as `gh pr create --fill` + /// does. Not glab's own `--fill`, which pushes again to a remote of its choosing. + public static func createPullRequestFromCommits( + worktree: String, base: String, title: String + ) async throws -> PullRequest { + guard Git.isValidBranchName(base) else { + throw GitHubError("refusing to open a merge request against '\(base)': not a valid branch name") + } + guard let project = await project(in: worktree) else { throw notAProject } + let context = try await Git.repositoryContext(in: worktree, baseBranch: base) + let log = try await Git.check( + ["log", "--reverse", "--format=%s%x1f%b%x1e", "\(context.baseTrackingRef ?? base)..HEAD", "--"], + in: worktree + ) + var arguments = [ + "mr", "create", "--source-branch", context.publishBranch, "--target-branch", base, + "--title", title, "--description", description(fromCommits: commits(fromLog: log.stdout)), + "--yes", "-R", project.webURL, + ] + if let fork = GitLabProject(remote: context.publishRemoteURL), fork != project { + arguments += ["--head", fork.webURL] + } + let created = try await check(arguments, cwd: worktree, timeout: mergeTimeout) + guard let number = mergeRequestNumber(in: created.stdout) else { + throw GitHubError("glab did not say which merge request it opened.") + } + return try await snapshot(number: number, in: project, worktree: worktree).pullRequest + } + + /// Merges exactly what the owner confirmed. `--sha` holds GitLab to the head that was shown, + /// and `--auto-merge=false` stops glab from merging later, once the pipeline passes. + public static func mergePullRequest( + _ expected: PullRequest, into base: String, method: GitHub.MergeMethod, + squashMessage: SquashCommitMessage? = nil, worktree: String + ) async throws -> DirectMergeOutcome { + let (head, expectedBase) = try await DirectMergeCheck.confirmed(expected, base: base, worktree: worktree) + guard let project = await project(in: worktree) else { throw notAProject } + let number = expected.number + let current = try await snapshot(number: number, in: project, worktree: worktree).pullRequest + try DirectMergeCheck.verify(current, against: expected, head: head, base: expectedBase) + + var mergeHead = head + if method == .rebase { + mergeHead = try await rebase(number, confirmedHead: head, in: project, worktree: worktree) + } + var arguments = ["mr", "merge", String(number), "--yes", "--auto-merge=false", "--sha", mergeHead] + if method == .squash { + arguments.append("--squash") + if let squashMessage { arguments += ["--squash-message", squashMessage.commitMessage] } + } + arguments += ["-R", project.webURL] + let result = try await run(arguments, cwd: worktree, timeout: mergeTimeout) + let after = try? await snapshot(number: number, in: project, worktree: worktree).pullRequest + guard result.ok else { + if after?.isMerged == true { return .merged } + let refusal = result.stderr.isEmpty ? result.stdout : result.stderr + guard method != .rebase else { + throw GitHubError("GitLab rebased \(Forge.gitLab.reference(number)) but did not merge it: \(refusal)") + } + throw ShellError(command: "glab " + arguments.joined(separator: " "), status: result.status, stderr: refusal) + } + guard let after else { return .acceptedUnverified } + return after.isMerged ? .merged : .acceptedButOpen + } + + /// glab's `--rebase` rebases, then GitLab refuses the `--sha` it was given. So the rebase runs + /// first, and its new head is merged only if it replayed the confirmed commits and nothing else. + private static func rebase( + _ number: Int, confirmedHead: String, in project: GitLabProject, worktree: String + ) async throws -> String { + let confirmed = try await commits(of: number, in: project, worktree: worktree) + guard confirmed.contains(where: { $0.sha == confirmedHead }) else { + throw GitHubError("The merge request changed since it was shown. Refresh it before merging.") + } + try await check(["mr", "rebase", String(number), "-R", project.webURL], cwd: worktree, timeout: rebaseTimeout) + let rebased = try await snapshot(number: number, in: project, worktree: worktree).pullRequest + let replayed = try await commits(of: number, in: project, worktree: worktree) + guard let head = rebased.headRevision, replayed.contains(where: { $0.sha == head }), + replayed.map(\.title) == confirmed.map(\.title) else { + throw GitHubError("GitLab rebased \(Forge.gitLab.reference(number)), but its commits are not the ones confirmed. Nothing was merged.") + } + return head + } + + private static func commits( + of number: Int, in project: GitLabProject, worktree: String + ) async throws -> [GitLabDecoding.CommitPayload] { + let result = try await check( + api(project.host, "projects/\(project.apiID)/merge_requests/\(number)/commits?per_page=\(commitLimit)"), + cwd: worktree + ) + return try GitLabDecoding.commits(from: Data(result.stdout.utf8)) + } + + /// One commit's own body, or the subjects of several, oldest first: `gh pr create --fill`. + static func description(fromCommits commits: [(subject: String, body: String)]) -> String { + if commits.count == 1 { return commits[0].body.trimmingCharacters(in: .whitespacesAndNewlines) } + return commits.map { "- \($0.subject)" }.joined(separator: "\n") + } + + /// `git log --format=%s%x1f%b%x1e` read back into subjects and bodies. + static func commits(fromLog output: String) -> [(subject: String, body: String)] { + output.split(separator: "\u{1e}").compactMap { record in + let fields = record.split(separator: "\u{1f}", maxSplits: 1, omittingEmptySubsequences: false) + let subject = fields[0].trimmingCharacters(in: .whitespacesAndNewlines) + guard !subject.isEmpty else { return nil } + return (subject, fields.count > 1 ? String(fields[1]) : "") + } + } + + /// The number at the end of the merge request URL glab prints once it has opened one. + static func mergeRequestNumber(in output: String) -> Int? { + guard let range = output.range(of: #"/-/merge_requests/(\d+)"#, options: [.regularExpression, .backwards]) + else { return nil } + return Int(output[range].split(separator: "/").last ?? "") + } + // MARK: Reading one merge request struct Snapshot { diff --git a/Sources/BloomCore/GitLab/GitLabDecoding.swift b/Sources/BloomCore/GitLab/GitLabDecoding.swift index 2762505d1..da39b9109 100644 --- a/Sources/BloomCore/GitLab/GitLabDecoding.swift +++ b/Sources/BloomCore/GitLab/GitLabDecoding.swift @@ -44,6 +44,20 @@ enum GitLabDecoding { let finishedAt: String? } + struct CommitPayload: Decodable, Equatable { + let sha: String? + let title: String? + + enum CodingKeys: String, CodingKey { + case sha = "id" + case title + } + } + + static func commits(from data: Data) throws -> [CommitPayload] { + try decode([CommitPayload].self, from: data) + } + static func mergeRequest(from data: Data) throws -> MergeRequestPayload { try decode(MergeRequestPayload.self, from: data) } diff --git a/Sources/BloomCore/GitLab/GitLabInstructions.swift b/Sources/BloomCore/GitLab/GitLabInstructions.swift new file mode 100644 index 000000000..0dd060e2d --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabInstructions.swift @@ -0,0 +1,80 @@ +import Foundation + +/// What Bloom tells an agent on a GitLab project: GitHub's texts in GitLab's words, so the two +/// cannot drift, except where the commands differ. GitHub's own defaults are never edited. +public enum GitLabInstructions { + /// Not `pr-instructions.md`, which a worktree may already hold with GitHub's steps in it. + public static let mergeRequestScratchPath = "\(WorktreeScratch.generated)/mr-instructions.md" + + public static var mergeRequestMarkdown: String { + translate(PullRequestInstructions.defaultMarkdown).replacingOccurrences( + of: "`gh pr create --base <target branch> --title <title> --body\n <description>`", + with: "`glab mr create --target-branch <target branch> --title <title>\n --description <description> --yes`" + ) + } + + public static var conflictMarkdown: String { translate(ConflictInstructions.defaultMarkdown) } + + public static let merge = """ + This message names the merge request, the branch it is on, and the merge method to use. + Nothing below chooses any of those, and nothing below may change them. + + - If this project has a skill or an instruction file about merging, follow that first. It + outranks everything here. + - Read the merge request's head commit, its `sha`, from `glab mr view <number> -F json`. + - Merge with `glab mr merge <number> --yes --auto-merge=false --sha <head commit>`, plus + `--squash` if the message names it, run from this worktree. + - For a rebase, run `glab mr rebase <number>` first and wait for it, then read the new head + commit and merge with no method flag. Not glab's `--rebase`: it rebases, then GitLab + refuses the `--sha` it was given, because the head has moved. + - Pass no other flags. `--auto-merge=false` is required: without it glab sets "merge when + the pipeline succeeds", which merges later, when nobody is watching. Not + `--remove-source-branch`: the branch is deleted below, once the merge has happened. + - **If GitLab refuses the merge, stop.** Say what it said, in its own words. Do not retry it, + do not force it, and do not change an approval rule, a protected branch or any other + project setting to get round it. A refusal is an answer, and the person who pressed the + button is reading this. + - Only once the merge has actually succeeded, delete the branch on the server with + `git push --delete -- origin refs/heads/<branch>`, using the branch the message names. If + git answers that the remote ref does not exist, it was deleted on merge and there is + nothing left to do. + - Change nothing on this machine. Do not delete the local branch, do not remove or move the + worktree, and do not check out anything else. + - Do not commit and do not push. If the worktree is holding work GitLab has not got, say in + one line that it was left behind. + - Finish by saying what happened: that it merged, and whether the branch on the server is gone. + + If a step fails, stop and say what went wrong instead of working around it. + """ + + /// The built-in template for a GitLab project, or nil to use GitHub's as it stands. + public static func defaultTemplate(for id: PromptID) -> String? { + switch id { + case .mergePullRequest: + "Merge merge request !{{number}} into {{base_branch}} as a {{method}} ({{method_flag}}). " + + "It is on the branch {{branch}}." + case .createPullRequest, .pushLocalWork, .fixConflicts, .continueAfterMerge: + translate(PromptRegistry.definition(for: id).defaultTemplate) + case .carryOnArchived, .review, .nameWorkspace: + nil + } + } + + /// The merge method as the GitLab template names it. Merge is no flag at all: the project's + /// own method, whether that is a merge commit, semi-linear or fast forward. + public static func flag(for method: GitHub.MergeMethod) -> String { + switch method { + case .merge: "no method flag, the project's own merge method" + case .squash: "`--squash`" + case .rebase: "`glab mr rebase` first, then no method flag" + } + } + + static func translate(_ text: String) -> String { + [ + ("Pull request #", "Merge request !"), ("pull request #", "merge request !"), + ("Pull request", "Merge request"), ("pull request", "merge request"), + ("Pull Request", "Merge Request"), ("GitHub", "GitLab"), + ].reduce(text) { $0.replacingOccurrences(of: $1.0, with: $1.1) } + } +} diff --git a/Sources/BloomCore/Transcript/PromptOverrides.swift b/Sources/BloomCore/Transcript/PromptOverrides.swift index 7aca2f28c..16fd097d7 100644 --- a/Sources/BloomCore/Transcript/PromptOverrides.swift +++ b/Sources/BloomCore/Transcript/PromptOverrides.swift @@ -18,6 +18,12 @@ public struct PromptOverrides: @unchecked Sendable { keyPrefix + id.rawValue } + /// GitLab's overrides live apart, so a GitHub template saying `gh pr merge` never reaches a + /// GitLab project. + public static func key(for id: PromptID, forge: Forge) -> String { + forge == .gitHub ? key(for: id) : keyPrefix + "gitLab." + id.rawValue + } + private let defaults: UserDefaults public init(defaults: UserDefaults = .standard) { @@ -54,4 +60,13 @@ public struct PromptOverrides: @unchecked Sendable { } return override } + + 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 !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return GitLabInstructions.defaultTemplate(for: id) ?? PromptRegistry.definition(for: id).defaultTemplate + } + return override + } } diff --git a/Sources/BloomCore/Transcript/SentTurn.swift b/Sources/BloomCore/Transcript/SentTurn.swift index 7743c4cb9..e2347dc13 100644 --- a/Sources/BloomCore/Transcript/SentTurn.swift +++ b/Sources/BloomCore/Transcript/SentTurn.swift @@ -72,6 +72,7 @@ public enum SentTurn { /// written to, because the reader pressed the button and never sees the file. public static let mergeTitle = "Merge instructions" public static let pullRequestTitle = "Pull request instructions" + public static let mergeRequestTitle = "Merge request instructions" public static let conflictTitle = "Conflict instructions" public static let projectTitle = "Project instructions" @@ -91,6 +92,8 @@ public enum SentTurn { switch path { case PullRequestInstructions.projectPath, PullRequestInstructions.scratchPath: return pullRequestTitle + case GitLabInstructions.mergeRequestScratchPath: + return mergeRequestTitle case ConflictInstructions.scratchPath: return conflictTitle default: @@ -153,6 +156,11 @@ public enum SentTurn { found += PullRequestInstructions.retiredDefaults.map { InjectedInstruction(title: pullRequestTitle, body: $0) } + found += [ + InjectedInstruction(title: mergeTitle, body: GitLabInstructions.merge), + InjectedInstruction(title: mergeRequestTitle, body: GitLabInstructions.mergeRequestMarkdown), + InjectedInstruction(title: conflictTitle, body: GitLabInstructions.conflictMarkdown), + ] return found }() diff --git a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift index 852b7df2b..30f228cca 100644 --- a/Sources/BloomCore/Workspace/WorkspaceCheckout.swift +++ b/Sources/BloomCore/Workspace/WorkspaceCheckout.swift @@ -303,7 +303,7 @@ public enum WorkspaceCheckoutPlan { /// The URL carries the repository as well, which is not thrown away: pasting another /// repository's pull request into this project is a mistake worth naming rather than a number /// that quietly resolves to somebody else's work. - public static func parseReference(_ text: String) -> PullRequestReference? { + public static func parseReference(_ text: String, forge: Forge = .gitHub) -> PullRequestReference? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -319,11 +319,26 @@ public enum WorkspaceCheckoutPlan { ) } + if forge == .gitLab, let mergeRequest = gitLabReference(trimmed) { return mergeRequest } let bare = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed guard let number = positiveNumber(bare) else { return nil } return PullRequestReference(number: number, repository: nil) } + /// `!12`, or a merge request URL: `https://<host>/<group path>/-/merge_requests/12`. + private static func gitLabReference(_ text: String) -> PullRequestReference? { + if text.hasPrefix("!") { + return positiveNumber(String(text.dropFirst())).map { PullRequestReference(number: $0, repository: nil) } + } + guard let url = URL(string: text), url.host != nil else { return nil } + let parts = url.path.split(separator: "/").map(String.init) + guard let separator = parts.firstIndex(of: "-"), separator >= 2, + parts.indices.contains(separator + 2), parts[separator + 1] == "merge_requests", + let number = positiveNumber(parts[separator + 2]) + else { return nil } + return PullRequestReference(number: number, repository: parts[..<separator].joined(separator: "/")) + } + private static func positiveNumber(_ text: String) -> Int? { guard !text.isEmpty, text.allSatisfy(\.isNumber), let value = Int(text), value > 0 else { return nil @@ -445,17 +460,17 @@ public enum WorkspaceCheckoutResolver { /// to have as number 42. Being told the numbers belong to different repositories is a fixable /// mistake; silently reviewing the wrong pull request is not. public static func problem( - with text: String, in repository: String? + with text: String, in repository: String?, forge: Forge = .gitHub ) -> String? { guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "Type a pull request number or paste its URL." } - guard let reference = WorkspaceCheckoutPlan.parseReference(text) else { - return "'\(text.trimmingCharacters(in: .whitespacesAndNewlines))' is not a pull request number or URL." + guard let reference = WorkspaceCheckoutPlan.parseReference(text, forge: forge) else { + return "'\(text.trimmingCharacters(in: .whitespacesAndNewlines))' is not a \(forge.request) number or URL." } if let repository, let named = reference.repository, named.lowercased() != repository.lowercased() { - return "That pull request belongs to \(named), and this project is \(repository)." + return "That \(forge.request) belongs to \(named), and this project is \(repository)." } return nil } @@ -463,10 +478,11 @@ public enum WorkspaceCheckoutResolver { /// The whole of it, including the gh call. Lives here rather than in the sheet so the sheet is /// left drawing a text field and reading an answer. public static func resolve(_ text: String, repoPath: String) async -> WorkspaceCheckoutResolution { + let kind = await ForgeResolver.forge(for: repoPath) let forge = await ForgeResolver.client(for: repoPath) let slug = await forge.repositorySlug(repoPath: repoPath) - if let problem = problem(with: text, in: slug) { return .failure(problem) } - guard let reference = WorkspaceCheckoutPlan.parseReference(text) else { + if let problem = problem(with: text, in: slug, forge: kind) { return .failure(problem) } + guard let reference = WorkspaceCheckoutPlan.parseReference(text, forge: kind) else { return .failure("That is not a pull request number or URL.") } do { diff --git a/Sources/BloomCore/Workspace/WorkspaceSource.swift b/Sources/BloomCore/Workspace/WorkspaceSource.swift index e0cd7d832..ad058a0f6 100644 --- a/Sources/BloomCore/Workspace/WorkspaceSource.swift +++ b/Sources/BloomCore/Workspace/WorkspaceSource.swift @@ -193,17 +193,21 @@ public struct WorkspaceSourceOffering: Sendable, Hashable { public let baseBranches: [String] /// The project's default branch, which a new branch is ordinarily cut from. See `carryOn`. public let defaultBranch: String + /// Which references a typed row accepts: `!12` and merge request URLs only on GitLab. + public let forge: Forge public init( pullRequests: [PullRequestListing] = [], branches: [ExistingBranch] = [], baseBranches: [String] = [], - defaultBranch: String = "" + defaultBranch: String = "", + forge: Forge = .gitHub ) { self.pullRequests = pullRequests self.branches = branches self.baseBranches = baseBranches self.defaultBranch = defaultBranch + self.forge = forge } /// The "Open, and carry on" section: what somebody else has already written. @@ -278,7 +282,7 @@ public struct WorkspaceSourceOffering: Sendable, Hashable { /// `WorkspaceCheckoutPlan.parseReference`, which already knows every shape a pull request /// arrives in. private func typedRows(for query: String) -> [WorkspaceSource] { - guard let reference = WorkspaceCheckoutPlan.parseReference(query) else { return [] } + guard let reference = WorkspaceCheckoutPlan.parseReference(query, forge: forge) else { return [] } guard !pullRequests.contains(where: { $0.number == reference.number }) else { return [] } return [.pullRequest(.typed(reference, text: query))] } diff --git a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift index 4386f3003..f12346172 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartContext.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartContext.swift @@ -102,19 +102,22 @@ public struct WorkspaceCheckoutOptions: Sendable { /// half of the picker knew the branch was taken and the pull request half did not, so the one /// that reached `git worktree add` was the one that could not have worked. public let holders: [String: BranchHolder] + public let forge: Forge public init( pullRequests: [PullRequestListing] = [], branches: [ExistingBranch] = [], access: GitHubAccess = .ready, failure: String? = nil, - holders: [String: BranchHolder] = [:] + holders: [String: BranchHolder] = [:], + forge: Forge = .gitHub ) { self.pullRequests = pullRequests self.branches = branches self.access = access self.failure = failure self.holders = holders + self.forge = forge } /// Both branch listings are read here rather than handed in. @@ -158,6 +161,7 @@ public struct WorkspaceCheckoutOptions: Sendable { workspaceNames: BranchHolder.names(of: workspaces, in: repoID) ) + let forgeKind = await ForgeResolver.forge(for: repoPath) func options( pullRequests: [PullRequestListing] = [], access: GitHubAccess = .ready, @@ -175,7 +179,8 @@ public struct WorkspaceCheckoutOptions: Sendable { ), access: access, failure: failure, - holders: branchesInUse + holders: branchesInUse, + forge: forgeKind ) } diff --git a/Tests/BloomCoreTests/GitHubStoredValueTests.swift b/Tests/BloomCoreTests/GitHubStoredValueTests.swift index 52d81cc69..50ea38a67 100644 --- a/Tests/BloomCoreTests/GitHubStoredValueTests.swift +++ b/Tests/BloomCoreTests/GitHubStoredValueTests.swift @@ -33,7 +33,7 @@ struct GitHubStoredValueTests { ]) #expect(GitHub.MergeMethod.allCases.map(\.rawValue) == ["merge", "squash", "rebase"]) #expect(MergeExecution.allCases.map(\.rawValue) == ["direct", "agent"]) - #expect(MergeExecution.allCases.map(\.label) == ["Merge with GitHub CLI", "Ask agent to merge"]) + #expect(MergeExecution.allCases.map { $0.label(on: .gitHub) } == ["Merge with GitHub CLI", "Ask agent to merge"]) let checks: [PullRequest.Checks] = [.none, .pending, .passing, .failing, .unavailable] #expect(checks.map(\.rawValue) == ["none", "pending", "passing", "failing", "unavailable"]) } diff --git a/Tests/BloomCoreTests/GitLabDirectTests.swift b/Tests/BloomCoreTests/GitLabDirectTests.swift new file mode 100644 index 000000000..646bc2793 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabDirectTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Direct GitLab create and merge", .tags(.git), .scratchDirectory) +struct GitLabDirectTests { + private let project = "https://gitlab.com/group/app" + + @Test("merges the confirmed head with no auto-merge, the method's flag and the project named") + func mergesTheConfirmedHead() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + + for method in [GitHub.MergeMethod.squash, .merge] { + let glab = MergeGlab(head: head) + let outcome = try await GitLab.$commandOverride.withValue({ arguments, _ in + await glab.execute(arguments) + }) { + try await GitLab.mergePullRequest(expected(head: head), into: "main", method: method, worktree: repo.path) + } + let flags: [String] = method == .squash ? ["--squash"] : [] + let merge = ["mr", "merge", "42", "--yes", "--auto-merge=false", "--sha", head] + flags + ["-R", project] + #expect(outcome == .merged) + #expect(await glab.merges == [merge]) + } + } + + @Test("a rebase runs first, and the rebased head is what gets merged") + func rebasesThenMerges() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let rebased = String(repeating: "b", count: 40) + let glab = MergeGlab(head: head, rebasedHead: rebased) + + let outcome = try await GitLab.$commandOverride.withValue({ arguments, _ in + await glab.execute(arguments) + }) { + try await GitLab.mergePullRequest(expected(head: head), into: "main", method: .rebase, worktree: repo.path) + } + + #expect(outcome == .merged) + #expect(await glab.rebases == 1) + #expect(await glab.merges == [ + ["mr", "merge", "42", "--yes", "--auto-merge=false", "--sha", rebased, "-R", project], + ]) + } + + @Test("a rebase that brought in a commit nobody confirmed is not merged") + func refusesAnUnconfirmedCommit() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let glab = MergeGlab(head: head, rebasedHead: String(repeating: "b", count: 40), extraAfterRebase: "Sneaked in") + + do { + _ = try await GitLab.$commandOverride.withValue({ arguments, _ in + await glab.execute(arguments) + }) { + try await GitLab.mergePullRequest(expected(head: head), into: "main", method: .rebase, worktree: repo.path) + } + Issue.record("A rebase with an unconfirmed commit was merged") + } catch let error as GitHubError { + #expect(error.message.contains("not the ones confirmed")) + } + #expect(await glab.merges.isEmpty) + } + + @Test("a newly pushed head refuses the direct merge before glab merges") + func refusesChangedHead() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let glab = MergeGlab(head: String(repeating: "a", count: 40)) + + do { + _ = try await GitLab.$commandOverride.withValue({ arguments, _ in + await glab.execute(arguments) + }) { + try await GitLab.mergePullRequest(expected(head: head), into: "main", method: .merge, worktree: repo.path) + } + Issue.record("A changed merge request head was merged") + } catch let error as GitHubError { + #expect(error.message == "The merge request changed since it was shown. Refresh it before merging.") + } + #expect(await glab.merges.isEmpty) + } + + @Test("opens the merge request from the pushed branch, described by its commits as gh --fill does") + func createsFromCommits() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + try repo.write("b.txt", "b\n") + try await Shell.check("git", ["add", "b.txt"], cwd: repo.path) + try await Shell.check("git", ["commit", "-q", "-m", "Add b"], cwd: repo.path) + let head = try await Git.headSHA(of: repo.path) + let glab = MergeGlab(head: head) + + let created = try await GitLab.$commandOverride.withValue({ arguments, _ in + await glab.execute(arguments) + }) { + try await GitLab.createPullRequestFromCommits(worktree: repo.path, base: "main", title: "Feature") + } + + #expect(created.number == 42) + #expect(created.forge == .gitLab) + #expect(await glab.creates == [[ + "mr", "create", "--source-branch", "feature", "--target-branch", "main", + "--title", "Feature", "--description", "- Add a\n- Add b", "--yes", "-R", project, + ]]) + } + + @Test("a single commit's body is the description, and glab's printed URL names the request") + func descriptionAndNumber() { + #expect(GitLab.description(fromCommits: [(subject: "Add a", body: "Why a\n")]) == "Why a") + #expect(GitLab.description(fromCommits: []) == "") + #expect(GitLab.commits(fromLog: "Add a\u{1f}Why\n\u{1e}\nAdd b\u{1f}\u{1e}\n").map { $0.subject } == ["Add a", "Add b"]) + #expect(GitLab.mergeRequestNumber(in: "Creating…\nhttps://gitlab.com/g/app/-/merge_requests/42\n") == 42) + #expect(GitLab.mergeRequestNumber(in: "nothing here") == nil) + } + + @Test("GitHub's words for the direct routes are the ones it had") + func gitHubWords() { + #expect(Forge.gitHub.command("merge") == "gh pr merge") + #expect(Forge.gitHub.titledRequest == "Pull request") + #expect(Forge.gitHub.shortRequest == "PR") + #expect(Forge.gitLab.command("merge") == "glab mr merge") + #expect(MergeExecution.direct.label(on: .gitLab) == "Merge with GitLab CLI") + } + + /// On `feature`, one commit ahead of `main`, with `origin/main` where `main` is. + private func featureRepo() async throws -> TempRepo { + let repo = try await TempRepo() + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/app.git"], cwd: repo.path) + try await Shell.check("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], cwd: repo.path) + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + try repo.write("a.txt", "a\n") + try await Shell.check("git", ["add", "a.txt"], cwd: repo.path) + try await Shell.check("git", ["commit", "-q", "-m", "Add a", "-m", "Why a"], cwd: repo.path) + return repo + } + + private func expected(head: String) -> PullRequest { + PullRequest( + number: 42, title: "Feature", url: "\(project)/-/merge_requests/42", state: "OPEN", + branch: "feature", baseBranch: "main", headRevision: head, forge: .gitLab + ) + } +} + +/// glab for one merge request, !42 on `feature`, answering the API reads and the mr commands. +private actor MergeGlab { + private var head: String + private let rebasedHead: String? + private let extraAfterRebase: String? + private var merged = false + private(set) var merges: [[String]] = [] + private(set) var creates: [[String]] = [] + private(set) var rebases = 0 + + init(head: String, rebasedHead: String? = nil, extraAfterRebase: String? = nil) { + self.head = head + self.rebasedHead = rebasedHead + self.extraAfterRebase = extraAfterRebase + } + + func execute(_ arguments: [String]) -> ShellResult { + if arguments.starts(with: ["mr", "merge"]) { + merges.append(arguments) + merged = true + return ShellResult(status: 0, stdout: "", stderr: "") + } + if arguments.starts(with: ["mr", "create"]) { + creates.append(arguments) + return ShellResult(status: 0, stdout: "https://gitlab.com/group/app/-/merge_requests/42\n", stderr: "") + } + if arguments.starts(with: ["mr", "rebase"]) { + rebases += 1 + head = rebasedHead ?? head + return ShellResult(status: 0, stdout: "", stderr: "") + } + if arguments.last?.hasSuffix("/commits?per_page=100") == true { + let extra = rebases > 0 ? extraAfterRebase.map { [(String(repeating: "c", count: 40), $0)] } ?? [] : [] + let commits = extra + [(head, "Add a")] + let listed = commits.map { #"{"id":"\#($0.0)","title":"\#($0.1)"}"# }.joined(separator: ",") + return ShellResult(status: 0, stdout: "[\(listed)]", stderr: "") + } + let state = merged ? "merged" : "opened" + return ShellResult(status: 0, stdout: """ + {"iid":42,"title":"Feature","web_url":"https://gitlab.com/group/app/-/merge_requests/42",\ + "state":"\(state)","draft":false,"has_conflicts":false,"detailed_merge_status":"mergeable",\ + "source_branch":"feature","target_branch":"main","sha":"\(head)",\ + "source_project_id":1,"target_project_id":1} + """, stderr: "") + } +} diff --git a/Tests/BloomCoreTests/GitLabInstructionsTests.swift b/Tests/BloomCoreTests/GitLabInstructionsTests.swift new file mode 100644 index 000000000..7653dca20 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabInstructionsTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab instructions", .scratchDirectory) +struct GitLabInstructionsTests { + @Test("every GitLab text, as it stands") + func texts() { + expectCharacterised(GitLabInstructions.mergeRequestMarkdown, "gitlab-mr-instructions.md") + expectCharacterised(GitLabInstructions.merge, "gitlab-merge-instructions.md") + expectCharacterised(GitLabInstructions.conflictMarkdown, "gitlab-conflict-instructions.md") + for definition in PromptRegistry.all { + guard let template = GitLabInstructions.defaultTemplate(for: definition.id) else { continue } + expectCharacterised(template, "gitlab-prompt-\(definition.id.rawValue).txt") + } + } + + @Test("nothing sent to a GitLab project's agent names gh or GitHub's words") + func noGitHubWords() { + let texts = [GitLabInstructions.mergeRequestMarkdown, GitLabInstructions.merge, GitLabInstructions.conflictMarkdown] + + PromptRegistry.all.compactMap { GitLabInstructions.defaultTemplate(for: $0.id) } + for text in texts { + #expect(!text.contains("`gh "), "\(text.prefix(60))") + #expect(!text.contains("GitHub"), "\(text.prefix(60))") + #expect(!text.localizedCaseInsensitiveContains("pull request"), "\(text.prefix(60))") + } + #expect(GitLabInstructions.merge.contains("--auto-merge=false")) + #expect(GitLabInstructions.mergeRequestMarkdown.contains("glab mr create --target-branch")) + } + + @Test("a GitHub override never reaches a GitLab project, and GitHub's lookup is unchanged") + func overrides() throws { + let defaults = try #require(UserDefaults(suiteName: "gitlab-prompts-\(UUID().uuidString)")) + let overrides = PromptOverrides(defaults: defaults) + overrides.set("Run gh pr merge now.", for: .mergePullRequest) + + #expect(overrides.template(for: .mergePullRequest, forge: .gitHub) == "Run gh pr merge now.") + #expect(overrides.template(for: .mergePullRequest, forge: .gitLab) + == GitLabInstructions.defaultTemplate(for: .mergePullRequest)) + + defaults.set("Merge it with glab.", forKey: PromptOverrides.key(for: .mergePullRequest, forge: .gitLab)) + #expect(PromptOverrides.key(for: .mergePullRequest, forge: .gitLab) == "prompts.gitLab.mergePullRequest") + #expect(overrides.template(for: .mergePullRequest, forge: .gitLab) == "Merge it with glab.") + #expect(overrides.template(for: .review, forge: .gitLab) == overrides.template(for: .review)) + } + + @Test("the merge turn names GitLab's flags and carries GitLab's steps", arguments: [ + (method: GitHub.MergeMethod.squash, flag: "`--squash`"), + (method: .rebase, flag: "`glab mr rebase` first, then no method flag"), + (method: .merge, flag: "no method flag, the project's own merge method"), + ]) + func mergeTurn(method: GitHub.MergeMethod, flag: String) { + let context = MergePromptContext( + workspaceName: "w", number: 12, title: "t", branch: "feature", baseBranch: "main", + method: method, forge: .gitLab + ) + let template = GitLabInstructions.defaultTemplate(for: .mergePullRequest) ?? "" + let rendered = context.render(template: template).text + #expect(rendered.hasPrefix("Merge merge request !12 into main as a ")) + #expect(rendered.contains("(\(flag))")) + let turn = ProjectInstructions.turn(rendered, for: .merge, adding: .nothing, forge: .gitLab) + #expect(turn.contains(GitLabInstructions.merge)) + #expect(!turn.contains(MergeInstructions.canonical)) + } + + @Test("a GitHub merge turn is what it was") + func gitHubMergeTurn() { + let context = MergePromptContext( + workspaceName: "w", number: 12, title: "t", branch: "feature", baseBranch: "main", method: .squash + ) + #expect(context.values[PromptRegistry.MergePullRequest.methodFlag] == "--squash") + let turn = ProjectInstructions.turn("x", for: .merge, adding: .nothing) + #expect(turn == "x\n\n" + MergeInstructions.canonical) + } + + private static let referenceCases: [(text: String, number: Int, repository: String?)] = [ + ("!42", 42, nil), + ("https://gitlab.com/group/sub/app/-/merge_requests/42", 42, "group/sub/app"), + ("https://git.example.com/team/app/-/merge_requests/7/diffs", 7, "team/app"), + ("https://github.com/acme/app/pull/9", 9, "acme/app"), + ("#5", 5, nil), + ] + + @Test("merge request references", arguments: referenceCases) + func references(text: String, number: Int, repository: String?) throws { + let reference = try #require(WorkspaceCheckoutPlan.parseReference(text, forge: .gitLab)) + #expect(reference.number == number) + #expect(reference.repository == repository) + } + + @Test("a GitHub project reads !12 and merge request URLs as it always did: not at all") + func gitHubIgnoresMergeRequests() { + #expect(WorkspaceCheckoutPlan.parseReference("!12") == nil) + #expect(WorkspaceCheckoutPlan.parseReference("https://gitlab.com/group/app/-/merge_requests/12") == nil) + } + + @Test("a GitLab worktree gets its own instructions file, beside GitHub's") + func ownScratchFile() async throws { + let worktree = TestScratch.unique("gitlab-scratch") + try FileManager.default.createDirectory(atPath: worktree, withIntermediateDirectories: true) + let path = await PullRequestInstructions.ensure( + in: worktree, contents: GitLabInstructions.mergeRequestMarkdown, + scratch: GitLabInstructions.mergeRequestScratchPath + ) + #expect(path == GitLabInstructions.mergeRequestScratchPath) + let written = try String(contentsOfFile: "\(worktree)/\(GitLabInstructions.mergeRequestScratchPath)", encoding: .utf8) + #expect(written == GitLabInstructions.mergeRequestMarkdown) + } +} diff --git a/Tests/BloomCoreTests/SquashCommitMessageTests.swift b/Tests/BloomCoreTests/SquashCommitMessageTests.swift index 3530ad8a4..3eac0797f 100644 --- a/Tests/BloomCoreTests/SquashCommitMessageTests.swift +++ b/Tests/BloomCoreTests/SquashCommitMessageTests.swift @@ -44,4 +44,17 @@ struct SquashCommitMessageTests { let edited = try #require(SquashCommitMessage(subject: proposed.subject, body: "Why").edited(from: proposed)) #expect(edited == SquashCommitMessage(subject: "Ship it (#12)", body: "Why")) } + + @Test("on GitLab the proposal is the bare title, and the agent is asked for --squash-message") + func gitLab() throws { + let merge = PullRequest(number: 12, title: "Ship it", url: "", state: "opened", forge: .gitLab) + let proposed = SquashCommitMessage.proposed(for: merge) + #expect(proposed == SquashCommitMessage(subject: "Ship it")) + + let message = SquashCommitMessage(subject: "Ship the thing", body: "Why") + #expect(message.commitMessage == "Ship the thing\n\nWhy") + let asked = try #require(message.instruction(proposed: proposed, forge: .gitLab)) + #expect(asked.contains("`--squash-message` to `glab mr merge`")) + #expect(asked.contains("```\nShip the thing\n\nWhy\n```")) + } } diff --git a/Tests/fixtures/characterisation/gitlab-conflict-instructions.md b/Tests/fixtures/characterisation/gitlab-conflict-instructions.md new file mode 100644 index 000000000..eeef0fd27 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-conflict-instructions.md @@ -0,0 +1,39 @@ +# Resolving merge conflicts + +Bloom attaches this file when someone presses Fix merge conflicts. This copy is Bloom's own, is +rewritten on every press and is invisible to git, so an edit made here does not last. To change +what this project does about conflicts, write it in `.bloom/conflict-instructions.md` and commit +it: Bloom attaches that as well, and where the two disagree yours wins. The message these steps +arrived with is a prompt you can reword in Bloom's settings. + +That message names the merge request, the branch this worktree is on, and the branch that +conflicts with it. Call the second one this branch and the third the base branch below. + +- If this project has a skill or an instruction file about resolving conflicts, follow that + first. It outranks everything here. +- Fetch the base branch first, so you are working against what is on the server rather than a + stale copy of it. +- Bring the base branch into this branch the way this project brings it in: merge it unless + the project's own conventions say to rebase onto it. + It goes into this branch and never the other way round. +- Work through every conflicted file. Keep what this branch changed and what the base branch + changed, and where the two genuinely disagree, read enough of the code around them to work out + which is right instead of taking a side. +- Follow this project's conventions, and run whatever it uses to check itself before you call + anything resolved. +- Commit the resolution, with a message worded the way this project words one. +- Then push it. A conflict resolved only in this worktree is still a conflict to everybody else, + and the merge request goes on refusing to merge until the branch on the server carries the + resolution. If bringing the base branch in rewrote this branch's commits, which a rebase does, + the push needs `--force-with-lease`, and it may only ever go to this branch, never to the base + branch and never to any other branch. +- **Do not push if you are not sure.** Genuine uncertainty about what a resolution should be, a + check that fails for a reason neither branch explains, or anything you had to guess at: leave + the commit here, say what you are unsure about, and let a person look. A resolution nobody + believes in is worse on the server than in a worktree. +- Do not merge the merge request whatever happens. Whether the work is good is the reader's + decision, and they are the one who pressed the button. +- Finish by saying which files conflicted, what you decided in each of them, whether you pushed, + and anything you are not sure about. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-merge-instructions.md b/Tests/fixtures/characterisation/gitlab-merge-instructions.md new file mode 100644 index 000000000..889404966 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-merge-instructions.md @@ -0,0 +1,29 @@ +This message names the merge request, the branch it is on, and the merge method to use. +Nothing below chooses any of those, and nothing below may change them. + +- If this project has a skill or an instruction file about merging, follow that first. It + outranks everything here. +- Read the merge request's head commit, its `sha`, from `glab mr view <number> -F json`. +- Merge with `glab mr merge <number> --yes --auto-merge=false --sha <head commit>`, plus + `--squash` if the message names it, run from this worktree. +- For a rebase, run `glab mr rebase <number>` first and wait for it, then read the new head + commit and merge with no method flag. Not glab's `--rebase`: it rebases, then GitLab + refuses the `--sha` it was given, because the head has moved. +- Pass no other flags. `--auto-merge=false` is required: without it glab sets "merge when + the pipeline succeeds", which merges later, when nobody is watching. Not + `--remove-source-branch`: the branch is deleted below, once the merge has happened. +- **If GitLab refuses the merge, stop.** Say what it said, in its own words. Do not retry it, + do not force it, and do not change an approval rule, a protected branch or any other + project setting to get round it. A refusal is an answer, and the person who pressed the + button is reading this. +- Only once the merge has actually succeeded, delete the branch on the server with + `git push --delete -- origin refs/heads/<branch>`, using the branch the message names. If + git answers that the remote ref does not exist, it was deleted on merge and there is + nothing left to do. +- Change nothing on this machine. Do not delete the local branch, do not remove or move the + worktree, and do not check out anything else. +- Do not commit and do not push. If the worktree is holding work GitLab has not got, say in + one line that it was left behind. +- Finish by saying what happened: that it merged, and whether the branch on the server is gone. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-mr-instructions.md b/Tests/fixtures/characterisation/gitlab-mr-instructions.md new file mode 100644 index 000000000..3a6a7e664 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-mr-instructions.md @@ -0,0 +1,24 @@ +# Opening a merge request + +Bloom attaches this file when someone presses Create merge request. This copy is Bloom's own and +is invisible to git. To make it this project's, move it to `.bloom/pr-instructions.md`, edit it +to say how this project opens merge requests, and commit it. Bloom then uses that copy instead +and never writes over it, so everybody working here gets the change. + +The message this file came with names the branch to target. Call it the target branch below. + +- If this project has a skill or an instruction file about opening merge requests, follow that + first. It outranks everything here. +- Run `git status`. If anything is uncommitted, review it and commit it, following whatever + this project says about commit messages. +- Push the branch with `git push -u origin HEAD`. If it already tracks a different upstream, + push to that one instead. +- Read the whole branch with `git diff <target branch>...` before writing anything. The + description has to cover every change on the branch, not only what was done in this session. +- Open the merge request with `glab mr create --target-branch <target branch> --title <title> + --description <description> --yes`. If the repository has a merge request template, fill that in instead of + writing your own structure. Keep the title under 80 characters and the description under + five sentences. +- Say what the merge request URL is once it exists. + +If a step fails, stop and say what went wrong instead of working around it. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-prompt-continueAfterMerge.txt b/Tests/fixtures/characterisation/gitlab-prompt-continueAfterMerge.txt new file mode 100644 index 000000000..dfbf37632 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-prompt-continueAfterMerge.txt @@ -0,0 +1,5 @@ +Merge request !{{pull_request}} is merged, so {{previous_branch}} is finished with. + +This worktree is now on a new branch, {{branch}}, cut from an up to date {{base_branch}}, so everything that just landed is already underneath you. Nothing else moved: same directory, same session, and anything that was uncommitted is still here. + +Do not redo what the merge request landed, and do not start anything new yet. Say in one line that you are ready, then wait for what I ask next. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-prompt-createPullRequest.txt b/Tests/fixtures/characterisation/gitlab-prompt-createPullRequest.txt new file mode 100644 index 000000000..b72ecacbd --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-prompt-createPullRequest.txt @@ -0,0 +1 @@ +Create a merge request for this workspace against {{base_branch}}. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-prompt-fixConflicts.txt b/Tests/fixtures/characterisation/gitlab-prompt-fixConflicts.txt new file mode 100644 index 000000000..46fbc92e8 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-prompt-fixConflicts.txt @@ -0,0 +1,3 @@ +Merge request !{{number}} conflicts with {{base_branch}}, so GitLab will not merge it as it stands. Resolve that here, in this worktree, on {{branch}}: bring {{base_branch}} into this branch, work through the conflicts, commit, and push {{branch}}, so the conflict is gone for everybody rather than only here. + +Do not merge the merge request. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-prompt-mergePullRequest.txt b/Tests/fixtures/characterisation/gitlab-prompt-mergePullRequest.txt new file mode 100644 index 000000000..478dcd696 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-prompt-mergePullRequest.txt @@ -0,0 +1 @@ +Merge merge request !{{number}} into {{base_branch}} as a {{method}} ({{method_flag}}). It is on the branch {{branch}}. \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-prompt-pushLocalWork.txt b/Tests/fixtures/characterisation/gitlab-prompt-pushLocalWork.txt new file mode 100644 index 000000000..f3336a102 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-prompt-pushLocalWork.txt @@ -0,0 +1 @@ +Commit everything outstanding in this worktree, with a message that describes the change the way this project words one, then push {{branch}} so its merge request reflects what is here. If there is nothing to commit, just push. \ No newline at end of file From 2afedc89c285b9cd6a672483f8ac38ef31f83092 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 05/16] GitLab onboarding, settings, sign in and rebase --- Sources/Bloom/State/AppModel.swift | 14 +- .../State/WorkspaceModel+PullRequest.swift | 11 ++ Sources/Bloom/State/WorkspaceModel.swift | 16 +++ .../Chrome/Settings/AgentsSettingsView.swift | 21 +++ .../Views/Chrome/Settings/PromptEditor.swift | 11 +- .../Chrome/Settings/PromptSettingsView.swift | 29 ++++- .../Views/Chrome/Window/WelcomeWindow.swift | 4 +- .../Bloom/Views/Inspector/GitHubSignIn.swift | 17 +++ .../Views/Inspector/GitHubSignInSheet.swift | 82 +++++++++--- .../Bloom/Views/Inspector/InspectorView.swift | 8 +- .../Views/Inspector/PullRequestBar.swift | 14 ++ .../Views/Inspector/PullRequestSummary.swift | 17 +++ .../Views/RepoSettings/RepoSettingsView.swift | 9 ++ .../BloomCore/Bridge/WorkspaceListTool.swift | 1 + Sources/BloomCore/GitHub/ForgeRouting.swift | 2 +- .../BloomCore/GitHub/PullRequestStatus.swift | 5 +- Sources/BloomCore/GitLab/GitLab.swift | 11 ++ Sources/BloomCore/GitLab/GitLabIdentity.swift | 120 ++++++++++++++++++ Sources/BloomCore/Persistence/Settings.swift | 5 + .../Persistence/SettingsWriter.swift | 4 + .../Transcript/PromptOverrides.swift | 27 +++- .../Workspace/RepoSettingsDraft.swift | 6 + Sources/BloomCore/Workspace/SetupCheck.swift | 35 ++++- Sources/BloomCore/Workspace/SetupProbe.swift | 14 ++ Tests/BloomCoreTests/GitLabRemedyTests.swift | 21 +++ .../BloomCoreTests/GitLabSettingsTests.swift | 32 +++++ Tests/BloomCoreTests/SetupCheckTests.swift | 4 +- Tests/BloomCoreTests/SetupGitLabTests.swift | 104 +++++++++++++++ 28 files changed, 600 insertions(+), 44 deletions(-) create mode 100644 Sources/BloomCore/GitLab/GitLabIdentity.swift create mode 100644 Tests/BloomCoreTests/GitLabRemedyTests.swift create mode 100644 Tests/BloomCoreTests/GitLabSettingsTests.swift create mode 100644 Tests/BloomCoreTests/SetupGitLabTests.swift diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index e60ae430d..8d83ad33f 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -517,6 +517,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>? @@ -631,7 +640,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 cc81617b8..62ec3cfa5 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -2744,6 +2744,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<PromptID> = [] + @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 ffe688558..818bc1728 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 6f3f8af41..d3e9eea9c 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 = PaneNoticeContent(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 71062d584..7c39e037c 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<Bool, Never>] = [:] 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 bb067776a..5ba26e920 100644 --- a/Sources/BloomCore/GitLab/GitLab.swift +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -312,6 +312,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<String>) { + 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 <host> as <user> (<config path>)` 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"]) + } +} From 43b5eab1b66dff620607d72973fa539a4e91889f Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 06/16] GitLab pipeline wording, tool descriptions, shared reads and docs --- CLAUDE.md | 2 +- .../Bloom/Views/Inspector/ChecksView.swift | 14 ++- .../Views/Inspector/InspectorToolbar.swift | 1 + .../Views/Inspector/PullRequestBadge.swift | 2 +- Sources/BloomCore/Bridge/BridgeDispatch.swift | 10 +- Sources/BloomCore/GitHub/Forge.swift | 8 ++ .../BloomCore/GitHub/PullRequestStatus.swift | 11 ++- Sources/BloomCore/GitLab/GitLab.swift | 33 ++++++- Sources/BloomCore/GitLab/GitLabDecoding.swift | 5 +- .../BloomCore/GitLab/GitLabInstructions.swift | 22 +++++ Sources/BloomCore/GitLab/GitLabReads.swift | 54 +++++++++++ Sources/BloomCore/Model/WorkspaceStatus.swift | 18 +++- Tests/BloomCoreTests/GitLabCommandTests.swift | 39 +++++++- .../BloomCoreTests/GitLabDecodingTests.swift | 4 +- .../GitLabVocabularyTests.swift | 63 +++++++++++++ .../gitlab-tool-workspace_list.json | 24 +++++ .../gitlab-tool-workspace_merge.json | 25 +++++ .../gitlab-tool-workspace_start.json | 57 ++++++++++++ docs/GITLAB.md | 92 +++++++++++++++++++ 19 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 Sources/BloomCore/GitLab/GitLabReads.swift create mode 100644 Tests/BloomCoreTests/GitLabVocabularyTests.swift create mode 100644 Tests/fixtures/characterisation/gitlab-tool-workspace_list.json create mode 100644 Tests/fixtures/characterisation/gitlab-tool-workspace_merge.json create mode 100644 Tests/fixtures/characterisation/gitlab-tool-workspace_start.json create mode 100644 docs/GITLAB.md diff --git a/CLAUDE.md b/CLAUDE.md index 799b2ba36..89320b0c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ worktree on disk, which is why so much of what follows is about not destroying o Longer documents, pointed at rather than repeated here: `README.md` for what the app is, `RELEASING.md` for signing, notarising and the appcast, `docs/CODEX.md` for the Codex app-server -protocol as measured, `docs/GROK.md` for Grok's ACP over stdio, `docs/PROTOCOL.md` for Claude Code's stream-json, +protocol as measured, `docs/GROK.md` for Grok's ACP over stdio, `docs/GITLAB.md` for GitLab through `glab`, `docs/PROTOCOL.md` for Claude Code's stream-json, `docs/AGENTS-INTEGRATION.md` for how agent CLIs are detected, `docs/BRIDGE.md` for the MCP bridge an agent calls back in through and which callers may call what, `docs/PLAN.md` for what was built and in what order, `docs/start-from.html` for the design note the create sheet's source picker diff --git a/Sources/Bloom/Views/Inspector/ChecksView.swift b/Sources/Bloom/Views/Inspector/ChecksView.swift index 4e020d733..c8f3f6ae9 100644 --- a/Sources/Bloom/Views/Inspector/ChecksView.swift +++ b/Sources/Bloom/Views/Inspector/ChecksView.swift @@ -150,15 +150,19 @@ struct ChecksView: View { } else if checksUnavailable { EmptyStateView( glyph: "lock", - title: "Checks unavailable", - message: "GitHub did not let this token read check runs. A fine-grained personal " - + "access token cannot be given that permission, so their results are unknown." + title: model.forge == .gitHub ? "Checks unavailable" : "Pipeline unavailable", + message: model.forge == .gitHub + ? "GitHub did not let this token read check runs. A fine-grained personal " + + "access token cannot be given that permission, so their results are unknown." + : "GitLab did not let this token read the pipeline's jobs, so their results are unknown." ) } else if hasLoaded { EmptyStateView( glyph: "checkmark.seal", - title: "No checks", - message: "\(model.forge.name) has not reported a check run for this branch." + title: model.forge == .gitHub ? "No checks" : "No pipeline", + message: model.forge == .gitHub + ? "GitHub has not reported a check run for this branch." + : "GitLab has not reported a pipeline for this merge request." ) } else { LoadingView("Asking \(model.forge.name)") diff --git a/Sources/Bloom/Views/Inspector/InspectorToolbar.swift b/Sources/Bloom/Views/Inspector/InspectorToolbar.swift index 69547310a..a17ff66d2 100644 --- a/Sources/Bloom/Views/Inspector/InspectorToolbar.swift +++ b/Sources/Bloom/Views/Inspector/InspectorToolbar.swift @@ -184,6 +184,7 @@ struct InspectorToolbar: View { /// one file inside it, and the field holding a word is what says the list below is showing /// fewer. private func title(for tab: InspectorTab) -> String { + if tab == .checks { return model.forge.checks } guard tab == .changes, model.inspectorTab != .history, !model.changedFiles.isEmpty else { return tab.rawValue } return "\(tab.rawValue) (\(Set(model.changedFiles.map(\.path)).count))" } diff --git a/Sources/Bloom/Views/Inspector/PullRequestBadge.swift b/Sources/Bloom/Views/Inspector/PullRequestBadge.swift index 13167a0e3..4fa7d8dcd 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestBadge.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestBadge.swift @@ -71,7 +71,7 @@ struct PullRequestBadge: View { // control reports as `AXUnknown` and reads as a label rather than as something to press. .accessibilityElement(children: .ignore) .accessibilityAddTraits(.isButton) - .accessibilityLabel("Pull request \(number), \(title)") + .accessibilityLabel("\(forge.request.capitalizedFirst) \(number), \(title)") .accessibilityHint("Opens on \(forge.name)") .help("Open \(forge.reference(number)) on \(forge.name): \(title)") } diff --git a/Sources/BloomCore/Bridge/BridgeDispatch.swift b/Sources/BloomCore/Bridge/BridgeDispatch.swift index f333fc8c6..b3def9010 100644 --- a/Sources/BloomCore/Bridge/BridgeDispatch.swift +++ b/Sources/BloomCore/Bridge/BridgeDispatch.swift @@ -52,7 +52,8 @@ public struct BridgeDispatch: Sendable { // rather than left to fall through to method-not-found. return .result(id: id, .object([:])) case "tools/list": - let tools = toolbox.tools(for: identity.role).map(\.listing) + let gitLab = await callerForge() == .gitLab + let tools = toolbox.tools(for: identity.role).map { gitLab ? GitLabInstructions.listing(of: $0) : $0.listing } return .result(id: id, .object(["tools": .array(tools)])) case "tools/call": return await callTool(request, id: id) @@ -65,6 +66,13 @@ public struct BridgeDispatch: Sendable { } } + /// The forge of the workspace this agent runs in. Callers outside one, the owner included, + /// span projects and keep GitHub's descriptions. + private func callerForge() async -> Forge { + guard let id = identity.workspaceID, let workspace = try? await store.workspace(id: id) else { return .gitHub } + return await ForgeResolver.forge(for: workspace.path) + } + /// The protocol version is echoed back rather than negotiated. /// /// This server has no version-dependent behaviour: three methods, one tool, text results. diff --git a/Sources/BloomCore/GitHub/Forge.swift b/Sources/BloomCore/GitHub/Forge.swift index 8a89c39d1..159bea968 100644 --- a/Sources/BloomCore/GitHub/Forge.swift +++ b/Sources/BloomCore/GitHub/Forge.swift @@ -47,6 +47,14 @@ public enum Forge: String, Sendable, Hashable, Codable { } } + /// What the checks are called: GitLab shows a merge request's pipeline and its jobs. + public var checks: String { + switch self { + case .gitHub: "Checks" + case .gitLab: "Pipeline" + } + } + /// `#12` on GitHub, `!12` on GitLab, where `#12` is an issue. public func reference(_ number: Int) -> String { switch self { diff --git a/Sources/BloomCore/GitHub/PullRequestStatus.swift b/Sources/BloomCore/GitHub/PullRequestStatus.swift index 64d3cf67f..e0615a957 100644 --- a/Sources/BloomCore/GitHub/PullRequestStatus.swift +++ b/Sources/BloomCore/GitHub/PullRequestStatus.swift @@ -309,17 +309,17 @@ public extension PullRequest { /// to be fixed before the second one matters. private var openHeadline: String { switch checks { - case .failing: return "Checks failing" + case .failing: return "\(forge.checks) failing" // The headline has to agree with the line under it. `GitHub.rollup` tells a queued check // from a running one and says which in the summary, so a headline fixed at "Checks // running" would sit over the words "1 check queued". The summary is read back rather // than recomputed because `rollup` is its only writer and `PullRequest` carries no // rollup nodes to ask again. `WorkspaceStatusTests` pins the two lines together, in both // vocabularies, so a change to one of them fails rather than drifting. - case .pending: return checksSummary.hasSuffix("queued") ? "Checks queued" : "Checks running" + case .pending: return "\(forge.checks) \(checksSummary.hasSuffix("queued") ? "queued" : "running")" // Ahead of the review, because the review falls through to "Ready to merge", and that is // the claim nobody can make about checks nobody could read. - case .unavailable: return GitHub.checksUnavailableSummary + case .unavailable: return forge == .gitHub ? GitHub.checksUnavailableSummary : "Pipeline unavailable" case .passing, .none: break } switch reviewDecision?.uppercased() { @@ -333,7 +333,10 @@ public extension PullRequest { /// "No checks" under "Ready to merge" reads as something missing rather than as a fact. private var checksDetail: String? { // The headline already says the summary, so the line under it says why. - if checks == .unavailable { return "GitHub did not let this token read check runs" } + if checks == .unavailable { + return forge == .gitHub + ? "GitHub did not let this token read check runs" : "GitLab did not let this token read the pipeline's jobs" + } guard checks != .none, !checksSummary.isEmpty else { return nil } return checksSummary } diff --git a/Sources/BloomCore/GitLab/GitLab.swift b/Sources/BloomCore/GitLab/GitLab.swift index 5ba26e920..60c0295f1 100644 --- a/Sources/BloomCore/GitLab/GitLab.swift +++ b/Sources/BloomCore/GitLab/GitLab.swift @@ -3,7 +3,7 @@ import Foundation /// The glab boundary, read as `PullRequest` and `CheckRun`. /// /// Reads use `glab api` on the versioned REST API with the host and project named, rather than -/// glab's own JSON and remote guessing. No cache yet: a read is two or three small calls. +/// glab's own JSON and remote guessing. `GitLabReads` shares what can be shared. public enum GitLab: ForgeClient { @TaskLocal static var commandOverride: (@Sendable ([String], String?) async throws -> ShellResult)? @@ -44,7 +44,9 @@ public enum GitLab: ForgeClient { } public static func pullRequest(for workspace: Workspace, maxAge: Duration) async throws -> PullRequest? { - try await snapshot(for: workspace)?.pullRequest + try await GitLabReads.shared.snapshot(for: readKey(workspace), maxAge: maxAge) { + try await snapshot(for: workspace) + }?.pullRequest } public static func pullRequest(forBranch branch: String, worktree: String) async throws -> PullRequest? { @@ -57,7 +59,12 @@ public enum GitLab: ForgeClient { } public static func checks(for workspace: Workspace) async throws -> [CheckRun]? { - try await snapshot(for: workspace)?.runs ?? [] + let found = try await GitLabReads.shared.snapshot(for: readKey(workspace), maxAge: .zero) { + try await snapshot(for: workspace) + } + // Nil when the jobs could not be read, as on GitHub, so the tab says so. + guard let found else { return [] } + return found.pullRequest.checks == .unavailable ? nil : found.runs } public static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws { @@ -239,7 +246,12 @@ public enum GitLab: ForgeClient { // MARK: Reading one merge request - struct Snapshot { + /// A worktree whose branch changed is asking a different question. + private static func readKey(_ workspace: Workspace) -> String { + "\(workspace.path)\n\(workspace.branch)" + } + + struct Snapshot: Sendable { let pullRequest: PullRequest let runs: [CheckRun] } @@ -275,6 +287,10 @@ public enum GitLab: ForgeClient { return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: []), runs: []) } let pipelineProject = pipeline.projectId.map(String.init) ?? project.apiID + let pipelineKey = "\(project.host)/\(pipelineProject)/\(pipeline.id)" + if let runs = await GitLabReads.shared.jobs(of: pipelineKey, status: pipeline.status) { + return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: runs), runs: runs) + } // An unreadable pipeline (a private fork) means unknown checks, not no merge request. guard let jobs = try? await check( api(project.host, "projects/\(pipelineProject)/pipelines/\(pipeline.id)/jobs?per_page=\(jobLimit)"), @@ -282,6 +298,7 @@ public enum GitLab: ForgeClient { ), let runs = try? GitLabDecoding.jobs(from: Data(jobs.stdout.utf8)) else { return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: nil), runs: []) } + await GitLabReads.shared.remember(runs, of: pipelineKey, status: pipeline.status) return Snapshot(pullRequest: GitLabDecoding.pullRequest(payload, runs: runs), runs: runs) } @@ -324,8 +341,14 @@ public enum GitLab: ForgeClient { } static func project(in directory: String) async -> GitLabProject? { + guard let config = Git.repositoryPaths(in: directory).map({ ($0.commonDirectory as NSString).appendingPathComponent("config") }) + else { return nil } + let written = (try? FileManager.default.attributesOfItem(atPath: config))?[.modificationDate] as? Date + if let known = await GitLabReads.shared.project(in: directory, configWritten: written) { return known } let context = try? await Git.repositoryContext(in: directory) - return GitLabProject(remote: context?.baseRemoteURL) + let project = GitLabProject(remote: context?.baseRemoteURL) + await GitLabReads.shared.store(project, in: directory, configWritten: written) + return project } private static let notAProject = GitHubError("This repository has no GitLab remote Bloom can read.") diff --git a/Sources/BloomCore/GitLab/GitLabDecoding.swift b/Sources/BloomCore/GitLab/GitLabDecoding.swift index da39b9109..9c58e4b6c 100644 --- a/Sources/BloomCore/GitLab/GitLabDecoding.swift +++ b/Sources/BloomCore/GitLab/GitLabDecoding.swift @@ -8,6 +8,7 @@ enum GitLabDecoding { let id: Int /// The project the pipeline ran in, which for a merge request from a fork is the fork. let projectId: Int? + let status: String? } struct Author: Decodable { @@ -74,7 +75,9 @@ enum GitLabDecoding { static func pullRequest(_ payload: MergeRequestPayload, runs: [CheckRun]?) -> PullRequest { let detailed = payload.detailedMergeStatus ?? "" let state = state(payload.state) - let (checks, summary) = runs.map(GitHub.rollup) ?? (.unavailable, GitHub.checksUnavailableSummary) + let (checks, rollup) = runs.map(GitHub.rollup) ?? (.unavailable, "Pipeline unavailable") + // GitLab's word for a check run is a job. + let summary = rollup.replacingOccurrences(of: "check", with: "job") return PullRequest( number: payload.iid ?? 0, title: payload.title ?? "", diff --git a/Sources/BloomCore/GitLab/GitLabInstructions.swift b/Sources/BloomCore/GitLab/GitLabInstructions.swift index 0dd060e2d..bb858d51b 100644 --- a/Sources/BloomCore/GitLab/GitLabInstructions.swift +++ b/Sources/BloomCore/GitLab/GitLabInstructions.swift @@ -70,6 +70,28 @@ public enum GitLabInstructions { } } + /// A tool as an agent in a GitLab workspace is told about it: the same tool and schema, with + /// every description in GitLab's words. Field names, such as `include_github`, do not move. + public static func listing(of tool: BridgeTool) -> JSONValue { + describedForGitLab(tool.listing) + } + + private static func describedForGitLab(_ value: JSONValue, key: String? = nil) -> JSONValue { + switch value { + case .string(let text) where key == "description": + return .string(translate([ + // `#123` stays: it works on both forges, and a start may target a GitHub project. + ("`gh pr merge`", "`glab mr merge`"), ("gh call", "glab call"), + ].reduce(text) { $0.replacingOccurrences(of: $1.0, with: $1.1) })) + case .object(let fields): + return .object(fields.reduce(into: [:]) { $0[$1.key] = describedForGitLab($1.value, key: $1.key) }) + case .array(let items): + return .array(items.map { describedForGitLab($0) }) + default: + return value + } + } + static func translate(_ text: String) -> String { [ ("Pull request #", "Merge request !"), ("pull request #", "merge request !"), diff --git a/Sources/BloomCore/GitLab/GitLabReads.swift b/Sources/BloomCore/GitLab/GitLabReads.swift new file mode 100644 index 000000000..d61b15bbe --- /dev/null +++ b/Sources/BloomCore/GitLab/GitLabReads.swift @@ -0,0 +1,54 @@ +import Foundation + +/// What GitLab reads can share: one read in flight per workspace, the last answer for a caller +/// that allows one, the jobs of a pipeline that has finished, which cannot change, and each +/// directory's project until its git config is written. +actor GitLabReads { + static let shared = GitLabReads() + /// Final until somebody retries a job, which moves the pipeline back to running and so misses. + private static let finishedPipelines: Set<String> = ["success", "failed", "canceled", "skipped"] + + private var snapshots: [String: (snapshot: GitLab.Snapshot?, at: ContinuousClock.Instant)] = [:] + private var flights: [String: Task<GitLab.Snapshot?, Error>] = [:] + private var finishedJobs: [String: (status: String, runs: [CheckRun])] = [:] + private var projects: [String: (configWritten: Date?, project: GitLabProject?)] = [:] + + func snapshot( + for worktree: String, maxAge: Duration, + read: @escaping @Sendable () async throws -> GitLab.Snapshot? + ) async throws -> GitLab.Snapshot? { + if maxAge > .zero, let entry = snapshots[worktree], entry.at.duration(to: .now) <= maxAge { + return entry.snapshot + } + if let flight = flights[worktree] { return try await flight.value } + let flight = Task { try await read() } + flights[worktree] = flight + defer { flights[worktree] = nil } + let snapshot = try await flight.value + snapshots[worktree] = (snapshot, .now) + return snapshot + } + + /// The jobs read when the pipeline last had this same final status. + func jobs(of pipeline: String, status: String?) -> [CheckRun]? { + guard let status, let entry = finishedJobs[pipeline], entry.status == status else { return nil } + return entry.runs + } + + func remember(_ runs: [CheckRun], of pipeline: String, status: String?) { + guard let status, Self.finishedPipelines.contains(status) else { + finishedJobs[pipeline] = nil + return + } + finishedJobs[pipeline] = (status, runs) + } + + func project(in directory: String, configWritten: Date?) -> GitLabProject?? { + guard let entry = projects[directory], entry.configWritten == configWritten else { return nil } + return .some(entry.project) + } + + func store(_ project: GitLabProject?, in directory: String, configWritten: Date?) { + projects[directory] = (configWritten, project) + } +} diff --git a/Sources/BloomCore/Model/WorkspaceStatus.swift b/Sources/BloomCore/Model/WorkspaceStatus.swift index f157cddb7..65deafd1a 100644 --- a/Sources/BloomCore/Model/WorkspaceStatus.swift +++ b/Sources/BloomCore/Model/WorkspaceStatus.swift @@ -152,6 +152,20 @@ public enum WorkspaceStatus: String, Sendable, Hashable, CaseIterable, Codable { } } + /// The label in a forge's words. GitHub's are `label`'s. + public func label(for forge: Forge) -> String { + guard forge == .gitLab else { return label } + switch self { + case .closed: return "Merge request closed" + case .checksFailing: return "Pipeline failing" + case .checksRunning: return "Pipeline running" + case .checksPassed: return "Pipeline passed" + case .draft: return "Draft merge request" + case .pullRequestOpen: return "Merge request open" + default: return label + } + } + /// Whether this verdict came from GitHub rather than from the worktree. /// /// **Both halves are written out, and the `default` that used to stand for the second one is @@ -187,7 +201,7 @@ public enum WorkspaceStatus: String, Sendable, Hashable, CaseIterable, Codable { guard describesPullRequest, let pullRequest else { return label } let forge = pullRequest.forge - var text = "\(label), \(forge.request) \(forge.reference(pullRequest.number))" + var text = "\(label(for: forge)), \(forge.request) \(forge.reference(pullRequest.number))" if let detail = detail(pullRequest: pullRequest) { text += ": \(detail)" } return text } @@ -206,7 +220,7 @@ public enum WorkspaceStatus: String, Sendable, Hashable, CaseIterable, Codable { // read off the band rather than copied from it, so the two cannot drift. if self == .conflicted { return pullRequest.status.detail } let detail = pullRequest.checksSummary - guard !detail.isEmpty, detail != label else { return nil } + guard !detail.isEmpty, detail != label(for: pullRequest.forge) else { return nil } return detail } } diff --git a/Tests/BloomCoreTests/GitLabCommandTests.swift b/Tests/BloomCoreTests/GitLabCommandTests.swift index 72f287313..70909add1 100644 --- a/Tests/BloomCoreTests/GitLabCommandTests.swift +++ b/Tests/BloomCoreTests/GitLabCommandTests.swift @@ -55,7 +55,8 @@ struct GitLabCommandTests { func unreadableJobs() async throws { let repo = try await TempRepo() defer { repo.cleanUp() } - try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:gitlab-org/cli.git"], cwd: repo.path) + // Its own host, so the finished pipeline another test reads is not served from the cache. + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.unreadable.example:gitlab-org/cli.git"], cwd: repo.path) let detail = try fixture("mr-not-approved.json") let found = try await GitLab.$commandOverride.withValue({ arguments, _ in @@ -105,3 +106,39 @@ private actor GlabLog { commands.append(arguments) } } + +@Suite("GitLab read sharing") +struct GitLabReadSharingTests { + @Test("a finished pipeline's jobs are read once, a running one's every time") + func finishedJobs() async { + let reads = GitLabReads() + let runs = [CheckRun(name: "test", status: "COMPLETED", conclusion: "SUCCESS")] + await reads.remember(runs, of: "host/1/10", status: "running") + #expect(await reads.jobs(of: "host/1/10", status: "running") == nil) + await reads.remember(runs, of: "host/1/11", status: "failed") + #expect(await reads.jobs(of: "host/1/11", status: "failed") == runs) + // A retried job moves the same pipeline back to running: the old jobs must not be served. + #expect(await reads.jobs(of: "host/1/11", status: "running") == nil) + } + + @Test("an answer is reused only for a caller that allows one") + func maxAge() async throws { + let reads = GitLabReads() + let counter = ReadCounter() + for _ in 0..<2 { + _ = try await reads.snapshot(for: "/tmp/w", maxAge: .seconds(60)) { await counter.read() } + } + #expect(await counter.count == 1) + _ = try await reads.snapshot(for: "/tmp/w", maxAge: .zero) { await counter.read() } + #expect(await counter.count == 2) + } +} + +private actor ReadCounter { + private(set) var count = 0 + + func read() -> GitLab.Snapshot? { + count += 1 + return nil + } +} diff --git a/Tests/BloomCoreTests/GitLabDecodingTests.swift b/Tests/BloomCoreTests/GitLabDecodingTests.swift index 61d6e031a..e91795eee 100644 --- a/Tests/BloomCoreTests/GitLabDecodingTests.swift +++ b/Tests/BloomCoreTests/GitLabDecodingTests.swift @@ -18,7 +18,7 @@ struct GitLabDecodingTests { #expect(pullRequest.reviewDecision == "REVIEW_REQUIRED") #expect(pullRequest.blockers == [.approvalRequired]) #expect(pullRequest.checks == .failing) - #expect(pullRequest.checksSummary == "1 required check failed") + #expect(pullRequest.checksSummary == "1 required job failed") #expect(pullRequest.status.text == "Waiting for approval") #expect(pullRequest.status.canMerge == false) #expect(pullRequest.status.blockedReason == "This merge request needs an approval first.") @@ -40,7 +40,7 @@ struct GitLabDecodingTests { #expect(pullRequest.closedAt != nil) #expect(pullRequest.blockers.isEmpty) #expect(pullRequest.status.blockedReason == "This merge request is already merged.") - #expect(WorkspaceStatus.merged.summary(pullRequest: pullRequest) == "Merged, merge request !4015: No checks") + #expect(WorkspaceStatus.merged.summary(pullRequest: pullRequest) == "Merged, merge request !4015: No jobs") } @Test("unresolved threads block the merge") diff --git a/Tests/BloomCoreTests/GitLabVocabularyTests.swift b/Tests/BloomCoreTests/GitLabVocabularyTests.swift new file mode 100644 index 000000000..5b73489d6 --- /dev/null +++ b/Tests/BloomCoreTests/GitLabVocabularyTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("GitLab vocabulary") +struct GitLabVocabularyTests { + @Test("a GitLab merge request has a pipeline of jobs, a GitHub pull request has checks") + func pipeline() { + let failing: (Forge) -> PullRequest = { forge in + PullRequest( + number: 1, title: "", url: "", state: "OPEN", checks: .failing, + checksSummary: "1 required check failed", forge: forge + ) + } + #expect(failing(.gitHub).status.text == "Checks failing") + #expect(failing(.gitLab).status.text == "Pipeline failing") + #expect(WorkspaceStatus.checksFailing.label(for: .gitHub) == "Checks failing") + #expect(WorkspaceStatus.checksFailing.label(for: .gitLab) == "Pipeline failing") + #expect(WorkspaceStatus.draft.label(for: .gitLab) == "Draft merge request") + #expect(WorkspaceStatus.clean.label(for: .gitLab) == WorkspaceStatus.clean.label) + } +} + +@Suite("GitLab turns in the transcript") +struct GitLabSentTurnTests { + @Test("a GitLab merge turn shows its rules as a chip, like GitHub's") + func mergeTurnChip() { + let turn = ProjectInstructions.turn("Merge merge request !1 into main.", for: .merge, adding: .nothing, forge: .gitLab) + #expect(SentTurn.withoutInstructions(turn) == "Merge merge request !1 into main.") + #expect(SentTurn.title(forFile: GitLabInstructions.mergeRequestScratchPath) == SentTurn.mergeRequestTitle) + } +} + +@Suite("GitLab tool descriptions") +struct GitLabToolDescriptionTests { + private static let tools = [ + WorkspaceListTool().tool, + WorkspaceMergeTool(read: { _ in .noPullRequest }) { _, _, _ in .turnBegun(chat: "Merge") }.tool, + WorkspaceStartTool(start: { _, _, _, _ in throw CancellationError() }).tool, + ] + + @Test("an agent in a GitLab workspace is told about glab and merge requests, in the same schema") + func gitLabListings() throws { + for tool in Self.tools { + let listing = GitLabInstructions.listing(of: tool) + let text = BridgeToolResult.json(listing).text + expectCharacterised(text, "gitlab-tool-\(tool.name).json") + #expect(!text.contains("`gh ")) + #expect(!text.localizedCaseInsensitiveContains("pull request")) + guard case .object(let fields) = listing, case .object(let original) = tool.listing else { + Issue.record("A listing is an object") + continue + } + #expect(fields["name"] == original["name"]) + #expect(Set(propertyNames(fields["inputSchema"])) == Set(propertyNames(original["inputSchema"]))) + } + } + + private func propertyNames(_ schema: JSONValue?) -> [String] { + guard case .object(let fields) = schema, case .object(let properties) = fields["properties"] else { return [] } + return Array(properties.keys) + } +} diff --git a/Tests/fixtures/characterisation/gitlab-tool-workspace_list.json b/Tests/fixtures/characterisation/gitlab-tool-workspace_list.json new file mode 100644 index 000000000..9f4cb862f --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-tool-workspace_list.json @@ -0,0 +1,24 @@ +{ + "description" : "The workspaces Bloom has: what each one is, what state it is in, and where its worktree is on disk. This is how you find out what became of a workspace you started, since workspace_start answers before any work happens and nothing waits for it.\n\nEach one carries its id, name, branch, base branch and worktree path, its project, whether it is active or archived, how far its setup script got, whether an agent is running in it, whether one has stopped on a permission question and what it asked, whether there is output nobody has read, the size of its diff, who asked for it, its chats with their cost and context size, and any messages queued for those chats with the reason the queue is not moving.\n\nDo not call it in a loop to wait for a workspace you started: pass notify_when_done to workspace_start instead, and Bloom tells your chat when that work comes to rest.\n\nThe worktree path is the most useful thing here. It is an ordinary git checkout, so read the diff, the log and the files in it with your own tools rather than asking Bloom for them.\n\nagent_running is about right now, and a workspace with nothing running is still a workspace: most of them sit idle most of the time, waiting to be read and merged. project_list counts these same rows, so its workspaces for a project is how many appear here for it and its agents_running is how many of those are marked agent_running. If the two ever look as though they disagree, one of them is being read as the other's number.\n\nBy default it reads Bloom's database and nothing else, and at that price GitLab is not consulted at all: there is no merge request, no checks, and the status can never say merged, closed, draft or anything about checks. A default call has not looked, so do not report that a workspace has no merge request on the strength of one. Pass include_github to ask, which costs one glab call and one git call per workspace and is slow on a long list.\n\nThe diff counts and the states come from Bloom's database, which a running Bloom refreshes every few seconds. With Bloom closed they are as old as the last time it was open. Read only. It changes nothing and starts nothing.\n", + "inputSchema" : { + "properties" : { + "include_archived" : { + "description" : "Include workspaces that have been archived. They have no worktree left on disk. Off by default.", + "type" : "boolean" + }, + "include_github" : { + "description" : "Include merge request and check state. Costs one glab call and one git call per workspace, so it is slow on a long list. Off by default.", + "type" : "boolean" + }, + "project" : { + "description" : "Narrow it to one project, by the name or the path project_list reports. Leave it out for every project.", + "type" : "string" + } + }, + "required" : [ + + ], + "type" : "object" + }, + "name" : "workspace_list" +} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-tool-workspace_merge.json b/Tests/fixtures/characterisation/gitlab-tool-workspace_merge.json new file mode 100644 index 000000000..2fded4370 --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-tool-workspace_merge.json @@ -0,0 +1,25 @@ +{ + "description" : "Ask a workspace's own agent to merge its merge request.\n\nThis does not merge anything. It composes the request Bloom's agent-guided merge choice composes, with the project's merge instructions attached, and sends it into that workspace's chat as an ordinary message. The agent runs `glab mr merge` there, in front of the owner, under whatever permission mode they set, and can say what GitLab answered if it refuses.\n\nSo it returns once the turn has begun, and the merging happens inside that turn, after this call is over. Nothing has landed on GitLab when this answers, and the merge may still not happen. Do not report a merge request as merged on the strength of this call. Call workspace_list with include_github afterwards and read the state.\n\nIt refuses a merge request GitLab will not take, a worktree holding work GitLab has not got, and a workspace whose agent is busy. When it refuses, do not run `glab mr merge` yourself and do not ask another agent to. Merging publishes to a server other people share, and this tool only asks the workspace's own agent to do it.\n\nName the workspace by the id workspace_list reports, not by its name. It squash merges unless you say otherwise.", + "inputSchema" : { + "properties" : { + "method" : { + "description" : "How to merge it. Leave it out for squash, which is what Bloom's own button proposes.", + "enum" : [ + "merge", + "squash", + "rebase" + ], + "type" : "string" + }, + "workspace" : { + "description" : "The workspace whose merge request to merge, by the id workspace_list reports. Not its name: two workspaces may share one.", + "type" : "string" + } + }, + "required" : [ + "workspace" + ], + "type" : "object" + }, + "name" : "workspace_merge" +} \ No newline at end of file diff --git a/Tests/fixtures/characterisation/gitlab-tool-workspace_start.json b/Tests/fixtures/characterisation/gitlab-tool-workspace_start.json new file mode 100644 index 000000000..9395f318b --- /dev/null +++ b/Tests/fixtures/characterisation/gitlab-tool-workspace_start.json @@ -0,0 +1,57 @@ +{ + "description" : "Start a workspace and give it a task. It is a real git worktree on its own branch with its own agent, and it appears in Bloom's sidebar for the owner to watch and review.\n\nName the project to start it in with 'project', giving the name or the path that project_list reports. Bloom only starts workspaces in repositories it already has, so a project that is not on that list has to be registered with project_add first. If you are yourself running inside a Bloom workspace, leave 'project' out to start it in the project you are already in, or name another project to hand the work to that repository instead. The new workspace is still counted as one you started.\n\nUse it when a task splits into parts that do not need to see each other's edits, and you want them worked on at the same time rather than one after another.\n\nThere are two ways to start it, the two Bloom's own create window offers, and picking the wrong one is the mistake worth avoiding here.\n\n'New branch' is the default and needs nothing said. Commits land on a new branch, and merge into the branch you pick here. Name the branch to cut from with 'base_branch', or leave it out for the project's default branch.\n\n'Existing branch' is 'existing_branch'. Commits land on the branch you pick here, and merge when it does. Use it to carry on, review or fix work that is already on a branch: cutting a new branch off that branch instead gives you a workspace whose diff is empty, because it starts out identical to the branch you named. The branch has to exist already, and git allows one worktree per branch, so a branch another workspace is sitting on is refused rather than opened twice.\n\nTo review an existing GitLab merge request, use 'pull_request' with its number, '#123', or its GitLab URL. Bloom checks out the merge request itself, preserving its base and identity so the Changes, Checks and Merge controls refer to that merge request. Do not create a review branch with base_branch for this purpose.\n\nName only one of base_branch, existing_branch or pull_request.\n\n'model' and 'effort' are optional. Leave either out to inherit the calling chat's setting, or Bloom's default when the caller has no chat. An explicit effort must be one the selected model supports.\n\nIt returns as soon as the workspace exists, not when its work is done. The new agent starts on its own and keeps running while you carry on. There is no way to wait for it from here, so do not ask for one and then sit idle: say what you started and get on with your own work.\n\nPass notify_when_done: true to have Bloom tell this chat once, by itself, when the new agent's first turn comes to rest: finished (with its last message), failed (with the reason), or blocked waiting on the owner for a permission prompt or a question. With it, there is no need to tell the new agent to report back when it is done.\n\nThe task you give it is all it gets. It cannot see this conversation, so write the prompt as if to someone who has just opened the project for the first time.\n\nThis costs real money and real disk. Start one because the work genuinely divides, not to parallelise something you could do in a single pass. A workspace that was itself started this way cannot start others.", + "inputSchema" : { + "properties" : { + "agent" : { + "description" : "Which agent runs it. Leave it out for the one you are running on, or for Bloom's own default if you are not running in Bloom.", + "enum" : [ + "claudeCode", + "codex", + "grok" + ], + "type" : "string" + }, + "base_branch" : { + "description" : "Cut a new branch from this one. Your commits land on the new branch and merge back into this one. Leave it out for the project's default branch. Do not name it together with existing_branch.", + "type" : "string" + }, + "effort" : { + "description" : "The exact reasoning effort id, such as low, medium or high. Leave it out to inherit the calling chat's effort, or Bloom's default when the caller has no chat.", + "type" : "string" + }, + "existing_branch" : { + "description" : "Work on this branch itself, instead of cutting a new one. Your commits land on it. It has to exist already, locally or on the remote, and it must not be open in another workspace. Do not name it together with base_branch.", + "type" : "string" + }, + "model" : { + "description" : "The exact model id. Claude Code models are opus, sonnet, fable and haiku. Codex model ids come from the signed-in Codex account, for example gpt-5.6-sol. Do not use a Claude Code model with codex or a Codex model with claudeCode. Leave this out to use the selected agent's default model.", + "type" : "string" + }, + "name" : { + "description" : "What to call it in the sidebar. Leave it out and Bloom names it from the task.", + "type" : "string" + }, + "notify_when_done" : { + "description" : "Have Bloom tell this chat once when the new agent's first turn comes to rest: finished, failed, or waiting on the owner. Defaults to false.", + "type" : "boolean" + }, + "project" : { + "description" : "Which project to start it in, by the name or the path project_list reports. Inside a Bloom workspace, leave it out for the project you are in, or name another to start the work there.", + "type" : "string" + }, + "prompt" : { + "description" : "The task, written for someone with no context beyond the project itself.", + "type" : "string" + }, + "pull_request" : { + "description" : "Open this GitLab merge request itself for review, by number, #number or GitLab URL. This preserves the PR connection so Bloom can show checks and merge it. Do not combine it with base_branch or existing_branch.", + "type" : "string" + } + }, + "required" : [ + "prompt" + ], + "type" : "object" + }, + "name" : "workspace_start" +} \ No newline at end of file diff --git a/docs/GITLAB.md b/docs/GITLAB.md new file mode 100644 index 000000000..bd7adba11 --- /dev/null +++ b/docs/GITLAB.md @@ -0,0 +1,92 @@ +# GitLab + +Bloom speaks to GitLab through `glab`, the GitLab CLI, the way it speaks to GitHub through `gh`. A +project on GitLab gets the merge request strip, its pipeline's jobs, a failed job's log handed to +the agent, create and merge directly or through the agent, ready and rebase, and workspaces started +from a merge request, in GitLab's own words. Checked against `glab` 1.92.1 and the REST API v4. + +The rule every part of it follows: **nothing changes for a project on GitHub.** The one deliberate +exception is the onboarding checks screen, which shows an optional "GitLab CLI" row to everybody. +It never changes the verdict or its sentence. + +## Which forge a project uses + +`ForgeRouting.decide`, applied to a directory by `ForgeResolver`. First match wins: + +| Situation | Forge | +|---|---| +| `git.forge = "github"` or `"gitlab"` in a settings file | as stated | +| Any remote on a host containing `github` (github.com, Enterprise, an SSH alias) | GitHub | +| Base remote on gitlab.com | GitLab | +| Another host, `glab` installed and `glab auth status --hostname <host>` succeeds | GitLab | +| Anything else, including no remote | GitHub | + +The answer is cached per directory until git's config or a settings file is written. Where the +config file's own text settles it, which is every GitHub remote and every machine without `glab`, +no process runs at all. The `glab` probe runs at most once per host at a time, a yes is kept for +the launch and a no for five minutes. + +## What runs + +Reads go through `glab api --hostname <host>` against the REST API, naming the project rather +than letting `glab` guess it from the remotes. `GitLabReads` shares them: one read in flight per +workspace, `maxAge` honoured as on GitHub, and the jobs of a finished pipeline read once. + +| Need | Call | +|---|---| +| Merge requests from a branch | `projects/<path>/merge_requests?source_branch=<branch>&state=all` | +| One merge request | `projects/<path>/merge_requests/<iid>` | +| Its pipeline's jobs | `projects/<head_pipeline.project_id>/pipelines/<id>/jobs`, the source project for a fork | +| A failed job's log | `projects/<job's project>/jobs/<id>/trace` | +| Mark ready | `glab mr update <iid> --ready -R <project URL>` | +| Workspace from a merge request | `glab mr checkout <iid> --branch <name> -R <project URL>` | + +Creating and merging offer the same two routes as on GitHub, the agent or Bloom itself, and +merging directly is the default. Rebasing and resolving conflicts are turns sent to the agent. The +agent's steps are `GitLabInstructions`; the direct ones are in `GitLab`: + +| Direct route | Call | +|---|---| +| Create | `glab mr create --source-branch <branch> --target-branch <base> --title <title> --description <commits> --yes -R <project URL>`, after Bloom commits and pushes | +| Merge | `glab mr merge <iid> --yes --auto-merge=false --sha <head> [--squash] -R <project URL>` | +| Rebase merge | `glab mr rebase <iid>` first, then the merge above at the new head | + +Without `--auto-merge=false` glab sets "merge when the pipeline succeeds" instead of merging. +glab's own `--rebase` rebases and then GitLab refuses the `--sha` it was given, because the head +has moved, so neither route uses it. The direct rebase merge goes ahead only if the rebased branch +holds the commits that were confirmed and no others. The description is what `gh pr create --fill` +writes: one commit's body, or the subjects of several. Not glab's `--fill`, which pushes again. + +A merge request is found by its source branch, which GitLab keeps after the branch is deleted, so +no number is recorded and no column was added. A fork's merge request with the same branch name is +ignored unless the worktree was checked out from it. + +## What GitLab says that GitHub has no word for + +`detailed_merge_status` becomes `PullRequest.blockers`: waiting for approval, unresolved threads, +needs rebase, pipeline must pass, and so on. Any blocker turns the merge button off with GitLab's +reason, and an unknown status is read as a refusal, never as mergeable. Needs rebase offers Rebase. +The GitHub decoder never fills `blockers`. + +## Settings + +- `git.forge`, in the project's settings screen as "Hosted on" when `glab` is installed. +- `git.branch_prefix_type = "gitlab_username"`, the user `glab` is signed in as on the project's + host, read once per launch from `glab auth status` and again after a sign in. +- Prompt overrides for GitLab live under `prompts.gitLab.<id>`, edited from the GitLab side of the + prompt settings. A GitHub override never reaches a GitLab project; prompts with no GitLab + variant are shared. +- `.bloom/pr-instructions.md` and the other instruction files are shared: they are the team's own + words, and a project uses one forge. + +## What stays GitHub's + +Creating a repository, the onboarding verdict, the `gh` row and its sign in, App Intents +identifiers, the sidebar legend (it is not about one project), and the tool descriptions an agent +outside a GitLab workspace sees. + +## Tests + +`Tests/fixtures/gitlab` holds real REST answers from gitlab.com (`gitlab-org/cli`, 2026-10-08), +trimmed to the fields Bloom reads and with authors anonymised. The texts an agent is told on +GitLab are pinned under `Tests/fixtures/characterisation/gitlab-*`, beside GitHub's. From 800f35b9f15289adaed34e0f1535bf17f58e5085 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 16:01:32 +0200 Subject: [PATCH 07/16] Ask which forge a project uses when its remotes are on both --- Sources/Bloom/State/AppModel+Projects.swift | 26 +++++++ Sources/Bloom/State/AppModel.swift | 8 ++ .../Window/WorkspaceHoverCardView.swift | 2 +- .../Views/Inspector/PullRequestSummary.swift | 8 +- Sources/Bloom/Views/RootView.swift | 14 ++++ .../Views/Sidebar/StartProjectView.swift | 2 + .../BloomCore/Bridge/WorkspaceMergeTool.swift | 10 ++- Sources/BloomCore/GitHub/ForgeRouting.swift | 78 +++++++++++++++---- .../Presentation/WorkspaceHoverCard.swift | 6 +- Tests/BloomCoreTests/ForgeRoutingTests.swift | 29 +++++++ .../WorkspaceMergeToolTests.swift | 18 +++++ docs/GITLAB.md | 10 ++- 12 files changed, 183 insertions(+), 28 deletions(-) diff --git a/Sources/Bloom/State/AppModel+Projects.swift b/Sources/Bloom/State/AppModel+Projects.swift index f8369408b..e9d14850a 100644 --- a/Sources/Bloom/State/AppModel+Projects.swift +++ b/Sources/Bloom/State/AppModel+Projects.swift @@ -34,6 +34,8 @@ extension AppModel { case .alreadyRepository(let root): guard let repo = await addKnownRepository(at: root) else { return nil } if focusing { await focus(repo) } + // Not awaited: a glab probe to a host behind a VPN that is down takes seconds. + Task { await askForgeIfBoth(repo) } return repo case .refuse(let refusal): @@ -113,6 +115,30 @@ extension AppModel { )) } + /// Asks which forge `repo` uses when its remotes are on both, and returns once it is answered. + func askForgeIfBoth(_ repo: Repo) async { + guard forgeQuestion == nil, await ForgeResolver.offersBoth(repo.path), forgeQuestion == nil + else { return } + await withCheckedContinuation { answered in + forgeQuestionAnswered = answered + forgeQuestion = repo + } + } + + /// The answer to `forgeQuestion`, kept for every workspace of the project. `nil` is Not now. + func answerForgeQuestion(_ forge: Forge?, for repo: Repo? = nil) { + if let forge, let repo { + do { + try ForgeResolver.remember(forge, for: repo.path) + } catch { + alert = BloomAlert(title: "Could not save the forge", message: error.readableMessage) + } + } + let answered = forgeQuestionAnswered + forgeQuestionAnswered = nil + answered?.resume() + } + /// Called by `ProjectSetupSheet` once the folder really is a repository. func finishProjectSetup(_ path: String?) async { ProjectSetup.shared.dismiss() diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index 8d83ad33f..e72737648 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -289,6 +289,14 @@ final class AppModel { /// Non-nil after an archive script failed, while the owner decides between keeping the /// workspace and archiving it without the script. RootView presents it. var pendingScriptFailure: ScriptFailureRequest? + /// A project just added with remotes on both GitHub and GitLab. RootView asks which one it + /// uses. See `ForgeResolver.offersBoth`. + var forgeQuestion: Repo? { + // A dialog dismissed without its buttons must still let `askForgeIfBoth` return. + didSet { if forgeQuestion == nil { Task { self.answerForgeQuestion(nil) } } } + } + /// Resumed once `forgeQuestion` is answered, so the create window can wait for it. + @ObservationIgnored var forgeQuestionAnswered: CheckedContinuation<Void, Never>? /// The transcript half of a search, one row per workspace. Held here rather than in `HomeView` /// for the same reason `homeFilter` is: the pane is destroyed and rebuilt every time the /// selection leaves Home, and a result list that had to be fetched again on the way back would diff --git a/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift b/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift index bfedee43c..9205ec27a 100644 --- a/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift +++ b/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift @@ -176,7 +176,7 @@ struct WorkspaceHoverCardView: View { // `verbatim`, for the reason `PullRequestBadge` writes down: an interpolated // `Int` inside a `LocalizedStringKey` is formatted for the locale, and a // machine set to Dutch drew "#2.631" for a pull request GitHub calls 2631. - Text(verbatim: "#\(pullRequest.number)") + Text(verbatim: pullRequest.forge.reference(pullRequest.number)) .font(Typo.caption) .monospacedDigit() .foregroundStyle(Palette.textSecondary) diff --git a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift index 7c39e037c..724fad29e 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift @@ -401,7 +401,7 @@ struct PullRequestSummary: View { } private var archiveHelp: String { - return "Remove this workspace's worktree. #\(pullRequest.number) is merged, so " + return "Remove this workspace's worktree. \(pullRequest.forge.reference(pullRequest.number)) is merged, so " + "this asks first only when something here exists nowhere else." } @@ -434,7 +434,7 @@ struct PullRequestSummary: View { branchActions.reason ?? "Ask this workspace's agent to \(pushLabel.lowercased()) branch " + "\(pullRequest.branch.isEmpty ? "this branch" : pullRequest.branch), so " - + "#\(pullRequest.number) is what is on this disk." + + "\(pullRequest.forge.reference(pullRequest.number)) is what is on this disk." ) } @@ -502,7 +502,7 @@ struct PullRequestSummary: View { .help( branchActions.reason ?? "Ask this workspace's agent to bring \(baseBranch) into this worktree and " - + "resolve the conflicts here. Nothing is pushed and #\(pullRequest.number)" + + "resolve the conflicts here. Nothing is pushed and \(pullRequest.forge.reference(pullRequest.number))" + " is not merged." ) } @@ -582,7 +582,7 @@ struct PullRequestSummary: View { /// request is this" without spending any of the strip's width on an answer the reader already /// has from the workspace name. private var helpText: String { - var text = "#\(pullRequest.number) \(pullRequest.title)" + var text = "\(pullRequest.forge.reference(pullRequest.number)) \(pullRequest.title)" if let detail = status.detail { text += "\n\(detail)" } if let reason = status.blockedReason { text += "\n\(reason)" } return text diff --git a/Sources/Bloom/Views/RootView.swift b/Sources/Bloom/Views/RootView.swift index ed32ecc35..25d412016 100644 --- a/Sources/Bloom/Views/RootView.swift +++ b/Sources/Bloom/Views/RootView.swift @@ -152,6 +152,20 @@ struct RootView: View { // `ArchiveRequest` in the core, where it can be tested. Text(request.message) } + // Asked when a project with remotes on both forges is added and no forge is declared. + // Not now writes nothing: detection stays in charge, which picks GitHub, and asks again. + .confirmationDialog( + "Which forge does this project use?", + isPresented: $app.forgeQuestion.isPresent(), + titleVisibility: .visible, + presenting: app.forgeQuestion + ) { repo in + Button(Forge.gitLab.name) { app.answerForgeQuestion(.gitLab, for: repo) } + Button(Forge.gitHub.name) { app.answerForgeQuestion(.gitHub, for: repo) } + Button("Not now", role: .cancel) { app.answerForgeQuestion(nil) } + } message: { repo in + Text("\(repo.name) has remotes on GitHub and GitLab. Every workspace of the project will use the one you choose, and you can change it in the project settings. Until you choose, Bloom uses GitHub.") + } // The question asked before a session that is still working is closed. On the window for // the reason the archive confirmation above is: it is raised from the tab strip's close // button and from Cmd+W in the menu bar, and there is no one control both of those could diff --git a/Sources/Bloom/Views/Sidebar/StartProjectView.swift b/Sources/Bloom/Views/Sidebar/StartProjectView.swift index ad824a23a..1cd7521e1 100644 --- a/Sources/Bloom/Views/Sidebar/StartProjectView.swift +++ b/Sources/Bloom/Views/Sidebar/StartProjectView.swift @@ -702,6 +702,8 @@ struct StartProjectView: View { // asking its root view to present it. openWindow(id: BloomApp.mainWindowID) guard let repo else { return } + // Answered before New Workspace goes up: the main window shows one sheet at a time. + await app.askForgeIfBoth(repo) Task { @MainActor in await Task.yield() NotificationCenter.default.post(name: .bloomNewWorkspace, object: repo) diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index 03905d191..9342c5427 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -306,14 +306,16 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } private func note(workspace: Workspace, pullRequest: PullRequest, chat: String) -> String { - """ + let forge = pullRequest.forge + return """ Nothing is merged. A turn has begun in '\(workspace.name)', in the chat '\(chat)': its \ - agent has been asked to merge #\(pullRequest.number) and it runs `gh pr merge` itself, \ + agent has been asked to merge \(forge.reference(pullRequest.number)) and it runs \ + `\(forge.command("merge"))` itself, \ where the owner can watch it and answer anything it asks. Bloom does not wait for that \ turn and there is no way to wait for it from here, so do not sit idle. The merge may still \ - not happen: GitHub is allowed to refuse, and the agent is told to stop and report a \ + not happen: \(forge.name) is allowed to refuse, and the agent is told to stop and report a \ refusal rather than force it. To find out what became of it, call workspace_list with \ - include_github and read the pull request's state. + include_github and read the \(forge.request)'s state. """ } diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift index 9eb9c9f5a..6b55876ec 100644 --- a/Sources/BloomCore/GitHub/ForgeRouting.swift +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -29,9 +29,17 @@ public enum ForgeRouting { static func isGitLabCom(_ host: String) -> Bool { host == "gitlab.com" || host.hasSuffix(".gitlab.com") } + + /// The hosts beside a GitHub remote that could make the repository GitLab's as well. Empty + /// without a GitHub remote, because then `decide` answers without anybody being asked. + static func hostsBesideGitHub(_ hosts: [String]) -> Set<String> { + guard hosts.contains(where: isGitHub) else { return [] } + return Set(hosts.filter { !isGitHub($0) }) + } } -/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes. +/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes, +/// or for as long as a glab refusal lasts when that refusal is what made it GitHub. public enum ForgeResolver { private static let resolutions = ForgeResolutions() @@ -53,28 +61,65 @@ public enum ForgeResolver { 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) + let (forge, lasting) = await resolve(directory: directory, checkout: checkout, configPath: configPath) + // GitHub because glab said no is kept no longer than that no: a VPN that was down comes back. + let expiry = lasting ? nil : ContinuousClock.now + GlabSignIn.refusalLifetime + await resolutions.store(forge, for: directory, fingerprint: fingerprint, expiry: expiry) return forge } - private static func resolve(directory: String, checkout: String, configPath: String?) async -> Forge { - if let declared = SettingsLoader.load(repo: checkout).forge { return declared } + private static func resolve( + directory: String, checkout: String, configPath: String? + ) async -> (forge: Forge, lasting: Bool) { + if let declared = SettingsLoader.load(repo: checkout).forge { return (declared, true) } // 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 } + if hosts.contains(where: ForgeRouting.isGitHub) { return (.gitHub, true) } + if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return (.gitHub, true) } - 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) + let remotes = await configuredRemoteURLs(in: directory) 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 + case .forge(let decided): return (decided, true) + case .askGlab(let host): + let signedIn = await GlabSignIn.shared.isSignedIn(to: host) + return (signedIn ? .gitLab : .gitHub, signedIn) } } + /// Whether a repository has remotes on both forges and no `git.forge` settling it. `decide` + /// would quietly pick GitHub there, which is wrong for a GitLab project mirrored to GitHub, + /// so the owner is asked instead and the answer goes through `remember`. Never without glab: + /// GitLab is no answer there, and the settings screen hides the choice for the same reason. + public static func offersBoth(_ directory: String) async -> Bool { + guard GlabSignIn.isInstalled() else { return false } + let checkout = mainCheckout(Git.repositoryPaths(in: directory)) ?? directory + guard SettingsLoader.load(repo: checkout).forge == nil else { return false } + let hosts = await configuredRemoteURLs(in: directory).compactMap { GitHub.repositoryHost($0) } + let others = ForgeRouting.hostsBesideGitHub(hosts) + if others.contains(where: ForgeRouting.isGitLabCom) { return true } + for host in others where await GlabSignIn.shared.isSignedIn(to: host) { return true } + return false + } + + /// Writes the owner's answer to `.bloom/settings.local.toml` in the project's own checkout, + /// which `forge(for:)` reads for every workspace. Not the shared file: remotes are this + /// machine's git config, so a teammate with only the GitHub remote must not inherit it. + public static func remember(_ forge: Forge, for directory: String) throws { + let checkout = mainCheckout(Git.repositoryPaths(in: directory)) ?? directory + let path = (checkout as NSString).appendingPathComponent(".bloom/settings.local.toml") + var document = SettingsDocument(contentsOf: path) + SettingsWriter.apply(.forge(forge), to: &document) + SettingsWriter.prepareFolder(for: path, repo: checkout) + try document.write(to: path) + } + + private static func configuredRemoteURLs(in directory: String) async -> [String] { + let config = (try? await Git.repositoryConfiguration(in: directory)) ?? [:] + return config.filter { $0.key.hasPrefix("remote.") && $0.key.hasSuffix(".url") }.map(\.value) + } + /// 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] { @@ -97,15 +142,16 @@ public enum ForgeResolver { } private actor ForgeResolutions { - private var entries: [String: (fingerprint: [Date?], forge: Forge)] = [:] + private var entries: [String: (fingerprint: [Date?], forge: Forge, expiry: ContinuousClock.Instant?)] = [:] func forge(for directory: String, fingerprint: [Date?]) -> Forge? { - guard let entry = entries[directory], entry.fingerprint == fingerprint else { return nil } + guard let entry = entries[directory], entry.fingerprint == fingerprint, + entry.expiry.map({ ContinuousClock.now < $0 }) ?? true else { return nil } return entry.forge } - func store(_ forge: Forge, for directory: String, fingerprint: [Date?]) { - entries[directory] = (fingerprint, forge) + func store(_ forge: Forge, for directory: String, fingerprint: [Date?], expiry: ContinuousClock.Instant?) { + entries[directory] = (fingerprint, forge, expiry) } } @@ -114,7 +160,7 @@ private actor ForgeResolutions { actor GlabSignIn { static let shared = GlabSignIn() private static let timeout = Duration.seconds(10) - private static let refusalLifetime = Duration.seconds(300) + static let refusalLifetime = Duration.seconds(300) private var answers: [String: (signedIn: Bool, at: ContinuousClock.Instant)] = [:] private var probes: [String: Task<Bool, Never>] = [:] diff --git a/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift b/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift index 0ca5b1dbe..416926ec6 100644 --- a/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift +++ b/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift @@ -33,11 +33,13 @@ public struct WorkspaceHoverCard: Sendable, Hashable { public var number: Int public var title: String public var url: String + public var forge: Forge - public init(number: Int, title: String, url: String) { + public init(number: Int, title: String, url: String, forge: Forge = .gitHub) { self.number = number self.title = title self.url = url + self.forge = forge } } @@ -100,7 +102,7 @@ public struct WorkspaceHoverCard: Sendable, Hashable { /// `PullRequestOwnership` drops it upstream, which is where that rule lives. static func reference(to pullRequest: PullRequest?) -> PullRequestRef? { pullRequest.map { - PullRequestRef(number: $0.number, title: $0.title, url: $0.url) + PullRequestRef(number: $0.number, title: $0.title, url: $0.url, forge: $0.forge) } } } diff --git a/Tests/BloomCoreTests/ForgeRoutingTests.swift b/Tests/BloomCoreTests/ForgeRoutingTests.swift index bbc5f9327..4605211d3 100644 --- a/Tests/BloomCoreTests/ForgeRoutingTests.swift +++ b/Tests/BloomCoreTests/ForgeRoutingTests.swift @@ -58,6 +58,35 @@ struct ForgeRoutingTests { #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) } + @Test("only hosts beside a GitHub remote can make a repository ambiguous", arguments: [ + (hosts: ["github.com", "gitlab.com"], others: Set(["gitlab.com"])), + (hosts: ["github-work", "git.example.fr"], others: Set(["git.example.fr"])), + (hosts: ["gitlab.com", "git.example.fr"], others: Set<String>()), + (hosts: ["github.com"], others: Set<String>()), + ]) + func hostsBesideGitHub(hosts: [String], others: Set<String>) { + #expect(ForgeRouting.hostsBesideGitHub(hosts) == others) + } + + @Test("a repository on both forges asks once, and the answer stays on this machine", .tags(.git)) + func choice() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/app.git"], cwd: repo.path) + try await Shell.check("git", ["remote", "add", "mirror", "git@github.com:acme/app.git"], cwd: repo.path) + let glabInstalled: @Sendable ([String], String?) async throws -> ShellResult = { _, _ in ShellResult(status: 1, stdout: "", stderr: "") } + let asked = await GitLab.$commandOverride.withValue(glabInstalled) { await ForgeResolver.offersBoth(repo.path) } + #expect(asked) + #expect(await ForgeResolver.forge(for: repo.path) == .gitHub) + + try ForgeResolver.remember(.gitLab, for: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + let askedAgain = await GitLab.$commandOverride.withValue(glabInstalled) { await ForgeResolver.offersBoth(repo.path) } + #expect(askedAgain == false) + #expect(FileManager.default.fileExists(atPath: "\(repo.path)/.bloom/settings.local.toml")) + #expect(FileManager.default.fileExists(atPath: "\(repo.path)/.bloom/settings.toml") == false) + } + @Test("remote addresses are read from git's config file without git") func remoteURLs() { let config = "[remote \"origin\"]\n\turl = git@github.com:acme/app.git\n\tpushurl = x\n[remote \"up\"]\n\turl=\"https://gitlab.com/g/a\"\n" diff --git a/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift b/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift index df30e0f8b..62d49d85d 100644 --- a/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift +++ b/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift @@ -565,6 +565,24 @@ struct WorkspaceMergeToolTests { #expect(!json["state"]!.stringValue!.contains("merge")) } + @Test("on GitLab the answer names the merge request, glab and GitLab") + func theAnswerOnGitLab() async throws { + let store = try makeTestStore("merge-answer-gitlab") + let target = try await workspace(in: store) + var mergeRequest = Self.open() + mergeRequest.forge = .gitLab + + let result = await tool(reading: .found(mergeRequest, local: nil)).call( + request(["workspace": .string(target.id.rawValue)]), as: .owner, store: store + ) + + let note = try #require(try answer(result)["note"]?.stringValue) + #expect(note.contains("asked to merge !42 and it runs `glab mr merge` itself")) + #expect(note.contains("GitLab is allowed to refuse")) + #expect(note.contains("read the merge request's state")) + #expect(!note.contains("gh ")) + } + @Test("squash is what it proposes, and the other two are carried through unaltered") func methods() async throws { let store = try makeTestStore("merge-methods") diff --git a/docs/GITLAB.md b/docs/GITLAB.md index bd7adba11..297e44f1b 100644 --- a/docs/GITLAB.md +++ b/docs/GITLAB.md @@ -21,7 +21,15 @@ It never changes the verdict or its sentence. | Another host, `glab` installed and `glab auth status --hostname <host>` succeeds | GitLab | | Anything else, including no remote | GitHub | -The answer is cached per directory until git's config or a settings file is written. Where the +A project added with a GitHub remote and a GitLab one (gitlab.com, or a host `glab` is signed in +to) and no `git.forge` is asked which forge it uses. The answer is written as `git.forge` to +`.bloom/settings.local.toml` in the project's checkout, which every workspace reads: remotes are +this machine's git config, so the answer is not committed for the team. "Not now" writes nothing: the +table above stays in charge, so GitHub, and the question comes back the next time the folder is +added. + +The answer is cached per directory until git's config or a settings file is written, or for five +minutes when it is GitHub only because `glab` said no. Where the config file's own text settles it, which is every GitHub remote and every machine without `glab`, no process runs at all. The `glab` probe runs at most once per host at a time, a yes is kept for the launch and a no for five minutes. From 89f92b90309925ad7082ee5e1fe04ff382b2286c Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 8 Oct 2026 11:44:06 +0200 Subject: [PATCH 08/16] Run shell commands from the composer with a ! prefix A draft starting with ! runs in the worktree on StreamingProcess, with the workspace's variables and without provider credentials, and its output goes to the agent as the next message once it has finished. See spatie/bloom#7. --- Sources/Bloom/State/TranscriptModel.swift | 7 +- .../State/WorkspaceModel+ShellCommands.swift | 50 ++++++ .../Composer/ComposerShellRunView.swift | 45 ++++++ .../Views/Center/Composer/ComposerView.swift | 15 ++ .../GitHub/CheckFailureHandoff.swift | 2 +- .../BloomCore/System/ShellCommandRun.swift | 58 +++++++ .../BloomCore/Transcript/ShellCommand.swift | 114 +++++++++++++ Tests/BloomCoreTests/ShellCommandTests.swift | 151 ++++++++++++++++++ 8 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 Sources/Bloom/State/WorkspaceModel+ShellCommands.swift create mode 100644 Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift create mode 100644 Sources/BloomCore/System/ShellCommandRun.swift create mode 100644 Sources/BloomCore/Transcript/ShellCommand.swift create mode 100644 Tests/BloomCoreTests/ShellCommandTests.swift diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index 4153eb448..aaa9a2e21 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -273,6 +273,11 @@ final class TranscriptModel { /// as a pane changes what it shows. See `PromptRecall`. @ObservationIgnored var promptRecall = PromptRecall() + /// The `!` command running from this chat's composer. On the session for the reason + /// `promptRecall` is: the composer is handed from chat to chat. See `WorkspaceModel+ShellCommands`. + var shellRun: ShellCommand.Run? + @ObservationIgnored var shellRunTask: Task<Void, Never>? + /// What has been asked for on this session and has not gone yet, oldest first. /// /// Read by the transcript to draw the pending bubbles and by the drain to decide what goes @@ -1554,7 +1559,7 @@ final class TranscriptModel { return runner } - private var usesInteractiveTerminal: Bool { + var usesInteractiveTerminal: Bool { guard let workspaceID = session.workspaceID else { return false } CenterTabStore.shared.load(workspaceID: workspaceID) return CenterTabStore.shared.terminal(for: session.id, in: workspaceID) != nil diff --git a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift new file mode 100644 index 000000000..6a13c67cb --- /dev/null +++ b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift @@ -0,0 +1,50 @@ +import BloomCore + +/// Running a `!` command typed into one of this workspace's chats, and handing its output to the +/// agent once it has finished. Here rather than on the transcript because the command gets the +/// workspace's variables, the ones a terminal pane gets, and the workspace is what knows them. +/// What the command is and what the agent is told are `ShellCommand`'s; this is the doing. +extension WorkspaceModel { + /// Whether the command was started. Refused while another is running in the same chat, so the + /// draft stays where it is rather than vanishing into nothing. + func runShellCommand(_ command: String, from transcript: TranscriptModel) -> Bool { + if transcript.usesInteractiveTerminal { + app.alert = BloomAlert( + title: "This agent runs in a terminal", + message: "Open its agent tab and run the command there." + ) + return false + } + guard transcript.shellRun == nil else { + app.notice = BloomNotice(message: "A command is already running in this chat.") + return false + } + + var variables: [String: String] = [:] + if let repo, let manager = app.manager { + variables = manager.environment(for: workspace, repo: repo, port: port) + } + let cwd = transcript.cwd + transcript.shellRun = ShellCommand.Run(command: command) + transcript.shellRunTask = Task { @MainActor [weak transcript, app] in + let ending = await ShellCommandRun.run(command, cwd: cwd, variables: variables) { line in + transcript?.shellRun?.output.append(line) + } + guard let transcript else { return } + let output = transcript.shellRun?.output ?? ShellCommand.Output() + transcript.shellRun = nil + transcript.shellRunTask = nil + // Stopped by hand is somebody changing their mind, so nothing goes to the agent. + guard !Task.isCancelled else { + app.notice = BloomNotice(message: "The command was stopped. Nothing was sent to the agent.") + return + } + _ = await transcript.submit(ShellCommand.message(command: command, output: output, ending: ending)) + } + return true + } + + func stopShellCommand(in transcript: TranscriptModel) { + transcript.shellRunTask?.cancel() + } +} diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift new file mode 100644 index 000000000..42bf751cd --- /dev/null +++ b/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import BloomCore + +/// Above the composer: what a `!` draft will do before it is sent, and the command's last lines +/// while it runs, with a way to stop it. The output goes to the agent as a message once the +/// command has finished, so this only has to show that it is alive. See `ShellCommand`. +struct ComposerShellRunView: View { + var transcript: TranscriptModel + var model: WorkspaceModel + + /// Enough to see a test runner moving, few enough not to push the transcript off the window. + private static let visibleLines = 6 + + var body: some View { + if let run = transcript.shellRun { + VStack(alignment: .leading, spacing: Metrics.spacingSmall) { + HStack(spacing: Metrics.spacing) { + ProgressView().controlSize(.small) + Text("$ \(run.command)") + .font(Typo.codeSmall) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + Button("Stop") { model.stopShellCommand(in: transcript) } + .help("Stop the command. Nothing is sent to the agent.") + } + if !run.output.isEmpty { + Text(run.output.recent(Self.visibleLines).joined(separator: "\n")) + .font(Typo.codeTiny) + .foregroundStyle(Palette.textSecondary) + .lineLimit(Self.visibleLines) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + .padding(Metrics.spacingSmall) + } else if ShellCommand.command(in: transcript.draft) != nil { + Text("Runs in the worktree. The agent gets the output when it finishes.") + .font(Typo.caption) + .foregroundStyle(Palette.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Metrics.spacingSmall) + } + } +} diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 5875df45b..fa5dc14c7 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -90,6 +90,7 @@ struct ComposerView: View { .help("Drag to resize. Double-click to fit the text.") ComposerPlansView(transcript: transcript, model: model, controls: controls) + if let model { ComposerShellRunView(transcript: transcript, model: model) } composer } // The chrome is whatever is left once the editor's share is taken off, so this settles on @@ -466,6 +467,20 @@ struct ComposerView: View { guard canSend else { return } draftSaveTask?.cancel() + // Only here, the path of a draft somebody typed. A message from another agent or from the + // bridge reaches `submit` without passing through this, so it can never run a command. + if let command = ShellCommand.command(in: transcript.draft) { + guard let model else { + app.notice = BloomNotice(message: "Shell commands run in a workspace chat.") + return + } + guard model.runShellCommand(command, from: transcript) else { return } + transcript.draft = "" + caret = 0 + saveDraftNow() + return + } + if let question = SideConversation.question(in: transcript.draft) { guard canOpenSideConversation, let model else { app.notice = BloomNotice(message: "Use /btw in a workspace chat to open a side conversation.") diff --git a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift index ae366a1ef..835b68744 100644 --- a/Sources/BloomCore/GitHub/CheckFailureHandoff.swift +++ b/Sources/BloomCore/GitHub/CheckFailureHandoff.swift @@ -189,7 +189,7 @@ public enum CheckFailureHandoff { return String(line[line.index(after: space)...]) } - private static func stripAnsi(_ line: String) -> String { + static func stripAnsi(_ line: String) -> String { guard line.contains("\u{1B}") else { return line } var out = "" var scanning = false diff --git a/Sources/BloomCore/System/ShellCommandRun.swift b/Sources/BloomCore/System/ShellCommandRun.swift new file mode 100644 index 000000000..c6e544862 --- /dev/null +++ b/Sources/BloomCore/System/ShellCommandRun.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Running a `!` command from the composer. See `ShellCommand`. +/// +/// **`StreamingProcess`, the way setup scripts run, and neither of the other two ways Bloom has.** +/// `Shell.run` returns nothing until the process has ended, so a two minute `npm test` would look +/// like a composer that had hung. A terminal tab streams, but a command typed into a shell there +/// reports no exit status (see `RunScriptActivity`), and finishing is what hands the output to the +/// agent. +/// +/// **Stdin is closed straight away.** Nobody can type into this process, so a command that reads +/// stdin (`cat` with no file, a confirmation prompt) gets end of file and goes on, rather than +/// waiting for ever on a pipe nobody writes to. +public enum ShellCommandRun { + /// How long a stopped command has to exit after SIGTERM before it is killed, as for setup. + static let stopGrace: Duration = .seconds(5) + + /// Runs `command` in the user's shell, handing each line to `onLine` in order, and returns how + /// it ended. Cancelling the calling task stops it. + /// + /// `variables` are the workspace's own, the ones a terminal pane gets. The environment crosses + /// `untrustedProcess` like a terminal's does: the command is the user's, but `npm test` runs the + /// repository's code, and that is exactly what the boundary is for. + public static func run( + _ command: String, + cwd: String, + variables: [String: String], + onLine: @escaping @MainActor @Sendable (String) -> Void + ) async -> ProcessEnding { + // A PATH that arrives a moment late is a `command not found` for something the user has + // installed. Usually free: the probe finished long before anybody typed a `!`. + await LoginShellPath.ready() + let process = StreamingProcess( + executable: LoginShell.path(), + arguments: ["-c", command], + cwd: cwd, + environment: ProviderCredentialEnvironment.untrustedProcess(Shell.environment(extra: variables)) + ) + // A launch failure is reported through `lines`, which is read below. + try? process.start() + process.closeStdin() + + await withTaskCancellationHandler { + do { + for try await line in process.lines { await onLine(line) } + } catch { + await onLine("\(error)") + } + } onCancel: { + process.terminate() + Task.detached { + try? await Task.sleep(for: stopGrace) + process.kill() + } + } + return await process.ending + } +} diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift new file mode 100644 index 000000000..bca4ea54d --- /dev/null +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -0,0 +1,114 @@ +import Foundation + +/// A composer draft that starts with `!`: a shell command run in the worktree rather than a message, +/// whose output goes to the agent as the next message once it has finished. +/// +/// This is Claude Code's shell mode, done by Bloom. stream-json has no such mode, and Codex and Grok +/// have nothing like it either, so the output reaches the agent as an ordinary user message and every +/// agent gets it the same way. The agent answers it, which is the point: `! npm test` gets an +/// explanation of the failures without a second prompt. +public enum ShellCommand { + /// The command a draft asks to run, or nil when the draft is a message. + /// + /// The `!` has to be the first character, as in Claude Code, so a message that merely contains + /// one further in is never run. A lone `!` is nil: there is nothing to run. + public static func command(in draft: String) -> String? { + guard draft.hasPrefix("!") else { return nil } + let command = draft.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) + return command.isEmpty ? nil : command + } + + /// A command running from a chat's composer, and what it has printed so far. + public struct Run: Sendable, Equatable { + public var command: String + public var output = Output() + + public init(command: String) { + self.command = command + } + } + + /// What a command printed, kept from both ends as it arrives. + /// + /// Both ends for the reason `CheckFailureHandoff.excerpt` gives: the tail is where a failure + /// usually is, and a build that failed on its first file says so at the top. Kept as it arrives + /// rather than cut at the end, because the whole output of `yes` or a chatty build is not + /// something to hold in memory only to throw most of it away. + public struct Output: Sendable, Equatable { + static let headLines = CheckFailureHandoff.headLines + static let tailLines = CheckFailureHandoff.tailLines + /// One line of minified output can be a megabyte, and the line budget cannot see that. + static let maxLineCharacters = 2_000 + static let maxCharacters = CheckFailureHandoff.maxCharacters + + public private(set) var head: [String] = [] + public private(set) var tail: [String] = [] + public private(set) var droppedLines = 0 + + public init() {} + + public var isEmpty: Bool { head.isEmpty } + + public mutating func append(_ raw: String) { + let line = Self.clean(raw) + guard head.count == Self.headLines else { + head.append(line) + return + } + tail.append(line) + if tail.count > Self.tailLines { + tail.removeFirst() + droppedLines += 1 + } + } + + /// The last lines printed, for watching the command run. + public func recent(_ count: Int) -> [String] { + Array((head + tail).suffix(count)) + } + + /// What the agent is shown. The cut admits it is a cut, so the agent does not reason about + /// a run it has only seen part of as if it had seen all of it. + /// + /// The character cap keeps the end, where the line budget keeps both: what is left by then + /// is lines too long to read whole, and the last of them is where a command says how it + /// ended. + public var text: String { + var lines = head + if droppedLines > 0 { lines += ["", "[\(droppedLines) lines of this output are not shown]", ""] } + let joined = (lines + tail).joined(separator: "\n") + guard joined.count > Self.maxCharacters else { return joined } + return "[the start of this output is not shown]\n" + String(joined.suffix(Self.maxCharacters)) + } + + /// A progress bar redraws its line with carriage returns, and only the last redraw is what + /// a terminal would have left on screen. Colour is stripped because a text box has no + /// terminal behind it, and an escape sequence costs tokens and reads as corruption. + static func clean(_ raw: String) -> String { + let shown = raw.split(separator: "\r").last.map(String.init) ?? "" + let plain = CheckFailureHandoff.stripAnsi(shown) + guard plain.count > maxLineCharacters else { return plain } + return String(plain.prefix(maxLineCharacters)) + " [the rest of this line is not shown]" + } + } + + /// The message the agent receives once the command has finished. + public static func message(command: String, output: Output, ending: ProcessEnding) -> String { + let fence = fence(enclosing: command + output.text) + let ran = "I ran this in the worktree:\n\n\(fence)sh\n\(command)\n\(fence)\n\nIt \(ending.sentence)" + guard !output.isEmpty else { return ran + " and printed nothing." } + return ran + ". Its output:\n\n\(fence)\n\(output.text)\n\(fence)" + } + + /// A Markdown fence longer than any run of backticks inside it, so output that prints a fence of + /// its own cannot close this one early. + static func fence(enclosing text: String) -> String { + var longest = 0 + var current = 0 + for character in text { + current = character == "`" ? current + 1 : 0 + longest = max(longest, current) + } + return String(repeating: "`", count: max(3, longest + 1)) + } +} diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift new file mode 100644 index 000000000..32c5c90a3 --- /dev/null +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Running a `!` command from the composer", .timeLimit(.minutes(1))) +struct ShellCommandTests { + // MARK: - Which drafts are commands + + @Test("A draft starting with `!` is the command after it") + func readsCommand() { + #expect(ShellCommand.command(in: "!npm test") == "npm test") + #expect(ShellCommand.command(in: "! git status \n") == "git status") + } + + @Test("A `!` anywhere but first, or with nothing after it, is a message") + func leavesMessagesAlone() { + #expect(ShellCommand.command(in: "Ship it!") == nil) + #expect(ShellCommand.command(in: " !ls") == nil) + #expect(ShellCommand.command(in: "!") == nil) + #expect(ShellCommand.command(in: "! ") == nil) + } + + // MARK: - What is kept of the output + + @Test("Short output is kept whole") + func keepsShortOutput() { + var output = ShellCommand.Output() + output.append("one") + output.append("two") + #expect(output.text == "one\ntwo") + #expect(output.recent(1) == ["two"]) + } + + @Test("Long output keeps both ends and says how much went from the middle") + func keepsBothEnds() { + var output = ShellCommand.Output() + let total = ShellCommand.Output.headLines + ShellCommand.Output.tailLines + 5 + for index in 1...total { output.append("line \(index)") } + + #expect(output.droppedLines == 5) + #expect(output.head.first == "line 1") + #expect(output.tail.last == "line \(total)") + #expect(output.text.contains("[5 lines of this output are not shown]")) + #expect(!output.text.contains("line \(ShellCommand.Output.headLines + 1)\n")) + } + + @Test("A progress bar keeps its last redraw, without colour") + func cleansLines() { + #expect(ShellCommand.Output.clean("10%\r50%\r100%") == "100%") + #expect(ShellCommand.Output.clean("\u{1B}[31mfailed\u{1B}[0m") == "failed") + } + + @Test("A huge line is cut, and the whole message stays under the cap") + func capsSize() { + var output = ShellCommand.Output() + let huge = String(repeating: "x", count: ShellCommand.Output.maxLineCharacters * 3) + for _ in 0..<200 { output.append(huge) } + + #expect(output.head[0].hasSuffix("[the rest of this line is not shown]")) + #expect(output.text.hasPrefix("[the start of this output is not shown]")) + #expect(output.text.count <= ShellCommand.Output.maxCharacters + 100) + } + + // MARK: - What the agent is told + + @Test("The message names the command, how it ended and what it printed") + func composesMessage() { + var output = ShellCommand.Output() + output.append("1 failing") + let message = ShellCommand.message(command: "npm test", output: output, ending: .exited(1)) + + #expect(message == "I ran this in the worktree:\n\n```sh\nnpm test\n```\n\n" + + "It exited with status 1. Its output:\n\n```\n1 failing\n```") + } + + @Test("A command that printed nothing says so rather than showing an empty block") + func saysNothingPrinted() { + let message = ShellCommand.message(command: "true", output: .init(), ending: .exited(0)) + #expect(message.hasSuffix("It exited with status 0 and printed nothing.")) + } + + @Test("Output holding a fence cannot close the message's own") + func outgrowsFences() { + var output = ShellCommand.Output() + output.append("````") + let message = ShellCommand.message(command: "cat README.md", output: output, ending: .exited(0)) + #expect(message.contains("`````sh\n")) + } + + // MARK: - Running + + @Test("Lines arrive in order, stderr included, and the status is the command's own") + @MainActor + func runs() async { + let collected = Collected() + let ending = await ShellCommandRun.run( + "echo one; echo two >&2; exit 3", cwd: Self.directory, variables: [:] + ) { collected.lines.append($0) } + + #expect(collected.lines == ["one", "two"]) + #expect(ending == .exited(3)) + } + + @Test("A command reading stdin gets end of file rather than waiting for ever") + @MainActor + func closesStdin() async { + let collected = Collected() + let ending = await ShellCommandRun.run("cat; echo done", cwd: Self.directory, variables: [:]) { + collected.lines.append($0) + } + + #expect(collected.lines == ["done"]) + #expect(ending == .exited(0)) + } + + @Test("The workspace's variables arrive and provider credentials do not") + @MainActor + func crossesCredentialBoundary() async throws { + let credential = try #require(ProviderCredentialEnvironment.prohibitedNames.min()) + let collected = Collected() + _ = await ShellCommandRun.run( + "echo \"$BLOOM_SHELL_TEST\"; echo \"${\(credential):-absent}\"", + cwd: Self.directory, + variables: ["BLOOM_SHELL_TEST": "workspace", credential: "secret"] + ) { collected.lines.append($0) } + + #expect(collected.lines == ["workspace", "absent"]) + } + + @Test("Cancelling stops the command") + @MainActor + func stops() async { + let (started, signal) = AsyncStream.makeStream(of: Void.self) + let run = Task { @MainActor in + await ShellCommandRun.run("echo started; exec sleep 600", cwd: Self.directory, variables: [:]) { _ in + signal.yield() + } + } + for await _ in started { break } + run.cancel() + + #expect(await run.value == .signalled(SIGTERM)) + } + + private static let directory = FileManager.default.temporaryDirectory.path + + @MainActor + private final class Collected { + var lines: [String] = [] + } +} From faa9f9789aa66419f68d1dff61e803970bb84b0f Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 8 Oct 2026 11:59:48 +0200 Subject: [PATCH 09/16] Draw a ! command in the transcript the way Claude Code does The message now uses Claude Code's bash-input and bash-stdout tags, which Claude models already read as a command the user ran, and the transcript reads them back to draw the command after a ! and its output under a corner instead of the raw message in a bubble. --- .../Views/Transcript/PendingTurnRowView.swift | 1 + .../Transcript/ShellCommandRowView.swift | 58 +++++++++++++++++++ .../Views/Transcript/TranscriptRowView.swift | 4 +- .../BloomCore/Transcript/ShellCommand.swift | 54 ++++++++++++----- Tests/BloomCoreTests/ShellCommandTests.swift | 43 +++++++++----- 5 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 Sources/Bloom/Views/Transcript/ShellCommandRowView.swift diff --git a/Sources/Bloom/Views/Transcript/PendingTurnRowView.swift b/Sources/Bloom/Views/Transcript/PendingTurnRowView.swift index 794c87458..c18c6d1a6 100644 --- a/Sources/Bloom/Views/Transcript/PendingTurnRowView.swift +++ b/Sources/Bloom/Views/Transcript/PendingTurnRowView.swift @@ -125,6 +125,7 @@ struct PendingTurnRowView: View { /// typed words and a count. The full text goes to the agent untouched; only the drawing of /// the wait is summarised, the same bargain the sent bubble makes with its chips. private var displayText: String { + if let command = ShellCommand.split(delivery.body) { return "! \(command.command)" } guard let review = ReviewTurn.split(delivery.body) else { return attachmentTurn.body } diff --git a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift new file mode 100644 index 000000000..b7040bf3a --- /dev/null +++ b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import BloomCore + +/// A `!` command the user ran, drawn the way Claude Code draws one: the command after a `!`, and +/// what it printed under a `└`. Not a bubble, because the message under it is tags the reader never +/// typed and the output is a terminal's, which reads as one only in a fixed width face. See +/// `ShellCommand`. +struct ShellCommandRowView: View { + var sent: ShellCommand.Sent + + /// About a screenful of a test run's summary. The rest is one click away. + private static let collapsedLines = 10 + + @State private var isExpanded = false + + private var lines: [String] { + sent.output.isEmpty ? [] : sent.output.components(separatedBy: "\n") + } + + private var shownLines: [String] { + isExpanded ? lines : Array(lines.prefix(Self.collapsedLines)) + } + + var body: some View { + VStack(alignment: .leading, spacing: TranscriptLayout.tight) { + HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { + Text("!").foregroundStyle(Palette.accent) + Text(sent.command).foregroundStyle(Palette.textPrimary) + } + .font(Typo.code) + + HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { + Text("└").foregroundStyle(Palette.textTertiary) + VStack(alignment: .leading, spacing: TranscriptLayout.tight) { + if lines.isEmpty { + Text("(no output)").foregroundStyle(Palette.textSecondary) + } else { + Text(shownLines.joined(separator: "\n")).foregroundStyle(Palette.textSecondary) + } + if lines.count > Self.collapsedLines { + Button(isExpanded ? "Show less" : "Show all \(lines.count) lines") { + isExpanded.toggle() + } + .linkButton() + } + if !sent.succeeded { + Text("It \(sent.status).").foregroundStyle(Palette.textSecondary) + } + } + } + .font(Typo.codeSmall) + } + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, TranscriptLayout.inset) + .padding(.vertical, TranscriptLayout.inset) + } +} diff --git a/Sources/Bloom/Views/Transcript/TranscriptRowView.swift b/Sources/Bloom/Views/Transcript/TranscriptRowView.swift index 1f8e9e3c5..f54831fcf 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptRowView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptRowView.swift @@ -111,7 +111,9 @@ struct TranscriptRowView: View, Equatable { // Nothing is taken out for the instructions Bloom appends. They stay in the text and // the bubble draws each of them as a chip where it sits: see `SentTurn`, and the row // this used to hand a separate string to. - if let review = ReviewTurn.split(typed) { + if let command = ShellCommand.split(typed) { + ShellCommandRowView(sent: command) + } else if let review = ReviewTurn.split(typed) { UserTurnRowView( text: review.message, reviewChips: review.chips, diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift index bca4ea54d..104db44c7 100644 --- a/Sources/BloomCore/Transcript/ShellCommand.swift +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -93,22 +93,48 @@ public enum ShellCommand { } /// The message the agent receives once the command has finished. + /// + /// In the tags Claude Code itself writes for a `!` command, which Claude models already read as + /// "the user ran this", and which any other agent reads as plainly as prose. They are also what + /// lets `split` find the turn again without guessing, so the transcript can draw it as a + /// command rather than as the text the agent was given. public static func message(command: String, output: Output, ending: ProcessEnding) -> String { - let fence = fence(enclosing: command + output.text) - let ran = "I ran this in the worktree:\n\n\(fence)sh\n\(command)\n\(fence)\n\nIt \(ending.sentence)" - guard !output.isEmpty else { return ran + " and printed nothing." } - return ran + ". Its output:\n\n\(fence)\n\(output.text)\n\(fence)" + "\(Tag.input)\(command)\(Tag.inputEnd)\n\(Tag.output)\(output.text)\(Tag.outputEnd)\n" + + "\(Tag.status)\(ending.sentence)\(Tag.statusEnd)" } - /// A Markdown fence longer than any run of backticks inside it, so output that prints a fence of - /// its own cannot close this one early. - static func fence(enclosing text: String) -> String { - var longest = 0 - var current = 0 - for character in text { - current = character == "`" ? current + 1 : 0 - longest = max(longest, current) - } - return String(repeating: "`", count: max(3, longest + 1)) + /// A sent command, read back out of the message `message` wrote. + public struct Sent: Sendable, Equatable { + public var command: String + public var output: String + /// How it ended, in `ProcessEnding.sentence`'s words. + public var status: String + + public var succeeded: Bool { status == ProcessEnding.exited(0).sentence } + } + + /// The command a user turn carried, or nil for every turn `message` did not write. Strict, like + /// `ReviewTurn.split`: anything not exactly that shape is drawn as the text it is. The output is + /// read up to the last closing tag, so output that prints the tag itself is kept whole. + public static func split(_ text: String) -> Sent? { + guard text.hasPrefix(Tag.input), text.hasSuffix(Tag.statusEnd), + let inputEnd = text.range(of: Tag.inputEnd + "\n" + Tag.output), + let outputEnd = text.range(of: Tag.outputEnd + "\n" + Tag.status, options: .backwards), + inputEnd.upperBound <= outputEnd.lowerBound + else { return nil } + return Sent( + command: String(text[text.index(text.startIndex, offsetBy: Tag.input.count)..<inputEnd.lowerBound]), + output: String(text[inputEnd.upperBound..<outputEnd.lowerBound]), + status: String(text[outputEnd.upperBound...].dropLast(Tag.statusEnd.count)) + ) + } + + private enum Tag { + static let input = "<bash-input>" + static let inputEnd = "</bash-input>" + static let output = "<bash-stdout>" + static let outputEnd = "</bash-stdout>" + static let status = "<bash-status>" + static let statusEnd = "</bash-status>" } } diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift index 32c5c90a3..2d011041a 100644 --- a/Tests/BloomCoreTests/ShellCommandTests.swift +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -63,28 +63,45 @@ struct ShellCommandTests { // MARK: - What the agent is told - @Test("The message names the command, how it ended and what it printed") + @Test("The message names the command, what it printed and how it ended, in Claude Code's tags") func composesMessage() { var output = ShellCommand.Output() output.append("1 failing") let message = ShellCommand.message(command: "npm test", output: output, ending: .exited(1)) - #expect(message == "I ran this in the worktree:\n\n```sh\nnpm test\n```\n\n" - + "It exited with status 1. Its output:\n\n```\n1 failing\n```") + #expect(message == "<bash-input>npm test</bash-input>\n<bash-stdout>1 failing</bash-stdout>\n" + + "<bash-status>exited with status 1</bash-status>") } - @Test("A command that printed nothing says so rather than showing an empty block") - func saysNothingPrinted() { - let message = ShellCommand.message(command: "true", output: .init(), ending: .exited(0)) - #expect(message.hasSuffix("It exited with status 0 and printed nothing.")) + @Test("A sent command is read back whole, so the transcript can draw it as one") + func readsBack() { + var output = ShellCommand.Output() + output.append("printed </bash-stdout>\n<bash-status> itself") + let message = ShellCommand.message(command: "cat odd.txt", output: output, ending: .exited(0)) + + let sent = ShellCommand.split(message) + #expect(sent?.command == "cat odd.txt") + #expect(sent?.output == "printed </bash-stdout>\n<bash-status> itself") + #expect(sent?.succeeded == true) } - @Test("Output holding a fence cannot close the message's own") - func outgrowsFences() { - var output = ShellCommand.Output() - output.append("````") - let message = ShellCommand.message(command: "cat README.md", output: output, ending: .exited(0)) - #expect(message.contains("`````sh\n")) + @Test("A command that printed nothing and failed reads back as both") + func readsBackEmptyFailure() { + let message = ShellCommand.message(command: "false", output: .init(), ending: .exited(1)) + let sent = ShellCommand.split(message) + + #expect(sent?.output == "") + #expect(sent?.status == "exited with status 1") + #expect(sent?.succeeded == false) + } + + @Test("Anything not written by `message` is drawn as the text it is") + func leavesOtherTurnsAlone() { + #expect(ShellCommand.split("Run npm test please") == nil) + #expect(ShellCommand.split("<bash-input>ls</bash-input>") == nil) + #expect(ShellCommand.split( + "<bash-input>ls</bash-input>\n<bash-stdout>a</bash-stdout>\n<bash-status>ok</bash-status> and more" + ) == nil) } // MARK: - Running From a841fe59aae4503575f6d656a711039d7eade471 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 8 Oct 2026 12:05:55 +0200 Subject: [PATCH 10/16] Colour the composer in shell mode and draw a ! command everywhere it is read The composer's border and hint turn amber from the moment a draft starts with !, as Claude Code's do. The bubble drawn while a command's message is being sent, the one-line summary, Up recall and the queued message's Edit all read a sent command as what was typed, so the raw tags no longer flash up before the stored row replaces them. --- .../Views/Center/Composer/ComposerBox.swift | 12 +++++++----- .../Views/Center/Composer/ComposerPrompt.swift | 5 ++++- .../Center/Composer/ComposerShellRunView.swift | 6 +++--- .../Views/Center/Composer/ComposerView.swift | 3 ++- .../Views/Transcript/ShellCommandRowView.swift | 2 +- .../Views/Transcript/TranscriptListView.swift | 7 ++++++- .../Transcript/PendingMessageDiscard.swift | 1 + .../BloomCore/Transcript/PromptRecall.swift | 4 +++- .../BloomCore/Transcript/ShellCommand.swift | 18 +++++++++++++----- .../BloomCore/Transcript/UserTurnPrompt.swift | 4 +++- Tests/BloomCoreTests/ShellCommandTests.swift | 15 +++++++++++++++ 11 files changed, 58 insertions(+), 19 deletions(-) diff --git a/Sources/Bloom/Views/Center/Composer/ComposerBox.swift b/Sources/Bloom/Views/Center/Composer/ComposerBox.swift index 4568042a7..394848367 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerBox.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerBox.swift @@ -6,6 +6,8 @@ struct ComposerBox: ViewModifier { @Binding var isFocused: Bool var isFloating = false var isDropTarget = false + /// A border that stays up whatever the focus, for a mode the box is in. Shell mode is the one. + var accent: Color? @Environment(\.controlActiveState) private var activeState @Environment(\.colorSchemeContrast) private var contrast @@ -38,10 +40,10 @@ struct ComposerBox: ViewModifier { .glassEffect(.regular, in: shape) .overlay { shape.strokeBorder( - isDropTarget ? Palette.controlAccent : focusColour, - lineWidth: isDropTarget || contrast == .increased ? 2 : 0.5 + isDropTarget ? Palette.controlAccent : (accent ?? focusColour), + lineWidth: isDropTarget || accent != nil || contrast == .increased ? 2 : 0.5 ) - .opacity(isDropTarget ? 1 : (isRingVisible ? focusOpacity : 0)) + .opacity(isDropTarget || accent != nil ? 1 : (isRingVisible ? focusOpacity : 0)) .allowsHitTesting(false) .accessibilityHidden(true) } @@ -74,10 +76,10 @@ struct ComposerBox: ViewModifier { extension View { func composerBox( - isFocused: Binding<Bool>, isDropTarget: Bool = false, isFloating: Bool = false + isFocused: Binding<Bool>, isDropTarget: Bool = false, isFloating: Bool = false, accent: Color? = nil ) -> some View { modifier(ComposerBox( - isFocused: isFocused, isFloating: isFloating, isDropTarget: isDropTarget + isFocused: isFocused, isFloating: isFloating, isDropTarget: isDropTarget, accent: accent )) } } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift b/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift index 905a519b9..68945bad6 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift @@ -59,6 +59,8 @@ struct ComposerPrompt<Footer: View>: View { /// before a workspace exists retain the chip's external-editor fallback. var onOpenCommand: (@MainActor (String) -> Void)? var isFloating = false + /// See `ComposerBox.accent`. + var accent: Color? /// The footer, handed what it can ask this view to write into the draft. Passed in rather than /// reached for, because everything an attachment and a quick prompt do lives here and the /// footer is only the buttons. See `ComposerPromptActions`. @@ -170,7 +172,8 @@ struct ComposerPrompt<Footer: View>: View { .composerBox( isFocused: $isFocused, isDropTarget: isDropTarget, - isFloating: isFloating + isFloating: isFloating, + accent: accent ) // The editor takes the drops that land on the text itself; this takes the ones that land // on the chips, the footer and the padding, which is most of the box. diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift index 42bf751cd..e7df27aa7 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift @@ -34,10 +34,10 @@ struct ComposerShellRunView: View { } } .padding(Metrics.spacingSmall) - } else if ShellCommand.command(in: transcript.draft) != nil { - Text("Runs in the worktree. The agent gets the output when it finishes.") + } else if ShellCommand.isShellMode(transcript.draft) { + Text("! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") .font(Typo.caption) - .foregroundStyle(Palette.textSecondary) + .foregroundStyle(Palette.warning) .frame(maxWidth: .infinity, alignment: .leading) .padding(Metrics.spacingSmall) } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index fa5dc14c7..6020c60c7 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -147,7 +147,8 @@ struct ComposerView: View { onKey: handle(key:), onOpenAttachment: open(attachment:), onOpenCommand: open(commandPath:), - isFloating: true + isFloating: true, + accent: model != nil && ShellCommand.isShellMode(transcript.draft) ? Palette.warning : nil ) { actions in ComposerFooterView( controls: controls, diff --git a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift index b7040bf3a..11d384ecc 100644 --- a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift +++ b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift @@ -24,7 +24,7 @@ struct ShellCommandRowView: View { var body: some View { VStack(alignment: .leading, spacing: TranscriptLayout.tight) { HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { - Text("!").foregroundStyle(Palette.accent) + Text("!").foregroundStyle(Palette.warning) Text(sent.command).foregroundStyle(Palette.textPrimary) } .font(Typo.code) diff --git a/Sources/Bloom/Views/Transcript/TranscriptListView.swift b/Sources/Bloom/Views/Transcript/TranscriptListView.swift index c99cd8c60..02b74ea30 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptListView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptListView.swift @@ -841,11 +841,16 @@ struct TranscriptListView: View { .frame(maxWidth: .infinity, alignment: .leading) ) } + let command = ShellCommand.split(sending.body) let review = ReviewTurn.split(sending.body) let turn = AttachmentTrailer.split(sending.body) return AnyView( Group { - if let review { + // The same reading `TranscriptRowView` takes of the stored turn, or the + // raw tags flash up until the row the CLI echoes replaces this one. + if let command { + ShellCommandRowView(sent: command) + } else if let review { UserTurnRowView( text: review.message, reviewChips: review.chips, diff --git a/Sources/BloomCore/Transcript/PendingMessageDiscard.swift b/Sources/BloomCore/Transcript/PendingMessageDiscard.swift index fec2d53a7..1bded3d99 100644 --- a/Sources/BloomCore/Transcript/PendingMessageDiscard.swift +++ b/Sources/BloomCore/Transcript/PendingMessageDiscard.swift @@ -69,6 +69,7 @@ public enum PendingMessageDiscard { /// a fact about the body, where everything else here is a policy about the box. public static func isPlainText(_ body: String) -> Bool { ReviewTurn.split(body) == nil && AttachmentTrailer.split(body).paths.isEmpty + && ShellCommand.split(body) == nil } // MARK: - The question diff --git a/Sources/BloomCore/Transcript/PromptRecall.swift b/Sources/BloomCore/Transcript/PromptRecall.swift index 3988a165d..1c68b5294 100644 --- a/Sources/BloomCore/Transcript/PromptRecall.swift +++ b/Sources/BloomCore/Transcript/PromptRecall.swift @@ -94,7 +94,9 @@ public struct PromptRecall: Equatable, Sendable { var output: [String] = [] for text in sent { let presented = SentTurn.withoutInstructions(text) - let typed = ReviewTurn.split(presented)?.message ?? presented + let typed = ShellCommand.split(presented)?.typed + ?? ReviewTurn.split(presented)?.message + ?? presented let prompt = typed.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty, output.last != prompt else { continue } output.append(prompt) diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift index 104db44c7..de2906e7f 100644 --- a/Sources/BloomCore/Transcript/ShellCommand.swift +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -8,12 +8,17 @@ import Foundation /// agent gets it the same way. The agent answers it, which is the point: `! npm test` gets an /// explanation of the failures without a second prompt. public enum ShellCommand { - /// The command a draft asks to run, or nil when the draft is a message. - /// - /// The `!` has to be the first character, as in Claude Code, so a message that merely contains - /// one further in is never run. A lone `!` is nil: there is nothing to run. + /// Whether the composer is in shell mode, which it is from the moment the `!` is typed and + /// before there is a command after it, as in Claude Code. The `!` has to be the first + /// character, so a message that merely contains one further in is never run. + public static func isShellMode(_ draft: String) -> Bool { + draft.hasPrefix("!") + } + + /// The command a draft asks to run, or nil when the draft is a message. A lone `!` is nil: + /// there is nothing to run. public static func command(in draft: String) -> String? { - guard draft.hasPrefix("!") else { return nil } + guard isShellMode(draft) else { return nil } let command = draft.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) return command.isEmpty ? nil : command } @@ -111,6 +116,9 @@ public enum ShellCommand { public var status: String public var succeeded: Bool { status == ProcessEnding.exited(0).sentence } + + /// The command as it was typed, which is what a summary shows and what Up puts back. + public var typed: String { "! \(command)" } } /// The command a user turn carried, or nil for every turn `message` did not write. Strict, like diff --git a/Sources/BloomCore/Transcript/UserTurnPrompt.swift b/Sources/BloomCore/Transcript/UserTurnPrompt.swift index 21dde9432..acbcc71fb 100644 --- a/Sources/BloomCore/Transcript/UserTurnPrompt.swift +++ b/Sources/BloomCore/Transcript/UserTurnPrompt.swift @@ -27,7 +27,9 @@ public enum UserTurnPrompt { public static func summary(of text: String, limit: Int = summaryLimit) -> String? { let presented = SentTurn.withoutInstructions(text) let visible: String - if let review = ReviewTurn.split(presented) { + if let command = ShellCommand.split(presented) { + visible = command.typed + } else if let review = ReviewTurn.split(presented) { visible = if review.message.isEmpty { Counted.of(review.chips.count, "review comment") } else { diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift index 2d011041a..f771e5701 100644 --- a/Tests/BloomCoreTests/ShellCommandTests.swift +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -104,6 +104,21 @@ struct ShellCommandTests { ) == nil) } + @Test("Elsewhere a sent command reads as what was typed: in a summary, and under Up") + func readsAsTyped() { + let message = ShellCommand.message(command: "npm test", output: .init(), ending: .exited(0)) + + #expect(UserTurnPrompt.summary(of: message) == "! npm test") + #expect(PromptRecall.prompts(from: [message]) == ["! npm test"]) + #expect(ShellCommand.command(in: "! npm test") == "npm test") + } + + @Test("A queued command is not offered for editing, which would hand back its tags") + func isNotPlainText() { + let message = ShellCommand.message(command: "ls", output: .init(), ending: .exited(0)) + #expect(!PendingMessageDiscard.isPlainText(message)) + } + // MARK: - Running @Test("Lines arrive in order, stderr included, and the status is the command's own") From f959dd944360df3aa3821bfc102e57660bd36841 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 8 Oct 2026 12:27:58 +0200 Subject: [PATCH 11/16] Tint the shell mode composer, space the !, and close what review found The composer's glass takes the amber too, and a ! typed into an empty box becomes "! ". From an adversarial review: a draft carrying an attachment is refused, since its backticked path is a command substitution to the shell; closing, clearing, archiving or quitting a chat stops its command; a quick prompt never sends a command on its own; output is gathered off the main actor and handed over at most every 100ms; Stop during the PATH probe starts nothing; output that cannot be sent says so; and the tests run /bin/sh and assert order only within one stream. --- Sources/Bloom/State/TranscriptModel.swift | 4 + .../State/WorkspaceModel+ShellCommands.swift | 14 ++-- .../Views/Center/Composer/ComposerBox.swift | 6 +- .../Views/Center/Composer/ComposerView.swift | 20 ++++- .../BloomCore/System/ShellCommandRun.swift | 52 +++++++++--- .../BloomCore/Transcript/ShellCommand.swift | 7 ++ Tests/BloomCoreTests/ShellCommandTests.swift | 83 ++++++++++++------- 7 files changed, 136 insertions(+), 50 deletions(-) diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index aaa9a2e21..c0c9d0e1a 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -1455,6 +1455,9 @@ final class TranscriptModel { /// the same method. So a Codex chat's server was never signalled by anything, which is the /// orphaned-children bug this app already fixed once on the Claude Code side. func terminateNow() { + // A `!` command is this chat's child too, and nothing else would stop it: a dev server + // started from the composer would hold its port long after the chat had gone. + shellRunTask?.cancel() // `cancelTurn` rather than `stop`, and this is the reason the two are separate: Stop's // other half empties the queue into a composer that is going away with this chat. cancelTurn() @@ -1484,6 +1487,7 @@ final class TranscriptModel { func shutdown() async { idleEvictionTask?.cancel() idleEvictionTask = nil + shellRunTask?.cancel() guard let runner else { return } terminateNow() diff --git a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift index 6a13c67cb..fd3f77559 100644 --- a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift +++ b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift @@ -27,19 +27,23 @@ extension WorkspaceModel { let cwd = transcript.cwd transcript.shellRun = ShellCommand.Run(command: command) transcript.shellRunTask = Task { @MainActor [weak transcript, app] in - let ending = await ShellCommandRun.run(command, cwd: cwd, variables: variables) { line in - transcript?.shellRun?.output.append(line) + let (ending, output) = await ShellCommandRun.run(command, cwd: cwd, variables: variables) { output in + transcript?.shellRun?.output = output } guard let transcript else { return } - let output = transcript.shellRun?.output ?? ShellCommand.Output() transcript.shellRun = nil transcript.shellRunTask = nil - // Stopped by hand is somebody changing their mind, so nothing goes to the agent. + // Stopped by hand is somebody changing their mind, so nothing goes to the agent. So is + // the chat being closed or archived under it, which cancels this in `terminateNow`. guard !Task.isCancelled else { app.notice = BloomNotice(message: "The command was stopped. Nothing was sent to the agent.") return } - _ = await transcript.submit(ShellCommand.message(command: command, output: output, ending: ending)) + let message = ShellCommand.message(command: command, output: output, ending: ending) + guard await transcript.submit(message) else { + app.notice = BloomNotice(message: "The command finished, but its output could not be sent to the agent.") + return + } } return true } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerBox.swift b/Sources/Bloom/Views/Center/Composer/ComposerBox.swift index 394848367..35d7d1315 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerBox.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerBox.swift @@ -11,6 +11,10 @@ struct ComposerBox: ViewModifier { @Environment(\.controlActiveState) private var activeState @Environment(\.colorSchemeContrast) private var contrast + /// Enough of the accent in the glass to say the box is in another mode, little enough that the + /// text on it keeps its contrast. + private static let accentTint = 0.25 + private var isRingVisible: Bool { isFocused && activeState.showsFocusRing } private var shape: RoundedRectangle { @@ -37,7 +41,7 @@ struct ComposerBox: ViewModifier { padded // One material for the whole composer. Its controls keep their ordinary styles, // and completion menus are attached outside this modifier. - .glassEffect(.regular, in: shape) + .glassEffect(.regular.tint(accent?.opacity(Self.accentTint)), in: shape) .overlay { shape.strokeBorder( isDropTarget ? Palette.controlAccent : (accent ?? focusColour), diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 6020c60c7..8bff1b269 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -177,7 +177,11 @@ struct ComposerView: View { .task(id: "planning:\(transcript.session.id):\(transcript.isRunning)") { if let store = app.store { await ComposerPlanningSupport.shared.refresh(from: store) } } - .onChange(of: transcript.draft) { _, _ in + .onChange(of: transcript.draft) { old, new in + if model != nil, let spaced = ShellCommand.autoSpaced(from: old, to: new) { + transcript.draft = spaced + caret = (spaced as NSString).length + } scheduleDraftSave() } .onChange(of: transcript.composerFocusRequests) { _, _ in @@ -468,13 +472,20 @@ struct ComposerView: View { guard canSend else { return } draftSaveTask?.cancel() - // Only here, the path of a draft somebody typed. A message from another agent or from the - // bridge reaches `submit` without passing through this, so it can never run a command. + // Only here, Return or Send on the box. A message from another agent or from the bridge + // reaches `submit` without passing through this, and a quick prompt that would send a + // command stops short of it in `fire`, so a command only ever runs from a key press. if let command = ShellCommand.command(in: transcript.draft) { guard let model else { app.notice = BloomNotice(message: "Shell commands run in a workspace chat.") return } + // An attachment is a backticked path in the draft, which a shell reads as a command to + // run and substitute. The chip hides that, so the draft is refused rather than run. + guard AttachmentDraft.parse(transcript.draft, paths: attachments.map(\.path)).paths.isEmpty else { + app.notice = BloomNotice(message: "A shell command cannot carry attachments. Type the path instead.") + return + } guard model.runShellCommand(command, from: transcript) else { return } transcript.draft = "" caret = 0 @@ -596,6 +607,9 @@ struct ComposerView: View { // of being left behind under a turn that answered something else. The form's own line // says so before the switch is turned on. insert(prompt) + // A quick prompt's text can be rewritten by an agent through the bridge, so one that + // makes the draft a command is left in the box for its owner to run with Return. + guard !ShellCommand.isShellMode(transcript.draft) else { return } send() case .composeInNewChat: openChat(for: prompt, sending: false) diff --git a/Sources/BloomCore/System/ShellCommandRun.swift b/Sources/BloomCore/System/ShellCommandRun.swift index c6e544862..610d21493 100644 --- a/Sources/BloomCore/System/ShellCommandRun.swift +++ b/Sources/BloomCore/System/ShellCommandRun.swift @@ -15,37 +15,69 @@ public enum ShellCommandRun { /// How long a stopped command has to exit after SIGTERM before it is killed, as for setup. static let stopGrace: Duration = .seconds(5) - /// Runs `command` in the user's shell, handing each line to `onLine` in order, and returns how - /// it ended. Cancelling the calling task stops it. + /// How often the output is handed to the main actor while the command runs. + /// + /// Not once per line: `! yes` prints faster than the main actor can take a hop and a redraw per + /// line, and the stream behind `lines` buffers without bound, so a per line hand-off grew memory + /// until Stop. The output is kept here, off the main actor, and a snapshot goes over at most this + /// often, which is still faster than anybody reads. + static let publishInterval: Duration = .milliseconds(100) + + /// Runs `command`, handing what it has printed so far to `onOutput` as it goes, and returns how + /// it ended with everything it printed. The first line is handed over as soon as it arrives. + /// Cancelling the calling task stops it. /// /// `variables` are the workspace's own, the ones a terminal pane gets. The environment crosses /// `untrustedProcess` like a terminal's does: the command is the user's, but `npm test` runs the /// repository's code, and that is exactly what the boundary is for. + /// + /// `shell` is the user's, because the command is written in its syntax. A parameter so the + /// suite can name `/bin/sh` rather than inherit whatever shell the machine running it has. + /// + /// `@concurrent` so the loop over the output stays off the caller's actor, which is the main + /// actor, whatever the default isolation of a nonisolated async function becomes. + @concurrent public static func run( _ command: String, cwd: String, variables: [String: String], - onLine: @escaping @MainActor @Sendable (String) -> Void - ) async -> ProcessEnding { + shell: String = LoginShell.path(), + onOutput: @escaping @MainActor @Sendable (ShellCommand.Output) -> Void + ) async -> (ending: ProcessEnding, output: ShellCommand.Output) { // A PATH that arrives a moment late is a `command not found` for something the user has // installed. Usually free: the probe finished long before anybody typed a `!`. await LoginShellPath.ready() + // The probe can take seconds, and Stop pressed during it must not start the command after. + guard !Task.isCancelled else { return (.signalled(SIGTERM), ShellCommand.Output()) } + let process = StreamingProcess( - executable: LoginShell.path(), + executable: shell, arguments: ["-c", command], cwd: cwd, environment: ProviderCredentialEnvironment.untrustedProcess(Shell.environment(extra: variables)) ) - // A launch failure is reported through `lines`, which is read below. + // A launch failure is reported through `lines`, which is read below. Started before the + // cancellation handler is installed, so a cancel that has already happened signals a + // process that exists. try? process.start() process.closeStdin() - await withTaskCancellationHandler { + let output = await withTaskCancellationHandler { + var output = ShellCommand.Output() + var published: ContinuousClock.Instant? do { - for try await line in process.lines { await onLine(line) } + for try await line in process.lines { + output.append(line) + let now = ContinuousClock.now + if published.map({ $0.duration(to: now) >= publishInterval }) ?? true { + published = now + await onOutput(output) + } + } } catch { - await onLine("\(error)") + output.append("\(error)") } + return output } onCancel: { process.terminate() Task.detached { @@ -53,6 +85,6 @@ public enum ShellCommandRun { process.kill() } } - return await process.ending + return (await process.ending, output) } } diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift index de2906e7f..ea69d191f 100644 --- a/Sources/BloomCore/Transcript/ShellCommand.swift +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -15,6 +15,13 @@ public enum ShellCommand { draft.hasPrefix("!") } + /// What the draft becomes when a `!` is typed into an empty composer: the `!` and a space, so + /// the command is typed where Claude Code would put it. Nil for every other edit, including a + /// paste that starts with `!` and a backspace over the space, which would otherwise come back. + public static func autoSpaced(from old: String, to new: String) -> String? { + old.isEmpty && new == "!" ? "! " : nil + } + /// The command a draft asks to run, or nil when the draft is a message. A lone `!` is nil: /// there is nothing to run. public static func command(in draft: String) -> String? { diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift index f771e5701..50a3a4211 100644 --- a/Tests/BloomCoreTests/ShellCommandTests.swift +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -20,6 +20,14 @@ struct ShellCommandTests { #expect(ShellCommand.command(in: "! ") == nil) } + @Test("A `!` typed into an empty composer gets its space, and nothing else does") + func spacesTheBang() { + #expect(ShellCommand.autoSpaced(from: "", to: "!") == "! ") + #expect(ShellCommand.autoSpaced(from: "", to: "!ls") == nil) + #expect(ShellCommand.autoSpaced(from: "! ", to: "!") == nil) + #expect(ShellCommand.autoSpaced(from: "Hi", to: "Hi!") == nil) + } + // MARK: - What is kept of the output @Test("Short output is kept whole") @@ -119,65 +127,78 @@ struct ShellCommandTests { #expect(!PendingMessageDiscard.isPlainText(message)) } + @Test("A command over several lines comes back whole") + func readsBackMultiline() { + let message = ShellCommand.message(command: "echo a\necho b", output: .init(), ending: .exited(0)) + #expect(ShellCommand.split(message)?.command == "echo a\necho b") + } + // MARK: - Running - @Test("Lines arrive in order, stderr included, and the status is the command's own") - @MainActor + @Test("Each stream keeps its own order, both arrive, and the status is the command's own") func runs() async { - let collected = Collected() - let ending = await ShellCommandRun.run( - "echo one; echo two >&2; exit 3", cwd: Self.directory, variables: [:] - ) { collected.lines.append($0) } + // Order is only promised within a stream: stdout and stderr are two pipes, read by two + // handlers, and which of them is drained first is the scheduler's business. + let result = await Self.run("echo one; echo two; echo three >&2; exit 3") + let lines = result.output.head - #expect(collected.lines == ["one", "two"]) - #expect(ending == .exited(3)) + #expect(lines.filter { $0 != "three" } == ["one", "two"]) + #expect(Set(lines) == ["one", "two", "three"]) + #expect(result.ending == .exited(3)) } @Test("A command reading stdin gets end of file rather than waiting for ever") - @MainActor func closesStdin() async { - let collected = Collected() - let ending = await ShellCommandRun.run("cat; echo done", cwd: Self.directory, variables: [:]) { - collected.lines.append($0) - } + let result = await Self.run("cat; echo done") - #expect(collected.lines == ["done"]) - #expect(ending == .exited(0)) + #expect(result.output.head == ["done"]) + #expect(result.ending == .exited(0)) } @Test("The workspace's variables arrive and provider credentials do not") - @MainActor func crossesCredentialBoundary() async throws { let credential = try #require(ProviderCredentialEnvironment.prohibitedNames.min()) - let collected = Collected() - _ = await ShellCommandRun.run( + let result = await Self.run( "echo \"$BLOOM_SHELL_TEST\"; echo \"${\(credential):-absent}\"", - cwd: Self.directory, variables: ["BLOOM_SHELL_TEST": "workspace", credential: "secret"] - ) { collected.lines.append($0) } + ) - #expect(collected.lines == ["workspace", "absent"]) + #expect(result.output.head == ["workspace", "absent"]) } - @Test("Cancelling stops the command") - @MainActor + @Test("Cancelling stops the command, and the first line is shown without waiting for a second") func stops() async { let (started, signal) = AsyncStream.makeStream(of: Void.self) - let run = Task { @MainActor in - await ShellCommandRun.run("echo started; exec sleep 600", cwd: Self.directory, variables: [:]) { _ in - signal.yield() - } + let run = Task { + await ShellCommandRun.run( + "echo started; exec sleep 600", cwd: Self.directory, variables: [:], shell: "/bin/sh" + ) { _ in signal.yield() } } for await _ in started { break } run.cancel() - #expect(await run.value == .signalled(SIGTERM)) + #expect(await run.value.ending == .signalled(SIGTERM)) + } + + @Test("Cancelled before it starts, nothing is run") + func stopsBeforeStarting() async throws { + let marker = FileManager.default.temporaryDirectory.appending(path: "bloom-shell-\(UUID())").path + let run = Task { + withUnsafeCurrentTask { $0?.cancel() } + return await Self.run("touch '\(marker)'") + } + + #expect(await run.value.ending == .signalled(SIGTERM)) + #expect(!FileManager.default.fileExists(atPath: marker)) } private static let directory = FileManager.default.temporaryDirectory.path - @MainActor - private final class Collected { - var lines: [String] = [] + /// `/bin/sh` rather than the user's shell, so a suite run under fish, or a zsh whose + /// `.zshenv` exports something, asserts the same thing as anywhere else. + private static func run( + _ command: String, variables: [String: String] = [:] + ) async -> (ending: ProcessEnding, output: ShellCommand.Output) { + await ShellCommandRun.run(command, cwd: directory, variables: variables, shell: "/bin/sh") { _ in } } } From 823b65bb83ceb1c289319ea18f556bf3c10b29e7 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Fri, 9 Oct 2026 08:21:56 +0200 Subject: [PATCH 12/16] Hold the queue behind a running ! command and draw it in the transcript A message typed while a command ran went to the agent at once, so "then cherry-pick" was answered by an agent that had not heard the build was under way. A running command is now a DeliveryHold of its own, which holds on every backend, and its output is queued at the time the command started, so it goes ahead of anything typed after it. The command is drawn in the transcript where its output will land, with its last lines and Stop, rather than above the composer, which keeps only the shell mode hint. --- Sources/Bloom/State/TranscriptModel.swift | 14 +++- .../State/WorkspaceModel+ShellCommands.swift | 8 +-- .../Composer/ComposerShellModeHint.swift | 19 +++++ .../Composer/ComposerShellRunView.swift | 45 ------------ .../Views/Center/Composer/ComposerView.swift | 2 +- .../Transcript/ShellCommandRowView.swift | 71 ++++++++++++++----- .../Views/Transcript/TranscriptListView.swift | 25 +++++++ Sources/BloomCore/Agent/DeliveryHold.swift | 10 ++- .../Transcript/TranscriptEntryID.swift | 6 +- .../Workspace/WorkspaceMergeFailure.swift | 8 +++ Tests/BloomCoreTests/ShellCommandTests.swift | 32 +++++++++ .../TranscriptEntryIDTests.swift | 1 + 12 files changed, 166 insertions(+), 75 deletions(-) create mode 100644 Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift delete mode 100644 Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index c0c9d0e1a..841190246 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -273,7 +273,8 @@ final class TranscriptModel { /// as a pane changes what it shows. See `PromptRecall`. @ObservationIgnored var promptRecall = PromptRecall() - /// The `!` command running from this chat's composer. On the session for the reason + /// The `!` command running from this chat's composer, which holds the queue while it runs (see + /// `DeliveryHold.command`). On the session for the reason /// `promptRecall` is: the composer is handed from chat to chat. See `WorkspaceModel+ShellCommands`. var shellRun: ShellCommand.Run? @ObservationIgnored var shellRunTask: Task<Void, Never>? @@ -755,8 +756,11 @@ final class TranscriptModel { /// in: as a sent bubble if nothing is holding the queue, as a pending one if something is. See /// `sending`. @discardableResult + /// `queuedAt` places the message in the queue, which is ordered by it. A `!` command's output + /// passes the time the command started, so what was typed while it ran goes after it. func submit(_ text: String, clearingDraft sourceDraft: String? = nil, - interactionMode: InteractionMode? = nil, sourcePlan: PlanArtefact? = nil) async -> Bool { + interactionMode: InteractionMode? = nil, sourcePlan: PlanArtefact? = nil, + queuedAt: Date = Date()) async -> Bool { guard !isWorkspaceArchiving else { return false } // Anything said to the chat supersedes a continuation waiting on the allowance, including // the continuation itself, which disarms before it gets here. @@ -779,7 +783,10 @@ final class TranscriptModel { // Built here rather than inside the enqueue, so the row that goes in the table and the // bubble that goes on screen are one object with one id. Drawn twice under two ids is the // duplicate that would appear the moment the queue was read back. - let delivery = Delivery(targetSessionID: session.id, body: body, interactionMode: interactionMode ?? session.interactionMode) + let delivery = Delivery( + targetSessionID: session.id, body: body, createdAt: queuedAt, + interactionMode: interactionMode ?? session.interactionMode + ) // Ask Bloom is the only transcript with no workspace, and the only one named this way. if workspace == nil { let id = session.id @@ -860,6 +867,7 @@ final class TranscriptModel { let model = workspace.flatMap { app.existingModel(for: $0.id) } return DeliveryHold.of( isRunningSetup: model?.isRunningSetup ?? false, + isRunningCommand: shellRun != nil, isTurnRunning: isRunning, isAwaitingQuestion: isAwaitingPermission ) diff --git a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift index fd3f77559..11d7aaf58 100644 --- a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift +++ b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift @@ -1,4 +1,5 @@ import BloomCore +import Foundation /// Running a `!` command typed into one of this workspace's chats, and handing its output to the /// agent once it has finished. Here rather than on the transcript because the command gets the @@ -25,6 +26,7 @@ extension WorkspaceModel { variables = manager.environment(for: workspace, repo: repo, port: port) } let cwd = transcript.cwd + let startedAt = Date() transcript.shellRun = ShellCommand.Run(command: command) transcript.shellRunTask = Task { @MainActor [weak transcript, app] in let (ending, output) = await ShellCommandRun.run(command, cwd: cwd, variables: variables) { output in @@ -40,15 +42,11 @@ extension WorkspaceModel { return } let message = ShellCommand.message(command: command, output: output, ending: ending) - guard await transcript.submit(message) else { + guard await transcript.submit(message, queuedAt: startedAt) else { app.notice = BloomNotice(message: "The command finished, but its output could not be sent to the agent.") return } } return true } - - func stopShellCommand(in transcript: TranscriptModel) { - transcript.shellRunTask?.cancel() - } } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift new file mode 100644 index 000000000..d50489247 --- /dev/null +++ b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift @@ -0,0 +1,19 @@ +import SwiftUI +import BloomCore + +/// Above the composer while a draft starts with `!`: what pressing Return will do instead of +/// sending a message. The running command is drawn in the transcript, where its output will land, +/// rather than here. See `ShellCommandRowView` and `ShellCommand`. +struct ComposerShellModeHint: View { + var draft: String + + var body: some View { + if ShellCommand.isShellMode(draft) { + Text("! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") + .font(Typo.caption) + .foregroundStyle(Palette.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Metrics.spacingSmall) + } + } +} diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift deleted file mode 100644 index e7df27aa7..000000000 --- a/Sources/Bloom/Views/Center/Composer/ComposerShellRunView.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI -import BloomCore - -/// Above the composer: what a `!` draft will do before it is sent, and the command's last lines -/// while it runs, with a way to stop it. The output goes to the agent as a message once the -/// command has finished, so this only has to show that it is alive. See `ShellCommand`. -struct ComposerShellRunView: View { - var transcript: TranscriptModel - var model: WorkspaceModel - - /// Enough to see a test runner moving, few enough not to push the transcript off the window. - private static let visibleLines = 6 - - var body: some View { - if let run = transcript.shellRun { - VStack(alignment: .leading, spacing: Metrics.spacingSmall) { - HStack(spacing: Metrics.spacing) { - ProgressView().controlSize(.small) - Text("$ \(run.command)") - .font(Typo.codeSmall) - .lineLimit(1) - .truncationMode(.tail) - Spacer() - Button("Stop") { model.stopShellCommand(in: transcript) } - .help("Stop the command. Nothing is sent to the agent.") - } - if !run.output.isEmpty { - Text(run.output.recent(Self.visibleLines).joined(separator: "\n")) - .font(Typo.codeTiny) - .foregroundStyle(Palette.textSecondary) - .lineLimit(Self.visibleLines) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - } - .padding(Metrics.spacingSmall) - } else if ShellCommand.isShellMode(transcript.draft) { - Text("! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") - .font(Typo.caption) - .foregroundStyle(Palette.warning) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Metrics.spacingSmall) - } - } -} diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 8bff1b269..2730c6821 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -90,7 +90,7 @@ struct ComposerView: View { .help("Drag to resize. Double-click to fit the text.") ComposerPlansView(transcript: transcript, model: model, controls: controls) - if let model { ComposerShellRunView(transcript: transcript, model: model) } + if model != nil { ComposerShellModeHint(draft: transcript.draft) } composer } // The chrome is whatever is left once the editor's share is taken off, so this settles on diff --git a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift index 11d384ecc..5f59c8946 100644 --- a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift +++ b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift @@ -5,16 +5,40 @@ import BloomCore /// what it printed under a `└`. Not a bubble, because the message under it is tags the reader never /// typed and the output is a terminal's, which reads as one only in a fixed width face. See /// `ShellCommand`. +/// +/// The same drawing while the command runs, in the place its output will land, so the command is +/// on screen before anything typed after it: drawn above the composer instead, it was nowhere in +/// the conversation until it finished, under a reply to the message that came after it. struct ShellCommandRowView: View { - var sent: ShellCommand.Sent + var command: String + var output: String + /// How it ended, or nil while it is still running. + var status: String? + var succeeded = true + var onStop: (() -> Void)? /// About a screenful of a test run's summary. The rest is one click away. private static let collapsedLines = 10 @State private var isExpanded = false + init(sent: ShellCommand.Sent) { + command = sent.command + output = sent.output + status = sent.status + succeeded = sent.succeeded + } + + init(running run: ShellCommand.Run, onStop: @escaping () -> Void) { + command = run.command + output = run.output.recent(Self.collapsedLines).joined(separator: "\n") + self.onStop = onStop + } + + private var isRunning: Bool { status == nil } + private var lines: [String] { - sent.output.isEmpty ? [] : sent.output.components(separatedBy: "\n") + output.isEmpty ? [] : output.components(separatedBy: "\n") } private var shownLines: [String] { @@ -25,30 +49,39 @@ struct ShellCommandRowView: View { VStack(alignment: .leading, spacing: TranscriptLayout.tight) { HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { Text("!").foregroundStyle(Palette.warning) - Text(sent.command).foregroundStyle(Palette.textPrimary) + Text(command).foregroundStyle(Palette.textPrimary) + if isRunning { + ProgressView().controlSize(.small) + if let onStop { + Button("Stop", action: onStop) + .help("Stop the command. Nothing is sent to the agent.") + } + } } .font(Typo.code) - HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { - Text("└").foregroundStyle(Palette.textTertiary) - VStack(alignment: .leading, spacing: TranscriptLayout.tight) { - if lines.isEmpty { - Text("(no output)").foregroundStyle(Palette.textSecondary) - } else { - Text(shownLines.joined(separator: "\n")).foregroundStyle(Palette.textSecondary) - } - if lines.count > Self.collapsedLines { - Button(isExpanded ? "Show less" : "Show all \(lines.count) lines") { - isExpanded.toggle() + if !(isRunning && lines.isEmpty) { + HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { + Text("└").foregroundStyle(Palette.textTertiary) + VStack(alignment: .leading, spacing: TranscriptLayout.tight) { + if lines.isEmpty { + Text("(no output)").foregroundStyle(Palette.textSecondary) + } else { + Text(shownLines.joined(separator: "\n")).foregroundStyle(Palette.textSecondary) + } + if !isRunning, lines.count > Self.collapsedLines { + Button(isExpanded ? "Show less" : "Show all \(lines.count) lines") { + isExpanded.toggle() + } + .linkButton() + } + if let status, !succeeded { + Text("It \(status).").foregroundStyle(Palette.textSecondary) } - .linkButton() - } - if !sent.succeeded { - Text("It \(sent.status).").foregroundStyle(Palette.textSecondary) } } + .font(Typo.codeSmall) } - .font(Typo.codeSmall) } .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Bloom/Views/Transcript/TranscriptListView.swift b/Sources/Bloom/Views/Transcript/TranscriptListView.swift index 02b74ea30..2c3f0b18d 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptListView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptListView.swift @@ -905,6 +905,31 @@ struct TranscriptListView: View { } )) + // A `!` command while it runs, between what has been said and what is queued behind it, + // which is where its output will land. Always in the list and drawing nothing when there + // is none, like the singletons above, so a command starting is not an insertion. + let shellRun = transcript.shellRun + out.append(TranscriptTableEntry( + id: .shellRun, + contentKey: TranscriptContentKey { + $0.combine("shellRun") + $0.combine(transcript.session.id) + $0.combine(shellRun?.command) + // Moves with every snapshot of the output, which arrives at most every 100ms, so + // the cell is measured again as it grows. + $0.combine(shellRun.map { $0.output.head.count + $0.output.tail.count + $0.output.droppedLines }) + }, + drawsNothing: shellRun == nil, + content: { + guard let shellRun else { return AnyView(EmptyView()) } + return AnyView( + ShellCommandRowView(running: shellRun) { transcript.shellRunTask?.cancel() } + .padding(.horizontal, TranscriptLayout.inset) + .frame(maxWidth: .infinity, alignment: .leading) + ) + } + )) + // After everything that has been said, because that is where the next thing to be said // belongs. Drawn from the workspace's queue rather than from a row, so none of it can // reach the agent before it is actually sent. diff --git a/Sources/BloomCore/Agent/DeliveryHold.swift b/Sources/BloomCore/Agent/DeliveryHold.swift index caa2c50a5..f85c4607f 100644 --- a/Sources/BloomCore/Agent/DeliveryHold.swift +++ b/Sources/BloomCore/Agent/DeliveryHold.swift @@ -36,6 +36,11 @@ public enum DeliveryHold: Equatable, Sendable, CaseIterable { /// The worktree's setup script is still running. Nothing may be said to an agent in a /// worktree that has not finished being built. case setup + /// A `!` command typed into this chat is still running. What is typed after it was typed + /// knowing it had been run, so it waits for the output to reach the agent first: sent at once, + /// "then cherry-pick" was answered by an agent that had not heard the build was under way, and + /// offered to start it. See `ShellCommand`. + case command /// The agent has stopped on a permission question. Writing a user line into a turn that is /// blocked on an answer is the mid-turn injection every backend is unhappy about. case question @@ -70,10 +75,12 @@ public enum DeliveryHold: Equatable, Sendable, CaseIterable { /// `.none` for Claude Code would have told that tool a busy workspace was quiet. public static func of( isRunningSetup: Bool, + isRunningCommand: Bool = false, isTurnRunning: Bool, isAwaitingQuestion: Bool ) -> DeliveryHold { if isRunningSetup { return .setup } + if isRunningCommand { return .command } if isAwaitingQuestion { return .question } if isTurnRunning { return .turn } return .none @@ -94,7 +101,7 @@ public enum DeliveryHold: Equatable, Sendable, CaseIterable { switch self { case .none: true case .turn: agent.acceptsMidTurnMessage - case .setup, .question: false + case .setup, .command, .question: false } } @@ -119,6 +126,7 @@ public enum DeliveryHold: Equatable, Sendable, CaseIterable { guard !allowsDelivery(on: agent) else { return nil } switch self { case .setup: return "Goes as soon as setup finishes." + case .command: return "Goes after the command's output." case .question: return "Goes once you have answered the question above." case .turn: return "Goes when this turn ends." case .none: return nil diff --git a/Sources/BloomCore/Transcript/TranscriptEntryID.swift b/Sources/BloomCore/Transcript/TranscriptEntryID.swift index c8cf81d10..9b49f9c9f 100644 --- a/Sources/BloomCore/Transcript/TranscriptEntryID.swift +++ b/Sources/BloomCore/Transcript/TranscriptEntryID.swift @@ -35,6 +35,9 @@ public enum TranscriptEntryID: Hashable, Sendable, CustomStringConvertible { case sending /// The per-token tail of a running turn. case streaming + /// A `!` command running from the composer, drawn where its output will land: after what has + /// been said and before what is queued behind it. See `ShellCommand`. + case shellRun /// A queued message, waiting to be sent. case pending(DeliveryID) /// Breathing room after all content, including streaming output and queued messages. @@ -71,7 +74,7 @@ public enum TranscriptEntryID: Hashable, Sendable, CustomStringConvertible { public var redrawsItself: Bool { switch self { case .row, .fold, .bottomSpacing: false - case .setup, .sending, .streaming, .pending: true + case .setup, .sending, .streaming, .shellRun, .pending: true } } @@ -82,6 +85,7 @@ public enum TranscriptEntryID: Hashable, Sendable, CustomStringConvertible { case .fold(let seq): "fold.\(seq)" case .sending: "sending" case .streaming: "streaming" + case .shellRun: "shellRun" case .pending(let id): "pending.\(id)" case .bottomSpacing: "bottomSpacing" } diff --git a/Sources/BloomCore/Workspace/WorkspaceMergeFailure.swift b/Sources/BloomCore/Workspace/WorkspaceMergeFailure.swift index 1ca6c6035..94654f27a 100644 --- a/Sources/BloomCore/Workspace/WorkspaceMergeFailure.swift +++ b/Sources/BloomCore/Workspace/WorkspaceMergeFailure.swift @@ -214,6 +214,14 @@ public enum WorkspaceMergeTrouble: Sendable, Equatable { something untrue. Wait for the turn to finish, check with workspace_list that \ nothing is agent_running, then ask again. """ + case .command: + // Not reached today: the hold this tool reads comes from the store, and a `!` command + // is the composer's, which the store does not know about. Worded for when it is. + return """ + Bloom will not ask for a merge in '\(workspace)' because the owner is running a \ + command in its chat, and anything said there waits for that command's output. \ + Wait for it to finish, then ask again. + """ case .none: // Unreachable: `.none` is what lets a send through. Said plainly rather than left to a // default, so widening `DeliveryHold` is a compile error here rather than a sentence diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift index 50a3a4211..911071de2 100644 --- a/Tests/BloomCoreTests/ShellCommandTests.swift +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -133,6 +133,38 @@ struct ShellCommandTests { #expect(ShellCommand.split(message)?.command == "echo a\necho b") } + // MARK: - What waits for it + + @Test("A running command holds the queue on every backend, behind setup and ahead of the rest") + func holdsTheQueue() { + let held = DeliveryHold.of( + isRunningSetup: false, isRunningCommand: true, isTurnRunning: true, isAwaitingQuestion: true + ) + #expect(held == .command) + let settingUp = DeliveryHold.of( + isRunningSetup: true, isRunningCommand: true, isTurnRunning: false, isAwaitingQuestion: false + ) + #expect(settingUp == .setup) + for agent in AgentKind.allCases { + #expect(!DeliveryHold.command.allowsDelivery(on: agent)) + #expect(DeliveryHold.command.sentence(on: agent) != nil) + #expect(!Delivery.goesImmediately(behind: [], hold: .command, on: agent)) + } + } + + @Test("The output goes ahead of what was typed while the command ran") + func queuesAheadOfWhatFollowed() async throws { + let store = try makeTestStore("shell-order") + let session = try await store.upsert(AskConversation.newSession()) + let startedAt = Date(timeIntervalSinceNow: -60) + try await store.enqueueDelivery(Delivery(targetSessionID: session.id, body: "then cherry-pick")) + let output = ShellCommand.message(command: "make dev-fast", output: .init(), ending: .exited(0)) + try await store.enqueueDelivery(Delivery(targetSessionID: session.id, body: output, createdAt: startedAt)) + + let queue = try await store.pendingDeliveries(sessionID: session.id) + #expect(queue.map(\.body) == [output, "then cherry-pick"]) + } + // MARK: - Running @Test("Each stream keeps its own order, both arrive, and the status is the command's own") diff --git a/Tests/BloomCoreTests/TranscriptEntryIDTests.swift b/Tests/BloomCoreTests/TranscriptEntryIDTests.swift index 820a87ae7..8574c4d6a 100644 --- a/Tests/BloomCoreTests/TranscriptEntryIDTests.swift +++ b/Tests/BloomCoreTests/TranscriptEntryIDTests.swift @@ -29,6 +29,7 @@ struct TranscriptEntryIDTests { #expect(TranscriptEntryID.streaming.redrawsItself) #expect(TranscriptEntryID.setup.redrawsItself) #expect(TranscriptEntryID.sending.redrawsItself) + #expect(TranscriptEntryID.shellRun.redrawsItself) #expect(TranscriptEntryID.pending(DeliveryID("d1")).redrawsItself) } From a20d766e3b26a73dad4f78672251d72d318d4ef6 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Mon, 12 Oct 2026 08:52:27 +0200 Subject: [PATCH 13/16] Hold a ! command sent during a turn until the turn ends Run at once, a command and an agent worked in the same worktree without either knowing: the agent could start the build the command was already running. A ! sent while a turn runs or waits on a question now waits for the turn to end and for its file changes to be recorded, then runs, which is Claude Code's rule. It is drawn in the transcript as waiting, with Remove. Stop starts it at once, as Esc does in Claude Code, and its output is still queued at the time Return was pressed, ahead of anything typed after it. --- Sources/Bloom/State/TranscriptModel.swift | 30 +++++++++++++++++++ .../State/WorkspaceModel+ShellCommands.swift | 30 +++++++++++++------ .../Composer/ComposerShellModeHint.swift | 6 +++- .../Views/Center/Composer/ComposerView.swift | 9 +++++- .../Transcript/ShellCommandRowView.swift | 17 +++++++++-- .../Views/Transcript/TranscriptListView.swift | 5 ++-- .../BloomCore/Transcript/ShellCommand.swift | 22 ++++++++++++-- Tests/BloomCoreTests/ShellCommandTests.swift | 7 +++++ 8 files changed, 108 insertions(+), 18 deletions(-) diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index 841190246..fa87d1c77 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -279,6 +279,31 @@ final class TranscriptModel { var shellRun: ShellCommand.Run? @ObservationIgnored var shellRunTask: Task<Void, Never>? + /// Starts a `!` command that was waiting for the turn to end, once it has, and once the turn's + /// file changes have been recorded, so the command's own changes are not filed under the turn. + /// + /// **A Stop starts it too**, at once rather than after the stopped turn's file changes are + /// recorded, which is not what Stop does to queued messages. That is Claude + /// Code's rule ("Press Esc to interrupt the turn... Claude Code keeps what you queued and sends + /// it right away"), and a command differs from a message here: it costs nothing to run, and + /// handing it back to the composer would put `! command` in front of the messages Stop also + /// hands back, turning all of them into one command. + func startWaitingShellCommand() { + guard shellRun?.isWaiting == true, !isRunning, !isAwaitingPermission, + !history.isCapturing, !history.isFinalisingTurn, !isWorkspaceArchiving, + let workspace, let model = app.existingModel(for: workspace.id) else { return } + model.startShellCommand(in: self) + } + + /// Stop on a running command, or Remove on a waiting one. + func stopShellCommand() { + guard let task = shellRunTask else { + shellRun = nil + return + } + task.cancel() + } + /// What has been asked for on this session and has not gone yet, oldest first. /// /// Read by the transcript to draw the pending bubbles and by the drain to decide what goes @@ -925,6 +950,7 @@ final class TranscriptModel { guard !usesInteractiveTerminal else { return } guard !isReconcilingPresentation else { return } guard !history.isCapturing, !history.isFinalisingTurn, !(history.hasActiveTurn && !isRunning) else { return } + startWaitingShellCommand() guard !isWorkspaceArchiving, !wasStoppedByHand, store != nil else { return } guard drainState.begin() else { return } var allowRepeat = true @@ -1423,6 +1449,7 @@ final class TranscriptModel { func stop() { steering = nil cancelTurn() + startWaitingShellCommand() // **And the queue comes back with it.** Not draining after a Stop was right about the // messages not going and wrong about what became of them: they sat under the transcript @@ -1951,6 +1978,9 @@ final class TranscriptModel { // as it was. Stop's own answer is not here at all, because it has already run: the // queue was handed back to the composer on the frame the button was pressed. history.isFinalisingTurn = false + // Before the drain's own guards, because a Stop keeps the drain away and does not keep + // a waiting command away. See `startWaitingShellCommand`. + startWaitingShellCommand() if !isReconcilingPresentation, let steered = steering { steering = nil wasStoppedByHand = false diff --git a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift index 11d7aaf58..262088f0f 100644 --- a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift +++ b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift @@ -6,8 +6,9 @@ import Foundation /// workspace's variables, the ones a terminal pane gets, and the workspace is what knows them. /// What the command is and what the agent is told are `ShellCommand`'s; this is the doing. extension WorkspaceModel { - /// Whether the command was started. Refused while another is running in the same chat, so the - /// draft stays where it is rather than vanishing into nothing. + /// Whether the command was taken. It runs now, or once the agent's turn ends when one is under + /// way (see `ShellCommand.waitsForTurn`). Refused while another is waiting or running in the + /// same chat, so the draft stays where it is rather than vanishing into nothing. func runShellCommand(_ command: String, from transcript: TranscriptModel) -> Bool { if transcript.usesInteractiveTerminal { app.alert = BloomAlert( @@ -17,19 +18,31 @@ extension WorkspaceModel { return false } guard transcript.shellRun == nil else { - app.notice = BloomNotice(message: "A command is already running in this chat.") + app.notice = BloomNotice(message: "A command is already waiting or running in this chat.") return false } + let waits = ShellCommand.waitsForTurn( + isTurnRunning: transcript.isRunning, isAwaitingQuestion: transcript.isAwaitingPermission + ) + transcript.shellRun = ShellCommand.Run(command: command, isWaiting: waits) + if !waits { startShellCommand(in: transcript) } + return true + } + + /// Starts the command the chat is holding. Called at once from `runShellCommand`, or by the + /// transcript when the turn the command was waiting for has ended. + func startShellCommand(in transcript: TranscriptModel) { + guard let run = transcript.shellRun, transcript.shellRunTask == nil else { return } + transcript.shellRun?.isWaiting = false + var variables: [String: String] = [:] if let repo, let manager = app.manager { variables = manager.environment(for: workspace, repo: repo, port: port) } let cwd = transcript.cwd - let startedAt = Date() - transcript.shellRun = ShellCommand.Run(command: command) transcript.shellRunTask = Task { @MainActor [weak transcript, app] in - let (ending, output) = await ShellCommandRun.run(command, cwd: cwd, variables: variables) { output in + let (ending, output) = await ShellCommandRun.run(run.command, cwd: cwd, variables: variables) { output in transcript?.shellRun?.output = output } guard let transcript else { return } @@ -41,12 +54,11 @@ extension WorkspaceModel { app.notice = BloomNotice(message: "The command was stopped. Nothing was sent to the agent.") return } - let message = ShellCommand.message(command: command, output: output, ending: ending) - guard await transcript.submit(message, queuedAt: startedAt) else { + let message = ShellCommand.message(command: run.command, output: output, ending: ending) + guard await transcript.submit(message, queuedAt: run.queuedAt) else { app.notice = BloomNotice(message: "The command finished, but its output could not be sent to the agent.") return } } - return true } } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift index d50489247..507a53b01 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift @@ -6,10 +6,14 @@ import BloomCore /// rather than here. See `ShellCommandRowView` and `ShellCommand`. struct ComposerShellModeHint: View { var draft: String + /// Whether the command would wait for the agent's turn to end. See `ShellCommand.waitsForTurn`. + var waitsForTurn: Bool var body: some View { if ShellCommand.isShellMode(draft) { - Text("! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") + Text(waitsForTurn + ? "! for shell mode. Runs in the worktree when this turn ends, and the agent gets the output." + : "! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") .font(Typo.caption) .foregroundStyle(Palette.warning) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 2730c6821..f5fe926f3 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -90,7 +90,14 @@ struct ComposerView: View { .help("Drag to resize. Double-click to fit the text.") ComposerPlansView(transcript: transcript, model: model, controls: controls) - if model != nil { ComposerShellModeHint(draft: transcript.draft) } + if model != nil { + ComposerShellModeHint( + draft: transcript.draft, + waitsForTurn: ShellCommand.waitsForTurn( + isTurnRunning: transcript.isRunning, isAwaitingQuestion: transcript.isAwaitingPermission + ) + ) + } composer } // The chrome is whatever is left once the editor's share is taken off, so this settles on diff --git a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift index 5f59c8946..382fa4ac4 100644 --- a/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift +++ b/Sources/Bloom/Views/Transcript/ShellCommandRowView.swift @@ -15,6 +15,8 @@ struct ShellCommandRowView: View { /// How it ended, or nil while it is still running. var status: String? var succeeded = true + /// Sent during a turn and waiting for it to end. See `ShellCommand.waitsForTurn`. + var isWaiting = false var onStop: (() -> Void)? /// About a screenful of a test run's summary. The rest is one click away. @@ -32,6 +34,7 @@ struct ShellCommandRowView: View { init(running run: ShellCommand.Run, onStop: @escaping () -> Void) { command = run.command output = run.output.recent(Self.collapsedLines).joined(separator: "\n") + isWaiting = run.isWaiting self.onStop = onStop } @@ -51,15 +54,23 @@ struct ShellCommandRowView: View { Text("!").foregroundStyle(Palette.warning) Text(command).foregroundStyle(Palette.textPrimary) if isRunning { - ProgressView().controlSize(.small) + if !isWaiting { ProgressView().controlSize(.small) } if let onStop { - Button("Stop", action: onStop) - .help("Stop the command. Nothing is sent to the agent.") + Button(isWaiting ? "Remove" : "Stop", action: onStop) + .help(isWaiting + ? "Take the command out without running it." + : "Stop the command. Nothing is sent to the agent.") } } } .font(Typo.code) + if isWaiting { + Text("Runs when this turn ends.") + .font(Typo.caption) + .foregroundStyle(Palette.textSecondary) + } + if !(isRunning && lines.isEmpty) { HStack(alignment: .firstTextBaseline, spacing: Metrics.spacingSmall) { Text("└").foregroundStyle(Palette.textTertiary) diff --git a/Sources/Bloom/Views/Transcript/TranscriptListView.swift b/Sources/Bloom/Views/Transcript/TranscriptListView.swift index 2c3f0b18d..cfe7534a4 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptListView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptListView.swift @@ -905,7 +905,7 @@ struct TranscriptListView: View { } )) - // A `!` command while it runs, between what has been said and what is queued behind it, + // A `!` command while it waits or runs, between what has been said and what is queued behind it, // which is where its output will land. Always in the list and drawing nothing when there // is none, like the singletons above, so a command starting is not an insertion. let shellRun = transcript.shellRun @@ -915,6 +915,7 @@ struct TranscriptListView: View { $0.combine("shellRun") $0.combine(transcript.session.id) $0.combine(shellRun?.command) + $0.combine(shellRun?.isWaiting) // Moves with every snapshot of the output, which arrives at most every 100ms, so // the cell is measured again as it grows. $0.combine(shellRun.map { $0.output.head.count + $0.output.tail.count + $0.output.droppedLines }) @@ -923,7 +924,7 @@ struct TranscriptListView: View { content: { guard let shellRun else { return AnyView(EmptyView()) } return AnyView( - ShellCommandRowView(running: shellRun) { transcript.shellRunTask?.cancel() } + ShellCommandRowView(running: shellRun) { transcript.stopShellCommand() } .padding(.horizontal, TranscriptLayout.inset) .frame(maxWidth: .infinity, alignment: .leading) ) diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift index ea69d191f..16c607e97 100644 --- a/Sources/BloomCore/Transcript/ShellCommand.swift +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -30,16 +30,34 @@ public enum ShellCommand { return command.isEmpty ? nil : command } - /// A command running from a chat's composer, and what it has printed so far. + /// A command sent from a chat's composer, and what it has printed so far. public struct Run: Sendable, Equatable { public var command: String + /// When Return was pressed, which is where its output goes in the queue: ahead of anything + /// typed after it, whether it ran at once or waited for a turn. + public var queuedAt: Date + /// Sent during a turn and not started yet. See `waitsForTurn`. + public var isWaiting: Bool public var output = Output() - public init(command: String) { + public init(command: String, queuedAt: Date = Date(), isWaiting: Bool = false) { self.command = command + self.queuedAt = queuedAt + self.isWaiting = isWaiting } } + /// Whether a command sent now waits for the agent's turn to end before it runs. + /// + /// Claude Code's rule, from its documentation: "Commands and shell commands: Claude Code holds + /// them until the turn ends, then runs them one at a time". Run at once instead, a command and + /// an agent work in the same worktree without either knowing about the other: the agent can + /// start the build the command is already running, or edit the files it is reading. A turn + /// stopped on a permission question has not ended, so that waits too. + public static func waitsForTurn(isTurnRunning: Bool, isAwaitingQuestion: Bool) -> Bool { + isTurnRunning || isAwaitingQuestion + } + /// What a command printed, kept from both ends as it arrives. /// /// Both ends for the reason `CheckFailureHandoff.excerpt` gives: the tail is where a failure diff --git a/Tests/BloomCoreTests/ShellCommandTests.swift b/Tests/BloomCoreTests/ShellCommandTests.swift index 911071de2..9cab2abbb 100644 --- a/Tests/BloomCoreTests/ShellCommandTests.swift +++ b/Tests/BloomCoreTests/ShellCommandTests.swift @@ -152,6 +152,13 @@ struct ShellCommandTests { } } + @Test("A command sent during a turn, or while it waits on a question, waits for it to end") + func waitsForTheTurn() { + #expect(ShellCommand.waitsForTurn(isTurnRunning: true, isAwaitingQuestion: false)) + #expect(ShellCommand.waitsForTurn(isTurnRunning: false, isAwaitingQuestion: true)) + #expect(!ShellCommand.waitsForTurn(isTurnRunning: false, isAwaitingQuestion: false)) + } + @Test("The output goes ahead of what was typed while the command ran") func queuesAheadOfWhatFollowed() async throws { let store = try makeTestStore("shell-order") From 88d46992ba95089ba97b9d0a35fb598e9c5a48f6 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Mon, 12 Oct 2026 12:09:44 +0200 Subject: [PATCH 14/16] Add an Auto model to new workspaces, chosen by a light model reading the task Off by default. With it on, the create window's agent settings offer Auto, a light model on any signed in agent reads the opening message, and the first chat starts on the rung it chose. The decision and its reason are stored with the conversation and drawn on the session start row, with Change to rerun the opening message on another rung. See docs/ROUTER.md. --- CLAUDE.md | 3 +- Sources/Bloom/State/AgentAvailability.swift | 58 + Sources/Bloom/State/AppModel+Router.swift | 26 + Sources/Bloom/State/AppModel+Workspaces.swift | 39 +- Sources/Bloom/State/AppModel.swift | 2 + Sources/Bloom/State/OpeningRoute.swift | 115 ++ Sources/Bloom/State/TranscriptModel.swift | 59 ++ Sources/Bloom/State/WorkspaceModel.swift | 22 +- .../Center/Composer/ComposerFooterView.swift | 6 +- .../Composer/ComposerSettingsPicker.swift | 57 +- .../Views/Center/Panes/ChatPaneView.swift | 18 +- .../Views/Center/Panes/OpeningRouteCard.swift | 114 ++ .../Chrome/Settings/AgentsSettingsView.swift | 2 + .../Chrome/Settings/ModelSettingsView.swift | 4 + .../Settings/RouterSettingsSection.swift | 238 +++++ .../CreateWorkspace/CreateWorkspaceView.swift | 33 +- .../Transcript/ModelRouteChangeMenu.swift | 34 + .../Transcript/SessionStartRowView.swift | 51 +- .../Views/Transcript/TranscriptListView.swift | 13 + .../Views/Transcript/TranscriptRowView.swift | 8 +- Sources/BloomCore/Agent/ClaudeRouterAsk.swift | 181 ++++ .../Agent/Codex/CodexRouterAsk.swift | 124 +++ .../BloomCore/Agent/Grok/GrokRouterAsk.swift | 101 ++ Sources/BloomCore/Agent/ModelRouteNote.swift | 127 +++ Sources/BloomCore/Agent/ModelRouter.swift | 122 +++ .../BloomCore/Agent/ModelRouterProgress.swift | 84 ++ .../BloomCore/Agent/ModelRouterSettings.swift | 127 +++ .../BloomCore/Agent/ModelRouterTable.swift | 102 ++ Sources/BloomCore/Agent/ModelRouting.swift | 180 ++++ Sources/BloomCore/Agent/RouterAnalyser.swift | 111 ++ Sources/BloomCore/Agent/RouterSignal.swift | 42 + .../BloomCore/GitLab/GitLabInstructions.swift | 2 +- .../Presentation/ModelRouteCaption.swift | 95 ++ .../BloomCore/Transcript/PromptRegistry.swift | 58 +- Tests/BloomCoreTests/ModelRouterTests.swift | 993 ++++++++++++++++++ docs/CODEX.md | 1 + docs/GROK.md | 4 + docs/ROUTER.md | 150 +++ 38 files changed, 3470 insertions(+), 36 deletions(-) create mode 100644 Sources/Bloom/State/AgentAvailability.swift create mode 100644 Sources/Bloom/State/AppModel+Router.swift create mode 100644 Sources/Bloom/State/OpeningRoute.swift create mode 100644 Sources/Bloom/Views/Center/Panes/OpeningRouteCard.swift create mode 100644 Sources/Bloom/Views/Chrome/Settings/RouterSettingsSection.swift create mode 100644 Sources/Bloom/Views/Transcript/ModelRouteChangeMenu.swift create mode 100644 Sources/BloomCore/Agent/ClaudeRouterAsk.swift create mode 100644 Sources/BloomCore/Agent/Codex/CodexRouterAsk.swift create mode 100644 Sources/BloomCore/Agent/Grok/GrokRouterAsk.swift create mode 100644 Sources/BloomCore/Agent/ModelRouteNote.swift create mode 100644 Sources/BloomCore/Agent/ModelRouter.swift create mode 100644 Sources/BloomCore/Agent/ModelRouterProgress.swift create mode 100644 Sources/BloomCore/Agent/ModelRouterSettings.swift create mode 100644 Sources/BloomCore/Agent/ModelRouterTable.swift create mode 100644 Sources/BloomCore/Agent/ModelRouting.swift create mode 100644 Sources/BloomCore/Agent/RouterAnalyser.swift create mode 100644 Sources/BloomCore/Agent/RouterSignal.swift create mode 100644 Sources/BloomCore/Presentation/ModelRouteCaption.swift create mode 100644 Tests/BloomCoreTests/ModelRouterTests.swift create mode 100644 docs/ROUTER.md diff --git a/CLAUDE.md b/CLAUDE.md index 89320b0c5..c020de16d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ Longer documents, pointed at rather than repeated here: `README.md` for what the `RELEASING.md` for signing, notarising and the appcast, `docs/CODEX.md` for the Codex app-server protocol as measured, `docs/GROK.md` for Grok's ACP over stdio, `docs/GITLAB.md` for GitLab through `glab`, `docs/PROTOCOL.md` for Claude Code's stream-json, `docs/AGENTS-INTEGRATION.md` for how agent CLIs are detected, `docs/BRIDGE.md` for the MCP -bridge an agent calls back in through and which callers may call what, `docs/PLAN.md` for what was +bridge an agent calls back in through and which callers may call what, `docs/ROUTER.md` for the +automatic router that has a light model pick a new workspace's model and effort, `docs/PLAN.md` for what was built and in what order, `docs/start-from.html` for the design note the create sheet's source picker was drawn from, which is a page to open in a browser rather than to read here. diff --git a/Sources/Bloom/State/AgentAvailability.swift b/Sources/Bloom/State/AgentAvailability.swift new file mode 100644 index 000000000..ab316e5bc --- /dev/null +++ b/Sources/Bloom/State/AgentAvailability.swift @@ -0,0 +1,58 @@ +import BloomCore +import Foundation +import Observation + +/// Which agent CLIs are signed in on this Mac, outliving any one screen, so the router asks only +/// an agent that can answer. The Agents pane hands over what it detects; the create window +/// refreshes when the answer is older than `staleAfter`. +@MainActor +@Observable +final class AgentAvailability { + static let shared = AgentAvailability() + + private(set) var connected: Set<AgentKind> = [] + /// Tells "nothing is connected" from "nobody has looked yet". + private(set) var hasLoaded = false + + @ObservationIgnored private var overrides: [AgentKind: String] = [:] + @ObservationIgnored private var store: Store? + @ObservationIgnored private var loadTask: Task<Void, Never>? + @ObservationIgnored private var loadedAt: Date? + + static let staleAfter: TimeInterval = 300 + + /// Detects at launch only when the router, its only reader, is on: detection runs every agent's + /// `--version`. + func configure(store: Store) { + self.store = store + if ModelRouterPreferences().isEnabled { refresh() } + } + + func refresh() { + loadTask?.cancel() + let store = store + loadTask = Task { [weak self] in + let overrides = await AgentCatalog.executablePathOverrides(in: store) + let statuses = await AgentCatalog(overrides: overrides).statuses() + guard !Task.isCancelled else { return } + self?.adopt(statuses: statuses, overrides: overrides) + } + } + + func refreshIfStale(now: Date = Date()) { + if let loadedAt, now.timeIntervalSince(loadedAt) < Self.staleAfter { return } + refresh() + } + + /// What a screen that has just run its own detection found, so this does not run it twice. + func adopt(statuses: [AgentStatus], overrides: [AgentKind: String]) { + connected = Set(statuses.filter { $0.connection == .connected }.map(\.kind)) + self.overrides = overrides + hasLoaded = true + loadedAt = Date() + } + + func executable(for kind: AgentKind) -> String { + AgentCatalog.executable(for: kind, override: overrides[kind]) + } +} diff --git a/Sources/Bloom/State/AppModel+Router.swift b/Sources/Bloom/State/AppModel+Router.swift new file mode 100644 index 000000000..60e16cbe0 --- /dev/null +++ b/Sources/Bloom/State/AppModel+Router.swift @@ -0,0 +1,26 @@ +import BloomCore + +/// The router's two decisions that need live state (who reads the task, which table applies), +/// in one place so the create window offering Auto and `startWorkspace` asking cannot disagree. +extension AppModel { + struct OpeningRouter { + let analyser: RouterAnalyser + let table: ModelRouterTable + } + + /// Nil when nothing signed in can read a task, or the chat's agent has no table yet (a Codex or + /// Grok list that has not answered). + func openingRouter( + for backend: AgentKind, + settings: ModelRouterSettings = ModelRouterPreferences().settings + ) -> OpeningRouter? { + let models = ComposerModelCatalog.shared.models + guard let analyser = RouterAnalyser.resolve( + chatBackend: backend, + settings: settings, + connected: AgentAvailability.shared.connected, + models: models + ), let table = settings.table(for: backend, models: models[backend] ?? []) else { return nil } + return OpeningRouter(analyser: analyser, table: table) + } +} diff --git a/Sources/Bloom/State/AppModel+Workspaces.swift b/Sources/Bloom/State/AppModel+Workspaces.swift index 564b67cdd..1c9d12d19 100644 --- a/Sources/Bloom/State/AppModel+Workspaces.swift +++ b/Sources/Bloom/State/AppModel+Workspaces.swift @@ -46,13 +46,14 @@ extension AppModel { controls: ComposerControls? = nil, staged: StagedAttachments? = nil, checkout: WorkspaceCheckout? = nil, - runSetupScript: Bool = true + runSetupScript: Bool = true, + routesModel: Bool = false ) async -> Workspace? { do { return try await startWorkspace( in: repo, prompt: prompt, baseBranch: baseBranch, opensWith: opensWith, branch: branch, controls: controls, staged: staged, checkout: checkout, - runSetupScript: runSetupScript + runSetupScript: runSetupScript, routesModel: routesModel ) } catch { // Diagnosed rather than reported. `error.readableMessage` on a `ShellError` is the git @@ -99,7 +100,10 @@ extension AppModel { /// A thread on the chosen backend for the new chat to pick up. Only `carryOn` passes one. /// See `WorkspaceStartRequest.resuming`. resuming: String? = nil, - runSetupScript: Bool = true + runSetupScript: Bool = true, + /// Whether the automatic router chooses the first chat's model. Only the create window passes + /// true, since nothing else has a window to show the router's card in. See `OpeningRoute`. + routesModel: Bool = false ) async throws -> Workspace { guard let manager else { throw AppNotReady.stillStartingUp } isCreatingWorkspace = true @@ -138,6 +142,31 @@ extension AppModel { effectiveControls.agentKind = agentKind } + // Asked before the worktree is cut, so the analyser reads while git and the setup script run. + // Short circuited, so a workspace created with the router off decodes no table. + let router = routesModel ? openingRouter(for: effectiveControls.agentKind) : nil + let route: OpeningRoute? + if let router, ModelRouting.shouldRoute( + isEnabled: routesModel, + hasAnalyser: true, + mode: opensWith, + backend: effectiveControls.agentKind, + prompt: spoken, + isResuming: resuming != nil + ) { + route = OpeningRoute( + task: spoken, + project: repo.name, + controls: effectiveControls, + analyser: router.analyser, + executable: AgentAvailability.shared.executable(for: router.analyser.kind), + table: router.table, + models: ComposerModelCatalog.shared.models[effectiveControls.agentKind] ?? [] + ) + } else { + route = nil + } + // Whether to ask a model for a name at all. Read here rather than inside the closure // below, because it is two facts about this machine that only the main actor holds: the // preference, and whether the CLI is installed. @@ -267,6 +296,8 @@ extension AppModel { // The only way out with the row still up. Everything past this line has a stored // workspace behind it, and `reload` retires the pending row against that. forgetPending(id) + // And the only way out with the router still asking about a chat that will not exist. + route?.cancel() throw error } @@ -308,7 +339,7 @@ extension AppModel { // The persisted setup state carries the creation choice. The opening prompt still goes // through the same queue when setup is skipped, including in a chat workspace. - await model(for: started.workspace).startSetupThenSend(prompt: opening, repo: repo) + await model(for: started.workspace).startSetupThenSend(prompt: opening, repo: repo, route: route) return started.workspace } diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index e72737648..ae807d7f5 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -567,6 +567,8 @@ final class AppModel { Log.launchStep("store open") self.store = store ComposerModelCatalog.shared.configure(store: store) + // For the automatic router; in the background, so nothing at launch waits for it. + AgentAvailability.shared.configure(store: store) self.manager = WorkspaceManager(store: store) try await store.resetRunningSessions() try await store.recoverDeliveryClaims() diff --git a/Sources/Bloom/State/OpeningRoute.swift b/Sources/Bloom/State/OpeningRoute.swift new file mode 100644 index 000000000..3ea29ae8f --- /dev/null +++ b/Sources/Bloom/State/OpeningRoute.swift @@ -0,0 +1,115 @@ +import BloomCore +import Observation + +/// The router's question about a new workspace's first chat: asked when Create is pressed, so the +/// analyser reads while git cuts the worktree, and joined to the chat by `settle(into:in:)`. +/// +/// It holds the queue's drain, not the setup. Settling never drains: an analyser can fail before +/// the setup script starts, which once sent the opening message beside `composer install`. Not a +/// `DeliveryHold` case, because those are stored facts the bridge reads and this is in memory only. +@MainActor +@Observable +final class OpeningRoute { + private(set) var progress = ModelRouterProgress() + /// Nil while thinking, and for good when skipped or unanswered. + private(set) var route: ModelRoute? + /// Whether the first message may go: the route has been written to the chat, or given up on. + private(set) var isSettled = false + private(set) var wasSkipped = false + private(set) var sessionID: SessionID? + var isDismissed = false + + let analyser: RouterAnalyser + + @ObservationIgnored private var asking: Task<ModelRoute?, Never>? + @ObservationIgnored private var applying: Task<Void, Never>? + + /// - Parameter controls: what the chat keeps if the router declines. + /// - Parameter models: possibly empty; nothing waits for a fetch, an empty list only skips the + /// narrowing. + init( + task: String, + project: String, + controls: ComposerControls, + analyser: RouterAnalyser, + executable: String, + table: ModelRouterTable, + models: [AgentModel], + router: ModelRouter = ModelRouter() + ) { + self.analyser = analyser + let steps = router.progress( + task: task, + project: project, + template: PromptOverrides().template(for: .routeTask), + analyser: analyser, + executable: executable + ) + let current = controls.model + let backend = controls.agentKind + asking = Task { [weak self] () -> ModelRoute? in + var last = ModelRouterProgress() + for await step in steps { + last = step + self?.progress = step + } + // An answer landing in the same breath as Skip is still skipped. + guard !Task.isCancelled, let answer = last.answer else { return nil } + return ModelRouting.route(answer, table: table, backend: backend, current: current, models: models) + } + } + + /// Applies through `TranscriptModel.updatePreferences`, so the first delivery's runner is built + /// on the routed model, then syncs the workspace's session list as `ComposerSessionEditor.apply` + /// does, so the strip does not describe the old model. A model or effort picked in the composer + /// while the analyser reads wins, as a Skip would. + func settle(into transcript: TranscriptModel, in workspace: WorkspaceModel) { + guard applying == nil else { return } + sessionID = transcript.session.id + let startedOn = [transcript.session.model, transcript.session.effort] + applying = Task { [weak self, weak transcript, weak workspace] in + guard let self else { return } + var answer: ModelRoute? + if let asking = self.asking { answer = await asking.value } + guard !Task.isCancelled else { return } + if let transcript, [transcript.session.model, transcript.session.effort] != startedOn { + answer = nil + self.wasSkipped = true + } + if let answer, let transcript { + await transcript.updatePreferences(model: answer.model, effort: answer.effort) + if let workspace, let index = workspace.sessions.firstIndex(where: { $0.id == transcript.session.id }) { + workspace.sessions[index] = transcript.session + } + await transcript.record(ModelRouteNote(route: answer, analyser: self.analyser.name)) + } + self.route = answer + self.isSettled = true + } + } + + /// Returns at once when the route never joined a chat. `WorkspaceModel.runSetupThenSend` awaits + /// this before its drain. + func settled() async { + guard let applying else { return } + await applying.value + } + + func skip() { + guard !isSettled else { return } + wasSkipped = true + asking?.cancel() + } + + /// Settled as well as stopped: an archive can be refused after stopping everything, and an + /// unsettled route would then keep the first chat's queue shut for good. + func cancel() { + asking?.cancel() + applying?.cancel() + isSettled = true + } + + func holds(_ id: SessionID) -> Bool { + !isSettled && sessionID == id + } +} diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index fa87d1c77..833153fff 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -89,6 +89,8 @@ struct TranscriptRow: Identifiable, Hashable, Sendable { } } +extension TranscriptRow: ModelRouteNoteRow {} + /// The state behind one session's transcript: the rows, whether the agent is running, and the /// partial text currently streaming in. @MainActor @@ -747,6 +749,49 @@ final class TranscriptModel { await refreshSession() } + // MARK: - The router's note + + /// See `ModelRouteNote`. + func record(_ note: ModelRouteNote) async { + guard let store else { return } + _ = try? await store.appendNext(sessionID: session.id, kind: .notice, payload: note.payload) + await appendLatestMessages() + } + + /// Which session start row carries which note, and where Change is offered. See + /// `ModelRouteNote.placement`. + var routePlacement: ModelRouteNote.Placement { + ModelRouteNote.placement(in: rows, isQueueEmpty: pendingDeliveries.isEmpty) + } + + /// The rungs Change offers, read from the table in Settings. + func routeChoices() -> [ModelRoute]? { + guard let table = ModelRouterPreferences().settings.table(for: session.agentKind, models: agentModels) + else { return nil } + return ModelRouteNote.choices( + table: table, backend: session.agentKind, current: session.model, models: agentModels + ) + } + + /// `cancelTurn` rather than `stop`, because Stop hands the queue back to the composer and would + /// pull the opening message back out of the queue it is about to join. + func rerunOpening(on route: ModelRoute) async { + guard let opening = routePlacement.offer?.opening else { return } + if isRunning { cancelTurn() } + await updatePreferences(model: route.model, effort: route.effort) + // The strip reads the workspace's copy, as `ComposerSessionEditor.apply` does. + if let model = workspace.flatMap({ app.existingModel(for: $0.id) }), + let index = model.sessions.firstIndex(where: { $0.id == session.id }) { + model.sessions[index] = session + } + await record(ModelRouteNote(route: route, analyser: nil)) + await submit(opening) + } + + private var agentModels: [AgentModel] { + ComposerModelCatalog.shared.models[session.agentKind] ?? [] + } + /// Asks the list to go back to the newest row. /// /// The live end rather than a row, and that is the whole of what changed here. This used to @@ -906,6 +951,7 @@ final class TranscriptModel { /// question the moment a running turn stopped holding the queue on two of the four backends: /// the tooltip went on offering to queue a message that was about to go straight out. var queuesNextMessage: Bool { + if isAwaitingOpeningRoute { return true } if history.isCapturing || history.isFinalisingTurn || (history.hasActiveTurn && !isRunning) { return true } if isRunning, session.interactionMode != activeInteractionMode { return true } return !Delivery.goesImmediately( @@ -926,9 +972,20 @@ final class TranscriptModel { if isRunning, let mode = pendingDeliveries.first?.interactionMode, mode != activeInteractionMode { return "Goes when this turn ends." } + // Setup is usually the longer wait, so the router's sentence shows only once setup is done. + if isAwaitingOpeningRoute, deliveryHold != .setup { return ModelRouting.holdSentence } return deliveryHold.sentence(on: session.agentKind) } + /// Read off the workspace's model, like the setup half of `deliveryHold`, so only one thing holds + /// the question. See `OpeningRoute`. + var isAwaitingOpeningRoute: Bool { + guard let workspace, let route = app.existingModel(for: workspace.id)?.openingRoute else { + return false + } + return route.holds(session.id) + } + /// Hands the queue to the agent, from the front, for as long as it is allowed to go. /// /// **How many leave in one pass is the backend's answer and `Delivery.deliverable` is where it @@ -952,6 +1009,8 @@ final class TranscriptModel { guard !history.isCapturing, !history.isFinalisingTurn, !(history.hasActiveTurn && !isRunning) else { return } startWaitingShellCommand() guard !isWorkspaceArchiving, !wasStoppedByHand, store != nil else { return } + // `WorkspaceModel.runSetupThenSend` drains once the route settles, so nothing is left behind. + guard !isAwaitingOpeningRoute else { return } guard drainState.begin() else { return } var allowRepeat = true defer { finishDrain(allowRepeat: allowRepeat) } diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index 62ec3cfa5..ac1425983 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -390,6 +390,9 @@ final class WorkspaceModel { @ObservationIgnored private var setupRunTask: Task<Bool, Never>? /// Set by `stopSetup`, so a run the reader stopped is not announced as a failed setup. @ObservationIgnored private var setupWasStopped = false + /// The router's answer for the first chat, in memory only, so nil for any workspace this launch + /// did not create with the router on. See `OpeningRoute`. + var openingRoute: OpeningRoute? init(workspace: Workspace, app: AppModel) { self.workspace = workspace @@ -1268,6 +1271,8 @@ final class WorkspaceModel { func stopEverything() { for state in sideConversations.values { state.task?.cancel() } for transcript in transcripts.values { transcript.terminateNow() } + // A router still thinking for a workspace on its way out is a process thinking for nobody. + openingRoute?.cancel() setupTask?.cancel() setupTask = nil arrivalTask?.cancel() @@ -1303,6 +1308,7 @@ final class WorkspaceModel { /// than only asking them to leave. func shutdown() async { for state in sideConversations.values { state.task?.cancel() } + openingRoute?.cancel() setupTask?.cancel() setupTask = nil // Nilled like the three above: a cancelled refresh returns through its @@ -1331,10 +1337,20 @@ final class WorkspaceModel { /// route from anything typed while the script was going, and the two raced: the owner opened a /// workspace with "list the technologies used", typed "test" a moment later, and got "test" /// answered first. See `Delivery` and `TranscriptModel.submit`. - func startSetupThenSend(prompt: String?, repo: Repo) async { + func startSetupThenSend(prompt: String?, repo: Repo, route: OpeningRoute? = nil) async { let cliSession = activeSession.flatMap { session in CenterTabStore.shared.terminal(for: session.id, in: workspace.id).map { _ in session } } + // Joined before the opening message is queued, so the pending bubble shows the router's caption + // from its first frame. A terminal chat has its own picker, so the router never applies there. + if let route { + if cliSession == nil, let session = activeSession { + openingRoute = route + route.settle(into: transcript(for: session), in: self) + } else { + route.cancel() + } + } if let cliSession { pendingCLIPrompts[cliSession.id] = prompt } let cliDelivery: Delivery? if let cliSession, let prompt, !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1462,6 +1478,10 @@ final class WorkspaceModel { await launchCLI(cliSession, prompt: cliPrompt ?? "", repo: repo) return } + // Awaited before `activeSession` is read: a session read first is the pre-route row, which + // `transcript(for:)` wrote back over the routed one, running the turn on the window's model. + await openingRoute?.settled() + guard !Task.isCancelled else { return } guard let session = activeSession, CenterTabStore.shared.terminal(for: session.id, in: workspace.id) == nil else { return } // The worktree is built, so whatever was asked for while it was being built may go, oldest diff --git a/Sources/Bloom/Views/Center/Composer/ComposerFooterView.swift b/Sources/Bloom/Views/Center/Composer/ComposerFooterView.swift index aa786f315..f5213fda1 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerFooterView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerFooterView.swift @@ -67,6 +67,8 @@ struct ComposerFooterView: View, Equatable { var showsAgentControls: Bool = true var usesCLIChat: Binding<Bool>? var supportsCLIChat: Bool = true + /// Nil where Auto is not offered. See `ComposerSettingsPicker.autoModel`. + var autoModel: Binding<Bool>? /// Identifies the owner of the callbacks when the row is compared across parent updates. var actionIdentity: ObjectIdentifier? @@ -89,6 +91,7 @@ struct ComposerFooterView: View, Equatable { && lhs.showsAgentControls == rhs.showsAgentControls && (lhs.usesCLIChat != nil) == (rhs.usesCLIChat != nil) && lhs.supportsCLIChat == rhs.supportsCLIChat + && lhs.autoModel?.wrappedValue == rhs.autoModel?.wrappedValue && lhs.actionIdentity == rhs.actionIdentity } @@ -288,7 +291,8 @@ struct ComposerFooterView: View, Equatable { onOutputStyle: { id in edit { $0.outputStyle = id } }, onPermissionMode: selectPermissionMode, onContextWindow: { tokens in edit { $0.codexContextWindow = tokens } }, - onInteractionMode: { mode in edit { $0.interactionMode = mode } } + onInteractionMode: { mode in edit { $0.interactionMode = mode } }, + autoModel: autoModel ) ComposerFastModeToggle( diff --git a/Sources/Bloom/Views/Center/Composer/ComposerSettingsPicker.swift b/Sources/Bloom/Views/Center/Composer/ComposerSettingsPicker.swift index 18bf58794..9a2ed439a 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerSettingsPicker.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerSettingsPicker.swift @@ -24,6 +24,8 @@ struct ComposerSettingsPicker: View { var onPermissionMode: @MainActor (String) -> Void var onContextWindow: @MainActor (Int) -> Void var onInteractionMode: @MainActor (InteractionMode) -> Void = { _ in } + /// Nil where the router cannot choose: only the create window offers Auto. See `OpeningRoute`. + var autoModel: Binding<Bool>? @Environment(AppModel.self) private var app @Environment(\.openSettings) private var openSettings @@ -67,7 +69,8 @@ struct ComposerSettingsPicker: View { onPermissionMode: onPermissionMode, onContextWindow: onContextWindow, onInteractionMode: onInteractionMode, - onSavePreset: savePreset + onSavePreset: savePreset, + autoModel: autoModel ) .environment(\.fontScale, 1) } @@ -76,13 +79,24 @@ struct ComposerSettingsPicker: View { private func presetMenu(matched: ModelPreset?) -> some View { Menu { + if let autoModel { + Toggle(isOn: autoModel) { + Text(ComposerSettingsPanel.autoLabel) + Text(ComposerSettingsPanel.autoSummary) + } + Divider() + } + Section("Presets") { ForEach(library.presets) { preset in // A toggle rather than a button, because the tick is the menu's to draw // and a toggle is what asks for it. See `ComposerOptionMenu.rows`. Toggle(isOn: Binding( - get: { matched?.id == preset.id }, - set: { _ in MainActor.assumeIsolated { onPreset(preset) } } + get: { autoModel?.wrappedValue != true && matched?.id == preset.id }, + set: { _ in MainActor.assumeIsolated { + autoModel?.wrappedValue = false + onPreset(preset) + } } )) { Text(preset.name) Text(presetSummary(preset)) @@ -136,6 +150,7 @@ struct ComposerSettingsPicker: View { /// The preset's name when the composer is set to one, and otherwise the two choices that say /// most about what a turn costs. The dot beside a one-off is drawn by the label. private func label(matched: ModelPreset?) -> String { + if autoModel?.wrappedValue == true { return ComposerSettingsPanel.autoLabel } if let matched { return matched.name } return "\(modelLabel) · \(effortLabel)" } @@ -172,6 +187,14 @@ private struct ComposerSettingsPanel: View { var onContextWindow: @MainActor (Int) -> Void var onInteractionMode: @MainActor (InteractionMode) -> Void = { _ in } var onSavePreset: @MainActor (String) -> Void + var autoModel: Binding<Bool>? + + static let autoLabel = "Auto" + static let autoSummary = "A light model reads the message and picks the model and reasoning" + /// No model or effort id has a space in it. + private static let autoTag = "bloom auto" + + private var isAuto: Bool { autoModel?.wrappedValue == true } /// The name being typed for a new preset, or nil while the row shows its button. @State private var presetName: String? @@ -184,7 +207,12 @@ private struct ComposerSettingsPanel: View { VStack(alignment: .leading, spacing: Metrics.spacing) { settingRow("Model") { modelPicker } settingRow("Reasoning") { - optionPicker("Reasoning", selection: controls.effort, options: efforts, onSelect: onEffort) + optionPicker( + "Reasoning", + selection: isAuto ? Self.autoTag : controls.effort, + options: withAuto(efforts), + onSelect: { id in choose(id, then: onEffort) } + ) } if controls.offersOutputStyle { @@ -275,6 +303,10 @@ private struct ComposerSettingsPanel: View { private var modelPicker: some View { Picker("Model", selection: modelBinding) { + if autoModel != nil { + Text(Self.autoLabel).tag(Self.autoTag) + Divider() + } ForEach(models) { section in Section(section.title) { ForEach(section.options) { option in @@ -326,7 +358,22 @@ private struct ComposerSettingsPanel: View { } private var modelBinding: Binding<String> { - Binding(get: { controls.model }, set: { id in MainActor.assumeIsolated { onModel(id) } }) + Binding( + get: { isAuto ? Self.autoTag : controls.model }, + set: { id in MainActor.assumeIsolated { choose(id, then: onModel) } } + ) + } + + /// Picking a value by hand turns Auto off even when the value is unchanged: the pick is the decision. + private func choose(_ id: String, then select: @MainActor (String) -> Void) { + guard let autoModel else { return select(id) } + autoModel.wrappedValue = id == Self.autoTag + if id != Self.autoTag { select(id) } + } + + private func withAuto(_ options: [ComposerOption]) -> [ComposerOption] { + guard autoModel != nil else { return options } + return [ComposerOption(id: Self.autoTag, label: Self.autoLabel)] + options } /// "Save as preset…", which becomes a name field in place, or a line saying which preset these diff --git a/Sources/Bloom/Views/Center/Panes/ChatPaneView.swift b/Sources/Bloom/Views/Center/Panes/ChatPaneView.swift index 798344517..f757abbac 100644 --- a/Sources/Bloom/Views/Center/Panes/ChatPaneView.swift +++ b/Sources/Bloom/Views/Center/Panes/ChatPaneView.swift @@ -63,11 +63,19 @@ struct ChatPaneView: View { } var body: some View { - TranscriptView( - transcript: transcript, - isRunningSetup: model.isRunningSetup, - memory: model.transcriptMemory(pane: pane) - ) { isTranscriptScrolledUp = $0 } + VStack(spacing: 0) { + // Above the transcript rather than over it, so it pushes the setup log down. See + // `OpeningRouteCard`. + if let route = model.openingRoute, route.sessionID == transcript.session.id { + OpeningRouteCard(route: route, transcript: transcript) { model.openingRoute = nil } + } + + TranscriptView( + transcript: transcript, + isRunningSetup: model.isRunningSetup, + memory: model.transcriptMemory(pane: pane) + ) { isTranscriptScrolledUp = $0 } + } .environment(\.composerRoom, room) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { diff --git a/Sources/Bloom/Views/Center/Panes/OpeningRouteCard.swift b/Sources/Bloom/Views/Center/Panes/OpeningRouteCard.swift new file mode 100644 index 000000000..d284d69a5 --- /dev/null +++ b/Sources/Bloom/Views/Center/Panes/OpeningRouteCard.swift @@ -0,0 +1,114 @@ +import SwiftUI +import BloomCore + +/// The automatic router at work, at the head of the conversation it is choosing a model for. +/// Every word and the rule for drawing it are `ModelRouteCaption`'s. +/// +/// Above the transcript rather than in its table, which caches row heights and handles a block +/// that grows several times a second worst of all. +struct OpeningRouteCard: View { + let route: OpeningRoute + /// Read here rather than in `ChatPaneView` so arriving rows re-run this small body, not the pane's. + let transcript: TranscriptModel + /// The workspace lets go of the route, so nothing watches the rows for the rest of its life. + var onFinished: () -> Void + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var isShown: Bool { + ModelRouteCaption.showsCard( + isSettled: route.isSettled, + isDismissed: route.isDismissed, + hasSessionStarted: transcript.rows.contains { + TranscriptRowInk.isSessionStart(kind: $0.kind, payload: $0.payload) + } + ) + } + + var body: some View { + Group { + if isShown { + card.transition(.opacity) + } + } + .animation(reduceMotion ? nil : Motion.hover, value: isShown) + .animation(reduceMotion ? nil : Motion.hover, value: route.isSettled) + .onChange(of: isShown) { _, shown in + if !shown { onFinished() } + } + } + + /// Drawn as a transcript row, because a glass card above the conversation read as a box inside + /// a box. + private var card: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: TranscriptLayout.glyphGap) { + statusGlyph + .frame(width: TranscriptLayout.glyphWidth) + + Text(ModelRouteCaption.title(route: route.route, isSettled: route.isSettled)) + .font(Typo.label) + .foregroundStyle(Palette.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 0) + + if route.isSettled { + Button("Dismiss") { route.isDismissed = true } + .linkButton() + .font(Typo.caption) + } else { + Button("Skip") { route.skip() } + .linkButton() + .font(Typo.caption) + .help("Start on the model and effort the new workspace window was set to") + } + } + .transcriptRowFrame() + + detailText(ModelRouteCaption.detail( + route: route.route, isSettled: route.isSettled, wasSkipped: route.wasSkipped, + analyser: route.analyser.name, + readsOnlyTheMessage: route.analyser.kind == .claudeCode + )) + + // Gone once settled: a paragraph of working under a decision reads as the decision being unsure. + if !route.isSettled { + let reasoning = ModelRouteCaption.reasoningTail(route.progress.reasoning) + if !reasoning.isEmpty { + detailText(reasoning) + .lineLimit(4) + .padding(.top, Metrics.spacingSmall) + .accessibilityLabel("\(route.analyser.name) is thinking: \(reasoning)") + } + } + } + .padding(.horizontal, TranscriptLayout.inset) + .padding(.vertical, TranscriptLayout.block) + .frame(maxWidth: TranscriptLayout.conversationMeasure, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var statusGlyph: some View { + if route.isSettled { + TranscriptGlyph(symbol: route.route == nil ? "arrow.uturn.backward" : "checkmark.circle") + } else { + ProgressView() + .controlSize(.mini) + .accessibilityHidden(true) + } + } + + /// Under the title, where an expanded row's body goes. + private func detailText(_ text: String) -> some View { + Text(text) + .font(Typo.caption) + .foregroundStyle(Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, TranscriptLayout.detailIndent) + .padding(.trailing, TranscriptLayout.inset) + } +} diff --git a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift index 446d7de9e..7ec94e35d 100644 --- a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift @@ -463,6 +463,8 @@ struct AgentsSettingsView: View { private func read(from catalog: AgentCatalog) async { let found = await catalog.statuses() statuses = Dictionary(found.map { ($0.kind, $0) }, uniquingKeysWith: { _, latest in latest }) + // A sign in, sign out or new path seen here is what the router should believe too. + AgentAvailability.shared.adopt(statuses: found, overrides: overrides) } private func loadOverrides() async -> [AgentKind: String] { diff --git a/Sources/Bloom/Views/Chrome/Settings/ModelSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/ModelSettingsView.swift index 6ebfe1b69..5e11cd56f 100644 --- a/Sources/Bloom/Views/Chrome/Settings/ModelSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/ModelSettingsView.swift @@ -36,6 +36,10 @@ struct ModelSettingsView: View { } } + // Its own preferences rather than `AppDefaults`: it is about how Bloom asks, not what a session + // starts on. + RouterSettingsSection() + Section("New session behaviour") { Picker("Open new chats in", selection: $defaults.terminalChat) { Text("Bloom chat").tag(false) diff --git a/Sources/Bloom/Views/Chrome/Settings/RouterSettingsSection.swift b/Sources/Bloom/Views/Chrome/Settings/RouterSettingsSection.swift new file mode 100644 index 000000000..79447d259 --- /dev/null +++ b/Sources/Bloom/Views/Chrome/Settings/RouterSettingsSection.swift @@ -0,0 +1,238 @@ +import SwiftUI +import BloomCore + +/// Settings, Sessions, Automatic router. Every row but the switch starts on Bloom's suggestion and +/// stores nothing until changed (see `ModelRouterSettings`). Only signed in agents are listed, +/// because a row for one with no account would do nothing. +struct RouterSettingsSection: View { + @State private var isEnabled = ModelRouterPreferences().isEnabled + @State private var settings = ModelRouterPreferences().settings + + private var catalog: ComposerModelCatalog { .shared } + + /// The agents the router can ask, in the order Bloom offers agents everywhere else. + private var agents: [AgentKind] { + AgentKind.runnable.filter { AgentAvailability.shared.connected.contains($0) } + } + + var body: some View { + Section { + Toggle(isOn: $isEnabled) { + Text("Choose the model for each new workspace") + Text("A light model reads the first message and picks a model and reasoning effort for it before the conversation starts. What each agent can reach while it reads is below.") + } + + if isEnabled { + if agents.isEmpty { + Text(emptyNote) + .settingsFootnote() + } else { + Picker(selection: $settings.analyser) { + Text("The chat's own agent").tag(ModelRouterSettings.Analyser.followChat) + ForEach(agents) { kind in + Text(kind.label).tag(ModelRouterSettings.Analyser.agent(kind)) + } + } label: { + Text("Read the task with") + Text("When that agent is signed out, the first signed in one reads it instead.") + } + + ForEach(agents) { kind in + SettingsRow("\(kind.label) reader") { + RouterChoicePicker( + kind: kind, + choice: analyserChoice(for: kind), + suggestion: suggestedAnalyser(for: kind) + ) + } + } + + DisclosureGroup("Routing tables") { + ForEach(agents) { kind in + RouterTableEditor(kind: kind, settings: $settings) + } + } + } + } + } header: { + Text("Automatic router") + } footer: { + VStack(alignment: .leading, spacing: Metrics.spacingSmall) { + Text("A model or effort picked by hand in the new workspace window always wins. The router never moves a chat to another agent.") + .settingsFootnote() + if isEnabled { + ForEach(agents) { kind in + Text(ModelRouteCaption.safetyNote(for: kind)) + .settingsFootnote() + } + } + } + } + .onChange(of: isEnabled) { _, value in + ModelRouterPreferences().isEnabled = value + if value { AgentAvailability.shared.refreshIfStale() } + } + .onChange(of: settings) { _, value in + ModelRouterPreferences().settings = value + } + .task { + if isEnabled { AgentAvailability.shared.refreshIfStale() } + } + } + + private var emptyNote: String { + AgentAvailability.shared.hasLoaded + ? "No signed in agent can read tasks yet. Sign in to one under Agents." + : "Looking for signed in agents\u{2026}" + } + + private func suggestedAnalyser(for kind: AgentKind) -> ModelRouteChoice { + let suggested = RouterAnalyser.analyser(on: kind, stored: nil, models: catalog.models[kind] ?? []) + return ModelRouteChoice(model: suggested.model, effort: suggested.effort) + } + + private func analyserChoice(for kind: AgentKind) -> Binding<ModelRouteChoice?> { + Binding( + get: { settings.analyserModels[kind] }, + set: { settings.analyserModels[kind] = $0 } + ) + } +} + +/// One agent's table: a row per rung, each on the suggestion until it is changed, and a reset. +private struct RouterTableEditor: View { + let kind: AgentKind + @Binding var settings: ModelRouterSettings + + private var catalog: ComposerModelCatalog { .shared } + + private var suggested: ModelRouterTable? { + ModelRouterTable.suggested(for: kind, models: catalog.models[kind] ?? []) + } + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.spacingSmall) { + HStack { + Text(kind.label) + .font(Typo.labelEmphasis) + Spacer(minLength: 0) + if settings.tables[kind] != nil { + Button("Reset") { settings.tables[kind] = nil } + .linkButton() + .font(Typo.caption) + } + } + if let suggested { + ForEach(TaskComplexity.allCases, id: \.self) { rung in + SettingsRow(rung.label) { + RouterChoicePicker( + kind: kind, + choice: choice(for: rung), + suggestion: suggested.choice(for: rung) + ) + } + } + } else { + Text("\(kind.label)'s models have not been listed yet, so there is nothing to route between.") + .settingsFootnote() + } + } + .padding(.vertical, Metrics.spacingSmall) + } + + /// A rung set back to the suggestion leaves the table, so Reset shows only while something is + /// actually changed. + private func choice(for rung: TaskComplexity) -> Binding<ModelRouteChoice?> { + Binding( + get: { settings.tables[kind]?[rung] }, + set: { value in + var rungs = settings.tables[kind] ?? [:] + rungs[rung] = value + settings.tables[kind] = rungs.isEmpty ? nil : rungs + } + ) + } +} + +/// Nil is Bloom's suggestion; picking the suggestion again stores nil rather than a copy. +private struct RouterChoicePicker: View { + let kind: AgentKind + @Binding var choice: ModelRouteChoice? + let suggestion: ModelRouteChoice + + private var catalog: ComposerModelCatalog { .shared } + + /// The suggestion's row. Its tag is empty, which no model id is. + private static let suggestedTag = "" + + private var models: [ComposerOption] { + ComposerOption.adding([choice?.model ?? ""], to: catalog.options(for: kind)) + } + + private var efforts: [ComposerOption] { + guard let choice else { return [] } + return ComposerOption.adding([choice.effort], to: catalog.efforts(for: kind, model: choice.model)) + } + + var body: some View { + HStack(spacing: Metrics.gutter) { + Picker("Model", selection: model) { + Text("Suggested: \(label(of: suggestion))").tag(Self.suggestedTag) + ForEach(models) { option in + Text(option.label).tag(option.id) + } + } + .labelsHidden() + .fixedSize() + + if choice != nil, !efforts.isEmpty { + Picker("Effort", selection: effort) { + ForEach(efforts) { option in + Text(option.label).tag(option.id) + } + } + .labelsHidden() + .fixedSize() + } + } + } + + private func label(of choice: ModelRouteChoice) -> String { + let name = catalog.options(for: kind).first { $0.id == choice.model }?.label + ?? (choice.model.isEmpty ? "\(kind.label)'s default" : ModelLabel.readable(choice.model)) + guard !choice.effort.isEmpty else { return name } + return "\(name), \(ModelRouteCaption.effortLabel(choice.effort).lowercased())" + } + + /// The composer's rule: keep the effort where the new model takes it, otherwise its default. + private var model: Binding<String> { + Binding( + get: { choice?.model ?? Self.suggestedTag }, + set: { id in + MainActor.assumeIsolated { + guard id != Self.suggestedTag else { + choice = nil + return + } + let wanted = choice?.effort ?? suggestion.effort + choice = ModelRouteChoice( + model: id, + effort: catalog.resolvedEffort(wanted, for: kind, model: id) + ) + } + } + ) + } + + private var effort: Binding<String> { + Binding( + get: { choice?.effort ?? "" }, + set: { level in + MainActor.assumeIsolated { + guard let current = choice else { return } + choice = ModelRouteChoice(model: current.model, effort: level) + } + } + ) + } +} diff --git a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift index ada71edc3..4b751687d 100644 --- a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift +++ b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift @@ -85,6 +85,12 @@ struct CreateWorkspaceView: View { @State private var runSetupScript = true @State private var isLoading = false + /// Read once per project load, so the body does not go to `UserDefaults` on every pass. + @State private var isRouterEnabled = false + @State private var routerSettings = ModelRouterSettings() + /// The Auto entry: on when the setting is, off once a model or effort is picked by hand. + @State private var routesModel = false + /// Whether a model will be asked to name this workspace, which decides what the window may /// honestly promise about the branch. Both halves are settled off the main actor in `load`, /// because one of them looks for a binary on the PATH. @@ -234,6 +240,18 @@ struct CreateWorkspaceView: View { private var offersName: Bool { checkout == nil } + /// See `AppModel.openingRouter`. + private var router: AppModel.OpeningRouter? { + guard isRouterEnabled else { return nil } + return app.openingRouter(for: controls.agentKind, settings: routerSettings) + } + + private var offersRouting: Bool { + ModelRouting.offers( + isEnabled: isRouterEnabled, hasAnalyser: router != nil, mode: mode, backend: controls.agentKind + ) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -646,6 +664,8 @@ struct CreateWorkspaceView: View { controls: controls, onChange: { new in app.rememberModelChoice(from: controls, to: new) + // A model or effort chosen by hand is a decision the router does not overrule. + if ModelRouting.takesOver(from: controls, to: new) { routesModel = false } controls = new }, canSend: canCreate, @@ -667,7 +687,8 @@ struct CreateWorkspaceView: View { get: { usesCLIChat && defaultCLIMode != nil }, set: { usesCLIChat = $0 } ), - supportsCLIChat: defaultCLIMode != nil + supportsCLIChat: defaultCLIMode != nil, + autoModel: offersRouting ? $routesModel : nil ) } } @@ -1050,6 +1071,12 @@ struct CreateWorkspaceView: View { let settings = (try? await app.store?.applyingProjectAgentDefaults( to: context.settings, for: repo.id )) ?? context.settings + let routerPreferences = ModelRouterPreferences() + isRouterEnabled = routerPreferences.isEnabled + routerSettings = routerPreferences.settings + routesModel = isRouterEnabled + // Someone may have signed in or out in a terminal since the last answer. + if isRouterEnabled { AgentAvailability.shared.refreshIfStale() } controls = ComposerControls( // The Codex list only sharpens the effort here, and the sheet's own footer is what // fetches it: a window opened before that call returns still gets the backend the @@ -1269,6 +1296,7 @@ struct CreateWorkspaceView: View { let source = checkout let chosenControls = controls let shouldRunSetup = runSetupScript + let shouldRoute = routesModel && offersRouting // A file can be moved or deleted between being attached and Create being pressed, and // naming a path that is not there only teaches the agent that Bloom lies about paths. @@ -1301,7 +1329,8 @@ struct CreateWorkspaceView: View { controls: chosenControls, staged: staged, checkout: source, - runSetupScript: shouldRunSetup + runSetupScript: shouldRunSetup, + routesModel: shouldRoute ) } // Whatever survived is in the worktree now, and whatever did not was never going to be. diff --git a/Sources/Bloom/Views/Transcript/ModelRouteChangeMenu.swift b/Sources/Bloom/Views/Transcript/ModelRouteChangeMenu.swift new file mode 100644 index 000000000..c9bc39eb2 --- /dev/null +++ b/Sources/Bloom/Views/Transcript/ModelRouteChangeMenu.swift @@ -0,0 +1,34 @@ +import SwiftUI +import BloomCore + +/// Every rung of the table, while the opening message is the only thing asked; picking one +/// re-sends it on that model. See `TranscriptModel.rerunOpening(on:)`. +struct ModelRouteChangeMenu: View { + var current: ModelRoute + var choices: [ModelRoute] + var onChoose: (ModelRoute) -> Void + + var body: some View { + Menu { + ForEach(choices, id: \.complexity) { choice in + // A toggle so the menu draws its own tick beside the rung the chat is on. + let isCurrent = choice.model == current.model && choice.effort == current.effort + Toggle( + "\(choice.complexity.label): \(ModelRouteCaption.title(route: choice, isSettled: true))", + isOn: Binding(get: { isCurrent }, set: { picked in if picked { onChoose(choice) } }) + ) + .disabled(isCurrent) + } + } label: { + Text("Change") + .font(Typo.caption) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .foregroundStyle(Palette.link) + .pointerStyle(.link) + .help("Stops this run and sends the opening message again on another model") + } +} diff --git a/Sources/Bloom/Views/Transcript/SessionStartRowView.swift b/Sources/Bloom/Views/Transcript/SessionStartRowView.swift index 4c81159c9..e761a09be 100644 --- a/Sources/Bloom/Views/Transcript/SessionStartRowView.swift +++ b/Sources/Bloom/Views/Transcript/SessionStartRowView.swift @@ -8,6 +8,11 @@ import BloomCore /// setting reads the same everywhere in the app. struct SessionStartRowView: View { var info: AgentInit + /// Carried by this row rather than a row of its own, so the model is not named twice. + var route: ModelRouteNote? + /// Nil where Change offers nothing. See `ModelRouteChangeMenu`. + var routeChoices: [ModelRoute]? + var onReroute: (ModelRoute) -> Void = { _ in } private var modelLabel: String { ComposerOption.label( @@ -24,23 +29,43 @@ struct SessionStartRowView: View { } var body: some View { - HStack(spacing: TranscriptLayout.glyphGap) { - TranscriptGlyph(symbol: "bolt.horizontal.circle") + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: TranscriptLayout.glyphGap) { + TranscriptGlyph(symbol: "bolt.horizontal.circle") - Text("Started with") - .font(Typo.label) - .foregroundStyle(Palette.textSecondary) - .transcriptLabelColumn("Started with", font: Typo.label) + Text("Started with") + .font(Typo.label) + .foregroundStyle(Palette.textSecondary) + .transcriptLabelColumn("Started with", font: Typo.label) - if !info.model.isEmpty { - Chip(text: modelLabel) - } - if !info.permissionMode.isEmpty { - Chip(text: permissionLabel) + if !info.model.isEmpty { + Chip(text: modelLabel) + } + // The CLI does not report its starting effort, so it shows only where the router chose it. + if let effort = route?.route.effort, !effort.isEmpty { + Chip(text: "\(ModelRouteCaption.effortLabel(effort)) effort") + } + if !info.permissionMode.isEmpty { + Chip(text: permissionLabel) + } + + Spacer(minLength: 0) + + if let route, let routeChoices { + ModelRouteChangeMenu(current: route.route, choices: routeChoices, onChoose: onReroute) + } } + .transcriptRowFrame() - Spacer(minLength: 0) + // Below the one-line frame, because a wrapped reason inside it ran out of the row. + if let route { + Text(route.detail) + .font(Typo.caption) + .foregroundStyle(Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, TranscriptLayout.detailIndent) + .padding(.trailing, TranscriptLayout.inset) + } } - .transcriptRowFrame() } } diff --git a/Sources/Bloom/Views/Transcript/TranscriptListView.swift b/Sources/Bloom/Views/Transcript/TranscriptListView.swift index cfe7534a4..c661bb046 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptListView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptListView.swift @@ -494,6 +494,10 @@ struct TranscriptListView: View { ) let lastVisibleRow = drawnRows.last(where: { !TranscriptNoise.isHidden($0) }) let lastVisibleSeq = lastVisibleRow?.seq + // Once per pass, and into each key below: the cell is only rebuilt when its key changes, + // so a Change worked out inside it stayed on screen after it stopped applying. + let routePlacement = transcript.routePlacement + let offeredChoices = routePlacement.offer == nil ? nil : transcript.routeChoices() let hasCompletedTurn = drawnRange.upperBound == rows.count && sending == nil && lastVisibleRow.map { transcript.isCurrentTurnResult($0) } == true // The group the loop is inside, so its line is emitted once, and the indices of its @@ -697,7 +701,11 @@ struct TranscriptListView: View { // is built rather than baked in as this runs, and putting it in the key would rebuild // the cell again a fifth of a second later when the answer expired, throwing away the // settle it is meant to be showing. See `TranscriptArrivals`. + let routeNote = routePlacement.notes[row.seq] + let routeChoices = routePlacement.offer?.sessionStartSeq == row.seq ? offeredChoices : nil let key = TranscriptContentKey { + $0.combine(routeNote) + $0.combine(routeChoices) $0.combine(row.id) $0.combine(row.seq) $0.combine(row.kind) @@ -780,6 +788,11 @@ struct TranscriptListView: View { onToggle: { toggle(row.seq) }, onAnswer: { requestID, decision in Task { await transcript.answer(requestID: requestID, decision: decision) } + }, + routeNote: routeNote, + routeChoices: routeChoices, + onReroute: { route in + Task { await transcript.rerunOpening(on: route) } } ) // The arriving animation belongs to the bubble, not the earlier work. diff --git a/Sources/Bloom/Views/Transcript/TranscriptRowView.swift b/Sources/Bloom/Views/Transcript/TranscriptRowView.swift index f54831fcf..6bc19389d 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptRowView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptRowView.swift @@ -51,6 +51,8 @@ struct TranscriptRowView: View, Equatable { && lhs.row.permissionDecision == rhs.row.permissionDecision && lhs.row.permissionNote == rhs.row.permissionNote && lhs.projectName == rhs.projectName + && lhs.routeNote == rhs.routeNote + && lhs.routeChoices == rhs.routeChoices // Both are the list's answer about the message above this one, so neither is visible // in the row and both change without anything on the row changing. } @@ -82,6 +84,10 @@ struct TranscriptRowView: View, Equatable { /// Answering a permission question. Never a user turn: it writes a control response that /// unblocks a turn already in flight. var onAnswer: (String, PermissionDecision) -> Void = { _, _ in } + /// Only on a session start row. See `SessionStartRowView`. + var routeNote: ModelRouteNote? + var routeChoices: [ModelRoute]? + var onReroute: (ModelRoute) -> Void = { _ in } var body: some View { content @@ -237,7 +243,7 @@ struct TranscriptRowView: View, Equatable { case .system: if let info = initInfo { - SessionStartRowView(info: info) + SessionStartRowView(info: info, route: routeNote, routeChoices: routeChoices, onReroute: onReroute) } else if let wake = backgroundWake { BackgroundWakeRowView(wake: wake) } else if let compaction { diff --git a/Sources/BloomCore/Agent/ClaudeRouterAsk.swift b/Sources/BloomCore/Agent/ClaudeRouterAsk.swift new file mode 100644 index 000000000..acff36838 --- /dev/null +++ b/Sources/BloomCore/Agent/ClaudeRouterAsk.swift @@ -0,0 +1,181 @@ +import Foundation + +/// The Claude Code analyser's invocation as a value, so it can be asserted on without a process. +public struct ModelRouterLaunch: Sendable, Hashable { + public let executable: String + public let arguments: [String] + /// Written to stdin so a task that begins with a dash is never a flag. + public let stdin: String + public let cwd: String + + public init(executable: String, arguments: [String], stdin: String, cwd: String) { + self.executable = executable + self.arguments = arguments + self.stdin = stdin + self.cwd = cwd + } +} + +/// The routing question asked of Claude Code: the namer's `claude -p` invocation, with no tools, +/// MCP servers, project settings or session, in a scratch directory, so the analyser sees only the +/// sentence typed. Stream-json with partial messages, so the card can show the thinking while the +/// opening message waits; the answer is still the `--json-schema` structured output. +public enum ClaudeRouterAsk { + /// Injected so the suite drives the whole path without a network, an account or a bill. + public typealias Run = @Sendable (ModelRouterLaunch) -> AsyncThrowingStream<String, Error> + + static let structuredOutputTool = "StructuredOutput" + + public static func argv(model: String, effort: String, systemPrompt: String = ModelRouter.systemPrompt) -> [String] { + var arguments = [ + "-p", + "--model", ModelAlias.cliValue(for: model), + ] + // See `RouterAnalyser.readingEffort`. Absent for a model that takes none. + if !effort.isEmpty { arguments += ["--effort", effort] } + arguments += [ + "--output-format", "stream-json", + // Required by the CLI with `-p` and stream-json. See docs/PROTOCOL.md. + "--verbose", + "--include-partial-messages", + "--json-schema", ModelRouter.jsonSchema, + "--system-prompt", systemPrompt, + // Empty is "no tools at all", so the analyser cannot read a file whatever the task asks. + "--tools", "", + "--disable-slash-commands", + "--strict-mcp-config", + "--no-session-persistence", + "--safe-mode", + ] + return arguments + } + + public static func launch(_ request: ModelRouterRequest) -> ModelRouterLaunch { + ModelRouterLaunch( + executable: request.executable, + arguments: argv( + model: request.analyser.model.isEmpty ? RouterAnalyser.suggestedModel(for: .claudeCode, models: []) : request.analyser.model, + effort: request.analyser.effort, + systemPrompt: request.systemPrompt + ), + stdin: request.prompt, + cwd: request.cwd + ) + } + + /// Cancelling the reader reaches the process through `StreamingProcess.lines`, so a skipped route + /// leaves no `claude` thinking in the background. + public static let shell: Run = { launch in + AsyncThrowingStream { continuation in + let reader = Task { + let process = StreamingProcess( + executable: launch.executable, + arguments: launch.arguments, + cwd: launch.cwd, + mergeStderr: false + ) + do { + // Touching `lines` is what starts the process, so it comes before the write. + let lines = process.lines + process.write(launch.stdin) + process.closeStdin() + for try await line in lines { + continuation.yield(line) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in reader.cancel() } + } + } + + public static func ask(_ request: ModelRouterRequest, run: @escaping Run = ClaudeRouterAsk.shell) -> AsyncThrowingStream<RouterSignal, Error> { + AsyncThrowingStream { continuation in + let reader = Task { + var decoder = Decoder() + do { + for try await line in run(ClaudeRouterAsk.launch(request)) { + for signal in decoder.signals(for: line) { continuation.yield(signal) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in reader.cancel() } + } + } + + /// Stateful because with `--include-partial-messages` every thinking block arrives twice (in + /// pieces, then whole); the whole block is used only when no piece arrived. + struct Decoder { + private var streamedThinking = false + private var hasThinking = false + + mutating func signals(for line: String) -> [RouterSignal] { + guard let json = JSONValue.parse(line) else { return [] } + switch json["type"]?.stringValue { + case "stream_event": return signals(event: json["event"]) + case "assistant": return signals(assistant: json["message"]) + case "result": return signals(result: json) + default: return [] + } + } + + private mutating func signals(event: JSONValue?) -> [RouterSignal] { + guard let event else { return [] } + switch event["type"]?.stringValue { + case "content_block_start": + // A second block of thinking starts a new paragraph. + guard event["content_block"]?["type"]?.stringValue == "thinking", hasThinking else { return [] } + return [.thinking("\n\n")] + case "content_block_delta": + guard event["delta"]?["type"]?.stringValue == "thinking_delta", + let text = event["delta"]?["thinking"]?.stringValue, !text.isEmpty + else { return [] } + streamedThinking = true + hasThinking = true + return [.thinking(text)] + default: + return [] + } + } + + private mutating func signals(assistant message: JSONValue?) -> [RouterSignal] { + var out: [RouterSignal] = [] + for block in message?["content"]?.arrayValue ?? [] { + switch block["type"]?.stringValue { + case "thinking": + guard !streamedThinking, let text = block["thinking"]?.stringValue, !text.isEmpty else { continue } + out.append(.thinking((hasThinking ? "\n\n" : "") + text)) + hasThinking = true + case "tool_use": + // How `--json-schema` delivers its answer inside the turn. + if block["name"]?.stringValue == ClaudeRouterAsk.structuredOutputTool, let input = block["input"] { + out.append(.structured(input)) + } + case "text": + if let text = block["text"]?.stringValue, !text.isEmpty { out.append(.text(text)) } + default: + continue + } + } + return out + } + + /// `structured_output` first; the `result` text is the fallback for a CLI that puts it there. + private func signals(result: JSONValue) -> [RouterSignal] { + if result["is_error"]?.boolValue == true { return [.finished(isError: true)] } + var out: [RouterSignal] = [] + if let structured = result["structured_output"] { + out.append(.structured(structured)) + } else if let object = ModelRouterProgress.object(in: result["result"]?.stringValue) { + out.append(.structured(object)) + } + out.append(.finished(isError: false)) + return out + } + } +} diff --git a/Sources/BloomCore/Agent/Codex/CodexRouterAsk.swift b/Sources/BloomCore/Agent/Codex/CodexRouterAsk.swift new file mode 100644 index 000000000..193c6a146 --- /dev/null +++ b/Sources/BloomCore/Agent/Codex/CodexRouterAsk.swift @@ -0,0 +1,124 @@ +import Foundation + +/// The routing question asked of Codex: one thread, one turn, on a short-lived app-server +/// (`codex exec` has no reasoning deltas for the card to draw). +/// +/// Codex has no "no tools" switch, so the thread is read-only in a scratch directory on the +/// `untrusted` policy and every approval is declined. Not `never`: a read-only sandbox runs reads +/// without asking, so `never` let the analyser `cat` any file by absolute path. No output schema +/// is sent; the prompt asks for the JSON in words. +public enum CodexRouterAsk { + public typealias MakeClient = @Sendable (CodexClient.Configuration) -> CodexClient + + public static let liveClient: MakeClient = { CodexClient(configuration: $0) } + + /// `turn/start` spells it `readOnly`; `thread/start` takes `read-only`. + static let readOnlyPolicy = JSONValue.object(["type": .string("readOnly")]) + + public static func ask(_ request: ModelRouterRequest, makeClient: @escaping MakeClient = CodexRouterAsk.liveClient) -> AsyncThrowingStream<RouterSignal, Error> { + AsyncThrowingStream { continuation in + let client = makeClient(CodexClient.Configuration(executable: request.executable, cwd: request.cwd)) + let worker = Task { + // Subscribed before `start`, because the fan-out replays nothing to a late reader. + let events = client.events + let model = request.analyser.model.isEmpty ? nil : request.analyser.model + do { + try await client.start() + let thread = try await client.startThread( + cwd: request.cwd, + model: model, + approvalPolicy: .untrusted, + sandbox: .readOnly, + developerInstructions: request.systemPrompt + ) + try await client.startTurn( + threadID: thread.id, + input: [.text(request.prompt)], + model: model, + effort: request.analyser.effort, + approvalPolicy: .untrusted, + sandboxPolicy: CodexRouterAsk.readOnlyPolicy + ) + var decoder = Decoder(threadID: thread.id) + for await event in events { + if case .approval(let asked) = event { + await client.answer(asked, decision: .decline) + continue + } + for signal in decoder.signals(for: event) { continuation.yield(signal) } + if decoder.isFinished { break } + } + // No turn end means cancellation (deadline or Skip), reported as an error so nothing reads + // a half answer as whole. + if !decoder.isFinished { continuation.yield(.finished(isError: true)) } + await client.stop() + continuation.finish() + } catch { + await client.stop() + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + worker.cancel() + // A request awaiting its reply does not hear the cancel, so close the connection too. + Task { await client.stop() } + } + } + } + + /// The answer's text comes from finished `agentMessage` items rather than deltas, so it arrives + /// once. Only `.commentary` is skipped, because providers set the phase inconsistently. + struct Decoder { + let threadID: String + private(set) var isFinished = false + private var reasoningItem: String? + private var streamedReasoning = false + private var sawAnswer = false + + init(threadID: String) { + self.threadID = threadID + } + + mutating func signals(for event: CodexEvent) -> [RouterSignal] { + if let id = event.threadID, !id.isEmpty, id != threadID { return [] } + switch event { + case .reasoningDelta(let delta): + guard !delta.text.isEmpty else { return [] } + let breaks = reasoningItem != nil && reasoningItem != delta.itemID + reasoningItem = delta.itemID + streamedReasoning = true + return [.thinking(breaks ? "\n\n" + delta.text : delta.text)] + case .itemCompleted(let completed): + return signals(for: completed.item) + case .turnCompleted(let turn): + isFinished = true + // Some servers send the last item only in the turn's completion. + let late = sawAnswer ? [] : turn.items.flatMap { signals(for: $0) } + return late + [.finished(isError: turn.status == .failed)] + case .turnError(let failure): + guard !failure.willRetry else { return [] } + isFinished = true + return [.finished(isError: true)] + case .closed: + isFinished = true + return [.finished(isError: true)] + default: + return [] + } + } + + private mutating func signals(for item: CodexItem) -> [RouterSignal] { + switch item { + case .agentMessage(let message): + guard message.phase != .commentary, !message.text.isEmpty else { return [] } + sawAnswer = true + return [.text(message.text)] + case .reasoning(let reasoning): + guard !streamedReasoning, !reasoning.displayText.isEmpty else { return [] } + return [.thinking(reasoning.displayText)] + default: + return [] + } + } + } +} diff --git a/Sources/BloomCore/Agent/Grok/GrokRouterAsk.swift b/Sources/BloomCore/Agent/Grok/GrokRouterAsk.swift new file mode 100644 index 000000000..c7060678e --- /dev/null +++ b/Sources/BloomCore/Agent/Grok/GrokRouterAsk.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The routing question asked of Grok: one session, one prompt, on a short-lived ACP connection. +/// +/// Not the headless print mode, whose only answer to a permission question is +/// `--always-approve`. ACP has no system prompt and no "no tools" switch, so the session runs in +/// plan mode in a scratch directory with every permission refused; Grok may still read a file +/// without asking. +public enum GrokRouterAsk { + public typealias MakeClient = @Sendable (GrokClient.Configuration) -> GrokClient + + public static let liveClient: MakeClient = { GrokClient(configuration: $0) } + + public static func ask(_ request: ModelRouterRequest, makeClient: @escaping MakeClient = GrokRouterAsk.liveClient) -> AsyncThrowingStream<RouterSignal, Error> { + AsyncThrowingStream { continuation in + let client = makeClient(GrokClient.Configuration( + executable: request.executable, + cwd: request.cwd, + model: request.analyser.model, + effort: request.analyser.effort, + alwaysApprove: false + )) + let worker = Task { + // Subscribed before `start`, because the fan-out replays nothing to a late reader. + let events = client.events + do { + try await client.start() + let session = try await client.newSession( + cwd: request.cwd, + mcpServers: [], + permissionMode: .plan + ) + let promptID = try await client.beginPrompt( + sessionID: session.id, + text: GrokRouterAsk.prompt(for: request) + ) + var decoder = Decoder(session: session.id, promptID: promptID) + for await event in events { + if case .permission(let asked) = event { + await client.answer(asked.id, with: GrokPermission.cancelledResult) + continue + } + for signal in decoder.signals(for: event) { continuation.yield(signal) } + if decoder.isFinished { break } + } + if !decoder.isFinished { continuation.yield(.finished(isError: true)) } + await client.closeSession(session.id) + await client.stop() + continuation.finish() + } catch { + await client.stop() + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + worker.cancel() + Task { await client.stop() } + } + } + } + + /// The instructions and the task in one message, since ACP has nowhere else to put the first. + static func prompt(for request: ModelRouterRequest) -> String { + request.systemPrompt + "\n\n" + request.prompt + } + + /// Grok has no "message finished" event, so `ModelRouterProgress` joins the chunks, and the + /// prompt's reply ends the turn. + struct Decoder { + /// Grok's own session id, not a Bloom id. + let session: String + let promptID: GrokRequestID + private(set) var isFinished = false + + init(session: String, promptID: GrokRequestID) { + self.session = session + self.promptID = promptID + } + + mutating func signals(for event: GrokEvent) -> [RouterSignal] { + switch event { + case .update(let update): + guard update.sessionID.isEmpty || update.sessionID == session else { return [] } + switch update.kind { + case .thought(let chunk): return chunk.isEmpty ? [] : [.thinking(chunk)] + case .text(let chunk): return chunk.isEmpty ? [] : [.text(chunk)] + case .toolCall, .toolCallUpdate, .usage, .other: return [] + } + case .promptCompleted(let result): + guard result.requestID == promptID else { return [] } + isFinished = true + return [.finished(isError: result.isError || result.wasCancelled)] + case .closed: + isFinished = true + return [.finished(isError: true)] + case .sessionReady, .permission, .unknown: + return [] + } + } + } +} diff --git a/Sources/BloomCore/Agent/ModelRouteNote.swift b/Sources/BloomCore/Agent/ModelRouteNote.swift new file mode 100644 index 000000000..4bf7bf85a --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouteNote.swift @@ -0,0 +1,127 @@ +import Foundation + +/// The router's decision, stored as a `.notice` row so it survives a relaunch at the head of the +/// chat. Not drawn as its own row: the session start row reads it, so the model is not named +/// twice. See `TranscriptModel.routeNote(forSessionStartAt:)`. A rung picked by hand writes one +/// too, with no analyser. +public struct ModelRouteNote: Sendable, Hashable { + public let route: ModelRoute + /// Nil when the owner picked the rung. + public let analyser: String? + + public init(route: ModelRoute, analyser: String?) { + self.route = route + self.analyser = analyser + } + + static let subtype = "model_route" + private static let marker = Data(#""subtype":"model_route""#.utf8) + + private struct Stored: Codable { + var type = "bloom_note" + var subtype = ModelRouteNote.subtype + let complexity: TaskComplexity + let model: String + let effort: String + let reason: String + let analyser: String? + } + + public var payload: Data { + let stored = Stored( + complexity: route.complexity, model: route.model, effort: route.effort, + reason: route.reason, analyser: analyser + ) + return (try? JSONEncoder().encode(stored)) ?? Data() + } + + /// Nil when a `.notice` row is something else, such as a rate limit. A byte search first, + /// because nearly every notice is a rate limit event and this is asked of all of them. + public init?(note payload: Data) { + guard payload.range(of: Self.marker) != nil, + let stored = try? JSONDecoder().decode(Stored.self, from: payload), + stored.subtype == Self.subtype else { return nil } + route = ModelRoute( + complexity: stored.complexity, model: stored.model, + effort: stored.effort, reason: stored.reason + ) + analyser = stored.analyser + } + + public var detail: String { + guard let analyser else { return "\(route.complexity.label), picked by hand." } + let chosen = "\(route.complexity.label) task, chosen by \(analyser)." + return route.reason.isEmpty ? chosen : "\(chosen) \(route.reason)" + } + + /// Which session start row carries which note, and which one offers Change. + public struct Placement: Equatable, Sendable { + /// Keyed by the session start row's seq. + public var notes: [Int: ModelRouteNote] = [:] + public var offer: Offer? + + public struct Offer: Equatable, Sendable { + public var sessionStartSeq: Int + /// What Change sends again. + public var opening: String + } + } + + /// One pass over the stored rows. A note belongs to the next session start, and a session + /// start with no note since the previous one carries none. Change is offered on the newest + /// carrier only, while every user turn is the opening (earlier changes sent the same words) + /// and nothing waits in the queue: a queued message would otherwise go first, on the new model. + public static func placement<Rows: Sequence>(in rows: Rows, isQueueEmpty: Bool) -> Placement + where Rows.Element: ModelRouteNoteRow { + var placement = Placement() + var waiting: ModelRouteNote? + var newestCarrier: Int? + var opening: String? + var saidOnlyTheOpening = true + for row in rows { + switch row.kind { + case .notice: + if let note = ModelRouteNote(note: row.payload) { + waiting = note + newestCarrier = nil + } + case .user where saidOnlyTheOpening: + let prompt = UserTurnPrompt.text(in: row.payload) + if opening == nil { opening = prompt } else if prompt != opening { saidOnlyTheOpening = false } + default: + if let note = waiting, TranscriptRowInk.isSessionStart(kind: row.kind, payload: row.payload) { + placement.notes[row.seq] = note + newestCarrier = row.seq + waiting = nil + } + } + } + if let newestCarrier, let opening, saidOnlyTheOpening, isQueueEmpty { + placement.offer = Placement.Offer(sessionStartSeq: newestCarrier, opening: opening) + } + return placement + } + + /// Narrowed the way the router's own answer was, so a pick by hand lands on a model the account + /// can run. + public static func choices( + table: ModelRouterTable, + backend: AgentKind, + current: String, + models: [AgentModel] + ) -> [ModelRoute] { + TaskComplexity.allCases.map { rung in + ModelRouting.route( + ModelRouterAnswer(complexity: rung, reason: ""), + table: table, backend: backend, current: current, models: models + ) + } + } +} + +/// A stored row, as `ModelRouteNote.placement` reads it. +public protocol ModelRouteNoteRow { + var seq: Int { get } + var kind: MessageKind { get } + var payload: Data { get } +} diff --git a/Sources/BloomCore/Agent/ModelRouter.swift b/Sources/BloomCore/Agent/ModelRouter.swift new file mode 100644 index 000000000..b6dfda15a --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouter.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Asks a light model how much a task asks of the model that will do it, and is allowed to fail. +/// +/// Prompt, deadline and reading are shared; each adapter (`ClaudeRouterAsk`, `CodexRouterAsk`, +/// `GrokRouterAsk`) only carries the question to its agent and turns the reply into +/// `RouterSignal`s. A light model, because a larger one classifies no better and costs more. +public struct ModelRouter: Sendable { + /// Injected so the suite drives the whole path without a network, an account or a bill. + public typealias Ask = @Sendable (ModelRouterRequest) -> AsyncThrowingStream<RouterSignal, Error> + + /// Names the JSON outright because only Claude Code can be made to answer through a schema. + public static let systemPrompt = """ + You triage coding tasks for an app that chooses which model will do them. Do not use any \ + tool and do not read any file. Think briefly, then answer with only the JSON object the task \ + asks for, with no commentary before or after it. + """ + + /// Built from `TaskComplexity` so the rungs the model may name and the table's can never disagree. + public static let jsonSchema: String = { + let rungs = TaskComplexity.allCases.map { "\"\($0.rawValue)\"" }.joined(separator: ",") + return #"{"type":"object","properties":{"complexity":{"type":"string","enum":["# + + rungs + + #"]},"reason":{"type":"string"}},"required":["complexity","reason"],"additionalProperties":false}"# + }() + + /// A cap on a call that never returns, not a measurement: the Claude call has taken up to 38 + /// seconds, almost all of it thinking. + public static let timeout = Duration.seconds(60) + + /// The first paragraphs say what the work is; a pasted log after them only adds to the bill. + public static let taskLimit = 4_000 + + private let ask: Ask + private let waitLimit: Duration + + public init(ask: @escaping Ask = ModelRouter.live, timeout: Duration = ModelRouter.timeout) { + self.ask = ask + self.waitLimit = timeout + } + + public static let live: Ask = { request in + switch request.analyser.kind { + case .claudeCode: + return ClaudeRouterAsk.ask(request) + case .codex: + return CodexRouterAsk.ask(request) + case .grok: + return GrokRouterAsk.ask(request) + case .cursor, .openCode: + // `RouterAnalyser.resolve` never chooses these; failed rather than trusted, as the last door. + return AsyncThrowingStream { $0.finish(throwing: ModelRouterError.unsupported(request.analyser.kind)) } + } + } + + public static func prompt( + task: String, + project: String, + template: String = PromptRegistry.definition(for: .routeTask).defaultTemplate + ) -> String { + let trimmed = task.trimmingCharacters(in: .whitespacesAndNewlines) + let capped = trimmed.count > taskLimit ? String(trimmed.prefix(taskLimit)) : trimmed + + return PromptTemplate.render(template, values: [ + PromptRegistry.RouteTask.task: capped, + PromptRegistry.RouteTask.project: project, + ]).text + } + + /// Every element is the whole of what has been said so far. The last is always finished and there + /// is no error path: a workspace whose analyser failed starts on what the window was set to. + /// Cancelling the reading task stops the process, which is what Skip is. + public func progress( + task: String, + project: String, + template: String, + analyser: RouterAnalyser, + executable: String? = nil + ) -> AsyncStream<ModelRouterProgress> { + let request = ModelRouterRequest( + analyser: analyser, + prompt: Self.prompt(task: task, project: project, template: template), + executable: executable + ) + let ask = self.ask + let waitLimit = self.waitLimit + // The task, not the rendered prompt: the template alone would still render a full turn. + let hasTask = !task.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + return AsyncStream { continuation in + let worker = Task { + var progress = ModelRouterProgress() + if hasTask { + do { + for try await signal in ask(request) { + let changed = progress.ingest(signal) + if progress.isFinished { break } + if changed { continuation.yield(progress) } + } + } catch { + // Offline, signed out or past the deadline all end the same way, in `finish`. + } + } + progress.finish() + continuation.yield(progress) + continuation.finish() + } + let deadline = Task { + do { + try await Task.sleep(for: waitLimit) + worker.cancel() + } catch { + // Cancelled because the answer arrived first. + } + } + continuation.onTermination = { _ in + worker.cancel() + deadline.cancel() + } + } + } +} diff --git a/Sources/BloomCore/Agent/ModelRouterProgress.swift b/Sources/BloomCore/Agent/ModelRouterProgress.swift new file mode 100644 index 000000000..2be8d8c07 --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouterProgress.swift @@ -0,0 +1,84 @@ +import Foundation + +/// What the analyser has said so far, folded from `RouterSignal`s. A value rather than a callback, +/// so it crosses actors, compares, and lets the suite replay exactly what the card would show. +public struct ModelRouterProgress: Sendable, Equatable { + public enum Phase: Sendable, Equatable { + case thinking + case answered(ModelRouterAnswer) + /// Failed, timed out, skipped or unusable: the caller does the same thing for all of them. + case failed + } + + /// Empty when the model did not think out loud; the card then draws only its spinner. + public private(set) var reasoning = "" + public private(set) var phase: Phase = .thinking + + private var answerText = "" + private var structured: JSONValue? + + public init() {} + + public var answer: ModelRouterAnswer? { + if case .answered(let answer) = phase { return answer } + return nil + } + + public var isFinished: Bool { phase != .thinking } + + /// Ignored once the phase has left `.thinking`, so a signal after a timeout cannot revive it. + @discardableResult + public mutating func ingest(_ signal: RouterSignal) -> Bool { + guard phase == .thinking else { return false } + switch signal { + case .thinking(let text): + guard !text.isEmpty else { return false } + reasoning += text + return true + case .text(let text): + answerText += text + return false + case .structured(let value): + structured = value + return false + case .finished(let isError): + phase = isError ? .failed : settled() + return true + } + } + + public mutating func finish() { + if phase == .thinking { phase = .failed } + } + + private func settled() -> Phase { + for candidate in [structured, Self.object(in: answerText)] { + if let candidate, let answer = ModelRouting.answer(from: candidate) { + return .answered(answer) + } + } + return .failed + } + + /// Forgives a code fence and a sentence around the object by parsing the outermost braces. + static func object(in text: String?) -> JSONValue? { + guard var body = text?.trimmingCharacters(in: .whitespacesAndNewlines), !body.isEmpty else { + return nil + } + if body.hasPrefix("```") { + body = body + .split(separator: "\n", omittingEmptySubsequences: false) + .dropFirst() + .filter { !$0.hasPrefix("```") } + .joined(separator: "\n") + } + if let parsed = JSONValue.parse(body), parsed.objectValue != nil { return parsed } + guard let open = body.firstIndex(of: "{"), let close = body.lastIndex(of: "}"), open < close else { + return nil + } + guard let parsed = JSONValue.parse(String(body[open...close])), parsed.objectValue != nil else { + return nil + } + return parsed + } +} diff --git a/Sources/BloomCore/Agent/ModelRouterSettings.swift b/Sources/BloomCore/Agent/ModelRouterSettings.swift new file mode 100644 index 000000000..67f9f982e --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouterSettings.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Which agent analyses, on which model, and the owner's changes to each agent's table. +/// +/// Every field is an override, empty for nearly everybody. Storing only the differences lets a +/// suggestion improve when an agent's list does, rather than pinning the list of the day. +public struct ModelRouterSettings: Sendable, Equatable { + public enum Analyser: Sendable, Hashable { + /// The chat's agent, or the first connected one when that one is not. + case followChat + case agent(AgentKind) + } + + public var analyser: Analyser + public var analyserModels: [AgentKind: ModelRouteChoice] + public var tables: [AgentKind: [TaskComplexity: ModelRouteChoice]] + + public init( + analyser: Analyser = .followChat, + analyserModels: [AgentKind: ModelRouteChoice] = [:], + tables: [AgentKind: [TaskComplexity: ModelRouteChoice]] = [:] + ) { + self.analyser = analyser + self.analyserModels = analyserModels + self.tables = tables + } + + /// Nil when there is neither suggestion nor override, which keeps what the window was set to. + public func table(for kind: AgentKind, models: [AgentModel]) -> ModelRouterTable? { + let overrides = tables[kind] ?? [:] + guard let suggested = ModelRouterTable.suggested(for: kind, models: models) else { + return overrides.isEmpty ? nil : ModelRouterTable(choices: overrides) + } + return suggested.overriding(overrides) + } +} + +// MARK: - Storage + +/// String keys, because a dictionary keyed by an enum encodes as an unreadable flat array. +private struct StoredRouterSettings: Codable { + var analyser: String? + var analyserModels: [String: ModelRouteChoice]? + var tables: [String: [String: ModelRouteChoice]]? +} + +extension ModelRouterSettings { + static let followChatWord = "chat" + + /// Anything that does not decode (say, from a later version) is dropped rather than failing the + /// whole value, so one stale entry cannot reset the owner's other choices. + public static func decode(_ raw: String?) -> ModelRouterSettings { + guard let raw, let data = raw.data(using: .utf8), + let stored = try? JSONDecoder().decode(StoredRouterSettings.self, from: data) + else { return ModelRouterSettings() } + + let analyser: Analyser = stored.analyser.flatMap(AgentKind.init(rawValue:)).map(Analyser.agent) ?? .followChat + var models: [AgentKind: ModelRouteChoice] = [:] + for (key, choice) in stored.analyserModels ?? [:] { + if let kind = AgentKind(rawValue: key) { models[kind] = choice } + } + var tables: [AgentKind: [TaskComplexity: ModelRouteChoice]] = [:] + for (key, rungs) in stored.tables ?? [:] { + guard let kind = AgentKind(rawValue: key) else { continue } + var table: [TaskComplexity: ModelRouteChoice] = [:] + for (rung, choice) in rungs { + if let complexity = TaskComplexity(rawValue: rung) { table[complexity] = choice } + } + if !table.isEmpty { tables[kind] = table } + } + return ModelRouterSettings(analyser: analyser, analyserModels: models, tables: tables) + } + + /// Nil for the empty value, so a reset removes the preference. + public func encoded() -> String? { + guard self != ModelRouterSettings() else { return nil } + let analyserWord: String + switch analyser { + case .followChat: analyserWord = Self.followChatWord + case .agent(let kind): analyserWord = kind.rawValue + } + let stored = StoredRouterSettings( + analyser: analyserWord, + analyserModels: Dictionary(uniqueKeysWithValues: analyserModels.map { ($0.key.rawValue, $0.value) }), + tables: Dictionary(uniqueKeysWithValues: tables.map { kind, rungs in + (kind.rawValue, Dictionary(uniqueKeysWithValues: rungs.map { ($0.key.rawValue, $0.value) })) + }) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(stored) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} + +/// Whether new workspaces ask the router, and how it is set up, as preferences. +/// +/// Off by default, unlike the namer: routing changes which model works, what it costs, and how +/// long the first turn waits, a trade somebody should opt into. +/// `@unchecked Sendable` because `UserDefaults` is thread safe but not annotated. +public struct ModelRouterPreferences: @unchecked Sendable { + public static let key = "router.workspaces" + public static let settingsKey = "router.settings" + public static let fallback = false + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var isEnabled: Bool { + get { defaults.object(forKey: Self.key) as? Bool ?? Self.fallback } + nonmutating set { defaults.set(newValue, forKey: Self.key) } + } + + public var settings: ModelRouterSettings { + get { ModelRouterSettings.decode(defaults.string(forKey: Self.settingsKey)) } + nonmutating set { + if let raw = newValue.encoded() { + defaults.set(raw, forKey: Self.settingsKey) + } else { + defaults.removeObject(forKey: Self.settingsKey) + } + } + } +} diff --git a/Sources/BloomCore/Agent/ModelRouterTable.swift b/Sources/BloomCore/Agent/ModelRouterTable.swift new file mode 100644 index 000000000..12d47c144 --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouterTable.swift @@ -0,0 +1,102 @@ +import Foundation + +/// A model and a reasoning effort, in the words the chat's own CLI takes them. +public struct ModelRouteChoice: Sendable, Hashable, Codable { + public var model: String + /// Empty for a model that takes no effort, which is how the runners spell "send no effort". + public var effort: String + + public init(model: String, effort: String) { + self.model = model + self.effort = effort + } +} + +/// Which model and effort each rung of `TaskComplexity` runs on, for one agent. +/// +/// The analyser never names a model: it cannot know which models this account has or what the +/// owner will pay for, and a hallucinated id would fail a first turn nobody is watching. One table +/// per agent, because a route never changes agent. See `suggested(for:models:)`. +public struct ModelRouterTable: Sendable, Hashable { + public var choices: [TaskComplexity: ModelRouteChoice] + + public init(choices: [TaskComplexity: ModelRouteChoice]) { + self.choices = choices + } + + /// Family aliases rather than versioned ids, so the table does not go stale when a model ships. + /// Max is on no rung: a level that can turn a quick answer into a quarter of an hour should be + /// somebody's decision rather than a classifier's. + public static let standard = ModelRouterTable(choices: [ + .trivial: ModelRouteChoice(model: "haiku", effort: "medium"), + .simple: ModelRouteChoice(model: "sonnet", effort: "high"), + .moderate: ModelRouteChoice(model: "sonnet", effort: "xhigh"), + .complex: ModelRouteChoice(model: "opus", effort: "high"), + .deep: ModelRouteChoice(model: "opus", effort: "xhigh"), + ]) + + /// An empty model for a missing rung, which `ModelRouting.route` reads as "keep what the window + /// was set to"; borrowing another table's model would put it on a backend that never heard of it. + public func choice(for complexity: TaskComplexity) -> ModelRouteChoice { + choices[complexity] ?? ModelRouteChoice(model: "", effort: "") + } + + /// This table with the owner's own changes written over it, rung by rung. + public func overriding(_ overrides: [TaskComplexity: ModelRouteChoice]) -> ModelRouterTable { + ModelRouterTable(choices: choices.merging(overrides) { _, owner in owner }) + } + + // MARK: - Suggested tables + + /// Claude Code gets `standard`, because its aliases always resolve. Codex and Grok are read off + /// the list (most capable first): the lower two rungs go to the newest light model, the upper + /// three to the flagship. Nil when there is nothing to read. + public static func suggested(for kind: AgentKind, models: [AgentModel]) -> ModelRouterTable? { + switch kind { + case .claudeCode: + return standard + case .codex, .grok: + let offered = models.filter { !$0.hidden } + guard let flagship = offered.first(where: { !RouterLightModels.isLight($0.id, on: kind) }) + ?? offered.first + else { return nil } + let light = offered.first { RouterLightModels.isLight($0.id, on: kind) } ?? flagship + return ModelRouterTable(choices: [ + .trivial: rung(light, preferring: ["low", "minimal", "medium"]), + .simple: rung(light, preferring: ["medium", "low", "high"]), + .moderate: rung(flagship, preferring: ["medium", "low", "high"]), + .complex: rung(flagship, preferring: ["high", "medium", "xhigh"]), + .deep: rung(flagship, preferring: ["xhigh", "high", "medium"]), + ]) + case .cursor, .openCode: + return nil + } + } + + /// By level name rather than list position, because lists are ordered differently and a position + /// would put `ultra` on a rung by accident. + static func rung(_ model: AgentModel, preferring levels: [String]) -> ModelRouteChoice { + guard !model.supportedEfforts.isEmpty else { return ModelRouteChoice(model: model.id, effort: "") } + let taken = Set(model.supportedEfforts.map(\.id)) + let effort = levels.first { taken.contains($0) } ?? model.resolvedEffort(preferring: levels[0]) + return ModelRouteChoice(model: model.id, effort: effort) + } +} + +/// Which of an agent's models count as light, read off the vendors' own naming words because +/// nothing else in an id says anything about size. +public enum RouterLightModels { + static let grokWords = ["fast", "mini", "nano", "lite", "small"] + + public static func isLight(_ id: String, on kind: AgentKind) -> Bool { + switch kind { + case .claudeCode: + return ModelRouting.family(of: id) == "haiku" + case .codex: + return CodexModelRank.isReduced(id) + case .grok, .cursor, .openCode: + let words = id.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init) + return words.contains { grokWords.contains($0) } + } + } +} diff --git a/Sources/BloomCore/Agent/ModelRouting.swift b/Sources/BloomCore/Agent/ModelRouting.swift new file mode 100644 index 000000000..072fe0116 --- /dev/null +++ b/Sources/BloomCore/Agent/ModelRouting.swift @@ -0,0 +1,180 @@ +import Foundation + +/// How much a task asks of the model, as the router reads it. Named rungs rather than a score, +/// and the routing prompt describes them in the same words, so answer and table share one scale. +public enum TaskComplexity: String, Sendable, Hashable, CaseIterable, Codable { + case trivial + case simple + case moderate + case complex + case deep + + public var label: String { + switch self { + case .trivial: "Trivial" + case .simple: "Simple" + case .moderate: "Moderate" + case .complex: "Complex" + case .deep: "Deep" + } + } +} + +/// Nothing builds one of these from raw model output except `ModelRouting.answer(from:)`. +public struct ModelRouterAnswer: Sendable, Hashable { + public let complexity: TaskComplexity + /// One line, possibly empty, shown under the decision and never sent anywhere. + public let reason: String + + public init(complexity: TaskComplexity, reason: String) { + self.complexity = complexity + self.reason = reason + } +} + +/// The model and effort a new workspace's first chat starts on, and why. +public struct ModelRoute: Sendable, Hashable { + public let complexity: TaskComplexity + public let model: String + /// Empty for a model that takes no effort, which is how the runner spells "send no `--effort`". + public let effort: String + public let reason: String + + public init(complexity: TaskComplexity, model: String, effort: String, reason: String) { + self.complexity = complexity + self.model = model + self.effort = effort + self.reason = reason + } +} + +/// The automatic router's rules: whether to ask, how to read the answer, what it turns into. +/// Pure, so the suite can hold every decision to the answers a model actually gives. +public enum ModelRouting { + // MARK: - Whether to ask at all + + /// - Parameter isEnabled: the setting and the create window's Auto, which a manual pick turns off. + /// - Parameter backend: never changed by the route, so a Codex chat is never handed `sonnet`. + /// - Parameter isResuming: a resumed thread is not a new task. + public static func shouldRoute( + isEnabled: Bool, + hasAnalyser: Bool, + mode: WorkspaceStartMode, + backend: AgentKind, + prompt: String, + isResuming: Bool + ) -> Bool { + guard isEnabled, !isResuming else { return false } + guard offers(isEnabled: true, hasAnalyser: hasAnalyser, mode: mode, backend: backend) else { return false } + return !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Whether the create window offers Auto at all. + public static func offers(isEnabled: Bool, hasAnalyser: Bool, mode: WorkspaceStartMode, backend: AgentKind) -> Bool { + isEnabled && hasAnalyser && mode == .chat && backend.canRunWorkspaces + } + + /// Only the model, the effort and the backend take the model back from the router; a + /// permission mode or output style says nothing about which model should do the work. + public static func takesOver(from before: ComposerControls, to after: ComposerControls) -> Bool { + before.model != after.model || before.effort != after.effort || before.agentKind != after.agentKind + } + + /// See `DeliveryHold.sentence(on:)` for the register. + public static let holdSentence = "Goes once a model has been chosen." + + // MARK: - Reading the answer + + public static let reasonLimit = 200 + + /// Nil keeps the model the chat was created with. Only a rung wrapped in quotes or punctuation + /// is forgiven. + public static func answer(from structured: JSONValue) -> ModelRouterAnswer? { + guard let raw = structured["complexity"]?.stringValue else { return nil } + let word = raw + .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "\"'`.*"))) + .lowercased() + guard let complexity = TaskComplexity(rawValue: word) else { return nil } + return ModelRouterAnswer( + complexity: complexity, + reason: cleanReason(structured["reason"]?.stringValue) ?? "" + ) + } + + /// One paragraph of plain text, capped at a word, or nothing. + public static func cleanReason(_ raw: String?) -> String? { + guard let raw else { return nil } + // Control characters become spaces, because deleting a tab between two words joins them. + let flattened = String(String.UnicodeScalarView(raw.unicodeScalars.map { + $0.value < 0x20 || $0.value == 0x7F ? " " : $0 + })) + let words = flattened.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !words.isEmpty else { return nil } + guard words.count > reasonLimit else { return words } + + let cut = words.prefix(reasonLimit) + let kept = cut.lastIndex(of: " ").map { cut[..<$0] } ?? cut + return String(kept) + "\u{2026}" + } + + // MARK: - Turning it into a model + + /// - Parameter current: on Claude Code, kept when it is the table choice's family, so somebody + /// on `opus[1m]` routed to `opus` keeps a million tokens of context. + /// - Parameter models: the agent's last model list, empty when not asked. It only narrows. + public static func route( + _ answer: ModelRouterAnswer, + table: ModelRouterTable = .standard, + backend: AgentKind = .claudeCode, + current: String, + models: [AgentModel] = [] + ) -> ModelRoute { + let choice = table.choice(for: answer.complexity) + let model = chosenModel(choice.model, backend: backend, current: current, models: models) + return ModelRoute( + complexity: answer.complexity, + model: model, + effort: chosenEffort(choice.effort, model: model, backend: backend, models: models), + reason: answer.reason + ) + } + + static func chosenModel(_ wanted: String, backend: AgentKind, current: String, models: [AgentModel]) -> String { + guard !wanted.isEmpty else { return current } + let offered = models.filter { !$0.hidden } + + guard backend == .claudeCode else { + // Codex and Grok ids are exact; one the list no longer offers keeps the current model. + if !offered.isEmpty, !offered.contains(where: { $0.id == wanted }) { return current } + return wanted + } + + guard let wantedFamily = family(of: wanted) else { return wanted } + if family(of: current) == wantedFamily { return current } + + // A list with nothing of this family is an account that cannot run it. + if !offered.isEmpty, !offered.contains(where: { family(of: $0.id) == wantedFamily }) { + return current + } + return wanted + } + + static func chosenEffort(_ wanted: String, model: String, backend: AgentKind, models: [AgentModel]) -> String { + let modelFamily = backend == .claudeCode ? family(of: model) : nil + let entry = models.first { $0.id == model } + ?? models.first { modelFamily != nil && family(of: $0.id) == modelFamily } + guard let entry else { return wanted } + // Listed with no levels is a model that takes no effort (Haiku today). + guard !entry.supportedEfforts.isEmpty else { return "" } + return entry.resolvedEffort(preferring: wanted) + } + + /// Read the way `ModelAlias` reads one: `opus[1m]` and `claude-opus-5-5` are both Opus. + public static func family(of id: String) -> String? { + var name = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if name.hasPrefix("claude-") { name.removeFirst("claude-".count) } + return families.first { name == $0 || name.hasPrefix($0 + "-") || name.hasPrefix($0 + "[") } + } + + private static let families = ["opus", "sonnet", "haiku", "fable"] +} diff --git a/Sources/BloomCore/Agent/RouterAnalyser.swift b/Sources/BloomCore/Agent/RouterAnalyser.swift new file mode 100644 index 000000000..6b4e96fc6 --- /dev/null +++ b/Sources/BloomCore/Agent/RouterAnalyser.swift @@ -0,0 +1,111 @@ +import Foundation + +/// The agent and model that read a task and judge how much it asks. +/// +/// Chosen among connected agents rather than installed ones, because a signed-out CLI answers +/// every question with a sign-in error. The check is local, so an expired token still counts +/// and fails like any other failure. +public struct RouterAnalyser: Sendable, Hashable { + public var kind: AgentKind + /// Empty for the agent's own default model, used before its model list has answered. + public var model: String + public var effort: String + public var name: String + + public init(kind: AgentKind, model: String, effort: String, name: String) { + self.kind = kind + self.model = model + self.effort = effort + self.name = name + } + + // MARK: - Choosing one + + /// Who analyses a task for a chat on `chatBackend`, or nil when nobody can. A named agent that is + /// not connected falls back to following the chat rather than to nothing. + public static func resolve( + chatBackend: AgentKind, + settings: ModelRouterSettings, + connected: Set<AgentKind>, + models: [AgentKind: [AgentModel]] + ) -> RouterAnalyser? { + let candidates = AgentKind.runnable.filter { connected.contains($0) } + guard !candidates.isEmpty else { return nil } + + let kind: AgentKind + if case .agent(let wanted) = settings.analyser, candidates.contains(wanted) { + kind = wanted + } else if candidates.contains(chatBackend) { + kind = chatBackend + } else { + kind = candidates[0] + } + return analyser(on: kind, stored: settings.analyserModels[kind], models: models[kind] ?? []) + } + + /// The owner's model while the list still offers it, otherwise the suggestion, at the owner's + /// effort when the model takes it. + public static func analyser(on kind: AgentKind, stored: ModelRouteChoice?, models: [AgentModel]) -> RouterAnalyser { + let model: String + if let stored, !stored.model.isEmpty, isOffered(stored.model, on: kind, in: models) { + model = stored.model + } else { + model = suggestedModel(for: kind, models: models) + } + let listed = Self.entry(for: model, on: kind, in: models) + let effort: String + if let stored, stored.model == model, isTaken(stored.effort, by: listed) { + effort = stored.effort + } else { + effort = readingEffort(of: listed) + } + return RouterAnalyser(kind: kind, model: model, effort: effort, name: name(of: model, on: kind, entry: listed)) + } + + /// Haiku on Claude Code; elsewhere the first light model listed, else the last (least capable) + /// model, else empty for the agent's own default. + public static func suggestedModel(for kind: AgentKind, models: [AgentModel]) -> String { + if kind == .claudeCode { return "haiku" } + let offered = models.filter { !$0.hidden } + return (offered.first { RouterLightModels.isLight($0.id, on: kind) } ?? offered.last)?.id ?? "" + } + + /// `high` where the model takes it: the answer decides what a whole conversation costs, and on + /// Haiku `high` cost under a second more than `medium`. + static func readingEffort(of entry: AgentModel?) -> String { + guard let entry else { return "high" } + let taken = entry.supportedEfforts.map(\.id) + guard !taken.isEmpty else { return "" } + return ["high", "medium", "low", "minimal"].first { taken.contains($0) } ?? taken[0] + } + + // MARK: - Reading a list + + static func isOffered(_ model: String, on kind: AgentKind, in models: [AgentModel]) -> Bool { + let offered = models.filter { !$0.hidden } + guard !offered.isEmpty else { return true } + return entry(for: model, on: kind, in: offered) != nil + || (kind == .claudeCode && ModelRouting.family(of: model) != nil) + } + + /// For Claude Code, the first entry of the family, since the list may spell `haiku` as + /// `claude-haiku-4-5`. + static func entry(for model: String, on kind: AgentKind, in models: [AgentModel]) -> AgentModel? { + if let exact = models.first(where: { $0.id == model }) { return exact } + guard kind == .claudeCode, let family = ModelRouting.family(of: model) else { return nil } + return models.first { ModelRouting.family(of: $0.id) == family } + } + + static func isTaken(_ effort: String, by entry: AgentModel?) -> Bool { + guard let entry else { return true } + if entry.supportedEfforts.isEmpty { return effort.isEmpty } + return entry.supportedEfforts.contains { $0.id == effort } + } + + static func name(of model: String, on kind: AgentKind, entry: AgentModel?) -> String { + if let entry, !entry.displayName.isEmpty { return entry.displayName } + guard !model.isEmpty else { return "\(kind.label)'s default model" } + let readable = ModelLabel.readable(model) + return kind == .claudeCode ? "Claude \(readable)" : readable + } +} diff --git a/Sources/BloomCore/Agent/RouterSignal.swift b/Sources/BloomCore/Agent/RouterSignal.swift new file mode 100644 index 000000000..16a698761 --- /dev/null +++ b/Sources/BloomCore/Agent/RouterSignal.swift @@ -0,0 +1,42 @@ +import Foundation + +/// One thing an analyser said. Each adapter translates its own protocol into these, so the +/// progress and the rules that read the answer are written once. +public enum RouterSignal: Sendable, Equatable { + /// An adapter that sees a second block of thinking sends its paragraph break as one of these. + case thinking(String) + /// Where an agent with no structured output puts the JSON it was asked for. + case text(String) + /// Wins over anything in `text`. + case structured(JSONValue) + /// An error is the CLI's verdict on the turn, not on the answer. + case finished(isError: Bool) +} + +public struct ModelRouterRequest: Sendable, Hashable { + public var analyser: RouterAnalyser + /// Written to the agent as the user's turn, never as an argument, so a leading dash is no flag. + public var prompt: String + public var systemPrompt: String + public var executable: String + /// A directory with nothing in it. See `AgentScratchDirectory`. + public var cwd: String + + public init( + analyser: RouterAnalyser, + prompt: String, + systemPrompt: String = ModelRouter.systemPrompt, + executable: String? = nil, + cwd: String = AgentScratchDirectory.current() + ) { + self.analyser = analyser + self.prompt = prompt + self.systemPrompt = systemPrompt + self.executable = executable ?? analyser.kind.executableName + self.cwd = cwd + } +} + +public enum ModelRouterError: Error, Sendable, Equatable { + case unsupported(AgentKind) +} diff --git a/Sources/BloomCore/GitLab/GitLabInstructions.swift b/Sources/BloomCore/GitLab/GitLabInstructions.swift index bb858d51b..454739eef 100644 --- a/Sources/BloomCore/GitLab/GitLabInstructions.swift +++ b/Sources/BloomCore/GitLab/GitLabInstructions.swift @@ -55,7 +55,7 @@ public enum GitLabInstructions { + "It is on the branch {{branch}}." case .createPullRequest, .pushLocalWork, .fixConflicts, .continueAfterMerge: translate(PromptRegistry.definition(for: id).defaultTemplate) - case .carryOnArchived, .review, .nameWorkspace: + case .carryOnArchived, .review, .nameWorkspace, .routeTask: nil } } diff --git a/Sources/BloomCore/Presentation/ModelRouteCaption.swift b/Sources/BloomCore/Presentation/ModelRouteCaption.swift new file mode 100644 index 000000000..405a2bbc4 --- /dev/null +++ b/Sources/BloomCore/Presentation/ModelRouteCaption.swift @@ -0,0 +1,95 @@ +import Foundation + +/// What the router's card says, and whether it is drawn at all. Every sentence lives here rather +/// than in the view so the suite can hold each state to the house rules. +public enum ModelRouteCaption { + /// The newest part, because the reader's eye is on the last line as the text grows. + public static let reasoningLimit = 320 + + /// Once settled, the card stays until the session start row takes over the decision, because the + /// setup script can run for minutes before it. + public static func showsCard(isSettled: Bool, isDismissed: Bool, hasSessionStarted: Bool) -> Bool { + guard isSettled else { return true } + return !isDismissed && !hasSessionStarted + } + + public static func title(route: ModelRoute?, isSettled: Bool) -> String { + guard isSettled else { return "Choosing a model for this task" } + guard let route else { return "Kept the model this chat already had" } + let model = ModelLabel.readable(route.model) + guard !route.effort.isEmpty else { return model } + return "\(model) \u{00B7} \(effortLabel(route.effort)) effort" + } + + /// - Parameter readsOnlyTheMessage: true only for Claude Code, the one agent run with no tools + /// at all, so the card says no more than is true. + public static func detail( + route: ModelRoute?, + isSettled: Bool, + wasSkipped: Bool, + analyser: String, + readsOnlyTheMessage: Bool = true + ) -> String { + guard isSettled else { + let reading = readsOnlyTheMessage ? "your message, not your code" : "your message" + return "\(analyser) is reading \(reading). The conversation starts once it has chosen." + } + guard let route else { + return wasSkipped + ? "Skipped, so the first message keeps the model and effort it already had." + : "The router did not answer, so the first message goes on the model and effort " + + "the window was set to." + } + guard !route.reason.isEmpty else { return "\(route.complexity.label) task." } + return "\(route.complexity.label) task. \(route.reason)" + } + + /// "Extra high" rather than "Xhigh". See `CodexReasoningEffort`. + public static func effortLabel(_ effort: String) -> String { + CodexReasoningEffort(id: effort).label + } + + /// Newlines go because the card has four lines and a blank one would waste a quarter of them. + public static func reasoningTail(_ reasoning: String, limit: Int = reasoningLimit) -> String { + let flattened = reasoning + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + guard flattened.count > limit else { return flattened } + + let tail = flattened.suffix(limit) + let start = tail.firstIndex(of: " ").map { tail.index(after: $0) } ?? tail.startIndex + return "\u{2026}" + String(tail[start...]) + } + + public static func tableSummary(_ table: ModelRouterTable = .standard) -> String { + let rungs = TaskComplexity.allCases.compactMap { rung -> String? in + let choice = table.choice(for: rung) + guard !choice.model.isEmpty else { return nil } + let effort = choice.effort.isEmpty ? "" : ", \(effortLabel(choice.effort).lowercased())" + return "\(rung.label): \(ModelLabel.readable(choice.model))\(effort)" + } + guard !rungs.isEmpty else { return "" } + return rungs.joined(separator: ". ") + "." + } + + /// The three agents cannot promise the same isolation. See `ClaudeRouterAsk`, `CodexRouterAsk` + /// and `GrokRouterAsk`. + public static func safetyNote(for kind: AgentKind) -> String { + switch kind { + case .claudeCode: + return "Claude Code reads the task with every tool switched off, in an empty folder." + case .codex: + return "Codex reads the task in a read-only sandbox, in an empty folder, and every " + + "command it asks approval for is refused. It cannot be run with no tools at all, " + + "and starts your own MCP servers." + case .grok: + return "Grok reads the task in plan mode, in an empty folder, and every permission it " + + "asks for is refused. Plan mode can still read files without asking, and Grok " + + "starts your own MCP servers." + case .cursor, .openCode: + return "\(kind.label) cannot read tasks for the router." + } + } +} diff --git a/Sources/BloomCore/Transcript/PromptRegistry.swift b/Sources/BloomCore/Transcript/PromptRegistry.swift index b57f15f01..66a35cd5c 100644 --- a/Sources/BloomCore/Transcript/PromptRegistry.swift +++ b/Sources/BloomCore/Transcript/PromptRegistry.swift @@ -13,6 +13,7 @@ public enum PromptID: String, Sendable, Hashable, CaseIterable, Codable { case carryOnArchived case review case nameWorkspace + case routeTask } /// A substitution a prompt may use, and the one line of help shown beside it in Settings. @@ -63,7 +64,7 @@ public struct PromptDefinition: Sendable, Hashable, Identifiable { public enum PromptRegistry { public static let all: [PromptDefinition] = [ createPullRequest, pushLocalWork, mergePullRequest, fixConflicts, continueAfterMerge, - carryOnArchived, review, nameWorkspace, + carryOnArchived, review, nameWorkspace, routeTask, ] public static func definition(for id: PromptID) -> PromptDefinition { @@ -147,6 +148,12 @@ public enum PromptRegistry { public static let project = "project" } + /// The same two names as naming, because it reads the same sentence for a different answer. + public enum RouteTask { + public static let task = "task" + public static let project = "project" + } + /// The names the review prompt may use. public enum Review { public static let message = "message" @@ -496,7 +503,8 @@ public enum PromptRegistry { """ ) - /// The one prompt here that does not go to the workspace's own agent. + /// One of the two prompts here that do not go to the workspace's own agent; `routeTask` is the + /// other. /// /// It is answered by a separate, short-lived `claude -p` process with every tool switched off /// and the default system prompt replaced, because naming a task needs none of the context @@ -537,4 +545,50 @@ public enum PromptRegistry { {{task}} """ ) + + /// The other prompt that does not go to the workspace's own agent. + /// + /// It asks for a rung of `TaskComplexity`, never a model, so an edited prompt can change how tasks + /// are sorted but never name a model the account lacks. Outside Claude Code's schema, an answer + /// naming any other rung is no answer. See `ModelRouting.answer(from:)`. + static let routeTask = PromptDefinition( + id: .routeTask, + title: "Choose a model for a new workspace", + summary: """ + Sent when a workspace is created with the automatic router on, to judge how much the task \ + asks before the first message goes. + """, + variables: [ + PromptVariable( + name: RouteTask.task, + summary: "The first thing you asked this workspace for." + ), + PromptVariable(name: RouteTask.project, summary: "The project it was created in."), + ], + defaultTemplate: """ + Judge how much this coding task asks of the model that will do it. + + Project: {{project}} + + Choose one complexity: + + - trivial: a typo, a rename, a one line configuration change, or a question about one file. + - simple: a small change in one or two files with an obvious approach. + - moderate: an ordinary feature or bug fix across a handful of files. + - complex: work across many files, a refactor, or a bug whose cause is not known yet. + - deep: architecture, concurrency, security, performance or a migration, where a wrong \ + turn is expensive. + + When it sits between two, choose the higher one. Give the reason in one short sentence, \ + in the language the task is written in. + + Answer with only this JSON object, and nothing before or after it: + + {"complexity": "<one of the five above>", "reason": "<one short sentence>"} + + ## Task + + {{task}} + """ + ) } diff --git a/Tests/BloomCoreTests/ModelRouterTests.swift b/Tests/BloomCoreTests/ModelRouterTests.swift new file mode 100644 index 000000000..c9447c7e5 --- /dev/null +++ b/Tests/BloomCoreTests/ModelRouterTests.swift @@ -0,0 +1,993 @@ +import Testing +import Foundation +@testable import BloomCore + +/// Lines shaped like the ones `claude -p --output-format stream-json --include-partial-messages` +/// writes, trimmed to the fields `ClaudeRouterAsk.Decoder` reads. +private enum ClaudeStream { + static func quoted(_ text: String) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: text, options: .fragmentsAllowed), as: UTF8.self) + } + + static func thinkingStart() -> String { + #"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}}"# + } + + static func thinking(_ text: String) -> String { + #"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"# + + quoted(text) + "}}}" + } + + static func thinkingBlock(_ text: String) -> String { + #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"# + + quoted(text) + #","signature":"sig"}]}}"# + } + + static func structuredTool(complexity: String, reason: String) -> String { + #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"StructuredOutput","input":{"complexity":""# + + complexity + #"","reason":""# + reason + #""}}]}}"# + } + + static func result(complexity: String, reason: String) -> String { + #"{"type":"result","subtype":"success","is_error":false,"result":"","structured_output":{"complexity":""# + + complexity + #"","reason":""# + reason + #""}}"# + } + + static func emptyResult() -> String { + #"{"type":"result","subtype":"success","is_error":false,"result":""}"# + } + + static func failedResult() -> String { + #"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"offline"}"# + } +} + +private let haiku = RouterAnalyser(kind: .claudeCode, model: "haiku", effort: "low", name: "Claude Haiku") + +/// A Claude Code runner that plays a fixed stream back, counting how often it was launched. +private func replaying(_ lines: [String], launches: LaunchCount = LaunchCount()) -> ModelRouter.Ask { + { request in + ClaudeRouterAsk.ask(request, run: { _ in + launches.increment() + return AsyncThrowingStream { continuation in + for line in lines { continuation.yield(line) } + continuation.finish() + } + }) + } +} + +/// An asker that hands back fixed signals, whichever agent was asked. +private func signalling(_ signals: [RouterSignal]) -> ModelRouter.Ask { + { _ in + AsyncThrowingStream { continuation in + for signal in signals { continuation.yield(signal) } + continuation.finish() + } + } +} + +/// Everything a router said, last element included. +private func collect( + _ router: ModelRouter, + task: String = "Fix the typo in the README", + analyser: RouterAnalyser = haiku +) async -> [ModelRouterProgress] { + var seen: [ModelRouterProgress] = [] + for await step in router.progress(task: task, project: "Bloom", template: "{{task}}", analyser: analyser) { + seen.append(step) + } + return seen +} + +private func model(_ id: String, _ efforts: String..., hidden: Bool = false, name: String = "") -> AgentModel { + AgentModel( + id: id, + displayName: name, + hidden: hidden, + supportedEfforts: efforts.map { AgentModelEffort(id: $0, label: $0) }, + defaultEffort: efforts.contains("medium") ? "medium" : (efforts.first ?? "") + ) +} + +// MARK: - The Claude Code analyser + +@Suite("Model router on Claude Code") +struct ClaudeRouterAskTests { + @Test("the routing call carries no tools, no MCP servers and no project settings", .tags(.security)) + func argvIsMinimal() { + let arguments = ClaudeRouterAsk.argv(model: "haiku", effort: "low") + + #expect(arguments.contains("-p")) + #expect(arguments.contains("--safe-mode")) + #expect(arguments.contains("--strict-mcp-config")) + #expect(arguments.contains("--disable-slash-commands")) + #expect(arguments.contains("--no-session-persistence")) + #expect(arguments.contains("--system-prompt")) + #expect(!arguments.contains("--append-system-prompt")) + + let tools = arguments.firstIndex(of: "--tools") + #expect(tools != nil) + if let tools { #expect(arguments[tools + 1] == "") } + } + + @Test("the thinking streams, which is what the card draws", .tags(.agentProtocol)) + func argvStreams() { + let arguments = ClaudeRouterAsk.argv(model: "haiku", effort: "low") + let format = arguments.firstIndex(of: "--output-format").map { arguments[$0 + 1] } + #expect(format == "stream-json") + // The CLI refuses stream-json under `-p` without it. + #expect(arguments.contains("--verbose")) + #expect(arguments.contains("--include-partial-messages")) + #expect(arguments.contains("--json-schema")) + } + + @Test("the model is the analyser's, translated for the CLI, and an empty effort sends none") + func modelAndEffort() { + let sonnet = ClaudeRouterAsk.argv(model: "sonnet-5", effort: "") + #expect(sonnet.contains(ModelAlias.cliValue(for: "sonnet-5"))) + #expect(!sonnet.contains("--effort")) + #expect(ClaudeRouterAsk.argv(model: "haiku", effort: "low").contains("--effort")) + } + + @Test("the task is written to stdin, so one starting with a dash is never a flag", .tags(.security)) + func taskGoesToStdin() { + let launch = ClaudeRouterAsk.launch(ModelRouterRequest(analyser: haiku, prompt: "--dangerously-skip-permissions")) + #expect(launch.stdin == "--dangerously-skip-permissions") + #expect(!launch.arguments.contains("--dangerously-skip-permissions")) + } + + @Test("it runs somewhere with no repository in it, on the owner's own binary when there is one") + func whereAndWhat() { + let plain = ModelRouterRequest(analyser: haiku, prompt: "anything") + #expect(plain.cwd == AgentScratchDirectory.current()) + #expect(plain.cwd != NSHomeDirectory()) + #expect(plain.executable == "claude") + + let custom = ClaudeRouterAsk.launch(ModelRouterRequest(analyser: haiku, prompt: "x", executable: "/opt/bin/claude")) + #expect(custom.executable == "/opt/bin/claude") + } + + @Test("thinking arrives as it is written, and the answer ends the stream", .tags(.agentProtocol)) + func happyPath() async throws { + let router = ModelRouter(ask: replaying([ + ClaudeStream.thinkingStart(), + ClaudeStream.thinking("A typo "), + ClaudeStream.thinking("in one file."), + ClaudeStream.thinkingBlock("A typo in one file."), + ClaudeStream.structuredTool(complexity: "trivial", reason: "One word in the README."), + ClaudeStream.result(complexity: "trivial", reason: "One word in the README."), + ])) + + let seen = await collect(router) + let last = try #require(seen.last) + + #expect(seen.contains { $0.reasoning == "A typo " && $0.phase == .thinking }) + // Once, although the CLI sent it twice: in pieces, and then whole. + #expect(last.reasoning == "A typo in one file.") + #expect(last.answer == ModelRouterAnswer(complexity: .trivial, reason: "One word in the README.")) + #expect(seen.filter(\.isFinished).count == 1) + } + + @Test("a process that fails is not an error, it is no answer") + func failedProcess() async throws { + let router = ModelRouter(ask: { request in + ClaudeRouterAsk.ask(request, run: { _ in + AsyncThrowingStream { $0.finish(throwing: ShellError(command: "claude", status: 1, stderr: "offline")) } + }) + }) + let last = try #require(await collect(router).last) + #expect(last.phase == .failed) + } + + @Test("an error result is no answer either") + func errorResult() async throws { + let last = try #require(await collect(ModelRouter(ask: replaying([ClaudeStream.failedResult()]))).last) + #expect(last.phase == .failed) + } + + @Test("a stream that stops before its result has not answered") + func truncated() async throws { + let last = try #require(await collect(ModelRouter(ask: replaying([ClaudeStream.thinking("Hmm")]))).last) + #expect(last.phase == .failed) + #expect(last.reasoning == "Hmm") + } + + @Test("a task with nothing in it asks nothing rather than sending a blank turn") + func emptyTask() async throws { + let launches = LaunchCount() + let router = ModelRouter(ask: replaying([], launches: launches)) + let last = try #require(await collect(router, task: " ").last) + #expect(launches.value == 0) + #expect(last.phase == .failed) + + // With the built-in template too, which renders a page of instructions around nothing. + var seen: [ModelRouterProgress] = [] + for await step in router.progress( + task: " \n ", project: "Bloom", + template: PromptRegistry.definition(for: .routeTask).defaultTemplate, analyser: haiku + ) { + seen.append(step) + } + #expect(launches.value == 0) + #expect(seen.last?.phase == .failed) + } + + @Test("an answer only in the result text is still found", .tags(.agentProtocol)) + func answerInResultText() async throws { + let line = #"{"type":"result","is_error":false,"result":"{\"complexity\":\"complex\",\"reason\":\"Many files.\"}"}"# + let last = try #require(await collect(ModelRouter(ask: replaying([line]))).last) + #expect(last.answer == ModelRouterAnswer(complexity: .complex, reason: "Many files.")) + } + + @Test("an answer only in the tool call is still found", .tags(.agentProtocol)) + func answerInToolCall() async throws { + let last = try #require(await collect(ModelRouter(ask: replaying([ + ClaudeStream.structuredTool(complexity: "deep", reason: "A race."), + ClaudeStream.emptyResult(), + ]))).last) + #expect(last.answer == ModelRouterAnswer(complexity: .deep, reason: "A race.")) + } + + @Test("thinking with no deltas is read off the whole block, and a second block is a new paragraph") + func thinkingBlocks() { + var decoder = ClaudeRouterAsk.Decoder() + let said1 = decoder.signals(for: ClaudeStream.thinkingBlock("Small change.")) + #expect(said1 == [.thinking("Small change.")]) + let said2 = decoder.signals(for: ClaudeStream.thinkingBlock("Still small.")) + #expect(said2 == [.thinking("\n\nStill small.")]) + + var streaming = ClaudeRouterAsk.Decoder() + _ = streaming.signals(for: ClaudeStream.thinking("First.")) + let said3 = streaming.signals(for: ClaudeStream.thinkingStart()) + #expect(said3 == [.thinking("\n\n")]) + } + + @Test( + "lines that are not the protocol say nothing", + arguments: ["", "not json", "{}", #"{"type":"system","subtype":"init"}"#] + ) + func noise(line: String) { + var decoder = ClaudeRouterAsk.Decoder() + let said4 = decoder.signals(for: line) + #expect(said4.isEmpty) + } +} + +// MARK: - The router, whichever agent answers + +@Suite("Model router") +struct ModelRouterTests { + @Test("the schema offers exactly the rungs the table has", .tags(.agentProtocol)) + func schema() throws { + let object = try #require( + try JSONSerialization.jsonObject(with: Data(ModelRouter.jsonSchema.utf8)) as? [String: Any] + ) + let properties = try #require(object["properties"] as? [String: Any]) + #expect(properties.keys.sorted() == ["complexity", "reason"]) + #expect(object["additionalProperties"] as? Bool == false) + + let complexity = try #require(properties["complexity"] as? [String: Any]) + let rungs = try #require(complexity["enum"] as? [String]) + #expect(rungs == TaskComplexity.allCases.map(\.rawValue)) + } + + @Test("the deadline is a cap on a call that never returns, not a measurement") + func timeout() { + #expect(ModelRouter.timeout > .seconds(40)) + #expect(ModelRouter.timeout <= .seconds(90)) + } + + @Test("the prompt carries the task and the project") + func promptRenders() { + let prompt = ModelRouter.prompt(task: "Add a dark mode toggle", project: "Bloom") + #expect(prompt.contains("Add a dark mode toggle")) + #expect(prompt.contains("Bloom")) + #expect(!prompt.contains("{{")) + } + + @Test("the built-in prompt names every rung, and asks for the JSON in words") + func promptNamesEveryRung() { + let template = PromptRegistry.definition(for: .routeTask).defaultTemplate + for rung in TaskComplexity.allCases { + #expect(template.contains("- \(rung.rawValue):"), "the prompt never describes \(rung.rawValue)") + } + #expect(template.contains("\"complexity\"")) + #expect(template.contains("\"reason\"")) + } + + @Test("a pasted stack trace is capped rather than paid for") + func promptCapsTheTask() { + let huge = String(repeating: "stack frame\n", count: 5_000) + let prompt = ModelRouter.prompt(task: huge, project: "Bloom") + #expect(prompt.count < ModelRouter.taskLimit + 2_000) + } + + @Test("the answer is the same whichever agent gave it") + func agentAgnostic() async throws { + let codex = RouterAnalyser(kind: .codex, model: "gpt-5.5-mini", effort: "low", name: "GPT-5.5 mini") + let router = ModelRouter(ask: signalling([ + .thinking("Looks small."), + .text(#"Here it is: {"complexity":"simple","reason":"One function."}"#), + .finished(isError: false), + ])) + let last = try #require(await collect(router, analyser: codex).last) + #expect(last.reasoning == "Looks small.") + #expect(last.answer == ModelRouterAnswer(complexity: .simple, reason: "One function.")) + } + + @Test("an agent Bloom cannot talk to is no answer, and asks nothing") + func unsupportedAgent() async throws { + let cursor = RouterAnalyser(kind: .cursor, model: "", effort: "", name: "Cursor") + let last = try #require(await collect(ModelRouter(ask: ModelRouter.live), analyser: cursor).last) + #expect(last.phase == .failed) + } + + /// The bound below is the router's own deadline, made small so the test is not a minute long. + /// What is asserted is the outcome, not how long it took: a stream that never ends must still + /// end the route, failed, and before the suite's own limit kills the test from outside. + @Test("an analyser that never answers is given up on", .timeLimit(.minutes(1))) + func neverAnswers() async throws { + let router = ModelRouter(ask: { _ in AsyncThrowingStream { _ in } }, timeout: .milliseconds(50)) + let last = try #require(await collect(router).last) + #expect(last.phase == .failed) + } +} + +@Suite("Model router progress") +struct ModelRouterProgressTests { + @Test("a forced structure wins over prose") + func structureWins() { + var progress = ModelRouterProgress() + progress.ingest(.text(#"{"complexity":"trivial","reason":"prose"}"#)) + progress.ingest(.structured(.object(["complexity": .string("deep"), "reason": .string("schema")]))) + progress.ingest(.finished(isError: false)) + #expect(progress.answer == ModelRouterAnswer(complexity: .deep, reason: "schema")) + } + + @Test("prose is read whether fenced, introduced or bare") + func proseShapes() { + #expect(ModelRouterProgress.object(in: "```json\n{\"complexity\":\"simple\"}\n```")?["complexity"]?.stringValue == "simple") + #expect(ModelRouterProgress.object(in: "Sure. {\"complexity\":\"deep\"} Done.")?["complexity"]?.stringValue == "deep") + #expect(ModelRouterProgress.object(in: "no object here") == nil) + #expect(ModelRouterProgress.object(in: "[1, 2]") == nil) + } + + @Test("an error ends it without an answer, whatever arrived before") + func errorFinish() { + var progress = ModelRouterProgress() + progress.ingest(.structured(.object(["complexity": .string("simple")]))) + progress.ingest(.finished(isError: true)) + #expect(progress.phase == .failed) + } + + @Test("nothing after the end can change it") + func settledIsFinal() { + var progress = ModelRouterProgress() + progress.ingest(.structured(.object(["complexity": .string("simple")]))) + progress.ingest(.finished(isError: false)) + let changed = progress.ingest(.thinking("late")) + #expect(!changed) + #expect(progress.reasoning.isEmpty) + #expect(progress.answer?.complexity == .simple) + } +} + +// MARK: - The Codex and Grok analysers + +@Suite("Model router on Codex") +struct CodexRouterAskTests { + private func delta(_ text: String, item: String = "r1", thread: String = "t1") -> CodexEvent { + .reasoningDelta(CodexTextDelta(itemID: item, threadID: thread, turnID: "u1", text: text)) + } + + private func message(_ text: String, phase: CodexMessagePhase) -> CodexEvent { + .itemCompleted(CodexItemEvent(item: .agentMessage(CodexAgentMessage(id: "m1", text: text, phase: phase)), threadID: "t1", turnID: "u1")) + } + + @Test("reasoning streams, with a paragraph between two items", .tags(.agentProtocol)) + func reasoning() { + var decoder = CodexRouterAsk.Decoder(threadID: "t1") + let said5 = decoder.signals(for: delta("One.")) + #expect(said5 == [.thinking("One.")]) + let said6 = decoder.signals(for: delta(" More.")) + #expect(said6 == [.thinking(" More.")]) + let said7 = decoder.signals(for: delta("Two.", item: "r2")) + #expect(said7 == [.thinking("\n\nTwo.")]) + } + + @Test("another thread on the same connection says nothing") + func otherThread() { + var decoder = CodexRouterAsk.Decoder(threadID: "t1") + let said8 = decoder.signals(for: delta("Elsewhere.", thread: "t2")) + #expect(said8.isEmpty) + } + + @Test("the answer is the final message, and narration before it is not", .tags(.agentProtocol)) + func answer() { + var decoder = CodexRouterAsk.Decoder(threadID: "t1") + let said9 = decoder.signals(for: message("Let me think.", phase: .commentary)) + #expect(said9.isEmpty) + let said10 = decoder.signals(for: message(#"{"complexity":"moderate"}"#, phase: .finalAnswer)) + #expect(said10 == [.text(#"{"complexity":"moderate"}"#)]) + // Providers do not always set the phase, so an unknown one is still an answer. + let said11 = decoder.signals(for: message("x", phase: .unknown)) + #expect(said11 == [.text("x")]) + } + + @Test("the turn's end finishes it, and a failed turn is an error") + func turnEnds() { + var done = CodexRouterAsk.Decoder(threadID: "t1") + _ = done.signals(for: message("{}", phase: .finalAnswer)) + let said12 = done.signals(for: .turnCompleted(CodexTurn(id: "u1", threadID: "t1", status: .completed))) + #expect(said12 == [.finished(isError: false)]) + #expect(done.isFinished) + + var failed = CodexRouterAsk.Decoder(threadID: "t1") + let said13 = failed.signals(for: .turnCompleted(CodexTurn(id: "u1", threadID: "t1", status: .failed))) + #expect(said13 == [.finished(isError: true)]) + } + + @Test("an answer only in the completed turn is still found") + func lateAnswer() { + var decoder = CodexRouterAsk.Decoder(threadID: "t1") + let turn = CodexTurn( + id: "u1", threadID: "t1", status: .completed, + items: [.agentMessage(CodexAgentMessage(id: "m1", text: "{}", phase: .finalAnswer))] + ) + let said14 = decoder.signals(for: .turnCompleted(turn)) + #expect(said14 == [.text("{}"), .finished(isError: false)]) + } + + @Test("an error the server will retry is not the end, and a closed connection is") + func errors() { + var decoder = CodexRouterAsk.Decoder(threadID: "t1") + let retry = CodexTurnError(threadID: "t1", turnID: "u1", message: "busy", willRetry: true) + let said15 = decoder.signals(for: .turnError(retry)) + #expect(said15.isEmpty) + #expect(!decoder.isFinished) + let said16 = decoder.signals(for: .closed(reason: "gone")) + #expect(said16 == [.finished(isError: true)]) + } +} + +@Suite("Model router on Grok") +struct GrokRouterAskTests { + private func update(_ kind: GrokSessionUpdate.Kind, session: String = "s1") -> GrokEvent { + .update(GrokSessionUpdate(sessionID: session, kind: kind, raw: .null)) + } + + @Test("thoughts are reasoning and message chunks are the answer", .tags(.agentProtocol)) + func chunks() { + var decoder = GrokRouterAsk.Decoder(session: "s1", promptID: .number(3)) + let said17 = decoder.signals(for: update(.thought("Hmm."))) + #expect(said17 == [.thinking("Hmm.")]) + let said18 = decoder.signals(for: update(.text("{\"complexity\""))) + #expect(said18 == [.text("{\"complexity\"")]) + let said19 = decoder.signals(for: update(.thought("x"), session: "other")) + #expect(said19.isEmpty) + } + + @Test("only this prompt's reply ends it, and a refusal is an error") + func ends() { + var decoder = GrokRouterAsk.Decoder(session: "s1", promptID: .number(3)) + let other = GrokPromptResult(requestID: .number(2), sessionID: "s1", stopReason: "end_turn", raw: .null) + let said20 = decoder.signals(for: .promptCompleted(other)) + #expect(said20.isEmpty) + + let refused = GrokPromptResult(requestID: .number(3), sessionID: "s1", stopReason: "refusal", raw: .null) + let said21 = decoder.signals(for: .promptCompleted(refused)) + #expect(said21 == [.finished(isError: true)]) + #expect(decoder.isFinished) + } + + @Test("the instructions travel ahead of the task, since ACP has no system prompt", .tags(.security)) + func instructionsFirst() { + let grok = RouterAnalyser(kind: .grok, model: "grok-5-fast", effort: "low", name: "Grok 5 Fast") + let prompt = GrokRouterAsk.prompt(for: ModelRouterRequest(analyser: grok, prompt: "The task.")) + #expect(prompt.hasPrefix(ModelRouter.systemPrompt)) + #expect(prompt.hasSuffix("The task.")) + } +} + +// MARK: - Choosing the analyser + +@Suite("Router analyser") +struct RouterAnalyserTests { + private let lists: [AgentKind: [AgentModel]] = [ + .codex: [model("gpt-5.5", "low", "medium", "high"), model("gpt-5.5-mini", "minimal", "low", "medium", name: "GPT-5.5 mini")], + .grok: [model("grok-5", "low", "high"), model("grok-5-fast", "low")], + ] + + private func resolve( + chat: AgentKind, + connected: Set<AgentKind>, + settings: ModelRouterSettings = ModelRouterSettings() + ) -> RouterAnalyser? { + RouterAnalyser.resolve(chatBackend: chat, settings: settings, connected: connected, models: lists) + } + + @Test("the chat's own agent analyses when it is connected") + func followsTheChat() { + #expect(resolve(chat: .codex, connected: [.claudeCode, .codex])?.kind == .codex) + #expect(resolve(chat: .claudeCode, connected: [.claudeCode, .codex])?.kind == .claudeCode) + } + + @Test("otherwise the first connected agent, in the order Bloom offers them") + func fallsBack() { + #expect(resolve(chat: .codex, connected: [.grok, .claudeCode])?.kind == .claudeCode) + #expect(resolve(chat: .claudeCode, connected: [.grok])?.kind == .grok) + } + + @Test("a named agent wins while it is connected, and falls back when it is not") + func namedAgent() { + let grokFirst = ModelRouterSettings(analyser: .agent(.grok)) + #expect(resolve(chat: .claudeCode, connected: [.claudeCode, .grok], settings: grokFirst)?.kind == .grok) + #expect(resolve(chat: .claudeCode, connected: [.claudeCode], settings: grokFirst)?.kind == .claudeCode) + } + + @Test("nobody connected, or only agents Bloom cannot talk to, is nobody") + func nobody() { + #expect(resolve(chat: .claudeCode, connected: []) == nil) + #expect(resolve(chat: .claudeCode, connected: [.cursor, .openCode]) == nil) + } + + @Test("the suggested model is each agent's light one") + func suggestions() { + #expect(RouterAnalyser.suggestedModel(for: .claudeCode, models: []) == "haiku") + #expect(RouterAnalyser.suggestedModel(for: .codex, models: lists[.codex] ?? []) == "gpt-5.5-mini") + #expect(RouterAnalyser.suggestedModel(for: .grok, models: lists[.grok] ?? []) == "grok-5-fast") + // No light model: the least capable on the list. No list: the agent's own default. + #expect(RouterAnalyser.suggestedModel(for: .codex, models: [model("gpt-5.5"), model("gpt-5.4")]) == "gpt-5.4") + #expect(RouterAnalyser.suggestedModel(for: .codex, models: []).isEmpty) + } + + @Test("the reader thinks at high, and takes none on a model that takes none") + func efforts() throws { + let codex = try #require(resolve(chat: .codex, connected: [.codex])) + #expect(codex.model == "gpt-5.5-mini") + // The mini goes no higher than medium, so the reader takes the next level down. + #expect(codex.effort == "medium") + #expect(codex.name == "GPT-5.5 mini") + + let haikuOnly = RouterAnalyser.analyser(on: .claudeCode, stored: nil, models: [model("haiku")]) + #expect(haikuOnly.effort.isEmpty) + #expect(RouterAnalyser.analyser(on: .claudeCode, stored: nil, models: []).effort == "high") + let lowOnly = RouterAnalyser.analyser(on: .grok, stored: nil, models: [model("grok-5-fast", "minimal", "low")]) + #expect(lowOnly.effort == "low") + } + + @Test("the owner's model is kept while the list still offers it") + func storedModel() { + let stored = ModelRouteChoice(model: "gpt-5.5", effort: "medium") + let kept = RouterAnalyser.analyser(on: .codex, stored: stored, models: lists[.codex] ?? []) + #expect(kept.model == "gpt-5.5") + #expect(kept.effort == "medium") + + let retired = ModelRouteChoice(model: "gpt-4.1", effort: "medium") + #expect(RouterAnalyser.analyser(on: .codex, stored: retired, models: lists[.codex] ?? []).model == "gpt-5.5-mini") + } + + @Test("a name is always something a person can read") + func names() { + #expect(RouterAnalyser.analyser(on: .claudeCode, stored: nil, models: []).name == "Claude Haiku") + #expect(RouterAnalyser.analyser(on: .codex, stored: nil, models: []).name == "Codex's default model") + } +} + +// MARK: - Tables and settings + +@Suite("Router tables") +struct ModelRouterTableTests { + private let codex = [ + model("gpt-5.5", "minimal", "low", "medium", "high", "xhigh"), + model("gpt-5.5-mini", "low", "medium", "high"), + model("codex-auto-review", "low", hidden: true), + ] + + @Test("Claude Code's table is the written one, whatever its list says") + func claude() { + #expect(ModelRouterTable.suggested(for: .claudeCode, models: []) == .standard) + } + + @Test("Codex's is read off its list: the light model low, the full one high", .tags(.agentProtocol)) + func codexTable() throws { + let table = try #require(ModelRouterTable.suggested(for: .codex, models: codex)) + #expect(table.choice(for: .trivial) == ModelRouteChoice(model: "gpt-5.5-mini", effort: "low")) + #expect(table.choice(for: .simple) == ModelRouteChoice(model: "gpt-5.5-mini", effort: "medium")) + #expect(table.choice(for: .moderate) == ModelRouteChoice(model: "gpt-5.5", effort: "medium")) + #expect(table.choice(for: .complex) == ModelRouteChoice(model: "gpt-5.5", effort: "high")) + #expect(table.choice(for: .deep) == ModelRouteChoice(model: "gpt-5.5", effort: "xhigh")) + } + + @Test("an account with no light model runs every rung on the full one") + func noLightModel() throws { + let table = try #require(ModelRouterTable.suggested(for: .grok, models: [model("grok-5", "low", "high")])) + #expect(TaskComplexity.allCases.allSatisfy { table.choice(for: $0).model == "grok-5" }) + #expect(table.choice(for: .trivial).effort == "low") + #expect(table.choice(for: .deep).effort == "high") + } + + @Test("nothing to read is no table") + func nothing() { + #expect(ModelRouterTable.suggested(for: .codex, models: []) == nil) + #expect(ModelRouterTable.suggested(for: .cursor, models: codex) == nil) + } + + @Test("the owner's rungs go over the suggestion, and only those") + func overrides() throws { + let settings = ModelRouterSettings(tables: [.codex: [.deep: ModelRouteChoice(model: "gpt-5.5", effort: "high")]]) + let table = try #require(settings.table(for: .codex, models: codex)) + #expect(table.choice(for: .deep).effort == "high") + #expect(table.choice(for: .trivial).model == "gpt-5.5-mini") + } + + @Test("a Codex route stays on Codex models, and keeps the chat's model when a stored one is gone") + func codexRoute() throws { + let table = try #require(ModelRouterTable.suggested(for: .codex, models: codex)) + let trivial = ModelRouting.route( + ModelRouterAnswer(complexity: .trivial, reason: ""), table: table, backend: .codex, + current: "gpt-5.5", models: codex + ) + #expect(trivial.model == "gpt-5.5-mini") + #expect(trivial.effort == "low") + + let stale = ModelRouterTable(choices: [.deep: ModelRouteChoice(model: "gpt-4.1", effort: "high")]) + let kept = ModelRouting.route( + ModelRouterAnswer(complexity: .deep, reason: ""), table: stale, backend: .codex, + current: "gpt-5.5", models: codex + ) + #expect(kept.model == "gpt-5.5") + } + + @Test("a rung the table does not have keeps the chat's model") + func missingRung() { + let route = ModelRouting.route( + ModelRouterAnswer(complexity: .simple, reason: ""), table: ModelRouterTable(choices: [:]), + backend: .grok, current: "grok-5" + ) + #expect(route.model == "grok-5") + } +} + +@Suite("Router settings") +struct ModelRouterSettingsTests { + @Test("everything the owner chose survives a round trip") + func roundTrip() throws { + let settings = ModelRouterSettings( + analyser: .agent(.codex), + analyserModels: [.codex: ModelRouteChoice(model: "gpt-5.5-mini", effort: "low")], + tables: [.grok: [.deep: ModelRouteChoice(model: "grok-5", effort: "high")]] + ) + let raw = try #require(settings.encoded()) + #expect(raw.contains("\"codex\"")) + #expect(ModelRouterSettings.decode(raw) == settings) + } + + @Test("the defaults store nothing, and nothing reads back as the defaults") + func empty() { + #expect(ModelRouterSettings().encoded() == nil) + #expect(ModelRouterSettings.decode(nil) == ModelRouterSettings()) + #expect(ModelRouterSettings.decode("not json") == ModelRouterSettings()) + } + + @Test("an agent or a rung from somewhere else is left out, and the rest is kept") + func unknownEntries() { + let raw = #"{"analyser":"copilot","analyserModels":{"codex":{"model":"m","effort":"low"},"copilot":{"model":"x","effort":""}},"tables":{"grok":{"epic":{"model":"y","effort":""},"deep":{"model":"grok-5","effort":"high"}}}}"# + let settings = ModelRouterSettings.decode(raw) + #expect(settings.analyser == .followChat) + #expect(settings.analyserModels.keys.map(\.rawValue) == ["codex"]) + #expect(settings.tables[.grok]?.keys.map(\.rawValue) == ["deep"]) + } + + @Test("off unless somebody turns it on, and the settings live beside the switch") + func preferences() throws { + let suite = "bloom.router.tests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let preferences = ModelRouterPreferences(defaults: defaults) + #expect(!preferences.isEnabled) + preferences.isEnabled = true + #expect(ModelRouterPreferences(defaults: defaults).isEnabled) + + preferences.settings = ModelRouterSettings(analyser: .agent(.grok)) + #expect(ModelRouterPreferences(defaults: defaults).settings.analyser == .agent(.grok)) + preferences.settings = ModelRouterSettings() + #expect(defaults.string(forKey: ModelRouterPreferences.settingsKey) == nil) + } +} + +// MARK: - The rules + +@Suite("Model routing") +struct ModelRoutingTests { + @Test("a rung is read whatever case or wrapping it arrives in") + func forgivingRung() throws { + let answer = try #require(ModelRouting.answer(from: .object([ + "complexity": .string(" \"Moderate.\" "), "reason": .string("A form field."), + ]))) + #expect(answer.complexity == .moderate) + } + + @Test("a rung the table does not have is no answer", arguments: ["", "hard", "5", "medium"]) + func unknownRung(word: String) { + #expect(ModelRouting.answer(from: .object(["complexity": .string(word)])) == nil) + } + + @Test("a missing reason is not a missing answer") + func missingReason() { + let answer = ModelRouting.answer(from: .object(["complexity": .string("simple")])) + #expect(answer == ModelRouterAnswer(complexity: .simple, reason: "")) + } + + @Test("a reason is one line, capped at a word") + func reasonIsCleaned() throws { + #expect(ModelRouting.cleanReason("Two\nlines\tand gaps") == "Two lines and gaps") + #expect(ModelRouting.cleanReason(" ") == nil) + + let long = String(repeating: "word ", count: 100) + let cleaned = try #require(ModelRouting.cleanReason(long)) + #expect(cleaned.count <= ModelRouting.reasonLimit + 1) + #expect(cleaned.hasSuffix("word\u{2026}")) + } + + @Test("every rung lands where Claude Code's table says", arguments: TaskComplexity.allCases) + func followsTheTable(rung: TaskComplexity) { + let route = ModelRouting.route(ModelRouterAnswer(complexity: rung, reason: ""), current: "claude-fable-5-1") + let choice = ModelRouterTable.standard.choice(for: rung) + #expect(route.model == choice.model) + #expect(route.effort == choice.effort) + } + + @Test("Claude Code's table climbs: no rung runs a smaller model than the one below it") + func tableIsOrdered() { + let order = ["haiku", "sonnet", "opus", "fable"] + let ranks = TaskComplexity.allCases.map { rung in + order.firstIndex(of: ModelRouterTable.standard.choice(for: rung).model) ?? -1 + } + #expect(!ranks.contains(-1)) + #expect(ranks == ranks.sorted()) + } + + @Test("the owner's own variant of the same family is kept") + func keepsTheVariant() { + let route = ModelRouting.route(ModelRouterAnswer(complexity: .complex, reason: ""), current: "opus[1m]") + #expect(route.model == "opus[1m]") + #expect(route.effort == "high") + } + + @Test("a family the account does not offer keeps the model the chat already had") + func unavailableFamily() { + let models = [model("sonnet", "low", "medium", "high"), model("opus", "low", "medium", "high")] + let route = ModelRouting.route(ModelRouterAnswer(complexity: .trivial, reason: ""), current: "sonnet", models: models) + #expect(route.model == "sonnet") + } + + @Test("an effort the model does not take lands on one it does") + func clampsTheEffort() { + let models = [model("opus", "low", "medium", "high")] + let route = ModelRouting.route(ModelRouterAnswer(complexity: .deep, reason: ""), current: "sonnet", models: models) + #expect(route.model == "opus") + #expect(route.effort == "medium") + } + + @Test("a model listed with no efforts is sent none") + func modelWithoutEffort() { + let models = [model("haiku"), model("opus", "high")] + let route = ModelRouting.route(ModelRouterAnswer(complexity: .trivial, reason: ""), current: "opus", models: models) + #expect(route.model == "haiku") + #expect(route.effort.isEmpty) + } + + @Test( + "a Claude family is read the way the CLI reads one", + arguments: [ + ("opus", "opus"), ("opus[1m]", "opus"), ("claude-opus-5-5", "opus"), + ("claude-sonnet-5[1m]", "sonnet"), ("Haiku", "haiku"), ("gpt-5.5", nil), ("", nil), + ] as [(String, String?)] + ) + func family(id: String, expected: String?) { + #expect(ModelRouting.family(of: id) == expected) + } + + @Test("a Bloom chat on any agent Bloom runs, with an analyser and something written, is routed") + func routes() { + for backend in AgentKind.runnable { + #expect(ModelRouting.shouldRoute( + isEnabled: true, hasAnalyser: true, mode: .chat, backend: backend, + prompt: "Fix it", isResuming: false + )) + } + } + + @Test("everything else is not") + func declines() { + func asks( + isEnabled: Bool = true, hasAnalyser: Bool = true, mode: WorkspaceStartMode = .chat, + backend: AgentKind = .claudeCode, prompt: String = "Fix it", isResuming: Bool = false + ) -> Bool { + ModelRouting.shouldRoute( + isEnabled: isEnabled, hasAnalyser: hasAnalyser, mode: mode, + backend: backend, prompt: prompt, isResuming: isResuming + ) + } + #expect(!asks(isEnabled: false)) + #expect(!asks(hasAnalyser: false)) + #expect(!asks(mode: .claudeCLI)) + #expect(!asks(mode: .terminal)) + #expect(!asks(backend: .cursor)) + #expect(!asks(prompt: " \n ")) + #expect(!asks(isResuming: true)) + } + + @Test("a model or an effort picked by hand takes over, and nothing else does") + func takesOver() { + let before = ComposerControls(model: "opus", effort: "high") + + var picked = before + picked.model = "sonnet" + #expect(ModelRouting.takesOver(from: before, to: picked)) + + var effort = before + effort.effort = "low" + #expect(ModelRouting.takesOver(from: before, to: effort)) + + var mode = before + mode.permissionMode = .plan + mode.isFastMode = true + #expect(!ModelRouting.takesOver(from: before, to: mode)) + } +} + +@Suite("Model route caption") +struct ModelRouteCaptionTests { + @Test("the card stays while the router thinks, and goes when the session starts") + func visibility() { + #expect(ModelRouteCaption.showsCard(isSettled: false, isDismissed: true, hasSessionStarted: true)) + #expect(ModelRouteCaption.showsCard(isSettled: true, isDismissed: false, hasSessionStarted: false)) + #expect(!ModelRouteCaption.showsCard(isSettled: true, isDismissed: true, hasSessionStarted: false)) + #expect(!ModelRouteCaption.showsCard(isSettled: true, isDismissed: false, hasSessionStarted: true)) + } + + @Test("a route reads as a model and an effort") + func title() { + let route = ModelRoute(complexity: .deep, model: "opus", effort: "xhigh", reason: "") + #expect(ModelRouteCaption.title(route: route, isSettled: true) == "Opus \u{00B7} Extra high effort") + + let haikuRoute = ModelRoute(complexity: .trivial, model: "haiku", effort: "", reason: "") + #expect(ModelRouteCaption.title(route: haikuRoute, isSettled: true) == "Haiku") + } + + @Test("the card names whoever is reading") + func namesTheAnalyser() { + let detail = ModelRouteCaption.detail(route: nil, isSettled: false, wasSkipped: false, analyser: "GPT-5.5 mini") + #expect(detail.hasPrefix("GPT-5.5 mini is reading")) + } + + @Test("every state says something, and none of it is a dash") + func sentences() { + let route = ModelRoute(complexity: .simple, model: "sonnet", effort: "medium", reason: "One file.") + var sentences = [ + ModelRouteCaption.title(route: nil, isSettled: false), + ModelRouteCaption.title(route: nil, isSettled: true), + ModelRouteCaption.detail(route: nil, isSettled: false, wasSkipped: false, analyser: "Claude Haiku"), + ModelRouteCaption.detail(route: nil, isSettled: true, wasSkipped: true, analyser: "Claude Haiku"), + ModelRouteCaption.detail(route: nil, isSettled: true, wasSkipped: false, analyser: "Claude Haiku"), + ModelRouteCaption.detail(route: route, isSettled: true, wasSkipped: false, analyser: "Claude Haiku"), + ModelRouteCaption.tableSummary(), + ModelRouting.holdSentence, + ] + sentences += AgentKind.allCases.map(ModelRouteCaption.safetyNote(for:)) + for sentence in sentences { + #expect(!sentence.isEmpty) + #expect(!sentence.contains("\u{2014}") && !sentence.contains("\u{2013}")) + } + #expect(ModelRouteCaption.detail(route: route, isSettled: true, wasSkipped: false, analyser: "x") == "Simple task. One file.") + } + + @Test("the thinking is shown from its newest end, starting at a word") + func reasoningTail() { + #expect(ModelRouteCaption.reasoningTail("One.\n\nTwo.") == "One. Two.") + + let long = (1...200).map { "word\($0)" }.joined(separator: " ") + let tail = ModelRouteCaption.reasoningTail(long, limit: 40) + #expect(tail.hasPrefix("\u{2026}word")) + #expect(tail.hasSuffix("word200")) + #expect(tail.count <= 41) + } +} + +@Suite("Model route note") +struct ModelRouteNoteTests { + private let route = ModelRoute(complexity: .simple, model: "sonnet", effort: "high", reason: "A small README edit.") + + @Test("the decision survives being stored, and nothing else reads as one") + func roundTrip() { + let note = ModelRouteNote(route: route, analyser: "Haiku 4.5") + #expect(ModelRouteNote(note: note.payload) == note) + #expect(ModelRouteNote(note: ConversationNotResumed.note) == nil) + #expect(ConversationNotResumed.sentence(fromNote: note.payload) == nil) + } + + @Test("the row says who chose, and why") + func detail() { + #expect(ModelRouteNote(route: route, analyser: "Haiku 5.5").detail + == "Simple task, chosen by Haiku 5.5. A small README edit.") + #expect(ModelRouteNote(route: route, analyser: nil).detail == "Simple, picked by hand.") + } + + private struct Row: ModelRouteNoteRow { + var seq: Int + var kind: MessageKind + var payload: Data + } + + private func note(_ seq: Int, _ analyser: String? = "Haiku 5.5") -> Row { + Row(seq: seq, kind: .notice, payload: ModelRouteNote(route: route, analyser: analyser).payload) + } + + private func user(_ seq: Int, _ text: String) -> Row { + Row(seq: seq, kind: .user, payload: Data(#"{"type":"user","message":{"content":[{"type":"text","text":"\#(text)"}]}}"#.utf8)) + } + + private func sessionStart(_ seq: Int) -> Row { + Row(seq: seq, kind: .system, payload: Data(#"{"type":"system","subtype":"init","model":"sonnet"}"#.utf8)) + } + + @Test("a note belongs to the next session start, and Change sits on the newest one") + func placement() { + let rows = [note(0), user(1, "fix it"), sessionStart(2)] + let placement = ModelRouteNote.placement(in: rows, isQueueEmpty: true) + #expect(placement.notes[2]?.analyser == "Haiku 5.5") + #expect(placement.offer == .init(sessionStartSeq: 2, opening: "fix it")) + + // A rerun: its own note, the same words, a new session start. Only the newest offers Change. + let rerun = rows + [note(3, nil), user(4, "fix it"), sessionStart(5)] + let moved = ModelRouteNote.placement(in: rerun, isQueueEmpty: true) + #expect(moved.notes[2] != nil) + #expect(moved.notes[5]?.analyser == nil) + #expect(moved.offer?.sessionStartSeq == 5) + } + + @Test("Change goes once something else has been said, or something waits in the queue") + func noOffer() { + let rows = [note(0), user(1, "fix it"), sessionStart(2)] + #expect(ModelRouteNote.placement(in: rows + [user(3, "and the tests")], isQueueEmpty: true).offer == nil) + #expect(ModelRouteNote.placement(in: rows, isQueueEmpty: false).offer == nil) + // A rerun asked for but not started yet: the old row no longer offers it. + #expect(ModelRouteNote.placement(in: rows + [note(3, nil)], isQueueEmpty: true).offer == nil) + } + + @Test("a later run with no note of its own carries none") + func laterRun() { + let rows = [note(0), user(1, "fix it"), sessionStart(2), user(3, "more"), sessionStart(4)] + let placement = ModelRouteNote.placement(in: rows, isQueueEmpty: true) + #expect(placement.notes[2] != nil) + #expect(placement.notes[4] == nil) + } + + @Test("every rung is offered, narrowed like the router's own answer") + func choices() { + let choices = ModelRouteNote.choices(table: .standard, backend: .claudeCode, current: "opus[1m]", models: []) + #expect(choices.map(\.complexity) == TaskComplexity.allCases) + // The owner's own variant of a family is kept, as `ModelRouting.route` keeps it. + #expect(choices.last?.model == "opus[1m]") + #expect(choices.first?.model == "haiku") + } +} + +/// Counts launches made from a `@Sendable` closure. +private final class LaunchCount: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.lock(); count += 1; lock.unlock() + } + + var value: Int { + lock.lock(); defer { lock.unlock() } + return count + } +} diff --git a/docs/CODEX.md b/docs/CODEX.md index fe417b0d2..913e8d249 100644 --- a/docs/CODEX.md +++ b/docs/CODEX.md @@ -557,6 +557,7 @@ flag and Bloom infers it; here it is handed over. | `AgentKind.canRunWorkspaces` (`Model/Models.swift`) | Was `self == .claudeCode` | **Done.** It admits `.codex` too, and it was the last switch thrown, which is what made everything above reachable | | `SlashCommandIndex` | Documents in its own header that it is Claude Code's list and that a second backend gets its own index | Codex has `skills/list` as a real RPC, so its index is a call rather than a directory walk. A `CodexSkillIndex` beside it, chosen per chat | | `WorkspaceNamer` | Shells out to `claude` with Claude-only flags (`--json-schema`, `--tools ""`, `--safe-mode`) and gates on `Shell.which("claude")` | Leave it on Claude Code, and say so. A workspace name is not a chat, so it does not follow the chat's backend. It should fall back to the mechanical name when `claude` is absent, which it already does. Revisit only if a Codex-only machine turns out to be common | +| `CodexRouterAsk` (`Agent/Codex/`) | The automatic router's question on Codex: one thread and one turn on a short-lived app-server, read-only sandbox, `untrusted` approval policy with every question declined, in `AgentScratchDirectory` | **Done.** Not `exec`, for the reason at the top of this file: the card wants reasoning deltas. No output schema and no `ephemeral` flag are sent, because neither has been measured here; the JSON is asked for in words. See `docs/ROUTER.md` | | `InstallPing.agentName(installed:)` (`System/InstallPing.swift`) | `AgentKind.allCases.first(where: \.canRunWorkspaces)`, i.e. exactly one runnable agent | Becomes a set. The wire name should report what is installed **and** runnable, not the first one. It reports no credential and must keep reporting none | | `ContextWindowUsage` (`Views/Center/Composer/ContextWindowUsage.swift`) | Reads the limit off `result.modelUsage.<model>.contextWindow` and the used figure off the last `assistant` event's usage | Codex hands both over directly on `thread/tokenUsage/updated`: `modelContextWindow` and the `total` breakdown. Simpler, not harder. Give it a second reader and keep the doc comment explaining why Claude needs two lines and Codex needs one | | `AgentsSettingsView` (`Views/Chrome/Settings/`) | Shows a "cannot run workspaces" note for anything that cannot run one | Follows `canRunWorkspaces` already, so it corrects itself. Its account table for Codex is already written and verified | diff --git a/docs/GROK.md b/docs/GROK.md index ced43e70c..90a587e90 100644 --- a/docs/GROK.md +++ b/docs/GROK.md @@ -11,6 +11,10 @@ stdio` instead, which is ACP JSON-RPC, the same class of protocol as Codex's app grok agent [--always-approve] [--model <id>] [--reasoning-effort <level>] --no-leader stdio ``` +The automatic router asks Grok one question through the same `grok agent` and ACP, not through the +headless mode, because the headless mode answers a permission question only by approving it. See +`GrokRouterAsk` and `docs/ROUTER.md`. + `--no-leader` is load-bearing. Without it a Bloom chat can attach to the owner's interactive TUI leader. `GROK_DISABLE_AUTOUPDATER=1` is set in the child environment so update banners cannot land on stdout. diff --git a/docs/ROUTER.md b/docs/ROUTER.md new file mode 100644 index 000000000..123e072be --- /dev/null +++ b/docs/ROUTER.md @@ -0,0 +1,150 @@ +# The automatic router + +Off by default. Settings, Sessions, Automatic router turns it on. With it on, a new workspace's first +chat does not start on whatever model the window happened to be set to: a light model reads the +task first, and Bloom picks the model and reasoning effort from what it says. + +It works on every agent Bloom runs a chat on (Claude Code, Codex, Grok) and only with agents that +are signed in. It never moves a chat to another agent: a Codex chat is routed among Codex models. + +## What happens when Create is pressed + +``` +Create ──▶ AppModel.startWorkspace + ├─ openingRouter(for:) who reads the task, and with which table + ├─ OpeningRoute(...) the analyser starts reading now, streaming + ├─ WorkspaceManager.start worktree cut, session row written + └─ startSetupThenSend(route:) + ├─ route.settle(into:in:) joins the question to the new chat + ├─ opening message queued (pending bubble: "Goes once a model has been chosen.") + ├─ setup script runs, as before + └─ await route.settled() answer written with updatePreferences, then the + one drain, after setup, as it always was +``` + +The analyser is asked before the worktree exists, so most of its wait is spent behind git and the +setup script. Only the opening message waits; nothing else about the workspace does. + +While it waits, a line at the head of the conversation, pushing the setup log down, shows a +spinner, the analyser's thinking as it is written, and a Skip button. Once settled it says what was +chosen and why, or that the window's choice was kept, until the agent's own "Started with" row +arrives, however long the setup script runs. + +An answered route is written into the conversation (`ModelRouteNote`, a `.notice` row), and the +"Started with" row of that run draws it: the model the CLI reports, the routed effort, the +permissions, and under them the rung, who chose it and why. The note has no line of its own, because +both named the model one above the other. It stays after a relaunch. While the opening message is +still the only thing asked, the newest such row offers Change: picking another rung stops the run if +it is going, puts the chat on that rung's model and sends the opening message again, and its own +"Started with" says it was picked by hand. + +## Signed in, not just installed + +"Connected" is `AgentStatus.Connection.connected`: the CLI is found and an account is configured. +Claude Code: `~/.claude.json` or `ANTHROPIC_API_KEY`. Codex: `~/.codex/auth.json`. Grok: +`~/.grok/auth.json` or `XAI_API_KEY`. The check is local, so an expired token still counts, and the +call then fails like any other failure: the chat keeps what the window was set to. Cursor and +OpenCode are never connected, because Bloom does not read their accounts and has no runner for them. + +`AgentAvailability` holds the answer for the app. It detects at launch when the router is on, takes over whatever the +Agents pane detects, and the create window asks again when the last answer is five minutes old. + +## Who reads the task + +`RouterAnalyser.resolve`, in the core: + +1. A named agent, from Settings, while it is signed in. +2. Otherwise the chat's own agent, while it is signed in. +3. Otherwise the first signed in agent, in the order Claude Code, Codex, Grok. +4. Otherwise nobody, and the create window offers no Auto. + +On that agent, the model the owner chose in Settings while the list still offers it, otherwise the +suggestion: Haiku on Claude Code, the newest reduced model (`mini`, `nano`) on Codex, the newest +`fast` or `mini` model on Grok. The effort is `high` where the model takes it: on Haiku 5.5 it measured one to two seconds longer +than `medium`, most of the wait being the CLI starting, for under a tenth of a cent, and that wait is +spent behind the setup script. Then `medium`, `low` and `minimal`. + +| Agent | Adapter | How it is kept away from the code | +|---|---|---| +| Claude Code | `ClaudeRouterAsk`: `claude -p`, stream-json, `--json-schema` | no tools at all, no MCP, safe mode, no session saved, empty folder | +| Codex | `CodexRouterAsk`: one thread on a short-lived app-server | read-only sandbox, `untrusted` approvals all declined, empty folder | +| Grok | `GrokRouterAsk`: one prompt on a short-lived ACP connection | plan mode, permissions refused, empty folder | + +Codex and Grok cannot be run with no tools at all, and both start the owner's own MCP servers. +Settings says so, and the card only says "not your code" for Claude Code. + +Codex is on `untrusted` rather than `never` because `docs/CODEX.md` measured a read-only sandbox +running reads and commands without a single question: under `never` an analyser could read any +file the owner can by absolute path. Under `untrusted` every question is declined, but Codex's own +help says commands it trusts, such as `cat`, run without asking, and whether that reaches a file +outside the empty folder has not been measured (see "Not measured yet"). Grok's plan mode is the strictest mode Bloom can send it, and it is +a research mode, so Grok may still read a file without asking. Neither has an output schema Bloom has measured, so the prompt asks for the JSON in +words and `ModelRouterProgress` reads it out of the answer's text. + +## Which model the chat gets + +The analyser answers one of five rungs and a reason. It never names a model. + +| Rung | Claude Code | Codex and Grok | +|---|---|---| +| trivial | haiku, medium | light model, low | +| simple | sonnet, high | light model, medium | +| moderate | sonnet, extra high | full model, medium | +| complex | opus, high | full model, high | +| deep | opus, extra high | full model, extra high, else high | + +Claude Code's table is written down (`ModelRouterTable.standard`), because its aliases always +resolve. Codex's and Grok's are read off the list each agent last answered with +(`ModelRouterTable.suggested`), the light model being the newest reduced one and the full model the +most capable. An account with no light model runs every rung on the full one. `max` and `ultra` are +on no rung: they can turn a quick answer into a quarter of an hour, and that should be somebody's +decision rather than a classifier's. + +Every rung can be changed in Settings, per agent, and Reset puts it back on the suggestion. Only the +changed rungs are stored, so a suggestion goes on improving as the lists do. + +`ModelRouting.route` then narrows: + +- Claude Code: the owner's own variant of the same family is kept (`opus[1m]` stays `opus[1m]`), and + a family the account does not offer keeps the window's model. +- Codex and Grok: an id the list no longer offers keeps the window's model. +- An effort the model does not take lands on the model's own default, and a model with no levels + is sent none. + +## Who wins + +The router is the Auto entry at the head of the Model and Reasoning pickers in the create window's +agent settings, and of its presets menu, and it is selected when the setting is on. Picking a model, +an effort or a preset there turns Auto off, even when it is the value the window already held, and +picking Auto hands the choice back. A model or effort picked in the composer while the analyser +reads wins too, as a Skip would. After Create, the row's Change menu or the composer's own picker. +Only the create window offers Auto: in a chat it would mean a light model before every message. + +## Why the hold is not a `DeliveryHold` + +`DeliveryHold` is read off stored rows by the bridge's tools as well as by the transcript. The +router's wait lives in memory for a few seconds after Create and cannot outlive the process that is +having it, so `TranscriptModel` asks `OpeningRoute.holds(_:)` beside the hold instead of widening +the enum. + +## The prompt + +Editable in Settings, Prompts, as "Choose a model for a new workspace". It may change how tasks are +sorted; it cannot change the rungs. On Claude Code a schema holds them to the five above, and on +Codex and Grok an answer naming any other rung is no answer at all. + +## Not measured yet + +- Whether Codex under `untrusted` runs a trusted read such as `cat` on an absolute path without + asking. If it does, the read-only sandbox is the only thing between the analyser and the owner's + files, and Settings should say so. + +- Whether Codex sends reasoning deltas without a `summary` setting on the turn, and whether its + app-server takes `ephemeral` on `thread/start` and `outputSchema` on `turn/start`. Until then a + router thread can appear in Codex's own history, and the card may show only its spinner. +- Whether Grok sends thought chunks at a low effort. + +## Not done yet + +- Only the create window routes. A `bloom://` link, the Services menu and a Shortcut have no window + to draw the card in, and the bridge and Carry On name their own controls. From dca7360d84056d2b4ee4b4b897c57da9d86b2d50 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Thu, 15 Oct 2026 07:01:10 +0200 Subject: [PATCH 15/16] Keep the project's editor one click away in the inspector toolbar Once a worktree has been opened through Open Worktree in, its application sits beside the overflow menu: a click opens the worktree in it, a hold offers the others. --- .../Views/Inspector/InspectorToolbar.swift | 4 ++ .../Inspector/PreferredEditorButton.swift | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 Sources/Bloom/Views/Inspector/PreferredEditorButton.swift diff --git a/Sources/Bloom/Views/Inspector/InspectorToolbar.swift b/Sources/Bloom/Views/Inspector/InspectorToolbar.swift index a17ff66d2..110dc0d95 100644 --- a/Sources/Bloom/Views/Inspector/InspectorToolbar.swift +++ b/Sources/Bloom/Views/Inspector/InspectorToolbar.swift @@ -74,6 +74,10 @@ struct InspectorToolbar: View { } + // Keyed by repository so its `@AppStorage` key follows a switch of workspace. + PreferredEditorButton(workspace: model.workspace) + .id(model.workspace.repoID) + // No Refresh. The list keeps itself current: `AppModel`'s poll re-reads the selected // workspace's changed files while the app is frontmost, and a finished turn re-reads // them at once. A button asking the reader to do the app's job was only ever covering diff --git a/Sources/Bloom/Views/Inspector/PreferredEditorButton.swift b/Sources/Bloom/Views/Inspector/PreferredEditorButton.swift new file mode 100644 index 000000000..65ef85036 --- /dev/null +++ b/Sources/Bloom/Views/Inspector/PreferredEditorButton.swift @@ -0,0 +1,47 @@ +import SwiftUI +import BloomCore + +/// Opens the worktree in the project's editor in one click; a hold shows the list to switch. +/// +/// Read from the per repository key alone, not the global fallback, so it appears once this +/// project has a choice of its own, and `@AppStorage` shows it the moment one is recorded. +struct PreferredEditorButton: View { + let workspace: Workspace + + @AppStorage private var chosenBundleID: String? + + private static let iconSize: CGFloat = 16 + + init(workspace: Workspace) { + self.workspace = workspace + _chosenBundleID = AppStorage(OpenInPreferences.key(forRepo: workspace.repoID)) + } + + private var target: OpenInTarget { .folder(workspace.path) } + + /// Nil when the remembered application is no longer installed, so no dead button is drawn. + private var chosenApp: DetectedApp? { + guard let chosenBundleID else { return nil } + return OpenIn.candidates(for: target, repo: workspace.repoID).first { $0.id == chosenBundleID } + } + + var body: some View { + if let app = chosenApp { + Menu { + OpenInAppItems(target: target) + } label: { + Image(nsImage: app.icon) + .resizable() + .frame(width: Self.iconSize, height: Self.iconSize) + } primaryAction: { + OpenIn.open(workspace.path, with: app, repo: workspace.repoID) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .environment(\.openInRepoID, workspace.repoID) + .help("Open worktree in \(app.app.name)") + .accessibilityLabel("Open worktree in \(app.app.name)") + } + } +} From b81dd25c7e4b6a1b815e94c0bc8f506ed835ca55 Mon Sep 17 00:00:00 2001 From: Hugo Heneault <hugo@ownweb.fr> Date: Mon, 19 Oct 2026 17:13:23 +0200 Subject: [PATCH 16/16] Run ! commands in conversations outside a workspace The runner lived on WorkspaceModel, which an Ask conversation does not have, so its composer never entered shell mode. It is the transcript's now: the command runs in the chat's folder, with the workspace's variables when there is a workspace. --- .../State/TranscriptModel+ShellCommands.swift | 64 +++++++++++++++++++ Sources/Bloom/State/TranscriptModel.swift | 9 ++- .../State/WorkspaceModel+ShellCommands.swift | 64 ------------------- .../Composer/ComposerShellModeHint.swift | 4 +- .../Views/Center/Composer/ComposerView.swift | 22 +++---- .../BloomCore/Transcript/ShellCommand.swift | 4 +- 6 files changed, 80 insertions(+), 87 deletions(-) create mode 100644 Sources/Bloom/State/TranscriptModel+ShellCommands.swift delete mode 100644 Sources/Bloom/State/WorkspaceModel+ShellCommands.swift diff --git a/Sources/Bloom/State/TranscriptModel+ShellCommands.swift b/Sources/Bloom/State/TranscriptModel+ShellCommands.swift new file mode 100644 index 000000000..c013c644c --- /dev/null +++ b/Sources/Bloom/State/TranscriptModel+ShellCommands.swift @@ -0,0 +1,64 @@ +import BloomCore +import Foundation + +/// Running a `!` command typed into this chat, and handing its output to the agent once it has +/// finished. A workspace chat runs it with the variables a terminal pane gets; a conversation +/// outside any workspace runs it in its own folder with none. What the command is and what the +/// agent is told are `ShellCommand`'s; this is the doing. +extension TranscriptModel { + /// Whether the command was taken. It runs now, or once the agent's turn ends when one is under + /// way (see `ShellCommand.waitsForTurn`). Refused while another is waiting or running in the + /// same chat, so the draft stays where it is rather than vanishing into nothing. + func runShellCommand(_ command: String) -> Bool { + if usesInteractiveTerminal { + app.alert = BloomAlert( + title: "This agent runs in a terminal", + message: "Open its agent tab and run the command there." + ) + return false + } + guard shellRun == nil else { + app.notice = BloomNotice(message: "A command is already waiting or running in this chat.") + return false + } + + let waits = ShellCommand.waitsForTurn(isTurnRunning: isRunning, isAwaitingQuestion: isAwaitingPermission) + shellRun = ShellCommand.Run(command: command, isWaiting: waits) + if !waits { startShellCommand() } + return true + } + + /// Starts the command this chat is holding. Called at once from `runShellCommand`, or by + /// `startWaitingShellCommand` when the turn the command was waiting for has ended. + func startShellCommand() { + guard let run = shellRun, shellRunTask == nil else { return } + shellRun?.isWaiting = false + + let variables = shellVariables + shellRunTask = Task { @MainActor [weak self, cwd, app] in + let (ending, output) = await ShellCommandRun.run(run.command, cwd: cwd, variables: variables) { output in + self?.shellRun?.output = output + } + guard let self else { return } + shellRun = nil + shellRunTask = nil + // Stopped by hand is somebody changing their mind, so nothing goes to the agent. So is + // the chat being closed or archived under it, which cancels this in `terminateNow`. + guard !Task.isCancelled else { + app.notice = BloomNotice(message: "The command was stopped. Nothing was sent to the agent.") + return + } + let message = ShellCommand.message(command: run.command, output: output, ending: ending) + guard await submit(message, queuedAt: run.queuedAt) else { + app.notice = BloomNotice(message: "The command finished, but its output could not be sent to the agent.") + return + } + } + } + + private var shellVariables: [String: String] { + guard let workspace, let model = app.existingModel(for: workspace.id), + let repo = model.repo, let manager = app.manager else { return [:] } + return manager.environment(for: model.workspace, repo: repo, port: model.port) + } +} diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index 833153fff..187819a69 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -116,7 +116,7 @@ final class TranscriptModel { /// Where this chat's agent runs: its worktree, or the directory retained by its Ask tab. /// New Ask conversations use the folder chosen in Settings, with Bloom's own folder as fallback. let cwd: String - private unowned let app: AppModel + unowned let app: AppModel /// Where this conversation's file paths point, and which workspace a file chip opens into. /// One value rather than the whole workspace, because those two fields are all a row has ever /// read off it. See `TranscriptHome`. @@ -277,7 +277,7 @@ final class TranscriptModel { /// The `!` command running from this chat's composer, which holds the queue while it runs (see /// `DeliveryHold.command`). On the session for the reason - /// `promptRecall` is: the composer is handed from chat to chat. See `WorkspaceModel+ShellCommands`. + /// `promptRecall` is: the composer is handed from chat to chat. See `TranscriptModel+ShellCommands`. var shellRun: ShellCommand.Run? @ObservationIgnored var shellRunTask: Task<Void, Never>? @@ -292,9 +292,8 @@ final class TranscriptModel { /// hands back, turning all of them into one command. func startWaitingShellCommand() { guard shellRun?.isWaiting == true, !isRunning, !isAwaitingPermission, - !history.isCapturing, !history.isFinalisingTurn, !isWorkspaceArchiving, - let workspace, let model = app.existingModel(for: workspace.id) else { return } - model.startShellCommand(in: self) + !history.isCapturing, !history.isFinalisingTurn, !isWorkspaceArchiving else { return } + startShellCommand() } /// Stop on a running command, or Remove on a waiting one. diff --git a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift b/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift deleted file mode 100644 index 262088f0f..000000000 --- a/Sources/Bloom/State/WorkspaceModel+ShellCommands.swift +++ /dev/null @@ -1,64 +0,0 @@ -import BloomCore -import Foundation - -/// Running a `!` command typed into one of this workspace's chats, and handing its output to the -/// agent once it has finished. Here rather than on the transcript because the command gets the -/// workspace's variables, the ones a terminal pane gets, and the workspace is what knows them. -/// What the command is and what the agent is told are `ShellCommand`'s; this is the doing. -extension WorkspaceModel { - /// Whether the command was taken. It runs now, or once the agent's turn ends when one is under - /// way (see `ShellCommand.waitsForTurn`). Refused while another is waiting or running in the - /// same chat, so the draft stays where it is rather than vanishing into nothing. - func runShellCommand(_ command: String, from transcript: TranscriptModel) -> Bool { - if transcript.usesInteractiveTerminal { - app.alert = BloomAlert( - title: "This agent runs in a terminal", - message: "Open its agent tab and run the command there." - ) - return false - } - guard transcript.shellRun == nil else { - app.notice = BloomNotice(message: "A command is already waiting or running in this chat.") - return false - } - - let waits = ShellCommand.waitsForTurn( - isTurnRunning: transcript.isRunning, isAwaitingQuestion: transcript.isAwaitingPermission - ) - transcript.shellRun = ShellCommand.Run(command: command, isWaiting: waits) - if !waits { startShellCommand(in: transcript) } - return true - } - - /// Starts the command the chat is holding. Called at once from `runShellCommand`, or by the - /// transcript when the turn the command was waiting for has ended. - func startShellCommand(in transcript: TranscriptModel) { - guard let run = transcript.shellRun, transcript.shellRunTask == nil else { return } - transcript.shellRun?.isWaiting = false - - var variables: [String: String] = [:] - if let repo, let manager = app.manager { - variables = manager.environment(for: workspace, repo: repo, port: port) - } - let cwd = transcript.cwd - transcript.shellRunTask = Task { @MainActor [weak transcript, app] in - let (ending, output) = await ShellCommandRun.run(run.command, cwd: cwd, variables: variables) { output in - transcript?.shellRun?.output = output - } - guard let transcript else { return } - transcript.shellRun = nil - transcript.shellRunTask = nil - // Stopped by hand is somebody changing their mind, so nothing goes to the agent. So is - // the chat being closed or archived under it, which cancels this in `terminateNow`. - guard !Task.isCancelled else { - app.notice = BloomNotice(message: "The command was stopped. Nothing was sent to the agent.") - return - } - let message = ShellCommand.message(command: run.command, output: output, ending: ending) - guard await transcript.submit(message, queuedAt: run.queuedAt) else { - app.notice = BloomNotice(message: "The command finished, but its output could not be sent to the agent.") - return - } - } - } -} diff --git a/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift index 507a53b01..de4defd89 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerShellModeHint.swift @@ -12,8 +12,8 @@ struct ComposerShellModeHint: View { var body: some View { if ShellCommand.isShellMode(draft) { Text(waitsForTurn - ? "! for shell mode. Runs in the worktree when this turn ends, and the agent gets the output." - : "! for shell mode. Runs in the worktree, and the agent gets the output when it finishes.") + ? "! for shell mode. Runs in this chat's folder when this turn ends, and the agent gets the output." + : "! for shell mode. Runs in this chat's folder, and the agent gets the output when it finishes.") .font(Typo.caption) .foregroundStyle(Palette.warning) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index f5fe926f3..ea5c8aee3 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -90,14 +90,12 @@ struct ComposerView: View { .help("Drag to resize. Double-click to fit the text.") ComposerPlansView(transcript: transcript, model: model, controls: controls) - if model != nil { - ComposerShellModeHint( - draft: transcript.draft, - waitsForTurn: ShellCommand.waitsForTurn( - isTurnRunning: transcript.isRunning, isAwaitingQuestion: transcript.isAwaitingPermission - ) + ComposerShellModeHint( + draft: transcript.draft, + waitsForTurn: ShellCommand.waitsForTurn( + isTurnRunning: transcript.isRunning, isAwaitingQuestion: transcript.isAwaitingPermission ) - } + ) composer } // The chrome is whatever is left once the editor's share is taken off, so this settles on @@ -155,7 +153,7 @@ struct ComposerView: View { onOpenAttachment: open(attachment:), onOpenCommand: open(commandPath:), isFloating: true, - accent: model != nil && ShellCommand.isShellMode(transcript.draft) ? Palette.warning : nil + accent: ShellCommand.isShellMode(transcript.draft) ? Palette.warning : nil ) { actions in ComposerFooterView( controls: controls, @@ -185,7 +183,7 @@ struct ComposerView: View { if let store = app.store { await ComposerPlanningSupport.shared.refresh(from: store) } } .onChange(of: transcript.draft) { old, new in - if model != nil, let spaced = ShellCommand.autoSpaced(from: old, to: new) { + if let spaced = ShellCommand.autoSpaced(from: old, to: new) { transcript.draft = spaced caret = (spaced as NSString).length } @@ -483,17 +481,13 @@ struct ComposerView: View { // reaches `submit` without passing through this, and a quick prompt that would send a // command stops short of it in `fire`, so a command only ever runs from a key press. if let command = ShellCommand.command(in: transcript.draft) { - guard let model else { - app.notice = BloomNotice(message: "Shell commands run in a workspace chat.") - return - } // An attachment is a backticked path in the draft, which a shell reads as a command to // run and substitute. The chip hides that, so the draft is refused rather than run. guard AttachmentDraft.parse(transcript.draft, paths: attachments.map(\.path)).paths.isEmpty else { app.notice = BloomNotice(message: "A shell command cannot carry attachments. Type the path instead.") return } - guard model.runShellCommand(command, from: transcript) else { return } + guard transcript.runShellCommand(command) else { return } transcript.draft = "" caret = 0 saveDraftNow() diff --git a/Sources/BloomCore/Transcript/ShellCommand.swift b/Sources/BloomCore/Transcript/ShellCommand.swift index 16c607e97..3d67dad10 100644 --- a/Sources/BloomCore/Transcript/ShellCommand.swift +++ b/Sources/BloomCore/Transcript/ShellCommand.swift @@ -1,7 +1,7 @@ import Foundation -/// A composer draft that starts with `!`: a shell command run in the worktree rather than a message, -/// whose output goes to the agent as the next message once it has finished. +/// A composer draft that starts with `!`: a shell command run in the chat's folder rather than a +/// message, whose output goes to the agent as the next message once it has finished. /// /// This is Claude Code's shell mode, done by Bloom. stream-json has no such mode, and Codex and Grok /// have nothing like it either, so the output reaches the agent as an ordinary user message and every