From 810d3acadea778324dd8a3fe897ca59254fa8faf Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Thu, 11 Jun 2026 19:51:57 -0400 Subject: [PATCH 1/2] Replace StubCorveilTaskBackend with real CorveilTaskBackend (CROW-495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors JiraTaskBackend over the `corveil` CLI surface so Corveil-tasked sessions work end-to-end (paired with a GitHub/GitLab CodeBackend via Session.codeProvider). Declares `[batchedQuery, projectBoardStatus]` — batched on the assumption corveil#1364's bulk `--ids` has landed; the project-board status surface is real, with `.inReview` mapping lossily to corveil's `in_progress` (no review-distinct intermediate exists). - New `CorveilTaskBackend.swift` with `CorveilConfig` + `CorveilTaskID` parser; all six TaskBackend methods build `corveil task …` argv via the shared `ShellRunner`. Auth-failure output rewrites to a clear "run `corveil login`" hint, matching the Jira pattern. - `ProviderManager` factory swaps the `.corveil` arm to the real backend and `fetchTicket` delegates Corveil URLs the same way Jira does; URL detection takes an `additionalCorveilHosts` list parallel to GitLab. - `WorkspaceInfo.corveilHost` (Codable, decoded with `decodeIfPresent`) plus a "Corveil host" row in `WorkspaceFormView` and a `Corveil` tag in the Task Backend picker. - `IssueTracker` dedupes per-workspace `CorveilConfig`s and adds a `fetchCorveilIssues` path next to `fetchJiraIssues`. - Removes `StubCorveilTaskBackend.swift` and the associated stub test; ADR 0005 swaps the Corveil row in the Decision and Migration tables and documents the `.inReview → in_progress` asymmetry. - New `CorveilTaskBackendTests.swift` (23 tests against `FakeShellRunner`, covering argv shape, JSON parsing, status round-trips, and auth-error surfacing); `ProviderManagerTests` gains five Corveil routing tests. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: F5DEB949-5B6F-4878-9D00-306312BBE797 --- .../Sources/CrowCore/Models/AppConfig.swift | 10 +- .../Backends/CorveilTaskBackend.swift | 328 ++++++++++++++++++ .../Backends/JiraTaskBackend.swift | 4 +- .../Backends/StubCorveilTaskBackend.swift | 41 --- .../CrowProvider/ProviderManager.swift | 49 ++- .../Sources/CrowProvider/TaskBackend.swift | 2 +- .../CrowProviderTests/BackendsTests.swift | 14 - .../CorveilTaskBackendTests.swift | 282 +++++++++++++++ .../ProviderManagerTests.swift | 43 +++ .../Sources/CrowUI/WorkspaceFormView.swift | 14 + Sources/Crow/App/IssueTracker.swift | 29 ++ .../0005-task-and-code-backend-protocols.md | 23 +- 12 files changed, 758 insertions(+), 81 deletions(-) create mode 100644 Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift delete mode 100644 Packages/CrowProvider/Sources/CrowProvider/Backends/StubCorveilTaskBackend.swift create mode 100644 Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index ca1ab9e..7f782f8 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -300,6 +300,11 @@ 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? + /// 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. + /// `nil` is fine: the public `corveil.io` is auto-detected. + public var corveilHost: String? /// The CLI tool name derived from the current `provider` value. /// Unlike `cli` (which may be stale from an old config file), this is always correct. @@ -327,6 +332,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { jiraProjectKey: String? = nil, jiraJQL: String? = nil, jiraSite: String? = nil, + corveilHost: String? = nil, gateway: WorkspaceGateway? = nil ) { self.id = id @@ -342,6 +348,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { self.jiraProjectKey = jiraProjectKey self.jiraJQL = jiraJQL self.jiraSite = jiraSite + self.corveilHost = corveilHost self.gateway = gateway } @@ -360,12 +367,13 @@ 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) + 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, gateway + case taskProvider, jiraProjectKey, jiraJQL, jiraSite, corveilHost, gateway } /// Characters that are unsafe in directory names (workspace names become directory names). diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift new file mode 100644 index 0000000..d9943cb --- /dev/null +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift @@ -0,0 +1,328 @@ +import Foundation +import CrowCore + +/// Per-workspace Corveil configuration threaded into ``CorveilTaskBackend``. +/// +/// The corveil CLI manages its own auth state and target host (`corveil login`, +/// `CORVEIL_URL`), so none of these are required to *call* Corveil — they only +/// refine behavior: +/// - `host`: self-hosted Corveil host (e.g. `corveil.acme.io`) used **only** as a +/// fallback when corveil's JSON omits the `url` field (corveil#1363 added it, +/// so post-landing the fallback is almost never taken). The public `corveil.io` +/// is auto-detected; this is only needed for self-hosted instances. +public struct CorveilConfig: Sendable, Equatable { + public let host: String? + + public init(host: String? = nil) { + self.host = host + } +} + +/// `TaskBackend` implementation for Corveil. Wraps the `corveil` CLI. +/// +/// Corveil is a **task-only** provider (no embedded git) — the second instance +/// of the "task tracker with no code surface" shape ADR 0005 carved out, after +/// Jira. A Corveil-tasked session pairs with a GitHub/GitLab `CodeBackend` +/// (resolved via `Session.codeProvider`); `ProviderManager.codeBackend(.corveil)` +/// returns `nil`. +/// +/// Capabilities: +/// - `.batchedQuery` — corveil's `task list --ids` (corveil#1364) gives us bulk +/// fetch in a single HTTP request per polling cycle. +/// - `.projectBoardStatus` — corveil exposes a real `in_progress` intermediate +/// status, wired through `setTaskStatus`. The `.inReview` → `in_progress` +/// mapping is lossy (corveil has no review-distinct status), so the +/// project-board UI surface handles the visual distinction. +/// +/// See ADR 0005. +public struct CorveilTaskBackend: TaskBackend { + public let provider: Provider = .corveil + public let capabilities: Set = [.batchedQuery, .projectBoardStatus] + + private let shellRunner: ShellRunner + private let config: CorveilConfig + + public init(shellRunner: ShellRunner, config: CorveilConfig = CorveilConfig()) { + self.shellRunner = shellRunner + self.config = config + } + + // MARK: - TaskBackend + + public func fetchTask(url: String) async throws -> TicketInfo { + guard let parsed = CorveilTaskID.parse(url) else { + throw ProviderError.invalidURL(url) + } + let output = try await run([ + "corveil", "task", "get", parsed.id, "--json", + ]) + let obj = Self.firstObject(output) + let title = (obj?["title"] as? String) ?? "Task \(parsed.id)" + // corveil#1363 puts the user-facing URL right on the task; fall back to + // a host-built URL when missing (older CLIs / unexpected payloads). + let resolvedURL = (obj?["url"] as? String) ?? browseURL(for: parsed.id) ?? url + return TicketInfo( + number: parsed.number, + title: title, + repo: "", + org: "", + url: resolvedURL, + provider: .corveil, + isMR: false + ) + } + + public func listAssigned(includeClosed: Bool) async throws -> AssignedListing { + let open: [AssignedIssue] + do { + let openOut = try await run([ + "corveil", "task", "list", + "--assignee", "@me", + "--status", "open", + "--json", + ]) + open = Self.parseAssigned(openOut, host: config.host, statusOverride: nil) + } catch { + // Match Jira's degrade-to-empty semantics rather than throwing. + return AssignedListing(open: [], closed: []) + } + + guard includeClosed else { + return AssignedListing(open: open, closed: []) + } + + let closed: [AssignedIssue] + do { + let closedOut = try await run([ + "corveil", "task", "list", + "--assignee", "@me", + "--status", "closed", + "--json", + ]) + closed = Self.parseAssigned(closedOut, host: config.host, statusOverride: .done) + } catch { + return AssignedListing(open: open, closed: []) + } + return AssignedListing(open: open, closed: closed) + } + + public func setLabels(url: String, add: [String], remove: [String]) async throws { + guard !add.isEmpty || !remove.isEmpty else { return } + guard let parsed = CorveilTaskID.parse(url) else { throw ProviderError.invalidURL(url) } + var args = ["corveil", "task", "update", parsed.id] + for label in add { + args.append("--add-label") + args.append(label) + } + for label in remove { + args.append("--remove-label") + args.append(label) + } + _ = try await run(args) + } + + public func setTaskStatus(url: String, status: TicketStatus) async throws { + guard let parsed = CorveilTaskID.parse(url) else { throw ProviderError.invalidURL(url) } + _ = try await run([ + "corveil", "task", "update", parsed.id, + "--status", Self.corveilStatusName(for: status), + ]) + } + + public func assign(url: String, to login: String) async throws { + guard let parsed = CorveilTaskID.parse(url) else { throw ProviderError.invalidURL(url) } + _ = try await run([ + "corveil", "task", "update", parsed.id, + "--assignee", login, + ]) + } + + public func createTask(repo: String, title: String, body: String, labels: [String]) async throws -> TicketInfo { + // Corveil has no project/repo concept analogous to GitHub or Jira's + // project key, so the `repo` parameter is intentionally ignored here. + // Self-assign matches the Jira pattern in setup.sh flows. + var args = [ + "corveil", "task", "create", + "--title", title, + "--description", body, + "--assignee", "@me", + ] + for label in labels { + args.append("--label") + args.append(label) + } + args.append("--json") + let output = try await run(args) + + guard let obj = Self.firstObject(output), + let id = obj["id"].flatMap({ Self.stringify($0) }), + let parsed = CorveilTaskID.parse(id) else { + throw ProviderError.commandFailed("corveil task create did not return a parseable id; got: \(output)") + } + let resolvedURL = (obj["url"] as? String) ?? browseURL(for: parsed.id) ?? parsed.id + return TicketInfo( + number: parsed.number, + title: title, + repo: "", + org: "", + url: resolvedURL, + provider: .corveil, + isMR: false + ) + } + + // MARK: - Helpers + + /// Run a `corveil` invocation, translating shell failures into typed + /// `ProviderError`s and giving a clear hint when corveil isn't authenticated. + private func run(_ args: [String]) async throws -> String { + do { + return try await shellRunner.run(args: args, env: [:], cwd: NSHomeDirectory()) + } catch let ShellRunnerError.nonZeroExit(_, output) { + if Self.looksUnauthenticated(output) { + throw ProviderError.commandFailed("corveil is not authenticated — run `corveil login`. (\(output))") + } + throw ProviderError.commandFailed(output) + } + } + + private func browseURL(for id: String) -> String? { + let host = config.host?.isEmpty == false ? config.host! : nil + guard let host else { return nil } + let prefix = host.hasPrefix("http") ? host : "https://\(host)" + return "\(prefix)/dashboard/tasks/\(id)" + } + + static func looksUnauthenticated(_ output: String) -> Bool { + let lower = output.lowercased() + return lower.contains("corveil login") + || lower.contains("not authenticated") + || lower.contains("unauthorized") + || lower.contains("please login") + } + + /// Map a Crow pipeline status to a Corveil status. Corveil's vocabulary is + /// `open` / `in_progress` / `closed`; Crow's pipeline has five stages plus + /// `.unknown`. `.inReview` collapses into `in_progress` — corveil has no + /// review-distinct intermediate, so the project-board status capability is + /// the UI surface that distinguishes "in review" visually. + static func corveilStatusName(for status: TicketStatus) -> String { + switch status { + case .backlog, .ready: return "open" + case .inProgress, .inReview: return "in_progress" + case .done: return "closed" + case .unknown: return "open" + } + } + + /// Reverse mapping from a corveil status string into Crow's `TicketStatus`. + /// The reverse direction is lossless because we map "open" → `.ready` (the + /// natural "queued, not started" state); `.backlog` is unreachable from + /// corveil JSON, which is fine because it's a Crow-side curation concept. + static func ticketStatus(fromCorveil raw: String) -> TicketStatus { + switch raw.lowercased() { + case "open": return .ready + case "in_progress", "in-progress", "inprogress": return .inProgress + case "closed", "done": return .done + default: return .unknown + } + } + + // MARK: - JSON parsing + + /// corveil emits a JSON array of tasks for `task list` and a bare object for + /// `task get` / `task create`. Return the first element of an array, or the + /// bare object itself. + static func firstObject(_ output: String) -> [String: Any]? { + guard let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) else { return nil } + if let arr = json as? [[String: Any]] { return arr.first } + if let obj = json as? [String: Any] { return obj } + return nil + } + + /// Stringify a JSON value that could be either a string or a number (corveil + /// could plausibly emit task ids as either). Used for parsing the created + /// task's id out of `task create --json`. + static func stringify(_ value: Any) -> String? { + if let s = value as? String { return s } + if let n = value as? NSNumber { return n.stringValue } + return nil + } + + static func parseAssigned(_ output: String, host: String?, statusOverride: TicketStatus?) -> [AssignedIssue] { + guard let data = output.data(using: .utf8), + let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } + return items.compactMap { item -> AssignedIssue? in + guard let idRaw = item["id"].flatMap(stringify), + let parsed = CorveilTaskID.parse(idRaw) else { return nil } + let title = (item["title"] as? String) ?? "Task \(parsed.id)" + let rawStatus = (item["status"] as? String) ?? "" + let status = statusOverride ?? ticketStatus(fromCorveil: rawStatus) + let state = (statusOverride == .done || rawStatus.lowercased() == "closed") ? "closed" : "open" + let labels = (item["labels"] as? [String] ?? []).map { LabelInfo(name: $0) } + // Prefer the url emitted by corveil#1363; fall back to a host-built URL. + let url: String = (item["url"] as? String) + ?? host.flatMap({ h -> String? in + let prefix = h.hasPrefix("http") ? h : "https://\(h)" + return "\(prefix)/dashboard/tasks/\(parsed.id)" + }) + ?? parsed.id + return AssignedIssue( + id: "corveil:\(parsed.id)", + number: parsed.number, + title: title, + state: state, + url: url, + repo: "", + labels: labels, + provider: .corveil, + projectStatus: status + ) + } + } +} + +/// Parses Corveil task ids out of either a dashboard URL or a bare id. +/// +/// Recognized shapes: +/// - `https://corveil.io/dashboard/tasks/42` +/// - `https://corveil.acme.io/dashboard/tasks/abc-42` +/// - bare numeric id: `42` +/// - bare slug id: `task-42` (best-effort — only when a numeric suffix is present) +/// +/// `number` is the integer suffix when present (used by `TicketInfo.number` and +/// `AssignedIssue.number`); `id` is the full token passed to the CLI. +enum CorveilTaskID { + static func parse(_ spec: String) -> (id: String, number: Int)? { + let trimmed = spec.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + // Bare id (numeric or slug): no slash, no scheme. + if !trimmed.contains("/") { + return makeID(from: trimmed) + } + + // URL: pick the segment after `/tasks/`, stripping any query/fragment. + if let range = trimmed.range(of: "/tasks/") { + let tail = String(trimmed[range.upperBound...]) + let cleaned = tail + .split(whereSeparator: { $0 == "/" || $0 == "?" || $0 == "#" }) + .first + .map(String.init) ?? "" + return makeID(from: cleaned) + } + return nil + } + + private static func makeID(from raw: String) -> (id: String, number: Int)? { + guard !raw.isEmpty else { return nil } + if let n = Int(raw) { return (raw, n) } + // Slug with a trailing numeric suffix (e.g. "task-42"); extract the + // suffix for the `number` field but pass the full id to the CLI. + if let suffix = raw.split(separator: "-").last, let n = Int(suffix) { + return (raw, n) + } + return nil + } +} diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift index d56c950..0613324 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/JiraTaskBackend.swift @@ -26,8 +26,8 @@ public struct JiraConfig: Sendable, Equatable { /// `TaskBackend` implementation for Atlassian Jira. Wraps the `acli` CLI. /// /// Jira is a **task-only** provider (no embedded git) — exactly the "task tracker -/// with no code surface" shape ADR 0005 carved out, today represented only by the -/// `StubCorveilTaskBackend`. A Jira-tasked session pairs with a GitHub/GitLab +/// with no code surface" shape ADR 0005 carved out, today shared with +/// `CorveilTaskBackend`. A Jira-tasked session pairs with a GitHub/GitLab /// `CodeBackend` (resolved via `Session.codeProvider`); `ProviderManager.codeBackend(.jira)` /// returns `nil`. /// diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/StubCorveilTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/StubCorveilTaskBackend.swift deleted file mode 100644 index 4cc38c4..0000000 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/StubCorveilTaskBackend.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import CrowCore - -/// `TaskBackend` stub for Corveil — present to prove the abstraction holds for a -/// third provider, not to ship behavior. Every method throws -/// `ProviderError.unimplemented`. See ADR 0005. -/// -/// Corveil has no embedded git, so there is intentionally no `StubCorveilCodeBackend`: -/// a Corveil-tasked session pairs with a `.github` or `.gitlab` `CodeBackend`. Once a -/// real Corveil API integration lands, this stub is replaced; a `Session.codeProvider` -/// field (separate follow-up) lets the two halves come from different providers. -public struct StubCorveilTaskBackend: TaskBackend { - public let provider: Provider = .corveil - public let capabilities: Set = [] - - public init() {} - - public func fetchTask(url: String) async throws -> TicketInfo { - throw ProviderError.unimplemented("StubCorveilTaskBackend.fetchTask") - } - - public func listAssigned(includeClosed: Bool) async throws -> AssignedListing { - throw ProviderError.unimplemented("StubCorveilTaskBackend.listAssigned") - } - - public func setLabels(url: String, add: [String], remove: [String]) async throws { - throw ProviderError.unimplemented("StubCorveilTaskBackend.setLabels") - } - - public func setTaskStatus(url: String, status: TicketStatus) async throws { - throw ProviderError.unimplemented("StubCorveilTaskBackend.setTaskStatus") - } - - public func assign(url: String, to login: String) async throws { - throw ProviderError.unimplemented("StubCorveilTaskBackend.assign") - } - - public func createTask(repo: String, title: String, body: String, labels: [String]) async throws -> TicketInfo { - throw ProviderError.unimplemented("StubCorveilTaskBackend.createTask") - } -} diff --git a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift index 7989405..6306208 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift @@ -9,12 +9,21 @@ import CrowCore public actor ProviderManager { /// Additional GitLab hosts beyond gitlab.com (user-configurable). nonisolated private let additionalGitLabHosts: [String] + /// Additional Corveil hosts beyond corveil.io (user-configurable per + /// workspace via `WorkspaceInfo.corveilHost`). URL detection routes any + /// match here to `.corveil`. + nonisolated private let additionalCorveilHosts: [String] /// Subprocess runner shared by all backends this manager hands out. nonisolated private let shellRunner: ShellRunner - public init(additionalGitLabHosts: [String] = [], shellRunner: ShellRunner = ProcessShellRunner()) { + public init( + additionalGitLabHosts: [String] = [], + additionalCorveilHosts: [String] = [], + shellRunner: ShellRunner = ProcessShellRunner() + ) { self.additionalGitLabHosts = additionalGitLabHosts + self.additionalCorveilHosts = additionalCorveilHosts self.shellRunner = shellRunner } @@ -22,14 +31,19 @@ public actor ProviderManager { /// Hand out a ``TaskBackend`` for the given provider. Use the URL-based /// variant when only a ticket URL is in hand. - public nonisolated func taskBackend(for provider: Provider, host: String? = nil, jira: JiraConfig? = nil) -> TaskBackend { + public nonisolated func taskBackend( + for provider: Provider, + host: String? = nil, + jira: JiraConfig? = nil, + corveil: CorveilConfig? = nil + ) -> TaskBackend { switch provider { case .github: return GitHubTaskBackend(shellRunner: shellRunner) case .gitlab: return GitLabTaskBackend(shellRunner: shellRunner, host: host) case .corveil: - return StubCorveilTaskBackend() + return CorveilTaskBackend(shellRunner: shellRunner, config: corveil ?? CorveilConfig()) case .jira: return JiraTaskBackend(shellRunner: shellRunner, config: jira ?? JiraConfig()) } @@ -60,14 +74,18 @@ public actor ProviderManager { /// URL-driven `TaskBackend` lookup — detect the provider from `url` and /// hand back the matching backend. public nonisolated func taskBackend(forURL url: String) -> TaskBackend { - let detected = Self.detect(url: url, additionalGitLabHosts: additionalGitLabHosts) + let detected = Self.detect(url: url, additionalGitLabHosts: additionalGitLabHosts, additionalCorveilHosts: additionalCorveilHosts) return taskBackend(for: detected.provider, host: detected.host) } /// Single source of truth for URL → provider detection. The actor-isolated /// ``detectProvider(from:)`` and the nonisolated factory paths both delegate /// here so the matching logic can never drift. - nonisolated static func detect(url: String, additionalGitLabHosts: [String]) -> (provider: Provider, cli: String, host: String?) { + nonisolated static func detect( + url: String, + additionalGitLabHosts: [String], + additionalCorveilHosts: [String] = [] + ) -> (provider: Provider, cli: String, host: String?) { if url.contains("github.com") { return (.github, "gh", nil) } else if Validation.isJiraSpec(url) { @@ -78,9 +96,11 @@ public actor ProviderManager { } else if url.contains("gitlab.com") { return (.gitlab, "glab", "gitlab.com") } else if url.contains("corveil.io") { - // Corveil is task-only (no embedded git, no CLI). Detected so the - // stub backend can be exercised end-to-end via URL. See ADR 0005. - return (.corveil, "", nil) + // Public corveil.io — task-only, driven by `corveil`. See ADR 0005. + return (.corveil, "corveil", nil) + } + for host in additionalCorveilHosts where !host.isEmpty && url.contains(host) { + return (.corveil, "corveil", host) } for host in additionalGitLabHosts { if url.contains(host) { @@ -95,7 +115,7 @@ public actor ProviderManager { /// Falls back to `.github` for unrecognized hosts — the `gh` CLI call will fail clearly /// if the URL is actually a self-hosted GitLab instance, which is an acceptable failure mode. public func detectProvider(from url: String) -> (provider: Provider, cli: String, host: String?) { - Self.detect(url: url, additionalGitLabHosts: additionalGitLabHosts) + Self.detect(url: url, additionalGitLabHosts: additionalGitLabHosts, additionalCorveilHosts: additionalCorveilHosts) } /// Parse issue/PR number and repo from a ticket URL. @@ -155,6 +175,11 @@ public actor ProviderManager { if detected.provider == .jira { return try await taskBackend(for: .jira).fetchTask(url: url) } + // Corveil ids aren't org/repo/number tuples either; delegate to its + // backend the same way Jira does. + if detected.provider == .corveil { + return try await taskBackend(for: .corveil).fetchTask(url: url) + } guard let parsed = parseTicketURL(url) else { throw ProviderError.invalidURL(url) @@ -180,8 +205,8 @@ public actor ProviderManager { output = try await shell(env: env, cwd: NSHomeDirectory(), "glab", "issue", "view", "\(parsed.number)", "--repo", repoSlug) } case .corveil: - // Stub: real Corveil API integration arrives in a follow-up. See ADR 0005. - throw ProviderError.unimplemented("Corveil ticket fetching not yet implemented") + // Handled by the early return above (delegated to CorveilTaskBackend). + throw ProviderError.unimplemented("unreachable: Corveil handled before the switch") case .jira: // Handled by the early return above (delegated to JiraTaskBackend). throw ProviderError.unimplemented("unreachable: Jira handled before the switch") @@ -347,7 +372,7 @@ public enum ProviderError: Error { case invalidURL(String) case commandFailed(String) /// A backend method is part of the protocol but not implemented for this provider yet - /// (e.g. `StubCorveilTaskBackend` — every method throws this; see ADR 0005). + /// (e.g. a capability-gated method on a backend without the capability). See ADR 0005. case unimplemented(String) /// GitHub `INSUFFICIENT_SCOPES` — the OAuth token is missing a required scope /// (typically `read:project`). Surfaced as a typed error so call sites can diff --git a/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift index 56c82ac..4571453 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift @@ -5,7 +5,7 @@ import CrowCore /// /// `TaskBackend` is paired with a `CodeBackend` (separate protocol — see ADR 0005) /// when a session also produces a PR. A Corveil-tasked session that delegates its -/// PR work to GitHub will use a `StubCorveilTaskBackend` here and a `GitHubCodeBackend` +/// PR work to GitHub will use a `CorveilTaskBackend` here and a `GitHubCodeBackend` /// there. The split exists because tasks (the unit of work) and code (the VCS artifact) /// are independent dimensions. /// diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift index 6f81b5c..05b07ad 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/BackendsTests.swift @@ -613,20 +613,6 @@ final class BackendsTests: XCTestCase { XCTAssertEqual(meta.headRefName, "f") } - // MARK: - Stub Corveil - - func testStubCorveilTaskBackendThrowsUnimplementedForEveryMethod() async { - let backend = StubCorveilTaskBackend() - XCTAssertEqual(backend.provider, .corveil) - XCTAssertTrue(backend.capabilities.isEmpty) - await XCTAssertThrowsErrorAsync(try await backend.fetchTask(url: "https://corveil.io/t/1")) - await XCTAssertThrowsErrorAsync(try await backend.listAssigned()) - await XCTAssertThrowsErrorAsync(try await backend.setLabels(url: "x", add: ["a"], remove: [])) - await XCTAssertThrowsErrorAsync(try await backend.setTaskStatus(url: "x", status: .inReview)) - await XCTAssertThrowsErrorAsync(try await backend.assign(url: "x", to: "me")) - await XCTAssertThrowsErrorAsync(try await backend.createTask(repo: "a/b", title: "t", body: "b", labels: [])) - } - // MARK: - Factory func testProviderManagerHandsOutMatchingBackends() async { diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift new file mode 100644 index 0000000..ac66cda --- /dev/null +++ b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift @@ -0,0 +1,282 @@ +import XCTest +import CrowCore +@testable import CrowProvider + +/// Exercises `CorveilTaskBackend` against `FakeShellRunner` — the ADR 0005 +/// testability bar. Asserts the exact `corveil` argv for each method plus the +/// JSON parsing, id parsing, and status mapping, without spawning real `corveil`. +final class CorveilTaskBackendTests: XCTestCase { + + private func backend(_ fake: FakeShellRunner, config: CorveilConfig = CorveilConfig()) -> CorveilTaskBackend { + CorveilTaskBackend(shellRunner: fake, config: config) + } + + // MARK: - Capabilities + + func testDeclaresBatchedQueryAndProjectBoardStatus() { + let b = backend(FakeShellRunner()) + XCTAssertEqual(b.provider, .corveil) + XCTAssertTrue(b.capabilities.contains(.batchedQuery)) + XCTAssertTrue(b.capabilities.contains(.projectBoardStatus)) + } + + // MARK: - CorveilTaskID parsing + + func testCorveilTaskIDParsesDashboardURL() { + let parsed = CorveilTaskID.parse("https://corveil.io/dashboard/tasks/42") + XCTAssertEqual(parsed?.id, "42") + XCTAssertEqual(parsed?.number, 42) + } + + func testCorveilTaskIDParsesSelfHostedURL() { + let parsed = CorveilTaskID.parse("https://corveil.acme.io/dashboard/tasks/137") + XCTAssertEqual(parsed?.id, "137") + XCTAssertEqual(parsed?.number, 137) + } + + func testCorveilTaskIDParsesBareNumericId() { + XCTAssertEqual(CorveilTaskID.parse("42")?.id, "42") + XCTAssertEqual(CorveilTaskID.parse("42")?.number, 42) + } + + func testCorveilTaskIDStripsQueryAndFragment() { + let parsed = CorveilTaskID.parse("https://corveil.io/dashboard/tasks/42?foo=bar") + XCTAssertEqual(parsed?.id, "42") + XCTAssertEqual(parsed?.number, 42) + } + + func testCorveilTaskIDParsesSlugWithNumericSuffix() { + let parsed = CorveilTaskID.parse("task-99") + XCTAssertEqual(parsed?.id, "task-99") + XCTAssertEqual(parsed?.number, 99) + } + + func testCorveilTaskIDRejectsUnparseable() { + XCTAssertNil(CorveilTaskID.parse("")) + XCTAssertNil(CorveilTaskID.parse("https://corveil.io/dashboard")) + XCTAssertNil(CorveilTaskID.parse("not-numeric")) + } + + // MARK: - fetchTask + + func testFetchTaskInvokesCorveilGetAndReadsURLField() async throws { + let fake = FakeShellRunner() + fake.responses = [.success(#"{"id":"42","title":"Fix the thing","url":"https://corveil.io/dashboard/tasks/42"}"#)] + let b = backend(fake) + let info = try await b.fetchTask(url: "https://corveil.io/dashboard/tasks/42") + + XCTAssertEqual(info.title, "Fix the thing") + XCTAssertEqual(info.number, 42) + XCTAssertEqual(info.provider, .corveil) + XCTAssertFalse(info.isMR) + XCTAssertEqual(info.url, "https://corveil.io/dashboard/tasks/42") + + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(Array(args.prefix(3)), ["corveil", "task", "get"]) + XCTAssertTrue(args.contains("42")) + XCTAssertTrue(args.contains("--json")) + } + + func testFetchTaskFallsBackToHostBuiltURLWhenJSONOmitsURL() async throws { + let fake = FakeShellRunner() + fake.responses = [.success(#"{"id":"42","title":"No URL field"}"#)] + let b = backend(fake, config: CorveilConfig(host: "corveil.acme.io")) + let info = try await b.fetchTask(url: "https://corveil.acme.io/dashboard/tasks/42") + XCTAssertEqual(info.url, "https://corveil.acme.io/dashboard/tasks/42") + } + + func testFetchTaskRejectsUnparseableURL() async { + do { + _ = try await backend(FakeShellRunner()).fetchTask(url: "https://corveil.io/dashboard") + XCTFail("expected throw") + } catch ProviderError.invalidURL { + // expected + } catch { + XCTFail("expected invalidURL, got \(error)") + } + } + + // MARK: - listAssigned + + func testListAssignedSendsAtMeAndOpenStatus() async throws { + let fake = FakeShellRunner() + fake.responses = [.success(""" + [ + {"id":"1","title":"Open one","status":"in_progress","labels":["bug","crow:auto"],"url":"https://corveil.io/dashboard/tasks/1"}, + {"id":"2","title":"Open two","status":"open"} + ] + """)] + let b = backend(fake) + let listing = try await b.listAssigned(includeClosed: false) + + XCTAssertEqual(listing.open.count, 2) + XCTAssertTrue(listing.closed.isEmpty) + let first = listing.open[0] + XCTAssertEqual(first.id, "corveil:1") + XCTAssertEqual(first.number, 1) + XCTAssertEqual(first.provider, .corveil) + XCTAssertEqual(first.state, "open") + XCTAssertEqual(first.projectStatus, .inProgress) + XCTAssertEqual(first.url, "https://corveil.io/dashboard/tasks/1") + XCTAssertEqual(first.labels.map(\.name), ["bug", "crow:auto"]) + XCTAssertEqual(listing.open[1].projectStatus, .ready) + + XCTAssertEqual(fake.calls.count, 1) + let args = fake.calls[0].args + XCTAssertEqual(Array(args.prefix(3)), ["corveil", "task", "list"]) + XCTAssertTrue(args.contains("--assignee")) + XCTAssertEqual(args[args.firstIndex(of: "--assignee")! + 1], "@me") + XCTAssertTrue(args.contains("--status")) + XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "open") + } + + func testListAssignedIssuesSecondCallWhenIncludeClosed() async throws { + let fake = FakeShellRunner() + fake.responses = [ + .success("[]"), + .success(#"[{"id":"9","title":"Done one","status":"closed"}]"#), + ] + let b = backend(fake) + let listing = try await b.listAssigned(includeClosed: true) + + XCTAssertEqual(fake.calls.count, 2) + let closedArgs = fake.calls[1].args + XCTAssertEqual(closedArgs[closedArgs.firstIndex(of: "--status")! + 1], "closed") + XCTAssertEqual(listing.closed.count, 1) + XCTAssertEqual(listing.closed[0].state, "closed") + XCTAssertEqual(listing.closed[0].projectStatus, .done) + } + + func testListAssignedDegradesToEmptyOnFailure() async throws { + let fake = FakeShellRunner() + fake.responses = [.failure(ShellRunnerError.nonZeroExit(exitCode: 1, output: "boom"))] + let listing = try await backend(fake).listAssigned(includeClosed: false) + XCTAssertTrue(listing.open.isEmpty) + XCTAssertTrue(listing.closed.isEmpty) + } + + // MARK: - setLabels + + func testSetLabelsAddsAndRemovesViaRepeatedFlags() async throws { + let fake = FakeShellRunner() + try await backend(fake).setLabels( + url: "42", add: ["crow-tracked", "bug"], remove: ["stale"] + ) + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(Array(args.prefix(4)), ["corveil", "task", "update", "42"]) + + let addCount = args.enumerated().filter { $0.element == "--add-label" }.count + XCTAssertEqual(addCount, 2) + let removeCount = args.enumerated().filter { $0.element == "--remove-label" }.count + XCTAssertEqual(removeCount, 1) + + // Verify each add-label flag is followed by the right value. + let firstAddIdx = args.firstIndex(of: "--add-label")! + XCTAssertEqual(args[firstAddIdx + 1], "crow-tracked") + let removeIdx = args.firstIndex(of: "--remove-label")! + XCTAssertEqual(args[removeIdx + 1], "stale") + } + + func testSetLabelsNoOpWhenEmpty() async throws { + let fake = FakeShellRunner() + try await backend(fake).setLabels(url: "42", add: [], remove: []) + XCTAssertTrue(fake.calls.isEmpty) + } + + // MARK: - setTaskStatus + + func testSetTaskStatusMapsInReviewToInProgress() async throws { + let fake = FakeShellRunner() + try await backend(fake).setTaskStatus(url: "https://corveil.io/dashboard/tasks/42", status: .inReview) + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(Array(args.prefix(4)), ["corveil", "task", "update", "42"]) + XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "in_progress") + } + + func testStatusNameMappingCoversAllCases() { + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .backlog), "open") + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .ready), "open") + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .inProgress), "in_progress") + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .inReview), "in_progress") + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .done), "closed") + XCTAssertEqual(CorveilTaskBackend.corveilStatusName(for: .unknown), "open") + } + + func testReverseStatusMapping() { + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "open"), .ready) + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "in_progress"), .inProgress) + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "in-progress"), .inProgress) + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "closed"), .done) + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "done"), .done) + XCTAssertEqual(CorveilTaskBackend.ticketStatus(fromCorveil: "weird"), .unknown) + } + + // MARK: - assign + + func testAssignInvokesCorveilUpdate() async throws { + let fake = FakeShellRunner() + try await backend(fake).assign(url: "42", to: "@me") + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(Array(args.prefix(4)), ["corveil", "task", "update", "42"]) + XCTAssertEqual(args[args.firstIndex(of: "--assignee")! + 1], "@me") + } + + // MARK: - createTask + + func testCreateTaskWithLabelsAndParsesIdAndURL() async throws { + let fake = FakeShellRunner() + fake.responses = [.success(#"{"id":"100","title":"Created","url":"https://corveil.io/dashboard/tasks/100"}"#)] + let b = backend(fake) + let info = try await b.createTask(repo: "", title: "Created", body: "desc", labels: ["a", "b"]) + + XCTAssertEqual(info.number, 100) + XCTAssertEqual(info.url, "https://corveil.io/dashboard/tasks/100") + XCTAssertEqual(info.provider, .corveil) + + let args = fake.calls.first?.args ?? [] + XCTAssertEqual(Array(args.prefix(3)), ["corveil", "task", "create"]) + XCTAssertEqual(args[args.firstIndex(of: "--title")! + 1], "Created") + XCTAssertEqual(args[args.firstIndex(of: "--description")! + 1], "desc") + XCTAssertEqual(args[args.firstIndex(of: "--assignee")! + 1], "@me") + + let labelCount = args.enumerated().filter { $0.element == "--label" }.count + XCTAssertEqual(labelCount, 2) + XCTAssertTrue(args.contains("--json")) + } + + func testCreateTaskAcceptsNumericIdInResponse() async throws { + let fake = FakeShellRunner() + // corveil JSON could plausibly emit ids as numbers; the parser handles both. + fake.responses = [.success(#"{"id":7,"title":"x"}"#)] + let info = try await backend(fake).createTask(repo: "", title: "x", body: "y", labels: []) + XCTAssertEqual(info.number, 7) + } + + func testCreateTaskThrowsWhenJSONLacksParseableID() async { + let fake = FakeShellRunner() + fake.responses = [.success("{}")] + do { + _ = try await backend(fake).createTask(repo: "", title: "x", body: "y", labels: []) + XCTFail("expected throw") + } catch ProviderError.commandFailed { + // expected + } catch { + XCTFail("expected commandFailed, got \(error)") + } + } + + // MARK: - auth error surfacing + + func testUnauthenticatedOutputSurfacesClearError() async { + let fake = FakeShellRunner() + fake.responses = [.failure(ShellRunnerError.nonZeroExit(exitCode: 1, output: "Error: please run corveil login"))] + do { + _ = try await backend(fake).fetchTask(url: "https://corveil.io/dashboard/tasks/1") + XCTFail("expected throw") + } catch let ProviderError.commandFailed(msg) { + XCTAssertTrue(msg.lowercased().contains("corveil login")) + } catch { + XCTFail("expected commandFailed, got \(error)") + } + } +} diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift index 86095b0..99d047d 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/ProviderManagerTests.swift @@ -137,6 +137,49 @@ struct ProviderManagerTests { #expect(manager.codeBackend(for: .jira) == nil) } + // MARK: - Corveil (ADR 0005 task-only provider) + + @Test func detectProviderPublicCorveilURL() async { + let result = await manager.detectProvider(from: "https://corveil.io/dashboard/tasks/42") + #expect(result.provider == .corveil) + #expect(result.cli == "corveil") + #expect(result.host == nil) + } + + @Test func detectProviderSelfHostedCorveilURL() async { + let mgr = ProviderManager(additionalCorveilHosts: ["corveil.acme.io"]) + let result = await mgr.detectProvider(from: "https://corveil.acme.io/dashboard/tasks/137") + #expect(result.provider == .corveil) + #expect(result.cli == "corveil") + #expect(result.host == "corveil.acme.io") + } + + @Test func detectProviderMultipleCorveilHosts() async { + let mgr = ProviderManager(additionalCorveilHosts: ["corveil.a.com", "corveil.b.com"]) + let resultA = await mgr.detectProvider(from: "https://corveil.a.com/dashboard/tasks/1") + #expect(resultA.host == "corveil.a.com") + let resultB = await mgr.detectProvider(from: "https://corveil.b.com/dashboard/tasks/2") + #expect(resultB.host == "corveil.b.com") + } + + @Test func taskBackendForCorveilIsCorveilBackend() { + let backend = manager.taskBackend(for: .corveil) + #expect(backend.provider == .corveil) + #expect(backend is CorveilTaskBackend) + #expect(backend.capabilities.contains(.batchedQuery)) + #expect(backend.capabilities.contains(.projectBoardStatus)) + } + + @Test func codeBackendForCorveilIsNil() { + // Corveil is task-only — no VCS surface (pairs with a GitHub/GitLab code backend). + #expect(manager.codeBackend(for: .corveil) == nil) + } + + @Test func taskBackendForCorveilURLDetectsCorveil() { + let backend = manager.taskBackend(forURL: "https://corveil.io/dashboard/tasks/42") + #expect(backend.provider == .corveil) + } + // MARK: - detectProvider edge cases @Test func detectProviderCaseSensitiveHost() async { diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 509b159..5e92a02 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -19,6 +19,7 @@ public struct WorkspaceFormView: View { @State private var jiraSite: String @State private var jiraProjectKey: String @State private var jiraJQL: String + @State private var corveilHost: String @State private var alwaysIncludeText: String @State private var autoReviewReposText: String @State private var excludeReviewReposText: String @@ -50,6 +51,7 @@ public struct WorkspaceFormView: View { self._jiraSite = State(initialValue: workspace?.jiraSite ?? "") self._jiraProjectKey = State(initialValue: workspace?.jiraProjectKey ?? "") self._jiraJQL = State(initialValue: workspace?.jiraJQL ?? "") + self._corveilHost = State(initialValue: workspace?.corveilHost ?? "") self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") self._autoReviewReposText = State(initialValue: workspace?.autoReviewRepos.joined(separator: ", ") ?? "") self._excludeReviewReposText = State(initialValue: workspace?.excludeReviewRepos.joined(separator: ", ") ?? "") @@ -71,6 +73,7 @@ public struct WorkspaceFormView: View { } private var jiraSelected: Bool { taskProvider == "jira" } + private var corveilSelected: Bool { taskProvider == "corveil" } /// Jira is offered only when acli is installed + authenticated, OR when the /// workspace already had Jira selected (so an existing choice isn't silently @@ -142,6 +145,7 @@ public struct WorkspaceFormView: View { if jiraOfferable { Text("Jira").tag("jira") } + Text("Corveil").tag("corveil") } Text("Where tickets / work items live. Defaults to the Code Backend.") .font(.caption) @@ -164,6 +168,14 @@ public struct WorkspaceFormView: View { .font(.caption) .foregroundStyle(.secondary) } + + if corveilSelected { + TextField("Corveil host (e.g., corveil.acme.io — optional)", text: $corveilHost) + .textFieldStyle(.roundedBorder) + Text("Only needed for self-hosted Corveil. Public corveil.io is auto-detected. The CLI authenticates against its own configured host (`corveil login`).") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Repos") { @@ -251,6 +263,7 @@ public struct WorkspaceFormView: View { // otherwise leave nil so the workspace "follows" the code backend. let resolvedTaskProvider: String? = (taskProvider == provider) ? nil : taskProvider let isJira = taskProvider == "jira" + let isCorveil = taskProvider == "corveil" return WorkspaceInfo( id: existingID ?? UUID(), @@ -266,6 +279,7 @@ public struct WorkspaceFormView: View { jiraProjectKey: isJira ? nonEmpty(jiraProjectKey) : nil, jiraJQL: isJira ? nonEmpty(jiraJQL) : nil, jiraSite: isJira ? nonEmpty(jiraSite) : nil, + corveilHost: isCorveil ? nonEmpty(corveilHost) : nil, gateway: gatewayForSave ) } diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 67e53a0..14fb4c8 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -357,6 +357,15 @@ final class IssueTracker { let cfg = JiraConfig(site: ws.jiraSite, projectKey: ws.jiraProjectKey, jql: ws.jiraJQL) if !jiraConfigs.contains(cfg) { jiraConfigs.append(cfg) } } + // Collect distinct Corveil configs. The corveil CLI is authed to one + // host, so the workspace host (used only for URL routing) is what + // varies; we dedupe by host to avoid fanning out to the same authed + // session twice. + var corveilConfigs: [CorveilConfig] = [] + for ws in config.workspaces where ws.derivedTaskProvider == "corveil" { + let cfg = CorveilConfig(host: ws.corveilHost) + if !corveilConfigs.contains(cfg) { corveilConfigs.append(cfg) } + } var allIssues: [AssignedIssue] = [] @@ -397,6 +406,12 @@ final class IssueTracker { allIssues.append(contentsOf: issues) } + // Corveil — one list per distinct config (best-effort). + for cfg in corveilConfigs { + let issues = await fetchCorveilIssues(config: cfg) + allIssues.append(contentsOf: issues) + } + appState.assignedIssues = allIssues let ticketExcludePatterns = config.defaults.excludeTicketRepos @@ -2238,6 +2253,20 @@ final class IssueTracker { } } + /// Fetch open Corveil tasks assigned to the user for one workspace config. + /// Best-effort (the backend itself degrades to empty on failure), mirroring + /// the GitLab / Jira paths. + private func fetchCorveilIssues(config: CorveilConfig) async -> [AssignedIssue] { + let backend = providerManager.taskBackend(for: .corveil, corveil: config) + do { + let listing = try await backend.listAssigned(includeClosed: false) + return listing.open + } catch { + print("[IssueTracker] fetchCorveilIssues(host: \(config.host ?? "—")) failed: \(error)") + return [] + } + } + // MARK: - Mark In Review func markInReview(sessionID: UUID) async { diff --git a/docs/adr/0005-task-and-code-backend-protocols.md b/docs/adr/0005-task-and-code-backend-protocols.md index 23b0353..bbe2af0 100644 --- a/docs/adr/0005-task-and-code-backend-protocols.md +++ b/docs/adr/0005-task-and-code-backend-protocols.md @@ -59,9 +59,10 @@ Concrete implementations: | `GitHubCodeBackend` | `.github` | `[autoMergeLabel, batchedPRStates, autoMerge, updateBranch]` | | `GitLabTaskBackend` | `.gitlab` | `[]` | | `GitLabCodeBackend` | `.gitlab` | `[]` | -| `StubCorveilTaskBackend` | `.corveil` | `[]` — every method throws `.unimplemented` | +| `JiraTaskBackend` | `.jira` | `[projectBoardStatus]` | +| `CorveilTaskBackend` | `.corveil` | `[batchedQuery, projectBoardStatus]` | -There is no `StubCorveilCodeBackend` — Corveil has no git. A Corveil-tasked session uses a `.github` or `.gitlab` `CodeBackend`, which is the whole point of the split. +Corveil and Jira are both task-only providers; neither has a `CodeBackend`. A Corveil-tasked (or Jira-tasked) session uses a `.github` or `.gitlab` `CodeBackend`, which is the whole point of the split. `Session.provider` remains a single optional field — code looks up both backends from the same value. A follow-up adds `Session.codeProvider: Provider?` once a real Corveil backend is implemented and cross-backend sessions are a working flow, not a theoretical one. @@ -73,12 +74,12 @@ Method-by-method state as of the #454 PR. "Site" is where the implementation liv | Method | GitHub | GitLab | Corveil | Notes | |---|---|---|---|---| -| `fetchTask` | ✅ | ✅ | throws | shipped #411 | -| `setLabels` | ✅ | ✅ | throws | shipped #411 | -| `setTaskStatus` | ✅ (#454) | throws (no cap) | throws | #454 closed the escape-hatch — `IssueTracker.markInReview` no longer runs a parallel GraphQL mutation | -| `listAssigned` | ✅ (#454) | ✅ (#454) | throws | GitHub batches open+closed in one GraphQL call; GitLab issues two REST calls | -| `assign` | ✅ (#454) | ✅ (#454) | throws | for `setup.sh` and skill flows | -| `createTask` | ✅ (#454) | ✅ (#454) | throws | for `/crow-create-ticket` | +| `fetchTask` | ✅ | ✅ | ✅ (#495) | shipped #411 | +| `setLabels` | ✅ | ✅ | ✅ (#495) | shipped #411 | +| `setTaskStatus` | ✅ (#454) | throws (no cap) | ✅ (#495) | #454 closed the escape-hatch — `IssueTracker.markInReview` no longer runs a parallel GraphQL mutation. Corveil maps `.inReview` → `in_progress` lossily (corveil has no distinct review status). | +| `listAssigned` | ✅ (#454) | ✅ (#454) | ✅ (#495) | GitHub batches open+closed in one GraphQL call; GitLab issues two REST calls; Corveil uses `--assignee @me --status open` (corveil#1362). | +| `assign` | ✅ (#454) | ✅ (#454) | ✅ (#495) | for `setup.sh` and skill flows | +| `createTask` | ✅ (#454) | ✅ (#454) | ✅ (#495) | for `/crow-create-ticket` | ### CodeBackend @@ -105,12 +106,14 @@ After #454, `rg '"gh"|"glab"|gh api|glab api' Sources/Crow/App/IssueTracker.swif - The parallel GitHub/GitLab halves of `listAssigned`, `prStates`, `findRecentPRsForBranches`, etc. stop sitting in the same file. They moved to their respective backend files where their idioms (batched GraphQL vs per-MR REST) are local concerns, not global ones. - Non-coding tasks are no longer an architectural awkwardness. A task without a `CodeBackend` is a normal session; PR-related code paths simply aren't invoked. - Capability flags now mean what they say. A backend that declares `.projectBoardStatus` actually implements `setTaskStatus` — no more "the UI guard is the capability but the implementation lives elsewhere." +- A real `CorveilTaskBackend` (#495) closes the "Corveil is a stub" item from #454's acceptance list. Corveil is now a real task-only provider alongside Jira; both pair with a `.github` or `.gitlab` `CodeBackend` via `Session.codeProvider`. **Harder / accepted:** - More indirection: a call to "remove a label" goes through `manager.taskBackend(for:) → setLabels(...)` instead of a direct `gh issue edit` shell-out. Reviewers comparing the diff to the old code path have to follow one extra hop. - Capability flags are an enum the maintainer keeps in sync. A new capability is a two-edit change (add the case, declare it on the relevant backend) — easy to forget, hard to catch by build. - The `Session.provider == provider` simplification limits cross-backend pairings until the `Session.codeProvider` follow-up lands. Real Corveil work blocks on that ticket. +- Corveil's status vocabulary (`open` / `in_progress` / `closed`) is shorter than Crow's pipeline (`backlog` / `ready` / `inProgress` / `inReview` / `done`). `setTaskStatus` for `.inReview` maps lossily to `in_progress` on Corveil — the project-board status capability surfaces the "in review" distinction visually instead. - `setup.sh` and skill scripts continue to shell `gh`/`glab` directly — they don't run through Swift and stay out of this abstraction. A separate effort once a real Corveil backend exists. - The GitHub viewer's consolidated query is now split across two calls (`listAssigned` for issues, `listMonitoredPRs` for PRs + reviews). One extra GraphQL round-trip per polling cycle (negligible against the ~5000/hour rate limit budget). The trade buys clean task/code separation in the protocol surface. @@ -124,7 +127,7 @@ After #454, `rg '"gh"|"glab"|gh api|glab api' Sources/Crow/App/IssueTracker.swif ## References -- Tickets: [#410](https://github.com/radiusmethod/crow/issues/410) (foundation, closed via #411), [#454](https://github.com/radiusmethod/crow/issues/454) (migration) +- Tickets: [#410](https://github.com/radiusmethod/crow/issues/410) (foundation, closed via #411), [#454](https://github.com/radiusmethod/crow/issues/454) (migration), [#495](https://github.com/radiusmethod/crow/issues/495) (real Corveil backend) - PRs: #411 (foundation), the PR closing #454 (migration) - Code: - `Packages/CrowProvider/Sources/CrowProvider/TaskBackend.swift` @@ -133,4 +136,4 @@ After #454, `rg '"gh"|"glab"|gh api|glab api' Sources/Crow/App/IssueTracker.swif - `Packages/CrowProvider/Sources/CrowProvider/Backends/` - `Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift` - `Packages/CrowCore/Sources/CrowCore/ShellRunner.swift` -- Follow-up tickets: real Corveil `TaskBackend`, `Session.codeProvider` field for cross-backend pairings, `setup.sh` / skill migration off direct `gh`/`glab`. +- Follow-up tickets: `Session.codeProvider` field for cross-backend pairings, `setup.sh` / skill migration off direct `gh`/`glab`, PR-merge → task-close auto-link for Corveil (depends on corveil#1365). From da9dbbe96c82a8c82f1f68b05d8f58be334dca76 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Thu, 11 Jun 2026 19:59:07 -0400 Subject: [PATCH 2/2] Fan out listAssigned across open + in_progress for Corveil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #501 review feedback. Corveil's `--status` is an exact match on a single status value, not "not closed" semantics — so a `--status open` query excludes in_progress tasks. The concrete consequence: a task we just moved to in_progress via `setTaskStatus(.inProgress)` would vanish from the assigned board on the next `IssueTracker.refresh` poll because `fetchCorveilIssues` → `listAssigned(includeClosed: false)` would no longer return it. `listAssigned` now issues one call for `--status open` and one for `--status in_progress`, merging the results into the open half so the "not closed" parity matches GitHub (`state:open`) and Jira (`statusCategory != Done`). - Factors out the per-status query into a `listByStatus(_:)` helper. - Replaces `testListAssignedSendsAtMeAndOpenStatus` (which fed an in_progress task into the "open" response — a no-op under the old argv filter) and updates `testListAssignedIssuesSecondCallWhenIncludeClosed` to expect three calls (open + in_progress + closed). - Updates ADR 0005's TaskBackend status row to describe the fan-out. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: F5DEB949-5B6F-4878-9D00-306312BBE797 --- .../Backends/CorveilTaskBackend.swift | 31 ++++++---- .../CorveilTaskBackendTests.swift | 61 +++++++++++-------- .../0005-task-and-code-backend-protocols.md | 2 +- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift index d9943cb..004373c 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/Backends/CorveilTaskBackend.swift @@ -73,15 +73,18 @@ public struct CorveilTaskBackend: TaskBackend { } public func listAssigned(includeClosed: Bool) async throws -> AssignedListing { + // Corveil's `--status` is an exact match on a single status value, not + // "not closed" semantics. To match GitHub (`state:open` = not-closed) + // and Jira (`statusCategory != Done`) we fan out across both not-closed + // statuses — otherwise a task we just moved to `in_progress` via + // `setTaskStatus(.inProgress)` would silently vanish from the assigned + // board on the next `IssueTracker.refresh` poll. let open: [AssignedIssue] do { - let openOut = try await run([ - "corveil", "task", "list", - "--assignee", "@me", - "--status", "open", - "--json", - ]) + let openOut = try await listByStatus("open") + let inProgressOut = try await listByStatus("in_progress") open = Self.parseAssigned(openOut, host: config.host, statusOverride: nil) + + Self.parseAssigned(inProgressOut, host: config.host, statusOverride: nil) } catch { // Match Jira's degrade-to-empty semantics rather than throwing. return AssignedListing(open: [], closed: []) @@ -93,12 +96,7 @@ public struct CorveilTaskBackend: TaskBackend { let closed: [AssignedIssue] do { - let closedOut = try await run([ - "corveil", "task", "list", - "--assignee", "@me", - "--status", "closed", - "--json", - ]) + let closedOut = try await listByStatus("closed") closed = Self.parseAssigned(closedOut, host: config.host, statusOverride: .done) } catch { return AssignedListing(open: open, closed: []) @@ -173,6 +171,15 @@ public struct CorveilTaskBackend: TaskBackend { // MARK: - Helpers + private func listByStatus(_ status: String) async throws -> String { + try await run([ + "corveil", "task", "list", + "--assignee", "@me", + "--status", status, + "--json", + ]) + } + /// Run a `corveil` invocation, translating shell failures into typed /// `ProviderError`s and giving a clear hint when corveil isn't authenticated. private func run(_ args: [String]) async throws -> String { diff --git a/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift index ac66cda..45f7258 100644 --- a/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift +++ b/Packages/CrowProvider/Tests/CrowProviderTests/CorveilTaskBackendTests.swift @@ -98,49 +98,58 @@ final class CorveilTaskBackendTests: XCTestCase { // MARK: - listAssigned - func testListAssignedSendsAtMeAndOpenStatus() async throws { + func testListAssignedFansOutAcrossOpenAndInProgressForTheOpenHalf() async throws { + // Corveil's `--status` is exact-match, not "not closed" — so to match + // GitHub/Jira semantics the backend must issue one call for `open` and + // one for `in_progress`, merging the results. Without this, a task we + // just moved to in_progress via setTaskStatus(.inProgress) would vanish + // from the assigned board on the next IssueTracker poll. let fake = FakeShellRunner() - fake.responses = [.success(""" - [ - {"id":"1","title":"Open one","status":"in_progress","labels":["bug","crow:auto"],"url":"https://corveil.io/dashboard/tasks/1"}, - {"id":"2","title":"Open two","status":"open"} + fake.responses = [ + .success(#"[{"id":"1","title":"Just queued","status":"open"}]"#), + .success(#"[{"id":"2","title":"Actively working","status":"in_progress","labels":["bug","crow:auto"],"url":"https://corveil.io/dashboard/tasks/2"}]"#), ] - """)] let b = backend(fake) let listing = try await b.listAssigned(includeClosed: false) XCTAssertEqual(listing.open.count, 2) XCTAssertTrue(listing.closed.isEmpty) - let first = listing.open[0] - XCTAssertEqual(first.id, "corveil:1") - XCTAssertEqual(first.number, 1) - XCTAssertEqual(first.provider, .corveil) - XCTAssertEqual(first.state, "open") - XCTAssertEqual(first.projectStatus, .inProgress) - XCTAssertEqual(first.url, "https://corveil.io/dashboard/tasks/1") - XCTAssertEqual(first.labels.map(\.name), ["bug", "crow:auto"]) - XCTAssertEqual(listing.open[1].projectStatus, .ready) - - XCTAssertEqual(fake.calls.count, 1) - let args = fake.calls[0].args - XCTAssertEqual(Array(args.prefix(3)), ["corveil", "task", "list"]) - XCTAssertTrue(args.contains("--assignee")) - XCTAssertEqual(args[args.firstIndex(of: "--assignee")! + 1], "@me") - XCTAssertTrue(args.contains("--status")) - XCTAssertEqual(args[args.firstIndex(of: "--status")! + 1], "open") + + XCTAssertEqual(fake.calls.count, 2) + for call in fake.calls { + let args = call.args + XCTAssertEqual(Array(args.prefix(3)), ["corveil", "task", "list"]) + XCTAssertEqual(args[args.firstIndex(of: "--assignee")! + 1], "@me") + } + XCTAssertEqual(fake.calls[0].args[fake.calls[0].args.firstIndex(of: "--status")! + 1], "open") + XCTAssertEqual(fake.calls[1].args[fake.calls[1].args.firstIndex(of: "--status")! + 1], "in_progress") + + let queued = listing.open.first(where: { $0.id == "corveil:1" }) + XCTAssertNotNil(queued) + XCTAssertEqual(queued?.projectStatus, .ready) + XCTAssertEqual(queued?.state, "open") + + let active = listing.open.first(where: { $0.id == "corveil:2" }) + XCTAssertNotNil(active) + XCTAssertEqual(active?.projectStatus, .inProgress) + XCTAssertEqual(active?.state, "open") + XCTAssertEqual(active?.url, "https://corveil.io/dashboard/tasks/2") + XCTAssertEqual(active?.labels.map(\.name), ["bug", "crow:auto"]) } - func testListAssignedIssuesSecondCallWhenIncludeClosed() async throws { + func testListAssignedIssuesThirdCallWhenIncludeClosed() async throws { + // Open half fans out (open + in_progress), then a third call for closed. let fake = FakeShellRunner() fake.responses = [ + .success("[]"), .success("[]"), .success(#"[{"id":"9","title":"Done one","status":"closed"}]"#), ] let b = backend(fake) let listing = try await b.listAssigned(includeClosed: true) - XCTAssertEqual(fake.calls.count, 2) - let closedArgs = fake.calls[1].args + XCTAssertEqual(fake.calls.count, 3) + let closedArgs = fake.calls[2].args XCTAssertEqual(closedArgs[closedArgs.firstIndex(of: "--status")! + 1], "closed") XCTAssertEqual(listing.closed.count, 1) XCTAssertEqual(listing.closed[0].state, "closed") diff --git a/docs/adr/0005-task-and-code-backend-protocols.md b/docs/adr/0005-task-and-code-backend-protocols.md index bbe2af0..38b695e 100644 --- a/docs/adr/0005-task-and-code-backend-protocols.md +++ b/docs/adr/0005-task-and-code-backend-protocols.md @@ -77,7 +77,7 @@ Method-by-method state as of the #454 PR. "Site" is where the implementation liv | `fetchTask` | ✅ | ✅ | ✅ (#495) | shipped #411 | | `setLabels` | ✅ | ✅ | ✅ (#495) | shipped #411 | | `setTaskStatus` | ✅ (#454) | throws (no cap) | ✅ (#495) | #454 closed the escape-hatch — `IssueTracker.markInReview` no longer runs a parallel GraphQL mutation. Corveil maps `.inReview` → `in_progress` lossily (corveil has no distinct review status). | -| `listAssigned` | ✅ (#454) | ✅ (#454) | ✅ (#495) | GitHub batches open+closed in one GraphQL call; GitLab issues two REST calls; Corveil uses `--assignee @me --status open` (corveil#1362). | +| `listAssigned` | ✅ (#454) | ✅ (#454) | ✅ (#495) | GitHub batches open+closed in one GraphQL call; GitLab issues two REST calls; Corveil's `--status` is exact-match, so the open half fans out into two `--assignee @me --status {open,in_progress}` calls (corveil#1362) to match "not closed" semantics. | | `assign` | ✅ (#454) | ✅ (#454) | ✅ (#495) | for `setup.sh` and skill flows | | `createTask` | ✅ (#454) | ✅ (#454) | ✅ (#495) | for `/crow-create-ticket` |