From 1adf671b1947a6c91f3f97b3993302acb8274530 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 18 Jun 2026 13:10:59 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Settings=20UI=20to=20map=20Crow=20ticket=20?= =?UTF-8?q?states=20=E2=86=94=20Jira=20workflow=20statuses=20(#523)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jira workflow status names are configurable per project, so Crow's hardcoded Crow→Jira mapping (.ready → "To Do", everything else verbatim) made transitions silently fail for projects that renamed a status. Add a per-workspace `jiraStatusMap` (Backlog/Ready/In Progress/In Review/Done → concrete Jira status names) persisted in config.json, edited in Settings → Workspaces, consulted by both status surfaces with a fallback to the built-in defaults when unset. - Model: `WorkspaceInfo.jiraStatusMap` + `JiraConfig.statusMap`; the default table moves to `TicketStatus.defaultJiraStatusName` (CrowCore) as the single source of truth shared by the UI and the backend. - In-app acli path: `JiraTaskBackend.setTaskStatus` consults the map; `IssueTracker.markInReview` resolves the matching workspace's map by the ticket's project key / site. - Agent MCP path: `ClaudeLauncher` embeds a "Jira Status Mapping" block in Jira-session prompts, and the `/crow-workspace` skill instructs the agent to consult `jiraStatusMap` before `transitionJiraIssue`. - Settings UI: a per-status field (placeholder = current default) plus a bonus "Fetch from Jira" button that populates dropdowns from the live workflow via `GET /rest/api/3/project/{key}/statuses` (new `JiraStatusFetcher`, reusing #524's Atlassian credentials); degrades to free-text when no credential/site is configured. - Tests (CrowCore/CrowProvider/CrowClaude) + docs/configuration.md. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: ACF93A34-A697-4DEF-B282-2734892C8AD2 --- .../Sources/CrowClaude/ClaudeLauncher.swift | 30 ++++- .../CrowClaudeTests/ClaudeLauncherTests.swift | 42 ++++++ .../Sources/CrowCore/JiraStatusFetcher.swift | 78 +++++++++++ .../Sources/CrowCore/Models/AppConfig.swift | 12 +- .../Sources/CrowCore/Models/Enums.swift | 14 ++ .../Tests/CrowCoreTests/AppConfigTests.swift | 20 +++ .../JiraStatusFetcherTests.swift | 74 +++++++++++ .../Backends/JiraTaskBackend.swift | 39 ++++-- .../JiraTaskBackendTests.swift | 37 +++++- .../CrowUI/Sources/CrowUI/SettingsView.swift | 34 ++++- .../Sources/CrowUI/WorkspaceFormView.swift | 123 ++++++++++++++++++ Resources/crow-workspace-SKILL.md.template | 9 ++ Sources/Crow/App/IssueTracker.swift | 37 +++++- docs/configuration.md | 28 ++++ skills/crow-workspace/SKILL.md | 9 ++ 15 files changed, 562 insertions(+), 24 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift index 4a6c47f4..0c19c08c 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift @@ -20,7 +20,8 @@ public actor ClaudeLauncher { ticketURL: String?, provider: Provider?, codeProvider: Provider? = nil, - customInstructions: String? = nil + customInstructions: String? = nil, + jiraStatusMap: [String: String]? = nil ) -> String { // Plan mode is set by the `--permission-mode plan` flag in setup.sh's // launch_claude(); do not prepend `/plan` here — that token is parsed @@ -84,6 +85,11 @@ public actor ClaudeLauncher { ) } + if provider == .jira, let mapping = Self.jiraStatusMappingBlock(jiraStatusMap) { + lines.append("") + lines.append(mapping) + } + if let instructions = customInstructions, !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { lines.append("") @@ -95,6 +101,28 @@ public actor ClaudeLauncher { return lines.joined(separator: "\n") } + /// Render the per-workspace Crow→Jira status-name overrides (#523) as a prompt + /// block so the agent's `transitionJiraIssue` calls use this project's actual + /// workflow status names instead of Crow's defaults. Returns `nil` when no + /// non-blank overrides are configured (the agent then uses default names). + static func jiraStatusMappingBlock(_ statusMap: [String: String]?) -> String? { + guard let statusMap else { return nil } + // Preserve pipeline order; skip blanks (those fall back to defaults). + let rows = TicketStatus.pipelineStatuses.compactMap { status -> String? in + guard let name = statusMap[status.rawValue]?.trimmingCharacters(in: .whitespaces), + !name.isEmpty else { return nil } + return "- \(status.rawValue) → \(name)" + } + guard !rows.isEmpty else { return nil } + var block = "## Jira Status Mapping" + block += "\n" + block += "\nWhen transitioning this work item via `transitionJiraIssue`, use this project's" + block += " configured Jira workflow status names (not Crow's defaults):" + block += "\n" + block += "\n" + rows.joined(separator: "\n") + return block + } + /// Append the final "open a PR/MR" instruction, branching on provider. private func appendOpenPRStep( to lines: inout [String], diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift index 0f85511c..6ed86485 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift @@ -176,6 +176,48 @@ import Testing #expect(prompt.contains("pushing the branch updates it")) } +@Test func generatePromptIncludesJiraStatusMappingBlockWhenConfigured() async { + let launcher = ClaudeLauncher() + let prompt = await launcher.generatePrompt( + session: Session(name: "jira-map", ticketNumber: 6859), + worktrees: [], + ticketURL: "https://acme.atlassian.net/browse/MAXX-6859", + provider: .jira, + codeProvider: .github, + jiraStatusMap: ["In Progress": "In Development", "In Review": "Code Review"] + ) + #expect(prompt.contains("## Jira Status Mapping")) + #expect(prompt.contains("In Progress → In Development")) + #expect(prompt.contains("In Review → Code Review")) + // Unmapped pipeline states are omitted (they fall back to defaults). + #expect(!prompt.contains("Backlog → ")) +} + +@Test func generatePromptOmitsJiraStatusMappingBlockWhenUnset() async { + let launcher = ClaudeLauncher() + let prompt = await launcher.generatePrompt( + session: Session(name: "jira-nomap", ticketNumber: 1), + worktrees: [], + ticketURL: "https://acme.atlassian.net/browse/MAXX-1", + provider: .jira, + codeProvider: .github + ) + #expect(!prompt.contains("## Jira Status Mapping")) +} + +@Test func generatePromptOmitsJiraStatusMappingForNonJiraProvider() async { + // A status map on a non-Jira session must never leak into the prompt. + let launcher = ClaudeLauncher() + let prompt = await launcher.generatePrompt( + session: Session(name: "gh", ticketNumber: 5), + worktrees: [], + ticketURL: "https://github.com/org/repo/issues/5", + provider: .github, + jiraStatusMap: ["In Progress": "In Development"] + ) + #expect(!prompt.contains("## Jira Status Mapping")) +} + @Test func generatePromptDoesNotStartWithPlanSlashCommand() async { // Plan mode is set via the `--permission-mode plan` flag in setup.sh, // not via a `/plan` slash-command prefix on the prompt body. See diff --git a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift new file mode 100644 index 00000000..484da47e --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Fetches the concrete workflow **status names** for a Jira project, so the +/// Settings status-mapping UI can offer live names instead of free-text (#523). +/// +/// `acli` has no transition/status *list* command and the in-app UI can't reach +/// the agent-side Atlassian MCP, so this calls the Jira Cloud REST endpoint +/// `GET /rest/api/3/project/{projectKey}/statuses` directly, authenticated with +/// the same Atlassian email + API token used for the MCP server (HTTP Basic). +/// That endpoint returns every status across the project's issue types — no +/// sample issue needed. +public enum JiraStatusFetcher { + public enum FetchError: Error, Equatable { + case badSite + case http(Int) + case transport(String) + case decode + } + + /// Build the project-statuses REST URL for a site host + project key. + /// Accepts a bare host (`acme.atlassian.net`) or a full `https://…` origin. + static func statusesURL(site: String, projectKey: String) -> URL? { + let trimmedSite = site.trimmingCharacters(in: .whitespaces) + let trimmedKey = projectKey.trimmingCharacters(in: .whitespaces) + guard !trimmedSite.isEmpty, !trimmedKey.isEmpty else { return nil } + let origin = trimmedSite.hasPrefix("http") ? trimmedSite : "https://\(trimmedSite)" + guard let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + return nil + } + return URL(string: "\(origin)/rest/api/3/project/\(encodedKey)/statuses") + } + + /// Parse the `project/{key}/statuses` JSON payload into a de-duplicated, + /// order-preserving list of status names across all issue types. + static func parseStatusNames(_ data: Data) -> [String]? { + guard let json = try? JSONSerialization.jsonObject(with: data), + let issueTypes = json as? [[String: Any]] else { return nil } + var seen = Set() + var names: [String] = [] + for issueType in issueTypes { + guard let statuses = issueType["statuses"] as? [[String: Any]] else { continue } + for status in statuses { + guard let name = status["name"] as? String, !name.isEmpty else { continue } + if seen.insert(name).inserted { names.append(name) } + } + } + return names + } + + /// Fetch the distinct status names for `projectKey` on `site`, authenticated + /// with `authorization` (a full header value, e.g. `Basic …` from + /// ``AtlassianMCPResolver``). Injectable transport for testing. + public static func fetchStatusNames( + site: String, + projectKey: String, + authorization: String, + transport: (URLRequest) async throws -> (Data, URLResponse) = { try await URLSession.shared.data(for: $0) } + ) async -> Result<[String], FetchError> { + guard let url = statusesURL(site: site, projectKey: projectKey) else { + return .failure(.badSite) + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(authorization, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await transport(request) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + return .failure(.http(http.statusCode)) + } + guard let names = parseStatusNames(data) else { return .failure(.decode) } + return .success(names) + } catch { + return .failure(.transport(error.localizedDescription)) + } + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 9cfed284..5cc63a06 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -376,6 +376,13 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { /// Atlassian site host (e.g. "acme.atlassian.net") used to build user-facing /// `…/browse/KEY` URLs. Only meaningful when `taskProvider == "jira"`. public var jiraSite: String? + /// Per-workspace override of the Crow→Jira status-name map. Keys are + /// ``TicketStatus`` raw values for the pipeline statuses ("Backlog", "Ready", + /// "In Progress", "In Review", "Done"); values are the concrete Jira workflow + /// status names for this project. A missing/blank entry falls back to + /// ``JiraTaskBackend.defaultJiraStatusName(for:)``. Only meaningful when + /// `taskProvider == "jira"`. See #523. + public var jiraStatusMap: [String: String]? /// Self-hosted Corveil host (e.g. "corveil.acme.io") used **only** for URL /// routing in `ProviderManager.detect` — Corveil's own auth/state lives in /// the CLI (`corveil login`, `CORVEIL_URL`), so Crow doesn't pipe it through. @@ -408,6 +415,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { jiraProjectKey: String? = nil, jiraJQL: String? = nil, jiraSite: String? = nil, + jiraStatusMap: [String: String]? = nil, corveilHost: String? = nil, gateway: WorkspaceGateway? = nil ) { @@ -424,6 +432,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { self.jiraProjectKey = jiraProjectKey self.jiraJQL = jiraJQL self.jiraSite = jiraSite + self.jiraStatusMap = jiraStatusMap self.corveilHost = corveilHost self.gateway = gateway } @@ -443,13 +452,14 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { jiraProjectKey = try container.decodeIfPresent(String.self, forKey: .jiraProjectKey) jiraJQL = try container.decodeIfPresent(String.self, forKey: .jiraJQL) jiraSite = try container.decodeIfPresent(String.self, forKey: .jiraSite) + jiraStatusMap = try container.decodeIfPresent([String: String].self, forKey: .jiraStatusMap) corveilHost = try container.decodeIfPresent(String.self, forKey: .corveilHost) gateway = try container.decodeIfPresent(WorkspaceGateway.self, forKey: .gateway) } private enum CodingKeys: String, CodingKey { case id, name, provider, cli, host, alwaysInclude, autoReviewRepos, excludeReviewRepos, customInstructions - case taskProvider, jiraProjectKey, jiraJQL, jiraSite, corveilHost, gateway + case taskProvider, jiraProjectKey, jiraJQL, jiraSite, jiraStatusMap, corveilHost, gateway } /// Characters that are unsafe in directory names (workspace names become directory names). diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift index 14c9b147..71484a96 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift @@ -105,6 +105,20 @@ public enum TicketStatus: String, Codable, Sendable, CaseIterable { default: self = .unknown } } + + /// The built-in Crow→Jira workflow status name for this pipeline status, used + /// as the fallback when a workspace has no per-project override (#523). Raw + /// values already match common Jira names ("In Progress", "In Review", "Done", + /// "Backlog"); only `.ready` needs a Jira-flavored alias ("To Do"). Surfaced + /// in CrowCore so both the Settings UI (placeholders) and `JiraTaskBackend` + /// (the live transition) share one source of truth. + public var defaultJiraStatusName: String { + switch self { + case .ready: return "To Do" + case .backlog, .inProgress, .inReview, .done, .unknown: + return rawValue + } + } } /// Sort order options for the ticket board. diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 7d384aa9..9630e4d9 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -336,6 +336,26 @@ import Testing #expect(config.workspaces[0].customInstructions == nil) } +@Test func workspaceJiraStatusMapRoundTrip() throws { + let config = AppConfig(workspaces: [ + WorkspaceInfo(name: "Org", taskProvider: "jira", + jiraStatusMap: ["In Progress": "In Development", "Ready": "To Do"]) + ]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.workspaces[0].jiraStatusMap?["In Progress"] == "In Development") + #expect(decoded.workspaces[0].jiraStatusMap?["Ready"] == "To Do") +} + +@Test func workspaceJiraStatusMapDefaultsNilWhenKeyMissing() throws { + // Legacy/non-Jira configs without the key default to nil (use built-in defaults). + let json = """ + {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh"}]} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.workspaces[0].jiraStatusMap == nil) +} + @Test func workspaceNameValidation() { // Valid name #expect(WorkspaceInfo.validateName("MyOrg", existingNames: []) == nil) diff --git a/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift new file mode 100644 index 00000000..6971af53 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift @@ -0,0 +1,74 @@ +import Testing +import Foundation +@testable import CrowCore + +@Suite struct JiraStatusFetcherTests { + + @Test func buildsStatusesURLFromBareHost() { + let url = JiraStatusFetcher.statusesURL(site: "acme.atlassian.net", projectKey: "PROPS") + #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/project/PROPS/statuses") + } + + @Test func buildsStatusesURLFromFullOrigin() { + let url = JiraStatusFetcher.statusesURL(site: "https://acme.atlassian.net", projectKey: "MAXX") + #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/project/MAXX/statuses") + } + + @Test func statusesURLNilForBlankInputs() { + #expect(JiraStatusFetcher.statusesURL(site: "", projectKey: "PROPS") == nil) + #expect(JiraStatusFetcher.statusesURL(site: "acme.atlassian.net", projectKey: " ") == nil) + } + + @Test func parsesAndDedupesStatusNamesAcrossIssueTypes() throws { + let json = """ + [ + {"name":"Task","statuses":[{"name":"To Do"},{"name":"In Progress"},{"name":"Done"}]}, + {"name":"Bug","statuses":[{"name":"To Do"},{"name":"In Review"},{"name":"Done"}]} + ] + """.data(using: .utf8)! + let names = try #require(JiraStatusFetcher.parseStatusNames(json)) + // Order-preserving, de-duplicated across issue types. + #expect(names == ["To Do", "In Progress", "Done", "In Review"]) + } + + @Test func parseReturnsEmptyForNoStatuses() throws { + let names = try #require(JiraStatusFetcher.parseStatusNames("[]".data(using: .utf8)!)) + #expect(names.isEmpty) + } + + @Test func parseReturnsNilForMalformedJSON() { + #expect(JiraStatusFetcher.parseStatusNames("not json".data(using: .utf8)!) == nil) + } + + @Test func fetchSetsBasicAuthHeaderAndParsesNames() async { + let payload = """ + [{"name":"Story","statuses":[{"name":"Backlog"},{"name":"Selected"}]}] + """.data(using: .utf8)! + var capturedAuth: String? + let result = await JiraStatusFetcher.fetchStatusNames( + site: "acme.atlassian.net", + projectKey: "PROPS", + authorization: "Basic ZW1haWw6dG9rZW4=", + transport: { request in + capturedAuth = request.value(forHTTPHeaderField: "Authorization") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (payload, response) + } + ) + #expect(capturedAuth == "Basic ZW1haWw6dG9rZW4=") + #expect(result == .success(["Backlog", "Selected"])) + } + + @Test func fetchSurfacesHTTPErrorStatus() async { + let result = await JiraStatusFetcher.fetchStatusNames( + site: "acme.atlassian.net", + projectKey: "PROPS", + authorization: "Basic x", + transport: { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + ) + #expect(result == .failure(.http(401))) + } +} diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift index 06133243..ec70081c 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift @@ -15,11 +15,16 @@ public struct JiraConfig: Sendable, Equatable { public let site: String? public let projectKey: String? public let jql: String? + /// Per-workspace Crow→Jira status-name overrides, keyed by ``TicketStatus`` + /// raw value (e.g. "In Progress" → "In Development"). A missing/blank entry + /// falls back to ``JiraTaskBackend/defaultJiraStatusName(for:)``. See #523. + public let statusMap: [String: String]? - public init(site: String? = nil, projectKey: String? = nil, jql: String? = nil) { + public init(site: String? = nil, projectKey: String? = nil, jql: String? = nil, statusMap: [String: String]? = nil) { self.site = site self.projectKey = projectKey self.jql = jql + self.statusMap = statusMap } } @@ -126,7 +131,7 @@ public struct JiraTaskBackend: TaskBackend { _ = try await run([ "acli", "jira", "workitem", "transition", "--key", parsed.key, - "--status", Self.jiraStatusName(for: status), + "--status", jiraStatusName(for: status), "--yes", ]) } @@ -216,18 +221,28 @@ public struct JiraTaskBackend: TaskBackend { || lower.contains("please login") } - /// Map a Crow pipeline status to a Jira workflow status name. `TicketStatus` - /// raw values already match common Jira names ("In Progress", "In Review", - /// "Done", "Backlog"); only `.ready` needs a Jira-flavored alias. + /// Resolve the Jira workflow status name for a Crow pipeline status, honoring + /// the per-workspace ``JiraConfig/statusMap`` override (#523) and otherwise + /// falling back to ``defaultJiraStatusName(for:)``. A blank override entry is + /// ignored (treated as unset) so an empty Settings field uses the default. + func jiraStatusName(for status: TicketStatus) -> String { + if let mapped = config.statusMap?[status.rawValue], + !mapped.trimmingCharacters(in: .whitespaces).isEmpty { + return mapped + } + return Self.defaultJiraStatusName(for: status) + } + + /// The built-in Crow→Jira status-name defaults, used as the fallback when a + /// workspace has no per-project override (#523). Delegates to + /// ``TicketStatus/defaultJiraStatusName`` (CrowCore) so the Settings UI and + /// this live-transition path share one source of truth. /// /// Best-effort: Jira workflow status names are configurable per project, so a - /// transition can still legitimately fail if a project renames its statuses. - static func jiraStatusName(for status: TicketStatus) -> String { - switch status { - case .ready: return "To Do" - case .backlog, .inProgress, .inReview, .done, .unknown: - return status.rawValue - } + /// transition can still legitimately fail if a project renames its statuses — + /// hence the per-workspace override map. + public static func defaultJiraStatusName(for status: TicketStatus) -> String { + status.defaultJiraStatusName } // MARK: - JSON parsing diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift index 0b60568b..e426b861 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift @@ -159,12 +159,37 @@ final class JiraTaskBackendTests: XCTestCase { XCTAssertTrue(args.contains("--yes")) } - func testStatusNameMapping() { - XCTAssertEqual(JiraTaskBackend.jiraStatusName(for: .ready), "To Do") - XCTAssertEqual(JiraTaskBackend.jiraStatusName(for: .inProgress), "In Progress") - XCTAssertEqual(JiraTaskBackend.jiraStatusName(for: .inReview), "In Review") - XCTAssertEqual(JiraTaskBackend.jiraStatusName(for: .done), "Done") - XCTAssertEqual(JiraTaskBackend.jiraStatusName(for: .backlog), "Backlog") + func testDefaultStatusNameMapping() { + XCTAssertEqual(JiraTaskBackend.defaultJiraStatusName(for: .ready), "To Do") + XCTAssertEqual(JiraTaskBackend.defaultJiraStatusName(for: .inProgress), "In Progress") + XCTAssertEqual(JiraTaskBackend.defaultJiraStatusName(for: .inReview), "In Review") + XCTAssertEqual(JiraTaskBackend.defaultJiraStatusName(for: .done), "Done") + XCTAssertEqual(JiraTaskBackend.defaultJiraStatusName(for: .backlog), "Backlog") + } + + func testStatusMapOverridesDefault() { + let cfg = JiraConfig(statusMap: ["In Progress": "In Development", "In Review": "Code Review"]) + let b = backend(FakeShellRunner(), config: cfg) + // Overridden states use the configured name… + XCTAssertEqual(b.jiraStatusName(for: .inProgress), "In Development") + XCTAssertEqual(b.jiraStatusName(for: .inReview), "Code Review") + // …unmapped states fall back to the built-in defaults. + XCTAssertEqual(b.jiraStatusName(for: .ready), "To Do") + XCTAssertEqual(b.jiraStatusName(for: .done), "Done") + } + + func testStatusMapBlankEntryFallsBackToDefault() { + let b = backend(FakeShellRunner(), config: JiraConfig(statusMap: ["In Progress": " "])) + XCTAssertEqual(b.jiraStatusName(for: .inProgress), "In Progress") + } + + func testSetTaskStatusUsesMappedNameFromConfig() async throws { + let fake = FakeShellRunner() + let cfg = JiraConfig(statusMap: ["In Review": "Code Review"]) + try await backend(fake, config: cfg) + .setTaskStatus(url: "https://acme.atlassian.net/browse/PROJ-5", status: .inReview) + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "Code Review") } // MARK: - assign diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index fdc8b7a1..8c47b3b3 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -64,6 +64,34 @@ public struct SettingsView: View { .map(\.name) } + /// Fetch a Jira project's live workflow status names for the workspace + /// status-mapping dropdown (#523), authenticating with the Atlassian MCP + /// credential from Settings → Automation. Runs off the main actor (resolving + /// an `op://` token shells out) and maps failures to user-facing copy. + private func fetchJiraStatuses(site: String, projectKey: String) async -> JiraStatusFetchResult { + let atlassian = config.atlassianMCP + return await Task.detached { () -> JiraStatusFetchResult in + guard let cfg = atlassian, !cfg.isEmpty, + let resolved = AtlassianMCPResolver.resolve(cfg) else { + return .failure("Add an Atlassian MCP credential in Settings → Automation first.") + } + switch await JiraStatusFetcher.fetchStatusNames( + site: site, projectKey: projectKey, authorization: resolved.authorization + ) { + case .success(let names): + return .success(names) + case .failure(.badSite): + return .failure("Invalid Atlassian site or project key.") + case .failure(.http(let code)): + return .failure("Jira returned HTTP \(code). Check the credential and project key.") + case .failure(.transport(let message)): + return .failure("Network error: \(message)") + case .failure(.decode): + return .failure("Couldn't parse Jira's response.") + } + }.value + } + public var body: some View { TabView { generalTab @@ -95,7 +123,8 @@ public struct SettingsView: View { .frame(width: 720, height: 480) .sheet(isPresented: $isAddingWorkspace) { WorkspaceFormView( - existingNames: otherWorkspaceNames() + existingNames: otherWorkspaceNames(), + fetchStatuses: { await fetchJiraStatuses(site: $0, projectKey: $1) } ) { ws in config.workspaces.append(ws) save() @@ -104,7 +133,8 @@ public struct SettingsView: View { .sheet(item: $editingWorkspace) { ws in WorkspaceFormView( workspace: ws, - existingNames: otherWorkspaceNames(excluding: ws.id) + existingNames: otherWorkspaceNames(excluding: ws.id), + fetchStatuses: { await fetchJiraStatuses(site: $0, projectKey: $1) } ) { updated in if let idx = config.workspaces.firstIndex(where: { $0.id == updated.id }) { config.workspaces[idx] = updated diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 74b9c281..cb5b58ae 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -1,6 +1,13 @@ import SwiftUI import CrowCore +/// Outcome of a Jira live-status fetch for the workspace status-mapping UI (#523): +/// the workflow status names, or a user-facing error message. +public enum JiraStatusFetchResult: Sendable { + case success([String]) + case failure(String) +} + /// Shared form for creating or editing a workspace. /// /// Used by both the Settings workspace editor and the Setup Wizard. @@ -19,6 +26,15 @@ public struct WorkspaceFormView: View { @State private var jiraSite: String @State private var jiraProjectKey: String @State private var jiraJQL: String + /// Per-pipeline-status Jira workflow name overrides, keyed by + /// `TicketStatus.rawValue`. A blank/absent entry uses the built-in default + /// (shown as the field's placeholder). See #523. + @State private var jiraStatusMap: [String: String] + /// Status names fetched from the live Jira workflow (bonus). Empty until the + /// operator taps "Fetch from Jira"; surfaced as a dropdown of suggestions. + @State private var fetchedStatuses: [String] = [] + @State private var isFetchingStatuses = false + @State private var fetchStatusesError: String? @State private var corveilHost: String @State private var alwaysInclude: [String] @State private var autoReviewRepos: [String] @@ -33,16 +49,23 @@ public struct WorkspaceFormView: View { private let existingID: UUID? private let existingNames: [String] private let onSave: (WorkspaceInfo) -> Void + /// Injected live-status fetcher for the Jira mapping section (#523). Given a + /// site host + project key, returns the workflow status names or a + /// user-facing error. `nil` (e.g. the Setup Wizard) disables the button. + private let fetchStatuses: ((String, String) async -> JiraStatusFetchResult)? /// - Parameters: /// - workspace: An existing workspace to edit, or `nil` to create a new one. /// - existingNames: Names of other workspaces, used for duplicate detection. + /// - fetchStatuses: Optional fetcher for the Jira status dropdown; `nil` disables it. /// - onSave: Called with the validated `WorkspaceInfo` when the user taps Save/Add. public init( workspace: WorkspaceInfo? = nil, existingNames: [String] = [], + fetchStatuses: ((String, String) async -> JiraStatusFetchResult)? = nil, onSave: @escaping (WorkspaceInfo) -> Void ) { + self.fetchStatuses = fetchStatuses self.existingID = workspace?.id self._name = State(initialValue: workspace?.name ?? "") self._provider = State(initialValue: workspace?.provider ?? "github") @@ -51,6 +74,7 @@ public struct WorkspaceFormView: View { self._jiraSite = State(initialValue: workspace?.jiraSite ?? "") self._jiraProjectKey = State(initialValue: workspace?.jiraProjectKey ?? "") self._jiraJQL = State(initialValue: workspace?.jiraJQL ?? "") + self._jiraStatusMap = State(initialValue: workspace?.jiraStatusMap ?? [:]) self._corveilHost = State(initialValue: workspace?.corveilHost ?? "") self._alwaysInclude = State(initialValue: workspace?.alwaysInclude ?? []) self._autoReviewRepos = State(initialValue: workspace?.autoReviewRepos ?? []) @@ -178,6 +202,59 @@ public struct WorkspaceFormView: View { } } + if jiraSelected { + Section("Jira Status Mapping") { + Text("Map Crow's pipeline states to this project's Jira workflow status names. Leave a field blank to use the default shown; names must match the project's workflow exactly.") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(TicketStatus.pipelineStatuses, id: \.self) { status in + HStack(spacing: 8) { + Text(status.rawValue) + .frame(width: 90, alignment: .leading) + .foregroundStyle(.secondary) + TextField(status.defaultJiraStatusName, text: jiraStatusBinding(for: status)) + .textFieldStyle(.roundedBorder) + if !fetchedStatuses.isEmpty { + Menu { + ForEach(fetchedStatuses, id: \.self) { name in + Button(name) { jiraStatusMap[status.rawValue] = name } + } + } label: { + Image(systemName: "chevron.down.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Pick from statuses fetched from Jira") + } + } + } + + HStack { + Button { + Task { await fetchJiraStatuses() } + } label: { + if isFetchingStatuses { + ProgressView().controlSize(.small) + } else { + Label("Fetch from Jira", systemImage: "arrow.down.circle") + } + } + .disabled(isFetchingStatuses || !canFetchStatuses) + if let error = fetchStatusesError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + Text(canFetchStatuses + ? "Populates a dropdown on each row from this project's live workflow." + : "Set the Atlassian Site + Project Key above and an Atlassian MCP credential in Settings → Automation to fetch live statuses.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Section("Repos") { VStack(alignment: .leading, spacing: 4) { Text("Always Include Repos") @@ -282,6 +359,7 @@ public struct WorkspaceFormView: View { jiraProjectKey: isJira ? nonEmpty(jiraProjectKey) : nil, jiraJQL: isJira ? nonEmpty(jiraJQL) : nil, jiraSite: isJira ? nonEmpty(jiraSite) : nil, + jiraStatusMap: isJira ? statusMapForSave : nil, corveilHost: isCorveil ? nonEmpty(corveilHost) : nil, gateway: gatewayForSave ) @@ -291,4 +369,49 @@ public struct WorkspaceFormView: View { let trimmed = text.trimmingCharacters(in: .whitespaces) return trimmed.isEmpty ? nil : trimmed } + + /// A two-way binding for one status's override field. Stores the value as + /// typed; `statusMapForSave` trims and drops blanks at save time. + private func jiraStatusBinding(for status: TicketStatus) -> Binding { + Binding( + get: { jiraStatusMap[status.rawValue] ?? "" }, + set: { jiraStatusMap[status.rawValue] = $0 } + ) + } + + /// The trimmed, non-empty overrides to persist, or `nil` when none are set + /// (so the workspace falls back entirely to the built-in defaults). + private var statusMapForSave: [String: String]? { + var result: [String: String] = [:] + for status in TicketStatus.pipelineStatuses { + let trimmed = (jiraStatusMap[status.rawValue] ?? "").trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { result[status.rawValue] = trimmed } + } + return result.isEmpty ? nil : result + } + + /// "Fetch from Jira" is available only when a fetcher is wired and the site + + /// project key are filled in (the credential check happens in the fetcher). + private var canFetchStatuses: Bool { + fetchStatuses != nil + && !jiraSite.trimmingCharacters(in: .whitespaces).isEmpty + && !jiraProjectKey.trimmingCharacters(in: .whitespaces).isEmpty + } + + /// Pull the live workflow status names for the configured project into + /// `fetchedStatuses` (drives the per-row dropdown). Best-effort: surfaces a + /// caption-level error and leaves the free-text fields untouched on failure. + private func fetchJiraStatuses() async { + guard let fetchStatuses else { return } + isFetchingStatuses = true + fetchStatusesError = nil + defer { isFetchingStatuses = false } + switch await fetchStatuses(jiraSite, jiraProjectKey) { + case .success(let names): + fetchedStatuses = names + if names.isEmpty { fetchStatusesError = "No statuses returned for this project." } + case .failure(let message): + fetchStatusesError = message + } + } } diff --git a/Resources/crow-workspace-SKILL.md.template b/Resources/crow-workspace-SKILL.md.template index 80878bb3..017805b4 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -103,6 +103,15 @@ 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`.) +**Status names when transitioning (CROW-523):** Jira workflow status names are +configurable per project, so before `transitionJiraIssue` consult this workspace's +`jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states +(`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual +status names. Use the mapped name for the target state; fall back to the Crow default +(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. If +your prompt already includes a "Jira Status Mapping" section, that is the same map — +prefer it. + ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 59773724..4aa69de2 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -388,7 +388,7 @@ final class IssueTracker { // site/JQL/project triple is what actually varies). var jiraConfigs: [JiraConfig] = [] for ws in config.workspaces where ws.derivedTaskProvider == "jira" { - let cfg = JiraConfig(site: ws.jiraSite, projectKey: ws.jiraProjectKey, jql: ws.jiraJQL) + let cfg = JiraConfig(site: ws.jiraSite, projectKey: ws.jiraProjectKey, jql: ws.jiraJQL, statusMap: ws.jiraStatusMap) if !jiraConfigs.contains(cfg) { jiraConfigs.append(cfg) } } // Collect distinct Corveil configs. The corveil CLI is authed to one @@ -2379,6 +2379,33 @@ final class IssueTracker { /// Best-effort (the backend itself degrades to empty on failure), mirroring /// the GitLab path — `includeClosed: false` skips the wasted closed query /// since refresh()'s closed-issue diff is GitHub-only today. + /// Resolve the per-workspace Crow→Jira status-name map (#523) for a ticket, + /// matching the ticket's Jira project key (then its site host) against the + /// configured Jira workspaces. Returns `nil` when no workspace defines a map, + /// so `JiraTaskBackend` falls back to its built-in defaults. + private static func jiraStatusMap(forTicket ticketURL: String) -> [String: String]? { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot) else { return nil } + let jiraWorkspaces = config.workspaces.filter { + $0.derivedTaskProvider == "jira" && !($0.jiraStatusMap?.isEmpty ?? true) + } + guard !jiraWorkspaces.isEmpty else { return nil } + // Prefer a project-key match (the ticket key's project, e.g. PROPS-12 → PROPS). + if let project = Validation.parseJiraKey(ticketURL)?.project, + let ws = jiraWorkspaces.first(where: { $0.jiraProjectKey?.uppercased() == project.uppercased() }) { + return ws.jiraStatusMap + } + // Then a site-host match (acli is authed to a single site). + if let ws = jiraWorkspaces.first(where: { ws in + guard let site = ws.jiraSite, !site.isEmpty else { return false } + return ticketURL.contains(site) + }) { + return ws.jiraStatusMap + } + // Single Jira workspace with a map → unambiguous; use it. + return jiraWorkspaces.count == 1 ? jiraWorkspaces[0].jiraStatusMap : nil + } + private func fetchJiraIssues(config: JiraConfig) async -> [AssignedIssue] { let backend = providerManager.taskBackend(for: .jira, jira: config) do { @@ -2411,7 +2438,13 @@ final class IssueTracker { let ticketURL = session.ticketURL, let taskProvider = session.provider else { return } - let backend = providerManager.taskBackend(for: taskProvider) + // For Jira, thread the matching workspace's per-project status-name map + // (#523) so the transition honors a renamed workflow ("In Progress" → + // "In Development"); other providers ignore the JiraConfig. + let jiraConfig: JiraConfig? = (taskProvider == .jira) + ? JiraConfig(statusMap: Self.jiraStatusMap(forTicket: ticketURL)) + : nil + let backend = providerManager.taskBackend(for: taskProvider, jira: jiraConfig) // Capability-gated across providers: GitHub Projects v2 and Jira workflow // transitions both expose `.projectBoardStatus` and implement // `setTaskStatus`. GitLab (no capability) returns early. diff --git a/docs/configuration.md b/docs/configuration.md index 237a2e00..11741d0b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -156,6 +156,34 @@ When set, Crow injects the server into launched Jira-task sessions (and the Mana > **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. +### Jira status mapping + +Jira workflow **status names are configurable per project**, so a project that renames a status (e.g. "In Development" instead of "In Progress") would otherwise make Crow's transitions silently fail. Each Jira workspace can map Crow's pipeline states to that project's concrete Jira status names via the per-workspace **`jiraStatusMap`** field: + +```jsonc +{ + "workspaces": [ + { + "name": "MyOrg", + "taskProvider": "jira", + "jiraProjectKey": "PROPS", + "jiraStatusMap": { + // Crow pipeline state (TicketStatus raw value) → this project's Jira status name + "Ready": "To Do", + "In Progress": "In Development", + "In Review": "Code Review" + } + } + ] +} +``` + +- **Keys** are Crow's pipeline states: `Backlog`, `Ready`, `In Progress`, `In Review`, `Done`. **Values** are the exact Jira workflow status names for that project (case- and spelling-sensitive). +- **A missing or blank entry falls back to the built-in default:** `Ready` → `To Do`; every other state uses its own name verbatim (`In Progress`, `In Review`, `Done`, `Backlog`). An entirely unset `jiraStatusMap` keeps today's behavior. +- Both status surfaces consult the map: the in-app **"Mark in review"** transition (`acli`) and the **agent-side** MCP `transitionJiraIssue` flow (the launched session's prompt embeds the map, and the `/crow-workspace` skill also reads it from `config.json`). + +Edit it under **Settings → Workspaces → (a Jira workspace) → Jira Status Mapping**. Each pipeline state gets a field whose placeholder is the current default — leave it blank to keep the default. If an Atlassian MCP credential is configured, **Fetch from Jira** populates per-row dropdowns from the project's live workflow (`GET /rest/api/3/project/{key}/statuses`); otherwise the fields are free-text. + ## 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-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index 80878bb3..017805b4 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -103,6 +103,15 @@ 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`.) +**Status names when transitioning (CROW-523):** Jira workflow status names are +configurable per project, so before `transitionJiraIssue` consult this workspace's +`jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states +(`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual +status names. Use the mapped name for the target state; fall back to the Crow default +(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. If +your prompt already includes a "Jira Status Mapping" section, that is the same map — +prefer it. + ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | From f2cd4b0cb7515d7780d5086aa40fed16fc25a04e Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 18 Jun 2026 13:27:52 -0500 Subject: [PATCH 2/2] Address review: drop dead launcher block, harden fetch + map resolution (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from #525 (dgershman): - Yellow #1/#2: the `ClaudeLauncher` `## Jira Status Mapping` prompt block was never wired through `CodingAgent`/`ClaudeCodeAgent`, so it was dead in production (same story as `customInstructions`). Drop the launcher block + its `jiraStatusMap` param and lean entirely on the live `/crow-workspace` SKILL path (which reads `jiraStatusMap` from config.json). Removed the now-stale SKILL/docs cross-reference to the prompt block. - Yellow (security): `JiraStatusFetcher.statusesURL` now strips any user-supplied scheme and forces https, so a `jiraSite` typed as `http://…` can't send the Atlassian Basic credential in cleartext. - Yellow #3: `IssueTracker.jiraStatusMap(forTicket:)` matches the workspace site by parsed-host equality instead of a loose substring, so `acme.atlassian.net` no longer matches `dev.acme.atlassian.net`. - Green: clear stale `fetchedStatuses` on jiraSite/projectKey change; add a 15s request timeout to the fetcher; single-source the "trim, drop blanks" rule via `String.nonBlank` (CrowCore). Tests updated (launcher block tests removed; http→https upgrade test added). make app clean; CrowCore/CrowProvider/CrowClaude + root 165 green. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: ACF93A34-A697-4DEF-B282-2734892C8AD2 --- .../Sources/CrowClaude/ClaudeLauncher.swift | 30 +------------ .../CrowClaudeTests/ClaudeLauncherTests.swift | 42 ------------------- .../Sources/CrowCore/JiraStatusFetcher.swift | 15 +++++-- .../Sources/CrowCore/StringExtensions.swift | 12 ++++++ .../JiraStatusFetcherTests.swift | 7 ++++ .../Backends/JiraTaskBackend.swift | 6 +-- .../Sources/CrowUI/WorkspaceFormView.swift | 14 ++++++- Resources/crow-workspace-SKILL.md.template | 4 +- Sources/Crow/App/IssueTracker.swift | 14 ++++--- docs/configuration.md | 2 +- skills/crow-workspace/SKILL.md | 4 +- 11 files changed, 56 insertions(+), 94 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/StringExtensions.swift diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift index 0c19c08c..4a6c47f4 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift @@ -20,8 +20,7 @@ public actor ClaudeLauncher { ticketURL: String?, provider: Provider?, codeProvider: Provider? = nil, - customInstructions: String? = nil, - jiraStatusMap: [String: String]? = nil + customInstructions: String? = nil ) -> String { // Plan mode is set by the `--permission-mode plan` flag in setup.sh's // launch_claude(); do not prepend `/plan` here — that token is parsed @@ -85,11 +84,6 @@ public actor ClaudeLauncher { ) } - if provider == .jira, let mapping = Self.jiraStatusMappingBlock(jiraStatusMap) { - lines.append("") - lines.append(mapping) - } - if let instructions = customInstructions, !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { lines.append("") @@ -101,28 +95,6 @@ public actor ClaudeLauncher { return lines.joined(separator: "\n") } - /// Render the per-workspace Crow→Jira status-name overrides (#523) as a prompt - /// block so the agent's `transitionJiraIssue` calls use this project's actual - /// workflow status names instead of Crow's defaults. Returns `nil` when no - /// non-blank overrides are configured (the agent then uses default names). - static func jiraStatusMappingBlock(_ statusMap: [String: String]?) -> String? { - guard let statusMap else { return nil } - // Preserve pipeline order; skip blanks (those fall back to defaults). - let rows = TicketStatus.pipelineStatuses.compactMap { status -> String? in - guard let name = statusMap[status.rawValue]?.trimmingCharacters(in: .whitespaces), - !name.isEmpty else { return nil } - return "- \(status.rawValue) → \(name)" - } - guard !rows.isEmpty else { return nil } - var block = "## Jira Status Mapping" - block += "\n" - block += "\nWhen transitioning this work item via `transitionJiraIssue`, use this project's" - block += " configured Jira workflow status names (not Crow's defaults):" - block += "\n" - block += "\n" + rows.joined(separator: "\n") - return block - } - /// Append the final "open a PR/MR" instruction, branching on provider. private func appendOpenPRStep( to lines: inout [String], diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift index 6ed86485..0f85511c 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift @@ -176,48 +176,6 @@ import Testing #expect(prompt.contains("pushing the branch updates it")) } -@Test func generatePromptIncludesJiraStatusMappingBlockWhenConfigured() async { - let launcher = ClaudeLauncher() - let prompt = await launcher.generatePrompt( - session: Session(name: "jira-map", ticketNumber: 6859), - worktrees: [], - ticketURL: "https://acme.atlassian.net/browse/MAXX-6859", - provider: .jira, - codeProvider: .github, - jiraStatusMap: ["In Progress": "In Development", "In Review": "Code Review"] - ) - #expect(prompt.contains("## Jira Status Mapping")) - #expect(prompt.contains("In Progress → In Development")) - #expect(prompt.contains("In Review → Code Review")) - // Unmapped pipeline states are omitted (they fall back to defaults). - #expect(!prompt.contains("Backlog → ")) -} - -@Test func generatePromptOmitsJiraStatusMappingBlockWhenUnset() async { - let launcher = ClaudeLauncher() - let prompt = await launcher.generatePrompt( - session: Session(name: "jira-nomap", ticketNumber: 1), - worktrees: [], - ticketURL: "https://acme.atlassian.net/browse/MAXX-1", - provider: .jira, - codeProvider: .github - ) - #expect(!prompt.contains("## Jira Status Mapping")) -} - -@Test func generatePromptOmitsJiraStatusMappingForNonJiraProvider() async { - // A status map on a non-Jira session must never leak into the prompt. - let launcher = ClaudeLauncher() - let prompt = await launcher.generatePrompt( - session: Session(name: "gh", ticketNumber: 5), - worktrees: [], - ticketURL: "https://github.com/org/repo/issues/5", - provider: .github, - jiraStatusMap: ["In Progress": "In Development"] - ) - #expect(!prompt.contains("## Jira Status Mapping")) -} - @Test func generatePromptDoesNotStartWithPlanSlashCommand() async { // Plan mode is set via the `--permission-mode plan` flag in setup.sh, // not via a `/plan` slash-command prefix on the prompt body. See diff --git a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift index 484da47e..5f8ebce9 100644 --- a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift +++ b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift @@ -18,16 +18,20 @@ public enum JiraStatusFetcher { } /// Build the project-statuses REST URL for a site host + project key. - /// Accepts a bare host (`acme.atlassian.net`) or a full `https://…` origin. + /// Accepts a bare host (`acme.atlassian.net`) or a full origin. The scheme is + /// always forced to **https** — a `jiraSite` typed as `http://…` would + /// otherwise send the Atlassian Basic credential in cleartext. static func statusesURL(site: String, projectKey: String) -> URL? { let trimmedSite = site.trimmingCharacters(in: .whitespaces) let trimmedKey = projectKey.trimmingCharacters(in: .whitespaces) guard !trimmedSite.isEmpty, !trimmedKey.isEmpty else { return nil } - let origin = trimmedSite.hasPrefix("http") ? trimmedSite : "https://\(trimmedSite)" - guard let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + // Strip any user-supplied scheme (http/https) and always use https. + let bareHost = trimmedSite.range(of: "://").map { String(trimmedSite[$0.upperBound...]) } ?? trimmedSite + guard !bareHost.isEmpty, + let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { return nil } - return URL(string: "\(origin)/rest/api/3/project/\(encodedKey)/statuses") + return URL(string: "https://\(bareHost)/rest/api/3/project/\(encodedKey)/statuses") } /// Parse the `project/{key}/statuses` JSON payload into a de-duplicated, @@ -63,6 +67,9 @@ public enum JiraStatusFetcher { request.httpMethod = "GET" request.setValue(authorization, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") + // Click-driven affordance — surface a slow workflow as a timeout error + // rather than leaving the button spinning on the default 60s. + request.timeoutInterval = 15 do { let (data, response) = try await transport(request) diff --git a/Packages/CrowCore/Sources/CrowCore/StringExtensions.swift b/Packages/CrowCore/Sources/CrowCore/StringExtensions.swift new file mode 100644 index 00000000..4b3f2219 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/StringExtensions.swift @@ -0,0 +1,12 @@ +import Foundation + +public extension String { + /// The whitespace-and-newline-trimmed string, or `nil` if it is empty after + /// trimming. Single source for the "treat a blank value as unset" rule shared + /// across the Jira status-mapping code (#523) — a blank override field falls + /// back to the built-in default. + var nonBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift index 6971af53..ab12de85 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift @@ -14,6 +14,13 @@ import Foundation #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/project/MAXX/statuses") } + @Test func statusesURLForcesHTTPSOnCleartextOrigin() { + // A site typed as http:// must be upgraded so the Basic credential is + // never sent in cleartext. + let url = JiraStatusFetcher.statusesURL(site: "http://acme.atlassian.net", projectKey: "PROPS") + #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/project/PROPS/statuses") + } + @Test func statusesURLNilForBlankInputs() { #expect(JiraStatusFetcher.statusesURL(site: "", projectKey: "PROPS") == nil) #expect(JiraStatusFetcher.statusesURL(site: "acme.atlassian.net", projectKey: " ") == nil) diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift index ec70081c..671b80b2 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift @@ -226,11 +226,7 @@ public struct JiraTaskBackend: TaskBackend { /// falling back to ``defaultJiraStatusName(for:)``. A blank override entry is /// ignored (treated as unset) so an empty Settings field uses the default. func jiraStatusName(for status: TicketStatus) -> String { - if let mapped = config.statusMap?[status.rawValue], - !mapped.trimmingCharacters(in: .whitespaces).isEmpty { - return mapped - } - return Self.defaultJiraStatusName(for: status) + config.statusMap?[status.rawValue]?.nonBlank ?? Self.defaultJiraStatusName(for: status) } /// The built-in Crow→Jira status-name defaults, used as the fallback when a diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index cb5b58ae..0a570f05 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -320,6 +320,11 @@ public struct WorkspaceFormView: View { .task { jiraAvailability = await AcliProbe.availability() } + // A previously-fetched status list belongs to the old site/project — drop + // it (and any error) when either changes so a stale list can't be applied + // to a different project. + .onChange(of: jiraSite) { _, _ in clearFetchedStatuses() } + .onChange(of: jiraProjectKey) { _, _ in clearFetchedStatuses() } .safeAreaInset(edge: .bottom) { HStack { Button("Cancel") { dismiss() } @@ -384,8 +389,7 @@ public struct WorkspaceFormView: View { private var statusMapForSave: [String: String]? { var result: [String: String] = [:] for status in TicketStatus.pipelineStatuses { - let trimmed = (jiraStatusMap[status.rawValue] ?? "").trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { result[status.rawValue] = trimmed } + if let name = jiraStatusMap[status.rawValue]?.nonBlank { result[status.rawValue] = name } } return result.isEmpty ? nil : result } @@ -401,6 +405,12 @@ public struct WorkspaceFormView: View { /// Pull the live workflow status names for the configured project into /// `fetchedStatuses` (drives the per-row dropdown). Best-effort: surfaces a /// caption-level error and leaves the free-text fields untouched on failure. + /// Drop a stale fetched-status list (and error) when the site/project changes. + private func clearFetchedStatuses() { + fetchedStatuses = [] + fetchStatusesError = nil + } + private func fetchJiraStatuses() async { guard let fetchStatuses else { return } isFetchingStatuses = true diff --git a/Resources/crow-workspace-SKILL.md.template b/Resources/crow-workspace-SKILL.md.template index 017805b4..56b47e0d 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -108,9 +108,7 @@ configurable per project, so before `transitionJiraIssue` consult this workspace `jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states (`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual status names. Use the mapped name for the target state; fall back to the Crow default -(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. If -your prompt already includes a "Jira Status Mapping" section, that is the same map — -prefer it. +(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. ### Provider Detection from URL diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 4aa69de2..fee998bb 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -2395,11 +2395,15 @@ final class IssueTracker { let ws = jiraWorkspaces.first(where: { $0.jiraProjectKey?.uppercased() == project.uppercased() }) { return ws.jiraStatusMap } - // Then a site-host match (acli is authed to a single site). - if let ws = jiraWorkspaces.first(where: { ws in - guard let site = ws.jiraSite, !site.isEmpty else { return false } - return ticketURL.contains(site) - }) { + // Then an exact site-host match (acli is authed to a single site). Compare + // parsed hosts, not a loose substring, so "acme.atlassian.net" doesn't + // match a "dev.acme.atlassian.net" workspace (or vice versa). + if let ticketHost = URL(string: ticketURL)?.host, + let ws = jiraWorkspaces.first(where: { ws in + guard let site = ws.jiraSite, !site.isEmpty else { return false } + let siteHost = URL(string: site.hasPrefix("http") ? site : "https://\(site)")?.host ?? site + return siteHost.caseInsensitiveCompare(ticketHost) == .orderedSame + }) { return ws.jiraStatusMap } // Single Jira workspace with a map → unambiguous; use it. diff --git a/docs/configuration.md b/docs/configuration.md index 11741d0b..28335bef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,7 +180,7 @@ Jira workflow **status names are configurable per project**, so a project that r - **Keys** are Crow's pipeline states: `Backlog`, `Ready`, `In Progress`, `In Review`, `Done`. **Values** are the exact Jira workflow status names for that project (case- and spelling-sensitive). - **A missing or blank entry falls back to the built-in default:** `Ready` → `To Do`; every other state uses its own name verbatim (`In Progress`, `In Review`, `Done`, `Backlog`). An entirely unset `jiraStatusMap` keeps today's behavior. -- Both status surfaces consult the map: the in-app **"Mark in review"** transition (`acli`) and the **agent-side** MCP `transitionJiraIssue` flow (the launched session's prompt embeds the map, and the `/crow-workspace` skill also reads it from `config.json`). +- Both status surfaces consult the map: the in-app **"Mark in review"** transition (`acli`) and the **agent-side** MCP `transitionJiraIssue` flow (the `/crow-workspace` skill reads `jiraStatusMap` from `config.json` before transitioning). Edit it under **Settings → Workspaces → (a Jira workspace) → Jira Status Mapping**. Each pipeline state gets a field whose placeholder is the current default — leave it blank to keep the default. If an Atlassian MCP credential is configured, **Fetch from Jira** populates per-row dropdowns from the project's live workflow (`GET /rest/api/3/project/{key}/statuses`); otherwise the fields are free-text. diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index 017805b4..56b47e0d 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -108,9 +108,7 @@ configurable per project, so before `transitionJiraIssue` consult this workspace `jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states (`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual status names. Use the mapped name for the target state; fall back to the Crow default -(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. If -your prompt already includes a "Jira Status Mapping" section, that is the same map — -prefer it. +(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. ### Provider Detection from URL