Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions Sources/Bloom/Views/Inspector/ChecksView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
1 change: 1 addition & 0 deletions Sources/Bloom/Views/Inspector/InspectorToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Bloom/Views/Inspector/PullRequestBadge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
10 changes: 9 additions & 1 deletion Sources/BloomCore/Bridge/BridgeDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions Sources/BloomCore/GitHub/Forge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions Sources/BloomCore/GitHub/PullRequestStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
}
Expand Down
33 changes: 28 additions & 5 deletions Sources/BloomCore/GitLab/GitLab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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? {
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
}
Expand Down Expand Up @@ -271,13 +283,18 @@ 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)"),
cwd: worktree
), 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)
}

Expand Down Expand Up @@ -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.")
Expand Down
5 changes: 4 additions & 1 deletion Sources/BloomCore/GitLab/GitLabDecoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ?? "",
Expand Down
22 changes: 22 additions & 0 deletions Sources/BloomCore/GitLab/GitLabInstructions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 !"),
Expand Down
54 changes: 54 additions & 0 deletions Sources/BloomCore/GitLab/GitLabReads.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 16 additions & 2 deletions Sources/BloomCore/Model/WorkspaceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
Loading
Loading