diff --git a/CLAUDE.md b/CLAUDE.md index ca36ccd57..7f15a7f45 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 03c75e2a7..6ac9a3a6d 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 { @@ -235,7 +242,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] } @@ -271,6 +283,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)"), @@ -278,6 +294,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) } @@ -320,8 +337,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 = ["success", "failed", "canceled", "skipped"] + + private var snapshots: [String: (snapshot: GitLab.Snapshot?, at: ContinuousClock.Instant)] = [:] + private var flights: [String: Task] = [:] + 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 ` 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 ` 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//merge_requests?source_branch=&state=all` | +| One merge request | `projects//merge_requests/` | +| Its pipeline's jobs | `projects//pipelines//jobs`, the source project for a fork | +| A failed job's log | `projects//jobs//trace` | +| Mark ready | `glab mr update --ready -R ` | +| Workspace from a merge request | `glab mr checkout --branch -R ` | + +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 --target-branch --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.