diff --git a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift new file mode 100644 index 0000000..5f8ebce --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift @@ -0,0 +1,85 @@ +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 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 } + // 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: "https://\(bareHost)/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") + // 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) + 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 9cfed28..5cc63a0 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 14c9b14..71484a9 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/Sources/CrowCore/StringExtensions.swift b/Packages/CrowCore/Sources/CrowCore/StringExtensions.swift new file mode 100644 index 0000000..4b3f221 --- /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/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 7d384aa..9630e4d 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 0000000..ab12de8 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/JiraStatusFetcherTests.swift @@ -0,0 +1,81 @@ +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 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) + } + + @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 0613324..671b80b 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,24 @@ 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 { + config.statusMap?[status.rawValue]?.nonBlank ?? 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 0b60568..e426b86 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 fdc8b7a..8c47b3b 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 74b9c28..0a570f0 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") @@ -243,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() } @@ -282,6 +364,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 +374,54 @@ 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 { + if let name = jiraStatusMap[status.rawValue]?.nonBlank { result[status.rawValue] = name } + } + 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. + /// 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 + 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 80878bb..56b47e0 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -103,6 +103,13 @@ 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. + ### 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 5977372..fee998b 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,37 @@ 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 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. + 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 +2442,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 237a2e0..28335be 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 `/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. + ## 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 80878bb..56b47e0 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -103,6 +103,13 @@ 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. + ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST |