From 90e3da3b2fb1e88e4ffb3fdab27d783d5251f5aa Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 18 Jun 2026 12:28:35 -0500 Subject: [PATCH 1/2] Replace acli with the Atlassian Remote MCP Server for Jira (agent flow) (#522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acli cannot set a Jira assignee (at create or after) and its transitions are unreliable, so every ticket Crow filed landed unassigned. Route the agent-side Jira flow (create-with-assignee, assign/reassign, transition, fetch, link) through the official Atlassian Remote MCP Server instead. Crow's in-app issue-board polling + auto-complete keep using acli (that path works); only the agent flow moved. - Model: AppConfig.atlassianMCP (endpoint/email/op:// token) + AtlassianMCPResolver builds `Authorization: Basic base64(email:token)`, mirroring GatewayResolver. - Settings β†’ Automation: "Atlassian MCP (Jira)" section, same op:// secret rules as the AI-gateway config. - Injection: ClaudeHookConfigWriter.writeAtlassianMcpConfig writes a project-root .mcp.json (http server, Authorization via ${env}) + pre-trusts it via enabledMcpjsonServers; resolved credential lives only in the 0600 settings.local.json env block. Wired at session launch for Jira-task worktrees and the Manager/cron (devRoot). - setup.sh: resolve task provider + Atlassian MCP from config.json, write .mcp.json/settings.local.json for Jira workspaces, git-exclude .mcp.json, and stop running GitHub issue auto-assign/project-status against Jira tickets (the `auto-assign failed` no-op). - Skills + Claude launcher prompt migrated to the MCP tools (getJiraIssue, createJiraIssue with assignee, transitionJiraIssue, ...), acli kept only as a fallback note. Bundled settings.json allows mcp__atlassian. - Resynced the stale release-fallback templates (setup.sh + SKILL.md) to their live sources so the feature ships in release builds. - Docs: configuration.md + automation.md (incl. one-time org-admin enablement + personal-API-token setup). Follow-up #523 tracks a configurable Crow↔Jira state-mapping UI. πŸ¦β€β¬› Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 9104A714-C9DE-4922-9C95-91F3A2749E37 --- .../CrowClaude/ClaudeHookConfigWriter.swift | 101 ++++++ .../Sources/CrowClaude/ClaudeLauncher.swift | 11 +- .../CrowClaudeTests/ClaudeLauncherTests.swift | 9 +- .../CrowCore/AtlassianMCPResolver.swift | 69 ++++ .../Sources/CrowCore/Models/AppConfig.swift | 72 ++++- .../AtlassianMCPResolverTests.swift | 72 +++++ .../CrowUI/AutomationSettingsView.swift | 70 ++++ .../CrowUI/Sources/CrowUI/SettingsView.swift | 1 + .../crow-batch-workspace-SKILL.md.template | 14 +- .../crow-create-ticket-SKILL.md.template | 40 ++- Resources/crow-workspace-SKILL.md.template | 175 ++++++++-- Resources/crow-workspace-setup.sh.template | 305 ++++++++++++++++-- Sources/Crow/App/Scaffolder.swift | 2 + Sources/Crow/App/SessionService.swift | 42 +++ docs/automation.md | 16 + docs/configuration.md | 31 ++ skills/crow-batch-workspace/SKILL.md | 2 +- skills/crow-create-ticket/SKILL.md | 40 ++- skills/crow-workspace/SKILL.md | 32 +- skills/crow-workspace/setup.sh | 150 ++++++++- 20 files changed, 1151 insertions(+), 103 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift index e6c8e084..0b55ba47 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift @@ -152,6 +152,107 @@ public struct ClaudeHookConfigWriter: HookConfigWriter { } } + // MARK: - Atlassian MCP (CROW-522) + + /// MCP server name we register for the Atlassian Remote MCP Server. + static let atlassianMcpServerName = "atlassian" + /// `env` key carrying the resolved `Authorization` header value. The + /// `.mcp.json` header references it via `${…}` expansion so the secret lives + /// only in the owner-only `settings.local.json`, never in `.mcp.json`. + static let atlassianMcpAuthEnvKey = "ATLASSIAN_MCP_AUTHORIZATION" + + /// Register (or clear) the Atlassian Remote MCP Server for a launched session + /// (CROW-522). Writes a project-root `.mcp.json` with an `http` server whose + /// `Authorization` header expands from `${ATLASSIAN_MCP_AUTHORIZATION}`, pre- + /// trusts it via `enabledMcpjsonServers`, and stores the resolved header value + /// in the `settings.local.json` `env` block (chmod 0600). Pass `nil` to remove + /// all three so toggling the MCP off β€” or a non-Jira session β€” leaves nothing + /// stale behind. + /// + /// `dirPath` is the worktree path for work/job/review sessions, or the dev + /// root for the Manager session β€” the same directory the session launches in, + /// so Claude Code reads both files. + public static func writeAtlassianMcpConfig(dirPath: String, resolved: AtlassianMCPResolver.Resolved?) { + let claudeDir = (dirPath as NSString).appendingPathComponent(".claude") + let settingsPath = (claudeDir as NSString).appendingPathComponent("settings.local.json") + let mcpPath = (dirPath as NSString).appendingPathComponent(".mcp.json") + + // --- settings.local.json: env + enabledMcpjsonServers --- + var settings: [String: Any] = [:] + if let data = FileManager.default.contents(atPath: settingsPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + settings = parsed + } + + var env = settings["env"] as? [String: Any] ?? [:] + var enabled = settings["enabledMcpjsonServers"] as? [String] ?? [] + if let resolved { + env[atlassianMcpAuthEnvKey] = resolved.authorization + if !enabled.contains(atlassianMcpServerName) { enabled.append(atlassianMcpServerName) } + } else { + env.removeValue(forKey: atlassianMcpAuthEnvKey) + enabled.removeAll { $0 == atlassianMcpServerName } + } + + if env.isEmpty { settings.removeValue(forKey: "env") } else { settings["env"] = env } + if enabled.isEmpty { + settings.removeValue(forKey: "enabledMcpjsonServers") + } else { + settings["enabledMcpjsonServers"] = enabled + } + + if settings.isEmpty { + try? FileManager.default.removeItem(atPath: settingsPath) + } else { + do { + try FileManager.default.createDirectory(atPath: claudeDir, withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: settingsPath)) + // The env block carries the resolved Basic credential, so restrict + // the file to owner-only β€” matching ConfigStore's 0600 on config.json. + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: settingsPath) + } catch { + NSLog("[ClaudeHookConfigWriter] Failed to write MCP settings to %@: %@", + settingsPath, error.localizedDescription) + } + } + + // --- .mcp.json: the server definition (no secret β€” references ${env}) --- + guard let resolved else { + // Only remove a .mcp.json that is ours (just the atlassian server); + // leave a user-authored multi-server file alone. + if let data = FileManager.default.contents(atPath: mcpPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = parsed["mcpServers"] as? [String: Any], + servers.count == 1, servers[atlassianMcpServerName] != nil { + try? FileManager.default.removeItem(atPath: mcpPath) + } + return + } + + var mcp: [String: Any] = [:] + if let data = FileManager.default.contents(atPath: mcpPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + mcp = parsed + } + var servers = mcp["mcpServers"] as? [String: Any] ?? [:] + servers[atlassianMcpServerName] = [ + "type": "http", + "url": resolved.endpoint, + "headers": ["Authorization": "${\(atlassianMcpAuthEnvKey)}"], + ] as [String: Any] + mcp["mcpServers"] = servers + + do { + let data = try JSONSerialization.data(withJSONObject: mcp, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: mcpPath)) + } catch { + NSLog("[ClaudeHookConfigWriter] Failed to write .mcp.json to %@: %@", + mcpPath, error.localizedDescription) + } + } + /// Remove our hook entries from a worktree's settings.local.json, preserving user settings. public func removeHookConfig(worktreePath: String) { let settingsPath = (worktreePath as NSString) diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift index ec05d76c..4a6c47f4 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift @@ -11,8 +11,9 @@ public actor ClaudeLauncher { /// "study the ticket" fetch command. /// - codeProvider: the **code** provider (where the PR lives) β€” drives the /// "open a PR/MR" step. Defaults to `provider` when `nil`. For a Jira-task - /// + GitHub-code session these differ: the ticket is fetched with `acli` - /// while the PR is still opened with `gh` (ADR 0005 cross-backend pairing). + /// + GitHub-code session these differ: the ticket is fetched via the + /// Atlassian MCP server while the PR is still opened with `gh` (ADR 0005 + /// cross-backend pairing; CROW-522 migrated Jira off `acli`). public func generatePrompt( session: Session, worktrees: [SessionWorktree], @@ -54,11 +55,9 @@ public actor ClaudeLauncher { case .jira: lines.append("") if let key = Validation.jiraKey(from: url) { - lines.append("```bash") - lines.append("acli jira workitem view \(key) --fields summary,status,description,comment") - lines.append("```") + lines.append("Fetch this work item via the **Atlassian MCP server** (pre-configured for this session): resolve your cloudId with `getAccessibleAtlassianResources`, then call `getJiraIssue` for key `\(key)`. Use the MCP tools β€” not `acli` β€” for any Jira create/assign/transition/comment as well.") } else { - lines.append("URL: \(url)") + lines.append("URL: \(url) β€” fetch it via the Atlassian MCP server (`getJiraIssue`).") } case .corveil, nil: lines.append("URL: \(url)") diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift index bc1647dc..0f85511c 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift @@ -57,7 +57,8 @@ import Testing @Test func generatePromptWithJiraTaskAndGitHubCode() async { // Cross-backend session (ADR 0005): Jira task + GitHub code. The ticket is - // fetched with `acli`, but the PR step still uses `gh`. + // fetched via the Atlassian MCP server (CROW-522), but the PR step still + // uses `gh`. let launcher = ClaudeLauncher() let session = Session(name: "test-session", ticketNumber: 7) @@ -69,8 +70,10 @@ import Testing codeProvider: .github ) - // Ticket step routes through acli with the extracted key. - #expect(prompt.contains("acli jira workitem view PROJ-7")) + // Ticket step routes through the Atlassian MCP with the extracted key β€” not acli. + #expect(prompt.contains("getJiraIssue")) + #expect(prompt.contains("PROJ-7")) + #expect(!prompt.contains("acli jira workitem")) #expect(!prompt.contains("gh issue view")) // PR step routes through the code backend (GitHub). #expect(prompt.contains("gh pr create")) diff --git a/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift b/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift new file mode 100644 index 00000000..26eb8673 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Resolves an ``AtlassianMCPConfig`` into the launch-ready value for the +/// Atlassian Remote MCP Server's `Authorization` header (CROW-522). +/// +/// The token (`tokenRef`) may be a plaintext string or an `op://…` 1Password +/// reference; references are resolved at launch via the `op` CLI so the secret +/// never lands at rest in `config.json`. The resolved output is an HTTP Basic +/// credential β€” `Basic base64("\(email):\(token)")` β€” matching Atlassian's +/// personal-API-token auth for the Rovo MCP Server. +/// +/// Resolved secret values are never logged. +public enum AtlassianMCPResolver { + /// Launch-ready values for injecting the Atlassian MCP server. + public struct Resolved: Equatable, Sendable { + /// The MCP endpoint URL (e.g. `https://mcp.atlassian.com/v1/mcp`). + public var endpoint: String + /// The full `Authorization` header value (e.g. `Basic ZW1haWw6dG9rZW4=`). + public var authorization: String + + public init(endpoint: String, authorization: String) { + self.endpoint = endpoint + self.authorization = authorization + } + } + + /// Resolve a config's token (resolving an `op://…` reference) and build the + /// Basic-auth `Authorization` header. Returns `nil` for an empty config, or + /// when the email/token is missing or the secret reference fails to resolve + /// (caller should then *not* inject the server, rather than inject a broken + /// credential). + /// + /// - Parameter resolveSecret: Injected for testability; defaults to `op read`. + public static func resolve( + _ config: AtlassianMCPConfig, + resolveSecret: (String) -> String? = GatewayResolver.opRead + ) -> Resolved? { + guard !config.isEmpty else { return nil } + + let email = config.email.trimmingCharacters(in: .whitespaces) + guard !email.isEmpty else { + NSLog("[AtlassianMCPResolver] No account email set; skipping MCP injection") + return nil + } + + let tokenRef = config.tokenRef.trimmingCharacters(in: .whitespaces) + guard !tokenRef.isEmpty else { + NSLog("[AtlassianMCPResolver] No API token set; skipping MCP injection") + return nil + } + + let token: String + if tokenRef.hasPrefix("op://") { + guard let secret = resolveSecret(tokenRef) else { + NSLog("[AtlassianMCPResolver] Failed to resolve API token reference (op read failed or op not signed in); skipping MCP injection") + return nil + } + token = secret + } else { + token = tokenRef + } + + let credential = "\(email):\(token)" + guard let encoded = credential.data(using: .utf8)?.base64EncodedString() else { + return nil + } + return Resolved(endpoint: config.resolvedEndpoint, authorization: "Basic \(encoded)") + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 1687c95a..9cfed284 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -65,6 +65,15 @@ public struct AppConfig: Codable, Sendable, Equatable { /// global `~/.zshrc` export doesn't bleed in). Per-workspace `gateway` blocks /// apply to non-Manager sessions only (CROW-402). public var managerGateway: WorkspaceGateway? + /// Optional Atlassian Remote MCP Server credential, shared org-wide (one + /// Atlassian account). When set and a session's task provider is Jira β€” or + /// for the Manager/cron sessions β€” Crow injects the Atlassian MCP server + /// into the launched session's `.mcp.json` and pre-trusts it, replacing the + /// `acli` agent flow for create/assign/transition/fetch (CROW-522). The API + /// token is stored as an `op://` reference (resolved at launch) so it never + /// lands at rest in `config.json`. When nil, no MCP is injected and Jira + /// sessions fall back to `acli`. + public var atlassianMCP: AtlassianMCPConfig? /// Effective review-exclude patterns: the global `defaults.excludeReviewRepos` /// unioned with every workspace's per-workspace `excludeReviewRepos`. A repo @@ -92,7 +101,8 @@ public struct AppConfig: Codable, Sendable, Equatable { jobs: [JobConfig] = [], defaultAgentKind: AgentKind = .claudeCode, agentsByKind: [String: AgentKind] = [:], - managerGateway: WorkspaceGateway? = nil + managerGateway: WorkspaceGateway? = nil, + atlassianMCP: AtlassianMCPConfig? = nil ) { self.workspaces = workspaces self.defaults = defaults @@ -112,6 +122,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.defaultAgentKind = defaultAgentKind self.agentsByKind = agentsByKind self.managerGateway = managerGateway + self.atlassianMCP = atlassianMCP } public init(from decoder: Decoder) throws { @@ -134,10 +145,11 @@ public struct AppConfig: Codable, Sendable, Equatable { defaultAgentKind = try container.decodeIfPresent(AgentKind.self, forKey: .defaultAgentKind) ?? .claudeCode agentsByKind = try container.decodeIfPresent([String: AgentKind].self, forKey: .agentsByKind) ?? [:] managerGateway = try container.decodeIfPresent(WorkspaceGateway.self, forKey: .managerGateway) + atlassianMCP = try container.decodeIfPresent(AtlassianMCPConfig.self, forKey: .atlassianMCP) } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind, managerGateway + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind, managerGateway, atlassianMCP } /// Resolve the agent that should drive a newly-created session of the @@ -230,6 +242,62 @@ extension WorkspaceGateway { } } +/// Atlassian Remote MCP Server credential (CROW-522). Drives the agent-side Jira +/// flow (create-with-assignee, assign, transition, fetch, link) via the official +/// MCP server instead of `acli`, in launched sessions, the Manager, and cron jobs. +/// +/// Auth is a **personal API token** sent as HTTP Basic: the launch-time resolver +/// builds `Authorization: Basic base64("\(email):\(token)")`. `tokenRef` is an +/// `op://…` 1Password reference (resolved at launch via `op read`) so the token +/// never lands at rest in `config.json`; a non-`op://` value is treated as a +/// plaintext token (stored in `config.json`, so warn in the UI). +/// +/// Note: the Atlassian org admin must first **enable API-token auth for the Rovo +/// MCP Server**, otherwise the headless calls 401. +public struct AtlassianMCPConfig: Codable, Sendable, Equatable { + /// The remote MCP endpoint. Defaults to Atlassian's recommended `/v1/mcp`. + public var endpoint: String + /// The Atlassian account email used for HTTP Basic auth. + public var email: String + /// The API token, as an `op://…` reference (preferred) or plaintext. + public var tokenRef: String + + /// Atlassian's recommended streamable-HTTP endpoint (the legacy `/v1/sse` + /// is deprecated after 2026-06-30). + public static let defaultEndpoint = "https://mcp.atlassian.com/v1/mcp" + + public init(endpoint: String = AtlassianMCPConfig.defaultEndpoint, email: String, tokenRef: String) { + self.endpoint = endpoint + self.email = email + self.tokenRef = tokenRef + } + + /// Whether this config has enough to inject a server. Both an email and a + /// token are required for Basic auth; a blank endpoint falls back to the + /// default at resolve time. + public var isEmpty: Bool { + email.trimmingCharacters(in: .whitespaces).isEmpty + && tokenRef.trimmingCharacters(in: .whitespaces).isEmpty + } + + /// The effective endpoint, falling back to the default when blank. + public var resolvedEndpoint: String { + let trimmed = endpoint.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? Self.defaultEndpoint : trimmed + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? Self.defaultEndpoint + email = try container.decodeIfPresent(String.self, forKey: .email) ?? "" + tokenRef = try container.decodeIfPresent(String.self, forKey: .tokenRef) ?? "" + } + + private enum CodingKeys: String, CodingKey { + case endpoint, email, tokenRef + } +} + /// Opt-in settings that let Crow type instructions into a session's managed /// Claude Code terminal when a watched PR transitions into a state that /// usually requires action. diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift new file mode 100644 index 00000000..64b0946d --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func atlassianMCPResolverBuildsBasicAuthFromPlaintextToken() throws { + let config = AtlassianMCPConfig(email: "me@example.com", tokenRef: "tok123") + // resolveSecret must NOT be consulted for a plaintext token. + let resolved = AtlassianMCPResolver.resolve(config) { _ in + Issue.record("op read should not be called for a plaintext token") + return nil + } + let expected = "Basic " + Data("me@example.com:tok123".utf8).base64EncodedString() + #expect(resolved?.authorization == expected) + #expect(resolved?.endpoint == AtlassianMCPConfig.defaultEndpoint) +} + +@Test func atlassianMCPResolverResolvesOpReference() throws { + let config = AtlassianMCPConfig( + endpoint: "https://mcp.atlassian.com/v1/mcp", + email: "me@example.com", + tokenRef: "op://Private/Atlassian/api_token") + var requestedRef: String? + let resolved = AtlassianMCPResolver.resolve(config) { ref in + requestedRef = ref + return "secretToken" + } + #expect(requestedRef == "op://Private/Atlassian/api_token") + let expected = "Basic " + Data("me@example.com:secretToken".utf8).base64EncodedString() + #expect(resolved?.authorization == expected) +} + +@Test func atlassianMCPResolverReturnsNilForEmptyConfig() throws { + let empty = AtlassianMCPConfig(email: "", tokenRef: "") + #expect(empty.isEmpty) + #expect(AtlassianMCPResolver.resolve(empty) { _ in "unused" } == nil) +} + +@Test func atlassianMCPResolverReturnsNilWhenSecretResolutionFails() throws { + let config = AtlassianMCPConfig(email: "me@example.com", tokenRef: "op://Vault/Item/missing") + // A failed op read must NOT inject a broken credential. + #expect(AtlassianMCPResolver.resolve(config) { _ in nil } == nil) +} + +@Test func atlassianMCPResolverReturnsNilWhenHalfConfigured() throws { + // Email without a token (or vice versa) can't form Basic auth. + let emailOnly = AtlassianMCPConfig(email: "me@example.com", tokenRef: "") + #expect(AtlassianMCPResolver.resolve(emailOnly) { _ in "unused" } == nil) + let tokenOnly = AtlassianMCPConfig(email: "", tokenRef: "tok") + #expect(AtlassianMCPResolver.resolve(tokenOnly) { _ in "unused" } == nil) +} + +@Test func atlassianMCPConfigDefaultsBlankEndpointToAtlassian() throws { + let config = AtlassianMCPConfig(endpoint: " ", email: "me@example.com", tokenRef: "tok") + #expect(config.resolvedEndpoint == AtlassianMCPConfig.defaultEndpoint) + #expect(AtlassianMCPResolver.resolve(config) { _ in nil }?.endpoint == AtlassianMCPConfig.defaultEndpoint) +} + +@Test func appConfigRoundTripsAtlassianMCP() throws { + let config = AppConfig(atlassianMCP: AtlassianMCPConfig( + email: "me@example.com", tokenRef: "op://Private/Atlassian/api_token")) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.atlassianMCP?.email == "me@example.com") + #expect(decoded.atlassianMCP?.tokenRef == "op://Private/Atlassian/api_token") + #expect(decoded.atlassianMCP?.endpoint == AtlassianMCPConfig.defaultEndpoint) +} + +@Test func appConfigDecodesMissingAtlassianMCPAsNil() throws { + let json = "{}".data(using: .utf8)! + let decoded = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(decoded.atlassianMCP == nil) +} diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 50e8d85a..03300d37 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -9,6 +9,7 @@ public struct AutomationSettingsView: View { @Binding var remoteControlEnabled: Bool @Binding var managerAutoPermissionMode: Bool @Binding var managerGateway: WorkspaceGateway? + @Binding var atlassianMCP: AtlassianMCPConfig? @Binding var autoRespond: AutoRespondSettings @Binding var attributionTrailers: Bool @Binding var autoMergeWatcherEnabled: Bool @@ -19,11 +20,16 @@ public struct AutomationSettingsView: View { @State private var managerGatewayBaseURL: String @State private var managerGatewayHeadersText: String + @State private var atlassianMCPEndpoint: String + @State private var atlassianMCPEmail: String + @State private var atlassianMCPToken: String + public init( defaults: Binding, remoteControlEnabled: Binding, managerAutoPermissionMode: Binding, managerGateway: Binding, + atlassianMCP: Binding, autoRespond: Binding, attributionTrailers: Binding, autoMergeWatcherEnabled: Binding, @@ -35,6 +41,7 @@ public struct AutomationSettingsView: View { self._remoteControlEnabled = remoteControlEnabled self._managerAutoPermissionMode = managerAutoPermissionMode self._managerGateway = managerGateway + self._atlassianMCP = atlassianMCP self._autoRespond = autoRespond self._attributionTrailers = attributionTrailers self._autoMergeWatcherEnabled = autoMergeWatcherEnabled @@ -45,6 +52,9 @@ public struct AutomationSettingsView: View { self._managerGatewayHeadersText = State(initialValue: managerGateway.wrappedValue.map { WorkspaceGateway.headerLines(from: $0.customHeaders) } ?? "") + self._atlassianMCPEndpoint = State(initialValue: atlassianMCP.wrappedValue?.endpoint ?? AtlassianMCPConfig.defaultEndpoint) + self._atlassianMCPEmail = State(initialValue: atlassianMCP.wrappedValue?.email ?? "") + self._atlassianMCPToken = State(initialValue: atlassianMCP.wrappedValue?.tokenRef ?? "") } /// Reject a half-filled Manager gateway (base URL xor headers), matching the @@ -72,6 +82,36 @@ public struct AutomationSettingsView: View { onSave?() } + /// Reject a half-filled Atlassian MCP block (email xor token). Basic auth + /// needs both; the endpoint falls back to the default when blank. + private var atlassianMCPValidationError: String? { + let hasEmail = !atlassianMCPEmail.trimmingCharacters(in: .whitespaces).isEmpty + let hasToken = !atlassianMCPToken.trimmingCharacters(in: .whitespaces).isEmpty + if hasEmail && !hasToken { return "Add an API token, or clear the account email." } + if hasToken && !hasEmail { return "Add the account email, or clear the API token." } + return nil + } + + /// Push the editor fields back into the `atlassianMCP` binding (nil when both + /// email and token are empty, or while half-filled so an invalid block isn't + /// persisted). + private func commitAtlassianMCP() { + let email = atlassianMCPEmail.trimmingCharacters(in: .whitespaces) + let token = atlassianMCPToken.trimmingCharacters(in: .whitespaces) + let endpoint = atlassianMCPEndpoint.trimmingCharacters(in: .whitespaces) + if email.isEmpty && token.isEmpty { + atlassianMCP = nil + } else if atlassianMCPValidationError == nil { + atlassianMCP = AtlassianMCPConfig( + endpoint: endpoint.isEmpty ? AtlassianMCPConfig.defaultEndpoint : endpoint, + email: email, + tokenRef: token) + } else { + return // half-filled β€” don't persist until valid + } + onSave?() + } + public var body: some View { Form { Section("Reviews") { @@ -152,6 +192,36 @@ public struct AutomationSettingsView: View { .foregroundStyle(.secondary) } + Section("Atlassian MCP (Jira)") { + TextField("Endpoint", text: $atlassianMCPEndpoint) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onChange(of: atlassianMCPEndpoint) { _, _ in commitAtlassianMCP() } + + TextField("Account email", text: $atlassianMCPEmail) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onChange(of: atlassianMCPEmail) { _, _ in commitAtlassianMCP() } + + TextField("API token (op:// reference or plaintext)", text: $atlassianMCPToken) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onChange(of: atlassianMCPToken) { _, _ in commitAtlassianMCP() } + Text("Personal API token from id.atlassian.com, sent as HTTP Basic (base64 of email:token). A value starting with `op://` is resolved at launch via the 1Password CLI and kept out of config.json (the resolved Authorization header is cached owner-only in settings.local.json); any other value is stored in plain text in config.json β€” prefer an `op://` reference.") + .font(.caption) + .foregroundStyle(.secondary) + + if let error = atlassianMCPValidationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Text("When set, Crow injects the official Atlassian Remote MCP Server into launched Jira-task sessions (and the Manager + cron jobs) so they create/assign/transition/fetch work items via MCP instead of acli. Requires an Atlassian org admin to first enable API-token auth for the Rovo MCP Server. Takes effect on next session launch.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Attribution") { Toggle("Add Crow-Session trailer to commits", isOn: $attributionTrailers) .onChange(of: attributionTrailers) { _, _ in onSave?() } diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 67e32b51..fdc8b7a1 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -73,6 +73,7 @@ public struct SettingsView: View { remoteControlEnabled: $config.remoteControlEnabled, managerAutoPermissionMode: $config.managerAutoPermissionMode, managerGateway: $config.managerGateway, + atlassianMCP: $config.atlassianMCP, autoRespond: $config.autoRespond, attributionTrailers: $config.attributionTrailers, autoMergeWatcherEnabled: $config.autoMergeWatcherEnabled, diff --git a/Resources/crow-batch-workspace-SKILL.md.template b/Resources/crow-batch-workspace-SKILL.md.template index b402c00d..dc481539 100644 --- a/Resources/crow-batch-workspace-SKILL.md.template +++ b/Resources/crow-batch-workspace-SKILL.md.template @@ -73,12 +73,15 @@ For each workspace spec, perform the same resolution as `/crow-workspace`: > Issue each `gh`/`git` fetch below as a **single, clean invocation** β€” one command per Bash call, no `cd …`/`echo`/`find` prefix or `| head` pipe β€” so the allowlist auto-approves it instead of prompting (see CLAUDE.md β†’ "Fetching Ticket / PR Data"). 1. **Read config**: `cat {devRoot}/.claude/config.json` -2. **Detect provider** from URL (see Provider Detection table in `/crow-workspace` skill) +2. **Detect provider** from URL (see Provider Detection table in `/crow-workspace` skill β€” includes Jira) 3. **Scan repos**: Find repos in all configured workspaces 4. **Match repo**: Score repos against ticket content -5. **Fetch ticket**: `gh issue view {url} --json title,body,labels` (with `dangerouslyDisableSandbox: true`) -6. **Check for existing PR**: `gh pr list --repo {owner}/{repo} --search "{issue_number}" --state open --json number,title,headRefName,url --limit 5` (with `dangerouslyDisableSandbox: true`) -7. **Generate names**: slug, branch, worktree path, session name (following `/crow-workspace` naming conventions) +5. **Fetch ticket** (provider-specific, with `dangerouslyDisableSandbox: true`): + - GitHub: `gh issue view {url} --json title,body,labels` + - GitLab: `GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments` + - Jira (task-only): fetch via the `atlassian` MCP server (`getAccessibleAtlassianResources` β†’ `getJiraIssue {key}`); title is the work item summary. (Fallback when MCP unconfigured: `acli jira workitem view {key} --json`, title at `.fields.summary`.) +6. **Check for existing PR**: `gh pr list --repo {owner}/{repo} --search "{issue_number}" --state open --json number,title,headRefName,url --limit 5` (with `dangerouslyDisableSandbox: true`). For a Jira-task session this runs against the workspace's configured GitHub/GitLab code repo, not Jira. +7. **Generate names**: slug, branch, worktree path, session name (following `/crow-workspace` naming conventions). **Jira:** resolve `{ticket_number}` as the **numeric suffix** of the key (`MAXX-6859` β†’ `6859`); `{ticket_url}` is the full `…/browse/{key}` URL; the slug uses the lowercased full key (`{repo}-maxx-6859-{slug}`). 8. **Compose prompt**: Use the First Prompt Template from `/crow-workspace` 9. **Write prompt file**: `cat > {devRoot}/.claude/prompts/crow-prompt-{session_name}.md` @@ -216,4 +219,5 @@ https://github.com/RadiusMethod/acme-api/issues/46 ``` - 5 parallel `setup.sh` calls - Each blocks one GCD thread in the socket server (well within the 64+ thread pool) -- `sleep 3` calls in each script overlap β€” total wait is ~3s, not 15s +- Each script's readiness poll (up to ~15s) overlaps with the others β€” they wait + concurrently, and most return as soon as the agent reports `agentLaunched` diff --git a/Resources/crow-create-ticket-SKILL.md.template b/Resources/crow-create-ticket-SKILL.md.template index 1b8f5d5f..863cfa0f 100644 --- a/Resources/crow-create-ticket-SKILL.md.template +++ b/Resources/crow-create-ticket-SKILL.md.template @@ -1,8 +1,11 @@ # Crow Create Ticket -Create a new ticket (GitHub issue via `gh`, or GitLab issue via `glab`) for a repo in -the current Crow workspace, assigned to the invoking user and labeled `crow:auto` so -Crow's auto-pickup queue implements it. +Create a new ticket (GitHub issue via `gh`, GitLab issue via `glab`, or Jira work item +via the **Atlassian MCP server**) for a repo/project in the current Crow workspace, +assigned to the invoking user and labeled `crow:auto`. For GitHub/GitLab, Crow's +auto-pickup queue then implements it. (Jira's auto-pickup isn't wired β€” the label is +for parity/board visibility β€” but the work item is created **with an assignee in one +step**, which `acli` could never do; CROW-522.) ## Important: Sandbox Bypass @@ -47,10 +50,15 @@ same file used by `/crow-workspace`. It maps each workspace to a provider/cli/ho ### Provider Detection -| Workspace `provider` | CLI | GITLAB_HOST | -|----------------------|------|----------------------| -| `github` | gh | β€” | -| `gitlab` | glab | workspace `host` | +| Workspace `taskProvider` | Tool | Notes | +|--------------------------|--------------|-----------------------------| +| `github` (or unset) | gh | β€” | +| `gitlab` | glab | workspace `host` | +| `jira` | Atlassian MCP | project `jiraProjectKey` | + +When a workspace sets `taskProvider: "jira"`, tasks live in Jira (code/PRs still on the +workspace's GitHub/GitLab repo). Use the Atlassian MCP tools instead of `gh`/`glab` for +the steps below. ## Instructions @@ -100,6 +108,10 @@ gh api user --jq .login GITLAB_HOST={host} glab api user --jq '.username' ``` +**Jira (Atlassian MCP):** resolve your own Atlassian `accountId` via the MCP β€” call +`atlassianUserInfo` (current user) or `lookupJiraAccountId`. You'll pass this +`accountId` as the assignee when creating the work item. + ### Step 4: Create the issue (assigned + labeled `crow:auto`) In both commands below, `BODY` is the user-provided body followed by the required @@ -130,6 +142,14 @@ GITLAB_HOST={host} glab issue create --repo {org/repo} \ --yes ``` +**Jira (Atlassian MCP):** call `createJiraIssue` with your resolved `cloudId` +(`getAccessibleAtlassianResources`), the workspace's `jiraProjectKey`, issue type +`Task`, the `TITLE` as summary, `BODY` (including the attribution footer) as +description, the `crow:auto` label, **and the assignee `accountId` from Step 3** so the +work item is created already assigned β€” the core fix in CROW-522. There is no +missing-label fallback to run (Step 5 is GitHub/GitLab-only); Jira accepts arbitrary +labels. + ### Step 4b: Attribution (REQUIRED) See `.claude/skills/crow-attribution/FOOTER.md` for the full rules. The body passed to @@ -143,7 +163,7 @@ followed by: - Crow filled in the agent name for this session before this skill reached you β€” paste the line literally; do not re-introduce `${…}` shell parameter expansion of your own (it silently fails inside single-quoted heredocs and the literal text leaks into the issue body). - Do not modify the URL β€” the link target is always `https://github.com/radiusmethod/crow`, never a fork or a derived value from the local git remote. - Do not wrap the line in additional formatting (no blockquote, no extra brackets, no surrounding text). -- This line MUST appear in every issue body, whether GitHub or GitLab, and whether or not the user supplied any body text. +- This line MUST appear in every issue body β€” GitHub, GitLab, or Jira β€” and whether or not the user supplied any body text. ### Step 5: Missing-label fallback @@ -172,6 +192,6 @@ the repo, assignee, and `crow:auto` label so they can confirm Crow will pick it - This skill only creates the ticket. Crow's auto-pickup queue (driven by the `crow:auto` label) is responsible for starting implementation β€” do not also set up a worktree or session here. Use `/crow-workspace` for that. -- Assignee is resolved dynamically (`gh api user` / `glab api user`); never hardcode a - login. +- Assignee is resolved dynamically (`gh api user` / `glab api user` / the Atlassian MCP + `atlassianUserInfo` accountId); never hardcode a login or accountId. - All `gh`, `glab`, and `git` commands require `dangerouslyDisableSandbox: true`. diff --git a/Resources/crow-workspace-SKILL.md.template b/Resources/crow-workspace-SKILL.md.template index 353f9642..80878bb3 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -24,13 +24,15 @@ Configuration is at `{devRoot}/.claude/config.json` (managed by the Crow app). T "workspaces": { "RadiusMethod": { "provider": "github", - "cli": "gh" + "cli": "gh", + "customInstructions": "Always run `npm test` before committing." }, "MyGitLab": { "provider": "gitlab", "cli": "glab", "host": "gitlab.example.com", - "alwaysInclude": [] + "alwaysInclude": [], + "customInstructions": null } }, "defaults": { @@ -48,6 +50,8 @@ Configuration is at `{devRoot}/.claude/config.json` (managed by the Crow app). T By default, `setup.sh` writes a per-worktree `.claude/settings.local.json` that overrides Claude Code's `attribution.commit` so commits include a `Crow-Session: ` trailer alongside the standard `Co-Authored-By: Claude` line. The trailer is a stable handle back to session metadata via `crow get-session `. To opt out globally, set `"attributionTrailers": false` at the top level of `{devRoot}/.claude/config.json` (also surfaced in Settings β†’ Automation β†’ Attribution). +The worktree's settings.local.json is added to that worktree's per-worktree git exclude list, so it stays local even when the repo's tracked `.gitignore` does not already cover it. + **When authoring commits by hand** (`git commit -m "…"`, heredoc, `git commit --amend`), include both `Crow-Session: ` and `Co-Authored-By: Claude ` as trailers at the end of the message. `attribution.commit` only fires for Claude Code's built-in commit flow; hand-rolled commits bypass it. `setup.sh` also installs a per-worktree `prepare-commit-msg` hook (CROW-518) that idempotently appends both trailers when missing β€” treat that hook as a safety net, not the primary path. Both trailers must be line-anchored at the end of the message; the `crow:merge` auto-merge gate parses `^Crow-Session:\s*\s*$` (see `IssueTracker.crowSessionTrailerPattern`). ## Multi-Workspace Discovery @@ -76,8 +80,6 @@ Match repos to input by: direct name match (+10), workspace mention (+5), keywor ## Git Provider Commands -Issue every ticket/PR fetch as a **single, clean invocation** β€” one `gh`/`glab`/`git` command per Bash call, with no `cd …`/`echo`/`find` prefix and no `| head`/`| tail` pipe. The permission allowlist prefix-matches the *whole* command, so bundling defeats rules like `Bash(gh issue view:*)` / `Bash(gh api:*)` and forces a prompt (see CLAUDE.md β†’ "Fetching Ticket / PR Data"). - ### GitHub (gh) ```bash gh issue view {url} --json title,body,labels @@ -90,15 +92,38 @@ GITLAB_HOST=gitlab.example.com glab issue view {number} --repo {org/repo} --comm GITLAB_HOST=gitlab.example.com glab mr view {number} --repo {org/repo} --comments ``` +### Jira (Atlassian MCP) +Jira work items are fetched via the **official Atlassian Remote MCP Server**, not +`acli` (CROW-522). When a workspace is configured for Jira and the Atlassian MCP +credential is set in Settings β†’ Automation, Crow pre-registers and auto-trusts the +`atlassian` MCP server in the launched session. Resolve your `cloudId` once with +`getAccessibleAtlassianResources`, then call `getJiraIssue` for the full key +(`PROJ-NNN`, e.g. `MAXX-6859`). The summary field is the `{ticket_title}`. Use the +same MCP tools (`createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, +`lookupJiraAccountId`) for any create/assign/transition. (If the MCP isn't +configured in this environment, fall back to `acli jira workitem view {key} --json`.) + ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | |---|---|---|---| | `github.com` | github | gh | - | +| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | Atlassian MCP | - | | `gitlab.example.com` | gitlab | glab | gitlab.example.com | | `gitlab.com` | gitlab | glab | gitlab.com | | `gitlab-il2.example.com` | gitlab | glab | gitlab-il2.example.com | +**Jira is task-only** (no code/VCS surface): the ticket lives in Jira while code +lands in the workspace's configured GitHub/GitLab repo. Detect it *before* the +loose GitLab match. The Jira key is `PROJ-NNN` (e.g. `MAXX-6859`). + +**Resolving `{ticket_number}` for Jira:** Jira keys have no standalone numeric +id, so use the **numeric suffix** of the key β€” `MAXX-6859` β†’ `6859`. Pass that as +`--ticket-number`, the full Atlassian browse URL as `--ticket-url` +(`https://.atlassian.net/browse/MAXX-6859`), and the summary as +`--ticket-title`. The worktree/branch/session slug uses the full lowercased key, +e.g. `{repo}-maxx-6859-{brief_slug}`. + ## PR Detection Before creating a worktree, check if the issue already has an open PR. If so, use the PR's branch instead of creating a new one. @@ -207,9 +232,56 @@ After the LLM resolves names (slug, branch, worktree path, session name), detect > **IMPORTANT:** All `crow`, `gh`, `glab`, and `git` commands require `dangerouslyDisableSandbox: true`. The `setup.sh` call itself must use `dangerouslyDisableSandbox: true` since it runs these commands internally. +#### Step 0: Pre-fetch ticket (and PR) content + +Before writing the prompt file, fetch the ticket title, body, and comments so they can be embedded directly into the `## Ticket` section. This avoids the launched Claude Code session sitting on a `dangerouslyDisableSandbox` permission prompt at startup when it tries to run `gh issue view` itself (issue #295). + +Use `dangerouslyDisableSandbox: true` for all of these fetches. Issue each as a **single, clean invocation** β€” one `gh`/`git` command per Bash call, with no `cd …`/`echo`/`find` prefix and no `| head`/`| tail` pipe. The permission allowlist prefix-matches the *whole* command, so bundling defeats the `Bash(gh issue view:*)` / `Bash(gh api:*)` rules and forces a prompt (see CLAUDE.md β†’ "Fetching Ticket / PR Data"). + +**GitHub:** +```bash +gh issue view {ticket_url} --comments +# Fallback if the above returns empty output (see issue #295): +gh api repos/{owner}/{repo}/issues/{number} +gh api repos/{owner}/{repo}/issues/{number}/comments +``` + +**GitLab (non-default host):** +```bash +GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments +``` + +**Jira (Atlassian MCP β€” task-only):** +Fetch the work item via the `atlassian` MCP server: resolve `cloudId` with +`getAccessibleAtlassianResources`, then `getJiraIssue` for `{key}` (full key, e.g. +`MAXX-6859` β€” not the numeric suffix). Use the work item's summary as +`{ticket_title}`. The code provider/PR detection below still runs against the +workspace's configured GitHub/GitLab repo, not Jira. (Fallback when MCP is +unconfigured: `acli jira workitem view {key} --json`, summary at `.fields.summary`.) + +**If an existing PR was detected for this ticket**, also fetch the PR view so it can be embedded: +```bash +gh pr view {pr_url} --comments +# Fallback: +gh api repos/{owner}/{repo}/pulls/{pr_number} +gh api repos/{owner}/{repo}/issues/{pr_number}/comments +``` + +**Also detect the repo's default branch** so the worktree base ref and the prompt template's PR/MR commands aren't pinned to `main` (issue #397). One clean call per repo: + +```bash +gh repo view {owner}/{repo} --json defaultBranchRef --jq .defaultBranchRef.name +# GitLab equivalent: +glab repo view {org}/{repo} -F json | jq -r .default_branch +``` + +Store the result as `{base_branch}` for substitution into Step 2 and into the prompt template. `setup.sh` will auto-detect from `origin/HEAD` if you omit the flag, but passing it explicitly avoids the round-trip and keeps the prompt template accurate. + +Render the fetched content into the prompt template per the formatting rules in **First Prompt Template** below. + #### Step 1: Write the prompt file -The LLM writes the prompt content (see template below) to a file: +The LLM writes the prompt content (see template below β€” with the pre-fetched ticket/PR content embedded) to a file: ```bash mkdir -p {devRoot}/.claude/prompts @@ -220,7 +292,7 @@ PROMPT #### Step 2: Run setup.sh -`setup.sh` resolves the coding agent automatically from `{devRoot}/.claude/config.json`: it prefers `agentsByKind["work"]`, falls back to `defaultAgentKind`, and finally to `claude-code`. Pass `--agent-kind ` only if you need to override the configured choice for a single invocation. Use `--agent-binary ` to pin the binary explicitly. +`setup.sh` resolves the coding agent automatically from `{devRoot}/.claude/config.json`: it prefers `agentsByKind["work"]`, falls back to `defaultAgentKind`, and finally to `claude-code`. Pass `--agent-kind ` only if you need to override the configured choice for a single invocation (e.g. for testing). The binary is looked up on PATH and in the standard install locations for the selected agent β€” pass `--agent-binary ` to pin it explicitly. ```bash .claude/skills/crow-workspace/setup.sh \ @@ -231,6 +303,7 @@ PROMPT --slug "{slug}" \ --branch "{branch}" \ --worktree-path "{worktree_path}" \ + --base-branch "{base_branch}" \ --session-name "{session_name}" \ --provider "{provider}" \ --cli "{cli}" \ @@ -285,6 +358,8 @@ For cross-workspace setups with multiple repos, call `setup.sh` once per repo: 1. **First repo** (primary): Use `--primary` flag. Capture `session_id` from output. 2. **Additional repos**: Pass `--session-id {uuid}` from the first call's output and use `--skip-launch` (Claude only launches once, in the primary worktree). +Detect each secondary repo's default branch with the same `gh repo view --json defaultBranchRef` call from Step 0 β€” each repo has its own (bigbang uses `master`, others use `main`). + ```bash # Secondary repo (no launch, attach to existing session): .claude/skills/crow-workspace/setup.sh \ @@ -294,7 +369,8 @@ For cross-workspace setups with multiple repos, call `setup.sh` once per repo: --repo "{other_repo}" \ --repo-path "{other_repo_path}" \ --slug "{slug}" \ - --branch "main" \ + --branch "{other_base_branch}" \ + --base-branch "{other_base_branch}" \ --worktree-path "{other_repo_path}" \ --session-name "{session_name}" \ --provider "{provider}" \ @@ -323,48 +399,108 @@ Extract dynamically from first non-heading line of CLAUDE.md or README.md. If no ## Ticket -IMPORTANT: All gh/glab commands MUST use dangerouslyDisableSandbox: true. They will fail with TLS certificate errors otherwise. Do NOT attempt sandboxed first. +**{ticket_title}** β€” {ticket_url} + +{ticket_body_verbatim} + +### Comments + +{rendered_comments_or_no_comments_marker} + +--- + +If you need fresher ticket data later, re-fetch with (all gh/glab commands MUST use dangerouslyDisableSandbox: true β€” they will fail with TLS certificate errors otherwise, and will prompt for approval): ```bash -gh issue view https://github.com/org/repo/issues/123 --comments +gh issue view {ticket_url} --comments +# Fallback if the above returns empty output (see issue #295): +gh api repos/{owner}/{repo}/issues/{number} +gh api repos/{owner}/{repo}/issues/{number}/comments ``` ## Instructions -1. Study the ticket thoroughly β€” use dangerouslyDisableSandbox: true for ALL gh/glab commands +1. Study the ticket above β€” it has been pre-fetched and embedded. Only re-run gh/glab if you need fresher data; those calls use dangerouslyDisableSandbox: true and will prompt for approval. 2. Create an implementation plan +3. Implement the plan +4. Commit the changes with a descriptive message +5. Push the branch to origin +6. Open a pull request linked to the ticket: + +```bash +gh pr create --title "" --body "Closes #123" --base {base_branch} +``` + +## Custom Instructions + +{workspace customInstructions text β€” include verbatim} ~~~ -**When an existing PR was detected**, add this section to the prompt between `## Ticket` and `## Instructions`: +If the workspace config contains a non-empty `customInstructions` field, append a `## Custom Instructions` section at the end of the prompt with its contents verbatim. Omit this section entirely if the field is absent, null, or empty. + +For GitLab tickets, substitute `glab mr create --title "" --description "Closes #{number}" --target-branch {base_branch}` on step 6 (use "merge request" instead of "pull request"). When no ticket number is available, drop the body/description and fall back to `gh pr create --fill` / `glab mr create --fill`. + +### Embedding pre-fetched content + +Render the content pre-fetched in **Step 0** directly into the template: + +- **Ticket body**: insert verbatim β€” preserve markdown, code fences, line breaks. Do not summarize or reformat. +- **Comments**: for each comment, render as `**@{login}** ({created_at}):` followed by the comment body, separated by `---` between comments. If there are zero comments, write `_No comments._` in the `### Comments` block. +- **PR body / PR review comments** (existing-PR variant): same rules as ticket body/comments. + +If the pre-fetch fails (network, auth, rate limit), fall back to the prior behavior: leave a short `_(Ticket pre-fetch failed β€” run the gh command below to retrieve.)_` note in place of the embedded body and proceed. The launched Claude can re-fetch with the documented commands. + +**When an existing PR was detected**, add this section to the prompt between `## Ticket` and `## Instructions` (with PR content also pre-fetched in Step 0 and embedded): ~~~markdown ## Existing Pull Request -There is an existing open PR for this issue. Review it before planning: +**{pr_title}** β€” {pr_url} + +{pr_body_verbatim} + +### PR Comments + +{rendered_pr_comments_or_no_comments_marker} + +--- + +This workspace is checked out on the PR's branch. Review existing changes with `git log origin/{base_branch}..HEAD` before adding new work. + +If you need fresher PR data later, re-fetch with (dangerouslyDisableSandbox: true): ```bash gh pr view {pr_url} --comments +# Fallback if the above returns empty: +gh api repos/{owner}/{repo}/pulls/{pr_number} +gh api repos/{owner}/{repo}/issues/{pr_number}/comments ``` - -This workspace is checked out on the PR's branch. Review existing changes with `git log origin/main..HEAD` before adding new work. ~~~ And update the Instructions section to: ~~~markdown ## Instructions -1. Review the existing PR and its changes β€” use dangerouslyDisableSandbox: true for ALL gh/glab commands -2. Study the ticket thoroughly -3. Create an implementation plan that builds on the existing work +1. Review the existing PR and ticket above β€” both have been pre-fetched and embedded. Use `git log origin/{base_branch}..HEAD` to see the current branch's changes. Only re-run gh/glab if you need fresher data; those calls use dangerouslyDisableSandbox: true and will prompt for approval. +2. Create an implementation plan that builds on the existing work +3. Implement the plan +4. Commit the changes with a descriptive message +5. Push the branch β€” this updates the existing PR automatically; do NOT open a new one ~~~ -For MyGitLab, add: `4. If any changes to my-project are required, create a new worktree with a feature branch before making modifications` +For MyGitLab, add: `6. If any changes to my-project are required, create a new worktree with a feature branch before making modifications` ### CLI Commands for Fetching Issues +The manager uses these in **Step 0** to pre-fetch ticket and PR content. They are also documented in the prompt so the launched Claude can re-run them for fresher data. + **GitHub:** ```bash gh issue view {url} --comments gh pr view {url} --comments +# Fallbacks if the above return empty (see issue #295): +gh api repos/{owner}/{repo}/issues/{number} +gh api repos/{owner}/{repo}/issues/{number}/comments +gh api repos/{owner}/{repo}/pulls/{pr_number} ``` **GitLab (non-default host like gitlab.example.com):** @@ -389,8 +525,7 @@ If `setup.sh` returns a JSON error (`"status": "error"`): | `git_worktree_add` β€” worktree creation failed | Branch may exist; script auto-retries after cleanup | | `new_session` β€” crow new-session failed | Crow app may not be running. Inform user. | | `add_worktree` β€” crow add-worktree failed | Use full UUID from session, check paths | -| `new_terminal` β€” crow new-terminal failed | Session may not exist; check session_id | -| `send_launch` β€” crow send failed | Terminal may not be ready; retry | +| `new_terminal` β€” crow new-terminal failed / could not create window | Session may not exist (check session_id), or tmux couldn't spawn a window under load | | `write_prompt` β€” prompt file not found | Verify prompt was written before calling setup.sh | ## crow CLI Reference diff --git a/Resources/crow-workspace-setup.sh.template b/Resources/crow-workspace-setup.sh.template index b8b488d1..36e0d7cc 100755 --- a/Resources/crow-workspace-setup.sh.template +++ b/Resources/crow-workspace-setup.sh.template @@ -46,6 +46,27 @@ BASE_BRANCH="" # Runtime state TERMINAL_ID="" +# Resolved AI gateway for this workspace (populated by resolve_gateway_env). +WS_BASE_URL="" +WS_CUSTOM_HEADERS="" +WS_HAS_GATEWAY=false +WS_GATEWAY_RESOLVED=false + +# Resolved task provider for this workspace (populated by resolve_task_provider). +# The CODE provider lives in $PROVIDER (github/gitlab); the TASK provider may +# differ (e.g. github code + jira tasks). Empty until resolved; falls back to +# $PROVIDER. Used to skip GitHub issue housekeeping for Jira tickets (CROW-522). +TASK_PROVIDER="" +TASK_PROVIDER_RESOLVED=false + +# Resolved Atlassian MCP server for this workspace (populated by +# resolve_atlassian_mcp_env). WS_MCP_AUTH carries the full `Basic …` header +# value; it is written only into the owner-only settings.local.json env block. +WS_MCP_URL="" +WS_MCP_AUTH="" +WS_HAS_MCP=false +WS_MCP_RESOLVED=false + # ─── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[setup.sh] $*" >&2; } @@ -141,6 +162,135 @@ agent_display_name() { esac } +# Resolve this workspace's AI gateway from {devRoot}/.claude/config.json (CROW-402). +# Populates WS_BASE_URL / WS_CUSTOM_HEADERS and sets WS_HAS_GATEWAY=true when a +# gateway is configured. Header values prefixed `op://` are resolved via the +# 1Password CLI (`op read`); any other value is used literally. Idempotent β€” the +# expensive `op read` only runs once. Never logs the resolved header values. +resolve_gateway_env() { + [[ "$WS_GATEWAY_RESOLVED" == true ]] && return 0 + WS_GATEWAY_RESOLVED=true + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping gateway resolution"; return 0; } + + local gateway + gateway=$(jq -c --arg name "$WORKSPACE" \ + '.workspaces[]? | select(.name == $name) | .gateway // empty' \ + "$config_path" 2>/dev/null) || return 0 + [[ -n "$gateway" && "$gateway" != "null" ]] || return 0 + + local base_url + base_url=$(jq -r '.baseURL // ""' <<< "$gateway") + [[ -n "$base_url" ]] || return 0 + + # Resolve each header value and join as newline-separated "Name: Value". + local headers="" name value resolved + while IFS= read -r name; do + [[ -n "$name" ]] || continue + value=$(jq -r --arg k "$name" '.customHeaders[$k]' <<< "$gateway") + if [[ "$value" == op://* ]]; then + if ! resolved=$(op read "$value" 2>/dev/null); then + log "Gateway: failed to resolve secret reference for header '$name' (op read failed); dropping it" + continue + fi + value="$resolved" + fi + [[ -n "$headers" ]] && headers+=$'\n' + headers+="$name: $value" + done < <(jq -r '.customHeaders | keys[]' <<< "$gateway" 2>/dev/null) + + WS_BASE_URL="$base_url" + WS_CUSTOM_HEADERS="$headers" + WS_HAS_GATEWAY=true + log "Gateway: routing this workspace through $base_url" +} + +# Populate TASK_PROVIDER from this workspace's config.json entry (CROW-522). +# Mirrors AppConfig.derivedTaskProvider: the explicit `taskProvider` when set, +# otherwise the code `provider`. Falls back to $PROVIDER when config/jq is +# unavailable. Idempotent. +resolve_task_provider() { + [[ "$TASK_PROVIDER_RESOLVED" == true ]] && return 0 + TASK_PROVIDER_RESOLVED=true + TASK_PROVIDER="$PROVIDER" + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + local tp + tp=$(jq -r --arg name "$WORKSPACE" \ + '.workspaces[]? | select(.name == $name) | (.taskProvider // .provider) // empty' \ + "$config_path" 2>/dev/null) || return 0 + [[ -n "$tp" && "$tp" != "null" ]] && TASK_PROVIDER="$tp" +} + +# Populate WS_MCP_URL / WS_MCP_AUTH and set WS_HAS_MCP=true when this workspace +# uses Jira tasks AND an Atlassian MCP credential is configured (CROW-522). +# Builds the HTTP Basic Authorization header from the account email + API token +# (`op://` token references resolved via `op read`). Idempotent β€” the expensive +# `op read` only runs once. Never logs the resolved token or header. +resolve_atlassian_mcp_env() { + [[ "$WS_MCP_RESOLVED" == true ]] && return 0 + WS_MCP_RESOLVED=true + + resolve_task_provider + [[ "$TASK_PROVIDER" == "jira" ]] || return 0 + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping Atlassian MCP resolution"; return 0; } + + local mcp + mcp=$(jq -c '.atlassianMCP // empty' "$config_path" 2>/dev/null) || return 0 + [[ -n "$mcp" && "$mcp" != "null" ]] || return 0 + + local endpoint email token_ref + endpoint=$(jq -r '.endpoint // "https://mcp.atlassian.com/v1/mcp"' <<< "$mcp") + email=$(jq -r '.email // ""' <<< "$mcp") + token_ref=$(jq -r '.tokenRef // ""' <<< "$mcp") + [[ -n "$email" && -n "$token_ref" ]] || { log "Atlassian MCP: email/token not set; skipping"; return 0; } + + local token="$token_ref" + if [[ "$token_ref" == op://* ]]; then + if ! token=$(op read "$token_ref" 2>/dev/null); then + log "Atlassian MCP: failed to resolve API token reference (op read failed); skipping MCP injection" + return 0 + fi + fi + + # base64 of "email:token" with no embedded newline (base64 wraps by default). + local b64 + b64=$(printf '%s' "$email:$token" | base64 | tr -d '\n') + WS_MCP_URL="$endpoint" + WS_MCP_AUTH="Basic $b64" + WS_HAS_MCP=true + log "Atlassian MCP: injecting $endpoint for this Jira workspace" +} + +# Build the shell prefix that applies (or clears) the gateway env vars on the +# `claude` launch line β€” mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. +# Gateway absent β†’ `unset … && ` so a no-gateway workspace doesn't inherit a +# sibling's or ~/.zshrc's gateway. Single header β†’ `ANTHROPIC_BASE_URL='…' +# ANTHROPIC_CUSTOM_HEADERS='…' `. Multi-header β†’ the header value has an embedded +# newline and can't go on the line (a pasted newline would submit the command +# early), so settings.local.json carries it; we still `unset ANTHROPIC_CUSTOM_HEADERS` +# so the gateway's baseURL is never paired with a stale ~/.zshrc-inherited header. +gateway_launch_prefix() { + if [[ "$WS_HAS_GATEWAY" != true ]]; then + printf 'unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ' + return 0 + fi + if [[ "$WS_CUSTOM_HEADERS" == *$'\n'* ]]; then + printf 'unset ANTHROPIC_CUSTOM_HEADERS && ANTHROPIC_BASE_URL=%s ' "$(posix_quote "$WS_BASE_URL")" + return 0 + fi + printf 'ANTHROPIC_BASE_URL=%s ANTHROPIC_CUSTOM_HEADERS=%s ' \ + "$(posix_quote "$WS_BASE_URL")" "$(posix_quote "$WS_CUSTOM_HEADERS")" +} + die() { local step="$1" msg="$2" local partial="" @@ -376,12 +526,16 @@ create_session() { log "Using existing session: $SESSION_ID" fi - # Step 2: Set ticket metadata (if URL provided) - if [[ -n "$TICKET_URL" && -n "$TICKET_NUMBER" ]]; then + # Step 2: Set ticket metadata (if URL provided). The number is optional β€” + # Jira keys (e.g. MAXX-6846) have no standalone numeric id, so gate on the + # URL and add --number only when it's actually numeric. (crow set-ticket + # accepts --url/--title without --number; a non-numeric --number would make + # ArgumentParser reject the whole call and drop url+title too.) + if [[ -n "$TICKET_URL" ]]; then log "Setting ticket metadata..." local ticket_args=(crow set-ticket --session "$SESSION_ID" --url "$TICKET_URL") [[ -n "$TICKET_TITLE" ]] && ticket_args+=(--title "$TICKET_TITLE") - ticket_args+=(--number "$TICKET_NUMBER") + [[ "$TICKET_NUMBER" =~ ^[0-9]+$ ]] && ticket_args+=(--number "$TICKET_NUMBER") "${ticket_args[@]}" >/dev/null 2>&1 \ || log "Warning: set-ticket failed (may already be set)" fi @@ -424,17 +578,33 @@ create_session() { # Write a per-worktree .claude/settings.local.json that overrides Claude Code's # attribution.commit so commits include a `Crow-Session: ` trailer -# alongside the standard `Co-Authored-By: Claude` line. Runs for every -# worktree (primary and secondary) regardless of --skip-launch, so any worktree -# the user later opens with Claude Code picks up the override. +# alongside the standard `Co-Authored-By: Claude` line, and β€” when this workspace +# has an AI gateway (CROW-402) β€” an `env` block so manual `claude` re-runs in the +# terminal inherit the gateway. Runs for every worktree (primary and secondary) +# regardless of --skip-launch, so any worktree the user later opens with Claude +# Code picks up both overrides. write_settings_local() { - if ! is_attribution_trailers_enabled; then - log "Attribution trailers disabled via config; skipping settings.local.json" + # Resolve the gateway and Atlassian MCP first so their blocks are written even + # when attribution trailers are disabled. + resolve_gateway_env + resolve_atlassian_mcp_env + + local want_attribution=false + if is_attribution_trailers_enabled && [[ -n "$SESSION_ID" ]]; then + want_attribution=true + elif [[ -z "$SESSION_ID" ]]; then + log "Warning: SESSION_ID not set, skipping attribution trailer" + else + log "Attribution trailers disabled via config" + fi + + if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true && "$WS_HAS_MCP" != true ]]; then + log "No attribution trailer, gateway, or MCP to write; skipping settings.local.json" return fi - if [[ -z "$SESSION_ID" ]]; then - log "Warning: SESSION_ID not set, skipping settings.local.json" + if ! command -v jq >/dev/null 2>&1; then + log "jq not found; skipping settings.local.json" return fi @@ -454,17 +624,51 @@ write_settings_local() { local display_name display_name=$(agent_display_name "$resolved_kind") - # The newlines inside the "commit" string are literal \n escapes in JSON; - # the heredoc passes them through to the file as the two-character sequence. - # Heredoc is unquoted so bash expands $display_name and $SESSION_ID. - cat > "$settings_path" <\\nCrow-Session: $SESSION_ID" - } -} -EOF - log "Wrote attribution settings to $settings_path (agent: $display_name)" + # Merge into existing settings (preserving hooks etc.) via jq, which handles + # JSON escaping of the newlines in the commit trailer and the header values. + local base="{}" + [[ -f "$settings_path" ]] && base=$(cat "$settings_path") + + local commit_trailer="πŸ¦β€β¬› Generated with $display_name, orchestrated by Crow + +Co-Authored-By: Claude +Crow-Session: $SESSION_ID" + + local merged + if ! merged=$(jq \ + --argjson want_attr "$want_attribution" \ + --arg commit "$commit_trailer" \ + --argjson want_gw "$WS_HAS_GATEWAY" \ + --arg base_url "$WS_BASE_URL" \ + --arg headers "$WS_CUSTOM_HEADERS" \ + --argjson want_mcp "$WS_HAS_MCP" \ + --arg mcp_auth "$WS_MCP_AUTH" \ + '(if $want_attr then .attribution.commit = $commit else . end) + | (if $want_gw then .env.ANTHROPIC_BASE_URL = $base_url + | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end) + | (if $want_mcp then .env.ATLASSIAN_MCP_AUTHORIZATION = $mcp_auth + | .enabledMcpjsonServers = ((.enabledMcpjsonServers // []) + + ["atlassian"] | unique) else . end)' \ + <<< "$base"); then + die "settings_local" "jq failed to build settings.local.json" + fi + printf '%s\n' "$merged" > "$settings_path" + # The env block can carry a resolved bearer token / MCP credential, so restrict + # the file to owner-only β€” matching ConfigStore's 0600 on config.json. + chmod 600 "$settings_path" 2>/dev/null || true + + # CROW-522: write a project-root .mcp.json registering the Atlassian Remote MCP + # Server. The Authorization header references the env var set above via ${…} + # expansion, so the secret stays only in the owner-only settings.local.json. + if [[ "$WS_HAS_MCP" == true ]]; then + write_mcp_json + fi + + if [[ "$WS_HAS_GATEWAY" == true || "$WS_HAS_MCP" == true ]]; then + log "Wrote settings.local.json (attribution + gateway/MCP env) to $settings_path (agent: $display_name)" + else + log "Wrote attribution settings to $settings_path (agent: $display_name)" + fi # Belt-and-suspenders: add the file to the per-worktree git exclude so it # is never accidentally committed even if the repo's .gitignore does not @@ -478,6 +682,37 @@ EOF if ! grep -qxF '.claude/settings.local.json' "$exclude_file" 2>/dev/null; then printf '\n# Added by crow setup.sh\n.claude/settings.local.json\n' >> "$exclude_file" fi + # CROW-522: the Atlassian MCP .mcp.json sits at the worktree root; exclude it + # too so a Jira workspace's MCP registration is never accidentally committed. + if [[ "$WS_HAS_MCP" == true ]] && ! grep -qxF '.mcp.json' "$exclude_file" 2>/dev/null; then + printf '.mcp.json\n' >> "$exclude_file" + fi +} + +# Write a project-root .mcp.json registering the Atlassian Remote MCP Server for +# this Jira workspace (CROW-522). Merges into an existing file, preserving any +# user-defined servers. The Authorization header is a ${…} reference to the env +# var in settings.local.json β€” the resolved secret never lands in this file. +write_mcp_json() { + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping .mcp.json"; return 0; } + local mcp_path="$WORKTREE_PATH/.mcp.json" + local base="{}" + [[ -f "$mcp_path" ]] && base=$(cat "$mcp_path") + + local merged + if ! merged=$(jq \ + --arg url "$WS_MCP_URL" \ + '.mcpServers.atlassian = { + "type": "http", + "url": $url, + "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } + }' \ + <<< "$base"); then + log "Warning: jq failed to build .mcp.json; skipping" + return 0 + fi + printf '%s\n' "$merged" > "$mcp_path" + log "Wrote .mcp.json (Atlassian MCP) to $mcp_path" } # ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── @@ -639,6 +874,16 @@ github_ops() { if [[ "$PROVIDER" != "github" ]]; then return fi + # CROW-522: a GitHub-code workspace can track its tasks in Jira. In that case + # $TICKET_URL is a Jira browse URL, not a GitHub issue β€” running `gh issue + # edit`/project-status against it just logs `auto-assign failed`. Skip GitHub + # issue housekeeping entirely when the task provider is Jira (assignment + + # status now happen via the Atlassian MCP in-session). + resolve_task_provider + if [[ "$TASK_PROVIDER" == "jira" ]]; then + log "Task provider is Jira; skipping GitHub issue auto-assign/project-status" + return + fi # Auto-assign if [[ "$SKIP_ASSIGN" != "true" && -n "$TICKET_URL" ]]; then @@ -824,7 +1069,17 @@ launch_claude_code() { rc_args=" --rc --name $(posix_quote "$SESSION_NAME")" log "Remote control enabled β€” launching with --rc --name '$SESSION_NAME'" fi - local launch_cmd="cd $WORKTREE_PATH && $bin --permission-mode plan$rc_args \"\$(cat $prompt_path)\"" + # CROW-402: prefix the launch line with the workspace gateway env (or `unset` + # when there's none) so the deferred launch overrides any global ~/.zshrc + # export. resolve_gateway_env is idempotent (it already ran in + # write_settings_local), so this reuses its result without a second `op read`. + # The assignments are intentionally not logged (the header value is a bearer + # token). Placed immediately before $bin so the command-prefix + # assignments bind to claude. + resolve_gateway_env + local gw_prefix + gw_prefix=$(gateway_launch_prefix) + local launch_cmd="cd $WORKTREE_PATH && ${gw_prefix}$bin --permission-mode plan$rc_args \"\$(cat $prompt_path)\"" create_agent_terminal "Claude Code" "$launch_cmd" } @@ -999,4 +1254,8 @@ main() { emit_result } -main "$@" +# Only run when executed directly β€” sourcing (e.g. from tests) exposes the +# functions without kicking off a full workspace setup. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index 1a8e4f23..95010b83 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -485,6 +485,8 @@ struct Scaffolder { "Bash(acli jira workitem edit:*)", "Bash(acli jira workitem create:*)", "Bash(acli jira auth status:*)", + "mcp__atlassian", + "mcp__atlassian__*", "Bash(git -C:*)", "Write(.claude/prompts/**)", "Bash(git fetch:*)", diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index fc478a82..e855f821 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -560,6 +560,15 @@ final class SessionService { ClaudeHookConfigWriter.writeGatewayEnv( dirPath: worktree.worktreePath, resolved: gatewayResolved) gatewayPrefix = ClaudeLaunchArgs.gatewayEnvPrefix(gatewayResolved) + + // CROW-522: inject the Atlassian Remote MCP Server for Jira-task + // worktrees so the session creates/assigns/transitions/fetches work + // items via MCP instead of acli. Always called (resolved or nil) so a + // non-Jira workspace β€” or one with the MCP unconfigured β€” clears any + // stale .mcp.json/env. + ClaudeHookConfigWriter.writeAtlassianMcpConfig( + dirPath: worktree.worktreePath, + resolved: workspaceAtlassianMCPResolved(for: sessionID)) } let rcEnabled = appState.remoteControlEnabled @@ -797,6 +806,36 @@ final class SessionService { private func writeManagerGatewayEnv() { guard let devRoot = ConfigStore.loadDevRoot() else { return } ClaudeHookConfigWriter.writeGatewayEnv(dirPath: devRoot, resolved: managerGatewayResolved()) + // CROW-522: the Manager (and cron jobs running at devRoot) get the + // Atlassian MCP whenever it's configured, since the Manager isn't bound + // to a single workspace. + ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: devRoot, resolved: atlassianMCPResolved()) + } + + /// Resolve the org-wide Atlassian MCP credential (`AppConfig.atlassianMCP`) + /// into a launch-ready Authorization header, or nil when unset/empty + /// (CROW-522). Used for the Manager and cron sessions at devRoot. + private func atlassianMCPResolved() -> AtlassianMCPResolver.Resolved? { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot), + let mcp = config.atlassianMCP, !mcp.isEmpty + else { return nil } + return AtlassianMCPResolver.resolve(mcp) + } + + /// Resolve the Atlassian MCP credential for a non-Manager session, gated on + /// the worktree's workspace using Jira as its task provider (CROW-522). + /// Returns nil for non-Jira workspaces or when the MCP is unconfigured. + private func workspaceAtlassianMCPResolved(for sessionID: UUID) -> AtlassianMCPResolver.Resolved? { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot), + let mcp = config.atlassianMCP, !mcp.isEmpty, + let worktree = appState.primaryWorktree(for: sessionID), + let wsName = Self.workspaceName(forWorktreePath: worktree.worktreePath, devRoot: devRoot), + let workspace = config.workspaces.first(where: { $0.name == wsName }), + workspace.derivedTaskProvider == "jira" + else { return nil } + return AtlassianMCPResolver.resolve(mcp) } /// Resolve the Manager's own AI gateway (`AppConfig.managerGateway`) from @@ -856,6 +895,9 @@ final class SessionService { // manual `claude` re-runs in this terminal inherit the same routing. The // Manager's cwd is the devRoot. ClaudeHookConfigWriter.writeGatewayEnv(dirPath: cwd, resolved: managerGatewayResolved()) + // CROW-522: inject the Atlassian MCP into the Manager terminal's devRoot + // so the Manager can drive Jira via MCP (headless). + ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: cwd, resolved: atlassianMCPResolved()) let rawTerminal = SessionTerminal( sessionID: session.id, name: session.name, diff --git a/docs/automation.md b/docs/automation.md index 54f9bb2d..bea4ea63 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -41,6 +41,22 @@ PR #228 split every automation toggle out of General into its own tab. Open **Se Turn this off if your account reports auto mode as unavailable. Worker sessions and CLI-spawned terminals are unaffected. Takes effect on next app launch β€” the Manager's stored command is rebuilt on hydration. +### Atlassian MCP (Jira) + +`acli` cannot set a Jira assignee (at create or after) and its transitions are unreliable, so every ticket Crow filed landed unassigned. PR #522 routes the **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, link) through the official **Atlassian Remote MCP Server** instead. Crow's in-app issue-board polling and auto-complete still use `acli` (that path works); only the agent flow moved. + +- **Atlassian MCP (Jira)** β€” set the endpoint (defaults to `https://mcp.atlassian.com/v1/mcp`), your Atlassian account **email**, and an **API token** (`op://` reference recommended, or plaintext). Crow builds an HTTP Basic `Authorization` header and injects the MCP server into launched Jira-task sessions, the Manager, and cron jobs. + +How injection works: for a session whose workspace uses `taskProvider: "jira"` (and the Manager/cron at the dev root), Crow writes a project-root `.mcp.json` registering an `http` server named `atlassian`, pre-trusts it via `enabledMcpjsonServers` in `.claude/settings.local.json`, and stores the resolved `Authorization` value in that file's `env` block (`ATLASSIAN_MCP_AUTHORIZATION`, chmod `0600`) β€” the `.mcp.json` header is a `${…}` reference, so no secret lands in it. The launched agent (and the migrated `/crow-create-ticket`, `/crow-workspace`, `/crow-batch-workspace` skills) use the MCP tools β€” `getJiraIssue`, `createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, `lookupJiraAccountId` β€” instead of `acli`. Backed by `AppConfig.atlassianMCP`. `gh`/`glab` GitHub/GitLab task paths are untouched. + + +**Headless auth β€” one-time setup.** The Manager and cron jobs run without a browser, so they use API-token auth (no OAuth consent screen). Two prerequisites: + +1. An Atlassian **org admin enables API-token auth for the Rovo MCP Server** (organization security settings β†’ Rovo MCP Server). Without this, calls return 401. +2. Create a **personal API token** at β†’ Security β†’ API tokens, then store it in **Settings β†’ Automation β†’ Atlassian MCP (Jira)** as an `op://` reference (preferred) or plaintext. + +Crow sends `Authorization: Basic base64(email:token)`. (A service-account `Bearer` key works too if you'd rather not attribute tickets to a personal account, but the Settings UI is geared to the personal-token Basic flow.) + ### Auto-respond PR #214 added two opt-in toggles that let Crow type a follow-up instruction into a session's Claude Code terminal when a PR signal arrives. Both are off by default β€” typing into a running terminal unprompted is intrusive. diff --git a/docs/configuration.md b/docs/configuration.md index 2fa89ac3..237a2e00 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,6 +125,37 @@ The Manager session sits at the dev root and isn't bound to a single workspace, Same shape, same secret-storage rules, same two-way injection (written to `{devRoot}/.claude/settings.local.json`). Configure it under **Settings β†’ Automation β†’ Manager AI Gateway**. Takes effect on the next app launch. +## Atlassian MCP (Jira) + +For workspaces with `taskProvider: "jira"`, Crow drives the **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, link) through the official **Atlassian Remote MCP Server** instead of `acli`. `acli` cannot set an assignee at create time, so every ticket it filed landed unassigned; the MCP `createJiraIssue` tool sets the assignee in one step. (Crow's in-app issue-board polling and auto-complete still use `acli` β€” only the agent flow moved.) + +Configure one org-wide credential under **Settings β†’ Automation β†’ Atlassian MCP (Jira)**. It is stored top-level in `config.json`: + +```jsonc +{ + "atlassianMCP": { + "endpoint": "https://mcp.atlassian.com/v1/mcp", + "email": "you@example.com", + // op:// reference β€” resolved at launch via the 1Password CLI; kept out of config.json + "tokenRef": "op://Private/Atlassian/api_token" + } +} +``` + +- **Auth** is a **personal API token** (from ) sent as HTTP Basic β€” Crow builds `Authorization: Basic base64(email:token)` at launch. +- **`tokenRef`** follows the same secret rules as gateway keys: an `op://` reference (recommended β€” never written to `config.json`) or a plaintext token (stored `0600`, with a UI warning). The resolved `Authorization` value is cached in the session's owner-only `.claude/settings.local.json` `env` block (`ATLASSIAN_MCP_AUTHORIZATION`) and never logged. + +When set, Crow injects the server into launched Jira-task sessions (and the Manager + cron jobs at the dev root) by writing a project-root `.mcp.json` and pre-trusting it via `enabledMcpjsonServers`: + +```jsonc +// {worktree-or-devRoot}/.mcp.json β€” the Authorization references the env var, so no secret lands here +{ "mcpServers": { "atlassian": { "type": "http", + "url": "https://mcp.atlassian.com/v1/mcp", + "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } } } } +``` + +> **Prerequisite:** an Atlassian **org admin must enable API-token auth for the Rovo MCP Server** for your org, otherwise the headless calls return 401. See [docs/automation.md](automation.md#atlassian-mcp-headless-auth) for the one-time setup. `gh`/`glab` GitHub/GitLab task paths are unaffected. + ## Manager Terminal The Manager tab runs Claude Code at the dev root and drives workspace orchestration. Its behavior is controlled by these top-level keys in `{devRoot}/.claude/config.json`: diff --git a/skills/crow-batch-workspace/SKILL.md b/skills/crow-batch-workspace/SKILL.md index 8c7b1a30..dc481539 100644 --- a/skills/crow-batch-workspace/SKILL.md +++ b/skills/crow-batch-workspace/SKILL.md @@ -79,7 +79,7 @@ For each workspace spec, perform the same resolution as `/crow-workspace`: 5. **Fetch ticket** (provider-specific, with `dangerouslyDisableSandbox: true`): - GitHub: `gh issue view {url} --json title,body,labels` - GitLab: `GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments` - - Jira (task-only): `acli jira workitem view {key} --json` (title at `.fields.summary`) + - Jira (task-only): fetch via the `atlassian` MCP server (`getAccessibleAtlassianResources` β†’ `getJiraIssue {key}`); title is the work item summary. (Fallback when MCP unconfigured: `acli jira workitem view {key} --json`, title at `.fields.summary`.) 6. **Check for existing PR**: `gh pr list --repo {owner}/{repo} --search "{issue_number}" --state open --json number,title,headRefName,url --limit 5` (with `dangerouslyDisableSandbox: true`). For a Jira-task session this runs against the workspace's configured GitHub/GitLab code repo, not Jira. 7. **Generate names**: slug, branch, worktree path, session name (following `/crow-workspace` naming conventions). **Jira:** resolve `{ticket_number}` as the **numeric suffix** of the key (`MAXX-6859` β†’ `6859`); `{ticket_url}` is the full `…/browse/{key}` URL; the slug uses the lowercased full key (`{repo}-maxx-6859-{slug}`). 8. **Compose prompt**: Use the First Prompt Template from `/crow-workspace` diff --git a/skills/crow-create-ticket/SKILL.md b/skills/crow-create-ticket/SKILL.md index 1b8f5d5f..863cfa0f 100644 --- a/skills/crow-create-ticket/SKILL.md +++ b/skills/crow-create-ticket/SKILL.md @@ -1,8 +1,11 @@ # Crow Create Ticket -Create a new ticket (GitHub issue via `gh`, or GitLab issue via `glab`) for a repo in -the current Crow workspace, assigned to the invoking user and labeled `crow:auto` so -Crow's auto-pickup queue implements it. +Create a new ticket (GitHub issue via `gh`, GitLab issue via `glab`, or Jira work item +via the **Atlassian MCP server**) for a repo/project in the current Crow workspace, +assigned to the invoking user and labeled `crow:auto`. For GitHub/GitLab, Crow's +auto-pickup queue then implements it. (Jira's auto-pickup isn't wired β€” the label is +for parity/board visibility β€” but the work item is created **with an assignee in one +step**, which `acli` could never do; CROW-522.) ## Important: Sandbox Bypass @@ -47,10 +50,15 @@ same file used by `/crow-workspace`. It maps each workspace to a provider/cli/ho ### Provider Detection -| Workspace `provider` | CLI | GITLAB_HOST | -|----------------------|------|----------------------| -| `github` | gh | β€” | -| `gitlab` | glab | workspace `host` | +| Workspace `taskProvider` | Tool | Notes | +|--------------------------|--------------|-----------------------------| +| `github` (or unset) | gh | β€” | +| `gitlab` | glab | workspace `host` | +| `jira` | Atlassian MCP | project `jiraProjectKey` | + +When a workspace sets `taskProvider: "jira"`, tasks live in Jira (code/PRs still on the +workspace's GitHub/GitLab repo). Use the Atlassian MCP tools instead of `gh`/`glab` for +the steps below. ## Instructions @@ -100,6 +108,10 @@ gh api user --jq .login GITLAB_HOST={host} glab api user --jq '.username' ``` +**Jira (Atlassian MCP):** resolve your own Atlassian `accountId` via the MCP β€” call +`atlassianUserInfo` (current user) or `lookupJiraAccountId`. You'll pass this +`accountId` as the assignee when creating the work item. + ### Step 4: Create the issue (assigned + labeled `crow:auto`) In both commands below, `BODY` is the user-provided body followed by the required @@ -130,6 +142,14 @@ GITLAB_HOST={host} glab issue create --repo {org/repo} \ --yes ``` +**Jira (Atlassian MCP):** call `createJiraIssue` with your resolved `cloudId` +(`getAccessibleAtlassianResources`), the workspace's `jiraProjectKey`, issue type +`Task`, the `TITLE` as summary, `BODY` (including the attribution footer) as +description, the `crow:auto` label, **and the assignee `accountId` from Step 3** so the +work item is created already assigned β€” the core fix in CROW-522. There is no +missing-label fallback to run (Step 5 is GitHub/GitLab-only); Jira accepts arbitrary +labels. + ### Step 4b: Attribution (REQUIRED) See `.claude/skills/crow-attribution/FOOTER.md` for the full rules. The body passed to @@ -143,7 +163,7 @@ followed by: - Crow filled in the agent name for this session before this skill reached you β€” paste the line literally; do not re-introduce `${…}` shell parameter expansion of your own (it silently fails inside single-quoted heredocs and the literal text leaks into the issue body). - Do not modify the URL β€” the link target is always `https://github.com/radiusmethod/crow`, never a fork or a derived value from the local git remote. - Do not wrap the line in additional formatting (no blockquote, no extra brackets, no surrounding text). -- This line MUST appear in every issue body, whether GitHub or GitLab, and whether or not the user supplied any body text. +- This line MUST appear in every issue body β€” GitHub, GitLab, or Jira β€” and whether or not the user supplied any body text. ### Step 5: Missing-label fallback @@ -172,6 +192,6 @@ the repo, assignee, and `crow:auto` label so they can confirm Crow will pick it - This skill only creates the ticket. Crow's auto-pickup queue (driven by the `crow:auto` label) is responsible for starting implementation β€” do not also set up a worktree or session here. Use `/crow-workspace` for that. -- Assignee is resolved dynamically (`gh api user` / `glab api user`); never hardcode a - login. +- Assignee is resolved dynamically (`gh api user` / `glab api user` / the Atlassian MCP + `atlassianUserInfo` accountId); never hardcode a login or accountId. - All `gh`, `glab`, and `git` commands require `dangerouslyDisableSandbox: true`. diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index 41dde853..80878bb3 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -92,20 +92,23 @@ GITLAB_HOST=gitlab.example.com glab issue view {number} --repo {org/repo} --comm GITLAB_HOST=gitlab.example.com glab mr view {number} --repo {org/repo} --comments ``` -### Jira (acli) -`acli` is already authenticated against a single Atlassian site, so it takes a -bare key (not a URL). Pass the full key (`PROJ-NNN`), not just the numeric suffix. -```bash -acli jira workitem view {key} --json # {key} = MAXX-6859 -``` -The summary is at `.fields.summary`; use it as `{ticket_title}`. +### Jira (Atlassian MCP) +Jira work items are fetched via the **official Atlassian Remote MCP Server**, not +`acli` (CROW-522). When a workspace is configured for Jira and the Atlassian MCP +credential is set in Settings β†’ Automation, Crow pre-registers and auto-trusts the +`atlassian` MCP server in the launched session. Resolve your `cloudId` once with +`getAccessibleAtlassianResources`, then call `getJiraIssue` for the full key +(`PROJ-NNN`, e.g. `MAXX-6859`). The summary field is the `{ticket_title}`. Use the +same MCP tools (`createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, +`lookupJiraAccountId`) for any create/assign/transition. (If the MCP isn't +configured in this environment, fall back to `acli jira workitem view {key} --json`.) ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | |---|---|---|---| | `github.com` | github | gh | - | -| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | acli | - | +| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | Atlassian MCP | - | | `gitlab.example.com` | gitlab | glab | gitlab.example.com | | `gitlab.com` | gitlab | glab | gitlab.com | | `gitlab-il2.example.com` | gitlab | glab | gitlab-il2.example.com | @@ -248,12 +251,13 @@ gh api repos/{owner}/{repo}/issues/{number}/comments GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments ``` -**Jira (acli β€” task-only):** -```bash -acli jira workitem view {key} --json # {key} = MAXX-6859 (full key, not the suffix) -``` -Use `.fields.summary` for `{ticket_title}`. The code provider/PR detection below -still runs against the workspace's configured GitHub/GitLab repo, not Jira. +**Jira (Atlassian MCP β€” task-only):** +Fetch the work item via the `atlassian` MCP server: resolve `cloudId` with +`getAccessibleAtlassianResources`, then `getJiraIssue` for `{key}` (full key, e.g. +`MAXX-6859` β€” not the numeric suffix). Use the work item's summary as +`{ticket_title}`. The code provider/PR detection below still runs against the +workspace's configured GitHub/GitLab repo, not Jira. (Fallback when MCP is +unconfigured: `acli jira workitem view {key} --json`, summary at `.fields.summary`.) **If an existing PR was detected for this ticket**, also fetch the PR view so it can be embedded: ```bash diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index 5e8509bd..36e0d7cc 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -52,6 +52,21 @@ WS_CUSTOM_HEADERS="" WS_HAS_GATEWAY=false WS_GATEWAY_RESOLVED=false +# Resolved task provider for this workspace (populated by resolve_task_provider). +# The CODE provider lives in $PROVIDER (github/gitlab); the TASK provider may +# differ (e.g. github code + jira tasks). Empty until resolved; falls back to +# $PROVIDER. Used to skip GitHub issue housekeeping for Jira tickets (CROW-522). +TASK_PROVIDER="" +TASK_PROVIDER_RESOLVED=false + +# Resolved Atlassian MCP server for this workspace (populated by +# resolve_atlassian_mcp_env). WS_MCP_AUTH carries the full `Basic …` header +# value; it is written only into the owner-only settings.local.json env block. +WS_MCP_URL="" +WS_MCP_AUTH="" +WS_HAS_MCP=false +WS_MCP_RESOLVED=false + # ─── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[setup.sh] $*" >&2; } @@ -192,6 +207,69 @@ resolve_gateway_env() { log "Gateway: routing this workspace through $base_url" } +# Populate TASK_PROVIDER from this workspace's config.json entry (CROW-522). +# Mirrors AppConfig.derivedTaskProvider: the explicit `taskProvider` when set, +# otherwise the code `provider`. Falls back to $PROVIDER when config/jq is +# unavailable. Idempotent. +resolve_task_provider() { + [[ "$TASK_PROVIDER_RESOLVED" == true ]] && return 0 + TASK_PROVIDER_RESOLVED=true + TASK_PROVIDER="$PROVIDER" + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + local tp + tp=$(jq -r --arg name "$WORKSPACE" \ + '.workspaces[]? | select(.name == $name) | (.taskProvider // .provider) // empty' \ + "$config_path" 2>/dev/null) || return 0 + [[ -n "$tp" && "$tp" != "null" ]] && TASK_PROVIDER="$tp" +} + +# Populate WS_MCP_URL / WS_MCP_AUTH and set WS_HAS_MCP=true when this workspace +# uses Jira tasks AND an Atlassian MCP credential is configured (CROW-522). +# Builds the HTTP Basic Authorization header from the account email + API token +# (`op://` token references resolved via `op read`). Idempotent β€” the expensive +# `op read` only runs once. Never logs the resolved token or header. +resolve_atlassian_mcp_env() { + [[ "$WS_MCP_RESOLVED" == true ]] && return 0 + WS_MCP_RESOLVED=true + + resolve_task_provider + [[ "$TASK_PROVIDER" == "jira" ]] || return 0 + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping Atlassian MCP resolution"; return 0; } + + local mcp + mcp=$(jq -c '.atlassianMCP // empty' "$config_path" 2>/dev/null) || return 0 + [[ -n "$mcp" && "$mcp" != "null" ]] || return 0 + + local endpoint email token_ref + endpoint=$(jq -r '.endpoint // "https://mcp.atlassian.com/v1/mcp"' <<< "$mcp") + email=$(jq -r '.email // ""' <<< "$mcp") + token_ref=$(jq -r '.tokenRef // ""' <<< "$mcp") + [[ -n "$email" && -n "$token_ref" ]] || { log "Atlassian MCP: email/token not set; skipping"; return 0; } + + local token="$token_ref" + if [[ "$token_ref" == op://* ]]; then + if ! token=$(op read "$token_ref" 2>/dev/null); then + log "Atlassian MCP: failed to resolve API token reference (op read failed); skipping MCP injection" + return 0 + fi + fi + + # base64 of "email:token" with no embedded newline (base64 wraps by default). + local b64 + b64=$(printf '%s' "$email:$token" | base64 | tr -d '\n') + WS_MCP_URL="$endpoint" + WS_MCP_AUTH="Basic $b64" + WS_HAS_MCP=true + log "Atlassian MCP: injecting $endpoint for this Jira workspace" +} + # Build the shell prefix that applies (or clears) the gateway env vars on the # `claude` launch line β€” mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. # Gateway absent β†’ `unset … && ` so a no-gateway workspace doesn't inherit a @@ -506,9 +584,10 @@ create_session() { # regardless of --skip-launch, so any worktree the user later opens with Claude # Code picks up both overrides. write_settings_local() { - # Resolve the gateway first so its env block is written even when attribution - # trailers are disabled. + # Resolve the gateway and Atlassian MCP first so their blocks are written even + # when attribution trailers are disabled. resolve_gateway_env + resolve_atlassian_mcp_env local want_attribution=false if is_attribution_trailers_enabled && [[ -n "$SESSION_ID" ]]; then @@ -519,8 +598,8 @@ write_settings_local() { log "Attribution trailers disabled via config" fi - if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true ]]; then - log "No attribution trailer or gateway to write; skipping settings.local.json" + if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true && "$WS_HAS_MCP" != true ]]; then + log "No attribution trailer, gateway, or MCP to write; skipping settings.local.json" return fi @@ -562,19 +641,31 @@ Crow-Session: $SESSION_ID" --argjson want_gw "$WS_HAS_GATEWAY" \ --arg base_url "$WS_BASE_URL" \ --arg headers "$WS_CUSTOM_HEADERS" \ + --argjson want_mcp "$WS_HAS_MCP" \ + --arg mcp_auth "$WS_MCP_AUTH" \ '(if $want_attr then .attribution.commit = $commit else . end) | (if $want_gw then .env.ANTHROPIC_BASE_URL = $base_url - | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end)' \ + | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end) + | (if $want_mcp then .env.ATLASSIAN_MCP_AUTHORIZATION = $mcp_auth + | .enabledMcpjsonServers = ((.enabledMcpjsonServers // []) + + ["atlassian"] | unique) else . end)' \ <<< "$base"); then die "settings_local" "jq failed to build settings.local.json" fi printf '%s\n' "$merged" > "$settings_path" - # The env block can carry a resolved bearer token, so restrict the file to - # owner-only β€” matching ConfigStore's 0600 on config.json. + # The env block can carry a resolved bearer token / MCP credential, so restrict + # the file to owner-only β€” matching ConfigStore's 0600 on config.json. chmod 600 "$settings_path" 2>/dev/null || true - if [[ "$WS_HAS_GATEWAY" == true ]]; then - log "Wrote settings.local.json (attribution + gateway env) to $settings_path (agent: $display_name)" + # CROW-522: write a project-root .mcp.json registering the Atlassian Remote MCP + # Server. The Authorization header references the env var set above via ${…} + # expansion, so the secret stays only in the owner-only settings.local.json. + if [[ "$WS_HAS_MCP" == true ]]; then + write_mcp_json + fi + + if [[ "$WS_HAS_GATEWAY" == true || "$WS_HAS_MCP" == true ]]; then + log "Wrote settings.local.json (attribution + gateway/MCP env) to $settings_path (agent: $display_name)" else log "Wrote attribution settings to $settings_path (agent: $display_name)" fi @@ -591,6 +682,37 @@ Crow-Session: $SESSION_ID" if ! grep -qxF '.claude/settings.local.json' "$exclude_file" 2>/dev/null; then printf '\n# Added by crow setup.sh\n.claude/settings.local.json\n' >> "$exclude_file" fi + # CROW-522: the Atlassian MCP .mcp.json sits at the worktree root; exclude it + # too so a Jira workspace's MCP registration is never accidentally committed. + if [[ "$WS_HAS_MCP" == true ]] && ! grep -qxF '.mcp.json' "$exclude_file" 2>/dev/null; then + printf '.mcp.json\n' >> "$exclude_file" + fi +} + +# Write a project-root .mcp.json registering the Atlassian Remote MCP Server for +# this Jira workspace (CROW-522). Merges into an existing file, preserving any +# user-defined servers. The Authorization header is a ${…} reference to the env +# var in settings.local.json β€” the resolved secret never lands in this file. +write_mcp_json() { + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping .mcp.json"; return 0; } + local mcp_path="$WORKTREE_PATH/.mcp.json" + local base="{}" + [[ -f "$mcp_path" ]] && base=$(cat "$mcp_path") + + local merged + if ! merged=$(jq \ + --arg url "$WS_MCP_URL" \ + '.mcpServers.atlassian = { + "type": "http", + "url": $url, + "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } + }' \ + <<< "$base"); then + log "Warning: jq failed to build .mcp.json; skipping" + return 0 + fi + printf '%s\n' "$merged" > "$mcp_path" + log "Wrote .mcp.json (Atlassian MCP) to $mcp_path" } # ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── @@ -752,6 +874,16 @@ github_ops() { if [[ "$PROVIDER" != "github" ]]; then return fi + # CROW-522: a GitHub-code workspace can track its tasks in Jira. In that case + # $TICKET_URL is a Jira browse URL, not a GitHub issue β€” running `gh issue + # edit`/project-status against it just logs `auto-assign failed`. Skip GitHub + # issue housekeeping entirely when the task provider is Jira (assignment + + # status now happen via the Atlassian MCP in-session). + resolve_task_provider + if [[ "$TASK_PROVIDER" == "jira" ]]; then + log "Task provider is Jira; skipping GitHub issue auto-assign/project-status" + return + fi # Auto-assign if [[ "$SKIP_ASSIGN" != "true" && -n "$TICKET_URL" ]]; then From 4dd4b4e74955a45c03b8270b712c16575c83172d Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 18 Jun 2026 12:40:35 -0500 Subject: [PATCH 2/2] Fix MCP teardown to remove only our server entry; add writer tests (#524 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses dgershman's Yellow finding: writeAtlassianMcpConfig's teardown (resolved == nil) only deleted .mcp.json when it held exactly one server, so a user-authored multi-server file kept a dangling `atlassian` entry after its ${ATLASSIAN_MCP_AUTHORIZATION} env var was removed β€” Claude Code would warn on the missing expansion next launch. Now mirror the env/enabledMcpjsonServers handling: remove only our server key, rewrite .mcp.json when other servers remain, delete it only when nothing of ours or theirs is left. Adds ClaudeHookConfigWriterMCPTests covering write (server/env/0600), user-server preservation on write, teardown removing only our entry while keeping the user's, and full-file deletion when only ours remained. πŸ¦β€β¬› Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 9104A714-C9DE-4922-9C95-91F3A2749E37 --- .../CrowClaude/ClaudeHookConfigWriter.swift | 36 ++++--- .../ClaudeHookConfigWriterMCPTests.swift | 101 ++++++++++++++++++ 2 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift index 0b55ba47..3a91d559 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift @@ -219,24 +219,36 @@ public struct ClaudeHookConfigWriter: HookConfigWriter { } // --- .mcp.json: the server definition (no secret β€” references ${env}) --- - guard let resolved else { - // Only remove a .mcp.json that is ours (just the atlassian server); - // leave a user-authored multi-server file alone. - if let data = FileManager.default.contents(atPath: mcpPath), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let servers = parsed["mcpServers"] as? [String: Any], - servers.count == 1, servers[atlassianMcpServerName] != nil { - try? FileManager.default.removeItem(atPath: mcpPath) - } - return - } - var mcp: [String: Any] = [:] if let data = FileManager.default.contents(atPath: mcpPath), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { mcp = parsed } var servers = mcp["mcpServers"] as? [String: Any] ?? [:] + + guard let resolved else { + // Teardown: remove only OUR server entry (its `${env}` reference was + // just cleared above), and preserve any user-authored servers. Mirror + // the env/enabledMcpjsonServers handling β€” only delete the file when + // nothing of ours or theirs remains. Leaving a dangling `atlassian` + // entry behind would warn on next launch (missing env var). + guard servers[atlassianMcpServerName] != nil else { return } + servers.removeValue(forKey: atlassianMcpServerName) + if servers.isEmpty { + mcp.removeValue(forKey: "mcpServers") + } else { + mcp["mcpServers"] = servers + } + if mcp.isEmpty { + try? FileManager.default.removeItem(atPath: mcpPath) + } else { + if let data = try? JSONSerialization.data(withJSONObject: mcp, options: [.prettyPrinted, .sortedKeys]) { + try? data.write(to: URL(fileURLWithPath: mcpPath)) + } + } + return + } + servers[atlassianMcpServerName] = [ "type": "http", "url": resolved.endpoint, diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift new file mode 100644 index 00000000..d26ec1e5 --- /dev/null +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import CrowClaude +@testable import CrowCore + +// MARK: - writeAtlassianMcpConfig (CROW-522) + +private func makeTempDir() -> String { + let dir = (NSTemporaryDirectory() as NSString) + .appendingPathComponent("crow-mcp-test-\(UUID().uuidString)") + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir +} + +private func readJSON(_ path: String) -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: path) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} + +@Test func writeAtlassianMcpConfigWritesServerEnvAndTrust() throws { + let dir = makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let resolved = AtlassianMCPResolver.Resolved( + endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic abc123") + + ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: resolved) + + // .mcp.json registers the http server with an env-reference header (no secret). + let mcp = readJSON((dir as NSString).appendingPathComponent(".mcp.json")) + let server = (mcp?["mcpServers"] as? [String: Any])?["atlassian"] as? [String: Any] + #expect(server?["type"] as? String == "http") + #expect(server?["url"] as? String == "https://mcp.atlassian.com/v1/mcp") + let auth = (server?["headers"] as? [String: Any])?["Authorization"] as? String + #expect(auth == "${ATLASSIAN_MCP_AUTHORIZATION}") + + // settings.local.json carries the resolved credential + the pre-trust entry. + let settingsPath = (dir as NSString).appendingPathComponent(".claude/settings.local.json") + let settings = readJSON(settingsPath) + #expect((settings?["env"] as? [String: Any])?["ATLASSIAN_MCP_AUTHORIZATION"] as? String == "Basic abc123") + #expect((settings?["enabledMcpjsonServers"] as? [String]) == ["atlassian"]) + + // settings.local.json is owner-only (0600). + let attrs = try FileManager.default.attributesOfItem(atPath: settingsPath) + #expect((attrs[.posixPermissions] as? NSNumber)?.intValue == 0o600) +} + +@Test func writeAtlassianMcpConfigPreservesUserServerOnWrite() throws { + let dir = makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") + let userFile: [String: Any] = ["mcpServers": ["my-server": ["type": "http", "url": "https://x"]]] + try JSONSerialization.data(withJSONObject: userFile).write(to: URL(fileURLWithPath: mcpPath)) + + ClaudeHookConfigWriter.writeAtlassianMcpConfig( + dirPath: dir, + resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) + + let servers = readJSON(mcpPath)?["mcpServers"] as? [String: Any] + #expect(servers?["atlassian"] != nil) + #expect(servers?["my-server"] != nil) // user's server untouched +} + +@Test func teardownRemovesOnlyOurServerAndKeepsUserServer() throws { + let dir = makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") + + // First write ours alongside a user-authored server. + let userFile: [String: Any] = ["mcpServers": ["my-server": ["type": "http", "url": "https://x"]]] + try JSONSerialization.data(withJSONObject: userFile).write(to: URL(fileURLWithPath: mcpPath)) + ClaudeHookConfigWriter.writeAtlassianMcpConfig( + dirPath: dir, + resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) + + // Teardown (resolved nil) must drop ONLY our entry β€” the dangling-server bug. + ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: nil) + + let servers = readJSON(mcpPath)?["mcpServers"] as? [String: Any] + #expect(servers?["atlassian"] == nil, "our server entry must be removed on teardown") + #expect(servers?["my-server"] != nil, "user's server must survive teardown") + + // And our env var must be gone (so nothing references a missing secret). + let settings = readJSON((dir as NSString).appendingPathComponent(".claude/settings.local.json")) + #expect((settings?["env"] as? [String: Any])?["ATLASSIAN_MCP_AUTHORIZATION"] == nil) + #expect((settings?["enabledMcpjsonServers"] as? [String])?.contains("atlassian") != true) +} + +@Test func teardownDeletesMcpJsonWhenOnlyOursRemained() throws { + let dir = makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") + + ClaudeHookConfigWriter.writeAtlassianMcpConfig( + dirPath: dir, + resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) + #expect(FileManager.default.fileExists(atPath: mcpPath)) + + ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: nil) + #expect(!FileManager.default.fileExists(atPath: mcpPath), + "a .mcp.json that held only our server should be removed entirely") +}