From c6b97997e182d1b953d8d547bacf414bc7f899f4 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 19 Jun 2026 14:39:30 -0500 Subject: [PATCH] =?UTF-8?q?Audit=20+=20fix=20Jira=20status=20transitions:?= =?UTF-8?q?=20session=20start=20=E2=86=92=20mapped=20In=20Progress=20(#529?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every ticket-state-change site and brought the Jira path to parity with GitHub. The headline bug: starting a Jira-backed ticket never moved it off Backlog — setup.sh only had a GitHub Projects-v2 mutation and explicitly skipped Jira. Mechanism (app-side Swift REST, single owner, headless-safe): - New JiraTransitionClient (CrowCore): GET the issue's available transitions, match the mapped status name, POST it. Unmapped/unreachable target → logged no-op, not an error. Reuses the atlassianMCP email+token as HTTP Basic (same plumbing JiraStatusFetcher/#523 already use); independent of acli (removed by #528) and of the agent-side MCP. - JiraConfig.authorization; JiraTaskBackend.setTaskStatus prefers REST when credentialed, falls back to acli otherwise. State-change sites: - Session start → mapped In Progress: new jira_ops() in setup.sh delegates to `crow transition-ticket` (runs for any code provider; covers crow-workspace and crow-batch-workspace, which share setup.sh). [was missing — the bug] - PR opened → In Review (markInReview) and mark done → Done (markIssueDone, #526) now resolve site + map + auth via IssueTracker.jiraConfig(forTicket:). - syncInReviewSessions / crow:merge gate: audited, no GitHub-Projects-v2 assumption for Jira; unchanged. Re-sync for tickets already stuck in Backlog: `crow resync-jira` walks every Jira-backed session and transitions its ticket to the status implied by the Crow session state, via the same graceful-degrade path (no-op when already correct). Single-ticket form: `crow transition-ticket --session --to ...`. New RPC verbs transition-ticket / resync-jira; CLI commands; docs. Tests: JiraTransitionClient (URL/match/gating/graceful no-op), JiraTaskBackend REST-vs-acli branch, CLI parse. Full suites green (CrowCore 306, CrowProvider 52, CrowCLI 43); app builds clean. Closes #529 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 2908EA7D-C272-4742-87A3-1B8CFAF9C007 --- CLAUDE.md | 2 + .../Commands/MetadataCommands.swift | 46 ++++++ .../Sources/CrowCLILib/CrowCommand.swift | 2 + .../CrowCLITests/CommandParsingTests.swift | 28 ++++ .../CrowCore/JiraTransitionClient.swift | 150 ++++++++++++++++++ .../JiraTransitionClientTests.swift | 129 +++++++++++++++ .../Backends/JiraTaskBackend.swift | 54 ++++++- .../JiraTaskBackendTests.swift | 70 ++++++++ Sources/Crow/App/AppDelegate.swift | 44 +++++ Sources/Crow/App/IssueTracker.swift | 129 ++++++++++++--- docs/automation.md | 22 ++- skills/crow-workspace/setup.sh | 23 ++- 12 files changed, 671 insertions(+), 28 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/JiraTransitionClient.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/JiraTransitionClientTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 712ed513..da57585b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,8 @@ crow delete-session --session → {"deleted":true} crow set-ticket --session --url "..." [--title "..."] [--number N] crow add-link --session --label "Issue" --url "..." --type ticket|pr|repo|custom crow list-links --session +crow transition-ticket --session --to inProgress|inReview|done → moves the linked ticket to a pipeline status (Jira honors jiraStatusMap) +crow resync-jira → re-sync every Jira ticket's status from its Crow session state ``` ### Worktree Commands diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/MetadataCommands.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/MetadataCommands.swift index 759e13e7..55379c58 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/Commands/MetadataCommands.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/MetadataCommands.swift @@ -57,6 +57,52 @@ public struct AddLink: ParsableCommand { } } +/// Transition a session's linked ticket to a pipeline status (CROW-529). +/// +/// For Jira this consults the workspace's `jiraStatusMap` and transitions via the +/// Jira Cloud REST API; for GitHub it moves the Projects-v2 status. `setup.sh` +/// calls this at session start (`--to inProgress`) so a Jira work item leaves +/// Backlog — the GitHub-only mutation in `setup.sh` had no Jira equivalent. +public struct TransitionTicket: ParsableCommand { + public static let configuration = CommandConfiguration(commandName: "transition-ticket", abstract: "Transition a session's ticket to a pipeline status") + @Option(name: .long, help: "Session UUID") var session: String + @Option(name: .long, help: "Target status: inProgress, inReview, or done") var to: String + + public init() {} + + static let allowedStatuses = ["inProgress", "inReview", "done"] + + public func validate() throws { + try validateUUID(session, label: "session UUID") + let normalized = to.lowercased() + guard Self.allowedStatuses.contains(where: { $0.lowercased() == normalized }) else { + throw ValidationError("Invalid --to '\(to)' (expected one of: \(Self.allowedStatuses.joined(separator: ", ")))") + } + } + + public func run() throws { + let result = try rpc("transition-ticket", params: [ + "session_id": .string(session), + "to": .string(to), + ]) + printJSON(result) + } +} + +/// Re-sync every Jira-backed session's ticket to the status implied by its Crow +/// session state (CROW-529) — one-shot remediation for tickets stuck in Backlog +/// because earlier sessions never transitioned them. +public struct ResyncJira: ParsableCommand { + public static let configuration = CommandConfiguration(commandName: "resync-jira", abstract: "Re-sync Jira ticket statuses from Crow session state") + + public init() {} + + public func run() throws { + let result = try rpc("resync-jira") + printJSON(result) + } +} + /// List all links for a session. public struct ListLinks: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "list-links", abstract: "List links for a session") diff --git a/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift index 4da2483f..6e8d06d7 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift @@ -28,6 +28,8 @@ public struct CrowCommand: ParsableCommand { Send.self, AddLink.self, ListLinks.self, + TransitionTicket.self, + ResyncJira.self, HookEventCmd.self, CodexNotify.self, ] diff --git a/Packages/CrowCLI/Tests/CrowCLITests/CommandParsingTests.swift b/Packages/CrowCLI/Tests/CrowCLITests/CommandParsingTests.swift index 2cfef07b..6760cc22 100644 --- a/Packages/CrowCLI/Tests/CrowCLITests/CommandParsingTests.swift +++ b/Packages/CrowCLI/Tests/CrowCLITests/CommandParsingTests.swift @@ -74,3 +74,31 @@ private let validUUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" let cmd = try AddLink.parse(["--session", validUUID, "--label", "Docs", "--url", "https://docs.com"]) #expect(cmd.type == "custom") } + +// MARK: - transition-ticket (#529) + +@Test func transitionTicketParsesValidArgs() throws { + let cmd = try TransitionTicket.parse(["--session", validUUID, "--to", "inProgress"]) + #expect(cmd.session == validUUID) + #expect(cmd.to == "inProgress") + try cmd.validate() +} + +@Test func transitionTicketAcceptsCaseInsensitiveStatus() throws { + let cmd = try TransitionTicket.parse(["--session", validUUID, "--to", "INREVIEW"]) + try cmd.validate() +} + +@Test func transitionTicketRejectsUnknownStatus() { + #expect(throws: (any Error).self) { + let cmd = try TransitionTicket.parse(["--session", validUUID, "--to", "backlog"]) + try cmd.validate() + } +} + +@Test func transitionTicketRejectsInvalidUUID() { + #expect(throws: (any Error).self) { + let cmd = try TransitionTicket.parse(["--session", "not-a-uuid", "--to", "done"]) + try cmd.validate() + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/JiraTransitionClient.swift b/Packages/CrowCore/Sources/CrowCore/JiraTransitionClient.swift new file mode 100644 index 00000000..e99b2577 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/JiraTransitionClient.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Performs a Jira **workflow status transition** via the Jira Cloud REST API, +/// fetching the issue's available transitions first and only firing one that +/// actually reaches the requested status name (#529). +/// +/// Why REST and not `acli` / the agent-side MCP: +/// - `acli` is being removed (#528), and it blind-fires `--status NAME` with no +/// way to know whether that status is a reachable transition — an invalid name +/// is a hard error instead of a graceful no-op. +/// - The agent-side `jira` MCP lives in launched Claude sessions; the Crow app +/// (Manager / cron / UI-driven mark-in-review & mark-done) can't reach it. +/// +/// So the app drives transitions itself over REST, authenticated with the same +/// Atlassian email + API token used elsewhere (HTTP Basic via +/// ``JiraCredentialResolver``). This mirrors ``JiraStatusFetcher`` (which already +/// reaches Jira Cloud REST for the Settings status picker) — same site host, +/// same credential, injectable transport for tests. +public enum JiraTransitionClient { + /// The result of attempting a transition. + public enum Outcome: Equatable, Sendable { + /// The matching transition was found and POSTed successfully. + case transitioned(id: String) + /// No available transition reaches `targetStatusName` — a graceful no-op + /// (the caller logs and moves on rather than erroring). `available` lists + /// the reachable target-status names for diagnostics. + case noMatchingTransition(available: [String]) + } + + public enum TransitionError: Error, Equatable, Sendable { + case badSite + case http(Int) + case transport(String) + case decode + } + + /// A single workflow transition: its id, the transition's own name, and the + /// status name it moves the issue **to**. + struct Transition: Equatable { + let id: String + let name: String + let toName: String + } + + /// Build the transitions REST URL for a site host + issue key. Accepts a bare + /// host (`acme.atlassian.net`) or a full origin; the scheme is always forced + /// to **https** so the Basic credential is never sent in cleartext (mirrors + /// ``JiraStatusFetcher/statusesURL(site:projectKey:)``). + static func transitionsURL(site: String, issueKey: String) -> URL? { + let trimmedSite = site.trimmingCharacters(in: .whitespaces) + let trimmedKey = issueKey.trimmingCharacters(in: .whitespaces) + guard !trimmedSite.isEmpty, !trimmedKey.isEmpty else { return nil } + 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/issue/\(encodedKey)/transitions") + } + + /// Parse the `GET /transitions` payload (`{ "transitions": [ { id, name, + /// to: { name } } ] }`) into a list of ``Transition``. + static func parseTransitions(_ data: Data) -> [Transition]? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let raw = json["transitions"] as? [[String: Any]] else { return nil } + return raw.compactMap { item in + guard let id = item["id"] as? String else { return nil } + let name = item["name"] as? String ?? "" + let toName = (item["to"] as? [String: Any])?["name"] as? String ?? "" + return Transition(id: id, name: name, toName: toName) + } + } + + /// Find the id of a transition that reaches `targetName`. Matches the target + /// **status** name (`to.name`) first — that's what `jiraStatusMap` resolves to + /// — then falls back to the transition's own `name` for workflows whose + /// transition label equals the status. Case-insensitive. + static func matchTransitionID(in transitions: [Transition], targetName: String) -> String? { + let target = targetName.trimmingCharacters(in: .whitespaces) + guard !target.isEmpty else { return nil } + if let byTo = transitions.first(where: { $0.toName.caseInsensitiveCompare(target) == .orderedSame }) { + return byTo.id + } + if let byName = transitions.first(where: { $0.name.caseInsensitiveCompare(target) == .orderedSame }) { + return byName.id + } + return nil + } + + /// Transition `issueKey` on `site` to the workflow status named + /// `targetStatusName`, authenticated with `authorization` (a full header + /// value, e.g. `Basic …` from ``JiraCredentialResolver``). Fetches the available + /// transitions, matches the target, and POSTs it. Returns + /// `.noMatchingTransition` (not a failure) when the target isn't reachable so + /// callers degrade gracefully. Injectable transport for testing. + public static func transition( + site: String, + issueKey: String, + targetStatusName: String, + authorization: String, + transport: (URLRequest) async throws -> (Data, URLResponse) = { try await URLSession.shared.data(for: $0) } + ) async -> Result { + guard let url = transitionsURL(site: site, issueKey: issueKey) else { + return .failure(.badSite) + } + + // 1. Fetch available transitions. + var getRequest = URLRequest(url: url) + getRequest.httpMethod = "GET" + getRequest.setValue(authorization, forHTTPHeaderField: "Authorization") + getRequest.setValue("application/json", forHTTPHeaderField: "Accept") + getRequest.timeoutInterval = 15 + + let transitions: [Transition] + do { + let (data, response) = try await transport(getRequest) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + return .failure(.http(http.statusCode)) + } + guard let parsed = parseTransitions(data) else { return .failure(.decode) } + transitions = parsed + } catch { + return .failure(.transport(error.localizedDescription)) + } + + // 2. Match the requested status name against a reachable transition. + guard let transitionID = matchTransitionID(in: transitions, targetName: targetStatusName) else { + return .success(.noMatchingTransition(available: transitions.map(\.toName))) + } + + // 3. POST the transition. + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + postRequest.setValue(authorization, forHTTPHeaderField: "Authorization") + postRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + postRequest.setValue("application/json", forHTTPHeaderField: "Accept") + postRequest.timeoutInterval = 15 + postRequest.httpBody = try? JSONSerialization.data(withJSONObject: ["transition": ["id": transitionID]]) + + do { + let (_, response) = try await transport(postRequest) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + return .failure(.http(http.statusCode)) + } + return .success(.transitioned(id: transitionID)) + } catch { + return .failure(.transport(error.localizedDescription)) + } + } +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/JiraTransitionClientTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/JiraTransitionClientTests.swift new file mode 100644 index 00000000..effa8e65 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/JiraTransitionClientTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import CrowCore + +@Suite struct JiraTransitionClientTests { + + // Sample `GET /transitions` payload: transition names differ from their + // target status names (the realistic Jira shape we must match on `to.name`). + private static let transitionsPayload = """ + {"transitions":[ + {"id":"11","name":"Start Progress","to":{"name":"In Development"}}, + {"id":"21","name":"Code Review","to":{"name":"In Review"}}, + {"id":"31","name":"Resolve","to":{"name":"Done"}} + ]} + """.data(using: .utf8)! + + // MARK: - URL building + + @Test func buildsTransitionsURLFromBareHost() { + let url = JiraTransitionClient.transitionsURL(site: "acme.atlassian.net", issueKey: "MAXX-12") + #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/issue/MAXX-12/transitions") + } + + @Test func transitionsURLForcesHTTPSOnCleartextOrigin() { + let url = JiraTransitionClient.transitionsURL(site: "http://acme.atlassian.net", issueKey: "MAXX-12") + #expect(url?.absoluteString == "https://acme.atlassian.net/rest/api/3/issue/MAXX-12/transitions") + } + + @Test func transitionsURLNilForBlankInputs() { + #expect(JiraTransitionClient.transitionsURL(site: "", issueKey: "MAXX-1") == nil) + #expect(JiraTransitionClient.transitionsURL(site: "acme.atlassian.net", issueKey: " ") == nil) + } + + // MARK: - Matching + + @Test func matchesOnTargetStatusNameCaseInsensitively() { + let transitions = try! #require(JiraTransitionClient.parseTransitions(Self.transitionsPayload)) + #expect(JiraTransitionClient.matchTransitionID(in: transitions, targetName: "In Development") == "11") + #expect(JiraTransitionClient.matchTransitionID(in: transitions, targetName: "in review") == "21") + #expect(JiraTransitionClient.matchTransitionID(in: transitions, targetName: "DONE") == "31") + } + + @Test func matchesOnTransitionNameWhenStatusNameDoesNotMatch() { + let transitions = try! #require(JiraTransitionClient.parseTransitions(Self.transitionsPayload)) + // No `to.name` is "Start Progress", but the transition's own name is. + #expect(JiraTransitionClient.matchTransitionID(in: transitions, targetName: "Start Progress") == "11") + } + + @Test func noMatchForUnknownStatus() { + let transitions = try! #require(JiraTransitionClient.parseTransitions(Self.transitionsPayload)) + #expect(JiraTransitionClient.matchTransitionID(in: transitions, targetName: "Backlog") == nil) + } + + // MARK: - Full transition flow + + @Test func transitionsWhenStatusReachableAndPOSTsMatchingID() async { + var posted: [String: Any]? + var sawGET = false + let result = await JiraTransitionClient.transition( + site: "acme.atlassian.net", + issueKey: "MAXX-12", + targetStatusName: "In Development", + authorization: "Basic creds", + transport: { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + if request.httpMethod == "GET" { + sawGET = true + #expect(request.value(forHTTPHeaderField: "Authorization") == "Basic creds") + return (Self.transitionsPayload, response) + } else { + posted = try! JSONSerialization.jsonObject(with: request.httpBody ?? Data()) as? [String: Any] + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!) + } + } + ) + #expect(sawGET) + #expect(result == .success(.transitioned(id: "11"))) + let transition = posted?["transition"] as? [String: Any] + #expect(transition?["id"] as? String == "11") + } + + @Test func gracefulNoOpWhenTargetStatusNotReachable() async { + var didPOST = false + let result = await JiraTransitionClient.transition( + site: "acme.atlassian.net", + issueKey: "MAXX-12", + targetStatusName: "Backlog", // not a reachable transition + authorization: "Basic creds", + transport: { request in + if request.httpMethod == "POST" { didPOST = true } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Self.transitionsPayload, response) + } + ) + // No POST fired — degrade gracefully rather than erroring. + #expect(!didPOST) + #expect(result == .success(.noMatchingTransition(available: ["In Development", "In Review", "Done"]))) + } + + @Test func surfacesHTTPErrorOnTransitionsFetch() async { + let result = await JiraTransitionClient.transition( + site: "acme.atlassian.net", + issueKey: "MAXX-12", + targetStatusName: "Done", + 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))) + } + + @Test func badSiteFailsBeforeAnyRequest() async { + var calledTransport = false + let result = await JiraTransitionClient.transition( + site: "", + issueKey: "MAXX-12", + targetStatusName: "Done", + authorization: "Basic x", + transport: { request in + calledTransport = true + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + #expect(!calledTransport) + #expect(result == .failure(.badSite)) + } +} diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift index 94301b73..0ceb5646 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift @@ -19,12 +19,19 @@ public struct JiraConfig: Sendable, Equatable { /// 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]? + /// HTTP Basic `Authorization` header (e.g. `Basic …` from + /// ``JiraCredentialResolver``) for the Jira Cloud REST transition path (#529). + /// When set (together with `site`), `setTaskStatus`/`closeTask` transition via + /// ``JiraTransitionClient`` — fetching available transitions and degrading + /// gracefully — instead of `acli`. Nil falls back to the legacy `acli` path. + public let authorization: String? - public init(site: String? = nil, projectKey: String? = nil, jql: String? = nil, statusMap: [String: String]? = nil) { + public init(site: String? = nil, projectKey: String? = nil, jql: String? = nil, statusMap: [String: String]? = nil, authorization: String? = nil) { self.site = site self.projectKey = projectKey self.jql = jql self.statusMap = statusMap + self.authorization = authorization } } @@ -37,8 +44,11 @@ public struct JiraConfig: Sendable, Equatable { /// returns `nil`. /// /// Capabilities: `.projectBoardStatus` — Jira workflow transitions are a real -/// status concept, wired through `setTaskStatus` via `acli jira workitem transition`. -/// Not `.batchedQuery` — `acli` has no consolidated multi-item endpoint. +/// status concept, wired through `setTaskStatus`. When a REST credential is +/// configured (``JiraConfig/authorization`` + `site`) transitions go via the +/// Jira Cloud REST API (``JiraTransitionClient``, #529); otherwise they fall +/// back to `acli jira workitem transition`. Not `.batchedQuery` — `acli` has no +/// consolidated multi-item endpoint. /// /// See ADR 0005. public struct JiraTaskBackend: TaskBackend { @@ -47,6 +57,8 @@ public struct JiraTaskBackend: TaskBackend { private let shellRunner: ShellRunner private let config: JiraConfig + /// HTTP transport for the REST transition path (#529); injectable for tests. + private let transport: @Sendable (URLRequest) async throws -> (Data, URLResponse) /// Default "my open tickets" query. Overridable per-workspace via `JiraConfig.jql`. static let defaultOpenJQL = "assignee = currentUser() AND statusCategory != Done" @@ -54,9 +66,14 @@ public struct JiraTaskBackend: TaskBackend { /// closed-issue removal detection in IssueTracker. static let closedJQL = "assignee = currentUser() AND statusCategory = Done AND updated >= -1d" - public init(shellRunner: ShellRunner, config: JiraConfig = JiraConfig()) { + public init( + shellRunner: ShellRunner, + config: JiraConfig = JiraConfig(), + transport: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { try await URLSession.shared.data(for: $0) } + ) { self.shellRunner = shellRunner self.config = config + self.transport = transport } // MARK: - TaskBackend @@ -128,10 +145,37 @@ public struct JiraTaskBackend: TaskBackend { public func setTaskStatus(url: String, status: TicketStatus) async throws { guard let parsed = JiraKey.parse(url) else { throw ProviderError.invalidURL(url) } + let targetName = jiraStatusName(for: status) + + // Preferred path (#529): credentialed Jira Cloud REST. Fetches the + // issue's available transitions first, so a target status that isn't a + // reachable transition (or an unmapped name) is a logged no-op rather + // than a hard error. Independent of `acli` (removed in #528). + if let authorization = config.authorization, let site = config.site, !site.isEmpty { + let outcome = await JiraTransitionClient.transition( + site: site, + issueKey: parsed.key, + targetStatusName: targetName, + authorization: authorization, + transport: transport + ) + switch outcome { + case .success(.transitioned): + return + case .success(.noMatchingTransition(let available)): + NSLog("[JiraTaskBackend] No transition to \"%@\" available for %@ (reachable: %@); skipping", + targetName, parsed.key, available.isEmpty ? "none" : available.joined(separator: ", ")) + return + case .failure(let error): + throw ProviderError.commandFailed("Jira REST transition failed for \(parsed.key): \(error)") + } + } + + // Legacy fallback: `acli` (pre-#528, or when no REST credential is set). _ = try await run([ "acli", "jira", "workitem", "transition", "--key", parsed.key, - "--status", jiraStatusName(for: status), + "--status", targetName, "--yes", ]) } diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift index 8b84d633..dcf26e64 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/JiraTaskBackendTests.swift @@ -2,6 +2,16 @@ import XCTest import CrowCore @testable import CrowProvider +/// Thread-safe mutable cell so a `@Sendable` transport closure can record what it +/// observed without tripping the concurrency checker. +private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var _value: T + init(_ value: T) { _value = value } + func set(_ value: T) { lock.lock(); _value = value; lock.unlock() } + func get() -> T { lock.lock(); defer { lock.unlock() }; return _value } +} + /// Exercises `JiraTaskBackend` against `FakeShellRunner` — the ADR 0005 /// testability bar. Asserts the exact `acli` argv for each method plus the JSON /// parsing, key parsing, and status mapping, without spawning real `acli`. @@ -192,6 +202,66 @@ final class JiraTaskBackendTests: XCTestCase { XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "Code Review") } + // MARK: - setTaskStatus REST path (#529) + + private static let transitionsJSON = """ + {"transitions":[ + {"id":"11","name":"Start","to":{"name":"In Development"}}, + {"id":"21","name":"Review","to":{"name":"In Review"}}, + {"id":"31","name":"Resolve","to":{"name":"Done"}} + ]} + """.data(using: .utf8)! + + /// With an Authorization header + site, transitions go via REST — `acli` is + /// never shelled out, and the POST carries the id of the transition whose + /// target status matches the mapped name. + func testSetTaskStatusUsesRESTWhenCredentialed() async throws { + let fake = FakeShellRunner() + let postedID = Box(nil) + let cfg = JiraConfig( + site: "acme.atlassian.net", + statusMap: ["In Progress": "In Development"], + authorization: "Basic creds" + ) + let b = JiraTaskBackend(shellRunner: fake, config: cfg, transport: { request in + if request.httpMethod == "POST" { + let body = try! JSONSerialization.jsonObject(with: request.httpBody ?? Data()) as? [String: Any] + postedID.set((body?["transition"] as? [String: Any])?["id"] as? String) + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!) + } + return (Self.transitionsJSON, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + }) + try await b.setTaskStatus(url: "https://acme.atlassian.net/browse/MAXX-7", status: .inProgress) + XCTAssertEqual(postedID.get(), "11") + XCTAssertTrue(fake.calls.isEmpty, "REST path must not shell out to acli") + } + + /// An unreachable target status is a graceful no-op: no POST, no throw, no acli. + func testSetTaskStatusRESTGracefulNoOpWhenUnavailable() async throws { + let fake = FakeShellRunner() + let didPOST = Box(false) + let cfg = JiraConfig(site: "acme.atlassian.net", authorization: "Basic creds") + let b = JiraTaskBackend(shellRunner: fake, config: cfg, transport: { request in + if request.httpMethod == "POST" { didPOST.set(true) } + return (Self.transitionsJSON, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + }) + // .backlog → "Backlog", which is not a reachable transition above. + try await b.setTaskStatus(url: "https://acme.atlassian.net/browse/MAXX-7", status: .backlog) + XCTAssertFalse(didPOST.get()) + XCTAssertTrue(fake.calls.isEmpty) + } + + /// Without an Authorization header, the legacy `acli` path is used (no REST). + func testSetTaskStatusFallsBackToAcliWithoutCredential() async throws { + let fake = FakeShellRunner() + let b = JiraTaskBackend(shellRunner: fake, config: JiraConfig(site: "acme.atlassian.net"), transport: { _ in + XCTFail("transport must not be called without a credential") + throw ProviderError.commandFailed("unreachable") + }) + try await b.setTaskStatus(url: "https://acme.atlassian.net/browse/PROJ-5", status: .inReview) + XCTAssertEqual(Array((fake.calls.first?.args ?? []).prefix(4)), ["acli", "jira", "workitem", "transition"]) + } + // MARK: - closeTask func testCloseTaskTransitionsToDefaultDone() async throws { diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 5e2d5123..907d92b8 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -1297,12 +1297,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Validation.isValidSessionName(name) } + /// Map a `transition-ticket --to` argument to a pipeline ``TicketStatus`` + /// (CROW-529). Accepts the camelCase tokens the CLI documents plus a few + /// forgiving spellings and the raw status value. Only the three states a + /// transition site moves a ticket to are accepted; `nil` for anything else. + nonisolated static func ticketStatus(fromArg arg: String) -> TicketStatus? { + switch arg.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "").replacingOccurrences(of: " ", with: "") { + case "inprogress": return .inProgress + case "inreview": return .inReview + case "done", "completed", "closed": return .done + default: return nil + } + } + private func startSocketServer(store: JSONStore, devRoot: String, sessionService: SessionService) { let capturedAppState = appState let capturedStore = store let capturedNotifManager = notificationManager let capturedService = sessionService let capturedTelemetryPort = sessionService.telemetryPort + // Set in applicationDidFinishLaunching before this runs (CROW-529: the + // `transition-ticket` / `resync-jira` verbs drive Jira status moves). + let capturedTracker = issueTracker let hookDebug = ProcessInfo.processInfo.environment["CROW_HOOK_DEBUG"] == "1" let router = CommandRouter(handlers: [ @@ -1463,6 +1479,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return ["session_id": .string(idStr)] } }, + "transition-ticket": { @Sendable params in + // CROW-529: transition a session's linked ticket to a pipeline + // status (honoring jiraStatusMap for Jira). `setup.sh` calls this + // at session start to move a Jira work item to its mapped + // In-Progress status — the GitHub Projects-v2 mutation setup.sh + // already does has no Jira equivalent without this. + guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { + throw RPCError.invalidParams("session_id required") + } + guard let toStr = params["to"]?.stringValue, + let status = AppDelegate.ticketStatus(fromArg: toStr) else { + throw RPCError.invalidParams("`to` required (one of: inProgress, inReview, done)") + } + guard let tracker = capturedTracker else { + throw RPCError.applicationError("Issue tracker not ready") + } + await tracker.transitionTicket(sessionID: id, to: status) + return ["session_id": .string(idStr), "to": .string(status.rawValue)] + }, + "resync-jira": { @Sendable _ in + // CROW-529: one-shot remediation for Jira tickets stuck in Backlog + // because earlier sessions never transitioned them. + guard let tracker = capturedTracker else { + throw RPCError.applicationError("Issue tracker not ready") + } + let attempted = await tracker.resyncJira() + return ["attempted": .int(attempted)] + }, "add-worktree": { @Sendable params in guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr), let repo = params["repo"]?.stringValue, !repo.isEmpty, diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index e58f8544..c0737915 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -2379,35 +2379,64 @@ 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 } + /// Find the configured Jira workspace whose project key (then exact site host, + /// then sole-candidate fallback) matches `ticketURL`. Shared by the status-map + /// and full-config resolvers so the matching can never drift. `candidates` + /// lets callers pre-filter (e.g. to workspaces that define a status map). + private static func matchJiraWorkspace(_ candidates: [WorkspaceInfo], forTicket ticketURL: String) -> WorkspaceInfo? { + guard !candidates.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 + let ws = candidates.first(where: { $0.jiraProjectKey?.uppercased() == project.uppercased() }) { + return ws } // 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 + let ws = candidates.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 + return ws + } + // Single candidate → unambiguous; use it. + return candidates.count == 1 ? candidates[0] : nil + } + + /// Resolve the per-workspace Crow→Jira status-name map (#523) for a ticket. + /// 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 candidates = config.workspaces.filter { + $0.derivedTaskProvider == "jira" && !($0.jiraStatusMap?.isEmpty ?? true) } - // Single Jira workspace with a map → unambiguous; use it. - return jiraWorkspaces.count == 1 ? jiraWorkspaces[0].jiraStatusMap : nil + return matchJiraWorkspace(candidates, forTicket: ticketURL)?.jiraStatusMap + } + + /// Build the full ``JiraConfig`` for a ticket: the matching workspace's site / + /// project / JQL / status-map (#523) plus the resolved Jira Cloud REST + /// `Authorization` header (#529) so `setTaskStatus`/`closeTask` transition via + /// REST rather than `acli`. The credential is the org-wide `jiraCredential` + /// username + API token (HTTP Basic, #528), the same one the Settings status + /// picker uses; nil when unconfigured, leaving the backend on its `acli` + /// fallback. + static func jiraConfig(forTicket ticketURL: String) -> JiraConfig { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot) else { return JiraConfig() } + let candidates = config.workspaces.filter { $0.derivedTaskProvider == "jira" } + let ws = matchJiraWorkspace(candidates, forTicket: ticketURL) + let authorization = config.jiraCredential.flatMap { JiraCredentialResolver.resolve($0) } + return JiraConfig( + site: ws?.jiraSite, + projectKey: ws?.jiraProjectKey, + jql: ws?.jiraJQL, + statusMap: ws?.jiraStatusMap, + authorization: authorization + ) } private func fetchJiraIssues(config: JiraConfig) async -> [AssignedIssue] { @@ -2446,7 +2475,7 @@ final class IssueTracker { // (#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)) + ? Self.jiraConfig(forTicket: ticketURL) : nil let backend = providerManager.taskBackend(for: taskProvider, jira: jiraConfig) // Capability-gated across providers: GitHub Projects v2 and Jira workflow @@ -2499,8 +2528,7 @@ final class IssueTracker { // GitLab/Corveil self-hosted instances are targeted correctly. let backend: TaskBackend if taskProvider == .jira { - let cfg = JiraConfig(statusMap: Self.jiraStatusMap(forTicket: ticketURL)) - backend = providerManager.taskBackend(for: .jira, jira: cfg) + backend = providerManager.taskBackend(for: .jira, jira: Self.jiraConfig(forTicket: ticketURL)) } else { backend = providerManager.taskBackend(forURL: ticketURL) } @@ -2529,6 +2557,67 @@ final class IssueTracker { appState.onCompleteSession?(sessionID) } + // MARK: - Transition ticket (session start, resync) + + /// Transition a session's linked ticket to an explicit pipeline `status`, + /// honoring the per-workspace `jiraStatusMap` for Jira (#523/#529). This is the + /// app-side entry point for the **session-start → In Progress** transition + /// that `setup.sh` delegates here via `crow transition-ticket` — `setup.sh` + /// only owns the GitHub Projects-v2 mutation, so a Jira session never moved + /// off Backlog before. Capability-gated (`.projectBoardStatus`), so GitLab + /// (no board status) is a no-op. Best-effort: auth / unavailable-transition + /// failures are logged and swallowed, mirroring `markInReview`. + func transitionTicket(sessionID: UUID, to status: TicketStatus) async { + guard let session = appState.sessions.first(where: { $0.id == sessionID }), + let ticketURL = session.ticketURL, + let taskProvider = session.provider else { return } + + let backend: TaskBackend + if taskProvider == .jira { + backend = providerManager.taskBackend(for: .jira, jira: Self.jiraConfig(forTicket: ticketURL)) + } else { + backend = providerManager.taskBackend(forURL: ticketURL) + } + guard backend.capabilities.contains(.projectBoardStatus) else { return } + + do { + try await backend.setTaskStatus(url: ticketURL, status: status) + } catch { + print("[IssueTracker] transitionTicket(\(status.rawValue)) failed for \(ticketURL): \(error.localizedDescription.prefix(200))") + return + } + + if let idx = appState.assignedIssues.firstIndex(where: { $0.url == ticketURL }) { + appState.assignedIssues[idx].projectStatus = status + } + print("[IssueTracker] Transitioned \(ticketURL) to \(status.rawValue)") + } + + /// One-shot remediation (#529): walk every Jira-backed session and transition + /// its ticket to the status implied by the Crow session state — fixing tickets + /// left in Backlog because session-start never transitioned them. Each move + /// goes through the same graceful-degrade REST path, so tickets already in the + /// right status (or lacking a valid transition) are no-ops. Returns the number + /// of sessions it attempted. Drives `crow resync-jira`. + @discardableResult + func resyncJira() async -> Int { + let targets: [(id: UUID, status: TicketStatus)] = appState.sessions.compactMap { session in + guard session.provider == .jira, session.ticketURL != nil else { return nil } + let status: TicketStatus + switch session.status { + case .inReview: status = .inReview + case .completed, .archived: status = .done + case .active, .paused: status = .inProgress + } + return (session.id, status) + } + for target in targets { + await transitionTicket(sessionID: target.id, to: target.status) + } + print("[IssueTracker] resyncJira: attempted \(targets.count) Jira session(s)") + return targets.count + } + /// Add the `crow:merge` auto-merge label to a session's PR, ensuring the /// label exists in the repo first. Capability-gated on `.autoMergeLabel` /// (GitHub only today). Mirrors `markInReview`'s in-flight/error handling. diff --git a/docs/automation.md b/docs/automation.md index 41fe2784..9b5a0d47 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -7,7 +7,7 @@ Crow automates the boring parts of moving a ticket from "assigned" to "merged". A fully automated ticket walks through these stages: 1. **Assignment** — an issue is assigned to you. If it carries the `crow:auto` label *and* the **Auto-launch workspaces** toggle is on (off by default — #312), Crow auto-creates a workspace for it (#211). -2. **Workspace** — a git worktree is created, ticket metadata is captured, and the issue is moved to "In Progress" on the project board. +2. **Workspace** — a git worktree is created, ticket metadata is captured, and the issue is moved to "In Progress" on the project board. For **GitHub** this is a Projects v2 status mutation; for **Jira** it transitions the work item to the status `jiraStatusMap` maps `In Progress` → (see [Jira status transitions](#jira-status-transitions)). 3. **Session** — Claude Code launches in plan mode with the issue context. Worker sessions inherit the configured permission mode; the Manager terminal can launch with `--permission-mode auto` (#189). 4. **PR open** — when Claude pushes the branch and you open a PR, Crow auto-suggests opening one if you forget (#213). 5. **Review** — repos that opt in get a review session auto-started when the PR turns reviewable (#209). The review board lets you batch-start, bulk-delete, and filter sessions (#207, #210, #212, #220, #226, #231). @@ -52,6 +52,26 @@ The `jira` server is configured **globally** in `~/.claude.json`'s top-level `mc > **In-app status fetch.** The "Fetch from Jira" button in **Settings → Workspaces** (the #523 status map) calls Jira's REST API *directly from the Crow app process*, which cannot use the MCP. That one feature uses a small **Settings → Automation → Jira (status fetch)** credential (`JIRA_USERNAME` + an `op://`/plaintext API token), stored in `config.json` as `jiraCredential`. It is unrelated to the agent-side MCP. + +**Jira status transitions (app-side).** The MCP above is *agent-side* (it lives in launched Claude sessions). Crow itself also moves a Jira work item through its workflow at three points the app owns — and those run **headless** (Manager, batch, cron), where no agent session is available: + +- **Session start → mapped In Progress** — `/crow-workspace` and `/crow-batch-workspace` transition the work item as the worktree is set up (previously a no-op for Jira, so tickets sat in Backlog — #529). +- **PR opened → mapped In Review** and **mark done → mapped Done** — the same path backs the session-row "Mark in review"/"Mark issue done" actions. + +Each transition resolves the target status name via the per-workspace **`jiraStatusMap`** (#523; default `Ready`→`To Do`, others verbatim — e.g. `In Progress`→`In Development` for SecurityScorecard), then calls the Jira Cloud REST API: it **fetches the issue's available transitions first** and only fires one that reaches the mapped status, so an unmapped or currently-unavailable status is a logged **no-op rather than an error**. It reuses the same **Settings → Automation → Jira (status fetch)** credential (`jiraCredential`, HTTP Basic) as the status-map "Fetch from Jira" button above; with no credential configured it falls back to `acli`. This path does **not** depend on the agent-side MCP. + +**Re-sync stuck tickets.** To fix Jira tickets left in Backlog by sessions started before this existed, run: + +```bash +crow resync-jira +``` + +It walks every Jira-backed session and transitions its ticket to the status implied by the Crow session state (`active`→In Progress, `inReview`→In Review, `completed`→Done). Each move goes through the same graceful-degrade path, so tickets already in the right status are no-ops. You can also move a single session's ticket explicitly: + +```bash +crow transition-ticket --session --to inProgress # or inReview | done +``` + ### Auto-respond PR #214 added two opt-in toggles that let Crow type a follow-up instruction into a session's Claude Code terminal when a PR signal arrives. Both are off by default — typing into a running terminal unprompted is intrusive. diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index 9c455e98..3a8697e8 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -782,8 +782,9 @@ github_ops() { # CROW-522: a GitHub-code workspace can track its tasks in Jira. In that case # $TICKET_URL is a Jira browse URL, not a GitHub issue — running `gh issue # edit`/project-status against it just logs `auto-assign failed`. Skip GitHub - # issue housekeeping entirely when the task provider is Jira (assignment + - # status now happen via the jira MCP in-session). + # issue housekeeping entirely when the task provider is Jira: assignment + # happens via the jira MCP in-session, and the Jira In-Progress transition + # happens in jira_ops() (CROW-529), not here. resolve_task_provider if [[ "$TASK_PROVIDER" == "jira" ]]; then log "Task provider is Jira; skipping GitHub issue auto-assign/project-status" @@ -803,6 +804,23 @@ github_ops() { fi } +# CROW-529: session-start status transition for Jira work items. setup.sh owns +# the GitHub Projects-v2 mutation (set_project_status) but there was no Jira +# equivalent, so a Jira-tasked session never left Backlog. Delegate to the Crow +# app — it resolves the mapped In-Progress status (jiraStatusMap, #523), fetches +# the issue's available transitions, and degrades gracefully when unavailable. +# Runs for any code provider (a Jira-tasked workspace may be GitHub- or +# GitLab-coded), so it lives outside github_ops. Best-effort, never fatal. +jira_ops() { + resolve_task_provider + [[ "$TASK_PROVIDER" == "jira" ]] || return 0 + [[ "$SKIP_PROJECT_STATUS" != "true" ]] || return 0 + [[ -n "$TICKET_URL" && -n "$SESSION_ID" ]] || return 0 + log "Transitioning Jira work item to In Progress..." + crow transition-ticket --session "$SESSION_ID" --to inProgress \ + || log "Warning: Jira status transition failed (non-fatal)" +} + set_project_status() { # Extract owner/repo from ticket URL local owner_repo @@ -1154,6 +1172,7 @@ main() { write_settings_local install_commit_hook github_ops + jira_ops write_prompt launch_agent emit_result