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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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))
}
}
}
12 changes: 11 additions & 1 deletion Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
) {
Expand All @@ -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
}
Expand All @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/StringExtensions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 20 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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",
])
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading