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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ crow delete-session --session <uuid> → {"deleted":true}
crow set-ticket --session <uuid> --url "..." [--title "..."] [--number N]
crow add-link --session <uuid> --label "Issue" --url "..." --type ticket|pr|repo|custom
crow list-links --session <uuid>
crow transition-ticket --session <uuid> --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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public struct CrowCommand: ParsableCommand {
Send.self,
AddLink.self,
ListLinks.self,
TransitionTicket.self,
ResyncJira.self,
HookEventCmd.self,
CodexNotify.self,
]
Expand Down
28 changes: 28 additions & 0 deletions Packages/CrowCLI/Tests/CrowCLITests/CommandParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
150 changes: 150 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/JiraTransitionClient.swift
Original file line number Diff line number Diff line change
@@ -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<Outcome, TransitionError> {
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))
}
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading